diff --git a/Manager/form.json b/Manager/form.json index 5fce8a4..b90cc99 100644 --- a/Manager/form.json +++ b/Manager/form.json @@ -206,6 +206,282 @@ } ] }, + { + "type": "ExpansionPanel", + "caption": "Anlagentopologie", + "expanded": true, + "items": [ + { + "type": "List", + "name": "AnlagenWechselrichter", + "caption": "Wechselrichter", + "rowCount": 4, + "add": true, + "delete": true, + "sortable": true, + "columns": [ + { + "caption": "ID", + "name": "ID", + "width": "130px", + "add": "wr-1", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "Name", + "name": "Name", + "width": "auto", + "add": "Wechselrichter", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "Typ", + "name": "Typ", + "width": "130px", + "add": "pv", + "edit": { + "type": "Select", + "options": [ + {"caption": "PV", "value": "pv"}, + {"caption": "Batterie", "value": "battery"}, + {"caption": "Hybrid", "value": "hybrid"} + ] + } + }, + { + "caption": "AC Nennleistung", + "name": "ACNennleistung_kW", + "width": "150px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " kW", + "minimum": 0.0, + "digits": 3 + } + }, + { + "caption": "Einspeisegrenze", + "name": "ACEinspeisegrenze_kW", + "width": "150px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " kW", + "minimum": 0.0, + "digits": 3 + } + }, + { + "caption": "Bezugsgrenze", + "name": "ACBezugsgrenze_kW", + "width": "140px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " kW", + "minimum": 0.0, + "digits": 3 + } + } + ] + }, + { + "type": "List", + "name": "AnlagenPVFlaechen", + "caption": "PV-Flaechen", + "rowCount": 6, + "add": true, + "delete": true, + "sortable": true, + "columns": [ + { + "caption": "ID", + "name": "ID", + "width": "120px", + "add": "pv-1", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "Name", + "name": "Name", + "width": "auto", + "add": "PV-Flaeche", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "DC-Leistung", + "name": "DCLeistung_kWp", + "width": "130px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " kWp", + "minimum": 0.0, + "digits": 3 + } + }, + { + "caption": "Neigung", + "name": "Neigung_Grad", + "width": "110px", + "add": 30.0, + "edit": { + "type": "NumberSpinner", + "suffix": " Grad", + "minimum": 0.0, + "maximum": 90.0, + "digits": 1 + } + }, + { + "caption": "Azimut", + "name": "Azimut_Grad", + "width": "110px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " Grad", + "minimum": -180.0, + "maximum": 180.0, + "digits": 1 + } + }, + { + "caption": "Wechselrichter-ID", + "name": "WechselrichterID", + "width": "150px", + "add": "wr-1", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "MPPT", + "name": "MPPT", + "width": "90px", + "add": "", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "Module", + "name": "Modulanzahl", + "width": "90px", + "add": 0, + "edit": { + "type": "NumberSpinner", + "minimum": 0, + "digits": 0 + } + }, + { + "caption": "Modulleistung", + "name": "Modulleistung_Wp", + "width": "130px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " Wp", + "minimum": 0.0, + "digits": 1 + } + } + ] + }, + { + "type": "List", + "name": "AnlagenBatterien", + "caption": "Batteriespeicher", + "rowCount": 4, + "add": true, + "delete": true, + "sortable": true, + "columns": [ + { + "caption": "ID", + "name": "ID", + "width": "120px", + "add": "bat-1", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "Name", + "name": "Name", + "width": "auto", + "add": "Batteriespeicher", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "Nennkapazitaet", + "name": "Nennkapazitaet_kWh", + "width": "150px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " kWh", + "minimum": 0.0, + "digits": 3 + } + }, + { + "caption": "Nutzkapazitaet", + "name": "Nutzkapazitaet_kWh", + "width": "150px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " kWh", + "minimum": 0.0, + "digits": 3 + } + }, + { + "caption": "Max. Laden", + "name": "MaxLadeleistung_kW", + "width": "130px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " kW", + "minimum": 0.0, + "digits": 3 + } + }, + { + "caption": "Max. Entladen", + "name": "MaxEntladeleistung_kW", + "width": "140px", + "add": 0.0, + "edit": { + "type": "NumberSpinner", + "suffix": " kW", + "minimum": 0.0, + "digits": 3 + } + }, + { + "caption": "Wechselrichter-ID", + "name": "WechselrichterID", + "width": "150px", + "add": "wr-1", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "Kopplung", + "name": "Kopplung", + "width": "130px", + "add": "ac", + "edit": { + "type": "Select", + "options": [ + {"caption": "AC", "value": "ac"}, + {"caption": "DC", "value": "dc"}, + {"caption": "Hybrid", "value": "hybrid"} + ] + } + } + ] + } + ] + }, { "type": "ExpansionPanel", "caption": "Erweiterte Einstellungen", diff --git a/Manager/module.php b/Manager/module.php index 4112e23..073481b 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -6,7 +6,9 @@ require_once __DIR__ . '/../libs/ManagerSchnittstelle.php'; require_once __DIR__ . '/../libs/Nachrichtenvertrag.php'; require_once __DIR__ . '/../libs/ManagerRegler.php'; require_once __DIR__ . '/../libs/Lizenzpruefung.php'; +require_once __DIR__ . '/../libs/Anlagentopologie.php'; +use Belevo\EnelixEMS\Anlagentopologie; use Belevo\EnelixEMS\Lizenzpruefung; use Belevo\EnelixEMS\ManagerRegler; use Belevo\EnelixEMS\ManagerSchnittstelle; @@ -91,6 +93,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen()); $this->RegisterPropertyFloat('SollwertSolarladen', 0.0); $this->RegisterPropertyFloat('Umschaltdifferenz', 5.0); + $this->RegisterPropertyString('AnlagenWechselrichter', '[]'); + $this->RegisterPropertyString('AnlagenPVFlaechen', '[]'); + $this->RegisterPropertyString('AnlagenBatterien', '[]'); $this->RegisterPropertyString('PrognoseAnschluss', '{}'); $this->RegisterPropertyString('SDLAnschluss', '{}'); $this->RegisterPropertyString('Lizenzcode', ''); @@ -253,6 +258,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle return json_encode($formular, JSON_THROW_ON_ERROR); } + public function AnlagentopologieExportieren(): string + { + return json_encode( + $this->leseAnlagentopologie(), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES + ); + } + public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void { if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID') @@ -1415,6 +1428,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung'); $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); + $this->leseAnlagentopologie(); foreach (['PrognoseAnschluss', 'SDLAnschluss', 'StoermeldeAnschluss'] as $property) { $wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); if (!is_array($wert)) { @@ -1423,6 +1437,17 @@ class Manager extends IPSModule implements ManagerSchnittstelle } } + /** @return array */ + private function leseAnlagentopologie(): array + { + return Anlagentopologie::ausJson( + $this->ReadPropertyString('AnlagenWechselrichter'), + $this->ReadPropertyString('AnlagenPVFlaechen'), + $this->ReadPropertyString('AnlagenBatterien'), + $this->ReadAttributeString('LizenzInstallationID') + ); + } + /** @return array */ private function dekodiereMonatsgrenzen(string $json): array { diff --git a/README.md b/README.md index e1f6fe3..1e0497c 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ der Uebernahme in einen Freigabebranch erfolgreich sein. - [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md) - [Batterieschnittstelle](docs/Schnittstelle-Batterie.md) - [ADR 0004: Betriebsartabhaengige Leistungsangebote](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md) +- [ADR 0005: Anlagentopologie fuer Prognosen](docs/adr/0005-anlagentopologie-fuer-prognosen.md) - [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md) - [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md) - [Pufferspeicher-Modul](docs/module/Pufferspeicher/README.md) diff --git a/docs/adr/0005-anlagentopologie-fuer-prognosen.md b/docs/adr/0005-anlagentopologie-fuer-prognosen.md new file mode 100644 index 0000000..053cc5e --- /dev/null +++ b/docs/adr/0005-anlagentopologie-fuer-prognosen.md @@ -0,0 +1,76 @@ +# ADR 0005: Anlagentopologie als Manager-Stammdaten + +## Kontext + +Die Prognose benoetigt technische Stammdaten zu PV-Flaechen, +Wechselrichtern und Batteriespeichern. Diese Daten beschreiben die reale +Installation und werden auch von der lokalen Regelung benoetigt. Eine +unabhaengige Pflege im Prognoseportal wuerde zwei konkurrierende Wahrheiten +erzeugen. + +Hybridwechselrichter benoetigen eine ausdrueckliche Topologie. PV und Batterie +duerfen nicht als zwei unabhaengige AC-Quellen mit jeweils voller +Wechselrichterleistung behandelt werden. + +## Entscheidung + +Der Enelix-Manager ist die fuehrende Quelle fuer die technische +Anlagentopologie. Er speichert drei getrennte Listen: + +- Wechselrichter mit Typ, AC-Nennleistung und optionalen AC-Grenzen, +- PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichterbezug, +- Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und + Wechselrichterbezug. + +Jede Komponente besitzt eine innerhalb ihrer Liste eindeutige, stabile ID. +PV-Flaechen und Batterien referenzieren einen Wechselrichter ueber diese ID. + +Die AC-Nennleistung gehoert zum Wechselrichter. Die DC-Leistung gehoert zur +PV-Flaeche. Modulanzahl und Modulleistung sind optionale Detailangaben und +muessen gemeinsam gepflegt werden. + +Bei einem Hybridwechselrichter bleiben PV-Flaechen und Batterie getrennte +Komponenten. Der Exportvertrag liefert zusaetzlich eine gemeinsame AC-Grenze, +die beide Seiten demselben Wechselrichter zuordnet. Damit darf ihre kombinierte +AC-Leistung die Wechselrichtergrenze nicht unabhaengig mehrfach ausschoepfen. + +Der Manager exportiert die normalisierte Struktur mit Vertragsversion '1.0' +und seiner stabilen Lizenz-Installations-ID. Eine aggregierte Zusammenfassung +erleichtert die schrittweise Anbindung bestehender Prognoseberechnungen. + +Tarife, Prognosevarianten und rein prognosespezifische Annahmen bleiben im +Prognoseportal. Dort werden die vom Manager gelieferten technischen Stammdaten +standardmaessig nur angezeigt. + +## Alternativen + +### Vollstaendige Pflege im Prognoseportal + +Diese Variante waere fuer den Prognosedienst einfach, erzeugt aber +Doppelpflege und kann von der lokal tatsaechlich installierten Anlage +abweichen. + +### Batterie und PV als ein Hybridobjekt speichern + +Ein einzelnes Objekt waere kompakt, bildet mehrere PV-Flaechen, MPPT-Eingaenge +und spaetere Erweiterungen jedoch schlecht ab. Ausserdem gingen die getrennten +Kapazitaets- und Leistungsgrenzen verloren. + +### AC- und DC-Leistung an jeder PV-Flaeche speichern + +Das wuerde bei mehreren Flaechen an einem Wechselrichter die AC-Leistung +mehrfach zaehlen. Deshalb wird AC am Wechselrichter und DC an der Flaeche +gespeichert. + +## Folgen + +- Bestehende Installationen bleiben mit drei leeren Listen gueltig. +- Fehlerhafte Referenzen oder unplausible technische Grenzen sperren die + Manager-Konfiguration kontrolliert. +- Ein Hybridwechselrichter kann mehrere PV-Flaechen und Batteriespeicher + verbinden, besitzt aber nur eine gemeinsame AC-Nennleistung. +- Das Prognoseportal muss die technischen Stammdaten aus dem Managervertrag + lesen und eigene Eingaben dafuer als schreibgeschuetzt behandeln. +- Fuer den automatischen Upload ist ein eigener, widerrufbarer + Installationszugang erforderlich. Der Lizenzcode selbst wird nicht als + dauerhaftes API-Secret verwendet. diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index 7546174..6bdf664 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -47,6 +47,9 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz | `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. | | `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. | | `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. | +| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ und AC-Grenzen. | +| `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. | +| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und Wechselrichter-ID. | | `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. | | `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. | | `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. | @@ -60,6 +63,28 @@ Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren. +## Anlagentopologie und Prognoseexport + +Der Manager ist die fuehrende Quelle fuer die technischen Stammdaten. Die +Konfiguration trennt Wechselrichter, PV-Flaechen und Batteriespeicher. AC-Leistung +wird am Wechselrichter, DC-Leistung an der PV-Flaeche gepflegt. Modulanzahl und +Modulleistung sind optional und muessen gemeinsam gesetzt werden. + +PV-Flaechen und Batterien referenzieren ihren Wechselrichter ueber dessen stabile +ID. Bei `hybrid` koennen beide denselben Wechselrichter verwenden. Der Export +`ENELIX_AnlagentopologieExportieren($InstanzID)` weist dann eine gemeinsame +AC-Grenze aus, damit PV und Batterie die Nennleistung nicht unabhaengig doppelt +beanspruchen. + +Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID, +die drei Komponentenlisten, gemeinsame AC-Grenzen und aggregierte Summen fuer +die schrittweise Prognoseanbindung. Tarif- und Variantenparameter bleiben im +Prognoseportal. Der automatische Upload benoetigt spaeter einen separaten, +widerrufbaren Installationszugang; der Lizenzcode wird dafuer nicht verwendet. + +Die Entscheidung und ihre Alternativen sind in +[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert. + ## Lizenzierung Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht diff --git a/libs/Anlagentopologie.php b/libs/Anlagentopologie.php new file mode 100644 index 0000000..43b2019 --- /dev/null +++ b/libs/Anlagentopologie.php @@ -0,0 +1,428 @@ + + * @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> */ + 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 $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 $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> $wechselrichter + * @return array + */ + 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> $inverters + * @param list> $pvArrays + * @param list> $batteries + * @return list> + */ + 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> $inverters + * @param list> $pvArrays + * @param list> $batteries + * @return array + */ + 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, + ]; + } +} diff --git a/tests/AnlagentopologieTest.php b/tests/AnlagentopologieTest.php new file mode 100644 index 0000000..eb04132 --- /dev/null +++ b/tests/AnlagentopologieTest.php @@ -0,0 +1,156 @@ + 'hybrid-1', + 'Name' => 'Hybrid Ost', + 'Typ' => 'hybrid', + 'ACNennleistung_kW' => 10.0, + 'ACEinspeisegrenze_kW' => 8.0, + 'ACBezugsgrenze_kW' => 0.0, + ]], JSON_THROW_ON_ERROR), + json_encode([ + [ + 'ID' => 'pv-ost', + 'Name' => 'Ostdach', + 'DCLeistung_kWp' => 6.4, + 'Neigung_Grad' => 25.0, + 'Azimut_Grad' => -90.0, + 'WechselrichterID' => 'hybrid-1', + 'MPPT' => 'A', + 'Modulanzahl' => 16, + 'Modulleistung_Wp' => 400.0, + ], + [ + 'ID' => 'pv-west', + 'Name' => 'Westdach', + 'DCLeistung_kWp' => 6.4, + 'Neigung_Grad' => 25.0, + 'Azimut_Grad' => 90.0, + 'WechselrichterID' => 'hybrid-1', + 'MPPT' => 'B', + 'Modulanzahl' => 16, + 'Modulleistung_Wp' => 400.0, + ], + ], JSON_THROW_ON_ERROR), + json_encode([[ + 'ID' => 'bat-1', + 'Name' => 'Hausspeicher', + 'Nennkapazitaet_kWh' => 13.5, + 'Nutzkapazitaet_kWh' => 12.8, + 'MaxLadeleistung_kW' => 5.0, + 'MaxEntladeleistung_kW' => 5.0, + 'WechselrichterID' => 'hybrid-1', + 'Kopplung' => 'hybrid', + ]], JSON_THROW_ON_ERROR), + '2a518707-8d62-4d97-82ce-606c95726500' + ); + + self::assertSame(Anlagentopologie::VERSION, $topologie['version']); + self::assertSame(12.8, $topologie['summary']['pvDcKwp']); + self::assertSame(10.0, $topologie['summary']['pvAcKw']); + self::assertSame(12.8, $topologie['summary']['usableBatteryKwh']); + self::assertSame([[ + 'inverterId' => 'hybrid-1', + 'acRatedKw' => 10.0, + 'pvArrayIds' => ['pv-ost', 'pv-west'], + 'batteryIds' => ['bat-1'], + ]], $topologie['sharedAcLimits']); + } + + public function testLeereTopologieBleibtFuerBestandsinstallationenGueltig(): void + { + $topologie = Anlagentopologie::ausJson( + '[]', + '[]', + '[]', + '2a518707-8d62-4d97-82ce-606c95726500' + ); + + self::assertSame([], $topologie['inverters']); + self::assertSame([], $topologie['pvArrays']); + self::assertSame([], $topologie['batteries']); + self::assertSame(0.0, $topologie['summary']['pvDcKwp']); + } + + public function testPvFlaecheBenoetigtEinenVorhandenenPassendenWechselrichter(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('referenzierter Wechselrichter ist nicht vorhanden'); + + Anlagentopologie::ausJson( + '[]', + json_encode([[ + 'ID' => 'pv-1', + 'DCLeistung_kWp' => 5.0, + 'Neigung_Grad' => 30.0, + 'Azimut_Grad' => 0.0, + 'WechselrichterID' => 'wr-fehlt', + ]], JSON_THROW_ON_ERROR), + '[]', + '2a518707-8d62-4d97-82ce-606c95726500' + ); + } + + public function testDcBatterieBenoetigtEinenHybridwechselrichter(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('DC- und Hybridkopplung'); + + Anlagentopologie::ausJson( + json_encode([[ + 'ID' => 'bat-wr', + 'Typ' => 'battery', + 'ACNennleistung_kW' => 5.0, + ]], JSON_THROW_ON_ERROR), + '[]', + json_encode([[ + 'ID' => 'bat-1', + 'Nennkapazitaet_kWh' => 10.0, + 'Nutzkapazitaet_kWh' => 9.0, + 'MaxLadeleistung_kW' => 4.0, + 'MaxEntladeleistung_kW' => 4.0, + 'WechselrichterID' => 'bat-wr', + 'Kopplung' => 'dc', + ]], JSON_THROW_ON_ERROR), + '2a518707-8d62-4d97-82ce-606c95726500' + ); + } + + public function testModuldatenMuessenGemeinsamGesetztWerden(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Modulanzahl und Modulleistung'); + + Anlagentopologie::ausJson( + json_encode([[ + 'ID' => 'pv-wr', + 'Typ' => 'pv', + 'ACNennleistung_kW' => 5.0, + ]], JSON_THROW_ON_ERROR), + json_encode([[ + 'ID' => 'pv-1', + 'DCLeistung_kWp' => 6.0, + 'Neigung_Grad' => 30.0, + 'Azimut_Grad' => 0.0, + 'WechselrichterID' => 'pv-wr', + 'Modulanzahl' => 15, + 'Modulleistung_Wp' => 0.0, + ]], JSON_THROW_ON_ERROR), + '[]', + '2a518707-8d62-4d97-82ce-606c95726500' + ); + } +} diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index e826abe..7b1e6ba 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -56,6 +56,9 @@ final class ManagerModulstrukturTest extends TestCase 'LizenzInstallationID', 'LizenzInformation', 'VerbraucherAktualisieren', + 'AnlagenWechselrichter', + 'AnlagenPVFlaechen', + 'AnlagenBatterien', ] as $property) { self::assertStringContainsString($property, $json); } @@ -193,6 +196,8 @@ final class ManagerModulstrukturTest extends TestCase { $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt); + self::assertStringContainsString("require_once __DIR__ . '/../libs/Anlagentopologie.php'", $inhalt); + self::assertStringContainsString('public function AnlagentopologieExportieren(): string', $inhalt); self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt); self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt); } diff --git a/tests/Symcon/modules/Manager.php b/tests/Symcon/modules/Manager.php index 32e0bc1..8ce5213 100644 --- a/tests/Symcon/modules/Manager.php +++ b/tests/Symcon/modules/Manager.php @@ -72,6 +72,35 @@ return static function (TestContext $test): void { ['InstanzID' => $warmwasserId, 'Aktiv' => true], ['InstanzID' => $pufferspeicherId, 'Aktiv' => true], ]; + $wechselrichterKonfiguration = [[ + 'ID' => 'hybrid-1', + 'Name' => 'Hybridwechselrichter', + 'Typ' => 'hybrid', + 'ACNennleistung_kW' => 10.0, + 'ACEinspeisegrenze_kW' => 8.0, + 'ACBezugsgrenze_kW' => 0.0, + ]]; + $pvKonfiguration = [[ + 'ID' => 'pv-1', + 'Name' => 'Suedflaeche', + 'DCLeistung_kWp' => 12.0, + 'Neigung_Grad' => 30.0, + 'Azimut_Grad' => 0.0, + 'WechselrichterID' => 'hybrid-1', + 'MPPT' => 'A', + 'Modulanzahl' => 30, + 'Modulleistung_Wp' => 400.0, + ]]; + $batterieKonfiguration = [[ + 'ID' => 'bat-1', + 'Name' => 'Hausspeicher', + 'Nennkapazitaet_kWh' => 13.5, + 'Nutzkapazitaet_kWh' => 12.8, + 'MaxLadeleistung_kW' => 5.0, + 'MaxEntladeleistung_kW' => 5.0, + 'WechselrichterID' => 'hybrid-1', + 'Kopplung' => 'hybrid', + ]]; $lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE')); $managerId = $test->createInstance( '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', @@ -87,6 +116,12 @@ return static function (TestContext $test): void { 'Lastspitzengrenze' => 0.0, 'SollwertSolarladen' => 0.0, 'Umschaltdifferenz' => 0.0, + 'AnlagenWechselrichter' => json_encode( + $wechselrichterKonfiguration, + JSON_THROW_ON_ERROR + ), + 'AnlagenPVFlaechen' => json_encode($pvKonfiguration, JSON_THROW_ON_ERROR), + 'AnlagenBatterien' => json_encode($batterieKonfiguration, JSON_THROW_ON_ERROR), 'DiagnosevariablenAnzeigen' => true, ] ); @@ -163,6 +198,21 @@ return static function (TestContext $test): void { $vorher = $lizenzInstallationID(); IPS_ApplyChanges($managerId); $test->assertSame($vorher, $lizenzInstallationID()); + + $topologie = json_decode( + ENELIX_AnlagentopologieExportieren($managerId), + true, + 512, + JSON_THROW_ON_ERROR + ); + $test->assertSame($vorher, $topologie['installationId'] ?? null); + $test->assertSame('1.0', $topologie['version'] ?? null); + $test->assertSame(12.0, $topologie['summary']['pvDcKwp'] ?? null); + $test->assertSame(10.0, $topologie['summary']['pvAcKw'] ?? null); + $test->assertSame(12.8, $topologie['summary']['usableBatteryKwh'] ?? null); + $test->assertSame('hybrid-1', $topologie['sharedAcLimits'][0]['inverterId'] ?? null); + $test->assertSame(['pv-1'], $topologie['sharedAcLimits'][0]['pvArrayIds'] ?? null); + $test->assertSame(['bat-1'], $topologie['sharedAcLimits'][0]['batteryIds'] ?? null); }); $zustandswert = static function (array $daten, int $instanzId, string $kennung) { foreach ($daten['Verbraucher'] ?? [] as $verbraucher) {