diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 705c2b3..ecd1504 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: - name: Abhaengigkeiten installieren run: composer install --no-interaction --no-progress --prefer-dist - - name: Pruefungen ausfuehren + - name: Verbraucher 1-Stufig separat pruefen + run: composer check:verbraucher-einstufig + + - name: Gesamttest ausfuehren run: composer check diff --git a/README.md b/README.md index 8aed29d..d5957f2 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,13 @@ composer install composer check ``` +Der Gesamttest schliesst alle Modulsuiten ein. Der einstufige Verbraucher kann +zusaetzlich unabhaengig ausgefuehrt werden: + +```bash +composer check:verbraucher-einstufig +``` + Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingecheckt. Die CI-Pruefung muss vor der Uebernahme in einen Freigabebranch erfolgreich sein. | Git-Branch | IP-Symcon-Kanal | diff --git a/VerbraucherEinStufig/form.json b/VerbraucherEinStufig/form.json index a6a0d4a..a6160bd 100644 --- a/VerbraucherEinStufig/form.json +++ b/VerbraucherEinStufig/form.json @@ -33,8 +33,15 @@ }, { "type": "NumberSpinner", - "name": "Umschaltabstand", - "caption": "Zeit bis zum naechsten Lastwechsel", + "name": "Mindesteinschaltdauer", + "caption": "Mindest-Einschaltdauer", + "suffix": " s", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "Mindestausschaltdauer", + "caption": "Mindest-Ausschaltdauer", "suffix": " s", "minimum": 0 } diff --git a/VerbraucherEinStufig/module.php b/VerbraucherEinStufig/module.php index 1858320..258ffd2 100644 --- a/VerbraucherEinStufig/module.php +++ b/VerbraucherEinStufig/module.php @@ -33,6 +33,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle 'Stoerung', 'Stoertext', 'Rueckmeldefehler', + 'SchaltbefehlAusstehend', + 'RestMindestzeit', ]; public function Create(): void @@ -45,7 +47,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $this->RegisterPropertyBoolean('SchaltkontaktInvertiert', false); $this->RegisterPropertyInteger('RueckmeldungVariableID', 0); $this->RegisterPropertyInteger('Mindestlaufzeit', 0); - $this->RegisterPropertyInteger('Umschaltabstand', 5); + $this->RegisterPropertyInteger('Mindesteinschaltdauer', 5); + $this->RegisterPropertyInteger('Mindestausschaltdauer', 5); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); $this->RegisterVariableBoolean('Schaltzustand', 'Schaltzustand', '~Switch', 100); @@ -55,7 +58,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $this->RegisterAttributeInteger('RegistrierteRueckmeldung', 0); $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); $this->RegisterAttributeInteger('LetzteManagerID', 0); - $this->RegisterAttributeInteger('GesperrtBis', 0); + $this->RegisterAttributeInteger('BestaetigtSeit', 0); + $this->RegisterAttributeInteger('FreigabeAb', 0); $this->RegisterAttributeInteger('LaufzeitStandZeit', 0); $this->RegisterAttributeString('LaufzeitTag', ''); $this->RegisterAttributeString('Leistungsangebot', '[0]'); @@ -75,6 +79,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $this->RegisterAttributeBoolean('ZustandSchaltzustand', false); $this->RegisterAttributeInteger('ZustandTageslaufzeit', 0); $this->RegisterAttributeBoolean('ZustandRueckmeldefehler', false); + $this->RegisterAttributeBoolean('ZustandSchaltbefehlAusstehend', false); + $this->RegisterAttributeInteger('ZustandRestMindestzeit', 0); $this->RegisterTimer( 'Meldezyklus', @@ -86,10 +92,12 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle 0, "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);" ); + // Bestehende Instanzen behalten den alten Timer deaktiviert. + $this->RegisterTimer('Umschaltfreigabe', 0, ''); $this->RegisterTimer( - 'Umschaltfreigabe', + 'MindestzeitAbgelaufen', 0, - "IPS_RequestAction(\$_IPS['TARGET'], 'Umschaltfreigabe', 0);" + "IPS_RequestAction(\$_IPS['TARGET'], 'MindestzeitAbgelaufen', 0);" ); $this->RegisterTimer( 'VorgabeTimeout', @@ -162,7 +170,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $this->sendeVerbraucherdaten(); return; - case 'Umschaltfreigabe': + case 'MindestzeitAbgelaufen': case 'VorgabeTimeout': case 'Tagesplanung': $this->SetTimerInterval((string) $ident, 0); @@ -223,14 +231,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $schaltzustand = $this->leseSchaltzustand(); if ($schaltzustand !== $vorherigerSchaltzustand) { - $this->setzeSchaltzustand($schaltzustand); - if ($this->ReadAttributeBoolean('Initialisiert') - && !$this->ReadAttributeBoolean('SchaltpruefungAusstehend') - ) { - $this->starteUmschaltsperre($jetzt); - } + $this->bestaetigeZustandswechsel($schaltzustand, $jetzt); } - $this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt); $this->pruefeVorgabeTimeout($jetzt); if ((bool) $this->leseZustand('SollwertGueltig') @@ -244,12 +246,23 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle } $aktiv = (bool) $this->GetValue('Aktiv'); + $wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend'); + $rueckmeldefehler = $this->ReadPropertyInteger('RueckmeldungVariableID') > 0 + && !$wartetAufRueckmeldung + && $schaltzustand !== $this->leseAktorSchaltzustand(); + $this->setzeZustand('Rueckmeldefehler', $rueckmeldefehler); + $fehlerfrei = $this->ReadAttributeString('Schaltfehler') === '' - && !(bool) $this->leseZustand('Rueckmeldefehler'); + && !$rueckmeldefehler; $verfuegbar = $aktiv && $fehlerfrei; $mindestlaufzeitErzwungen = $verfuegbar && $this->mussMindestlaufzeitErzwingen($jetzt); - $gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis'); + $restMindestzeit = $wartetAufRueckmeldung + ? 0 + : EinStufigRegler::restMindestzeit( + $this->ReadAttributeInteger('FreigabeAb'), + $jetzt + ); $ziel = 0; if ($verfuegbar) { @@ -261,20 +274,37 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle } $istleistung = $this->leistungFuerSchaltzustand($schaltzustand); - if ($ziel !== $istleistung && (!$aktiv || (!$gesperrt && $fehlerfrei))) { - $this->schalte($ziel > 0, $jetzt); + $zielzustand = $ziel > 0; + $befehlNoetig = $ziel !== $istleistung + || ($wartetAufRueckmeldung + && $zielzustand !== $this->ReadAttributeBoolean('Schaltziel')); + // Die lokale Deaktivierung bleibt der einzige bewusste Mindestzeit-Override. + $sicheresAusschalten = !$zielzustand && !$aktiv; + if ($befehlNoetig + && ($sicheresAusschalten + || ($restMindestzeit === 0 && !$wartetAufRueckmeldung && $fehlerfrei)) + ) { + $this->schalte($zielzustand, $jetzt); $schaltzustand = $this->leseSchaltzustand(); - $this->setzeSchaltzustand($schaltzustand); + $this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt); $istleistung = $this->leistungFuerSchaltzustand($schaltzustand); - $gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis'); + $wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend'); + $restMindestzeit = $wartetAufRueckmeldung + ? 0 + : EinStufigRegler::restMindestzeit( + $this->ReadAttributeInteger('FreigabeAb'), + $jetzt + ); } $this->setzeZustand('Sollleistung', $ziel); $this->setzeZustand('Verfuegbar', $verfuegbar); + $this->setzeZustand('SchaltbefehlAusstehend', $wartetAufRueckmeldung); + $this->setzeZustand('RestMindestzeit', $restMindestzeit); $aenderungMoeglich = $verfuegbar && !$mindestlaufzeitErzwungen - && !$gesperrt - && !$this->ReadAttributeBoolean('SchaltpruefungAusstehend'); + && $restMindestzeit === 0 + && !$wartetAufRueckmeldung; $this->setzeZustand('AenderungMoeglich', $aenderungMoeglich); $angebot = EinStufigRegler::leistungsangebot( @@ -314,58 +344,77 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $this->WriteAttributeBoolean('Schaltziel', $einschalten); $this->WriteAttributeBoolean('SchaltpruefungAusstehend', true); + $this->setzeZustand('SchaltbefehlAusstehend', true); $this->WriteAttributeBoolean('SchaltvorgangLaeuft', true); try { RequestAction($kontaktID, $aktorwert); } catch (Throwable $fehler) { $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + $this->setzeZustand('SchaltbefehlAusstehend', 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); + $this->bestaetigeZustandswechsel($istzustand, $jetzt); } elseif ($this->ReadPropertyInteger('RueckmeldungVariableID') <= 0) { $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + $this->setzeZustand('SchaltbefehlAusstehend', false); throw new RuntimeException('Schaltkontakt hat den angeforderten Zustand nicht uebernommen.'); - } elseif ($this->ReadPropertyInteger('Umschaltabstand') === 0) { - $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); - $this->setzeZustand('Rueckmeldefehler', true); + } else { + // Die physische Rueckmeldung bestimmt Zustand und Beginn der Mindestzeit. + $this->setzeZustand('Rueckmeldefehler', false); } $this->WriteAttributeString('Schaltfehler', ''); - $this->protokolliere('Lastwechsel', ['Eingeschaltet' => $einschalten]); + $this->protokolliere('Schaltbefehl', ['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); + if ($this->ReadAttributeBoolean('SchaltpruefungAusstehend') + && $schaltzustand === $this->ReadAttributeBoolean('Schaltziel') + ) { + $this->bestaetigeZustandswechsel($schaltzustand, $jetzt); } + + $this->setzeZustand( + 'SchaltbefehlAusstehend', + $this->ReadAttributeBoolean('SchaltpruefungAusstehend') + ); } - private function starteUmschaltsperre(int $jetzt): void + private function bestaetigeZustandswechsel(bool $schaltzustand, int $jetzt): void { - $gesperrtBis = $jetzt + $this->ReadPropertyInteger('Umschaltabstand'); - $this->WriteAttributeInteger('GesperrtBis', $gesperrtBis); + $vorherigerZustand = (bool) $this->leseZustand('Schaltzustand'); + $zustandGeaendert = $schaltzustand !== $vorherigerZustand + || $this->ReadAttributeInteger('BestaetigtSeit') <= 0; + + $this->setzeSchaltzustand($schaltzustand); + if ($zustandGeaendert) { + $this->WriteAttributeInteger('BestaetigtSeit', $jetzt); + $this->WriteAttributeInteger( + 'FreigabeAb', + EinStufigRegler::freigabezeitpunkt( + $schaltzustand, + $jetzt, + $this->ReadPropertyInteger('Mindesteinschaltdauer'), + $this->ReadPropertyInteger('Mindestausschaltdauer') + ) + ); + } + + if ($schaltzustand === $this->ReadAttributeBoolean('Schaltziel')) { + $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + } + $this->setzeZustand( + 'SchaltbefehlAusstehend', + $this->ReadAttributeBoolean('SchaltpruefungAusstehend') + ); + $this->setzeZustand('Rueckmeldefehler', false); + $this->protokolliere('Schaltzustand bestaetigt', ['Eingeschaltet' => $schaltzustand]); } private function aktualisiereTageslaufzeit(int $jetzt): void @@ -410,10 +459,14 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle private function planeTimer(int $jetzt): void { - $gesperrtBis = $this->ReadAttributeInteger('GesperrtBis'); + $this->SetTimerInterval('Umschaltfreigabe', 0); + $freigabeAb = $this->ReadAttributeInteger('FreigabeAb'); + $wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend'); $this->SetTimerInterval( - 'Umschaltfreigabe', - $gesperrtBis > $jetzt ? max(1, ($gesperrtBis - $jetzt) * 1000) : 0 + 'MindestzeitAbgelaufen', + !$wartetAufRueckmeldung && $freigabeAb > $jetzt + ? max(1, ($freigabeAb - $jetzt) * 1000) + : 0 ); $this->planeVorgabeTimeout($jetzt); @@ -457,7 +510,19 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $this->setzeZustand('Rueckmeldefehler', false); $this->WriteAttributeBoolean('Initialisiert', true); } - $this->setzeSchaltzustand($schaltzustand); + + if ($this->ReadAttributeInteger('BestaetigtSeit') <= 0) { + // Migration bestehender Instanzen ohne bestaetigten Zeitbezug. + $this->WriteAttributeBoolean('Schaltziel', $schaltzustand); + $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false); + $this->bestaetigeZustandswechsel($schaltzustand, $jetzt); + } else { + $this->setzeSchaltzustand($schaltzustand); + $this->setzeZustand( + 'SchaltbefehlAusstehend', + $this->ReadAttributeBoolean('SchaltpruefungAusstehend') + ); + } } private function setzeSchaltzustand(bool $eingeschaltet): void @@ -474,6 +539,11 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle return (bool) GetValue($rueckmeldungID); } + return $this->leseAktorSchaltzustand(); + } + + private function leseAktorSchaltzustand(): bool + { $kontaktwert = (bool) GetValue($this->ReadPropertyInteger('SchaltkontaktVariableID')); return $kontaktwert !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert'); @@ -551,6 +621,24 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle 'Wert' => (bool) $this->leseZustand('Schaltzustand'), 'Einheit' => '', ], + [ + 'Kennung' => 'SchaltbefehlAusstehend', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('SchaltbefehlAusstehend'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Schaltbereit', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('AenderungMoeglich'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'RestMindestzeit_s', + 'Art' => 'Istwert', + 'Wert' => (int) $this->leseZustand('RestMindestzeit'), + 'Einheit' => 's', + ], [ 'Kennung' => 'Tageslaufzeit_s', 'Art' => 'Istwert', @@ -636,8 +724,10 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle ) { throw new InvalidArgumentException('Mindestlaufzeit muss zwischen 0 und 86400 Sekunden liegen.'); } - if ($this->ReadPropertyInteger('Umschaltabstand') < 0) { - throw new InvalidArgumentException('Umschaltabstand darf nicht negativ sein.'); + foreach (['Mindesteinschaltdauer', 'Mindestausschaltdauer'] as $property) { + if ($this->ReadPropertyInteger($property) < 0) { + throw new InvalidArgumentException($property . ' darf nicht negativ sein.'); + } } $kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID'); @@ -713,6 +803,13 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { $this->registriereVerbraucherDiagnose(); $this->RegisterVariableBoolean('Rueckmeldefehler', 'Rueckmeldefehler', '~Alert', 200); + $this->RegisterVariableBoolean( + 'SchaltbefehlAusstehend', + 'Schaltbefehl ausstehend', + '~Switch', + 210 + ); + $this->RegisterVariableInteger('RestMindestzeit', 'Restliche Mindestzeit', '', 220); foreach (self::DIAGNOSE_VARIABLEN as $ident) { $wert = $ident === 'Leistungsquelle' ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET @@ -739,10 +836,12 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle 'Stoerung' => 'ZustandStoerung', 'Schaltzustand' => 'ZustandSchaltzustand', 'Rueckmeldefehler' => 'ZustandRueckmeldefehler', + 'SchaltbefehlAusstehend' => 'ZustandSchaltbefehlAusstehend', ]; $integerAttribute = [ 'Sollleistung' => 'ZustandSollleistung', 'Tageslaufzeit' => 'ZustandTageslaufzeit', + 'RestMindestzeit' => 'ZustandRestMindestzeit', ]; $floatAttribute = [ 'Istleistung' => 'ZustandIstleistung', @@ -783,10 +882,12 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle 'Stoerung' => 'ZustandStoerung', 'Schaltzustand' => 'ZustandSchaltzustand', 'Rueckmeldefehler' => 'ZustandRueckmeldefehler', + 'SchaltbefehlAusstehend' => 'ZustandSchaltbefehlAusstehend', ]; $integerAttribute = [ 'Sollleistung' => 'ZustandSollleistung', 'Tageslaufzeit' => 'ZustandTageslaufzeit', + 'RestMindestzeit' => 'ZustandRestMindestzeit', ]; $floatAttribute = [ 'Istleistung' => 'ZustandIstleistung', @@ -838,6 +939,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle 'Meldezyklus', 'RueckmeldungVerzoegert', 'Umschaltfreigabe', + 'MindestzeitAbgelaufen', 'VorgabeTimeout', 'Tagesplanung', ] as $timer) { diff --git a/composer.json b/composer.json index dd2af8a..f7f3172 100644 --- a/composer.json +++ b/composer.json @@ -20,10 +20,16 @@ }, "scripts": { "lint": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l", + "lint:verbraucher-einstufig": "find VerbraucherEinStufig libs/EinStufigRegler.php tests/VerbraucherEinStufig -name '*.php' -print0 | xargs -0 -n1 php -l", "test": "phpunit", + "test:verbraucher-einstufig": "phpunit --configuration phpunit.verbraucher-einstufig.xml", "check": [ "@lint", "@test" + ], + "check:verbraucher-einstufig": [ + "@lint:verbraucher-einstufig", + "@test:verbraucher-einstufig" ] }, "config": { diff --git a/docs/adr/0002-verbraucher-einstufig-eventbasiert.md b/docs/adr/0002-verbraucher-einstufig-eventbasiert.md index 7f5db7b..d471df3 100644 --- a/docs/adr/0002-verbraucher-einstufig-eventbasiert.md +++ b/docs/adr/0002-verbraucher-einstufig-eventbasiert.md @@ -2,56 +2,73 @@ ## Status -Akzeptiert. +Akzeptiert, am 17. September 2026 um getrennte Mindestzeiten erweitert. ## 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. +bildete Lastwechselsperren ueber `Interval`, `IdleCounterMax` und weitere +zyklusabhaengige Zaehler ab. Ein erster Enelix-2-Stand ersetzte dies durch +einen allgemeinen `Umschaltabstand`. Dieser konnte unterschiedliche +Geraeteanforderungen fuer Ein- und Aus-Zustand nicht ausdruecken. + +Bei einem asynchron schaltenden Geraet kann ausserdem der Aktorbefehl nicht als +physische Zustandsbestaetigung gelten. ## Entscheidung -Der neue `VerbraucherEinStufig` verwendet keine zyklische Regelberechnung. +Der `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. +Der allgemeine `Umschaltabstand` entfaellt. An seine Stelle treten: + +- `Mindesteinschaltdauer` in Sekunden, Standard 5, +- `Mindestausschaltdauer` in Sekunden, Standard 5. + +Die passende Mindestzeit beginnt mit dem bestaetigten Zustandswechsel. Ohne +separate Rueckmeldung bestaetigt die Aktorvariable den Wechsel unmittelbar. +Mit separater Rueckmeldung beginnt die Mindestzeit erst, wenn deren Wert den +Zielzustand erreicht. + +Bis dahin bleiben Istleistung und Leistungsangebot beim rueckgemeldeten +Zustand. `SchaltbefehlAusstehend=true`, +`AenderungMoeglich=false` und `Schaltbereit=false` machen die laufende +Umschaltung fuer den Manager sichtbar. 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. +gezaehlt. Bei separater Rueckmeldung zaehlt ausschliesslich der bestaetigte +Ein-Zustand. ## Alternativen -- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Sperrzeit +- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Zeitregeln 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. +- Ein allgemeiner Umschaltabstand wurde verworfen, weil Ein- und Aus-Zustand + unterschiedliche Mindestzeiten benoetigen koennen. +- Der Aktorbefehl als sofortige physische Bestaetigung wurde bei vorhandener + Rueckmeldung verworfen. +- Ein zusaetzlicher Rueckmelde-Timeout wurde nicht eingefuehrt. Ein + ausstehender Befehl bleibt transparent sichtbar, bis ein neues Ereignis den + Zustand klaert. ## 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. +- `Interval`, `IdleCounterMax` und `Umschaltabstand` entfallen. +- Waerend Mindestzeiten und ausstehenden Rueckmeldungen wird nur die + bestaetigte Istleistung angeboten. +- Der Manager erhaelt Status und Restmindestzeit als Zustandseintraege. +- Eine unerwartete Abweichung zwischen Aktor und Rueckmeldung ausserhalb eines + laufenden Schaltvorgangs wird als Stoerung gemeldet. +- Tageslaufzeit und Mindestzeiten sind unabhaengig von einer Zyklusdauer. +- Das Modul bleibt vollstaendig im Repository Enelix EMS. ## Offene Punkte - Eine spaetere sperrbare Variante wird separat spezifiziert. -- Unterschiedliche Ein- und Ausschaltabstaende benoetigen eine neue - Architekturentscheidung. +- Ein konfigurierbarer Rueckmelde-Timeout benoetigt eine eigene Entscheidung. diff --git a/docs/migration/Verbraucher-1-Stufig.md b/docs/migration/Verbraucher-1-Stufig.md index 8b60ead..3b35788 100644 --- a/docs/migration/Verbraucher-1-Stufig.md +++ b/docs/migration/Verbraucher-1-Stufig.md @@ -1,27 +1,28 @@ # 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. +eine neue Modul-ID, Vertrag `3.0` und eine ereignisbasierte Regelung. ## Eigenschaften -| Enelix 1 | Enelix 2 | Migration | +| Enelix 1 / bisheriges Enelix 2 | Aktuelles 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. | +| `Mindesttlaufzeit` | `Mindestlaufzeit` | Alten Minutenwert fuer gleiches Verhalten mit 60 multiplizieren. | +| `Zeit_Zwischen_Zustandswechseln` | `Mindesteinschaltdauer`, `Mindestausschaltdauer` | Alte Minuten nicht ungeprueft uebernehmen; beide Sekundenwerte geraetespezifisch setzen. | +| `Umschaltabstand` aus fruehem Enelix 2 | `Mindesteinschaltdauer`, `Mindestausschaltdauer` | Der alte Wert wird nicht automatisch uebertragen; beide Werte haben Standard 5 s. | +| `Interval` | entfaellt | Keine zyklische Regelberechnung. | +| `IdleCounterMax` | entfaellt | Keine zyklusbasierte Idle-Erkennung. | ## Variablen und Verhalten -| Enelix 1 | Enelix 2 | +| Alt | Aktuell | | --- | --- | -| `DailyOnTime` | `Tageslaufzeit` in echten Sekunden | -| `IstNacht` | entfaellt; Mindestlaufzeit wird am spaetestmoeglichen Start des lokalen Kalendertags erzwungen | +| `DailyOnTime` | `Tageslaufzeit` in bestaetigten echten Sekunden | +| `IstNacht` | entfaellt; Tagesmindestlaufzeit wird spaetestmoeglich erzwungen | | `IsTimerActive` | entfaellt; interne einmalige Timer | | `Aktuelle_Leistung` | gemeinsames `Istleistung` | | `Power` | gemeinsames `Sollleistung` | @@ -32,25 +33,25 @@ eine neue Modul-ID, den Vertrag `3.0` und eine ereignisbasierte Regelung. | `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. +Bei konfigurierter Rueckmeldung bestimmt nicht mehr der ausgegebene +Aktorbefehl, sondern erst der rueckgemeldete Zustand die Istleistung, die +Tageslaufzeit und den Start der jeweiligen Mindestzeit. ## Inbetriebnahme -1. Bestehende Instanzkonfiguration und aktuelle Aktorlogik dokumentieren. -2. Neue Instanz **Verbraucher 1-Stufig** anlegen. +1. Bestehende Instanzkonfiguration und Aktorlogik dokumentieren. +2. Neue Instanz **Verbraucher 1-Stufig** anlegen oder die bestehende + Enelix-2-Instanz aktualisieren. 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. +4. Tagesmindestlaufzeit gegebenenfalls von Minuten in Sekunden umrechnen. +5. Mindest-Ein- und Mindest-Aus-Zeit getrennt anhand der Geraeteanforderungen + setzen. 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. +7. Ein- und Ausschalten zuerst ohne reale Last oder unter Aufsicht pruefen. +8. Bei separater Rueckmeldung kontrollieren, dass + `SchaltbefehlAusstehend` erst mit deren Zustandswechsel verschwindet. +9. Mindestens je einen verhinderten Ein- und Ausschaltvorgang testen. +10. Tageslaufzeit und Tageswechsel kontrollieren. ## Rueckkehr diff --git a/docs/module/Verbraucher-1-Stufig/README.md b/docs/module/Verbraucher-1-Stufig/README.md index 7454db4..73b63fd 100644 --- a/docs/module/Verbraucher-1-Stufig/README.md +++ b/docs/module/Verbraucher-1-Stufig/README.md @@ -3,9 +3,10 @@ > Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`. Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im freien Zustand -meldet es dem Manager das Leistungsangebot `[0, Nennleistung]`. Waehrend der -Umschaltsperre und bei lokal erzwungener Mindestlaufzeit wird nur der aktuell -zulaessige Leistungswert angeboten. +meldet es dem Manager das Leistungsangebot `[0, Nennleistung]`. Waerend einer +Mindest-Ein- oder Mindest-Aus-Zeit, waehrend einer ausstehenden Rueckmeldung +und bei lokal erzwungener Tagesmindestlaufzeit wird nur der aktuelle +bestaetigte Leistungswert angeboten. ## Architekturentscheidung @@ -14,18 +15,19 @@ 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 Ende der zustandsabhaengigen Mindestzeit, - 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. +Es gibt keinen Regelzyklus, kein `Interval`, keinen `IdleCounterMax` und +keinen allgemeinen `Umschaltabstand`. `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 +[`Schnittstelle.md`](../../Schnittstelle.md) kommen acht Modul-Properties hinzu. | Ident | Typ | Standard | Beschreibung | @@ -33,14 +35,19 @@ Modul-Properties hinzu. | `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. | +| `RueckmeldungVariableID` | Integer | `0` | Optionale Boolean-Rueckmeldung; `true` bedeutet physisch 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. | +| `Mindesteinschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Ein-Zustand gehalten wird. | +| `Mindestausschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Aus-Zustand gehalten wird. | +| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt gemeinsame und modulspezifische Diagnosevariablen an. | + +Beide Mindestzeiten duerfen `0` sein. Die lokale Aktion `Aktiv=false` +bleibt der einzige bewusste Override und schaltet sicher aus, auch wenn die +Mindesteinschaltdauer noch laeuft. `EinstellungenInVisu` ist Teil der gemeinsamen Verbraucherbasis. Das Modul besitzt derzeit keine zusaetzlichen bedienbaren Einstellvariablen und wertet -diese Property deshalb noch nicht weiter aus. +diese Property deshalb nicht weiter aus. ## Variablen @@ -49,10 +56,10 @@ Immer sichtbar: | Ident | Typ / Zugriff | Beschreibung | | --- | --- | --- | | `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. | +| `Schaltzustand` | Boolean / Anzeige | Bestaetigter oder aus dem Aktor berechneter Zustand. | +| `Tageslaufzeit` | Integer / Anzeige | Bestaetigte Laufzeit des lokalen Kalendertags in Sekunden. | -Mit `DiagnosevariablenAnzeigen` werden diese Variablen zusaetzlich angelegt: +Mit `DiagnosevariablenAnzeigen` werden zusaetzlich angelegt: - `Istleistung` - `Leistungsquelle` @@ -63,47 +70,83 @@ Mit `DiagnosevariablenAnzeigen` werden diese Variablen zusaetzlich angelegt: - `Stoerung` - `Stoertext` - `Rueckmeldefehler` +- `SchaltbefehlAusstehend` +- `RestMindestzeit` Die Regellogik arbeitet mit persistenten Attributen und ist nicht von der Sichtbarkeit der Diagnosevariablen abhaengig. -## Zeit- und Schaltverhalten +## Mindest-Ein- und Mindest-Aus-Zeit -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. +Nach einer bestaetigten Einschaltung wird fuer +`Mindesteinschaltdauer` Sekunden kein regulaeres Ausschalten angeboten. Nach +einer bestaetigten Ausschaltung blockiert `Mindestausschaltdauer` ein +regulaeres Einschalten. + +Waerend einer Mindestzeit gilt: + +- `AenderungMoeglich=false`, +- `Schaltbereit=false`, +- `RestMindestzeit_s` enthaelt die verbleibenden Sekunden, +- `Leistungswerte_W` enthaelt nur die bestaetigte Istleistung. + +Ein einmaliger Timer loest am Ende der Mindestzeit genau eine Neuberechnung +aus. Der fruehere allgemeine `Umschaltabstand` ist vollstaendig entfallen. + +## Rueckmeldung und Schaltbereitschaft + +Ohne separate Rueckmeldung wird der logische Zustand der Aktorvariable als +sofortige Schaltbestaetigung verwendet. + +Mit `RueckmeldungVariableID` ist ausschliesslich deren Booleanwert fuer +`Schaltzustand`, `Istleistung_W`, Tageslaufzeit und den Beginn der +Mindestzeit massgeblich. Nach einem Aktorbefehl und vor der passenden +Rueckmeldung meldet das Modul: + +- die bisherige bestaetigte Istleistung, +- `SchaltbefehlAusstehend=true`, +- `Schaltbereit=false`, +- `AenderungMoeglich=false`, +- nur den bisherigen Leistungswert in `Leistungswerte_W`. + +Sobald die Rueckmeldung den Zielzustand bestaetigt, beginnt die passende +Mindest-Ein- oder Mindest-Aus-Zeit. Es gibt bewusst keinen zusaetzlichen +zyklischen Rueckmelde-Timeout. Bleibt die Rueckmeldung aus, bleibt der +Schaltbefehl sichtbar ausstehend. + +Weichen Aktor- und Rueckmeldezustand ohne ausstehenden Schaltbefehl voneinander +ab, wird `Rueckmeldefehler=true`, `Verfuegbar=false` und eine Stoerung +gemeldet. + +## Tagesmindestlaufzeit 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`. +beruecksichtigt. Nur der bestaetigte Ein-Zustand zaehlt. -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. +Ist die konfigurierte Tagesmindestlaufzeit noch nicht erreicht, wird der +Verbraucher zum spaetestmoeglichen Zeitpunkt angefordert, an dem die +Restlaufzeit bis Mitternacht noch erfuellt werden kann. Eine noch aktive +Mindestausschaltdauer hat dabei Vorrang; eine unmoegliche Restlaufzeit kann +nicht rueckwirkend nachgeholt werden. ## Managerkommunikation Das Modul implementiert `VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet -ausschliesslich den Vertrag `3.0`. Der Verbraucher besitzt keine +ausschliesslich 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. +Neben den gemeinsamen Vertragsfeldern werden diese Zustaende gemeldet: + +- `Schaltzustand` +- `SchaltbefehlAusstehend` +- `Schaltbereit` +- `RestMindestzeit_s` +- `Tageslaufzeit_s` +- `Rueckmeldefehler` + 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. @@ -116,16 +159,14 @@ wird nach `VorgabeTimeout` Sekunden ohne Erneuerung ungueltig. 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. +4. Als Schaltkontakt eine Booleanvariable mit funktionsfaehiger Aktion + auswaehlen und bei Bedarf `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. +6. Tagesmindestlaufzeit, Mindest-Einschaltdauer und Mindest-Ausschaltdauer + passend zum angeschlossenen Geraet festlegen. +7. Die 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 @@ -134,23 +175,37 @@ wird nach `VorgabeTimeout` Sekunden ohne Erneuerung ungueltig. ## 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. +- Eine Ein-Vorgabe setzt zuerst den Aktor; mit separater Rueckmeldung bleibt + `SchaltbefehlAusstehend=true`, bis diese einschaltet. +- Die Mindesteinschaltdauer beginnt erst mit der Ein-Rueckmeldung. +- Vor Ablauf der Mindesteinschaltdauer wird kein Ausschalten angeboten. +- Nach bestaetigter Ausschaltung beginnt die Mindestausschaltdauer. +- Vor Ablauf der Mindestausschaltdauer wird kein Einschalten angeboten. +- `RestMindestzeit_s` erreicht 0 und `Schaltbereit` wird danach true. +- `Tageslaufzeit` steigt nur bei bestaetigtem Ein-Zustand. +- Ein unerwarteter Unterschied zwischen Aktor und Rueckmeldung erzeugt eine + Stoerung. +- Nach einem Neustart wird keine alte Manager-Vorgabe ungeprueft fortgesetzt. + +## Tests + +Nur dieses Modul: + +```bash +composer check:verbraucher-einstufig +``` + +Gesamtes Repository inklusive dieser Modulsuite: + +```bash +composer check +``` + +Die separate Suite und ihre Struktur sind unter +[`tests/VerbraucherEinStufig`](../../../tests/VerbraucherEinStufig/README.md) +dokumentiert. ## Migration -Die Zuordnung der Enelix-1-Felder, Einheiten und bewusst entfallenen -Kommunikationsvariablen steht in +Die Zuordnung der Enelix-1- und bisherigen Enelix-2-Zeitwerte steht in [`docs/migration/Verbraucher-1-Stufig.md`](../../migration/Verbraucher-1-Stufig.md). diff --git a/libs/EinStufigRegler.php b/libs/EinStufigRegler.php index 74563f1..31d7d5a 100644 --- a/libs/EinStufigRegler.php +++ b/libs/EinStufigRegler.php @@ -34,6 +34,28 @@ final class EinStufigRegler return [0, $nennleistung]; } + public static function freigabezeitpunkt( + bool $eingeschaltet, + int $bestaetigtSeit, + int $mindesteinschaltdauer, + int $mindestausschaltdauer + ): int { + if ($bestaetigtSeit < 0 || $mindesteinschaltdauer < 0 || $mindestausschaltdauer < 0) { + throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.'); + } + + return $bestaetigtSeit + ($eingeschaltet ? $mindesteinschaltdauer : $mindestausschaltdauer); + } + + public static function restMindestzeit(int $freigabezeitpunkt, int $jetzt): int + { + if ($freigabezeitpunkt < 0 || $jetzt < 0) { + throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.'); + } + + return max(0, $freigabezeitpunkt - $jetzt); + } + public static function mussMindestlaufzeitErzwingen( int $tageslaufzeit, int $mindestlaufzeit, diff --git a/phpunit.verbraucher-einstufig.xml b/phpunit.verbraucher-einstufig.xml new file mode 100644 index 0000000..b36f2fc --- /dev/null +++ b/phpunit.verbraucher-einstufig.xml @@ -0,0 +1,13 @@ + + + + + tests/VerbraucherEinStufig + + + diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index 5cc692d..2cdb8f3 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -35,7 +35,7 @@ final class ManagerModulstrukturTest extends TestCase 512, JSON_THROW_ON_ERROR ); - $json = json_encode($formular, JSON_THROW_ON_ERROR); + $json = json_encode($formular, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); foreach ([ 'NetzleistungVariableID', diff --git a/tests/EinStufigReglerTest.php b/tests/VerbraucherEinStufig/EinStufigReglerTest.php similarity index 84% rename from tests/EinStufigReglerTest.php rename to tests/VerbraucherEinStufig/EinStufigReglerTest.php index c8f8945..ba20a3c 100644 --- a/tests/EinStufigReglerTest.php +++ b/tests/VerbraucherEinStufig/EinStufigReglerTest.php @@ -43,6 +43,25 @@ final class EinStufigReglerTest extends TestCase EinStufigRegler::leistungsangebot(3500, 1200, true, true, false); } + public function testFreigabeVerwendetZustandsabhaengigeMindestdauer(): void + { + self::assertSame(1010, EinStufigRegler::freigabezeitpunkt(true, 1000, 10, 20)); + self::assertSame(1020, EinStufigRegler::freigabezeitpunkt(false, 1000, 10, 20)); + } + + public function testRestMindestzeitWirdNieNegativ(): void + { + self::assertSame(7, EinStufigRegler::restMindestzeit(1007, 1000)); + self::assertSame(0, EinStufigRegler::restMindestzeit(1000, 1007)); + } + + public function testNegativeMindestdauerWirdAbgewiesen(): void + { + $this->expectException(InvalidArgumentException::class); + + EinStufigRegler::freigabezeitpunkt(true, 1000, -1, 0); + } + public function testMindestlaufzeitWirdErstZumSpaetestmoeglichenStartErzwungen(): void { self::assertFalse(EinStufigRegler::mussMindestlaufzeitErzwingen(0, 3600, 7200)); diff --git a/tests/VerbraucherEinStufigModulstrukturTest.php b/tests/VerbraucherEinStufig/ModulstrukturTest.php similarity index 68% rename from tests/VerbraucherEinStufigModulstrukturTest.php rename to tests/VerbraucherEinStufig/ModulstrukturTest.php index 1d24f13..3b80a1b 100644 --- a/tests/VerbraucherEinStufigModulstrukturTest.php +++ b/tests/VerbraucherEinStufig/ModulstrukturTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase; final class VerbraucherEinStufigModulstrukturTest extends TestCase { - private const MODULPFAD = __DIR__ . '/../VerbraucherEinStufig/'; + private const MODULPFAD = __DIR__ . '/../../VerbraucherEinStufig/'; public function testModulIstVollstaendigUndMetadatenSindGueltig(): void { @@ -43,7 +43,7 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase public function testPropertiesSindRegistriertUndImFormularVorhanden(): void { - $basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php'); + $basis = (string) file_get_contents(__DIR__ . '/../../libs/VerbraucherBasisTrait.php'); $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); preg_match_all( '/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/', @@ -65,7 +65,8 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase 'SchaltkontaktInvertiert', 'RueckmeldungVariableID', 'Mindestlaufzeit', - 'Umschaltabstand', + 'Mindesteinschaltdauer', + 'Mindestausschaltdauer', 'DiagnosevariablenAnzeigen', ], $properties @@ -77,17 +78,21 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase } } - public function testNurUmschaltabstandErsetztAlteZyklusProperties(): void + public function testGetrennteMindestzeitenErsetzenUmschaltabstand(): void { $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); self::assertStringContainsString( - "RegisterPropertyInteger('Umschaltabstand', 5)", + "RegisterPropertyInteger('Mindesteinschaltdauer', 5)", $modul ); + self::assertStringContainsString( + "RegisterPropertyInteger('Mindestausschaltdauer', 5)", + $modul + ); + self::assertStringNotContainsString("RegisterPropertyInteger('Umschaltabstand'", $modul); self::assertStringNotContainsString("RegisterPropertyInteger('Interval'", $modul); self::assertStringNotContainsString("RegisterPropertyInteger('IdleCounterMax'", $modul); - self::assertStringNotContainsString("RegisterTimer('Regelzyklus'", $modul); } public function testRegelungReagiertAufEreignisseUndEinmalTimer(): void @@ -96,13 +101,33 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase self::assertStringContainsString('public function MessageSink', $modul); self::assertStringContainsString('RegisterMessage($neueID, self::VM_UPDATE)', $modul); - foreach (['Umschaltfreigabe', 'VorgabeTimeout', 'Tagesplanung'] as $timer) { + foreach (['MindestzeitAbgelaufen', 'VorgabeTimeout', 'Tagesplanung'] as $timer) { self::assertStringContainsString("'$timer'", $modul); } self::assertStringContainsString( "SetTimerInterval('Meldezyklus', \$this->ReadPropertyInteger('Meldeintervall') * 1000)", $modul ); + self::assertStringNotContainsString("case 'Umschaltfreigabe':", $modul); + self::assertStringNotContainsString("RegisterTimer('Regelzyklus'", $modul); + } + + public function testRueckmeldungBestimmtZustandUndSchaltbereitschaft(): void + { + $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); + + self::assertStringContainsString( + "ReadPropertyInteger('RueckmeldungVariableID')", + $modul + ); + self::assertStringContainsString('bestaetigeZustandswechsel', $modul); + self::assertStringContainsString("'Kennung' => 'SchaltbefehlAusstehend'", $modul); + self::assertStringContainsString("'Kennung' => 'Schaltbereit'", $modul); + self::assertStringContainsString("'Kennung' => 'RestMindestzeit_s'", $modul); + self::assertStringContainsString( + 'setzeZustand(\'AenderungMoeglich\', $aenderungMoeglich)', + $modul + ); } public function testModulVerwendetAusschliesslichDenVertrag30(): void @@ -117,25 +142,19 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul); } - public function testFachvariablenUndRueckmeldestoerungSindEnthalten(): void + public function testFachvariablenUndRueckmeldestatusSindEnthalten(): void { $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); - self::assertStringContainsString( - "RegisterVariableBoolean('Schaltzustand'", - $modul - ); - self::assertStringContainsString( - "RegisterVariableInteger('Tageslaufzeit'", - $modul - ); + self::assertStringContainsString("RegisterVariableBoolean('Schaltzustand'", $modul); + self::assertStringContainsString("RegisterVariableInteger('Tageslaufzeit'", $modul); self::assertStringContainsString("'Kennung' => 'Rueckmeldefehler'", $modul); self::assertStringContainsString("'Kennung' => 'Tageslaufzeit_s'", $modul); } public function testManagerKenntDieModulIDGenauEinmal(): void { - $manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); + $manager = (string) file_get_contents(__DIR__ . '/../../Manager/module.php'); $modulID = '{15879A4E-D0C2-4495-83DE-46E1E462591E}'; self::assertSame(1, substr_count($manager, $modulID)); @@ -149,4 +168,20 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase self::assertStringContainsString('registriereVerbraucherDiagnose', $modul); self::assertStringContainsString('UnregisterVariable($ident)', $modul); } + + public function testSeparateSuiteIstImGesamttestEnthalten(): void + { + self::assertFileExists(__DIR__ . '/../../phpunit.verbraucher-einstufig.xml'); + + $composer = json_decode( + (string) file_get_contents(__DIR__ . '/../../composer.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + self::assertArrayHasKey('check:verbraucher-einstufig', $composer['scripts']); + + $gesamt = (string) file_get_contents(__DIR__ . '/../../phpunit.xml.dist'); + self::assertStringContainsString('tests', $gesamt); + } } diff --git a/tests/VerbraucherEinStufig/README.md b/tests/VerbraucherEinStufig/README.md new file mode 100644 index 0000000..5936329 --- /dev/null +++ b/tests/VerbraucherEinStufig/README.md @@ -0,0 +1,16 @@ +# Tests fuer Verbraucher 1-Stufig + +Die Tests des Moduls koennen unabhaengig vom restlichen Repository ausgefuehrt +werden: + +```bash +composer check:verbraucher-einstufig +``` + +Der Befehl prueft nur den Modulcode, die reine Regellogik und diese Tests. +Der allgemeine Befehl bleibt der externe Gesamteinstieg und schliesst die +Modulsuite durch das rekursive Verzeichnis `tests` automatisch ein: + +```bash +composer check +```