From f11b1fbe61acb246f6564ecfd56a47b6c848d2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=A4fliger?= Date: Sun, 27 Sep 2026 12:28:32 +0000 Subject: [PATCH] feat(utils): adapt virtual battery and VGT interface --- README.md | 12 +- VGTSchnittstelle/README.md | 9 + VGTSchnittstelle/form.json | 78 ++ VGTSchnittstelle/libs/VGTRegler.php | 126 +++ VGTSchnittstelle/module.json | 20 + VGTSchnittstelle/module.php | 432 ++++++++++ VirtuelleBatterie/README.md | 10 + VirtuelleBatterie/form.json | 167 ++++ .../libs/VirtuelleBatterieRegler.php | 220 +++++ VirtuelleBatterie/module.json | 12 + VirtuelleBatterie/module.php | 781 ++++++++++++++++++ docs/module/README.md | 13 +- docs/module/VGT-Schnittstelle/README.md | 123 ++- docs/module/Virtuelle-Batterie/README.md | 163 ++-- tests/DokumentationsstrukturTest.php | 4 +- tests/NeueModuleStrukturTest.php | 91 ++ tests/Symcon/manifest.php | 12 + tests/Symcon/modules/VGTSchnittstelle.php | 48 ++ tests/Symcon/modules/VirtuelleBatterie.php | 31 + tests/SymconTestContractTest.php | 9 +- tests/VGTReglerTest.php | 77 ++ tests/VirtuelleBatterieReglerTest.php | 111 +++ 22 files changed, 2445 insertions(+), 104 deletions(-) create mode 100644 VGTSchnittstelle/README.md create mode 100644 VGTSchnittstelle/form.json create mode 100644 VGTSchnittstelle/libs/VGTRegler.php create mode 100644 VGTSchnittstelle/module.json create mode 100644 VGTSchnittstelle/module.php create mode 100644 VirtuelleBatterie/README.md create mode 100644 VirtuelleBatterie/form.json create mode 100644 VirtuelleBatterie/libs/VirtuelleBatterieRegler.php create mode 100644 VirtuelleBatterie/module.json create mode 100644 VirtuelleBatterie/module.php create mode 100644 tests/NeueModuleStrukturTest.php create mode 100644 tests/Symcon/modules/VGTSchnittstelle.php create mode 100644 tests/Symcon/modules/VirtuelleBatterie.php create mode 100644 tests/VGTReglerTest.php create mode 100644 tests/VirtuelleBatterieReglerTest.php diff --git a/README.md b/README.md index 2404006..719b7b5 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,17 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix ## Status -Das Repository befindet sich im Aufbau. Verbrauchskostenreport, CC100 Hardware, -Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module -enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert. +Das Repository befindet sich im Aufbau. Verbrauchskostenreport, Virtuelle +Batterie, CC100 Hardware, Energiediagramm, VGT-Schnittstelle und Shelly Modul +sind als installierbare IP-Symcon-Module enthalten. ## Module - Verbrauchskostenreport (implementiert) -- Virtuelle Batterie +- Virtuelle Batterie (implementiert) - CC100 Hardware (implementiert) - Energiediagramm (implementiert) -- VGT-Schnittstelle +- VGT-Schnittstelle (implementiert) - Shelly Modul (implementiert) Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen @@ -62,7 +62,9 @@ Module geladen sein: - `CC100 Hardware` - `Energiediagramm` - `Shelly Modul` +- `VGT-Schnittstelle` - `Verbrauchskostenreport` +- `Virtuelle Batterie` Ein korrekter Zustand zeigt die Repository-URL, den ausgewählten Branch und kein Warnsymbol bei `Enelix Utils`. diff --git a/VGTSchnittstelle/README.md b/VGTSchnittstelle/README.md new file mode 100644 index 0000000..8a4d9b7 --- /dev/null +++ b/VGTSchnittstelle/README.md @@ -0,0 +1,9 @@ +# VGT-Schnittstelle + +Installierbares IP-Symcon-Modul fuer Enelix Utils ab Version 8.0. + +Das Modul fuehrt die bisherigen PV- und Batterie-SDL-Module zusammen. Die +bestehenden MQTT-Topics und Payload-Felder bleiben unveraendert. + +Die vollstaendige Konfiguration steht in +[`docs/module/VGT-Schnittstelle/README.md`](../docs/module/VGT-Schnittstelle/README.md). diff --git a/VGTSchnittstelle/form.json b/VGTSchnittstelle/form.json new file mode 100644 index 0000000..69f5ff1 --- /dev/null +++ b/VGTSchnittstelle/form.json @@ -0,0 +1,78 @@ +{ + "elements": [ + { + "type": "Label", + "caption": "Die MQTT-Topics und Nutzdaten entsprechen unveraendert den bisherigen MQTTPVSDL- und MQTTBatterySDL-Modulen." + }, + { + "type": "Select", + "name": "Geraeteart", + "caption": "Geraeteart", + "options": [ + {"caption": "PV", "value": 0}, + {"caption": "Batterie / SDL-Anteil", "value": 1} + ] + }, + { + "type": "ValidationTextBox", + "name": "TopicSuffix", + "caption": "Topic Suffix" + }, + { + "type": "SelectVariable", + "name": "ReqActionID", + "caption": "Ausgabe-Variable / SDL-Sollleistung" + }, + { + "type": "SelectVariable", + "name": "PowerProductionID", + "caption": "Aktuelle Leistung SDL" + }, + { + "type": "SelectVariable", + "name": "SoCID", + "caption": "SDL-Ladezustand (nur Batterie)" + }, + { + "type": "NumberSpinner", + "name": "TargetSoC", + "caption": "Ziel-Ladezustand", + "suffix": " %", + "minimum": 0, + "maximum": 100 + }, + { + "type": "NumberSpinner", + "name": "ChargePower", + "caption": "Ladeleistung", + "suffix": " W", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "DischargePower", + "caption": "Entladeleistung", + "suffix": " W", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "MaxPowerSetpoint", + "caption": "Maximaler Power Setpoint", + "suffix": " W", + "minimum": 0 + }, + { + "type": "CheckBox", + "name": "LoggingEin", + "caption": "Debug-Logging aktivieren" + } + ], + "actions": [ + { + "type": "Button", + "caption": "Steuerung testen", + "onClick": "ENLVGT_RunControl($id);" + } + ] +} diff --git a/VGTSchnittstelle/libs/VGTRegler.php b/VGTSchnittstelle/libs/VGTRegler.php new file mode 100644 index 0000000..f87fb47 --- /dev/null +++ b/VGTSchnittstelle/libs/VGTRegler.php @@ -0,0 +1,126 @@ += $zielLadezustand + && $ladezustand <= $zielLadezustand + 1.0 + ) { + return 0.0; + } + $ziel = $ladezustand < $zielLadezustand + ? abs($ladeleistung) + : -abs($entladeleistung); + $ziel = self::begrenzeSetpoint($geraeteart, $ziel, $maximum); + + return self::begrenzeNachLadezustand( + $geraeteart, + $ziel, + $ladezustand, + $minLadezustand, + $maxLadezustand + ); + } + + private static function begrenzeNachLadezustand( + int $geraeteart, + float $zielW, + float $ladezustand, + float $minimum, + float $maximum + ): float { + if ($geraeteart === self::GERAETEART_PV) { + return $zielW; + } + if ($minimum < 0.0 + || $maximum > 100.0 + || $minimum > $maximum + ) { + throw new InvalidArgumentException( + 'MinSoC und MaxSoC sind ungueltig.' + ); + } + if (($zielW < 0.0 && $ladezustand <= $minimum) + || ($zielW > 0.0 && $ladezustand >= $maximum) + ) { + return 0.0; + } + + return $zielW; + } + + private static function pruefeGeraeteart(int $geraeteart): void + { + if (!in_array($geraeteart, [ + self::GERAETEART_PV, + self::GERAETEART_BATTERIE, + ], true)) { + throw new InvalidArgumentException('Geraeteart ist ungueltig.'); + } + } +} diff --git a/VGTSchnittstelle/module.json b/VGTSchnittstelle/module.json new file mode 100644 index 0000000..241aba4 --- /dev/null +++ b/VGTSchnittstelle/module.json @@ -0,0 +1,20 @@ +{ + "id": "{4BA1A557-7970-4FB9-93B8-1F6ADA0A5A94}", + "name": "VGTSchnittstelle", + "type": 3, + "vendor": "Enelix", + "aliases": [ + "VGT-Schnittstelle", + "MQTT PV SDL", + "MQTT Battery SDL" + ], + "parentRequirements": [ + "{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}" + ], + "childRequirements": [], + "implemented": [ + "{7F7632D9-FA40-4F38-8DEA-C83CD4325A32}" + ], + "prefix": "ENLVGT", + "url": "" +} diff --git a/VGTSchnittstelle/module.php b/VGTSchnittstelle/module.php new file mode 100644 index 0000000..dbf3ff2 --- /dev/null +++ b/VGTSchnittstelle/module.php @@ -0,0 +1,432 @@ +RegisterPropertyInteger( + 'Geraeteart', + VGTRegler::GERAETEART_PV + ); + $this->RegisterPropertyString('TopicSuffix', ''); + $this->RegisterPropertyInteger('ReqActionID', 0); + $this->RegisterPropertyInteger('PowerProductionID', 0); + $this->RegisterPropertyInteger('SoCID', 0); + $this->RegisterPropertyFloat('TargetSoC', 50.0); + $this->RegisterPropertyInteger('ChargePower', 2500); + $this->RegisterPropertyInteger('DischargePower', 2500); + $this->RegisterPropertyInteger('MaxPowerSetpoint', 10000); + $this->RegisterPropertyBoolean('LoggingEin', false); + + $this->RegisterVariableBoolean('IsReady', 'Is Ready', '', 10); + $this->RegisterVariableBoolean('IsRunning', 'Is Running', '', 20); + $this->RegisterVariableFloat('MinSoC', 'Min SoC', '', 30); + $this->EnableAction('MinSoC'); + $this->RegisterVariableFloat('MaxSoC', 'Max SoC', '', 40); + $this->EnableAction('MaxSoC'); + $this->RegisterVariableInteger( + 'PowerSetpoint', + 'Power Setpoint', + '', + 50 + ); + $this->EnableAction('PowerSetpoint'); + $this->RegisterVariableString('Strategy', 'Strategy', '', 60); + $this->EnableAction('Strategy'); + $this->RegisterVariableString( + 'LastReadResponse', + 'Letzte Lese-Antwort', + '', + 70 + ); + $this->RegisterVariableString( + 'LastWriteResponse', + 'Letzte Steuer-Antwort', + '', + 80 + ); + $this->RegisterVariableBoolean('Stoerung', 'Stoerung', '~Switch', 90); + $this->RegisterVariableString('Stoertext', 'Stoertext', '', 100); + + $this->RegisterAttributeString('PublishQueue', '[]'); + $this->RegisterAttributeFloat('LetzterZielwert', 0.0); + $this->RegisterAttributeBoolean('SoCGrenzenInitialisiert', false); + + $this->ConnectParent('{F7A0DD2E-7684-95C0-64C2-D2A9DC47577B}'); + $this->RegisterTimer( + 'PublishDelay', + 0, + 'ENLVGT_DoDelayedPublish($_IPS["TARGET"]);' + ); + $this->RegisterTimer( + 'BatteryControlTimer', + 0, + 'ENLVGT_RunControl($_IPS["TARGET"]);' + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + try { + if (!$this->ReadAttributeBoolean('SoCGrenzenInitialisiert')) { + $this->SetValue('MinSoC', 0.0); + $this->SetValue('MaxSoC', 100.0); + $this->WriteAttributeBoolean('SoCGrenzenInitialisiert', true); + } + $this->pruefeKonfiguration(); + $suffix = $this->ReadPropertyString('TopicSuffix'); + $filter = '.*"Topic":"(feedback-request|remote-control-request)\\/' + . preg_quote($suffix, '/') + . '".*'; + $this->SetReceiveDataFilter($filter); + $this->SetTimerInterval('BatteryControlTimer', 3000); + $this->SetValue('IsReady', true); + $this->setzeFehler(''); + $this->SetStatus(self::STATUS_AKTIV); + $this->RunControl(); + } catch (Throwable $fehler) { + $this->SetTimerInterval('BatteryControlTimer', 0); + $this->SetValue('IsReady', false); + $this->SetValue('IsRunning', false); + $this->setzeFehler($fehler->getMessage()); + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + } + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'PowerSetpoint': + $begrenzt = VGTRegler::begrenzeSetpoint( + $this->ReadPropertyInteger('Geraeteart'), + (float) $wert, + (float) $this->ReadPropertyInteger('MaxPowerSetpoint') + ); + $this->SetValue('PowerSetpoint', (int) round($begrenzt)); + $this->RunControl(); + return; + + case 'Strategy': + $this->SetValue('Strategy', trim((string) $wert)); + $this->RunControl(); + return; + + case 'MinSoC': + $this->SetValue( + 'MinSoC', + min(100.0, max(0.0, (float) $wert)) + ); + $this->RunControl(); + return; + + case 'MaxSoC': + $this->SetValue( + 'MaxSoC', + min(100.0, max(0.0, (float) $wert)) + ); + $this->RunControl(); + return; + } + + throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); + } + + public function RunControl(): void + { + try { + $this->pruefeKonfiguration(); + $geraeteart = $this->ReadPropertyInteger('Geraeteart'); + $strategie = (string) $this->GetValue('Strategy'); + if ($geraeteart === VGTRegler::GERAETEART_PV + && !in_array($strategie, ['activate', 'stop'], true) + ) { + $this->SetValue('IsReady', true); + $this->SetValue('IsRunning', false); + $this->setzeFehler(''); + $this->SetStatus(self::STATUS_AKTIV); + return; + } + + $ladezustand = $geraeteart === VGTRegler::GERAETEART_BATTERIE + ? $this->leseNumerisch($this->ReadPropertyInteger('SoCID')) + : 0.0; + $zielwert = VGTRegler::zielwert( + $geraeteart, + $strategie, + (float) $this->GetValue('PowerSetpoint'), + (float) $this->ReadPropertyInteger('MaxPowerSetpoint'), + $ladezustand, + $this->ReadPropertyFloat('TargetSoC'), + (float) $this->ReadPropertyInteger('ChargePower'), + (float) $this->ReadPropertyInteger('DischargePower'), + (float) $this->GetValue('MinSoC'), + (float) $this->GetValue('MaxSoC') + ); + + RequestAction( + $this->ReadPropertyInteger('ReqActionID'), + $zielwert + ); + $this->WriteAttributeFloat('LetzterZielwert', $zielwert); + $this->SetValue('IsReady', true); + $this->SetValue('IsRunning', abs($zielwert) >= 0.5); + $this->setzeFehler(''); + $this->SetStatus(self::STATUS_AKTIV); + $this->protokolliere('Zielwert', $zielwert); + } catch (Throwable $fehler) { + $this->SetValue('IsRunning', false); + $this->setzeFehler($fehler->getMessage()); + $this->SetStatus(self::STATUS_AUSGABE_FEHLER); + $this->protokolliere('Steuerfehler', $fehler->getMessage()); + } + } + + public function BuildReadResponse(): string + { + $data = [ + 'power_production' => $this->leseOptionalNumerisch( + $this->ReadPropertyInteger('PowerProductionID') + ), + 'is_ready' => (bool) $this->GetValue('IsReady'), + 'is_running' => (bool) $this->GetValue('IsRunning'), + ]; + + if ($this->ReadPropertyInteger('Geraeteart') + === VGTRegler::GERAETEART_BATTERIE + ) { + $data['state_of_charge'] = $this->leseOptionalNumerisch( + $this->ReadPropertyInteger('SoCID') + ); + $data['min_soc'] = (float) $this->GetValue('MinSoC'); + $data['max_soc'] = (float) $this->GetValue('MaxSoC'); + } + + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + $this->SetValue('LastReadResponse', $json); + + return $json; + } + + public function HandleRemoteControlJSON(string $payload): ?string + { + if ($payload === '') { + return null; + } + + $data = json_decode($payload, true); + if (!is_array($data)) { + return null; + } + + if (array_key_exists('power_setpoint', $data)) { + $wert = VGTRegler::begrenzeSetpoint( + $this->ReadPropertyInteger('Geraeteart'), + (float) $data['power_setpoint'], + (float) $this->ReadPropertyInteger('MaxPowerSetpoint') + ); + $this->SetValue('PowerSetpoint', (int) round($wert)); + } + if (array_key_exists('strategy', $data)) { + $this->SetValue('Strategy', (string) $data['strategy']); + } + + $this->RunControl(); + + $antwort = [ + 'power_setpoint' => (int) $this->GetValue('PowerSetpoint'), + 'strategy' => (string) $this->GetValue('Strategy'), + ]; + $json = json_encode( + $antwort, + JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR + ); + $this->SetValue('LastWriteResponse', $json); + + return $json; + } + + public function ReceiveData($jsonString): void + { + $data = json_decode((string) $jsonString, true); + if (!is_array($data) + || (int) ($data['PacketType'] ?? 0) !== 3 + || !isset($data['Topic']) + ) { + return; + } + + $topic = (string) $data['Topic']; + $payload = (string) ($data['Payload'] ?? ''); + $suffix = $this->ReadPropertyString('TopicSuffix'); + $this->protokolliere('ReceiveData', $jsonString); + + if ($topic === 'feedback-request/' . $suffix) { + $this->QueuePublish( + 'feedback-response/' . $suffix, + $this->BuildReadResponse() + ); + return; + } + + if ($topic === 'remote-control-request/' . $suffix) { + $antwort = $this->HandleRemoteControlJSON($payload); + if ($antwort !== null) { + $this->QueuePublish( + 'remote-control-response/' . $suffix, + $antwort + ); + } + } + } + + public function QueuePublish(string $topic, string $payload): void + { + $queue = json_decode( + $this->ReadAttributeString('PublishQueue'), + true + ); + if (!is_array($queue)) { + $queue = []; + } + $queue[] = ['Topic' => $topic, 'Payload' => $payload]; + if (count($queue) > 100) { + array_shift($queue); + } + $this->WriteAttributeString( + 'PublishQueue', + json_encode($queue, JSON_THROW_ON_ERROR) + ); + $this->SetTimerInterval('PublishDelay', 50); + } + + public function DoDelayedPublish(): void + { + $queue = json_decode( + $this->ReadAttributeString('PublishQueue'), + true + ); + if (!is_array($queue) || $queue === []) { + $this->SetTimerInterval('PublishDelay', 0); + return; + } + + $eintrag = array_shift($queue); + $this->WriteAttributeString( + 'PublishQueue', + json_encode($queue, JSON_THROW_ON_ERROR) + ); + $this->SendDataToParent(json_encode([ + 'DataID' => '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}', + 'PacketType' => 3, + 'QualityOfService' => 0, + 'Retain' => false, + 'Topic' => (string) $eintrag['Topic'], + 'Payload' => (string) $eintrag['Payload'], + ], JSON_THROW_ON_ERROR)); + + $this->SetTimerInterval('PublishDelay', $queue === [] ? 0 : 50); + } + + private function pruefeKonfiguration(): void + { + VGTRegler::begrenzeSetpoint( + $this->ReadPropertyInteger('Geraeteart'), + 0.0, + (float) $this->ReadPropertyInteger('MaxPowerSetpoint') + ); + if (trim($this->ReadPropertyString('TopicSuffix')) === '') { + throw new InvalidArgumentException( + 'TopicSuffix ist nicht eingerichtet.' + ); + } + $this->pruefeAktionsvariable( + $this->ReadPropertyInteger('ReqActionID'), + 'ReqActionID' + ); + if ($this->ReadPropertyInteger('Geraeteart') + === VGTRegler::GERAETEART_BATTERIE + ) { + $this->pruefeNumerischeVariable( + $this->ReadPropertyInteger('SoCID'), + 'SoCID' + ); + } + } + + private function pruefeAktionsvariable(int $id, string $name): void + { + $this->pruefeNumerischeVariable($id, $name); + $variable = IPS_GetVariable($id); + if ((int) ($variable['VariableAction'] ?? 0) <= 0 + && (int) ($variable['VariableCustomAction'] ?? 0) <= 0 + ) { + throw new InvalidArgumentException( + $name . ' benoetigt eine Variablenaktion.' + ); + } + } + + private function pruefeNumerischeVariable(int $id, string $name): void + { + if ($id <= 0 || !IPS_VariableExists($id)) { + throw new InvalidArgumentException($name . ' ist nicht eingerichtet.'); + } + $variable = IPS_GetVariable($id); + if (!in_array((int) $variable['VariableType'], [1, 2], true)) { + throw new InvalidArgumentException( + $name . ' muss numerisch sein.' + ); + } + } + + private function leseNumerisch(int $id): float + { + $this->pruefeNumerischeVariable($id, 'Messvariable'); + return (float) GetValue($id); + } + + private function leseOptionalNumerisch(int $id): float + { + if ($id <= 0 || !IPS_VariableExists($id)) { + return 0.0; + } + $variable = IPS_GetVariable($id); + if (!in_array((int) $variable['VariableType'], [1, 2], true)) { + return 0.0; + } + + return (float) GetValue($id); + } + + private function setzeFehler(string $text): void + { + $this->SetValue('Stoerung', $text !== ''); + $this->SetValue('Stoertext', $text); + } + + /** @param mixed $daten */ + private function protokolliere(string $titel, $daten): void + { + if (!$this->ReadPropertyBoolean('LoggingEin')) { + return; + } + $text = is_string($daten) + ? $daten + : json_encode($daten, JSON_THROW_ON_ERROR); + $this->SendDebug($titel, $text, 0); + } +} diff --git a/VirtuelleBatterie/README.md b/VirtuelleBatterie/README.md new file mode 100644 index 0000000..0bc3389 --- /dev/null +++ b/VirtuelleBatterie/README.md @@ -0,0 +1,10 @@ +# Virtuelle Batterie + +Installierbares IP-Symcon-Modul fuer Enelix Utils ab Version 8.0. + +Es aggregiert mehrere physische Batterien, reserviert einen priorisierten +SDL-Anteil und stellt dem vorhandenen Enelix-EMS-Batteriemodul generische +Proxyregister fuer den Eigenverbrauch bereit. + +Konfiguration, Verdrahtung und Sicherheitsverhalten stehen in +[`docs/module/Virtuelle-Batterie/README.md`](../docs/module/Virtuelle-Batterie/README.md). diff --git a/VirtuelleBatterie/form.json b/VirtuelleBatterie/form.json new file mode 100644 index 0000000..6043cc5 --- /dev/null +++ b/VirtuelleBatterie/form.json @@ -0,0 +1,167 @@ +{ + "elements": [ + { + "type": "Label", + "caption": "Fasst mehrere physische Batterien zu einer virtuellen Batterie zusammen. SDL hat bei der Leistungsverteilung Vorrang." + }, + { + "type": "List", + "name": "Batterieliste", + "caption": "Physische Batterien", + "rowCount": 5, + "add": true, + "delete": true, + "columns": [ + { + "caption": "Name", + "name": "Name", + "width": "150px", + "add": "", + "edit": {"type": "ValidationTextBox"} + }, + { + "caption": "Kapazitaet", + "name": "Kapazitaet_kWh", + "width": "110px", + "suffix": " kWh", + "add": 0, + "edit": {"type": "NumberSpinner", "minimum": 0, "digits": 2} + }, + { + "caption": "Max. Laden", + "name": "MaxLaden_W", + "width": "110px", + "suffix": " W", + "add": 0, + "edit": {"type": "NumberSpinner", "minimum": 0, "digits": 0} + }, + { + "caption": "Max. Entladen", + "name": "MaxEntladen_W", + "width": "120px", + "suffix": " W", + "add": 0, + "edit": {"type": "NumberSpinner", "minimum": 0, "digits": 0} + }, + { + "caption": "Ladezustand", + "name": "LadezustandVariableID", + "width": "130px", + "add": 0, + "edit": {"type": "SelectVariable"} + }, + { + "caption": "Istleistung", + "name": "IstleistungVariableID", + "width": "130px", + "add": 0, + "edit": {"type": "SelectVariable"} + }, + { + "caption": "Sollleistung", + "name": "SollleistungVariableID", + "width": "140px", + "add": 0, + "edit": {"type": "SelectVariable"} + } + ], + "values": [] + }, + { + "type": "ExpansionPanel", + "caption": "SDL-Reserve", + "items": [ + { + "type": "NumberSpinner", + "name": "SDLReserveLaden", + "caption": "Reservierte SDL-Ladeleistung", + "suffix": " W", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "SDLReserveEntladen", + "caption": "Reservierte SDL-Entladeleistung", + "suffix": " W", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "Reservezeit", + "caption": "SDL-Reservezeit", + "suffix": " h", + "minimum": 0, + "maximum": 24, + "digits": 2 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Regelung und Sicherheit", + "items": [ + { + "type": "NumberSpinner", + "name": "Aktualisierungsintervall", + "caption": "Aktualisierungsintervall", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "MesswertMaxAlter", + "caption": "Maximales Messwertalter", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "VorgabeTimeout", + "caption": "Timeout fuer EV- und SDL-Vorgaben", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "EigenverbrauchStart", + "caption": "Startwert Eigenverbrauchskonto", + "suffix": " %", + "minimum": 0, + "maximum": 100, + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "SDLStart", + "caption": "Startwert SDL-Konto", + "suffix": " %", + "minimum": 0, + "maximum": 100, + "digits": 1 + }, + { + "type": "CheckBox", + "name": "DiagnoseAnzeigen", + "caption": "Diagnosevariablen anzeigen" + }, + { + "type": "CheckBox", + "name": "LoggingEin", + "caption": "Debug-Logging aktivieren" + } + ] + } + ], + "actions": [ + { + "type": "Button", + "caption": "Virtuelle Konten auf Startwerte zuruecksetzen", + "onClick": "IPS_RequestAction($id, 'KontenReset', true);" + }, + { + "type": "Button", + "caption": "Jetzt aktualisieren", + "onClick": "IPS_RequestAction($id, 'Aktualisieren', 0);" + } + ] +} diff --git a/VirtuelleBatterie/libs/VirtuelleBatterieRegler.php b/VirtuelleBatterie/libs/VirtuelleBatterieRegler.php new file mode 100644 index 0000000..e7b0373 --- /dev/null +++ b/VirtuelleBatterie/libs/VirtuelleBatterieRegler.php @@ -0,0 +1,220 @@ + $batterien + * @return array + */ + public static function aggregiere( + array $batterien, + float $sdlReserveLadenW, + float $sdlReserveEntladenW, + float $reservezeitH + ): array { + if ($sdlReserveLadenW < 0.0 + || $sdlReserveEntladenW < 0.0 + || $reservezeitH < 0.0 + ) { + throw new InvalidArgumentException( + 'SDL-Reserven und Reservezeit duerfen nicht negativ sein.' + ); + } + + $kapazitaet = 0.0; + $energie = 0.0; + $maxLaden = 0.0; + $maxEntladen = 0.0; + $istleistung = 0.0; + + foreach ($batterien as $batterie) { + $kapazitaetKWh = (float) $batterie['Kapazitaet_kWh']; + $ladezustand = (float) $batterie['Ladezustand_Prozent']; + $ladenW = (float) $batterie['MaxLaden_W']; + $entladenW = (float) $batterie['MaxEntladen_W']; + + if ($kapazitaetKWh <= 0.0 || $ladenW < 0.0 || $entladenW < 0.0) { + throw new InvalidArgumentException( + 'Kapazitaet muss positiv und Leistungsgrenzen duerfen nicht negativ sein.' + ); + } + if ($ladezustand < 0.0 || $ladezustand > 100.0) { + throw new InvalidArgumentException( + 'Ladezustand muss zwischen 0 und 100 Prozent liegen.' + ); + } + + $kapazitaet += $kapazitaetKWh; + $energie += $kapazitaetKWh * $ladezustand / 100.0; + $maxLaden += $ladenW; + $maxEntladen += $entladenW; + $istleistung += (float) $batterie['Istleistung_W']; + } + + if ($kapazitaet <= 0.0) { + throw new InvalidArgumentException( + 'Mindestens eine verfuegbare Batterie ist erforderlich.' + ); + } + + $sdlLadenW = min($sdlReserveLadenW, $maxLaden); + $sdlEntladenW = min($sdlReserveEntladenW, $maxEntladen); + $untereReserve = $sdlEntladenW * $reservezeitH / 1000.0; + $obereReserve = $sdlLadenW * $reservezeitH / 1000.0; + + if ($untereReserve + $obereReserve > $kapazitaet) { + throw new InvalidArgumentException( + 'Die SDL-Energiereserven sind groesser als die Gesamtkapazitaet.' + ); + } + + return [ + 'Kapazitaet_kWh' => $kapazitaet, + 'Energie_kWh' => $energie, + 'Ladezustand_Prozent' => $energie / $kapazitaet * 100.0, + 'Istleistung_W' => $istleistung, + 'MaxLaden_W' => $maxLaden, + 'MaxEntladen_W' => $maxEntladen, + 'SDLKapazitaet_kWh' => $untereReserve + $obereReserve, + 'EigenverbrauchKapazitaet_kWh' => + $kapazitaet - $untereReserve - $obereReserve, + 'SDLMaxLaden_W' => $sdlLadenW, + 'SDLMaxEntladen_W' => $sdlEntladenW, + 'EigenverbrauchMaxLaden_W' => max(0.0, $maxLaden - $sdlLadenW), + 'EigenverbrauchMaxEntladen_W' => max(0.0, $maxEntladen - $sdlEntladenW), + 'UntereSDLReserve_kWh' => $untereReserve, + 'ObereSDLReserve_kWh' => $obereReserve, + ]; + } + + /** + * SDL wird zuerst begrenzt. Eigenverbrauch erhaelt die verbleibende + * physische Leistung derselben Richtung. + * + * @param array $aggregat + * @return array{Eigenverbrauch_W:float, SDL_W:float, Gesamt_W:float} + */ + public static function kombiniere( + float $eigenverbrauchW, + float $sdlW, + array $aggregat + ): array { + $sdl = self::begrenze( + $sdlW, + (float) $aggregat['SDLMaxLaden_W'], + (float) $aggregat['SDLMaxEntladen_W'] + ); + $ev = self::begrenze( + $eigenverbrauchW, + (float) $aggregat['EigenverbrauchMaxLaden_W'], + (float) $aggregat['EigenverbrauchMaxEntladen_W'] + ); + + if ($sdl > 0.0 && $ev > 0.0) { + $ev = min($ev, max(0.0, (float) $aggregat['MaxLaden_W'] - $sdl)); + } elseif ($sdl < 0.0 && $ev < 0.0) { + $ev = max($ev, -max( + 0.0, + (float) $aggregat['MaxEntladen_W'] - abs($sdl) + )); + } + + $gesamt = self::begrenze( + $sdl + $ev, + (float) $aggregat['MaxLaden_W'], + (float) $aggregat['MaxEntladen_W'] + ); + + return [ + 'Eigenverbrauch_W' => $ev, + 'SDL_W' => $sdl, + 'Gesamt_W' => $gesamt, + ]; + } + + /** + * @param list $batterien + * @return array + */ + public static function verteile(float $gesamtW, array $batterien): array + { + $ergebnis = []; + foreach ($batterien as $batterie) { + $ergebnis[(int) $batterie['Index']] = 0.0; + } + if (abs($gesamtW) < 0.5) { + return $ergebnis; + } + + $laden = $gesamtW > 0.0; + usort( + $batterien, + static function (array $a, array $b) use ($laden): int { + $vergleich = (float) $a['Ladezustand_Prozent'] + <=> (float) $b['Ladezustand_Prozent']; + return $laden ? $vergleich : -$vergleich; + } + ); + + $rest = abs($gesamtW); + foreach ($batterien as $batterie) { + if ($rest < 0.5) { + break; + } + $grenze = $laden + ? (float) $batterie['MaxLaden_W'] + : (float) $batterie['MaxEntladen_W']; + $anteil = min($rest, max(0.0, $grenze)); + $ergebnis[(int) $batterie['Index']] = $laden ? $anteil : -$anteil; + $rest -= $anteil; + } + + return $ergebnis; + } + + /** + * Die SDL-Leistung wird bilanziell zuerst bedient. Die Differenz zur + * gemessenen Gesamtleistung wird dem Eigenverbrauch zugeordnet. + * + * @return array{Eigenverbrauch_W:float, SDL_W:float} + */ + public static function ordneIstleistungZu( + float $gesamtIstW, + float $sdlAuftragW + ): array { + return [ + 'Eigenverbrauch_W' => $gesamtIstW - $sdlAuftragW, + 'SDL_W' => $sdlAuftragW, + ]; + } + + private static function begrenze( + float $leistungW, + float $maxLadenW, + float $maxEntladenW + ): float { + return min($maxLadenW, max(-$maxEntladenW, $leistungW)); + } +} diff --git a/VirtuelleBatterie/module.json b/VirtuelleBatterie/module.json new file mode 100644 index 0000000..2dae28f --- /dev/null +++ b/VirtuelleBatterie/module.json @@ -0,0 +1,12 @@ +{ + "id": "{5B2A199B-2226-411F-B83B-FC1CBA58496B}", + "name": "Virtuelle Batterie", + "type": 3, + "vendor": "Enelix", + "aliases": [], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENLVB", + "url": "" +} diff --git a/VirtuelleBatterie/module.php b/VirtuelleBatterie/module.php new file mode 100644 index 0000000..23b99e2 --- /dev/null +++ b/VirtuelleBatterie/module.php @@ -0,0 +1,781 @@ +RegisterPropertyString('Batterieliste', '[]'); + $this->RegisterPropertyInteger('SDLReserveLaden', 0); + $this->RegisterPropertyInteger('SDLReserveEntladen', 0); + $this->RegisterPropertyFloat('Reservezeit', 0.5); + $this->RegisterPropertyInteger('Aktualisierungsintervall', 2); + $this->RegisterPropertyInteger('MesswertMaxAlter', 30); + $this->RegisterPropertyInteger('VorgabeTimeout', 30); + $this->RegisterPropertyFloat('EigenverbrauchStart', 50.0); + $this->RegisterPropertyFloat('SDLStart', 50.0); + $this->RegisterPropertyBoolean('DiagnoseAnzeigen', false); + $this->RegisterPropertyBoolean('LoggingEin', false); + + $this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10); + $this->EnableAction('Aktiv'); + + $this->RegisterVariableInteger( + 'EVManagement', + 'EV Managementregister', + '', + 20 + ); + $this->EnableAction('EVManagement'); + $this->RegisterVariableInteger('EVModus', 'EV Modusregister', '', 30); + $this->EnableAction('EVModus'); + $this->RegisterVariableFloat( + 'EVLadeleistung', + 'EV Laderegister', + '', + 40 + ); + $this->EnableAction('EVLadeleistung'); + $this->RegisterVariableFloat( + 'EVEntladeleistung', + 'EV Entladeregister', + '', + 50 + ); + $this->EnableAction('EVEntladeleistung'); + + $this->RegisterVariableFloat( + 'SDLSollleistung', + 'SDL Sollleistung', + '', + 60 + ); + $this->EnableAction('SDLSollleistung'); + + $this->RegisterVariableFloat( + 'GesamtLadezustand', + 'Gesamtladezustand', + '', + 100 + ); + $this->RegisterVariableFloat( + 'GesamtIstleistung', + 'Gesamte Istleistung', + '', + 110 + ); + $this->RegisterVariableFloat( + 'GesamtMaxLaden', + 'Gesamte maximale Ladeleistung', + '', + 120 + ); + $this->RegisterVariableFloat( + 'GesamtMaxEntladen', + 'Gesamte maximale Entladeleistung', + '', + 130 + ); + + $this->RegisterVariableFloat( + 'EigenverbrauchLadezustand', + 'Eigenverbrauch Ladezustand', + '', + 200 + ); + $this->RegisterVariableFloat( + 'EigenverbrauchSollleistung', + 'Eigenverbrauch Sollleistung', + '', + 210 + ); + $this->RegisterVariableFloat( + 'EigenverbrauchIstleistung', + 'Eigenverbrauch Istleistung', + '', + 220 + ); + $this->RegisterVariableFloat( + 'EigenverbrauchMaxLaden', + 'Eigenverbrauch maximal Laden', + '', + 230 + ); + $this->RegisterVariableFloat( + 'EigenverbrauchMaxEntladen', + 'Eigenverbrauch maximal Entladen', + '', + 240 + ); + + $this->RegisterVariableFloat( + 'SDLLadezustand', + 'SDL Ladezustand', + '', + 300 + ); + $this->RegisterVariableFloat( + 'SDLIstleistung', + 'SDL Istleistung', + '', + 310 + ); + $this->RegisterVariableFloat( + 'SDLMaxLaden', + 'SDL maximal Laden', + '', + 320 + ); + $this->RegisterVariableFloat( + 'SDLMaxEntladen', + 'SDL maximal Entladen', + '', + 330 + ); + + $this->RegisterVariableBoolean('Stoerung', 'Stoerung', '~Switch', 400); + $this->RegisterVariableString('Stoertext', 'Stoertext', '', 410); + $this->RegisterVariableString( + 'Verteilstatus', + 'Verteilstatus', + '', + 420 + ); + + $this->RegisterAttributeString('RegistrierteMesswerte', '[]'); + $this->RegisterAttributeString('LetzteZielwerte', '{}'); + $this->RegisterAttributeFloat('EigenverbrauchEnergie_kWh', 0.0); + $this->RegisterAttributeFloat('SDLEnergie_kWh', 0.0); + $this->RegisterAttributeBoolean('KontenInitialisiert', false); + $this->RegisterAttributeInteger('LetzteIntegration', 0); + $this->RegisterAttributeInteger('LetzteEVVorgabe', 0); + $this->RegisterAttributeInteger('LetzteSDLVorgabe', 0); + $this->RegisterAttributeFloat('EigenverbrauchAuftrag_W', 0.0); + + $this->RegisterTimer( + 'Aktualisierung', + 0, + 'IPS_RequestAction($_IPS["TARGET"], "Aktualisieren", 0);' + ); + $this->RegisterTimer( + 'EVUebernahme', + 0, + 'IPS_RequestAction($_IPS["TARGET"], "EVUebernehmen", 0);' + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $this->SetTimerInterval('Aktualisierung', 0); + $this->SetTimerInterval('EVUebernahme', 0); + $this->registriereMesswertmeldungen(); + + try { + $this->pruefeKonfiguration(); + $this->SetTimerInterval( + 'Aktualisierung', + $this->ReadPropertyInteger('Aktualisierungsintervall') * 1000 + ); + $this->aktualisiere(); + } catch (Throwable $fehler) { + $this->sichererZustand($fehler->getMessage()); + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + } + } + + public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void + { + if ((int) $nachricht !== self::VM_UPDATE) { + return; + } + $ids = json_decode( + $this->ReadAttributeString('RegistrierteMesswerte'), + true + ); + if (is_array($ids) && in_array((int) $senderID, $ids, true)) { + $this->aktualisiere(); + } + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'Aktiv': + $this->SetValue('Aktiv', (bool) $wert); + if (!(bool) $wert) { + $this->WriteAttributeFloat('EigenverbrauchAuftrag_W', 0.0); + $this->SetValue('SDLSollleistung', 0.0); + $this->sichererZustand(''); + $this->SetStatus(self::STATUS_INAKTIV); + return; + } + $this->aktualisiere(); + return; + + case 'EVManagement': + $this->SetValue('EVManagement', (int) $wert); + $this->uebernehmeEVVorgabe(); + return; + + case 'EVModus': + $this->SetValue('EVModus', (int) $wert); + $this->planeEVUebernahme(); + return; + + case 'EVLadeleistung': + $this->SetValue( + 'EVLadeleistung', + max(0.0, (float) $wert) + ); + $this->planeEVUebernahme(); + return; + + case 'EVEntladeleistung': + $this->SetValue( + 'EVEntladeleistung', + max(0.0, (float) $wert) + ); + $this->planeEVUebernahme(); + return; + + case 'SDLSollleistung': + $this->SetValue('SDLSollleistung', (float) $wert); + $this->WriteAttributeInteger('LetzteSDLVorgabe', time()); + $this->aktualisiere(); + return; + + case 'EVUebernehmen': + $this->SetTimerInterval('EVUebernahme', 0); + $this->uebernehmeEVVorgabe(); + return; + + case 'Aktualisieren': + $this->aktualisiere(); + return; + + case 'KontenReset': + $this->WriteAttributeBoolean('KontenInitialisiert', false); + $this->WriteAttributeInteger('LetzteIntegration', time()); + $this->aktualisiere(); + return; + } + + throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); + } + + private function planeEVUebernahme(): void + { + $this->SetTimerInterval('EVUebernahme', 50); + } + + private function uebernehmeEVVorgabe(): void + { + $management = (int) $this->GetValue('EVManagement'); + $modus = (int) $this->GetValue('EVModus'); + $ziel = 0.0; + if ($management === 1) { + $ziel = $modus === 1 + ? -(float) $this->GetValue('EVEntladeleistung') + : (float) $this->GetValue('EVLadeleistung'); + } + + $this->WriteAttributeFloat('EigenverbrauchAuftrag_W', $ziel); + $this->WriteAttributeInteger('LetzteEVVorgabe', time()); + $this->SetValue('EigenverbrauchSollleistung', $ziel); + $this->aktualisiere(); + } + + private function aktualisiere(): void + { + $semaphore = 'EnelixVirtuelleBatterie_' . $this->InstanceID; + if (!IPS_SemaphoreEnter($semaphore, 1000)) { + return; + } + + try { + if (!(bool) $this->GetValue('Aktiv')) { + $this->sichererZustand(''); + $this->SetStatus(self::STATUS_INAKTIV); + return; + } + + $this->pruefeKonfiguration(); + [$batterien, $fehler] = $this->leseBatterien(); + if ($batterien === []) { + throw new RuntimeException( + 'Keine Batterie besitzt aktuelle gueltige Messwerte.' + ); + } + + $aggregat = VirtuelleBatterieRegler::aggregiere( + $batterien, + (float) $this->ReadPropertyInteger('SDLReserveLaden'), + (float) $this->ReadPropertyInteger('SDLReserveEntladen'), + $this->ReadPropertyFloat('Reservezeit') + ); + $this->initialisiereKonten($aggregat); + + $jetzt = time(); + $timeout = $this->ReadPropertyInteger('VorgabeTimeout'); + $ev = $jetzt - $this->ReadAttributeInteger('LetzteEVVorgabe') + <= $timeout + ? $this->ReadAttributeFloat('EigenverbrauchAuftrag_W') + : 0.0; + $sdl = $jetzt - $this->ReadAttributeInteger('LetzteSDLVorgabe') + <= $timeout + ? (float) $this->GetValue('SDLSollleistung') + : 0.0; + + $auftrag = VirtuelleBatterieRegler::kombiniere( + $ev, + $sdl, + $aggregat + ); + $verteilung = VirtuelleBatterieRegler::verteile( + $auftrag['Gesamt_W'], + $batterien + ); + $this->schreibeVerteilung($verteilung); + $zuordnung = VirtuelleBatterieRegler::ordneIstleistungZu( + $aggregat['Istleistung_W'], + $auftrag['SDL_W'] + ); + $integrationsfehler = $this->integriereKonten( + $zuordnung, + $aggregat, + $jetzt + ); + + $this->SetValue( + 'GesamtLadezustand', + $aggregat['Ladezustand_Prozent'] + ); + $this->SetValue( + 'GesamtIstleistung', + $aggregat['Istleistung_W'] + ); + $this->SetValue('GesamtMaxLaden', $aggregat['MaxLaden_W']); + $this->SetValue( + 'GesamtMaxEntladen', + $aggregat['MaxEntladen_W'] + ); + $this->SetValue( + 'EigenverbrauchSollleistung', + $auftrag['Eigenverbrauch_W'] + ); + $this->SetValue( + 'EigenverbrauchIstleistung', + $zuordnung['Eigenverbrauch_W'] + ); + $this->SetValue( + 'EigenverbrauchMaxLaden', + $aggregat['EigenverbrauchMaxLaden_W'] + ); + $this->SetValue( + 'EigenverbrauchMaxEntladen', + $aggregat['EigenverbrauchMaxEntladen_W'] + ); + $this->SetValue('SDLIstleistung', $zuordnung['SDL_W']); + $this->SetValue('SDLMaxLaden', $aggregat['SDLMaxLaden_W']); + $this->SetValue( + 'SDLMaxEntladen', + $aggregat['SDLMaxEntladen_W'] + ); + $this->aktualisiereKontenanzeigen($aggregat); + + $status = [ + 'Eigenverbrauch_W' => round($auftrag['Eigenverbrauch_W'], 1), + 'SDL_W' => round($auftrag['SDL_W'], 1), + 'Gesamt_W' => round($auftrag['Gesamt_W'], 1), + 'Batterien' => count($batterien), + 'Verteilung_W' => $verteilung, + ]; + $this->SetValue( + 'Verteilstatus', + json_encode($status, JSON_THROW_ON_ERROR) + ); + + $fehler = array_values(array_filter(array_merge( + $fehler, + $integrationsfehler === '' ? [] : [$integrationsfehler] + ))); + $this->setzeFehler(implode(' ', $fehler)); + $this->SetStatus( + $fehler === [] + ? self::STATUS_AKTIV + : self::STATUS_MESSWERTE_UNGUELTIG + ); + $this->protokolliere('Verteilung', $status); + } catch (InvalidArgumentException $fehler) { + $this->sichererZustand($fehler->getMessage()); + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + } catch (Throwable $fehler) { + $this->sichererZustand($fehler->getMessage()); + $this->SetStatus(self::STATUS_AUSGABE_FEHLER); + } finally { + IPS_SemaphoreLeave($semaphore); + } + } + + /** + * @return array{0:list>, 1:list} + */ + private function leseBatterien(): array + { + $konfiguration = $this->leseBatterieliste(); + $batterien = []; + $fehler = []; + $maxAlter = $this->ReadPropertyInteger('MesswertMaxAlter'); + + foreach ($konfiguration as $index => $eintrag) { + $name = trim((string) ($eintrag['Name'] ?? '')); + if ($name === '') { + $name = 'Batterie ' . ($index + 1); + } + $socID = (int) ($eintrag['LadezustandVariableID'] ?? 0); + $leistungID = (int) ($eintrag['IstleistungVariableID'] ?? 0); + if (!$this->istAktuellerMesswert($socID, $maxAlter) + || !$this->istAktuellerMesswert($leistungID, $maxAlter) + ) { + $fehler[] = $name . ': Messwerte fehlen oder sind veraltet.'; + continue; + } + + $batterien[] = [ + 'Index' => $index, + 'Name' => $name, + 'Kapazitaet_kWh' => + (float) ($eintrag['Kapazitaet_kWh'] ?? 0.0), + 'MaxLaden_W' => (float) ($eintrag['MaxLaden_W'] ?? 0.0), + 'MaxEntladen_W' => + (float) ($eintrag['MaxEntladen_W'] ?? 0.0), + 'Ladezustand_Prozent' => (float) GetValue($socID), + 'Istleistung_W' => (float) GetValue($leistungID), + ]; + } + + return [$batterien, $fehler]; + } + + /** @return list> */ + private function leseBatterieliste(): array + { + $liste = json_decode( + $this->ReadPropertyString('Batterieliste'), + true, + 512, + JSON_THROW_ON_ERROR + ); + if (!is_array($liste)) { + throw new InvalidArgumentException( + 'Batterieliste muss eine JSON-Liste sein.' + ); + } + + return array_values($liste); + } + + private function pruefeKonfiguration(): void + { + foreach ([ + 'Aktualisierungsintervall', + 'MesswertMaxAlter', + 'VorgabeTimeout', + ] as $property) { + if ($this->ReadPropertyInteger($property) <= 0) { + throw new InvalidArgumentException( + $property . ' muss groesser als 0 sein.' + ); + } + } + if ($this->ReadPropertyFloat('Reservezeit') < 0.0) { + throw new InvalidArgumentException( + 'Reservezeit darf nicht negativ sein.' + ); + } + + $liste = $this->leseBatterieliste(); + if ($liste === []) { + throw new InvalidArgumentException( + 'Mindestens eine physische Batterie ist erforderlich.' + ); + } + + $zielIDs = []; + foreach ($liste as $index => $eintrag) { + $name = 'Batterie ' . ($index + 1); + if ((float) ($eintrag['Kapazitaet_kWh'] ?? 0.0) <= 0.0) { + throw new InvalidArgumentException( + $name . ': Kapazitaet muss groesser als 0 sein.' + ); + } + foreach ([ + 'LadezustandVariableID', + 'IstleistungVariableID', + ] as $feld) { + $this->pruefeNumerischeVariable( + (int) ($eintrag[$feld] ?? 0), + $name . ' ' . $feld, + false + ); + } + $zielID = (int) ($eintrag['SollleistungVariableID'] ?? 0); + $this->pruefeNumerischeVariable( + $zielID, + $name . ' SollleistungVariableID', + true + ); + if (isset($zielIDs[$zielID])) { + throw new InvalidArgumentException( + 'Jede Batterie benoetigt eine eigene Sollleistungsvariable.' + ); + } + $zielIDs[$zielID] = true; + } + } + + private function pruefeNumerischeVariable( + int $id, + string $name, + bool $aktion + ): void { + if ($id <= 0 || !IPS_VariableExists($id)) { + throw new InvalidArgumentException($name . ' ist nicht eingerichtet.'); + } + $variable = IPS_GetVariable($id); + if (!in_array((int) $variable['VariableType'], [1, 2], true)) { + throw new InvalidArgumentException($name . ' muss numerisch sein.'); + } + if ($aktion + && (int) ($variable['VariableAction'] ?? 0) <= 0 + && (int) ($variable['VariableCustomAction'] ?? 0) <= 0 + ) { + throw new InvalidArgumentException( + $name . ' benoetigt eine Variablenaktion.' + ); + } + } + + private function istAktuellerMesswert(int $id, int $maxAlter): bool + { + if ($id <= 0 || !IPS_VariableExists($id)) { + return false; + } + $variable = IPS_GetVariable($id); + if (!in_array((int) $variable['VariableType'], [1, 2], true)) { + return false; + } + $aktualisiert = (int) ($variable['VariableUpdated'] ?? 0); + + return $aktualisiert > 0 && time() - $aktualisiert <= $maxAlter; + } + + /** @param array $verteilung */ + private function schreibeVerteilung(array $verteilung): void + { + $alt = json_decode( + $this->ReadAttributeString('LetzteZielwerte'), + true + ); + if (!is_array($alt)) { + $alt = []; + } + $neu = []; + foreach ($this->leseBatterieliste() as $index => $eintrag) { + $zielID = (int) ($eintrag['SollleistungVariableID'] ?? 0); + $ziel = (float) ($verteilung[$index] ?? 0.0); + $neu[(string) $zielID] = $ziel; + if (!isset($alt[(string) $zielID]) + || abs((float) $alt[(string) $zielID] - $ziel) >= 0.5 + ) { + RequestAction($zielID, $ziel); + } + } + $this->WriteAttributeString( + 'LetzteZielwerte', + json_encode($neu, JSON_THROW_ON_ERROR) + ); + } + + private function sichererZustand(string $fehler): void + { + try { + $this->schreibeVerteilung([]); + } catch (Throwable $ausgabeFehler) { + if ($fehler === '') { + $fehler = $ausgabeFehler->getMessage(); + } else { + $fehler .= ' ' . $ausgabeFehler->getMessage(); + } + } + $this->SetValue('EigenverbrauchSollleistung', 0.0); + $this->SetValue('EigenverbrauchIstleistung', 0.0); + $this->SetValue('SDLIstleistung', 0.0); + $this->setzeFehler($fehler); + } + + /** @param array $aggregat */ + private function initialisiereKonten(array $aggregat): void + { + if ($this->ReadAttributeBoolean('KontenInitialisiert')) { + return; + } + $evProzent = min( + 100.0, + max(0.0, $this->ReadPropertyFloat('EigenverbrauchStart')) + ); + $sdlProzent = min( + 100.0, + max(0.0, $this->ReadPropertyFloat('SDLStart')) + ); + $this->WriteAttributeFloat( + 'EigenverbrauchEnergie_kWh', + $aggregat['EigenverbrauchKapazitaet_kWh'] * $evProzent / 100.0 + ); + $this->WriteAttributeFloat( + 'SDLEnergie_kWh', + $aggregat['SDLKapazitaet_kWh'] * $sdlProzent / 100.0 + ); + $this->WriteAttributeInteger('LetzteIntegration', time()); + $this->WriteAttributeBoolean('KontenInitialisiert', true); + } + + /** + * @param array{Eigenverbrauch_W:float, SDL_W:float} $zuordnung + * @param array $aggregat + */ + private function integriereKonten( + array $zuordnung, + array $aggregat, + int $jetzt + ): string { + $letzteZeit = $this->ReadAttributeInteger('LetzteIntegration'); + $this->WriteAttributeInteger('LetzteIntegration', $jetzt); + if ($letzteZeit <= 0 || $jetzt <= $letzteZeit) { + return ''; + } + $dauer = $jetzt - $letzteZeit; + $maxLuecke = max( + $this->ReadPropertyInteger('MesswertMaxAlter'), + $this->ReadPropertyInteger('Aktualisierungsintervall') * 3 + ); + if ($dauer > $maxLuecke) { + return 'Integrationsluecke erkannt; Konten wurden nicht fortgeschrieben.'; + } + + $evKapazitaet = $aggregat['EigenverbrauchKapazitaet_kWh']; + $sdlKapazitaet = $aggregat['SDLKapazitaet_kWh']; + $evEnergie = $this->ReadAttributeFloat('EigenverbrauchEnergie_kWh') + + $zuordnung['Eigenverbrauch_W'] * $dauer / 3600000.0; + $sdlEnergie = $this->ReadAttributeFloat('SDLEnergie_kWh') + + $zuordnung['SDL_W'] * $dauer / 3600000.0; + $this->WriteAttributeFloat( + 'EigenverbrauchEnergie_kWh', + min($evKapazitaet, max(0.0, $evEnergie)) + ); + $this->WriteAttributeFloat( + 'SDLEnergie_kWh', + min($sdlKapazitaet, max(0.0, $sdlEnergie)) + ); + + return ''; + } + + /** @param array $aggregat */ + private function aktualisiereKontenanzeigen(array $aggregat): void + { + $evKapazitaet = $aggregat['EigenverbrauchKapazitaet_kWh']; + $sdlKapazitaet = $aggregat['SDLKapazitaet_kWh']; + $this->SetValue( + 'EigenverbrauchLadezustand', + $evKapazitaet > 0.0 + ? $this->ReadAttributeFloat('EigenverbrauchEnergie_kWh') + / $evKapazitaet * 100.0 + : 0.0 + ); + $this->SetValue( + 'SDLLadezustand', + $sdlKapazitaet > 0.0 + ? $this->ReadAttributeFloat('SDLEnergie_kWh') + / $sdlKapazitaet * 100.0 + : 0.0 + ); + } + + private function registriereMesswertmeldungen(): void + { + $alteIDs = json_decode( + $this->ReadAttributeString('RegistrierteMesswerte'), + true + ); + if (!is_array($alteIDs)) { + $alteIDs = []; + } + $neueIDs = []; + try { + foreach ($this->leseBatterieliste() as $eintrag) { + foreach ([ + 'LadezustandVariableID', + 'IstleistungVariableID', + ] as $feld) { + $id = (int) ($eintrag[$feld] ?? 0); + if ($id > 0 && IPS_VariableExists($id)) { + $neueIDs[] = $id; + } + } + } + } catch (Throwable $fehler) { + $neueIDs = []; + } + $neueIDs = array_values(array_unique($neueIDs)); + + foreach ($alteIDs as $id) { + if (!in_array((int) $id, $neueIDs, true)) { + $this->UnregisterMessage((int) $id, self::VM_UPDATE); + } + } + foreach ($neueIDs as $id) { + if (!in_array($id, $alteIDs, true)) { + $this->RegisterMessage($id, self::VM_UPDATE); + } + } + $this->WriteAttributeString( + 'RegistrierteMesswerte', + json_encode($neueIDs, JSON_THROW_ON_ERROR) + ); + } + + private function setzeFehler(string $text): void + { + $this->SetValue('Stoerung', $text !== ''); + $this->SetValue('Stoertext', $text); + } + + /** @param mixed $daten */ + private function protokolliere(string $titel, $daten): void + { + if (!$this->ReadPropertyBoolean('LoggingEin')) { + return; + } + $text = is_string($daten) + ? $daten + : json_encode($daten, JSON_THROW_ON_ERROR); + $this->SendDebug($titel, $text, 0); + } +} diff --git a/docs/module/README.md b/docs/module/README.md index 07a776c..4d180e1 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,20 +1,19 @@ # Modulübersicht Enelix Utils -> Status: Verbrauchskostenreport, CC100 Hardware, Energiediagramm und Shelly -> Modul sind implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert. +> Status: Alle dokumentierten Enelix-Utils-Module sind implementiert. | Modul | Herkunft | EMS-Abhängigkeit | | --- | --- | --- | | [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine | -| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit | +| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 (implementiert) | Optionaler Vertrag, keine Code-Abhängigkeit | | [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW (implementiert) | Keine | | [Energiediagramm](Energiediagramm/README.md) | Energy_Pie (implementiert) | Keine | -| [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar | +| [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL (implementiert) | Optional konfigurierbar | | [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine | Utils-Module beeinflussen sich nicht gegenseitig. Prognose, Lizenzierung und Störüberwachung sind keine Module dieses Repositories. -`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Erst nach der -Teamfreigabe erhält ein Modul `module.json`, `form.json`, `module.php` und -zugehörige Unit-Tests. +`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Jedes Modul +besitzt `module.json`, `form.json`, `module.php`, Unit-Tests und einen +registrierten IP-Symcon-Laufzeittest. diff --git a/docs/module/VGT-Schnittstelle/README.md b/docs/module/VGT-Schnittstelle/README.md index f2165a1..5efa6df 100644 --- a/docs/module/VGT-Schnittstelle/README.md +++ b/docs/module/VGT-Schnittstelle/README.md @@ -1,42 +1,103 @@ # VGT-Schnittstelle -> Status: Diskussionsentwurf. Führt `MQTTPVSDL` und die vorhandenen -> Batterie-Felder aus `MQTTBatterySDL` zusammen. +> Status: Implementiert fuer IP-Symcon 8.0. Fuehrt `MQTTPVSDL` und +> `MQTTBatterySDL` zusammen. -Die bestehende MQTT-Arbeitsweise und technische Alt-Idents bleiben erhalten. -Die Geräteart PV oder Batterie steuert nur die sichtbaren Felder. +Die bestehende MQTT-Schnittstelle wurde unveraendert uebernommen. Weder +Topic-Namen noch Request- und Response-Felder wurden erweitert oder +umbenannt. Die Geraeteart bestimmt nur Regelverhalten und sichtbare Messwerte. -## Variablen +## Feste MQTT-Schnittstelle -| Technischer Ident | Typ / Zugriff | Beschreibung | +| Richtung | Topic | +| --- | --- | +| Lesen | `feedback-request/{TopicSuffix}` | +| Leseantwort | `feedback-response/{TopicSuffix}` | +| Steuern | `remote-control-request/{TopicSuffix}` | +| Steuerantwort | `remote-control-response/{TopicSuffix}` | + +Ein Steuerauftrag verwendet weiterhin: + +```json +{ + "power_setpoint": 3500, + "strategy": "activate" +} +``` + +Die Steuerantwort enthaelt weiterhin ausschliesslich +`power_setpoint` und `strategy`. + +Die Leseantwort enthaelt fuer PV: + +- `power_production` +- `is_ready` +- `is_running` + +Bei der Geraeteart Batterie kommen unveraendert hinzu: + +- `state_of_charge` +- `min_soc` +- `max_soc` + +MQTT wird weiterhin mit Pakettyp 3, QoS 0 und `Retain=false` ueber den +IP-Symcon-MQTT-Parent verwendet. + +## Batterie und virtuelle Batterie + +Im Batteriemodus wird `ReqActionID` auf die Variable `SDLSollleistung` +der virtuellen Batterie gelegt. `PowerProductionID` verweist auf +`SDLIstleistung`, `SoCID` auf `SDLLadezustand`. + +Die VGT-Schnittstelle schreibt niemals physische Batterieregister. Die +virtuelle Batterie priorisiert den SDL-Auftrag und verteilt den gemeinsamen +Nettosollwert. + +Die Vorzeichenumkehr von Enelix 1 bleibt erhalten: Bei `activate` wird der +empfangene Batterie-`power_setpoint` mit umgekehrtem Vorzeichen an die +Zielvariable uebergeben. + +## Strategien + +| Strategie | PV | Batterie | | --- | --- | --- | -| `IsReady` | Boolean / Anzeige | Bestehender Bereitschaftsstatus. | -| `IsRunning` | Boolean / Anzeige | Bestehender Bearbeitungsstatus. | -| `MinSoC` | Float / Anzeige | Nur Batterie; untere Ladezustandsgrenze. | -| `MaxSoC` | Float / Anzeige | Nur Batterie; obere Ladezustandsgrenze. | -| `PowerSetpoint` | Integer / bedienbar | Bestehende Leistungsvorgabe und Testaktion. | -| `Strategy` | String / bedienbar | Bestehende Strategie und Testaktion. | -| `LastReadResponse` | String / Anzeige | Letzte Lese-Antwort. | -| `LastWriteResponse` | String / Anzeige | Letzte Steuer-Antwort. | +| `activate` | Setpoint zwischen 0 und Maximum | invertierter Setpoint | +| `stop` | Freigabe auf konfiguriertes Maximum | 0 W | +| `restore` | keine aktive Fernbegrenzung | Regelung auf `TargetSoC` | + +Im Batteriemodus verhindern `MinSoC` und `MaxSoC` eine Vorgabe in die +falsche Richtung an der jeweiligen Ladezustandsgrenze. ## Properties -| Technischer Ident | Typ | Standard / Beschreibung | -| --- | --- | --- | -| `Geraeteart` | Auswahl | `PV`; alternativ `Batterie`. | -| `TopicSuffix` | String | leer; bestehender MQTT-Suffix. | -| `ReqActionID` | Integer | `0`; bestehende Ausgabevariable/Nennleistung. | -| `PowerProductionID` | Integer | `0`; aktuelle SDL-Leistung. | -| `SoCID` | Integer | `0`; nur bei Batterie. | -| `TargetSoC` | Float | `50` %; nur bei Batterie. | -| `ChargePower` | Integer | `2500` W; nur bei Batterie. | -| `DischargePower` | Integer | `2500` W; nur bei Batterie. | -| `MaxPowerSetpoint` | Integer | `10000` W. | +| Property | Standard | Beschreibung | +| --- | ---: | --- | +| `Geraeteart` | PV | PV oder Batterie | +| `TopicSuffix` | leer | Unveraenderter MQTT-Suffix | +| `ReqActionID` | 0 | Bedienbare Zielvariable | +| `PowerProductionID` | 0 | Aktuelle SDL-/PV-Leistung | +| `SoCID` | 0 | Ladezustand im Batteriemodus | +| `TargetSoC` | 50 % | Zielwert fuer `restore` | +| `ChargePower` | 2500 W | Ladeleistung fuer `restore` | +| `DischargePower` | 2500 W | Entladeleistung fuer `restore` | +| `MaxPowerSetpoint` | 10000 W | Begrenzung eingehender Setpoints | +| `LoggingEin` | false | Debug-Ausgaben | -## Verhalten und offene Punkte +## Robustheit -- MQTT-Parent, Auftragsauswertung, Timer, Aktionen und vorhandener Testknopf - bleiben fachlicher Ausgangspunkt. -- Passende MQTT-Verbindung zuordnen oder bei der Instanziierung anlegen. -- Keine neuen Zieladapter, Protokolle oder Datenqualitätsfelder in diesem Schritt. -- Optionale EMS-Anbindung darf keine feste Repository-Abhängigkeit erzeugen. +- Zielvariablen muessen numerisch und bedienbar sein. +- MQTT-Antworten werden in einer FIFO-Warteschlange verarbeitet; schnelle + parallele Anfragen ueberschreiben sich nicht mehr. +- Fehler der Zielaktion werden als Instanzstatus und Stoertext angezeigt. +- Ungueltige JSON-Nutzdaten werden ohne Hardwareaktion verworfen. +- MQTT-Payload und Topic-Vertrag bleiben dabei vollstaendig kompatibel. + +## Inbetriebnahme + +1. Vorhandenen MQTT-Parent zuordnen. +2. Geraeteart und bisherigen `TopicSuffix` uebernehmen. +3. Ziel- und Messvariablen konfigurieren. +4. Im Batteriemodus die Variablen der virtuellen Batterie verwenden. +5. `MinSoC`, `MaxSoC` und `TargetSoC` pruefen. +6. Zuerst `feedback-request`, danach `stop`, `activate` und + gegebenenfalls `restore` testen. diff --git a/docs/module/Virtuelle-Batterie/README.md b/docs/module/Virtuelle-Batterie/README.md index 1520e10..f126d90 100644 --- a/docs/module/Virtuelle-Batterie/README.md +++ b/docs/module/Virtuelle-Batterie/README.md @@ -1,68 +1,115 @@ # Virtuelle Batterie -> Status: Diskussionsentwurf. Übernimmt `Bat_EV_SDL_V4` für die Bereiche -> Eigenverbrauch und SDL. +> Status: Implementiert fuer IP-Symcon 8.0. Adaptiert das Grundkonzept aus +> `Bat_EV_SDL_V4`, ohne dessen herstellerspezifische Typnamen zu uebernehmen. -Batterien und VGT-Kommunikation werden ausgewählt. Das Modul bleibt ohne EMS -nutzbar; eine EMS-Anbindung verwendet optional denselben Nachrichtenvertrag, -ohne Code-Abhängigkeit zwischen den Repositories. +Das Modul fasst mehrere physische Batterien zu genau einer grossen virtuellen +Batterie zusammen. Diese Gesamtbatterie wird in einen Eigenverbrauchs- und +einen SDL-Anteil aufgeteilt. SDL hat bei der Leistungsverteilung Vorrang. -## Variablen +## Verantwortlichkeiten -| Ident | Typ / Zugriff | Beschreibung | -| --- | --- | --- | -| `Aktiv` | Boolean / bedienbar | Koordination aktivieren. | -| `EigenverbrauchSollleistung` | Float / bedingt bedienbar | W; nur bei lokaler Quelle schreiben. | -| `SDLSollleistung` | Float / Anzeige | W; nur aus gültigem zeitlichem Auftrag. | -| `EigenverbrauchIstleistung` | Float / Anzeige | W; zugeordneter Anteil. | -| `SDLIstleistung` | Float / Anzeige | W; zugeordneter Anteil. | -| `GesamtIstleistung` | Float / Anzeige | W; Summe gültiger physischer Messwerte. | -| `Leistungsquelle` | Integer / Anzeige | `0` fehlt, `1` berechnet, `2` gemessen. | -| `EigenverbrauchLadezustand` | Float / Anzeige | Virtueller Füllstand in %. | -| `SDLLadezustand` | Float / Anzeige | Virtueller Füllstand in %. | -| `EigenverbrauchMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. | -| `EigenverbrauchMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. | -| `SDLMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. | -| `SDLMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. | -| `EigenverbrauchStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. | -| `SDLStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. | -| `VirtuelleKontenRuecksetzen` | Boolean / Impuls | `true` löst aus und wird wieder zurückgesetzt. | -| `Managerstatus` | Integer / Anzeige | Nur bei Managerquelle. | -| `VGTStatus` | Integer / Anzeige | Nur bei VGT-Quelle. | -| `Stoerung` | Boolean / Anzeige | Eigene oder zugeordnete Fehler. | -| `Stoertext` | String / Anzeige | Diagnose. | +```text +Enelix Batterie-Modul -> EV-Proxyregister --+ + +-> Virtuelle Batterie -> physische Sollwerte +VGT-Schnittstelle ----> SDLSollleistung ----+ +``` + +Die virtuelle Batterie ist die einzige Instanz, die die konfigurierten +physischen Sollleistungsvariablen beschreibt. Das bestehende EMS-Batteriemodul +regelt den Eigenverbrauchsanteil. Die VGT-Schnittstelle regelt ausschliesslich +den SDL-Anteil. + +Positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen. + +## Physische Batterien + +Die Property `Batterieliste` enthaelt pro Batterie: + +| Feld | Bedeutung | +| --- | --- | +| `Name` | Lesbare Kennung | +| `Kapazitaet_kWh` | Nutzbare Kapazitaet | +| `MaxLaden_W` | Maximale Ladeleistung | +| `MaxEntladen_W` | Maximale Entladeleistung | +| `LadezustandVariableID` | Aktueller Ladezustand in Prozent | +| `IstleistungVariableID` | Gemessene Leistung in W | +| `SollleistungVariableID` | Bedienbare Zielvariable mit Vorzeichen | + +Der Gesamtladezustand ist kapazitaetsgewichtet. Beim Laden werden Batterien mit +tieferem Ladezustand zuerst verwendet, beim Entladen Batterien mit hoeherem +Ladezustand. + +## Anschluss des bestehenden Batteriemoduls + +Eine Enelix-EMS-Instanz **Batterie** wird als herstellerunabhaengige, von Enelix +gesteuerte Batterie eingerichtet. Ihre Messwerte und Register werden auf die +Variablen der virtuellen Batterie gelegt: + +| Property im EMS-Batteriemodul | Variable der virtuellen Batterie | +| --- | --- | +| `MaxLadeleistungVariableID` | `EigenverbrauchMaxLaden` | +| `MaxEntladeleistungVariableID` | `EigenverbrauchMaxEntladen` | +| `LadezustandVariableID` | `EigenverbrauchLadezustand` | +| `IstleistungVariableID` | `EigenverbrauchIstleistung` | +| `ManagementRegisterVariableID` | `EVManagement` | +| `ModusRegisterVariableID` | `EVModus` | +| `LadeleistungRegisterVariableID` | `EVLadeleistung` | +| `EntladeleistungRegisterVariableID` | `EVEntladeleistung` | + +Die Netzleistungsvariable bleibt die reale Messung des Anlagenanschlusspunktes. +Das Batteriemodul schreibt die vier Proxyregister atomar in seiner bestehenden +Reihenfolge. Erst das Managementregister uebernimmt den vollstaendigen +Eigenverbrauchsauftrag. + +## Anschluss der VGT-Schnittstelle + +| Property der VGT-Schnittstelle | Variable der virtuellen Batterie | +| --- | --- | +| `ReqActionID` | `SDLSollleistung` | +| `PowerProductionID` | `SDLIstleistung` | +| `SoCID` | `SDLLadezustand` | + +Damit erreicht kein MQTT-Befehl direkt eine physische Batterie. ## Properties -| Ident | Typ | Standard / Beschreibung | -| --- | --- | --- | -| `Batterieliste` | String/JSON | `[]`; Zielstelle, Kapazität, Grenzen und Messquellen je Batterie. | -| `Eigenverbrauchsquelle` | Integer | `0` lokal, `1` externe Variable, `2` Managervertrag. | -| `EigenverbrauchSollVariableID` | Integer | `0`; Pflicht bei Quelle 1. | -| `SDLQuelle` | Integer | `0` keine, `1` VGT-Instanz, `2` externer Auftragsdatensatz. | -| `VGTInstanzID` | Integer | `0`; Pflicht bei SDLQuelle 1. | -| `SDLAuftragVariableID` | Integer | `0`; Pflicht bei Quelle 2, inklusive Gültigkeit. | -| `SDLReserveLaden` | Float | `0` W. | -| `SDLReserveEntladen` | Float | `0` W. | -| `Reservezeit` | Float | `0.5` h; Bereich 0–24. | -| `Aktualisierungsintervall` | Integer | `2` s. | -| `MesswertMaxAlter` | Integer | `30` s. | -| `Meldeintervall` | Integer | `10` s; bei Vertragsanbindung. | -| `VorgabeTimeout` | Integer | `30` s; bei weitergeleiteten Vorgaben. | -| `AusfallEigenverbrauch` | Integer | `0` auf 0 begrenzen, `1` lokale begrenzte Vorgabe. | -| `AusfallEigenverbrauchLeistung` | Float | `0` W; nur bei Ausfallmodus 1. | -| `FilterAktiv` | Boolean | `true`. | -| `FilterToleranz` | Float | `15` %. | -| `FilterRampe` | Float | `2000` W/s. | -| `FilterTreffer` | Integer | `1`. | -| `Abgleichintervall` | Float | `6` h. | -| `Abgleichtoleranz` | Float | `2` %. | -| `DiagnoseAnzeigen` | Boolean | `false`. | -| `PrioritaetPV` | Integer | `0`; nur bei Manageranbindung. | -| `PrioritaetPeak` | Integer | `0`; nur bei Manageranbindung. | +| Property | Standard | Beschreibung | +| --- | ---: | --- | +| `Batterieliste` | `[]` | Physische Batterien und Zielvariablen | +| `SDLReserveLaden` | 0 W | Fuer SDL reservierte Ladeleistung | +| `SDLReserveEntladen` | 0 W | Fuer SDL reservierte Entladeleistung | +| `Reservezeit` | 0.5 h | Energetische Reserve je Richtung | +| `Aktualisierungsintervall` | 2 s | Zyklische Sicherheitsaktualisierung | +| `MesswertMaxAlter` | 30 s | Zulaessiges Alter physischer Messwerte | +| `VorgabeTimeout` | 30 s | Gueltigkeit der EV- und SDL-Vorgaben | +| `EigenverbrauchStart` | 50 % | Startwert des EV-Kontos | +| `SDLStart` | 50 % | Startwert des SDL-Kontos | +| `LoggingEin` | false | Debug-Ausgaben | -## Verhalten und offene Punkte +## Verhalten und Sicherheit -Eigenverbrauch und SDL ergeben zusammen genau einen begrenzten Batteriesollwert. -Pro physischer Batterie darf nur eine Stelle führen. Messgrundlage und -Aufteilung der berechneten Leistungsanteile sind im Test eindeutig zu belegen. +- SDL wird vor dem Eigenverbrauch auf die reservierte Leistung begrenzt. +- Gleichgerichtete Eigenverbrauchsleistung erhaelt nur die verbleibende + physische Leistung. +- Gegenlaeufige Auftraege werden bilanziell getrennt und physisch als + Nettosollwert verteilt. +- Fehlende oder veraltete Batterien werden aus der aktuellen Aggregation + entfernt und auf 0 W gesetzt. +- Bei Deaktivierung, ungueltiger Konfiguration oder ohne gueltige Batterie + werden alle physischen Sollleistungsvariablen auf 0 W gesetzt. +- Eine erkannte Integrationsluecke wird gemeldet und nicht still auf eine + kuerzere Dauer begrenzt. +- Physische Zielvariablen werden ausschliesslich mit `RequestAction` + geschrieben. + +## Inbetriebnahme + +1. Mess- und Sollleistungsvariablen aller physischen Batterien festlegen. +2. Batterieliste konfigurieren und Messwertalter kontrollieren. +3. SDL-Leistungsreserve und Reservezeit eintragen. +4. Das vorhandene EMS-Batteriemodul auf die EV-Proxyregister konfigurieren. +5. Die VGT-Schnittstelle auf die drei SDL-Variablen konfigurieren. +6. Zuerst mit deaktivierter virtueller Batterie alle Vorzeichen pruefen. +7. Danach Eigenverbrauch und SDL einzeln testen. +8. Erst abschliessend gleich- und gegenlaeufige Auftraege pruefen. diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index 1ca13cb..75b6468 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -13,10 +13,10 @@ final class DokumentationsstrukturTest extends TestCase { return [ 'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'], - 'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'], + 'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Implementiert'], 'CC100 Hardware' => ['CC100-Hardware', 'Status: Implementiert'], 'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'], - 'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'], + 'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Implementiert'], 'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'], ]; } diff --git a/tests/NeueModuleStrukturTest.php b/tests/NeueModuleStrukturTest.php new file mode 100644 index 0000000..aa86769 --- /dev/null +++ b/tests/NeueModuleStrukturTest.php @@ -0,0 +1,91 @@ + */ + public function module(): array + { + return [ + 'Virtuelle Batterie' => [ + 'VirtuelleBatterie', + 'Virtuelle Batterie', + 'ENLVB', + ], + 'VGT-Schnittstelle' => [ + 'VGTSchnittstelle', + 'VGTSchnittstelle', + 'ENLVGT', + ], + ]; + } + + public function testVirtuelleBatterieStelltEVProxyregisterBereit(): void + { + $modul = (string) file_get_contents( + __DIR__ . '/../VirtuelleBatterie/module.php' + ); + foreach ([ + "'EVManagement'", + "'EVModus'", + "'EVLadeleistung'", + "'EVEntladeleistung'", + "'SDLSollleistung'", + 'VirtuelleBatterieRegler::kombiniere', + ] as $bestandteil) { + self::assertStringContainsString($bestandteil, $modul); + } + } + + public function testPhysischeSollwerteWerdenNurPerActionGeschrieben(): void + { + $modul = (string) file_get_contents( + __DIR__ . '/../VirtuelleBatterie/module.php' + ); + $anfang = strpos($modul, 'private function schreibeVerteilung'); + $ende = strpos($modul, 'private function sichererZustand'); + self::assertNotFalse($anfang); + self::assertNotFalse($ende); + $funktion = substr($modul, $anfang, $ende - $anfang); + self::assertStringContainsString('RequestAction(', $funktion); + self::assertStringNotContainsString('SetValue($zielID', $funktion); + } +} diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php index 8ea797a..956375d 100644 --- a/tests/Symcon/manifest.php +++ b/tests/Symcon/manifest.php @@ -15,6 +15,18 @@ return [ 'Energiediagramm/*', ], ], + 'VGTSchnittstelle' => [ + 'file' => __DIR__ . '/modules/VGTSchnittstelle.php', + 'paths' => [ + 'VGTSchnittstelle/*', + ], + ], + 'VirtuelleBatterie' => [ + 'file' => __DIR__ . '/modules/VirtuelleBatterie.php', + 'paths' => [ + 'VirtuelleBatterie/*', + ], + ], 'ShellyModul' => [ 'file' => __DIR__ . '/modules/ShellyModul.php', 'paths' => [ diff --git a/tests/Symcon/modules/VGTSchnittstelle.php b/tests/Symcon/modules/VGTSchnittstelle.php new file mode 100644 index 0000000..785e503 --- /dev/null +++ b/tests/Symcon/modules/VGTSchnittstelle.php @@ -0,0 +1,48 @@ +createInstance( + '{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}', + 'MQTT-Testserver' + ); + $ioId = (int) IPS_GetInstance($mqttId)['ConnectionID']; + if ($ioId > 0 && !isset($instancesBefore[$ioId])) { + IPS_SetParent($ioId, $test->rootId()); + } + + $moduleId = $test->createInstance( + '{4BA1A557-7970-4FB9-93B8-1F6ADA0A5A94}', + 'VGT-Schnittstelle' + ); + if ((int) IPS_GetInstance($moduleId)['ConnectionID'] !== $mqttId) { + IPS_DisconnectInstance($moduleId); + IPS_ConnectInstance($moduleId, $mqttId); + IPS_ApplyChanges($moduleId); + } + + $test->runCase('MQTT-Elterninstanz wird verwendet', static function ( + TestContext $test + ) use ($moduleId, $mqttId): void { + $test->assertSame( + $mqttId, + (int) IPS_GetInstance($moduleId)['ConnectionID'] + ); + }); + + $test->runCase('Konfigurationsformular ist gueltiges JSON', static function ( + TestContext $test + ) use ($moduleId): void { + $form = json_decode( + IPS_GetConfigurationForm($moduleId), + true, + 512, + JSON_THROW_ON_ERROR + ); + $test->assertTrue(is_array($form)); + }); +}; diff --git a/tests/Symcon/modules/VirtuelleBatterie.php b/tests/Symcon/modules/VirtuelleBatterie.php new file mode 100644 index 0000000..e6c76b9 --- /dev/null +++ b/tests/Symcon/modules/VirtuelleBatterie.php @@ -0,0 +1,31 @@ +createInstance( + '{5B2A199B-2226-411F-B83B-FC1CBA58496B}', + 'Virtuelle Batterie' + ); + + $test->runCase('Konfigurationsformular ist gueltiges JSON', static function ( + TestContext $test + ) use ($moduleId): void { + $form = json_decode( + IPS_GetConfigurationForm($moduleId), + true, + 512, + JSON_THROW_ON_ERROR + ); + $test->assertTrue(is_array($form)); + }); + + $test->runCase('Sichere Startkonfiguration ist inaktiv', static function ( + TestContext $test + ) use ($moduleId): void { + $aktivId = IPS_GetObjectIDByIdent('Aktiv', $moduleId); + $test->assertSame(false, GetValue($aktivId)); + }); +}; diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php index f1110bd..8247163 100644 --- a/tests/SymconTestContractTest.php +++ b/tests/SymconTestContractTest.php @@ -58,7 +58,14 @@ final class SymconTestContractTest extends TestCase public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void { self::assertSame( - ['CC100Hardware', 'Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'], + [ + 'CC100Hardware', + 'Energiediagramm', + 'ShellyModul', + 'VGTSchnittstelle', + 'Verbrauchskostenreport', + 'VirtuelleBatterie', + ], $this->resolveAffectedModules(['tests/Symcon/TestContext.php']) ); } diff --git a/tests/VGTReglerTest.php b/tests/VGTReglerTest.php new file mode 100644 index 0000000..a1f6ae0 --- /dev/null +++ b/tests/VGTReglerTest.php @@ -0,0 +1,77 @@ +batterie(0, 10.0, 20.0, 5000.0, 5000.0), + $this->batterie(1, 30.0, 80.0, 10000.0, 8000.0), + ], 3000.0, 2000.0, 0.5); + + self::assertSame(40.0, $aggregat['Kapazitaet_kWh']); + self::assertSame(65.0, $aggregat['Ladezustand_Prozent']); + self::assertSame(15000.0, $aggregat['MaxLaden_W']); + self::assertSame(13000.0, $aggregat['MaxEntladen_W']); + self::assertSame(2.5, $aggregat['SDLKapazitaet_kWh']); + self::assertSame(37.5, $aggregat['EigenverbrauchKapazitaet_kWh']); + } + + public function testSDLHatBeiGleicherRichtungVorrang(): void + { + $aggregat = VirtuelleBatterieRegler::aggregiere([ + $this->batterie(0, 20.0, 50.0, 10000.0, 10000.0), + ], 6000.0, 6000.0, 0.5); + + $auftrag = VirtuelleBatterieRegler::kombiniere( + 8000.0, + 6000.0, + $aggregat + ); + + self::assertSame(6000.0, $auftrag['SDL_W']); + self::assertSame(4000.0, $auftrag['Eigenverbrauch_W']); + self::assertSame(10000.0, $auftrag['Gesamt_W']); + } + + public function testGegenlaeufigeAuftraegeWerdenNettoVerteilt(): void + { + $aggregat = VirtuelleBatterieRegler::aggregiere([ + $this->batterie(0, 20.0, 50.0, 10000.0, 10000.0), + ], 6000.0, 6000.0, 0.5); + + $auftrag = VirtuelleBatterieRegler::kombiniere( + 4000.0, + -3000.0, + $aggregat + ); + + self::assertSame(1000.0, $auftrag['Gesamt_W']); + self::assertSame(-3000.0, $auftrag['SDL_W']); + self::assertSame(4000.0, $auftrag['Eigenverbrauch_W']); + } + + public function testLadenBeginntBeiNiedrigstemLadezustand(): void + { + $verteilung = VirtuelleBatterieRegler::verteile(7000.0, [ + $this->batterie(0, 10.0, 80.0, 5000.0, 5000.0), + $this->batterie(1, 10.0, 20.0, 5000.0, 5000.0), + ]); + + self::assertSame(2000.0, $verteilung[0]); + self::assertSame(5000.0, $verteilung[1]); + } + + public function testIstleistungOrdnetSDLZuerstZu(): void + { + self::assertSame( + ['Eigenverbrauch_W' => 4000.0, 'SDL_W' => -3000.0], + VirtuelleBatterieRegler::ordneIstleistungZu(1000.0, -3000.0) + ); + } + + /** + * @return array{ + * Index:int, + * Name:string, + * Kapazitaet_kWh:float, + * MaxLaden_W:float, + * MaxEntladen_W:float, + * Ladezustand_Prozent:float, + * Istleistung_W:float + * } + */ + private function batterie( + int $index, + float $kapazitaet, + float $ladezustand, + float $maxLaden, + float $maxEntladen + ): array { + return [ + 'Index' => $index, + 'Name' => 'Batterie ' . $index, + 'Kapazitaet_kWh' => $kapazitaet, + 'MaxLaden_W' => $maxLaden, + 'MaxEntladen_W' => $maxEntladen, + 'Ladezustand_Prozent' => $ladezustand, + 'Istleistung_W' => 0.0, + ]; + } +}