429 lines
15 KiB
PHP
429 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Belevo\EnelixEMS;
|
|
|
|
use InvalidArgumentException;
|
|
use JsonException;
|
|
|
|
final class Anlagentopologie
|
|
{
|
|
public const VERSION = '1.0';
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
* @throws JsonException
|
|
*/
|
|
public static function ausJson(
|
|
string $wechselrichterJson,
|
|
string $pvFlaechenJson,
|
|
string $batterienJson,
|
|
string $installationID
|
|
): array {
|
|
$wechselrichter = self::dekodiereListe($wechselrichterJson, 'AnlagenWechselrichter');
|
|
$pvFlaechen = self::dekodiereListe($pvFlaechenJson, 'AnlagenPVFlaechen');
|
|
$batterien = self::dekodiereListe($batterienJson, 'AnlagenBatterien');
|
|
|
|
$inverters = [];
|
|
$inverterById = [];
|
|
foreach ($wechselrichter as $index => $eintrag) {
|
|
$pfad = 'Wechselrichter ' . ($index + 1);
|
|
$id = self::kennung($eintrag['ID'] ?? null, $pfad . ': ID');
|
|
if (isset($inverterById[$id])) {
|
|
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
|
|
}
|
|
$typ = self::auswahl(
|
|
$eintrag['Typ'] ?? null,
|
|
['pv', 'battery', 'hybrid'],
|
|
$pfad . ': Typ'
|
|
);
|
|
$acRatedKw = self::positiveZahl(
|
|
$eintrag['ACNennleistung_kW'] ?? null,
|
|
$pfad . ': AC-Nennleistung'
|
|
);
|
|
$inverter = [
|
|
'id' => $id,
|
|
'name' => self::name($eintrag['Name'] ?? null, $id),
|
|
'type' => $typ,
|
|
'acRatedKw' => $acRatedKw,
|
|
'acExportLimitKw' => self::optionaleGrenze(
|
|
$eintrag['ACEinspeisegrenze_kW'] ?? null,
|
|
$acRatedKw,
|
|
$pfad . ': AC-Einspeisegrenze'
|
|
),
|
|
'acImportLimitKw' => self::optionaleGrenze(
|
|
$eintrag['ACBezugsgrenze_kW'] ?? null,
|
|
$acRatedKw,
|
|
$pfad . ': AC-Bezugsgrenze'
|
|
),
|
|
];
|
|
$inverters[] = $inverter;
|
|
$inverterById[$id] = $inverter;
|
|
}
|
|
|
|
$pvArrays = [];
|
|
$pvIds = [];
|
|
foreach ($pvFlaechen as $index => $eintrag) {
|
|
$pfad = 'PV-Flaeche ' . ($index + 1);
|
|
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $pvIds, $pfad);
|
|
$inverterId = self::kennung(
|
|
$eintrag['WechselrichterID'] ?? null,
|
|
$pfad . ': Wechselrichter-ID'
|
|
);
|
|
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
|
|
if (!in_array($inverter['type'], ['pv', 'hybrid'], true)) {
|
|
throw new InvalidArgumentException(
|
|
$pfad . ': benoetigt einen PV- oder Hybridwechselrichter.'
|
|
);
|
|
}
|
|
$moduleCount = self::optionalePositiveGanzzahl(
|
|
$eintrag['Modulanzahl'] ?? null,
|
|
$pfad . ': Modulanzahl'
|
|
);
|
|
$modulePowerWp = self::optionalePositiveZahl(
|
|
$eintrag['Modulleistung_Wp'] ?? null,
|
|
$pfad . ': Modulleistung'
|
|
);
|
|
if (($moduleCount === null) !== ($modulePowerWp === null)) {
|
|
throw new InvalidArgumentException(
|
|
$pfad . ': Modulanzahl und Modulleistung muessen gemeinsam gepflegt werden.'
|
|
);
|
|
}
|
|
$pvArrays[] = [
|
|
'id' => $id,
|
|
'name' => self::name($eintrag['Name'] ?? null, $id),
|
|
'dcKwp' => self::positiveZahl(
|
|
$eintrag['DCLeistung_kWp'] ?? null,
|
|
$pfad . ': DC-Leistung'
|
|
),
|
|
'tiltDeg' => self::zahlImBereich(
|
|
$eintrag['Neigung_Grad'] ?? null,
|
|
0.0,
|
|
90.0,
|
|
$pfad . ': Neigung'
|
|
),
|
|
'azimuthDeg' => self::zahlImBereich(
|
|
$eintrag['Azimut_Grad'] ?? null,
|
|
-180.0,
|
|
180.0,
|
|
$pfad . ': Azimut'
|
|
),
|
|
'inverterId' => $inverterId,
|
|
'mpptInput' => self::optionalerText($eintrag['MPPT'] ?? null),
|
|
'moduleCount' => $moduleCount,
|
|
'modulePowerWp' => $modulePowerWp,
|
|
];
|
|
}
|
|
|
|
$batteryItems = [];
|
|
$batteryIds = [];
|
|
foreach ($batterien as $index => $eintrag) {
|
|
$pfad = 'Batterie ' . ($index + 1);
|
|
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $batteryIds, $pfad);
|
|
$inverterId = self::kennung(
|
|
$eintrag['WechselrichterID'] ?? null,
|
|
$pfad . ': Wechselrichter-ID'
|
|
);
|
|
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
|
|
$kopplung = self::auswahl(
|
|
$eintrag['Kopplung'] ?? null,
|
|
['ac', 'dc', 'hybrid'],
|
|
$pfad . ': Kopplung'
|
|
);
|
|
if ($kopplung === 'ac' && !in_array($inverter['type'], ['battery', 'hybrid'], true)) {
|
|
throw new InvalidArgumentException(
|
|
$pfad . ': AC-Kopplung benoetigt einen Batterie- oder Hybridwechselrichter.'
|
|
);
|
|
}
|
|
if ($kopplung !== 'ac' && $inverter['type'] !== 'hybrid') {
|
|
throw new InvalidArgumentException(
|
|
$pfad . ': DC- und Hybridkopplung benoetigen einen Hybridwechselrichter.'
|
|
);
|
|
}
|
|
$nominalCapacityKwh = self::positiveZahl(
|
|
$eintrag['Nennkapazitaet_kWh'] ?? null,
|
|
$pfad . ': Nennkapazitaet'
|
|
);
|
|
$usableCapacityKwh = self::positiveZahl(
|
|
$eintrag['Nutzkapazitaet_kWh'] ?? null,
|
|
$pfad . ': Nutzkapazitaet'
|
|
);
|
|
if ($usableCapacityKwh > $nominalCapacityKwh) {
|
|
throw new InvalidArgumentException(
|
|
$pfad . ': Nutzkapazitaet darf die Nennkapazitaet nicht uebersteigen.'
|
|
);
|
|
}
|
|
$batteryItems[] = [
|
|
'id' => $id,
|
|
'name' => self::name($eintrag['Name'] ?? null, $id),
|
|
'nominalCapacityKwh' => $nominalCapacityKwh,
|
|
'usableCapacityKwh' => $usableCapacityKwh,
|
|
'maxChargeKw' => self::positiveZahl(
|
|
$eintrag['MaxLadeleistung_kW'] ?? null,
|
|
$pfad . ': maximale Ladeleistung'
|
|
),
|
|
'maxDischargeKw' => self::positiveZahl(
|
|
$eintrag['MaxEntladeleistung_kW'] ?? null,
|
|
$pfad . ': maximale Entladeleistung'
|
|
),
|
|
'inverterId' => $inverterId,
|
|
'coupling' => $kopplung,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'version' => self::VERSION,
|
|
'installationId' => trim($installationID),
|
|
'inverters' => $inverters,
|
|
'pvArrays' => $pvArrays,
|
|
'batteries' => $batteryItems,
|
|
'sharedAcLimits' => self::gemeinsameAcGrenzen(
|
|
$inverters,
|
|
$pvArrays,
|
|
$batteryItems
|
|
),
|
|
'summary' => self::zusammenfassung($inverters, $pvArrays, $batteryItems),
|
|
];
|
|
}
|
|
|
|
/** @return list<array<string, mixed>> */
|
|
private static function dekodiereListe(string $json, string $name): array
|
|
{
|
|
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($daten)
|
|
|| ($daten !== [] && array_keys($daten) !== range(0, count($daten) - 1))
|
|
) {
|
|
throw new InvalidArgumentException($name . ' muss eine JSON-Liste sein.');
|
|
}
|
|
foreach ($daten as $eintrag) {
|
|
if (!is_array($eintrag)) {
|
|
throw new InvalidArgumentException($name . ' enthaelt einen ungueltigen Eintrag.');
|
|
}
|
|
}
|
|
|
|
return $daten;
|
|
}
|
|
|
|
/** @param array<string, bool> $verwendet */
|
|
private static function eindeutigeKennung($wert, array &$verwendet, string $pfad): string
|
|
{
|
|
$id = self::kennung($wert, $pfad . ': ID');
|
|
if (isset($verwendet[$id])) {
|
|
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
|
|
}
|
|
$verwendet[$id] = true;
|
|
|
|
return $id;
|
|
}
|
|
|
|
private static function kennung($wert, string $feld): string
|
|
{
|
|
if (!is_string($wert)
|
|
|| preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', trim($wert)) !== 1
|
|
) {
|
|
throw new InvalidArgumentException(
|
|
$feld . ' muss 1 bis 64 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.'
|
|
);
|
|
}
|
|
|
|
return trim($wert);
|
|
}
|
|
|
|
private static function name($wert, string $fallback): string
|
|
{
|
|
if ($wert === null || $wert === '') {
|
|
return $fallback;
|
|
}
|
|
if (!is_string($wert) || strlen(trim($wert)) > 120) {
|
|
throw new InvalidArgumentException('Name muss ein Text mit maximal 120 Zeichen sein.');
|
|
}
|
|
|
|
return trim($wert);
|
|
}
|
|
|
|
/** @param list<string> $werte */
|
|
private static function auswahl($wert, array $werte, string $feld): string
|
|
{
|
|
if (!is_string($wert) || !in_array($wert, $werte, true)) {
|
|
throw new InvalidArgumentException($feld . ' ist ungueltig.');
|
|
}
|
|
|
|
return $wert;
|
|
}
|
|
|
|
private static function positiveZahl($wert, string $feld): float
|
|
{
|
|
if (!is_int($wert) && !is_float($wert)) {
|
|
throw new InvalidArgumentException($feld . ' muss eine Zahl sein.');
|
|
}
|
|
if (!is_finite((float) $wert) || (float) $wert <= 0.0) {
|
|
throw new InvalidArgumentException($feld . ' muss groesser als 0 sein.');
|
|
}
|
|
|
|
return (float) $wert;
|
|
}
|
|
|
|
private static function optionalePositiveZahl($wert, string $feld): ?float
|
|
{
|
|
if ($wert === null || $wert === '' || $wert === 0 || $wert === 0.0) {
|
|
return null;
|
|
}
|
|
|
|
return self::positiveZahl($wert, $feld);
|
|
}
|
|
|
|
private static function optionalePositiveGanzzahl($wert, string $feld): ?int
|
|
{
|
|
if ($wert === null || $wert === '' || $wert === 0) {
|
|
return null;
|
|
}
|
|
if (!is_int($wert) || $wert <= 0) {
|
|
throw new InvalidArgumentException($feld . ' muss eine positive Ganzzahl sein.');
|
|
}
|
|
|
|
return $wert;
|
|
}
|
|
|
|
private static function zahlImBereich(
|
|
$wert,
|
|
float $minimum,
|
|
float $maximum,
|
|
string $feld
|
|
): float {
|
|
if ((!is_int($wert) && !is_float($wert))
|
|
|| !is_finite((float) $wert)
|
|
|| (float) $wert < $minimum
|
|
|| (float) $wert > $maximum
|
|
) {
|
|
throw new InvalidArgumentException(
|
|
sprintf('%s muss zwischen %g und %g liegen.', $feld, $minimum, $maximum)
|
|
);
|
|
}
|
|
|
|
return (float) $wert;
|
|
}
|
|
|
|
private static function optionaleGrenze($wert, float $nennleistung, string $feld): ?float
|
|
{
|
|
$grenze = self::optionalePositiveZahl($wert, $feld);
|
|
if ($grenze !== null && $grenze > $nennleistung) {
|
|
throw new InvalidArgumentException(
|
|
$feld . ' darf die AC-Nennleistung nicht uebersteigen.'
|
|
);
|
|
}
|
|
|
|
return $grenze;
|
|
}
|
|
|
|
private static function optionalerText($wert): ?string
|
|
{
|
|
if ($wert === null || $wert === '') {
|
|
return null;
|
|
}
|
|
if (!is_string($wert) || strlen(trim($wert)) > 64) {
|
|
throw new InvalidArgumentException('MPPT muss ein Text mit maximal 64 Zeichen sein.');
|
|
}
|
|
|
|
return trim($wert);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array<string, mixed>> $wechselrichter
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function wechselrichter(array $wechselrichter, string $id, string $pfad): array
|
|
{
|
|
if (!isset($wechselrichter[$id])) {
|
|
throw new InvalidArgumentException(
|
|
$pfad . ': referenzierter Wechselrichter ist nicht vorhanden.'
|
|
);
|
|
}
|
|
|
|
return $wechselrichter[$id];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $inverters
|
|
* @param list<array<string, mixed>> $pvArrays
|
|
* @param list<array<string, mixed>> $batteries
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private static function gemeinsameAcGrenzen(
|
|
array $inverters,
|
|
array $pvArrays,
|
|
array $batteries
|
|
): array {
|
|
$result = [];
|
|
foreach ($inverters as $inverter) {
|
|
if ($inverter['type'] !== 'hybrid') {
|
|
continue;
|
|
}
|
|
$pvIds = [];
|
|
foreach ($pvArrays as $pvArray) {
|
|
if ($pvArray['inverterId'] === $inverter['id']) {
|
|
$pvIds[] = $pvArray['id'];
|
|
}
|
|
}
|
|
$batteryIds = [];
|
|
foreach ($batteries as $battery) {
|
|
if ($battery['inverterId'] === $inverter['id']) {
|
|
$batteryIds[] = $battery['id'];
|
|
}
|
|
}
|
|
if ($pvIds === [] && $batteryIds === []) {
|
|
continue;
|
|
}
|
|
$result[] = [
|
|
'inverterId' => $inverter['id'],
|
|
'acRatedKw' => $inverter['acRatedKw'],
|
|
'pvArrayIds' => $pvIds,
|
|
'batteryIds' => $batteryIds,
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $inverters
|
|
* @param list<array<string, mixed>> $pvArrays
|
|
* @param list<array<string, mixed>> $batteries
|
|
* @return array<string, float>
|
|
*/
|
|
private static function zusammenfassung(
|
|
array $inverters,
|
|
array $pvArrays,
|
|
array $batteries
|
|
): array {
|
|
$pvInverterIds = [];
|
|
$pvDcKwp = 0.0;
|
|
foreach ($pvArrays as $pvArray) {
|
|
$pvDcKwp += $pvArray['dcKwp'];
|
|
$pvInverterIds[$pvArray['inverterId']] = true;
|
|
}
|
|
$pvAcKw = 0.0;
|
|
foreach ($inverters as $inverter) {
|
|
if (isset($pvInverterIds[$inverter['id']])) {
|
|
$pvAcKw += $inverter['acRatedKw'];
|
|
}
|
|
}
|
|
$usableBatteryKwh = 0.0;
|
|
$maxBatteryChargeKw = 0.0;
|
|
$maxBatteryDischargeKw = 0.0;
|
|
foreach ($batteries as $battery) {
|
|
$usableBatteryKwh += $battery['usableCapacityKwh'];
|
|
$maxBatteryChargeKw += $battery['maxChargeKw'];
|
|
$maxBatteryDischargeKw += $battery['maxDischargeKw'];
|
|
}
|
|
|
|
return [
|
|
'pvDcKwp' => $pvDcKwp,
|
|
'pvAcKw' => $pvAcKw,
|
|
'usableBatteryKwh' => $usableBatteryKwh,
|
|
'maxBatteryChargeKw' => $maxBatteryChargeKw,
|
|
'maxBatteryDischargeKw' => $maxBatteryDischargeKw,
|
|
];
|
|
}
|
|
}
|