From 5f1bdbc0fa0dc0c6b4e05d9830afc5e24d5dacb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=A4fliger?= Date: Tue, 15 Sep 2026 15:59:25 +0000 Subject: [PATCH] Enelix-2-Manager implementieren Installierbares IP-Symcon-Manager-Modul mit testbarer Verteilungslogik, Verbraucher-Timeouts, Sollwertversand und aktualisierter Dokumentation. --- Manager/form.json | 171 ++++++++++ Manager/module.json | 14 + Manager/module.php | 468 +++++++++++++++++++++++++++ README.md | 10 +- docs/module/Manager/README.md | 37 ++- docs/module/README.md | 8 +- libs/ManagerRegler.php | 252 +++++++++++++++ tests/DokumentationsstrukturTest.php | 9 +- tests/ManagerModulstrukturTest.php | 58 ++++ tests/ManagerReglerTest.php | 159 +++++++++ 10 files changed, 1175 insertions(+), 11 deletions(-) create mode 100644 Manager/form.json create mode 100644 Manager/module.json create mode 100644 Manager/module.php create mode 100644 libs/ManagerRegler.php create mode 100644 tests/ManagerModulstrukturTest.php create mode 100644 tests/ManagerReglerTest.php diff --git a/Manager/form.json b/Manager/form.json new file mode 100644 index 0000000..8993cdd --- /dev/null +++ b/Manager/form.json @@ -0,0 +1,171 @@ +{ + "elements": [ + { + "type": "Select", + "name": "Rolle", + "caption": "Rolle", + "options": [ + {"caption": "Alleine", "value": 0}, + {"caption": "Hauptmanager", "value": 1}, + {"caption": "Untermanager", "value": 2} + ] + }, + { + "type": "SelectVariable", + "name": "NetzleistungVariableID", + "caption": "Netzleistung" + }, + { + "type": "NumberSpinner", + "name": "Netzleistungsfaktor", + "caption": "Netzleistungsfaktor", + "digits": 4 + }, + { + "type": "NumberSpinner", + "name": "MesswertMaxAlter", + "caption": "Maximales Messwertalter", + "suffix": " s", + "minimum": 1 + }, + { + "type": "List", + "name": "VerbraucherZuordnung", + "caption": "Verbraucher", + "add": true, + "delete": true, + "sortable": true, + "columns": [ + { + "caption": "Instanz", + "name": "InstanzID", + "width": "auto", + "add": 0, + "edit": {"type": "SelectInstance"} + }, + { + "caption": "Aktiv", + "name": "Aktiv", + "width": "100px", + "add": false, + "edit": {"type": "CheckBox"} + } + ] + }, + { + "type": "CheckBox", + "name": "AutomatischeSuche", + "caption": "Verbraucher automatisch suchen" + }, + { + "type": "SelectObject", + "name": "SuchbereichID", + "caption": "Suchbereich" + }, + { + "type": "NumberSpinner", + "name": "Regelintervall", + "caption": "Regelintervall", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "Vorgabeintervall", + "caption": "Vorgabeintervall", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "VerbraucherTimeout", + "caption": "Verbraucher-Timeout", + "suffix": " s", + "minimum": 1 + }, + { + "type": "Select", + "name": "Lastspitzenmodus", + "caption": "Lastspitzenmodus", + "options": [ + {"caption": "Aus", "value": 0}, + {"caption": "Konstant", "value": 1}, + {"caption": "Monatlich", "value": 2} + ] + }, + { + "type": "NumberSpinner", + "name": "Lastspitzengrenze", + "caption": "Lastspitzengrenze", + "suffix": " W", + "digits": 1 + }, + { + "type": "ValidationTextBox", + "name": "Monatsgrenzen", + "caption": "Monatsgrenzen als JSON" + }, + { + "type": "CheckBox", + "name": "MonatsgrenzenVisuBedienbar", + "caption": "Monatsgrenzen in der Visualisierung bedienen" + }, + { + "type": "NumberSpinner", + "name": "SollwertSolarladen", + "caption": "Sollwert Solarladen", + "suffix": " W", + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "UmschaltschwelleUeberschuss", + "caption": "Umschaltschwelle Ueberschuss", + "suffix": " W", + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "UmschaltschwelleLastspitze", + "caption": "Umschaltschwelle Lastspitze", + "suffix": " W", + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "Umschaltdifferenz", + "caption": "Mindestdifferenz vor Umschaltung", + "suffix": " %", + "minimum": 0, + "maximum": 100, + "digits": 1 + }, + { + "type": "ExpansionPanel", + "caption": "Optionale obere Anschluesse", + "items": [ + {"type": "ValidationTextBox", "name": "PrognoseAnschluss", "caption": "Prognose / Forecast als JSON"}, + {"type": "ValidationTextBox", "name": "SDLAnschluss", "caption": "SDL / VGT als JSON"}, + {"type": "ValidationTextBox", "name": "LizenzAnschluss", "caption": "Lizenzierung als JSON"}, + {"type": "ValidationTextBox", "name": "StoermeldeAnschluss", "caption": "Stoerueberwachung als JSON"} + ] + }, + { + "type": "CheckBox", + "name": "LoggingEin", + "caption": "Logging ein" + } + ], + "actions": [ + { + "type": "Button", + "caption": "Regelung jetzt ausfuehren", + "onClick": "IPS_RequestAction($id, 'Regeln', true);" + } + ], + "status": [ + {"code": 102, "icon": "active", "caption": "Aktiv"}, + {"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"}, + {"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"} + ] +} diff --git a/Manager/module.json b/Manager/module.json new file mode 100644 index 0000000..fcd595b --- /dev/null +++ b/Manager/module.json @@ -0,0 +1,14 @@ +{ + "id": "{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}", + "name": "Manager", + "type": 3, + "vendor": "Belevo AG", + "aliases": [ + "Enelix 2 Manager" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENELIX", + "url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Manager" +} diff --git a/Manager/module.php b/Manager/module.php new file mode 100644 index 0000000..d2a899a --- /dev/null +++ b/Manager/module.php @@ -0,0 +1,468 @@ +RegisterPropertyInteger('Rolle', 0); + $this->RegisterPropertyInteger('NetzleistungVariableID', 0); + $this->RegisterPropertyFloat('Netzleistungsfaktor', 1.0); + $this->RegisterPropertyInteger('MesswertMaxAlter', 30); + $this->RegisterPropertyString('VerbraucherZuordnung', '[]'); + $this->RegisterPropertyBoolean('AutomatischeSuche', true); + $this->RegisterPropertyInteger('SuchbereichID', 0); + $this->RegisterPropertyInteger('Regelintervall', 2); + $this->RegisterPropertyInteger('Vorgabeintervall', 10); + $this->RegisterPropertyInteger('VerbraucherTimeout', 30); + $this->RegisterPropertyInteger('Lastspitzenmodus', 0); + $this->RegisterPropertyFloat('Lastspitzengrenze', 0.0); + $this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen()); + $this->RegisterPropertyBoolean('MonatsgrenzenVisuBedienbar', false); + $this->RegisterPropertyFloat('SollwertSolarladen', 0.0); + $this->RegisterPropertyFloat('UmschaltschwelleUeberschuss', 0.0); + $this->RegisterPropertyFloat('UmschaltschwelleLastspitze', 0.0); + $this->RegisterPropertyFloat('Umschaltdifferenz', 0.0); + $this->RegisterPropertyString('PrognoseAnschluss', '{}'); + $this->RegisterPropertyString('SDLAnschluss', '{}'); + $this->RegisterPropertyString('LizenzAnschluss', '{}'); + $this->RegisterPropertyString('StoermeldeAnschluss', '{}'); + $this->RegisterPropertyBoolean('LoggingEin', false); + + $this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10); + $this->EnableAction('Aktiv'); + $this->RegisterVariableString('Betriebsart', 'Betriebsart', '', 20); + $this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30); + $this->RegisterVariableBoolean('NetzleistungGueltig', 'Netzleistung gueltig', '~Switch', 40); + $this->RegisterVariableFloat('WirksameLastspitzengrenze', 'Wirksame Lastspitzengrenze', '', 50); + $this->RegisterVariableFloat('Verteilbudget', 'Verteilbudget', '', 60); + $this->RegisterVariableInteger('VerbraucherAnzahl', 'Verbraucher Anzahl', '', 70); + $this->RegisterVariableInteger('VerbraucherVerfuegbar', 'Verbraucher verfuegbar', '', 80); + $this->RegisterVariableString('Verbraucherstatus', 'Verbraucherstatus', '', 90); + $this->RegisterVariableString('Monatsgrenzen', 'Monatsgrenzen', '', 100); + $this->RegisterVariableString('Prognosestatus', 'Prognosestatus', '', 110); + $this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120); + $this->RegisterVariableString('Lizenzstatus', 'Lizenzstatus', '', 130); + $this->RegisterVariableString('Stoerueberwachungsstatus', 'Stoerueberwachungsstatus', '', 140); + $this->RegisterVariableBoolean('Sammelstoerung', 'Sammelstoerung', '~Alert', 150); + $this->RegisterVariableString('Stoertext', 'Stoertext', '', 160); + + $this->RegisterAttributeString('VerbraucherCache', '{}'); + $this->RegisterAttributeString('LetzteSollwerte', '{}'); + $this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0); + + $this->RegisterTimer( + 'Regelzyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', false);" + ); + $this->RegisterTimer( + 'VorgabenErneuern', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);" + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + try { + $this->pruefeKonfiguration(); + } catch (Throwable $fehler) { + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + $this->SetTimerInterval('Regelzyklus', 0); + $this->SetTimerInterval('VorgabenErneuern', 0); + $this->protokolliere('Konfiguration', $fehler->getMessage()); + return; + } + + $this->registriereNetzleistungsmeldung(); + $this->SetTimerInterval('Regelzyklus', $this->ReadPropertyInteger('Regelintervall') * 1000); + $this->SetTimerInterval('VorgabenErneuern', $this->ReadPropertyInteger('Vorgabeintervall') * 1000); + $this->SetValue('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); + $this->aktualisiereSichtbarkeit(); + $this->aktualisiereAnschlussstatus(); + $this->SetStatus(self::STATUS_AKTIV); + } + + public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void + { + if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID') + && (int) $nachricht === self::VM_UPDATE + ) { + $this->regeln(false); + } + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'Aktiv': + $this->SetValue('Aktiv', (bool) $wert); + $this->regeln(false); + return; + + case 'Regeln': + $this->regeln((bool) $wert); + return; + + case 'VerbraucherdatenEmpfangen': + if (!is_string($wert)) { + throw new InvalidArgumentException('Verbraucherdaten muessen als JSON uebergeben werden.'); + } + $daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new InvalidArgumentException('Verbraucherdaten muessen ein JSON-Objekt sein.'); + } + $this->VerbraucherdatenEmpfangen($daten); + return; + + case 'Monatsgrenzen': + if (!$this->ReadPropertyBoolean('MonatsgrenzenVisuBedienbar')) { + throw new RuntimeException('Monatsgrenzen sind in der Visualisierung nicht bedienbar.'); + } + $this->dekodiereMonatsgrenzen((string) $wert); + IPS_SetProperty($this->InstanceID, 'Monatsgrenzen', (string) $wert); + IPS_ApplyChanges($this->InstanceID); + return; + } + + throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); + } + + /** + * @param array $daten + */ + public function VerbraucherdatenEmpfangen(array $daten): void + { + Nachrichtenvertrag::pruefeVerbraucherdaten($daten); + if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { + throw new InvalidArgumentException('Verbraucherdaten sind an eine andere Instanz adressiert.'); + } + + $absenderID = $daten['Kopf']['AbsenderID']; + if (!in_array($absenderID, $this->aktiveVerbraucherIDs(), true)) { + throw new InvalidArgumentException('Der Absender ist diesem Manager nicht aktiv zugeordnet.'); + } + + $cache = $this->leseJsonAttribut('VerbraucherCache'); + $cache[(string) $absenderID] = [ + 'EmpfangenAm' => time(), + 'Daten' => $daten, + ]; + $this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR)); + $this->regeln(false); + } + + private function regeln(bool $erneuern): void + { + [$netzleistung, $netzleistungGueltig] = $this->leseNetzleistung(); + $this->SetValue('Netzleistung', $netzleistung); + $this->SetValue('NetzleistungGueltig', $netzleistungGueltig); + + $aktiv = (bool) $this->GetValue('Aktiv'); + if (!$aktiv || !$netzleistungGueltig) { + $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); + $this->SetValue('Verteilbudget', 0.0); + $this->SetStatus($aktiv ? self::STATUS_MESSWERT_UNGUELTIG : self::STATUS_AKTIV); + return; + } + + $stoerungen = []; + $verbraucher = $this->aktuelleVerbraucher($stoerungen); + $lastspitzengrenze = ManagerRegler::wirksameLastspitzengrenze( + $this->ReadPropertyInteger('Lastspitzenmodus'), + $this->ReadPropertyFloat('Lastspitzengrenze'), + $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')), + (int) date('n') + ); + + $betriebsart = ManagerRegler::bestimmeBetriebsart( + true, + true, + $netzleistung, + (string) $this->GetValue('Betriebsart'), + $lastspitzengrenze !== null, + $this->ReadPropertyFloat('UmschaltschwelleUeberschuss'), + $this->ReadPropertyFloat('UmschaltschwelleLastspitze'), + $this->ReadPropertyFloat('SollwertSolarladen'), + $lastspitzengrenze ?? 0.0, + $this->ReadPropertyFloat('Umschaltdifferenz') + ); + $zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK + ? (float) $lastspitzengrenze + : $this->ReadPropertyFloat('SollwertSolarladen'); + $ergebnis = ManagerRegler::verteile( + $netzleistung, + $zielnetzleistung, + $verbraucher, + $betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV' + ); + + $this->SetValue('Betriebsart', $betriebsart); + $this->SetValue('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0); + $this->SetValue('Verteilbudget', (float) $ergebnis['Verteilbudget_W']); + $this->SetValue('Verbraucherstatus', json_encode([ + 'Betriebsart' => $betriebsart, + 'Restabweichung_W' => $ergebnis['Restabweichung_W'], + 'Verbraucher' => $verbraucher, + ], JSON_THROW_ON_ERROR)); + + $letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte'); + foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) { + if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) { + continue; + } + try { + $this->sendeSollwert((int) $instanzID, $sollleistung); + $letzteSollwerte[(string) $instanzID] = $sollleistung; + } catch (Throwable $fehler) { + $stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage(); + } + } + $this->WriteAttributeString('LetzteSollwerte', json_encode($letzteSollwerte, JSON_THROW_ON_ERROR)); + + $this->aktualisiereStoerungen($verbraucher, $stoerungen); + $this->SetStatus(self::STATUS_AKTIV); + } + + /** @return array{0: float, 1: bool} */ + private function leseNetzleistung(): array + { + $variablenID = $this->ReadPropertyInteger('NetzleistungVariableID'); + if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) { + return [0.0, false]; + } + + $variable = IPS_GetVariable($variablenID); + if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) { + return [0.0, false]; + } + $wert = GetValue($variablenID); + if (!is_int($wert) && !is_float($wert)) { + return [0.0, false]; + } + + return [(float) $wert * $this->ReadPropertyFloat('Netzleistungsfaktor'), true]; + } + + /** + * @param list $stoerungen + * @return list}> + */ + private function aktuelleVerbraucher(array &$stoerungen): array + { + $ids = $this->aktiveVerbraucherIDs(); + $this->SetValue('VerbraucherAnzahl', count($ids)); + $cache = $this->leseJsonAttribut('VerbraucherCache'); + $timeout = $this->ReadPropertyInteger('VerbraucherTimeout'); + $aktuell = []; + + foreach ($ids as $instanzID) { + $eintrag = $cache[(string) $instanzID] ?? null; + if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) { + $stoerungen[] = 'Noch keine Meldung von Instanz ' . $instanzID . '.'; + continue; + } + if (time() - (int) $eintrag['EmpfangenAm'] > $timeout) { + $stoerungen[] = 'Meldung von Instanz ' . $instanzID . ' ist veraltet.'; + continue; + } + $aktuell[] = ['InstanzID' => $instanzID, 'Daten' => $eintrag['Daten']]; + } + + $verfuegbar = array_filter($aktuell, static fn (array $eintrag): bool => $eintrag['Daten']['Verfuegbar']); + $this->SetValue('VerbraucherVerfuegbar', count($verfuegbar)); + + return $aktuell; + } + + /** @return list */ + private function aktiveVerbraucherIDs(): array + { + $zuordnung = json_decode($this->ReadPropertyString('VerbraucherZuordnung'), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($zuordnung)) { + throw new InvalidArgumentException('VerbraucherZuordnung muss eine JSON-Liste sein.'); + } + + $ids = []; + foreach ($zuordnung as $eintrag) { + if (!is_array($eintrag)) { + throw new InvalidArgumentException('Jede Verbraucherzuordnung muss ein Objekt sein.'); + } + $instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null; + if (!is_int($instanzID) || $instanzID <= 0 || !is_bool($eintrag['Aktiv'] ?? null)) { + throw new InvalidArgumentException('Verbraucherzuordnung benoetigt InstanzID und Aktiv.'); + } + if ($eintrag['Aktiv']) { + $ids[] = $instanzID; + } + } + + return array_values(array_unique($ids)); + } + + private function sendeSollwert(int $instanzID, int $sollleistung): void + { + if (!IPS_InstanceExists($instanzID)) { + throw new RuntimeException('Instanz existiert nicht.'); + } + $paket = [ + 'Kopf' => [ + 'Version' => Nachrichtenvertrag::VERSION, + 'AbsenderID' => $this->InstanceID, + 'EmpfaengerID' => $instanzID, + 'Zeitpunkt' => time(), + ], + 'Sollleistung_W' => $sollleistung, + ]; + Nachrichtenvertrag::pruefeManagerdaten($paket); + IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR)); + $this->protokolliere('Sollwert', ['InstanzID' => $instanzID, 'Sollleistung_W' => $sollleistung]); + } + + /** + * @param list}> $verbraucher + * @param list $stoerungen + */ + private function aktualisiereStoerungen(array $verbraucher, array $stoerungen): void + { + foreach ($verbraucher as $eintrag) { + foreach ($eintrag['Daten']['Zustand'] as $zustand) { + if ($zustand['Art'] === 'Stoerung' && $zustand['Wert'] === true) { + $stoerungen[] = 'Instanz ' . $eintrag['InstanzID'] . ': ' + . ($zustand['Text'] ?? $zustand['Kennung']); + } + } + } + $stoerungen = array_values(array_unique($stoerungen)); + $this->SetValue('Sammelstoerung', $stoerungen !== []); + $this->SetValue('Stoertext', implode("\n", $stoerungen)); + } + + private function registriereNetzleistungsmeldung(): void + { + $alt = $this->ReadAttributeInteger('RegistrierteNetzleistungVariableID'); + $neu = $this->ReadPropertyInteger('NetzleistungVariableID'); + if ($alt > 0 && $alt !== $neu) { + $this->UnregisterMessage($alt, self::VM_UPDATE); + } + if ($neu > 0 && $neu !== $alt) { + $this->RegisterMessage($neu, self::VM_UPDATE); + } + $this->WriteAttributeInteger('RegistrierteNetzleistungVariableID', $neu); + } + + private function aktualisiereSichtbarkeit(): void + { + $logging = $this->ReadPropertyBoolean('LoggingEin'); + foreach ([ + 'NetzleistungGueltig', 'WirksameLastspitzengrenze', 'Verteilbudget', + 'VerbraucherAnzahl', 'VerbraucherVerfuegbar', 'Verbraucherstatus', + 'Prognosestatus', 'SDLStatus', 'Lizenzstatus', + 'Stoerueberwachungsstatus', 'Sammelstoerung', 'Stoertext', + ] as $ident) { + IPS_SetHidden($this->GetIDForIdent($ident), !$logging); + } + $monatsgrenzenBedienbar = $logging && $this->ReadPropertyBoolean('MonatsgrenzenVisuBedienbar'); + IPS_SetHidden($this->GetIDForIdent('Monatsgrenzen'), !$monatsgrenzenBedienbar); + if ($monatsgrenzenBedienbar) { + $this->EnableAction('Monatsgrenzen'); + } else { + $this->DisableAction('Monatsgrenzen'); + } + } + + private function aktualisiereAnschlussstatus(): void + { + foreach ([ + 'PrognoseAnschluss' => 'Prognosestatus', + 'SDLAnschluss' => 'SDLStatus', + 'LizenzAnschluss' => 'Lizenzstatus', + 'StoermeldeAnschluss' => 'Stoerueberwachungsstatus', + ] as $property => $variable) { + $konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); + $aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true; + $this->SetValue($variable, $aktiv ? 'Fehler' : 'NichtVerwendet'); + } + } + + private function pruefeKonfiguration(): void + { + foreach (['MesswertMaxAlter', 'Regelintervall', 'Vorgabeintervall', 'VerbraucherTimeout'] as $property) { + if ($this->ReadPropertyInteger($property) <= 0) { + throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); + } + } + if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0 + || $this->ReadPropertyFloat('Umschaltdifferenz') > 100 + ) { + throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 liegen.'); + } + $this->aktiveVerbraucherIDs(); + $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); + foreach (['PrognoseAnschluss', 'SDLAnschluss', 'LizenzAnschluss', 'StoermeldeAnschluss'] as $property) { + $wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($wert)) { + throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.'); + } + } + } + + /** @return array */ + private function dekodiereMonatsgrenzen(string $json): array + { + $werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($werte)) { + throw new InvalidArgumentException('Monatsgrenzen muss ein JSON-Objekt sein.'); + } + $ergebnis = []; + for ($monat = 1; $monat <= 12; ++$monat) { + $wert = $werte[(string) $monat] ?? $werte[$monat] ?? null; + if (!is_int($wert) && !is_float($wert)) { + throw new InvalidArgumentException('Monatsgrenzen muss fuer jeden Monat einen Zahlenwert enthalten.'); + } + $ergebnis[$monat] = (float) $wert; + } + + return $ergebnis; + } + + /** @return array */ + private function leseJsonAttribut(string $name): array + { + $wert = json_decode($this->ReadAttributeString($name), true, 512, JSON_THROW_ON_ERROR); + + return is_array($wert) ? $wert : []; + } + + private function standardMonatsgrenzen(): string + { + return json_encode(array_fill_keys(range(1, 12), 0), JSON_THROW_ON_ERROR); + } + + /** @param mixed $daten */ + private function protokolliere(string $bezeichnung, $daten): void + { + if ($this->ReadPropertyBoolean('LoggingEin')) { + $this->SendDebug($bezeichnung, is_string($daten) ? $daten : json_encode($daten), 0); + } + } +} diff --git a/README.md b/README.md index 2a04d85..d90b5d5 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,13 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon. ## Status -Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag und -die Deklaration der Verbraucherbasis sind implementiert. Die Module sind als -Diskussionsentwürfe dokumentiert, aber noch nicht als installierbare -IP-Symcon-Module angelegt. +Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die +Deklaration der Verbraucherbasis und das Manager-Modul sind implementiert. Die +weiteren Module sind als Diskussionsentwürfe dokumentiert. ## Geplante Module -- Manager +- Manager (implementiert) - Batterie - Wassererwaermer - Pufferspeicher @@ -50,5 +49,6 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche ## Dokumentation - [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md) +- [Manager-Modul](docs/module/Manager/README.md) - [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md) - [Modulübersicht](docs/module/README.md) diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index e008fea..35f7cbf 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -1,6 +1,6 @@ # Manager -> Status: Diskussionsentwurf. Führt Hauptmanager und Peakshaving zusammen. +> Status: Implementiert. Führt Hauptmanager und Peakshaving zusammen. Der Manager liest die Netzleistung, verwaltet ausschliesslich die ausgewählten Verbraucher und verteilt Leistung im PV- oder Peak-Betrieb. Die Prioritäten @@ -59,6 +59,41 @@ Ohne Logging sind nur `Aktiv`, `Betriebsart` und `Netzleistung` sichtbar. Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt. +## Verteilalgorithmus + +Der Manager bildet das Verteilbudget aus der aktuellen Netzleistung, dem Ziel +der aktiven Betriebsart und der aktuellen Leistung aller frisch gemeldeten +Verbraucher. Eine gültige Istleistung wird bevorzugt; fehlt sie, bleibt der im +Zustand bestätigte Sollwert konservativ reserviert. + +Nicht verfügbare, nicht änderbare oder aktuell angebotlose Verbraucher behalten +ihre Leistung und erhalten keine neue Vorgabe. Die übrigen Verbraucher werden +nach der gemeldeten PV- beziehungsweise Peak-Priorität und danach stabil nach +Instanz-ID sortiert. Von ihrem jeweils kleinsten erlaubten Leistungswert aus +wird das Budget in dieser Reihenfolge aufgefüllt. Einzelwerte und ganzzahlige +Bereiche werden direkt verarbeitet; Lücken werden nie durch unzulässige Werte +geschlossen. Eine verbleibende Abweichung wird im `Verbraucherstatus` +dokumentiert. + +Die Betriebsart wechselt unterhalb der Überschussschwelle zu `PV` und oberhalb +der Lastspitzenschwelle zu `Peak`. Zwischen den Schwellen bleibt sie erhalten. +`Umschaltdifferenz` verhindert zusätzlich einen Wechsel, wenn sich die beiden +berechneten Korrekturen nicht ausreichend unterscheiden. + +## Laufzeit und Fehlerverhalten + +- Netzleistungsänderungen und der Regel-Timer lösen die Berechnung aus. +- Identische Sollwerte werden nur beim Vorgabeintervall erneut gesendet. +- Verbraucherpakete werden zentral geprüft und nur von aktiv zugeordneten + Absendern angenommen. +- Veraltete oder noch fehlende Verbrauchermeldungen werden nicht verteilt und + als Sammelstörung ausgewiesen. +- Bei fehlender oder veralteter Netzleistung bleibt die Regelung `Inaktiv` und + sendet keine neuen Vorgaben. +- Die oberen Anschlüsse bleiben optional. Solange kein konkreter Adapter + implementiert ist, meldet ein aktivierter Anschluss den Status `Fehler`, ohne + die lokale EMS-Regelung zu blockieren. + ## Schnittstellen - Empfängt genau `VerbraucherdatenEmpfangen(array $daten)`. diff --git a/docs/module/README.md b/docs/module/README.md index 7fd1bf9..66491ee 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,7 +1,7 @@ -# EMS-Modulentwürfe +# EMS-Module und Modulentwürfe -> Diese Ordner enthalten Besprechungsgrundlagen und noch keine installierbaren -> IP-Symcon-Module. +> Der Manager ist als installierbares IP-Symcon-Modul umgesetzt. Die weiteren +> Ordner enthalten Besprechungsgrundlagen und noch keine installierbaren Module. Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der [EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb @@ -9,7 +9,7 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte. | Modul | Rolle | | --- | --- | -| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse | +| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) | | [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers | | [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung | | [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve | diff --git a/libs/ManagerRegler.php b/libs/ManagerRegler.php new file mode 100644 index 0000000..5ca1d2f --- /dev/null +++ b/libs/ManagerRegler.php @@ -0,0 +1,252 @@ + $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel + */ + public static function wirksameLastspitzengrenze( + int $modus, + float $konstanteGrenze, + array $monatsgrenzen, + int $monat + ): ?float { + if ($modus === 0) { + return null; + } + if ($modus === 1) { + return $konstanteGrenze; + } + if ($modus !== 2) { + throw new InvalidArgumentException('Lastspitzenmodus muss 0, 1 oder 2 sein.'); + } + if ($monat < 1 || $monat > 12 || !array_key_exists($monat, $monatsgrenzen)) { + throw new InvalidArgumentException('Fuer den aktuellen Monat fehlt eine Lastspitzengrenze.'); + } + + return (float) $monatsgrenzen[$monat]; + } + + public static function bestimmeBetriebsart( + bool $aktiv, + bool $netzleistungGueltig, + float $netzleistung, + string $bisherigeBetriebsart, + bool $peakAktiv, + float $umschaltschwelleUeberschuss, + float $umschaltschwelleLastspitze, + float $sollwertSolarladen, + float $wirksameLastspitzengrenze, + float $umschaltdifferenzProzent + ): string { + if (!$aktiv || !$netzleistungGueltig) { + return self::BETRIEBSART_INAKTIV; + } + if (!$peakAktiv) { + return self::BETRIEBSART_PV; + } + if ($umschaltdifferenzProzent < 0 || $umschaltdifferenzProzent > 100) { + throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 Prozent liegen.'); + } + + $kandidat = $bisherigeBetriebsart; + if ($netzleistung < $umschaltschwelleUeberschuss) { + $kandidat = self::BETRIEBSART_PV; + } elseif ($netzleistung > $umschaltschwelleLastspitze) { + $kandidat = self::BETRIEBSART_PEAK; + } elseif (!in_array($kandidat, [self::BETRIEBSART_PV, self::BETRIEBSART_PEAK], true)) { + $kandidat = self::BETRIEBSART_PV; + } + + if ($kandidat === $bisherigeBetriebsart + || !in_array($bisherigeBetriebsart, [self::BETRIEBSART_PV, self::BETRIEBSART_PEAK], true) + || $umschaltdifferenzProzent === 0.0 + ) { + return $kandidat; + } + + $pvKorrektur = $sollwertSolarladen - $netzleistung; + $peakKorrektur = $wirksameLastspitzengrenze - $netzleistung; + $bezugswert = max(abs($pvKorrektur), abs($peakKorrektur), 1.0); + $differenzProzent = abs($pvKorrektur - $peakKorrektur) / $bezugswert * 100.0; + + return $differenzProzent >= $umschaltdifferenzProzent + ? $kandidat + : $bisherigeBetriebsart; + } + + /** + * @param list> $verbraucher Vollstaendige, bereits gepruefte Verbraucherdaten + * @return array{ + * Verteilbudget_W: int, + * AktuelleVerbraucherleistung_W: int, + * NichtSteuerbar_W: int, + * Sollwerte: array, + * Restabweichung_W: int + * } + */ + public static function verteile( + float $netzleistung, + float $zielnetzleistung, + array $verbraucher, + string $prioritaetsfeld + ): array { + if (!in_array($prioritaetsfeld, ['PrioritaetPV', 'PrioritaetPeak'], true)) { + throw new InvalidArgumentException('Unbekanntes Prioritaetsfeld.'); + } + + $aktuelleLeistung = 0; + $nichtSteuerbar = 0; + $steuerbar = []; + + foreach ($verbraucher as $eintrag) { + self::pruefeVerteilEintrag($eintrag, $prioritaetsfeld); + $leistung = self::aktuelleLeistung($eintrag['Daten']); + $aktuelleLeistung += $leistung; + + $daten = $eintrag['Daten']; + if (!$daten['Verfuegbar'] || !$daten['AenderungMoeglich'] || $daten['Leistungswerte_W'] === []) { + $nichtSteuerbar += $leistung; + continue; + } + + $steuerbar[] = [ + 'InstanzID' => $eintrag['InstanzID'], + 'Prioritaet' => $daten[$prioritaetsfeld], + 'Leistungswerte_W' => $daten['Leistungswerte_W'], + ]; + } + + usort($steuerbar, static function (array $links, array $rechts): int { + return [$links['Prioritaet'], $links['InstanzID']] <=> [$rechts['Prioritaet'], $rechts['InstanzID']]; + }); + + $verteilbudget = (int) round($aktuelleLeistung + $zielnetzleistung - $netzleistung); + $sollwerte = []; + $minimumSumme = 0; + foreach ($steuerbar as $eintrag) { + $minimum = self::kleinsterLeistungswert($eintrag['Leistungswerte_W']); + $sollwerte[$eintrag['InstanzID']] = $minimum; + $minimumSumme += $minimum; + } + + $verfuegbar = max(0, $verteilbudget - $nichtSteuerbar - $minimumSumme); + foreach ($steuerbar as $eintrag) { + $instanzID = $eintrag['InstanzID']; + $minimum = $sollwerte[$instanzID]; + $neuerSollwert = self::groessterLeistungswertBis( + $eintrag['Leistungswerte_W'], + $minimum + $verfuegbar + ); + $verfuegbar -= $neuerSollwert - $minimum; + $sollwerte[$instanzID] = $neuerSollwert; + } + + $zugeteilt = $nichtSteuerbar + array_sum($sollwerte); + + return [ + 'Verteilbudget_W' => $verteilbudget, + 'AktuelleVerbraucherleistung_W' => $aktuelleLeistung, + 'NichtSteuerbar_W' => $nichtSteuerbar, + 'Sollwerte' => $sollwerte, + 'Restabweichung_W' => $verteilbudget - $zugeteilt, + ]; + } + + /** + * @param array $daten + */ + public static function angenommenerSollwert(array $daten): ?int + { + foreach ($daten['Zustand'] as $zustand) { + if ($zustand['Kennung'] === 'Sollleistung_W') { + return is_int($zustand['Wert']) ? $zustand['Wert'] : null; + } + } + + return null; + } + + /** + * @param array $daten + */ + private static function aktuelleLeistung(array $daten): int + { + if ($daten['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN + && is_numeric($daten['Istleistung_W']) + ) { + return (int) round((float) $daten['Istleistung_W']); + } + + return self::angenommenerSollwert($daten) ?? 0; + } + + /** + * @param array $eintrag + */ + private static function pruefeVerteilEintrag(array $eintrag, string $prioritaetsfeld): void + { + if (!isset($eintrag['InstanzID'], $eintrag['Daten']) + || !is_int($eintrag['InstanzID']) + || $eintrag['InstanzID'] <= 0 + || !is_array($eintrag['Daten']) + ) { + throw new InvalidArgumentException('Verbrauchereintrag ist ungueltig.'); + } + Nachrichtenvertrag::pruefeVerbraucherdaten($eintrag['Daten']); + if (!array_key_exists($prioritaetsfeld, $eintrag['Daten'])) { + throw new InvalidArgumentException('Prioritaet fehlt im Verbrauchereintrag.'); + } + } + + /** + * @param list $leistungswerte + */ + private static function kleinsterLeistungswert(array $leistungswerte): int + { + $erster = $leistungswerte[0]; + + return is_int($erster) ? $erster : $erster['Von_W']; + } + + /** + * @param list $leistungswerte + */ + private static function groessterLeistungswertBis(array $leistungswerte, int $obergrenze): int + { + $gewaehlt = self::kleinsterLeistungswert($leistungswerte); + foreach ($leistungswerte as $eintrag) { + if (is_int($eintrag)) { + if ($eintrag > $obergrenze) { + break; + } + $gewaehlt = $eintrag; + continue; + } + + if ($eintrag['Von_W'] > $obergrenze) { + break; + } + $gewaehlt = min($eintrag['Bis_W'], $obergrenze); + } + + return $gewaehlt; + } +} diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index b609cec..4ea863f 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -12,7 +12,6 @@ final class DokumentationsstrukturTest extends TestCase public function modulReadmes(): array { return [ - 'Manager', 'Batterie', 'Wassererwaermer', 'Pufferspeicher', @@ -42,4 +41,12 @@ final class DokumentationsstrukturTest extends TestCase self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md'); self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php'); } + + public function testManagerDokumentiertDenImplementiertenStand(): void + { + $inhalt = file_get_contents(__DIR__ . '/../docs/module/Manager/README.md'); + self::assertNotFalse($inhalt); + self::assertStringContainsString('Status: Implementiert', $inhalt); + self::assertStringContainsString('Verteilalgorithmus', $inhalt); + } } diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php new file mode 100644 index 0000000..a9fea51 --- /dev/null +++ b/tests/ManagerModulstrukturTest.php @@ -0,0 +1,58 @@ +verbraucher(20, 5, [0, 3000], 0), + $this->verbraucher(10, 0, [0, ['Von_W' => 1000, 'Bis_W' => 2000], 4000], 0), + ], + 'PrioritaetPV' + ); + + self::assertSame(4500, $ergebnis['Verteilbudget_W']); + self::assertSame([10 => 4000, 20 => 0], $ergebnis['Sollwerte']); + self::assertSame(500, $ergebnis['Restabweichung_W']); + } + + public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void + { + $fest = $this->verbraucher(10, 0, [0, 3000], 3000, false); + $steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 0, 'Bis_W' => 4000]], 0); + + $ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV'); + + self::assertSame(3000, $ergebnis['NichtSteuerbar_W']); + self::assertSame([20 => 2000], $ergebnis['Sollwerte']); + self::assertSame(0, $ergebnis['Restabweichung_W']); + } + + public function testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void + { + $verbraucher = $this->verbraucher(10, 0, [0, 3000], null, false); + $verbraucher['Daten']['Leistungsquelle'] = Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN; + $verbraucher['Daten']['Istleistung_W'] = null; + $verbraucher['Daten']['Zustand'][0]['Wert'] = 3000; + + $ergebnis = ManagerRegler::verteile(3000.0, 0.0, [$verbraucher], 'PrioritaetPV'); + + self::assertSame(3000, $ergebnis['AktuelleVerbraucherleistung_W']); + self::assertSame(-3000, $ergebnis['Restabweichung_W']); + } + + public function testMonatlicheGrenzeMussVollstaendigSein(): void + { + $this->expectException(InvalidArgumentException::class); + ManagerRegler::wirksameLastspitzengrenze(2, 0.0, [1 => 12000.0], 9); + } + + /** + * @param list $leistungswerte + * @return array{InstanzID: int, Daten: array} + */ + private function verbraucher( + int $instanzID, + int $prioritaet, + array $leistungswerte, + ?int $istleistung, + bool $aenderungMoeglich = true + ): array { + $quelle = $istleistung === null + ? Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN + : Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN; + + return [ + 'InstanzID' => $instanzID, + 'Daten' => [ + 'Kopf' => [ + 'Version' => Nachrichtenvertrag::VERSION, + 'AbsenderID' => $instanzID, + 'EmpfaengerID' => 1, + 'Zeitpunkt' => 1788825600, + ], + 'PrioritaetPV' => $prioritaet, + 'PrioritaetPeak' => $prioritaet, + 'Leistungswerte_W' => $leistungswerte, + 'AenderungMoeglich' => $aenderungMoeglich, + 'Verfuegbar' => true, + 'Istleistung_W' => $istleistung, + 'Leistungsquelle' => $quelle, + 'Zustand' => [[ + 'Kennung' => 'Sollleistung_W', + 'Art' => 'Sollwert', + 'Wert' => $istleistung, + 'Einheit' => 'W', + ]], + ], + ]; + } +}