From 7a3c436039b251ab83ea4357747994ea578afe0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=A4fliger?= Date: Wed, 16 Sep 2026 05:18:01 +0000 Subject: [PATCH] Verbraucher Warmwassererwaermer implementieren --- README.md | 8 +- Warmwassererwaermer/form.json | 288 ++++++ Warmwassererwaermer/module.json | 15 + Warmwassererwaermer/module.php | 898 ++++++++++++++++++ docs/Schnittstelle.md | 2 +- docs/adr/0001-warmwassererwaermer-vertrag.md | 24 + docs/migration/Boiler-x-Stufig.md | 49 + docs/module/README.md | 6 +- docs/module/Wassererwaermer/README.md | 139 ++- libs/WarmwasserRegler.php | 192 ++++ tests/DokumentationsstrukturTest.php | 26 +- tests/ManagerReglerTest.php | 2 +- tests/WarmwasserReglerTest.php | 82 ++ .../WarmwassererwaermerModulstrukturTest.php | 89 ++ 14 files changed, 1751 insertions(+), 69 deletions(-) create mode 100644 Warmwassererwaermer/form.json create mode 100644 Warmwassererwaermer/module.json create mode 100644 Warmwassererwaermer/module.php create mode 100644 docs/adr/0001-warmwassererwaermer-vertrag.md create mode 100644 docs/migration/Boiler-x-Stufig.md create mode 100644 libs/WarmwasserRegler.php create mode 100644 tests/WarmwasserReglerTest.php create mode 100644 tests/WarmwassererwaermerModulstrukturTest.php diff --git a/README.md b/README.md index d90b5d5..a648b7f 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon. ## Status Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die -Deklaration der Verbraucherbasis und das Manager-Modul sind implementiert. Die -weiteren Module sind als Diskussionsentwürfe dokumentiert. +Verbraucherbasis, der Manager und der mehrstufige Warmwassererwaermer sind +implementiert. Die weiteren Module sind als Diskussionsentwürfe dokumentiert. ## Geplante Module - Manager (implementiert) - Batterie -- Wassererwaermer +- Wassererwaermer (implementiert) - Pufferspeicher - Verbraucher 1-Stufig - Waermepumpe @@ -50,5 +50,7 @@ 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) +- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md) - [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md) - [Modulübersicht](docs/module/README.md) diff --git a/Warmwassererwaermer/form.json b/Warmwassererwaermer/form.json new file mode 100644 index 0000000..05d5965 --- /dev/null +++ b/Warmwassererwaermer/form.json @@ -0,0 +1,288 @@ +{ + "elements": [ + { + "type": "Label", + "caption": "Mehrstufiger Warmwassererwaermer nach Enelix-2-Vertrag 3.0" + }, + { + "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": "Interval", + "caption": "Regelintervall", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "IdleCounterMax", + "caption": "Zyklen bis Idle", + "minimum": 1 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Leistungsstufen", + "expanded": true, + "items": [ + { + "type": "List", + "name": "LeistungsStufen", + "caption": "Exklusive Leistungsstufen und Schaltkontakte", + "add": true, + "delete": true, + "sortable": true, + "columns": [ + { + "caption": "Stufe", + "name": "Stufe", + "width": "100px", + "add": 1, + "edit": { + "type": "NumberSpinner", + "minimum": 1 + } + }, + { + "caption": "Leistung", + "name": "Leistung", + "width": "180px", + "add": 0, + "edit": { + "type": "NumberSpinner", + "minimum": 1, + "suffix": " W" + } + }, + { + "caption": "Schaltkontakt", + "name": "Schaltkontakt_Stufe", + "width": "auto", + "add": 0, + "edit": { + "type": "SelectVariable" + } + } + ] + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Temperatur und Speicher", + "expanded": true, + "items": [ + { + "type": "SelectVariable", + "name": "Boilerfuehler_PT1", + "caption": "Boilerfuehler" + }, + { + "type": "NumberSpinner", + "name": "TemperaturMaxAlter", + "caption": "Maximales Messwertalter", + "suffix": " s", + "minimum": 1 + }, + { + "type": "CheckBox", + "name": "BoilertemperaturGlaetten", + "caption": "Boilertemperatur mit PT1 glaetten" + }, + { + "type": "NumberSpinner", + "name": "ZeitKonstante", + "caption": "PT1-Zeitkonstante", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "Boilervolumen", + "caption": "Boilervolumen", + "suffix": " l", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "Hysterese", + "caption": "Temperaturhysterese", + "suffix": " K", + "minimum": 0.1, + "digits": 1 + }, + { + "type": "CheckBox", + "name": "EinstellungenInVisu", + "caption": "Temperatursollwerte in der Visualisierung anzeigen und bedienen" + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Zeitplan", + "items": [ + { + "type": "List", + "name": "Zeitplan", + "caption": "Naechste Solltemperatur vorausberechnen", + "add": true, + "delete": true, + "sortable": true, + "columns": [ + { + "caption": "Uhrzeit", + "name": "Uhrzeit", + "width": "150px", + "add": "00:00", + "edit": { + "type": "ValidationTextBox" + } + }, + { + "caption": "Solltemperatur", + "name": "Solltemperatur", + "width": "180px", + "add": 50, + "edit": { + "type": "NumberSpinner", + "minimum": 0, + "maximum": 100, + "digits": 1, + "suffix": " C" + } + } + ] + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Legionellenfunktion", + "items": [ + { + "type": "CheckBox", + "name": "LegionellenfunktionAktiv", + "caption": "Legionellenfunktion aktiv" + }, + { + "type": "NumberSpinner", + "name": "LegionellenIntervallTage", + "caption": "Intervall", + "suffix": " Tage", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "LegionellenHaltezeit", + "caption": "Haltezeit auf Legionellentemperatur", + "suffix": " s", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "LegionellenMaximaldauer", + "caption": "Maximaldauer", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "NachtstartStunde", + "caption": "Nachtbeginn", + "suffix": " Uhr", + "minimum": 0, + "maximum": 23 + }, + { + "type": "NumberSpinner", + "name": "NachtendeStunde", + "caption": "Nachtende", + "suffix": " Uhr", + "minimum": 0, + "maximum": 23 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Diagnose", + "items": [ + { + "type": "CheckBox", + "name": "DiagnosevariablenAnzeigen", + "caption": "Diagnosevariablen anzeigen" + }, + { + "type": "CheckBox", + "name": "LoggingEin", + "caption": "Laufendes Debug-Logging aktivieren" + } + ] + } + ], + "actions": [ + { + "type": "Button", + "caption": "Regelung jetzt ausfuehren", + "onClick": "IPS_RequestAction($id, 'Regelzyklus', false);" + }, + { + "type": "Button", + "caption": "Aktuellen Status einmalig ins Debug-Log schreiben", + "onClick": "IPS_RequestAction($id, 'LogStatus', true);" + } + ], + "status": [ + { + "code": 102, + "icon": "active", + "caption": "Aktiv" + }, + { + "code": 201, + "icon": "inactive", + "caption": "Temperaturmessung fehlt oder ist veraltet" + }, + { + "code": 202, + "icon": "error", + "caption": "Konfiguration ungueltig" + }, + { + "code": 203, + "icon": "error", + "caption": "Schaltfehler" + } + ] +} diff --git a/Warmwassererwaermer/module.json b/Warmwassererwaermer/module.json new file mode 100644 index 0000000..2d49deb --- /dev/null +++ b/Warmwassererwaermer/module.json @@ -0,0 +1,15 @@ +{ + "id": "{B7C54AF4-AD7D-4FE4-B75D-203693906251}", + "name": "VerbraucherWarmwassererwaermer", + "type": 3, + "vendor": "Belevo AG", + "aliases": [ + "Enelix 2 Verbraucher Warmwassererwaermer", + "Enelix 2 Wassererwaermer" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENELIX", + "url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Warmwassererwaermer" +} diff --git a/Warmwassererwaermer/module.php b/Warmwassererwaermer/module.php new file mode 100644 index 0000000..8ed6303 --- /dev/null +++ b/Warmwassererwaermer/module.php @@ -0,0 +1,898 @@ + */ + private const DIAGNOSE_VARIABLEN = [ + 'Leistungsquelle', + 'SollwertGueltig', + 'Verfuegbar', + 'AenderungMoeglich', + 'TemperaturGueltig', + 'NachladenAktiv', + 'LegionellenbetriebAktiv', + 'LegioCounter', + 'Idle', + 'IdleCounter', + ]; + + public function Create(): void + { + parent::Create(); + + $this->registriereVerbraucherBasis(); + + // Aus Boiler_x_Stufig uebernommene und sicher ergaenzte Konfiguration. + $this->RegisterPropertyString('LeistungsStufen', '[]'); + $this->RegisterPropertyInteger('ZeitKonstante', 120); + $this->RegisterPropertyInteger('Boilerfuehler_PT1', 0); + $this->RegisterPropertyBoolean('BoilertemperaturGlaetten', false); + $this->RegisterPropertyInteger('Boilervolumen', 300); + $this->RegisterPropertyString('Zeitplan', '[]'); + $this->RegisterPropertyInteger('Interval', 5); + $this->RegisterPropertyInteger('IdleCounterMax', 2); + $this->RegisterPropertyInteger('TemperaturMaxAlter', 30); + $this->RegisterPropertyFloat('Hysterese', 5.0); + $this->RegisterPropertyBoolean('LegionellenfunktionAktiv', true); + $this->RegisterPropertyInteger('LegionellenIntervallTage', 7); + $this->RegisterPropertyInteger('LegionellenHaltezeit', 0); + $this->RegisterPropertyInteger('LegionellenMaximaldauer', 86400); + $this->RegisterPropertyInteger('NachtstartStunde', 22); + $this->RegisterPropertyInteger('NachtendeStunde', 7); + $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); + + $this->RegisterVariableFloat('Boilertemperatur', 'Boilertemperatur', '~Temperature', 100); + $this->RegisterVariableInteger('AktiveStufe', 'Aktive Stufe', '', 110); + $this->RegisterVariableFloat('BezogeneEnergie', 'Bezogene Energie', '~Electricity', 120); + + $this->RegisterVariableFloat('Mindesttemperatur', 'Mindesttemperatur', '~Temperature', 130); + $this->RegisterVariableFloat('Maximaltemperatur', 'Maximaltemperatur', '~Temperature', 140); + $this->RegisterVariableFloat('Legionellentemperatur', 'Legionellentemperatur', '~Temperature', 150); + $this->EnableAction('Mindesttemperatur'); + $this->EnableAction('Maximaltemperatur'); + $this->EnableAction('Legionellentemperatur'); + + $this->RegisterVariableBoolean('TemperaturGueltig', 'Temperatur gueltig', '~Switch', 200); + $this->RegisterVariableBoolean('NachladenAktiv', 'Nachladen aktiv', '~Switch', 210); + $this->RegisterVariableBoolean( + 'LegionellenbetriebAktiv', + 'Legionellenbetrieb aktiv', + '~Switch', + 220 + ); + $this->RegisterVariableInteger('LegioCounter', 'Zeit seit Legionellenabschluss', '', 230); + $this->RegisterVariableBoolean('Idle', 'Idle', '~Switch', 240); + $this->RegisterVariableInteger('IdleCounter', 'Idle Counter', '', 250); + + $this->RegisterAttributeBoolean('InitialwerteGesetzt', false); + $this->RegisterAttributeInteger('RegistrierteFuehlerID', 0); + $this->RegisterAttributeInteger('LetzteBerechnung', 0); + $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); + $this->RegisterAttributeInteger('LetzteManagerID', 0); + $this->RegisterAttributeInteger('LetzterLegionellenabschluss', 0); + $this->RegisterAttributeInteger('LegionellenStart', 0); + $this->RegisterAttributeInteger('LegionellenSeitZiel', 0); + $this->RegisterAttributeFloat('Glaettungswert', 0.0); + $this->RegisterAttributeBoolean('GlaettungInitialisiert', false); + $this->RegisterAttributeString('Leistungsangebot', '[]'); + $this->RegisterAttributeString('Schaltfehler', ''); + $this->RegisterAttributeString('Legionellenfehler', ''); + + $this->RegisterTimer( + 'Regelzyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Regelzyklus', false);" + ); + $this->RegisterTimer( + 'Meldezyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);" + ); + $this->RegisterTimer( + 'RueckmeldungVerzoegert', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);" + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $this->initialisiereTemperaturen(); + $this->aktualisiereSichtbarkeit(); + + try { + $this->pruefeKonfiguration(); + } catch (Throwable $fehler) { + $this->SetTimerInterval('Regelzyklus', 0); + $this->SetTimerInterval('Meldezyklus', 0); + $this->SetTimerInterval('RueckmeldungVerzoegert', 0); + $this->SetValue('Verfuegbar', false); + $this->SetValue('AenderungMoeglich', false); + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + $this->protokolliere('Konfiguration', $fehler->getMessage()); + return; + } + + $this->registriereFuehlermeldung(); + $this->SetTimerInterval('Regelzyklus', $this->ReadPropertyInteger('Interval') * 1000); + $this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000); + if ($this->ReadAttributeInteger('LetzterLegionellenabschluss') <= 0) { + $this->WriteAttributeInteger('LetzterLegionellenabschluss', time()); + } + + $this->regelzyklus(true); + } + + public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void + { + if ((int) $senderID === $this->ReadPropertyInteger('Boilerfuehler_PT1') + && (int) $nachricht === self::VM_UPDATE + ) { + $this->regelzyklus(true); + } + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'Aktiv': + $this->SetValue('Aktiv', (bool) $wert); + $this->regelzyklus(true); + return; + + case 'Mindesttemperatur': + case 'Maximaltemperatur': + case 'Legionellentemperatur': + $this->setzeTemperaturSollwert((string) $ident, $wert); + return; + + case 'Regelzyklus': + $this->regelzyklus(true); + return; + + case 'Melden': + if ((bool) $wert) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 0); + } + $this->sendeVerbraucherdaten(); + 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; + + case 'LogStatus': + $this->protokolliereStatus(true); + 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->regelzyklus(false); + $angebot = $this->leseLeistungsangebot(); + $sollleistung = $daten['Sollleistung_W']; + if (!in_array($sollleistung, $angebot, true)) { + throw new InvalidArgumentException('Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'); + } + + $this->WriteAttributeInteger('LetzteManagerID', $managerID); + $this->WriteAttributeInteger('LetzteVorgabeZeit', time()); + $this->SetValue('Sollleistung', $sollleistung); + $this->SetValue('SollwertGueltig', true); + $this->regelzyklus(false); + + // Verhindert eine synchrone Manager-Verbraucher-Endlosschleife. + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + + private function regelzyklus(bool $meldungPlanen): void + { + try { + $jetzt = time(); + $this->aktualisiereEnergie($jetzt); + $temperaturGueltig = $this->aktualisiereTemperatur(); + $this->aktualisiereLegionellenstatus($temperaturGueltig, $jetzt); + + [$angebot, $aenderungMoeglich, $verfuegbar, $zwangsleistung] = + $this->bestimmeLeistungsangebot($temperaturGueltig, $jetzt); + $this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR)); + $this->SetValue('Verfuegbar', $verfuegbar); + $this->SetValue('AenderungMoeglich', $aenderungMoeglich); + + $letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit'); + if ($letzteVorgabe <= 0 + || $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout') + ) { + $this->SetValue('SollwertGueltig', false); + } + + $ziel = 0; + if ($verfuegbar) { + if ($zwangsleistung !== null) { + $ziel = $zwangsleistung; + } elseif ((bool) $this->GetValue('SollwertGueltig')) { + $ziel = (int) $this->GetValue('Sollleistung'); + if (!in_array($ziel, $angebot, true)) { + $this->SetValue('SollwertGueltig', false); + $ziel = 0; + } + } + } + + $this->schalteLeistung($ziel); + $this->SetValue('Sollleistung', $ziel); + $this->aktualisiereStoerungen($temperaturGueltig); + if ($meldungPlanen) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + } catch (Throwable $fehler) { + $this->WriteAttributeString('Schaltfehler', $fehler->getMessage()); + $this->SetValue('Verfuegbar', false); + $this->SetValue('AenderungMoeglich', false); + $this->SetValue('Stoerung', true); + $this->SetValue('Stoertext', $fehler->getMessage()); + $this->SetStatus(self::STATUS_SCHALTFEHLER); + $this->protokolliere('Regelzyklus', $fehler->getMessage()); + } + } + + private function aktualisiereTemperatur(): bool + { + $fuehlerID = $this->ReadPropertyInteger('Boilerfuehler_PT1'); + if ($fuehlerID <= 0 || !IPS_VariableExists($fuehlerID)) { + $this->SetValue('TemperaturGueltig', false); + return false; + } + + $variable = IPS_GetVariable($fuehlerID); + if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('TemperaturMaxAlter')) { + $this->SetValue('TemperaturGueltig', false); + return false; + } + $rohwert = GetValue($fuehlerID); + if (!is_int($rohwert) && !is_float($rohwert)) { + $this->SetValue('TemperaturGueltig', false); + return false; + } + + $temperatur = (float) $rohwert; + if ($this->ReadPropertyBoolean('BoilertemperaturGlaetten')) { + if (!$this->ReadAttributeBoolean('GlaettungInitialisiert')) { + $this->WriteAttributeFloat('Glaettungswert', $temperatur); + $this->WriteAttributeBoolean('GlaettungInitialisiert', true); + } else { + $temperatur = WarmwasserRegler::pt1( + $this->ReadAttributeFloat('Glaettungswert'), + $temperatur, + $this->ReadPropertyInteger('Interval'), + $this->ReadPropertyInteger('ZeitKonstante') + ); + $this->WriteAttributeFloat('Glaettungswert', $temperatur); + } + } else { + $this->WriteAttributeBoolean('GlaettungInitialisiert', false); + } + + $this->SetValue('Boilertemperatur', $temperatur); + $this->SetValue('TemperaturGueltig', true); + + return true; + } + + /** + * @return array{0: list, 1: bool, 2: bool, 3: int|null} + */ + private function bestimmeLeistungsangebot(bool $temperaturGueltig, int $jetzt): array + { + $stufen = WarmwasserRegler::dekodiereLeistungsstufen($this->ReadPropertyString('LeistungsStufen')); + if (!(bool) $this->GetValue('Aktiv') || !$temperaturGueltig || $stufen === []) { + $this->SetValue('NachladenAktiv', false); + return [[], false, false, 0]; + } + + $alleLeistungen = WarmwasserRegler::leistungswerte($stufen); + $maximalleistung = max($alleLeistungen); + $temperatur = (float) $this->GetValue('Boilertemperatur'); + $minimum = (float) $this->GetValue('Mindesttemperatur'); + $maximum = (float) $this->GetValue('Maximaltemperatur'); + $hysterese = $this->ReadPropertyFloat('Hysterese'); + $aktuelleLeistung = (int) round((float) $this->GetValue('Istleistung')); + + $nachladen = (bool) $this->GetValue('NachladenAktiv'); + if ($temperatur < $minimum) { + $nachladen = true; + } elseif ($temperatur >= $minimum + $hysterese) { + $nachladen = false; + } + $this->SetValue('NachladenAktiv', $nachladen); + + $zeitplanErfordertHeizen = false; + $naechster = WarmwasserRegler::naechsterZeitplaneintrag( + $this->ReadPropertyString('Zeitplan'), + new DateTimeImmutable('now') + ); + if ($naechster !== null && $temperatur < $naechster['Solltemperatur']) { + $zeitplanErfordertHeizen = !WarmwasserRegler::kannZielErreichen( + (float) $this->ReadPropertyInteger('Boilervolumen'), + $temperatur, + $naechster['Solltemperatur'], + max(0, $naechster['Zeitpunkt'] - $jetzt), + $maximalleistung + ); + } + + if ((bool) $this->GetValue('LegionellenbetriebAktiv') || $nachladen || $zeitplanErfordertHeizen) { + return [[$maximalleistung], false, true, $maximalleistung]; + } + if ($temperatur >= $maximum) { + return [[0], false, true, 0]; + } + if ($aktuelleLeistung > 0 && $temperatur >= $maximum - $hysterese) { + return [[$aktuelleLeistung], false, true, $aktuelleLeistung]; + } + + return [$alleLeistungen, true, true, null]; + } + + private function aktualisiereLegionellenstatus(bool $temperaturGueltig, int $jetzt): void + { + $letzterAbschluss = $this->ReadAttributeInteger('LetzterLegionellenabschluss'); + $this->SetValue('LegioCounter', max(0, $jetzt - $letzterAbschluss)); + + if (!$this->ReadPropertyBoolean('LegionellenfunktionAktiv')) { + $this->SetValue('LegionellenbetriebAktiv', false); + $this->WriteAttributeInteger('LegionellenStart', 0); + $this->WriteAttributeInteger('LegionellenSeitZiel', 0); + $this->WriteAttributeString('Legionellenfehler', ''); + return; + } + + $aktiv = (bool) $this->GetValue('LegionellenbetriebAktiv'); + $faellig = $jetzt - $letzterAbschluss >= $this->ReadPropertyInteger('LegionellenIntervallTage') * 86400; + if (!$aktiv && $faellig && $this->istNachtstunde((int) date('G', $jetzt))) { + $aktiv = true; + $this->WriteAttributeInteger('LegionellenStart', $jetzt); + $this->WriteAttributeInteger('LegionellenSeitZiel', 0); + } + + if ($aktiv && $jetzt - $this->ReadAttributeInteger('LegionellenStart') + > $this->ReadPropertyInteger('LegionellenMaximaldauer') + ) { + $aktiv = false; + $this->WriteAttributeInteger('LetzterLegionellenabschluss', $jetzt); + $this->WriteAttributeString( + 'Legionellenfehler', + 'Legionellenprogramm innerhalb der Maximaldauer nicht abgeschlossen.' + ); + } + + if ($aktiv && $temperaturGueltig + && (float) $this->GetValue('Boilertemperatur') >= (float) $this->GetValue('Legionellentemperatur') + ) { + $seitZiel = $this->ReadAttributeInteger('LegionellenSeitZiel'); + if ($seitZiel <= 0) { + $seitZiel = $jetzt; + $this->WriteAttributeInteger('LegionellenSeitZiel', $seitZiel); + } + if ($jetzt - $seitZiel >= $this->ReadPropertyInteger('LegionellenHaltezeit')) { + $aktiv = false; + $this->WriteAttributeInteger('LetzterLegionellenabschluss', $jetzt); + $this->WriteAttributeInteger('LegionellenStart', 0); + $this->WriteAttributeInteger('LegionellenSeitZiel', 0); + $this->WriteAttributeString('Legionellenfehler', ''); + } + } + + $this->SetValue('LegionellenbetriebAktiv', $aktiv); + } + + private function schalteLeistung(int $zielLeistung): void + { + $stufen = WarmwasserRegler::dekodiereLeistungsstufen($this->ReadPropertyString('LeistungsStufen')); + $zielStufe = WarmwasserRegler::stufeFuerLeistung($stufen, $zielLeistung); + $bisherigeLeistung = (int) round((float) $this->GetValue('Istleistung')); + if ($zielLeistung === $bisherigeLeistung && $this->kontakteEntsprechenZiel($stufen, $zielLeistung)) { + $this->WriteAttributeString('Schaltfehler', ''); + $this->verarbeiteIdle(false); + return; + } + + try { + // Break-before-make: zuerst alle Kontakte aus, danach genau eine Stufe ein. + foreach ($stufen as $stufe) { + RequestAction($stufe['KontaktID'], false); + } + if ($zielLeistung > 0) { + foreach ($stufen as $stufe) { + if ($stufe['Leistung_W'] === $zielLeistung) { + RequestAction($stufe['KontaktID'], true); + break; + } + } + } + } catch (Throwable $fehler) { + foreach ($stufen as $stufe) { + try { + RequestAction($stufe['KontaktID'], false); + } catch (Throwable $ignoriert) { + } + } + $this->SetValue('Istleistung', 0.0); + $this->SetValue('AktiveStufe', 0); + throw new RuntimeException('Leistungsstufe konnte nicht sicher geschaltet werden: ' . $fehler->getMessage()); + } + + $this->WriteAttributeString('Schaltfehler', ''); + $this->SetValue('Istleistung', (float) $zielLeistung); + $this->SetValue('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET); + $this->SetValue('AktiveStufe', $zielStufe); + $this->verarbeiteIdle(true); + $this->protokolliere('Leistungsstufe', ['Leistung_W' => $zielLeistung, 'Stufe' => $zielStufe]); + } + + /** + * @param list $stufen + */ + private function kontakteEntsprechenZiel(array $stufen, int $zielLeistung): bool + { + foreach ($stufen as $stufe) { + if (!IPS_VariableExists($stufe['KontaktID'])) { + return false; + } + $sollEin = $zielLeistung > 0 && $stufe['Leistung_W'] === $zielLeistung; + if ((bool) GetValue($stufe['KontaktID']) !== $sollEin) { + return false; + } + } + + return true; + } + + private function verarbeiteIdle(bool $geaendert): void + { + $counter = (int) $this->GetValue('IdleCounter'); + if ($geaendert) { + $counter = $this->ReadPropertyInteger('IdleCounterMax'); + } elseif ($counter > 0) { + --$counter; + } + $this->SetValue('IdleCounter', $counter); + $this->SetValue('Idle', $counter === 0); + } + + private function aktualisiereEnergie(int $jetzt): void + { + $letzteBerechnung = $this->ReadAttributeInteger('LetzteBerechnung'); + $this->WriteAttributeInteger('LetzteBerechnung', $jetzt); + if ($letzteBerechnung <= 0 || $jetzt <= $letzteBerechnung) { + return; + } + + $zuwachs = WarmwasserRegler::energieKWh( + (float) $this->GetValue('Istleistung'), + $jetzt - $letzteBerechnung + ); + $this->SetValue('BezogeneEnergie', (float) $this->GetValue('BezogeneEnergie') + $zuwachs); + } + + private function aktualisiereStoerungen(bool $temperaturGueltig): void + { + $stoerungen = []; + if (!$temperaturGueltig) { + $stoerungen[] = 'Boilertemperatur fehlt oder ist veraltet.'; + } + $uebertemperatur = $temperaturGueltig + && (float) $this->GetValue('Boilertemperatur') + > max( + (float) $this->GetValue('Maximaltemperatur'), + (float) $this->GetValue('Legionellentemperatur') + ) + $this->ReadPropertyFloat('Hysterese'); + if ($uebertemperatur) { + $stoerungen[] = 'Uebertemperatur erkannt.'; + } + if ($this->ReadAttributeString('Schaltfehler') !== '') { + $stoerungen[] = $this->ReadAttributeString('Schaltfehler'); + } + if ($this->ReadAttributeString('Legionellenfehler') !== '') { + $stoerungen[] = $this->ReadAttributeString('Legionellenfehler'); + } + + $this->SetValue('Stoerung', $stoerungen !== []); + $this->SetValue('Stoertext', implode("\n", array_values(array_unique($stoerungen)))); + if ($this->ReadAttributeString('Schaltfehler') !== '') { + $this->SetStatus(self::STATUS_SCHALTFEHLER); + } elseif (!$temperaturGueltig) { + $this->SetStatus(self::STATUS_TEMPERATUR_UNGUELTIG); + } else { + $this->SetStatus(self::STATUS_AKTIV); + } + } + + 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 + { + $temperaturGueltig = (bool) $this->GetValue('TemperaturGueltig'); + $schaltfehler = $this->ReadAttributeString('Schaltfehler'); + $legionellenfehler = $this->ReadAttributeString('Legionellenfehler'); + $uebertemperatur = $temperaturGueltig + && (float) $this->GetValue('Boilertemperatur') + > max( + (float) $this->GetValue('Maximaltemperatur'), + (float) $this->GetValue('Legionellentemperatur') + ) + $this->ReadPropertyFloat('Hysterese'); + + 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->GetValue('AenderungMoeglich'), + 'Verfuegbar' => (bool) $this->GetValue('Verfuegbar'), + 'Istleistung_W' => (float) $this->GetValue('Istleistung'), + 'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET, + 'Zustand' => [ + [ + 'Kennung' => 'Sollleistung_W', + 'Art' => 'Sollwert', + 'Wert' => (bool) $this->GetValue('SollwertGueltig') + ? (int) $this->GetValue('Sollleistung') + : null, + 'Einheit' => 'W', + ], + [ + 'Kennung' => 'Wassertemperatur_C', + 'Art' => 'Istwert', + 'Wert' => $temperaturGueltig ? (float) $this->GetValue('Boilertemperatur') : null, + 'Einheit' => 'C', + ], + [ + 'Kennung' => 'Maximaltemperatur_C', + 'Art' => 'Sollwert', + 'Wert' => (float) $this->GetValue('Maximaltemperatur'), + 'Einheit' => 'C', + ], + [ + 'Kennung' => 'AktiveStufe', + 'Art' => 'Status', + 'Wert' => (int) $this->GetValue('AktiveStufe'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'NachladenAktiv', + 'Art' => 'Status', + 'Wert' => (bool) $this->GetValue('NachladenAktiv'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'LegionellenbetriebAktiv', + 'Art' => 'Status', + 'Wert' => (bool) $this->GetValue('LegionellenbetriebAktiv'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Fuehlerfehler', + 'Art' => 'Stoerung', + 'Wert' => !$temperaturGueltig, + 'Einheit' => '', + 'Text' => $temperaturGueltig ? '' : 'Boilertemperatur fehlt oder ist veraltet.', + ], + [ + 'Kennung' => 'Uebertemperatur', + 'Art' => 'Stoerung', + 'Wert' => $uebertemperatur, + 'Einheit' => '', + 'Text' => $uebertemperatur ? 'Uebertemperatur erkannt.' : '', + ], + [ + 'Kennung' => 'Schaltfehler', + 'Art' => 'Stoerung', + 'Wert' => $schaltfehler !== '', + 'Einheit' => '', + 'Text' => $schaltfehler, + ], + [ + 'Kennung' => 'Legionellenfehler', + 'Art' => 'Stoerung', + 'Wert' => $legionellenfehler !== '', + 'Einheit' => '', + 'Text' => $legionellenfehler, + ], + ], + ]; + } + + /** @return list */ + private function zugeordneteManagerIDs(): array + { + $ergebnis = []; + foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) { + try { + $zuordnung = json_decode( + (string) IPS_GetProperty($managerID, 'VerbraucherZuordnung'), + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (Throwable $fehler) { + continue; + } + if (!is_array($zuordnung)) { + 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)); + } + + 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', 'Interval', 'IdleCounterMax', 'TemperaturMaxAlter', + 'ZeitKonstante', 'Boilervolumen', 'LegionellenIntervallTage', 'LegionellenMaximaldauer', + ] as $property) { + if ($this->ReadPropertyInteger($property) <= 0) { + throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); + } + } + if ($this->ReadPropertyInteger('LegionellenHaltezeit') < 0) { + throw new InvalidArgumentException('LegionellenHaltezeit darf nicht negativ sein.'); + } + if ($this->ReadPropertyFloat('Hysterese') <= 0) { + throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.'); + } + foreach (['NachtstartStunde', 'NachtendeStunde'] as $property) { + $stunde = $this->ReadPropertyInteger($property); + if ($stunde < 0 || $stunde > 23) { + throw new InvalidArgumentException($property . ' muss zwischen 0 und 23 liegen.'); + } + } + + $minimum = (float) $this->GetValue('Mindesttemperatur'); + $maximum = (float) $this->GetValue('Maximaltemperatur'); + $legionellen = (float) $this->GetValue('Legionellentemperatur'); + if ($minimum < 0 || $minimum >= $maximum || $legionellen < $maximum) { + throw new InvalidArgumentException( + 'Temperaturen muessen Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur erfuellen.' + ); + } + + $fuehlerID = $this->ReadPropertyInteger('Boilerfuehler_PT1'); + if ($fuehlerID <= 0 || !IPS_VariableExists($fuehlerID)) { + throw new InvalidArgumentException('Boilerfuehler_PT1 ist nicht eingerichtet.'); + } + $fuehler = IPS_GetVariable($fuehlerID); + if (!in_array((int) $fuehler['VariableType'], [1, 2], true)) { + throw new InvalidArgumentException('Boilerfuehler_PT1 muss eine Integer- oder Floatvariable sein.'); + } + + $stufen = WarmwasserRegler::dekodiereLeistungsstufen($this->ReadPropertyString('LeistungsStufen')); + if ($stufen === []) { + throw new InvalidArgumentException('Mindestens eine Leistungsstufe ist erforderlich.'); + } + foreach ($stufen as $stufe) { + if (!IPS_VariableExists($stufe['KontaktID'])) { + throw new InvalidArgumentException('Schaltkontakt ' . $stufe['KontaktID'] . ' existiert nicht.'); + } + $kontakt = IPS_GetVariable($stufe['KontaktID']); + if ((int) $kontakt['VariableType'] !== 0) { + throw new InvalidArgumentException('Alle Schaltkontakte muessen Booleanvariablen sein.'); + } + } + WarmwasserRegler::naechsterZeitplaneintrag( + $this->ReadPropertyString('Zeitplan'), + new DateTimeImmutable('now') + ); + } + + private function registriereFuehlermeldung(): void + { + $alt = $this->ReadAttributeInteger('RegistrierteFuehlerID'); + $neu = $this->ReadPropertyInteger('Boilerfuehler_PT1'); + if ($alt > 0 && $alt !== $neu) { + $this->UnregisterMessage($alt, self::VM_UPDATE); + } + if ($neu > 0 && $neu !== $alt) { + $this->RegisterMessage($neu, self::VM_UPDATE); + } + $this->WriteAttributeInteger('RegistrierteFuehlerID', $neu); + } + + private function initialisiereTemperaturen(): void + { + if ($this->ReadAttributeBoolean('InitialwerteGesetzt')) { + return; + } + $this->SetValue('Mindesttemperatur', 45.0); + $this->SetValue('Maximaltemperatur', 60.0); + $this->SetValue('Legionellentemperatur', 65.0); + $this->SetValue('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET); + $this->SetValue('Idle', true); + $this->WriteAttributeBoolean('InitialwerteGesetzt', true); + } + + /** @param mixed $wert */ + private function setzeTemperaturSollwert(string $ident, $wert): void + { + if (!$this->ReadPropertyBoolean('EinstellungenInVisu')) { + throw new RuntimeException('Temperatureinstellungen sind in der Visualisierung nicht freigegeben.'); + } + if (!is_int($wert) && !is_float($wert)) { + throw new InvalidArgumentException('Temperatursollwert muss numerisch sein.'); + } + $temperatur = (float) $wert; + if ($temperatur < 0 || $temperatur > 100) { + throw new InvalidArgumentException('Temperatursollwert muss zwischen 0 und 100 Grad C liegen.'); + } + + $werte = [ + 'Mindesttemperatur' => (float) $this->GetValue('Mindesttemperatur'), + 'Maximaltemperatur' => (float) $this->GetValue('Maximaltemperatur'), + 'Legionellentemperatur' => (float) $this->GetValue('Legionellentemperatur'), + ]; + $werte[$ident] = $temperatur; + if ($werte['Mindesttemperatur'] >= $werte['Maximaltemperatur'] + || $werte['Maximaltemperatur'] > $werte['Legionellentemperatur'] + ) { + throw new InvalidArgumentException( + 'Temperaturen muessen Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur erfuellen.' + ); + } + + $this->SetValue($ident, $temperatur); + $this->regelzyklus(true); + } + + private function aktualisiereSichtbarkeit(): void + { + $diagnose = $this->ReadPropertyBoolean('DiagnosevariablenAnzeigen'); + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + IPS_SetHidden($this->GetIDForIdent($ident), !$diagnose); + } + + $einstellungen = $this->ReadPropertyBoolean('EinstellungenInVisu'); + foreach (['Mindesttemperatur', 'Maximaltemperatur', 'Legionellentemperatur'] as $ident) { + IPS_SetHidden($this->GetIDForIdent($ident), !$einstellungen); + if ($einstellungen) { + $this->EnableAction($ident); + } else { + $this->DisableAction($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 istNachtstunde(int $stunde): bool + { + $start = $this->ReadPropertyInteger('NachtstartStunde'); + $ende = $this->ReadPropertyInteger('NachtendeStunde'); + if ($start === $ende) { + return true; + } + if ($start < $ende) { + return $stunde >= $start && $stunde < $ende; + } + + return $stunde >= $start || $stunde < $ende; + } + + private function protokolliereStatus(bool $erzwingen): void + { + if (!$erzwingen && !$this->ReadPropertyBoolean('LoggingEin')) { + return; + } + $this->SendDebug('Status', json_encode([ + 'Aktiv' => (bool) $this->GetValue('Aktiv'), + 'Boilertemperatur_C' => (float) $this->GetValue('Boilertemperatur'), + 'TemperaturGueltig' => (bool) $this->GetValue('TemperaturGueltig'), + 'Leistungsangebot_W' => $this->leseLeistungsangebot(), + 'Sollleistung_W' => (int) $this->GetValue('Sollleistung'), + 'Istleistung_W' => (float) $this->GetValue('Istleistung'), + 'AktiveStufe' => (int) $this->GetValue('AktiveStufe'), + 'Stoertext' => (string) $this->GetValue('Stoertext'), + ], JSON_THROW_ON_ERROR), 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/Schnittstelle.md b/docs/Schnittstelle.md index 0dd6a01..e25fad5 100644 --- a/docs/Schnittstelle.md +++ b/docs/Schnittstelle.md @@ -100,7 +100,7 @@ Symcon-Datenpunkte werden durch `VerbraucherBasisTrait` registriert. | `Meldeintervall` | Integer | `10` | Vollständige Rückmeldung in Sekunden; muss grösser als 0 sein. | | `VorgabeTimeout` | Integer | `30` | Sollwert wird nach dieser Zeit ohne Erneuerung ungültig. | | `EinstellungenInVisu` | Boolean | `false` | Blendet lokale Einstellvariablen ein und macht sie geprüft bedienbar. | -| `LoggingEin` | Boolean | `false` | Aktiviert Diagnoseprotokoll und zusätzliche Diagnosevariablen. | +| `LoggingEin` | Boolean | `false` | Aktiviert das laufende Diagnoseprotokoll. Module können zusätzliche Diagnosevariablen getrennt einblenden. | ### Gemeinsame Variablen aller Verbraucher diff --git a/docs/adr/0001-warmwassererwaermer-vertrag.md b/docs/adr/0001-warmwassererwaermer-vertrag.md new file mode 100644 index 0000000..dee8030 --- /dev/null +++ b/docs/adr/0001-warmwassererwaermer-vertrag.md @@ -0,0 +1,24 @@ +# ADR 0001: Warmwassererwaermer verwendet ausschliesslich Vertrag 3.0 + +## Status + +Akzeptiert. + +## Entscheidung + +Der neue `VerbraucherWarmwassererwaermer` implementiert die bestehende +`VerbraucherSchnittstelle` und den `Nachrichtenvertrag` in Version `3.0`. +Die Enelix-1-Variablen `Power`, `PowerSteps`, `PV_Prio`, `Sperre_Prio`, +`Is_Peak_Shaving` und `Leistung_Delta` werden nicht als zweite parallele +Manager-Schnittstelle weitergefuehrt. + +Der Manager bleibt allein fuer die Betriebsart und Leistungsverteilung +verantwortlich. Lokale Temperatur- und Hygienesicherheit wird als eingeschraenktes +Leistungsangebot mit `AenderungMoeglich=false` gemeldet. + +## Folgen + +- Es gibt genau eine fachliche Empfangsmethode pro Richtung. +- Der Verbraucher hat keine Manager-ID-Property. +- Alte Instanzen werden anhand der Migrationstabelle neu konfiguriert. +- Manager und Verbraucher koennen unabhaengig getestet werden. diff --git a/docs/migration/Boiler-x-Stufig.md b/docs/migration/Boiler-x-Stufig.md new file mode 100644 index 0000000..bd3eb6a --- /dev/null +++ b/docs/migration/Boiler-x-Stufig.md @@ -0,0 +1,49 @@ +# Migration von Boiler_x_Stufig + +## Eigenschaften + +| Enelix 1 | Enelix 2 | Hinweis | +| --- | --- | --- | +| `LeistungsStufen` | `LeistungsStufen` | Felder `Stufe`, `Leistung`, `Schaltkontakt_Stufe` bleiben erhalten. | +| `ZeitKonstante` | `ZeitKonstante` | PT1-Berechnung verwendet das reale Regelintervall. | +| `Boilerfuehler_PT1` | `Boilerfuehler_PT1` | Alter und Datentyp werden geprueft. | +| `Boilertemperatur_glätten` | `BoilertemperaturGlaetten` | ASCII-sicherer Ident. | +| `Boilervolumen` | `Boilervolumen` | Wird fuer die Zeitplanprognose verwendet. | +| `Zeitplan` | `Zeitplan` | JSON-Liste mit `Uhrzeit` und `Solltemperatur`. | +| `Interval` | `Interval` | Regelintervall in Sekunden. | +| `IdleCounterMax` | `IdleCounterMax` | Verhalten bleibt erhalten. | + +Die neuen Properties `TemperaturMaxAlter`, `Hysterese`, die Parameter der +Legionellenfunktion und `DiagnosevariablenAnzeigen` ersetzen feste Werte und +nicht pruefbare Annahmen des Altmoduls. + +## Variablen + +| Enelix 1 | Enelix 2 | +| --- | --- | +| `Mindesttemperatur` | `Mindesttemperatur` | +| `Maximaltemperatur` | `Maximaltemperatur` | +| `Legionellentemperatur` | `Legionellentemperatur` | +| `Boilertemperatur` | `Boilertemperatur` | +| `LegioCounter` | `LegioCounter` | +| `Idle` | `Idle` | +| `IdleCounter` | `IdleCounter` | +| `Aktuelle_Leistung` | gemeinsames `Istleistung` | +| `Bezogene_Energie` | `BezogeneEnergie` in kWh | +| `PV_Prio` | Property `PrioritaetPV` | +| `Sperre_Prio` | Property `PrioritaetPeak` | +| `Power` | gemeinsames `Sollleistung` | +| `PowerSteps` | Vertragsfeld `Leistungswerte_W`, keine Variable | +| `Is_Peak_Shaving` | entfaellt; Betriebsart liegt im Manager | +| `Leistung_Delta` | entfaellt; Leistungsverteilung liegt im Manager | + +## Inbetriebnahme + +1. Neue Instanz `VerbraucherWarmwassererwaermer` anlegen. +2. Fuehler, Leistungsstufen und Schaltkontakte uebertragen. +3. Temperaturvariablen einmalig mit den bisherigen Sollwerten setzen. +4. Die Instanz im Enelix-2-Manager unter `VerbraucherZuordnung` aktiv zuordnen. +5. Erst danach `Aktiv` einschalten und jede Stufe beaufsichtigt pruefen. + +Das Modul verwendet eine neue Modul-ID. Eine automatische Umwandlung der alten +Instanz findet deshalb nicht statt. diff --git a/docs/module/README.md b/docs/module/README.md index 66491ee..d55deba 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,7 +1,7 @@ # EMS-Module und Modulentwürfe -> Der Manager ist als installierbares IP-Symcon-Modul umgesetzt. Die weiteren -> Ordner enthalten Besprechungsgrundlagen und noch keine installierbaren Module. +> Manager und Warmwassererwaermer 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 @@ -11,7 +11,7 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte. | --- | --- | | [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) | | [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers | -| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung | +| [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 | | [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready | diff --git a/docs/module/Wassererwaermer/README.md b/docs/module/Wassererwaermer/README.md index a3e9299..0a4485e 100644 --- a/docs/module/Wassererwaermer/README.md +++ b/docs/module/Wassererwaermer/README.md @@ -1,63 +1,98 @@ -# Wassererwärmer +# Verbraucher Warmwassererwaermer -> Status: Diskussionsentwurf. Vereinigt Boiler x-Stufig, Boiler mit Fühler, -> Askoheat und Heizung x-Stufig. +> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`. -Anzahl, Leistung und Ausgangsbelegung werden je Stufe konfiguriert. Temperatur-, -Nachlade-, Legionellen- und Askoheat-Werte sind Properties und können über -`EinstellungenInVisu` bedienbar gemacht werden. +Das Modul adaptiert ausschliesslich das fruehere Modul `Boiler_x_Stufig`. +Mehrere elektrische Leistungsstufen werden exklusiv geschaltet. Der Manager +waehlt eine der aktuell angebotenen Leistungen; Mindesttemperatur, Zeitplan und +Legionellenprogramm koennen die lokale Warmwassersicherheit priorisieren. -## Zusätzliche Variablen +## Sicherheit und Regelung -| Ident | Typ / Zugriff | Beschreibung | -| --- | --- | --- | -| `Wassertemperatur` | Float / Anzeige | Aktueller Wert in °C. | -| `TemperaturGueltig` | Boolean / Anzeige | Adapter liefert einen gültigen Wert. | -| `NachladenAktiv` | Boolean / Anzeige | Mindesttemperaturanforderung aktiv. | -| `LegionellenbetriebAktiv` | Boolean / Anzeige | Legionellenbetrieb läuft. | -| `AktiveStufe` | Integer / Anzeige | Stufenkennung; `0` bedeutet aus. | -| `LetzterLegionellenabschluss` | Integer / Anzeige | Unixzeit des letzten erfolgreichen Abschlusses. | -| `NachladeMindesttemperatur` | Float / lokal bedienbar | Wirksamer Parameter in °C. | -| `NachladeMaximaltemperatur` | Float / lokal bedienbar | Wirksamer Parameter in °C. | -| `Maximaltemperatur` | Float / lokal bedienbar | Normale obere Zieltemperatur in °C. | -| `Hysterese` | Float / lokal bedienbar | Hysterese in K. | -| `NachladeMindesttemperaturAktiv` | Boolean / lokal bedienbar | Mindesttemperaturnachladung ein/aus. | -| `NachladeMaximaltemperaturAktiv` | Boolean / lokal bedienbar | Separates Nachlade-Endziel ein/aus. | -| `LegionellenfunktionAktiv` | Boolean / lokal bedienbar | Funktion ein/aus. | -| `Legionellentemperatur` | Float / lokal bedienbar | Zieltemperatur in °C. | -| `AskoheatFunktionAktiv` | Boolean / lokal bedienbar | Ansteuerung ein/aus. | +- Die Schaltkontakte werden mit Break-before-make geschaltet: zuerst alle aus, + danach hoechstens eine Stufe ein. +- Fehlende, veraltete oder nicht numerische Temperaturwerte schalten die + EMS-Verfuegbarkeit aus und fuehren zu einer Stoerung. +- Manager-Vorgaben werden nur angenommen, wenn sie im zuletzt gemeldeten + `Leistungswerte_W`-Angebot enthalten sind. +- Nach `VorgabeTimeout` wird eine nicht erneuerte Manager-Vorgabe ungueltig. +- Mindesttemperatur, vorausberechneter Zeitplan und Legionellenprogramm koennen + die maximale Stufe lokal erzwingen. Diese Phase wird mit + `AenderungMoeglich=false` gemeldet. +- `BoilertemperaturGlaetten` aktiviert eine PT1-Glaettung mit konfigurierbarer + Zeitkonstante. -## Zusätzliche Properties +## Properties -| Ident | Typ | Standard / Beschreibung | -| --- | --- | --- | -| `NachladeMindesttemperatur` | Float | `0*` °C; Starttemperatur. | -| `NachladeMaximaltemperatur` | Float | `0*` °C; Endtemperatur. | -| `Maximaltemperatur` | Float | `0*` °C; normales oberes Ziel. | -| `Hysterese` | Float | `1` K; grösser 0. | -| `NachladeMindesttemperaturAktiv` | Boolean | `false`. | -| `NachladeMaximaltemperaturAktiv` | Boolean | `false`. | -| `LegionellenfunktionAktiv` | Boolean | `false`. | -| `Legionellentemperatur` | Float | `0*` °C. | -| `AskoheatFunktionAktiv` | Boolean | `false`. | -| `Betriebsprofil` | Auswahl | `Wassererwaermer` oder `EinfacheTemperaturheizung`. | -| `Leistungsstufen` | String/JSON | `[]`; Stufenkennung, Leistung in W und vollständige Ausgangsbelegung. | -| `Volumen` | Float | `0` l; `0` deaktiviert thermische Berechnung. | -| `Mindestlaufzeit` | Integer | `0` s. | -| `Mindeststillstand` | Integer | `0` s. | -| `AskoheatInstanzID` | Integer | `0`; nur bei aktivierter Funktion. | -| `AskoheatLeistungVariableID` | Integer | `0`; nur wenn der Adapter sie benötigt. | +Zu den sechs gemeinsamen Verbraucher-Properties aus +[`Schnittstelle.md`](../../Schnittstelle.md) kommen 17 Modul-Properties hinzu. -Temperatur-Maximalalter, Temperaturgrenze, Temperatur-Variable-ID, Zeitpläne, -Legionellenhaltezeit, Legionellenmaximaldauer und Zeitzone entfallen. +| Ident | Typ | Standard | Beschreibung | +| --- | --- | ---: | --- | +| `LeistungsStufen` | JSON-Liste | `[]` | Stufe, positive Leistung in W und Boolean-Schaltkontakt. | +| `ZeitKonstante` | Integer | `120` | PT1-Zeitkonstante in Sekunden. | +| `Boilerfuehler_PT1` | Integer | `0` | Temperaturvariable. | +| `BoilertemperaturGlaetten` | Boolean | `false` | PT1-Glaettung aktiv. | +| `Boilervolumen` | Integer | `300` | Volumen in Litern fuer die Zeitplanprognose. | +| `Zeitplan` | JSON-Liste | `[]` | Uhrzeit und Solltemperatur. | +| `Interval` | Integer | `5` | Regelintervall in Sekunden. | +| `IdleCounterMax` | Integer | `2` | Zyklen bis zum Idle-Status. | +| `TemperaturMaxAlter` | Integer | `30` | Maximal zulaessiges Messwertalter in Sekunden. | +| `Hysterese` | Float | `5.0` | Temperaturhysterese in K. | +| `LegionellenfunktionAktiv` | Boolean | `true` | Periodische Aufheizung aktiv. | +| `LegionellenIntervallTage` | Integer | `7` | Intervall seit erfolgreichem Abschluss. | +| `LegionellenHaltezeit` | Integer | `0` | Haltezeit auf Zieltemperatur in Sekunden. | +| `LegionellenMaximaldauer` | Integer | `86400` | Abbruchzeit in Sekunden. | +| `NachtstartStunde` | Integer | `22` | Fruehester Programmstart. | +| `NachtendeStunde` | Integer | `7` | Ende des Nachtfensters. | +| `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet zehn Diagnosevariablen ein. | -## Zustand +`LoggingEin` aktiviert laufende Debug-Ausgaben. Der Formular-Knopf schreibt +unabhaengig davon genau einen aktuellen Statusdatensatz ins Debug-Log. -`Wassertemperatur_C` als Istwert, `Maximaltemperatur_C` als Sollwert, -`AktiveStufe`, `NachladenAktiv` und `LegionellenbetriebAktiv` als Status sowie -`Fuehlerfehler` und `Uebertemperatur` als Störungen. +## Variablen -## Offene Punkte +Standardmaessig sichtbar sind nur die acht Betriebsvariablen `Aktiv`, +`Istleistung`, `Sollleistung`, `Stoerung`, `Stoertext`, `Boilertemperatur`, +`AktiveStufe` und `BezogeneEnergie`. -- Ablauf für „Wassererwärmer über Wärmepumpe betreiben“ separat festlegen. -- Konkreten Temperaturadapter und die Fühleranbindung bestätigen. +Mit `EinstellungenInVisu` werden `Mindesttemperatur`, `Maximaltemperatur` und +`Legionellentemperatur` sichtbar und geprueft bedienbar. Die Reihenfolge +`Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur` ist zwingend. + +Mit `DiagnosevariablenAnzeigen` werden diese zehn Variablen eingeblendet: + +- `Leistungsquelle` +- `SollwertGueltig` +- `Verfuegbar` +- `AenderungMoeglich` +- `TemperaturGueltig` +- `NachladenAktiv` +- `LegionellenbetriebAktiv` +- `LegioCounter` +- `Idle` +- `IdleCounter` + +## Managerkommunikation + +Das Modul implementiert `VerbraucherSchnittstelle::ManagerdatenEmpfangen()`. +Der Transport erfolgt wie beim Manager ueber `IPS_RequestAction` mit JSON. Der +Verbraucher besitzt keine Manager-ID-Property. Er ermittelt nur Manager, in +deren `VerbraucherZuordnung` seine Instanz aktiv eingetragen ist. + +Die Rueckmeldung erfolgt ereignisbasiert und zusaetzlich alle +`Meldeintervall` Sekunden. Eine Rueckmeldung nach empfangener Vorgabe wird kurz +verzoegert, damit keine synchrone Manager-Verbraucher-Endlosschleife entsteht. + +## Tests + +`WarmwasserReglerTest.php` prueft Leistungsstufen, PT1, Energieberechnung, +Zeitplan und thermische Prognose. `WarmwassererwaermerModulstrukturTest.php` +prueft Metadaten, Formular, 23 Properties, Sichtbarkeit, Schnittstellenvertrag +und Break-before-make-Schaltung. + +## Migration + +Die Feldzuordnung und die bewusst entfallenen Enelix-1-Kommunikationsvariablen +sind in [`docs/migration/Boiler-x-Stufig.md`](../../migration/Boiler-x-Stufig.md) +dokumentiert. diff --git a/libs/WarmwasserRegler.php b/libs/WarmwasserRegler.php new file mode 100644 index 0000000..bb3df20 --- /dev/null +++ b/libs/WarmwasserRegler.php @@ -0,0 +1,192 @@ + + */ + public static function dekodiereLeistungsstufen(string $json): array + { + $werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($werte) || !self::istListe($werte)) { + throw new InvalidArgumentException('LeistungsStufen muss eine JSON-Liste sein.'); + } + + $ergebnis = []; + $leistungen = []; + $kontakte = []; + foreach ($werte as $index => $eintrag) { + if (!is_array($eintrag)) { + throw new InvalidArgumentException('Jede Leistungsstufe muss ein Objekt sein.'); + } + + $stufe = $eintrag['Stufe'] ?? $index + 1; + $leistung = $eintrag['Leistung'] ?? null; + $kontakt = $eintrag['Schaltkontakt_Stufe'] ?? null; + if (!is_int($stufe) || $stufe <= 0) { + throw new InvalidArgumentException('Stufe muss eine positive Ganzzahl sein.'); + } + if (!is_int($leistung) || $leistung <= 0) { + throw new InvalidArgumentException('Leistung muss eine positive Ganzzahl sein.'); + } + if (!is_int($kontakt) || $kontakt <= 0) { + throw new InvalidArgumentException('Schaltkontakt_Stufe muss eine positive Variablen-ID sein.'); + } + if (isset($leistungen[$leistung])) { + throw new InvalidArgumentException('Leistungen muessen eindeutig sein.'); + } + if (isset($kontakte[$kontakt])) { + throw new InvalidArgumentException('Schaltkontakte duerfen nur einer Stufe zugeordnet sein.'); + } + + $leistungen[$leistung] = true; + $kontakte[$kontakt] = true; + $ergebnis[] = [ + 'Stufe' => $stufe, + 'Leistung_W' => $leistung, + 'KontaktID' => $kontakt, + ]; + } + + usort( + $ergebnis, + static fn (array $links, array $rechts): int => $links['Leistung_W'] <=> $rechts['Leistung_W'] + ); + + return $ergebnis; + } + + /** + * @param list $stufen + * @return list + */ + public static function leistungswerte(array $stufen): array + { + return array_merge([0], array_column($stufen, 'Leistung_W')); + } + + /** + * @param list $stufen + */ + public static function stufeFuerLeistung(array $stufen, int $leistung): int + { + if ($leistung === 0) { + return 0; + } + foreach ($stufen as $stufe) { + if ($stufe['Leistung_W'] === $leistung) { + return $stufe['Stufe']; + } + } + + throw new InvalidArgumentException('Die Sollleistung entspricht keiner konfigurierten Leistungsstufe.'); + } + + public static function pt1(float $bisher, float $eingang, int $deltaSekunden, int $zeitkonstante): float + { + if ($deltaSekunden <= 0) { + throw new InvalidArgumentException('Das Zeitintervall muss groesser als 0 sein.'); + } + if ($zeitkonstante <= 0) { + throw new InvalidArgumentException('Die Zeitkonstante muss groesser als 0 sein.'); + } + + $alpha = $deltaSekunden / ($zeitkonstante + $deltaSekunden); + + return $bisher + $alpha * ($eingang - $bisher); + } + + public static function energieKWh(float $leistungW, int $dauerSekunden): float + { + if ($dauerSekunden < 0) { + throw new InvalidArgumentException('Die Dauer darf nicht negativ sein.'); + } + + return $leistungW * $dauerSekunden / 3600000.0; + } + + public static function kannZielErreichen( + float $volumenLiter, + float $isttemperatur, + float $zieltemperatur, + int $verbleibendeSekunden, + int $maximalleistungW + ): bool { + if ($zieltemperatur <= $isttemperatur) { + return true; + } + if ($volumenLiter <= 0 || $verbleibendeSekunden < 0 || $maximalleistungW <= 0) { + return false; + } + + $erforderlicheWaermeJ = 4186.0 * $volumenLiter * ($zieltemperatur - $isttemperatur); + $verfuegbareWaermeJ = $maximalleistungW * $verbleibendeSekunden; + + return $verfuegbareWaermeJ >= $erforderlicheWaermeJ; + } + + /** + * @return array{Zeitpunkt: int, Solltemperatur: float}|null + */ + public static function naechsterZeitplaneintrag(string $json, DateTimeImmutable $jetzt): ?array + { + if (trim($json) === '') { + return null; + } + $werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($werte) || !self::istListe($werte)) { + throw new InvalidArgumentException('Zeitplan muss eine JSON-Liste sein.'); + } + + $naechster = null; + foreach ($werte as $eintrag) { + if (!is_array($eintrag) + || !is_string($eintrag['Uhrzeit'] ?? null) + || !is_numeric($eintrag['Solltemperatur'] ?? null) + || !preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $eintrag['Uhrzeit']) + ) { + throw new InvalidArgumentException('Zeitplaneintraege benoetigen Uhrzeit HH:MM und Solltemperatur.'); + } + + [$stunde, $minute] = array_map('intval', explode(':', $eintrag['Uhrzeit'])); + $zeitpunkt = $jetzt->setTime($stunde, $minute, 0); + if ($zeitpunkt <= $jetzt) { + $zeitpunkt = $zeitpunkt->modify('+1 day'); + } + if ($naechster === null || $zeitpunkt < $naechster['Datum']) { + $naechster = [ + 'Datum' => $zeitpunkt, + 'Solltemperatur' => (float) $eintrag['Solltemperatur'], + ]; + } + } + + if ($naechster === null) { + return null; + } + + return [ + 'Zeitpunkt' => $naechster['Datum']->getTimestamp(), + 'Solltemperatur' => $naechster['Solltemperatur'], + ]; + } + + /** @param array $wert */ + private static function istListe(array $wert): bool + { + return $wert === [] || array_keys($wert) === range(0, count($wert) - 1); + } +} diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index 4ea863f..5f9566d 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -8,18 +8,17 @@ use PHPUnit\Framework\TestCase; final class DokumentationsstrukturTest extends TestCase { - /** @return list */ + /** @return list */ public function modulReadmes(): array { return [ - 'Batterie', - 'Wassererwaermer', - 'Pufferspeicher', - 'Verbraucher-1-Stufig', - 'Waermepumpe', - 'Ladestation-Stand-Alone', - 'Ladestation-Gateway', - 'Easee-Gateway', + ['Batterie'], + ['Pufferspeicher'], + ['Verbraucher-1-Stufig'], + ['Waermepumpe'], + ['Ladestation-Stand-Alone'], + ['Ladestation-Gateway'], + ['Easee-Gateway'], ]; } @@ -49,4 +48,13 @@ final class DokumentationsstrukturTest extends TestCase self::assertStringContainsString('Status: Implementiert', $inhalt); self::assertStringContainsString('Verteilalgorithmus', $inhalt); } + + public function testWarmwassererwaermerDokumentiertDenImplementiertenStand(): void + { + $inhalt = file_get_contents(__DIR__ . '/../docs/module/Wassererwaermer/README.md'); + self::assertNotFalse($inhalt); + self::assertStringContainsString('Status: implementiert', $inhalt); + self::assertStringContainsString('Properties', $inhalt); + self::assertStringContainsString('Managerkommunikation', $inhalt); + } } diff --git a/tests/ManagerReglerTest.php b/tests/ManagerReglerTest.php index 1c097b3..f4ea4e3 100644 --- a/tests/ManagerReglerTest.php +++ b/tests/ManagerReglerTest.php @@ -88,7 +88,7 @@ final class ManagerReglerTest extends TestCase public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void { $fest = $this->verbraucher(10, 0, [0, 3000], 3000, false); - $steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 0, 'Bis_W' => 4000]], 0); + $steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 1, 'Bis_W' => 4000]], 0); $ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV'); diff --git a/tests/WarmwasserReglerTest.php b/tests/WarmwasserReglerTest.php new file mode 100644 index 0000000..52dc2b8 --- /dev/null +++ b/tests/WarmwasserReglerTest.php @@ -0,0 +1,82 @@ + 2, 'Leistung' => 6000, 'Schaltkontakt_Stufe' => 102], + ['Stufe' => 1, 'Leistung' => 3000, 'Schaltkontakt_Stufe' => 101], + ], JSON_THROW_ON_ERROR)); + + self::assertSame( + [ + ['Stufe' => 1, 'Leistung_W' => 3000, 'KontaktID' => 101], + ['Stufe' => 2, 'Leistung_W' => 6000, 'KontaktID' => 102], + ], + $stufen + ); + self::assertSame([0, 3000, 6000], WarmwasserRegler::leistungswerte($stufen)); + self::assertSame(2, WarmwasserRegler::stufeFuerLeistung($stufen, 6000)); + self::assertSame(0, WarmwasserRegler::stufeFuerLeistung($stufen, 0)); + } + + public function testDoppelteKontakteWerdenAbgewiesen(): void + { + $this->expectException(InvalidArgumentException::class); + WarmwasserRegler::dekodiereLeistungsstufen(json_encode([ + ['Stufe' => 1, 'Leistung' => 3000, 'Schaltkontakt_Stufe' => 101], + ['Stufe' => 2, 'Leistung' => 6000, 'Schaltkontakt_Stufe' => 101], + ], JSON_THROW_ON_ERROR)); + } + + public function testNichtKonfigurierteSollleistungWirdAbgewiesen(): void + { + $this->expectException(InvalidArgumentException::class); + WarmwasserRegler::stufeFuerLeistung([ + ['Stufe' => 1, 'Leistung_W' => 3000, 'KontaktID' => 101], + ], 1500); + } + + public function testPt1VerwendetRegelintervallUndZeitkonstante(): void + { + self::assertEqualsWithDelta(20.4, WarmwasserRegler::pt1(20.0, 30.0, 5, 120), 0.001); + } + + public function testEnergieWirdInKWhBerechnet(): void + { + self::assertEqualsWithDelta(3.0, WarmwasserRegler::energieKWh(3000.0, 3600), 0.000001); + } + + public function testThermischePrognose(): void + { + self::assertTrue(WarmwasserRegler::kannZielErreichen(300.0, 50.0, 60.0, 7200, 3000)); + self::assertFalse(WarmwasserRegler::kannZielErreichen(300.0, 20.0, 60.0, 3600, 3000)); + } + + public function testNaechsterZeitplaneintragIstHeuteOderMorgen(): void + { + $jetzt = new DateTimeImmutable('2026-09-16 10:00:00', new DateTimeZone('Europe/Zurich')); + $eintrag = WarmwasserRegler::naechsterZeitplaneintrag(json_encode([ + ['Uhrzeit' => '08:00', 'Solltemperatur' => 50], + ['Uhrzeit' => '11:00', 'Solltemperatur' => 55], + ], JSON_THROW_ON_ERROR), $jetzt); + + self::assertNotNull($eintrag); + self::assertSame( + (new DateTimeImmutable('2026-09-16 11:00:00', new DateTimeZone('Europe/Zurich')))->getTimestamp(), + $eintrag['Zeitpunkt'] + ); + self::assertSame(55.0, $eintrag['Solltemperatur']); + } +} diff --git a/tests/WarmwassererwaermerModulstrukturTest.php b/tests/WarmwassererwaermerModulstrukturTest.php new file mode 100644 index 0000000..b9b7d1b --- /dev/null +++ b/tests/WarmwassererwaermerModulstrukturTest.php @@ -0,0 +1,89 @@ +