From d4828f1ee5659dabbae8bcfa98e4641bb72412ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=A4fliger?= Date: Thu, 17 Sep 2026 09:32:17 +0000 Subject: [PATCH] Verbraucher 1-Stufig eventbasiert implementieren --- Manager/module.php | 1 + README.md | 9 +- VerbraucherEinStufig/form.json | 108 +++ VerbraucherEinStufig/module.json | 14 + VerbraucherEinStufig/module.php | 861 ++++++++++++++++++ ...0002-verbraucher-einstufig-eventbasiert.md | 57 ++ docs/migration/Verbraucher-1-Stufig.md | 60 ++ docs/module/README.md | 6 +- docs/module/Verbraucher-1-Stufig/README.md | 164 +++- libs/EinStufigRegler.php | 144 +++ tests/DokumentationsstrukturTest.php | 10 +- tests/EinStufigReglerTest.php | 115 +++ .../VerbraucherEinStufigModulstrukturTest.php | 132 +++ 13 files changed, 1652 insertions(+), 29 deletions(-) create mode 100644 VerbraucherEinStufig/form.json create mode 100644 VerbraucherEinStufig/module.json create mode 100644 VerbraucherEinStufig/module.php create mode 100644 docs/adr/0002-verbraucher-einstufig-eventbasiert.md create mode 100644 docs/migration/Verbraucher-1-Stufig.md create mode 100644 libs/EinStufigRegler.php create mode 100644 tests/EinStufigReglerTest.php create mode 100644 tests/VerbraucherEinStufigModulstrukturTest.php diff --git a/Manager/module.php b/Manager/module.php index dffb9a9..3d76a1b 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -19,6 +19,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const VERBRAUCHER_MODUL_IDS = [ '{B7C54AF4-AD7D-4FE4-B75D-203693906251}', '{15879A4E-D0C2-4495-83DE-46E1E462591E}', + '{15879A4E-D0C2-4495-83DE-46E1E462591E}', ]; private const MONATSNAMEN = [ 1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', diff --git a/README.md b/README.md index a648b7f..8aed29d 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon. ## Status Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die -Verbraucherbasis, der Manager und der mehrstufige Warmwassererwaermer sind -implementiert. Die weiteren Module sind als Diskussionsentwürfe dokumentiert. +Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer und der +1-stufige Verbraucher sind implementiert. Die weiteren Module sind als +Diskussionsentwürfe dokumentiert. ## Geplante Module @@ -14,7 +15,7 @@ implementiert. Die weiteren Module sind als Diskussionsentwürfe dokumentiert. - Batterie - Wassererwaermer (implementiert) - Pufferspeicher -- Verbraucher 1-Stufig +- Verbraucher 1-Stufig (implementiert) - Waermepumpe - Ladestation Stand-Alone - Ladestation Gateway @@ -51,6 +52,8 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche - [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md) - [Manager-Modul](docs/module/Manager/README.md) - [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md) +- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md) - [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md) +- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md) - [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md) - [Modulübersicht](docs/module/README.md) diff --git a/VerbraucherEinStufig/form.json b/VerbraucherEinStufig/form.json new file mode 100644 index 0000000..371b682 --- /dev/null +++ b/VerbraucherEinStufig/form.json @@ -0,0 +1,108 @@ +{ + "elements": [ + { + "type": "ExpansionPanel", + "caption": "Manager und Zeitverhalten", + "expanded": true, + "items": [ + { + "type": "NumberSpinner", + "name": "PrioritaetPV", + "caption": "Prioritaet PV", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "PrioritaetPeak", + "caption": "Prioritaet Peak", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "Meldeintervall", + "caption": "Meldeintervall", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "VorgabeTimeout", + "caption": "Vorgabe-Timeout", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "Umschaltabstand", + "caption": "Zeit bis zum naechsten Lastwechsel", + "suffix": " s", + "minimum": 0 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Verbraucher", + "expanded": true, + "items": [ + { + "type": "NumberSpinner", + "name": "Nennleistung", + "caption": "Nennleistung", + "suffix": " W", + "minimum": 1 + }, + { + "type": "SelectVariable", + "name": "SchaltkontaktVariableID", + "caption": "Schaltkontakt", + "validVariableTypes": [ + 0 + ] + }, + { + "type": "CheckBox", + "name": "SchaltkontaktInvertiert", + "caption": "Schaltkontakt invertieren" + }, + { + "type": "SelectVariable", + "name": "RueckmeldungVariableID", + "caption": "Optionale Schaltzustands-Rueckmeldung", + "validVariableTypes": [ + 0 + ] + }, + { + "type": "NumberSpinner", + "name": "Mindestlaufzeit", + "caption": "Taegliche Mindestlaufzeit", + "suffix": " s", + "minimum": 0, + "maximum": 86400 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Darstellung und Diagnose", + "items": [ + { + "type": "CheckBox", + "name": "EinstellungenInVisu", + "caption": "Lokale Einstellungen in der Visualisierung anzeigen" + }, + { + "type": "CheckBox", + "name": "DiagnosevariablenAnzeigen", + "caption": "Diagnosevariablen anzeigen" + }, + { + "type": "CheckBox", + "name": "LoggingEin", + "caption": "Diagnoseprotokoll aktivieren" + } + ] + } + ] +} diff --git a/VerbraucherEinStufig/module.json b/VerbraucherEinStufig/module.json new file mode 100644 index 0000000..f512351 --- /dev/null +++ b/VerbraucherEinStufig/module.json @@ -0,0 +1,14 @@ +{ + "id": "{15879A4E-D0C2-4495-83DE-46E1E462591E}", + "name": "VerbraucherEinStufig", + "type": 3, + "vendor": "Enelix", + "aliases": [ + "Verbraucher 1-Stufig" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENELIX", + "url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/VerbraucherEinStufig" +} diff --git a/VerbraucherEinStufig/module.php b/VerbraucherEinStufig/module.php new file mode 100644 index 0000000..1858320 --- /dev/null +++ b/VerbraucherEinStufig/module.php @@ -0,0 +1,861 @@ + */ + private const DIAGNOSE_VARIABLEN = [ + 'Istleistung', + 'Leistungsquelle', + 'Sollleistung', + 'SollwertGueltig', + 'Verfuegbar', + 'AenderungMoeglich', + 'Stoerung', + 'Stoertext', + 'Rueckmeldefehler', + ]; + + public function Create(): void + { + parent::Create(); + + $this->registriereVerbraucherBasis(); + $this->RegisterPropertyInteger('Nennleistung', 0); + $this->RegisterPropertyInteger('SchaltkontaktVariableID', 0); + $this->RegisterPropertyBoolean('SchaltkontaktInvertiert', false); + $this->RegisterPropertyInteger('RueckmeldungVariableID', 0); + $this->RegisterPropertyInteger('Mindestlaufzeit', 0); + $this->RegisterPropertyInteger('Umschaltabstand', 5); + $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); + + $this->RegisterVariableBoolean('Schaltzustand', 'Schaltzustand', '~Switch', 100); + $this->RegisterVariableInteger('Tageslaufzeit', 'Tageslaufzeit', '', 110); + + $this->RegisterAttributeInteger('RegistrierterSchaltkontakt', 0); + $this->RegisterAttributeInteger('RegistrierteRueckmeldung', 0); + $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); + $this->RegisterAttributeInteger('LetzteManagerID', 0); + $this->RegisterAttributeInteger('GesperrtBis', 0); + $this->RegisterAttributeInteger('LaufzeitStandZeit', 0); + $this->RegisterAttributeString('LaufzeitTag', ''); + $this->RegisterAttributeString('Leistungsangebot', '[0]'); + $this->RegisterAttributeBoolean('Initialisiert', false); + $this->RegisterAttributeBoolean('SchaltvorgangLaeuft', false); + $this->RegisterAttributeBoolean('SchaltpruefungAusstehend', false); + $this->RegisterAttributeBoolean('Schaltziel', false); + $this->RegisterAttributeString('Schaltfehler', ''); + + $this->RegisterAttributeFloat('ZustandIstleistung', 0.0); + $this->RegisterAttributeInteger('ZustandSollleistung', 0); + $this->RegisterAttributeBoolean('ZustandSollwertGueltig', false); + $this->RegisterAttributeBoolean('ZustandVerfuegbar', false); + $this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false); + $this->RegisterAttributeBoolean('ZustandStoerung', false); + $this->RegisterAttributeString('ZustandStoertext', ''); + $this->RegisterAttributeBoolean('ZustandSchaltzustand', false); + $this->RegisterAttributeInteger('ZustandTageslaufzeit', 0); + $this->RegisterAttributeBoolean('ZustandRueckmeldefehler', false); + + $this->RegisterTimer( + 'Meldezyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);" + ); + $this->RegisterTimer( + 'RueckmeldungVerzoegert', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);" + ); + $this->RegisterTimer( + 'Umschaltfreigabe', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Umschaltfreigabe', 0);" + ); + $this->RegisterTimer( + 'VorgabeTimeout', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', 0);" + ); + $this->RegisterTimer( + 'Tagesplanung', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Tagesplanung', 0);" + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $this->aktualisiereVariablen(); + $this->registriereMeldungen(); + + try { + $this->pruefeKonfiguration(); + } catch (Throwable $fehler) { + $this->deaktiviereTimer(); + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeStoerung($fehler->getMessage(), false); + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + $this->protokolliere('Konfiguration', $fehler->getMessage()); + return; + } + + $this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000); + $this->initialisiereZustand(); + $this->aktualisiere(true); + } + + public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void + { + if ((int) $nachricht !== self::VM_UPDATE || $this->ReadAttributeBoolean('SchaltvorgangLaeuft')) { + return; + } + + $relevanteIDs = array_filter([ + $this->ReadPropertyInteger('SchaltkontaktVariableID'), + $this->ReadPropertyInteger('RueckmeldungVariableID'), + ]); + if (in_array((int) $senderID, $relevanteIDs, true)) { + $this->aktualisiere(true); + } + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'Aktiv': + $this->SetValue('Aktiv', (bool) $wert); + if (!(bool) $wert) { + $this->setzeZustand('SollwertGueltig', false); + $this->SetTimerInterval('VorgabeTimeout', 0); + } + $this->aktualisiere(true); + return; + + case 'Melden': + if ((bool) $wert) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 0); + } + $this->aktualisiere(false); + $this->sendeVerbraucherdaten(); + return; + + case 'Umschaltfreigabe': + case 'VorgabeTimeout': + case 'Tagesplanung': + $this->SetTimerInterval((string) $ident, 0); + $this->aktualisiere(true); + return; + + case 'ManagerdatenEmpfangen': + if (!is_string($wert)) { + throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.'); + } + $daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.'); + } + $this->ManagerdatenEmpfangen($daten); + return; + } + + throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); + } + + /** @param array $daten */ + public function ManagerdatenEmpfangen(array $daten): void + { + Nachrichtenvertrag::pruefeManagerdaten($daten); + if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { + throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.'); + } + + $managerID = $daten['Kopf']['AbsenderID']; + if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) { + throw new InvalidArgumentException('Der Manager hat diesen Verbraucher nicht aktiv zugeordnet.'); + } + + $this->aktualisiere(false); + $sollleistung = $daten['Sollleistung_W']; + if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) { + throw new InvalidArgumentException( + 'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.' + ); + } + + $this->WriteAttributeInteger('LetzteManagerID', $managerID); + $this->WriteAttributeInteger('LetzteVorgabeZeit', time()); + $this->setzeZustand('Sollleistung', $sollleistung); + $this->setzeZustand('SollwertGueltig', true); + $this->planeVorgabeTimeout(time()); + $this->aktualisiere(false); + $this->planeMeldung(); + } + + private function aktualisiere(bool $meldungPlanen): void + { + try { + $jetzt = time(); + $this->aktualisiereTageslaufzeit($jetzt); + $vorherigerSchaltzustand = (bool) $this->leseZustand('Schaltzustand'); + $schaltzustand = $this->leseSchaltzustand(); + + if ($schaltzustand !== $vorherigerSchaltzustand) { + $this->setzeSchaltzustand($schaltzustand); + if ($this->ReadAttributeBoolean('Initialisiert') + && !$this->ReadAttributeBoolean('SchaltpruefungAusstehend') + ) { + $this->starteUmschaltsperre($jetzt); + } + } + + $this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt); + $this->pruefeVorgabeTimeout($jetzt); + if ((bool) $this->leseZustand('SollwertGueltig') + && !in_array( + (int) $this->leseZustand('Sollleistung'), + [0, $this->ReadPropertyInteger('Nennleistung')], + true + ) + ) { + $this->setzeZustand('SollwertGueltig', false); + } + + $aktiv = (bool) $this->GetValue('Aktiv'); + $fehlerfrei = $this->ReadAttributeString('Schaltfehler') === '' + && !(bool) $this->leseZustand('Rueckmeldefehler'); + $verfuegbar = $aktiv && $fehlerfrei; + $mindestlaufzeitErzwungen = $verfuegbar + && $this->mussMindestlaufzeitErzwingen($jetzt); + $gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis'); + + $ziel = 0; + if ($verfuegbar) { + if ($mindestlaufzeitErzwungen) { + $ziel = $this->ReadPropertyInteger('Nennleistung'); + } elseif ((bool) $this->leseZustand('SollwertGueltig')) { + $ziel = (int) $this->leseZustand('Sollleistung'); + } + } + + $istleistung = $this->leistungFuerSchaltzustand($schaltzustand); + if ($ziel !== $istleistung && (!$aktiv || (!$gesperrt && $fehlerfrei))) { + $this->schalte($ziel > 0, $jetzt); + $schaltzustand = $this->leseSchaltzustand(); + $this->setzeSchaltzustand($schaltzustand); + $istleistung = $this->leistungFuerSchaltzustand($schaltzustand); + $gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis'); + } + + $this->setzeZustand('Sollleistung', $ziel); + $this->setzeZustand('Verfuegbar', $verfuegbar); + $aenderungMoeglich = $verfuegbar + && !$mindestlaufzeitErzwungen + && !$gesperrt + && !$this->ReadAttributeBoolean('SchaltpruefungAusstehend'); + $this->setzeZustand('AenderungMoeglich', $aenderungMoeglich); + + $angebot = EinStufigRegler::leistungsangebot( + $this->ReadPropertyInteger('Nennleistung'), + $istleistung, + $verfuegbar, + $aenderungMoeglich, + $mindestlaufzeitErzwungen + ); + $this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR)); + $this->aktualisiereStoerung(); + $this->planeTimer($jetzt); + $this->SetStatus( + $this->ReadAttributeString('Schaltfehler') === '' + && !(bool) $this->leseZustand('Rueckmeldefehler') + ? self::STATUS_AKTIV + : self::STATUS_SCHALTFEHLER + ); + + if ($meldungPlanen) { + $this->planeMeldung(); + } + } catch (Throwable $fehler) { + $this->WriteAttributeString('Schaltfehler', $fehler->getMessage()); + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeStoerung($fehler->getMessage(), false); + $this->SetStatus(self::STATUS_SCHALTFEHLER); + $this->protokolliere('Aktualisierung', $fehler->getMessage()); + } + } + + private function schalte(bool $einschalten, int $jetzt): void + { + $kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID'); + $aktorwert = $einschalten !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert'); + + $this->WriteAttributeBoolean('Schaltziel', $einschalten); + $this->WriteAttributeBoolean('SchaltpruefungAusstehend', true); + $this->WriteAttributeBoolean('SchaltvorgangLaeuft', true); + try { + RequestAction($kontaktID, $aktorwert); + } catch (Throwable $fehler) { + $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + throw new RuntimeException('Schaltkontakt konnte nicht gesetzt werden: ' . $fehler->getMessage()); + } finally { + $this->WriteAttributeBoolean('SchaltvorgangLaeuft', false); + } + + $this->starteUmschaltsperre($jetzt); + $istzustand = $this->leseSchaltzustand(); + if ($istzustand === $einschalten) { + $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + $this->setzeZustand('Rueckmeldefehler', false); + } elseif ($this->ReadPropertyInteger('RueckmeldungVariableID') <= 0) { + $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + throw new RuntimeException('Schaltkontakt hat den angeforderten Zustand nicht uebernommen.'); + } elseif ($this->ReadPropertyInteger('Umschaltabstand') === 0) { + $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + $this->setzeZustand('Rueckmeldefehler', true); + } + + $this->WriteAttributeString('Schaltfehler', ''); + $this->protokolliere('Lastwechsel', ['Eingeschaltet' => $einschalten]); + } + + private function pruefeAusstehendenSchaltvorgang(bool $schaltzustand, int $jetzt): void + { + if (!$this->ReadAttributeBoolean('SchaltpruefungAusstehend')) { + if ((bool) $this->leseZustand('Rueckmeldefehler') + && $schaltzustand === $this->ReadAttributeBoolean('Schaltziel') + ) { + $this->setzeZustand('Rueckmeldefehler', false); + } + return; + } + if ($schaltzustand === $this->ReadAttributeBoolean('Schaltziel')) { + $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + $this->setzeZustand('Rueckmeldefehler', false); + return; + } + if ($jetzt >= $this->ReadAttributeInteger('GesperrtBis')) { + $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + $this->setzeZustand('Rueckmeldefehler', true); + } + } + + private function starteUmschaltsperre(int $jetzt): void + { + $gesperrtBis = $jetzt + $this->ReadPropertyInteger('Umschaltabstand'); + $this->WriteAttributeInteger('GesperrtBis', $gesperrtBis); + } + + private function aktualisiereTageslaufzeit(int $jetzt): void + { + $werte = EinStufigRegler::aktualisiereTageslaufzeit( + $this->ReadAttributeString('LaufzeitTag'), + (int) $this->leseZustand('Tageslaufzeit'), + $this->ReadAttributeInteger('LaufzeitStandZeit'), + $jetzt, + (bool) $this->leseZustand('Schaltzustand'), + $this->zeitzone() + ); + + $this->WriteAttributeString('LaufzeitTag', $werte['Tag']); + $this->WriteAttributeInteger('LaufzeitStandZeit', $werte['StandZeit']); + $this->setzeZustand('Tageslaufzeit', $werte['Sekunden']); + } + + private function mussMindestlaufzeitErzwingen(int $jetzt): bool + { + return EinStufigRegler::mussMindestlaufzeitErzwingen( + (int) $this->leseZustand('Tageslaufzeit'), + $this->ReadPropertyInteger('Mindestlaufzeit'), + EinStufigRegler::sekundenBisTagesende($jetzt, $this->zeitzone()) + ); + } + + private function pruefeVorgabeTimeout(int $jetzt): void + { + if (!(bool) $this->leseZustand('SollwertGueltig')) { + return; + } + + $letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit'); + if ($letzteVorgabe <= 0 + || $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout') + ) { + $this->setzeZustand('SollwertGueltig', false); + $this->SetTimerInterval('VorgabeTimeout', 0); + } + } + + private function planeTimer(int $jetzt): void + { + $gesperrtBis = $this->ReadAttributeInteger('GesperrtBis'); + $this->SetTimerInterval( + 'Umschaltfreigabe', + $gesperrtBis > $jetzt ? max(1, ($gesperrtBis - $jetzt) * 1000) : 0 + ); + $this->planeVorgabeTimeout($jetzt); + + $bisTagesende = EinStufigRegler::sekundenBisTagesende($jetzt, $this->zeitzone()); + $naechstePruefung = EinStufigRegler::sekundenBisNaechsteTagespruefung( + (int) $this->leseZustand('Tageslaufzeit'), + $this->ReadPropertyInteger('Mindestlaufzeit'), + $bisTagesende, + (bool) $this->leseZustand('Schaltzustand') + ); + $this->SetTimerInterval('Tagesplanung', $naechstePruefung * 1000); + } + + private function planeVorgabeTimeout(int $jetzt): void + { + if (!(bool) $this->leseZustand('SollwertGueltig')) { + $this->SetTimerInterval('VorgabeTimeout', 0); + return; + } + + $ablauf = $this->ReadAttributeInteger('LetzteVorgabeZeit') + + $this->ReadPropertyInteger('VorgabeTimeout'); + $this->SetTimerInterval('VorgabeTimeout', max(1, ($ablauf - $jetzt) * 1000)); + } + + private function planeMeldung(): void + { + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + + private function initialisiereZustand(): void + { + $jetzt = time(); + $schaltzustand = $this->leseSchaltzustand(); + + if (!$this->ReadAttributeBoolean('Initialisiert')) { + $this->WriteAttributeString('LaufzeitTag', date('Y-m-d', $jetzt)); + $this->WriteAttributeInteger('LaufzeitStandZeit', $jetzt); + $this->setzeZustand('Tageslaufzeit', 0); + $this->setzeZustand('SollwertGueltig', false); + $this->setzeZustand('Rueckmeldefehler', false); + $this->WriteAttributeBoolean('Initialisiert', true); + } + $this->setzeSchaltzustand($schaltzustand); + } + + private function setzeSchaltzustand(bool $eingeschaltet): void + { + $this->setzeZustand('Schaltzustand', $eingeschaltet); + $this->setzeZustand('Istleistung', (float) $this->leistungFuerSchaltzustand($eingeschaltet)); + $this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET); + } + + private function leseSchaltzustand(): bool + { + $rueckmeldungID = $this->ReadPropertyInteger('RueckmeldungVariableID'); + if ($rueckmeldungID > 0) { + return (bool) GetValue($rueckmeldungID); + } + + $kontaktwert = (bool) GetValue($this->ReadPropertyInteger('SchaltkontaktVariableID')); + + return $kontaktwert !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert'); + } + + private function leistungFuerSchaltzustand(bool $eingeschaltet): int + { + return $eingeschaltet ? $this->ReadPropertyInteger('Nennleistung') : 0; + } + + private function aktualisiereStoerung(): void + { + $texte = []; + if ($this->ReadAttributeString('Schaltfehler') !== '') { + $texte[] = $this->ReadAttributeString('Schaltfehler'); + } + if ((bool) $this->leseZustand('Rueckmeldefehler')) { + $texte[] = 'Schaltzustands-Rueckmeldung entspricht nicht dem angeforderten Zustand.'; + } + $this->setzeStoerung(implode("\n", $texte), $texte === []); + } + + private function setzeStoerung(string $text, bool $fehlerfrei): void + { + $this->setzeZustand('Stoerung', !$fehlerfrei); + $this->setzeZustand('Stoertext', $text); + } + + private function sendeVerbraucherdaten(): void + { + foreach ($this->zugeordneteManagerIDs() as $managerID) { + $daten = $this->baueVerbraucherdaten($managerID); + Nachrichtenvertrag::pruefeVerbraucherdaten($daten); + try { + IPS_RequestAction( + $managerID, + 'VerbraucherdatenEmpfangen', + json_encode($daten, JSON_THROW_ON_ERROR) + ); + } catch (Throwable $fehler) { + $this->protokolliere('Managerkommunikation', $fehler->getMessage()); + } + } + } + + /** @return array */ + private function baueVerbraucherdaten(int $managerID): array + { + return [ + 'Kopf' => [ + 'Version' => Nachrichtenvertrag::VERSION, + 'AbsenderID' => $this->InstanceID, + 'EmpfaengerID' => $managerID, + 'Zeitpunkt' => time(), + ], + 'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'), + 'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'), + 'Leistungswerte_W' => $this->leseLeistungsangebot(), + 'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'), + 'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'), + 'Istleistung_W' => (float) $this->leseZustand('Istleistung'), + 'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET, + 'Zustand' => [ + [ + 'Kennung' => 'Sollleistung_W', + 'Art' => 'Sollwert', + 'Wert' => (bool) $this->leseZustand('SollwertGueltig') + ? (int) $this->leseZustand('Sollleistung') + : null, + 'Einheit' => 'W', + ], + [ + 'Kennung' => 'Schaltzustand', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('Schaltzustand'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Tageslaufzeit_s', + 'Art' => 'Istwert', + 'Wert' => (int) $this->leseZustand('Tageslaufzeit'), + 'Einheit' => 's', + ], + [ + 'Kennung' => 'Rueckmeldefehler', + 'Art' => 'Stoerung', + 'Wert' => (bool) $this->leseZustand('Rueckmeldefehler'), + 'Einheit' => '', + 'Text' => (bool) $this->leseZustand('Rueckmeldefehler') + ? 'Schaltzustands-Rueckmeldung entspricht nicht dem angeforderten Zustand.' + : '', + ], + ], + ]; + } + + /** @return list */ + private function zugeordneteManagerIDs(): array + { + $ergebnis = []; + foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) { + try { + $automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche'); + $property = $automatisch + ? 'AutomatischeVerbraucherZuordnung' + : 'VerbraucherZuordnung'; + $zuordnung = $this->leseManagerZuordnung($managerID, $property); + if ($automatisch && $zuordnung === []) { + $zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung'); + } + } catch (Throwable $fehler) { + continue; + } + + foreach ($zuordnung as $eintrag) { + if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) { + continue; + } + $instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null; + if ($instanzID === $this->InstanceID) { + $ergebnis[] = (int) $managerID; + break; + } + } + } + + return array_values(array_unique($ergebnis)); + } + + /** @return array */ + private function leseManagerZuordnung(int $managerID, string $property): array + { + $zuordnung = json_decode( + (string) IPS_GetProperty($managerID, $property), + true, + 512, + JSON_THROW_ON_ERROR + ); + + return is_array($zuordnung) ? $zuordnung : []; + } + + private function pruefeKonfiguration(): void + { + foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) { + if ($this->ReadPropertyInteger($property) < 0) { + throw new InvalidArgumentException($property . ' muss mindestens 0 sein.'); + } + } + foreach (['Meldeintervall', 'VorgabeTimeout'] as $property) { + if ($this->ReadPropertyInteger($property) <= 0) { + throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); + } + } + if ($this->ReadPropertyInteger('Nennleistung') <= 0) { + throw new InvalidArgumentException('Nennleistung muss groesser als 0 sein.'); + } + if ($this->ReadPropertyInteger('Mindestlaufzeit') < 0 + || $this->ReadPropertyInteger('Mindestlaufzeit') > 86400 + ) { + throw new InvalidArgumentException('Mindestlaufzeit muss zwischen 0 und 86400 Sekunden liegen.'); + } + if ($this->ReadPropertyInteger('Umschaltabstand') < 0) { + throw new InvalidArgumentException('Umschaltabstand darf nicht negativ sein.'); + } + + $kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID'); + $this->pruefeBooleanVariable($kontaktID, 'Schaltkontakt', true); + + $rueckmeldungID = $this->ReadPropertyInteger('RueckmeldungVariableID'); + if ($rueckmeldungID > 0) { + $this->pruefeBooleanVariable($rueckmeldungID, 'Rueckmeldung', false); + } + } + + private function pruefeBooleanVariable(int $variableID, string $bezeichnung, bool $aktionErforderlich): void + { + if ($variableID <= 0 || !IPS_VariableExists($variableID)) { + throw new InvalidArgumentException($bezeichnung . ' ist nicht eingerichtet.'); + } + $variable = IPS_GetVariable($variableID); + if ((int) $variable['VariableType'] !== 0) { + throw new InvalidArgumentException($bezeichnung . ' muss eine Booleanvariable sein.'); + } + if ($aktionErforderlich + && (int) $variable['VariableAction'] <= 0 + && (int) $variable['VariableCustomAction'] <= 0 + ) { + throw new InvalidArgumentException($bezeichnung . ' benoetigt eine Standard- oder benutzerdefinierte Aktion.'); + } + } + + private function registriereMeldungen(): void + { + $alteIDs = array_unique(array_filter([ + $this->ReadAttributeInteger('RegistrierterSchaltkontakt'), + $this->ReadAttributeInteger('RegistrierteRueckmeldung'), + ])); + $neueIDs = array_unique(array_filter([ + $this->ReadPropertyInteger('SchaltkontaktVariableID'), + $this->ReadPropertyInteger('RueckmeldungVariableID'), + ])); + + foreach ($alteIDs as $alteID) { + if (!in_array($alteID, $neueIDs, true)) { + $this->UnregisterMessage($alteID, self::VM_UPDATE); + } + } + foreach ($neueIDs as $neueID) { + if (IPS_VariableExists($neueID)) { + $this->RegisterMessage($neueID, self::VM_UPDATE); + } + } + + $this->WriteAttributeInteger( + 'RegistrierterSchaltkontakt', + $this->ReadPropertyInteger('SchaltkontaktVariableID') + ); + $this->WriteAttributeInteger( + 'RegistrierteRueckmeldung', + $this->ReadPropertyInteger('RueckmeldungVariableID') + ); + } + + private function aktualisiereVariablen(): void + { + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + if ($ident === 'Leistungsquelle') { + continue; + } + $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { + $this->setzeZustand($ident, GetValue($variablenID)); + } + } + + if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { + $this->registriereVerbraucherDiagnose(); + $this->RegisterVariableBoolean('Rueckmeldefehler', 'Rueckmeldefehler', '~Alert', 200); + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + $wert = $ident === 'Leistungsquelle' + ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET + : $this->leseZustand($ident); + $this->setzeZustand($ident, $wert); + } + } else { + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { + $this->UnregisterVariable($ident); + } + } + } + } + + /** @param bool|int|float|string $wert */ + private function setzeZustand(string $ident, $wert): void + { + $booleanAttribute = [ + 'SollwertGueltig' => 'ZustandSollwertGueltig', + 'Verfuegbar' => 'ZustandVerfuegbar', + 'AenderungMoeglich' => 'ZustandAenderungMoeglich', + 'Stoerung' => 'ZustandStoerung', + 'Schaltzustand' => 'ZustandSchaltzustand', + 'Rueckmeldefehler' => 'ZustandRueckmeldefehler', + ]; + $integerAttribute = [ + 'Sollleistung' => 'ZustandSollleistung', + 'Tageslaufzeit' => 'ZustandTageslaufzeit', + ]; + $floatAttribute = [ + 'Istleistung' => 'ZustandIstleistung', + ]; + $stringAttribute = [ + 'Stoertext' => 'ZustandStoertext', + ]; + + if (isset($booleanAttribute[$ident])) { + $wert = (bool) $wert; + $this->WriteAttributeBoolean($booleanAttribute[$ident], $wert); + } elseif (isset($integerAttribute[$ident])) { + $wert = (int) $wert; + $this->WriteAttributeInteger($integerAttribute[$ident], $wert); + } elseif (isset($floatAttribute[$ident])) { + $wert = (float) $wert; + $this->WriteAttributeFloat($floatAttribute[$ident], $wert); + } elseif (isset($stringAttribute[$ident])) { + $wert = (string) $wert; + $this->WriteAttributeString($stringAttribute[$ident], $wert); + } elseif ($ident !== 'Leistungsquelle') { + throw new LogicException('Unbekannter interner Zustand: ' . $ident); + } + + $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { + SetValue($variablenID, $wert); + } + } + + /** @return bool|int|float|string */ + private function leseZustand(string $ident) + { + $booleanAttribute = [ + 'SollwertGueltig' => 'ZustandSollwertGueltig', + 'Verfuegbar' => 'ZustandVerfuegbar', + 'AenderungMoeglich' => 'ZustandAenderungMoeglich', + 'Stoerung' => 'ZustandStoerung', + 'Schaltzustand' => 'ZustandSchaltzustand', + 'Rueckmeldefehler' => 'ZustandRueckmeldefehler', + ]; + $integerAttribute = [ + 'Sollleistung' => 'ZustandSollleistung', + 'Tageslaufzeit' => 'ZustandTageslaufzeit', + ]; + $floatAttribute = [ + 'Istleistung' => 'ZustandIstleistung', + ]; + $stringAttribute = [ + 'Stoertext' => 'ZustandStoertext', + ]; + + if (isset($booleanAttribute[$ident])) { + return $this->ReadAttributeBoolean($booleanAttribute[$ident]); + } + if (isset($integerAttribute[$ident])) { + return $this->ReadAttributeInteger($integerAttribute[$ident]); + } + if (isset($floatAttribute[$ident])) { + return $this->ReadAttributeFloat($floatAttribute[$ident]); + } + if (isset($stringAttribute[$ident])) { + return $this->ReadAttributeString($stringAttribute[$ident]); + } + + throw new LogicException('Unbekannter interner Zustand: ' . $ident); + } + + /** @return list */ + private function leseLeistungsangebot(): array + { + $angebot = json_decode( + $this->ReadAttributeString('Leistungsangebot'), + true, + 512, + JSON_THROW_ON_ERROR + ); + if (!is_array($angebot)) { + return []; + } + + return array_values(array_map('intval', $angebot)); + } + + private function zeitzone(): DateTimeZone + { + return new DateTimeZone(date_default_timezone_get()); + } + + private function deaktiviereTimer(): void + { + foreach ([ + 'Meldezyklus', + 'RueckmeldungVerzoegert', + 'Umschaltfreigabe', + 'VorgabeTimeout', + 'Tagesplanung', + ] as $timer) { + $this->SetTimerInterval($timer, 0); + } + } + + /** @param mixed $daten */ + private function protokolliere(string $bezeichnung, $daten): void + { + if (!$this->ReadPropertyBoolean('LoggingEin')) { + return; + } + + $this->SendDebug( + $bezeichnung, + is_string($daten) ? $daten : json_encode($daten, JSON_THROW_ON_ERROR), + 0 + ); + } +} diff --git a/docs/adr/0002-verbraucher-einstufig-eventbasiert.md b/docs/adr/0002-verbraucher-einstufig-eventbasiert.md new file mode 100644 index 0000000..7f5db7b --- /dev/null +++ b/docs/adr/0002-verbraucher-einstufig-eventbasiert.md @@ -0,0 +1,57 @@ +# ADR 0002: Verbraucher 1-Stufig arbeitet ereignisbasiert + +## Status + +Akzeptiert. + +## Kontext + +Das Enelix-1-Modul berechnete seinen Zustand in einem festen Intervall und +bildete die Lastwechselsperre ueber `Interval`, `IdleCounterMax` und weitere +zyklusabhaengige Zaehler ab. Dieses Verhalten passt nicht zur +ereignisbasierten Enelix-2-Kommunikation und machte reale Zeitabstaende von +mehreren Properties abhaengig. + +## Entscheidung + +Der neue `VerbraucherEinStufig` verwendet keine zyklische Regelberechnung. +Schaltkontakt, Rueckmeldung, Manager-Vorgabe, Freigabe, Vorgabeablauf und +Tagesplanung loesen die Regelung direkt aus. + +Als einziger modulspezifischer Wert fuer die Lastwechselbegrenzung wird +`Umschaltabstand` in Sekunden registriert. Der Standardwert ist 5 Sekunden. +Das Ende der Sperre wird durch einen einmaligen Timer ausgeloest. + +Das gemeinsame `Meldeintervall` bleibt erhalten, weil Vertrag `3.0` +zusaetzlich zu Ereignismeldungen eine periodische Vollmeldung fordert. Dieser +Timer ist kein Regelzyklus. + +Die Tagesmindestlaufzeit wird in realen Sekunden und lokaler Symcon-Zeitzone +gezaehlt. Das Modul erzwingt den Betrieb erst zum spaetestmoeglichen Zeitpunkt, +an dem die fehlende Laufzeit vor Mitternacht noch erreicht werden kann. + +## Alternativen + +- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Sperrzeit + wieder voneinander abhaengen wuerden. +- Getrennte Ein- und Ausschaltzeiten wurden verworfen, weil sie fuer diese + Adaption nicht freigegeben sind. +- Die feste Nachtphase aus Enelix 1 wurde verworfen, weil sie weder + konfigurierbar noch Teil der Enelix-2-Spezifikation ist. + +## Folgen + +- Lastwechsel reagieren ohne Polling auf relevante Ereignisse. +- `Interval` und `IdleCounterMax` entfallen. +- Der Manager sieht waehrend einer Sperre nur die aktuelle Leistung und + `AenderungMoeglich=false`. +- Ein optionaler Rueckmeldekontakt muss innerhalb des Umschaltabstands folgen. +- Tageslaufzeit und Mindestlaufzeit sind unabhaengig von einer Zyklusdauer. +- Das Modul bleibt vollstaendig im Repository Enelix EMS; Enelix Utils erhaelt + keine geraetespezifische Logik. + +## Offene Punkte + +- Eine spaetere sperrbare Variante wird separat spezifiziert. +- Unterschiedliche Ein- und Ausschaltabstaende benoetigen eine neue + Architekturentscheidung. diff --git a/docs/migration/Verbraucher-1-Stufig.md b/docs/migration/Verbraucher-1-Stufig.md new file mode 100644 index 0000000..8b60ead --- /dev/null +++ b/docs/migration/Verbraucher-1-Stufig.md @@ -0,0 +1,60 @@ +# Migration von Verbraucher_1_Stufig + +Das Enelix-1-Modul wird nicht in-place aktualisiert. Das neue Modul verwendet +eine neue Modul-ID, den Vertrag `3.0` und eine ereignisbasierte Regelung. + +## Eigenschaften + +| Enelix 1 | Enelix 2 | Migration | +| --- | --- | --- | +| `BoilerLeistung` | `Nennleistung` | Wert in ganzen Watt uebernehmen. | +| `Schaltkontakt1` | `SchaltkontaktVariableID` | Boolean-Aktor uebernehmen und Aktion pruefen. | +| nicht vorhanden | `SchaltkontaktInvertiert` | Nur bei umgekehrter Aktorlogik aktivieren. | +| nicht vorhanden | `RueckmeldungVariableID` | Optional eine echte Boolean-Rueckmeldung zuordnen. | +| `Mindesttlaufzeit` | `Mindestlaufzeit` | Alter Wert war in Minuten gedacht; fuer gleiches Verhalten mit 60 multiplizieren und als Sekunden eintragen. | +| `Zeit_Zwischen_Zustandswechseln` | `Umschaltabstand` | Alte Minuten nicht uebernehmen. Neuen Sekundenwert bewusst festlegen; Standard ist 5 s. | +| `Interval` | entfaellt | Keine zyklische Regelberechnung mehr. | +| `IdleCounterMax` | entfaellt | Keine zyklusbasierte Idle-Erkennung mehr. | + +## Variablen und Verhalten + +| Enelix 1 | Enelix 2 | +| --- | --- | +| `DailyOnTime` | `Tageslaufzeit` in echten Sekunden | +| `IstNacht` | entfaellt; Mindestlaufzeit wird am spaetestmoeglichen Start des lokalen Kalendertags erzwungen | +| `IsTimerActive` | entfaellt; interne einmalige Timer | +| `Aktuelle_Leistung` | gemeinsames `Istleistung` | +| `Power` | gemeinsames `Sollleistung` | +| `PowerSteps` | Vertragsfeld `Leistungswerte_W` | +| `PV_Prio` | Property `PrioritaetPV` | +| `Sperre_Prio` | Property `PrioritaetPeak` | +| `Is_Peak_Shaving` | entfaellt; Betriebsart liegt im Manager | +| `Leistung_Delta` | entfaellt; Leistungsverteilung liegt im Manager | +| `Idle`, `IdleCounter` | entfallen | + +Die feste Enelix-1-Nachtzeit von 22:00 bis 07:00 wird nicht uebernommen. +Enelix 2 plant nur den Zeitpunkt, ab dem die verbleibende Mindestlaufzeit vor +dem lokalen Tagesende zwingend eingeschaltet werden muss. + +## Inbetriebnahme + +1. Bestehende Instanzkonfiguration und aktuelle Aktorlogik dokumentieren. +2. Neue Instanz **Verbraucher 1-Stufig** anlegen. +3. Leistung, Aktor, Invertierung und optional Rueckmeldung konfigurieren. +4. Mindestlaufzeit von Minuten in Sekunden umrechnen. +5. `Umschaltabstand` neu in Sekunden festlegen; nicht den alten Minutenwert + kopieren. +6. Neue Instanz im Enelix-2-Manager aktiv zuordnen. +7. Bei ausgeschalteter lokaler EMS-Freigabe Ein/Aus und Rueckmeldung + beaufsichtigt pruefen. +8. Alte Instanz erst deaktivieren, danach `Aktiv` an der neuen Instanz + einschalten. +9. Mindestens einen Timeout, einen verhinderten Lastwechsel und einen + Tageswechsel kontrollieren. + +## Rueckkehr + +Solange die alte Instanz nicht geloescht wurde, kann zurueckgekehrt werden, +indem die neue Instanz im Manager deaktiviert und ausgeschaltet wird. Danach +darf die alte Instanz wieder aktiviert werden. Beide Module duerfen nie +gleichzeitig denselben Aktor steuern. diff --git a/docs/module/README.md b/docs/module/README.md index d55deba..b0a88fb 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,7 +1,7 @@ # EMS-Module und Modulentwürfe -> Manager und Warmwassererwaermer sind als installierbare IP-Symcon-Module -> umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen. +> Manager, Warmwassererwaermer und Verbraucher 1-Stufig sind als installierbare +> IP-Symcon-Module umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen. Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der [EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb @@ -13,7 +13,7 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte. | [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers | | [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) | | [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve | -| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher | +| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) | | [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready | | [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung | | [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway | diff --git a/docs/module/Verbraucher-1-Stufig/README.md b/docs/module/Verbraucher-1-Stufig/README.md index be407c4..844387b 100644 --- a/docs/module/Verbraucher-1-Stufig/README.md +++ b/docs/module/Verbraucher-1-Stufig/README.md @@ -1,36 +1,156 @@ # Verbraucher 1-Stufig -> Status: Diskussionsentwurf. +> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`. -Schaltet eine elektrische Last. Das Leistungsangebot ist üblicherweise -`[0, Nennleistung]`. +Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im freien Zustand +meldet es dem Manager das Leistungsangebot `[0, Nennleistung]`. Waerend der +Umschaltsperre und bei lokal erzwungener Mindestlaufzeit wird nur der aktuell +zulaessige Leistungswert angeboten. -## Zusätzliche Variablen +## Architekturentscheidung + +Die Regelung ist ereignisbasiert. Neuberechnungen werden ausgeloest durch: + +- eine neue Manager-Vorgabe, +- eine Aenderung an Schaltkontakt oder optionaler Rueckmeldung, +- das Ein- oder Ausschalten der lokalen EMS-Freigabe, +- das Ende des Umschaltabstands, +- das Ablaufen einer Manager-Vorgabe, +- den naechsten relevanten Zeitpunkt der Tageslaufzeitplanung. + +Es gibt keinen Regelzyklus, kein `Interval` und keinen `IdleCounterMax`. +`Meldeintervall` bleibt als vertraglich geforderte periodische Vollmeldung +bestehen und steuert keine Regelberechnung. + +## Properties + +Zu den sechs gemeinsamen Verbraucher-Properties aus +[`Schnittstelle.md`](../../Schnittstelle.md) kommen sieben +Modul-Properties hinzu. + +| Ident | Typ | Standard | Beschreibung | +| --- | --- | ---: | --- | +| `Nennleistung` | Integer | `0` | Positive elektrische Leistung in W. | +| `SchaltkontaktVariableID` | Integer | `0` | Boolean-Aktor mit Standard- oder benutzerdefinierter Aktion. | +| `SchaltkontaktInvertiert` | Boolean | `false` | Kehrt die Ein-/Aus-Semantik des Aktors um. | +| `RueckmeldungVariableID` | Integer | `0` | Optionale Boolean-Rueckmeldung; `true` bedeutet eingeschaltet. | +| `Mindestlaufzeit` | Integer | `0` | Geforderte Laufzeit pro lokalem Kalendertag in Sekunden, maximal 86400. | +| `Umschaltabstand` | Integer | `5` | Sekunden bis zum naechsten erlaubten Lastwechsel. | +| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt gemeinsame Diagnosevariablen und `Rueckmeldefehler` an. | + +`EinstellungenInVisu` ist Teil der gemeinsamen Verbraucherbasis. Das Modul +besitzt derzeit keine zusaetzlichen bedienbaren Einstellvariablen und wertet +diese Property deshalb noch nicht weiter aus. + +## Variablen + +Immer sichtbar: | Ident | Typ / Zugriff | Beschreibung | | --- | --- | --- | -| `Schaltzustand` | Boolean / Anzeige | Rückgemeldeter oder berechneter Kontaktzustand. | -| `Tageslaufzeit` | Integer / Anzeige | Laufzeit seit lokalem Tagesbeginn in Sekunden; persistent mit Tageskennung. | +| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Startwert `false`. | +| `Schaltzustand` | Boolean / Anzeige | Rueckgemeldeter oder aus dem Aktor berechneter Zustand. | +| `Tageslaufzeit` | Integer / Anzeige | Laufzeit des lokalen Kalendertags in Sekunden. | -## Zusätzliche Properties +Mit `DiagnosevariablenAnzeigen` werden diese Variablen zusaetzlich angelegt: -| Ident | Typ | Standard / Beschreibung | -| --- | --- | --- | -| `Nennleistung` | Float | `0*` W; grösser 0. | -| `SchaltkontaktVariableID` | Integer | `0`; erforderlicher Boolean-Aktor mit Action. | -| `SchaltkontaktInvertiert` | Boolean | `false`; Ein-/Aus-Semantik des Aktors. | -| `RueckmeldungVariableID` | Integer | `0`; optionale echte Rückmeldung. | -| `Mindestlaufzeit` | Integer | `0` s. | -| `Umschaltabstand` | Integer | `60` s; Mindestabstand zwischen Änderungen. | +- `Istleistung` +- `Leistungsquelle` +- `Sollleistung` +- `SollwertGueltig` +- `Verfuegbar` +- `AenderungMoeglich` +- `Stoerung` +- `Stoertext` +- `Rueckmeldefehler` -Der frühere Ident `BoilerLeistung` wird durch `Nennleistung` ersetzt. +Die Regellogik arbeitet mit persistenten Attributen und ist nicht von der +Sichtbarkeit der Diagnosevariablen abhaengig. -## Zustand +## Zeit- und Schaltverhalten -`Schaltzustand` als Status, `Tageslaufzeit_s` als Istwert und -`Rueckmeldefehler` als Störung. +Nach jedem Lastwechsel wird `AenderungMoeglich=false` gemeldet und das +Leistungsangebot auf den aktuellen Wert eingeschraenkt. Nach +`Umschaltabstand` Sekunden gibt ein einmaliger Timer den naechsten +Lastwechsel frei. Der Standardwert betraegt 5 Sekunden. -## Offene Punkte +Die Tageslaufzeit wird mit der in IP-Symcon eingestellten lokalen Zeitzone +gefuehrt. Sommer- und Winterzeit sowie der Wechsel um Mitternacht werden +beruecksichtigt. Ist die konfigurierte Mindestlaufzeit noch nicht erreicht, +wird der Verbraucher erst zum spaetestmoeglichen Zeitpunkt lokal +eingeschaltet, an dem die Restlaufzeit bis Mitternacht noch erfuellt werden +kann. In dieser Phase meldet er `AenderungMoeglich=false`. -- Sperrbare Variante später integrieren und vollständig überarbeiten. -- Unterschiedliche Ein- und Ausschaltzeiten erst nach Freigabe ergänzen. +Bei Aktivierung sehr kurz vor Tagesende kann eine unmoeglich hohe Restlaufzeit +naturgemaess nicht mehr vollstaendig nachgeholt werden. Der Verbraucher wird +dann sofort eingeschaltet und die tatsaechliche Laufzeit erfasst. + +## Rueckmeldung und Fehler + +Ohne separate Rueckmeldung wird der Schaltzustand aus der Aktorvariable +berechnet. Mit `RueckmeldungVariableID` wartet das Modul bis zum Ende des +Umschaltabstands auf den angeforderten Zustand. Liegt er dann nicht an, werden +`Rueckmeldefehler=true`, `Stoerung=true` und `Verfuegbar=false` gemeldet. + +Die Istleistung ist immer berechnet: `0` oder `Nennleistung`. Deshalb wird +`Leistungsquelle=1` gemeldet, auch wenn der Boolean-Schaltzustand ueber eine +physische Rueckmeldung erfasst wird. + +## Managerkommunikation + +Das Modul implementiert +`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet +ausschliesslich den Vertrag `3.0`. Der Verbraucher besitzt keine +Manager-ID-Property. Er akzeptiert nur Manager, in deren manueller oder +automatischer Verbraucherzuordnung seine Instanz aktiv eingetragen ist. + +Rueckmeldungen erfolgen bei relevanten Ereignissen, kurz verzoegert nach einer +Manager-Vorgabe und zusaetzlich alle `Meldeintervall` Sekunden. Eine Vorgabe +wird nach `VorgabeTimeout` Sekunden ohne Erneuerung ungueltig. + +## Installation und Inbetriebnahme + +1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek + `https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder + aktualisieren. +2. Unter **Instanz hinzufuegen** nach **Verbraucher 1-Stufig** suchen und eine + Instanz anlegen. +3. `Nennleistung` in ganzen Watt eintragen. +4. Als `Schaltkontakt` eine Booleanvariable mit funktionsfaehiger Aktion + auswaehlen. Bei umgekehrter Aktorlogik `SchaltkontaktInvertiert` + aktivieren. +5. Optional eine separate Boolean-Rueckmeldung auswaehlen. Dort muss + `true` dem physisch eingeschalteten Verbraucher entsprechen. +6. Prioritaeten, `Mindestlaufzeit`, `Umschaltabstand`, + `Meldeintervall` und `VorgabeTimeout` einstellen und die Konfiguration + uebernehmen. +7. Die neue Instanz im Manager manuell aktiv zuordnen oder bei automatischer + Suche in der gefundenen Liste aktivieren. +8. Fuer die Erstpruefung `DiagnosevariablenAnzeigen` und bei Bedarf + `LoggingEin` einschalten. +9. Unter Aufsicht `Aktiv` einschalten und ueber den Manager je eine Ein- und + Aus-Vorgabe pruefen. + +## Abnahmecheckliste + +- `Aktiv=false` schaltet den Ausgang aus. +- `Aktiv=true` und eine Manager-Vorgabe `Nennleistung` schalten den Ausgang + ein. +- Direkt nach einem Wechsel ist `AenderungMoeglich=false`; nach 5 Sekunden + wird es mit Standardkonfiguration wieder `true`. +- Eine Gegenanforderung waehrend der Sperre wird vom Leistungsangebot + ausgeschlossen. +- Die optionale Rueckmeldung folgt dem Ausgang innerhalb des + Umschaltabstands; andernfalls erscheint eine Stoerung. +- `Tageslaufzeit` steigt nur im eingeschalteten Zustand und beginnt am + lokalen Tageswechsel wieder bei 0. +- Nach Ablauf von `VorgabeTimeout` wird ohne neue Manager-Vorgabe + ausgeschaltet, sobald der Umschaltabstand dies zulaesst. +- Nach Neustart oder erneuter Konfiguration wird der reale Schaltzustand + eingelesen; alte Manager-Vorgaben werden nicht ungeprueft fortgesetzt. + +## Migration + +Die Zuordnung der Enelix-1-Felder, Einheiten und bewusst entfallenen +Kommunikationsvariablen steht in +[`docs/migration/Verbraucher-1-Stufig.md`](../../migration/Verbraucher-1-Stufig.md). diff --git a/libs/EinStufigRegler.php b/libs/EinStufigRegler.php new file mode 100644 index 0000000..74563f1 --- /dev/null +++ b/libs/EinStufigRegler.php @@ -0,0 +1,144 @@ + + */ + public static function leistungsangebot( + int $nennleistung, + int $istleistung, + bool $verfuegbar, + bool $aenderungMoeglich, + bool $mindestlaufzeitErzwungen + ): array { + if ($nennleistung <= 0) { + throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.'); + } + if (!in_array($istleistung, [0, $nennleistung], true)) { + throw new InvalidArgumentException('Die Istleistung muss 0 oder der Nennleistung entsprechen.'); + } + + if (!$verfuegbar || !$aenderungMoeglich || $mindestlaufzeitErzwungen) { + return [$istleistung]; + } + + return [0, $nennleistung]; + } + + public static function mussMindestlaufzeitErzwingen( + int $tageslaufzeit, + int $mindestlaufzeit, + int $sekundenBisTagesende + ): bool { + self::pruefeLaufzeiten($tageslaufzeit, $mindestlaufzeit, $sekundenBisTagesende); + + $restlaufzeit = max(0, $mindestlaufzeit - $tageslaufzeit); + + return $restlaufzeit > 0 && $sekundenBisTagesende <= $restlaufzeit; + } + + public static function sekundenBisNaechsteTagespruefung( + int $tageslaufzeit, + int $mindestlaufzeit, + int $sekundenBisTagesende, + bool $eingeschaltet + ): int { + self::pruefeLaufzeiten($tageslaufzeit, $mindestlaufzeit, $sekundenBisTagesende); + + $restlaufzeit = max(0, $mindestlaufzeit - $tageslaufzeit); + if ($restlaufzeit === 0) { + return max(1, $sekundenBisTagesende); + } + if ($eingeschaltet) { + return max(1, min($restlaufzeit, $sekundenBisTagesende)); + } + + return max(1, $sekundenBisTagesende - $restlaufzeit); + } + + /** + * @return array{Tag: string, Sekunden: int, StandZeit: int} + */ + public static function aktualisiereTageslaufzeit( + string $gespeicherterTag, + int $gespeicherteSekunden, + int $standZeit, + int $jetzt, + bool $warEingeschaltet, + DateTimeZone $zeitzone + ): array { + if ($gespeicherteSekunden < 0 || $standZeit < 0 || $jetzt < 0) { + throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.'); + } + + $jetztDatum = self::datum($jetzt, $zeitzone); + $aktuellerTag = $jetztDatum->format('Y-m-d'); + if ($standZeit <= 0 || $standZeit > $jetzt) { + return [ + 'Tag' => $aktuellerTag, + 'Sekunden' => $gespeicherterTag === $aktuellerTag ? $gespeicherteSekunden : 0, + 'StandZeit' => $jetzt, + ]; + } + + $standDatum = self::datum($standZeit, $zeitzone); + $sekunden = $gespeicherteSekunden; + if ($gespeicherterTag !== $aktuellerTag || $standDatum->format('Y-m-d') !== $aktuellerTag) { + $sekunden = 0; + if ($warEingeschaltet) { + $tagesbeginn = $jetztDatum->setTime(0, 0)->getTimestamp(); + $sekunden = max(0, $jetzt - max($standZeit, $tagesbeginn)); + } + } elseif ($warEingeschaltet) { + $sekunden += $jetzt - $standZeit; + } + + $tagesbeginn = $jetztDatum->setTime(0, 0); + $tagesdauer = $tagesbeginn->modify('+1 day')->getTimestamp() - $tagesbeginn->getTimestamp(); + + return [ + 'Tag' => $aktuellerTag, + 'Sekunden' => min($sekunden, $tagesdauer), + 'StandZeit' => $jetzt, + ]; + } + + public static function sekundenBisTagesende(int $jetzt, DateTimeZone $zeitzone): int + { + if ($jetzt < 0) { + throw new InvalidArgumentException('Der Zeitpunkt darf nicht negativ sein.'); + } + + $datum = self::datum($jetzt, $zeitzone); + $tagesende = $datum->setTime(0, 0)->modify('+1 day'); + + return max(1, $tagesende->getTimestamp() - $jetzt); + } + + private static function pruefeLaufzeiten( + int $tageslaufzeit, + int $mindestlaufzeit, + int $sekundenBisTagesende + ): void { + if ($tageslaufzeit < 0 || $mindestlaufzeit < 0 || $mindestlaufzeit > 86400) { + throw new InvalidArgumentException('Laufzeiten sind ausserhalb des gueltigen Bereichs.'); + } + if ($sekundenBisTagesende <= 0) { + throw new InvalidArgumentException('Sekunden bis Tagesende muss groesser als 0 sein.'); + } + } + + private static function datum(int $zeitpunkt, DateTimeZone $zeitzone): DateTimeImmutable + { + return (new DateTimeImmutable('@' . $zeitpunkt))->setTimezone($zeitzone); + } +} diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index 5f9566d..6b25c68 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -14,7 +14,6 @@ final class DokumentationsstrukturTest extends TestCase return [ ['Batterie'], ['Pufferspeicher'], - ['Verbraucher-1-Stufig'], ['Waermepumpe'], ['Ladestation-Stand-Alone'], ['Ladestation-Gateway'], @@ -49,6 +48,15 @@ final class DokumentationsstrukturTest extends TestCase self::assertStringContainsString('Verteilalgorithmus', $inhalt); } + public function testVerbraucherEinStufigDokumentiertDenImplementiertenStand(): void + { + $inhalt = file_get_contents(__DIR__ . '/../docs/module/Verbraucher-1-Stufig/README.md'); + self::assertNotFalse($inhalt); + self::assertStringContainsString('Status: implementiert', $inhalt); + self::assertStringContainsString('Umschaltabstand', $inhalt); + self::assertStringContainsString('Installation und Inbetriebnahme', $inhalt); + } + public function testWarmwassererwaermerDokumentiertDenImplementiertenStand(): void { $inhalt = file_get_contents(__DIR__ . '/../docs/module/Wassererwaermer/README.md'); diff --git a/tests/EinStufigReglerTest.php b/tests/EinStufigReglerTest.php new file mode 100644 index 0000000..c8f8945 --- /dev/null +++ b/tests/EinStufigReglerTest.php @@ -0,0 +1,115 @@ +expectException(InvalidArgumentException::class); + + EinStufigRegler::leistungsangebot(3500, 1200, true, true, false); + } + + public function testMindestlaufzeitWirdErstZumSpaetestmoeglichenStartErzwungen(): void + { + self::assertFalse(EinStufigRegler::mussMindestlaufzeitErzwingen(0, 3600, 7200)); + self::assertTrue(EinStufigRegler::mussMindestlaufzeitErzwingen(0, 3600, 3600)); + self::assertTrue(EinStufigRegler::mussMindestlaufzeitErzwingen(1800, 3600, 1200)); + self::assertFalse(EinStufigRegler::mussMindestlaufzeitErzwingen(3600, 3600, 1)); + } + + public function testNaechsteTagespruefungIstEreignisbasiert(): void + { + self::assertSame( + 3600, + EinStufigRegler::sekundenBisNaechsteTagespruefung(0, 3600, 7200, false) + ); + self::assertSame( + 1800, + EinStufigRegler::sekundenBisNaechsteTagespruefung(1800, 3600, 7200, true) + ); + self::assertSame( + 7200, + EinStufigRegler::sekundenBisNaechsteTagespruefung(3600, 3600, 7200, false) + ); + } + + public function testLaufzeitWirdInnerhalbEinesTagesFortgeschrieben(): void + { + $zone = new DateTimeZone('Europe/Zurich'); + $von = (new DateTimeImmutable('2026-09-17 10:00:00', $zone))->getTimestamp(); + $bis = (new DateTimeImmutable('2026-09-17 10:02:30', $zone))->getTimestamp(); + + self::assertSame( + ['Tag' => '2026-09-17', 'Sekunden' => 250, 'StandZeit' => $bis], + EinStufigRegler::aktualisiereTageslaufzeit( + '2026-09-17', + 100, + $von, + $bis, + true, + $zone + ) + ); + } + + public function testLaufzeitWirdAnLokalerTagesgrenzeGetrennt(): void + { + $zone = new DateTimeZone('Europe/Zurich'); + $von = (new DateTimeImmutable('2026-09-16 23:59:50', $zone))->getTimestamp(); + $bis = (new DateTimeImmutable('2026-09-17 00:00:10', $zone))->getTimestamp(); + + self::assertSame( + ['Tag' => '2026-09-17', 'Sekunden' => 10, 'StandZeit' => $bis], + EinStufigRegler::aktualisiereTageslaufzeit( + '2026-09-16', + 1000, + $von, + $bis, + true, + $zone + ) + ); + } + + public function testTagesendeBeruecksichtigtSommerzeit(): void + { + $zone = new DateTimeZone('Europe/Zurich'); + $zeitpunkt = (new DateTimeImmutable('2026-03-29 00:00:00', $zone))->getTimestamp(); + + self::assertSame(23 * 3600, EinStufigRegler::sekundenBisTagesende($zeitpunkt, $zone)); + } +} diff --git a/tests/VerbraucherEinStufigModulstrukturTest.php b/tests/VerbraucherEinStufigModulstrukturTest.php new file mode 100644 index 0000000..c9a5414 --- /dev/null +++ b/tests/VerbraucherEinStufigModulstrukturTest.php @@ -0,0 +1,132 @@ +ReadPropertyInteger('Meldeintervall') * 1000)", + $modul + ); + } + + public function testModulVerwendetAusschliesslichDenVertrag30(): void + { + $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); + + self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul); + self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul); + self::assertStringContainsString('Nachrichtenvertrag::pruefeManagerdaten', $modul); + self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul); + self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul); + self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul); + } + + public function testFachvariablenUndRueckmeldestoerungSindEnthalten(): void + { + $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); + + self::assertStringContainsString( + "RegisterVariableBoolean('Schaltzustand'", + $modul + ); + self::assertStringContainsString( + "RegisterVariableInteger('Tageslaufzeit'", + $modul + ); + self::assertStringContainsString("'Kennung' => 'Rueckmeldefehler'", $modul); + self::assertStringContainsString("'Kennung' => 'Tageslaufzeit_s'", $modul); + } + + public function testDiagnosevariablenSindOptional(): void + { + $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); + + self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $modul); + self::assertStringContainsString('registriereVerbraucherDiagnose', $modul); + self::assertStringContainsString('UnregisterVariable($ident)', $modul); + } +}