From 98ee5691787054858ece5627ca15bb1c3a775529 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 22 Sep 2026 14:43:36 +0000 Subject: [PATCH] feat: Demoanlage um Batterie und Tagesprofile erweitern --- examples/Demoanlage/README.md | 54 ++-- examples/Demoanlage/bootstrap.php | 421 ++++++++++++++++++++++++------ tests/DemoanlageBootstrapTest.php | 46 ++++ 3 files changed, 424 insertions(+), 97 deletions(-) diff --git a/examples/Demoanlage/README.md b/examples/Demoanlage/README.md index 299bee8..fb96beb 100644 --- a/examples/Demoanlage/README.md +++ b/examples/Demoanlage/README.md @@ -2,13 +2,16 @@ Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x: -- Enelix Manager mit manueller Zuordnung, +- Enelix Manager mit manueller Zuordnung von fünf steuerbaren Teilnehmern, - zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW), - einen einstufigen Entfeuchter (0,9 kW), -- simulierte PV-Erzeugung, Grundlast, Netzleistung und Boilertemperaturen, -- native Symcon-Instanz `Energy Distribution` für den Energiefluss, -- eigene Kachelansichten mit allen Laufzeitparametern je Boiler, -- kombinierten Verlauf für PV-Leistung, Hausverbrauch und Netzleistung, +- einen zweistufigen Pufferspeicher (1,8/3,6 kW) mit Heizkurve, +- einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW), +- simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast, + Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände, +- native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten, +- eigene Kachelansichten mit Laufzeitparametern für Boiler, Puffer und Batterie, +- kombinierten Verlauf für PV-Leistung, Hausverbrauch, EMS-Leistung und Netzleistung, - kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie, - Archivierung der Leistungs-, Energie- und Temperaturwerte. @@ -27,26 +30,43 @@ print_r($result); Ein weiterer Lauf aktualisiert dieselbe Anlage anhand stabiler Idents und erzeugt keine Duplikate. Bestehende, nicht zur Demo gehörende Objekte werden nicht verändert oder gelöscht. +Der Bootstrap hinterlegt absichtlich keinen Lizenzcode und überschreibt eine +bereits konfigurierte Manager-Lizenz nicht. Bei einer neuen Anlage wird die +Struktur vollständig erstellt; der Manager bleibt bis zur regulären +Lizenzaktivierung im Konfigurationsformular auf Status 203. Solange der Manager +nicht freigegeben ist, bleiben seine Zuordnung und die Verbraucher deaktiviert, +damit keine Lizenzfehler protokolliert werden. Nach der Aktivierung den +Bootstrap erneut ausführen; er ordnet dann alle fünf Teilnehmer zu und startet +die Regelung. + ## Bedienung In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`. Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit, -PV-Spitzenleistung, Bewölkung und Haus-Grundlast verändern. Der Manager verteilt -den verfügbaren PV-Überschuss nach Priorität auf beide Boiler und den Entfeuchter. +dynamische Profile, PV-Spitzenleistung, mittlere Bewölkung, Haus-Grundlast und +eine ungeregelte Zusatzlast verändern. Bei aktivierten dynamischen Profilen +entstehen reproduzierbare Morgen- und Abendspitzen, ziehende Bewölkung und ein +Tagesgang der Außentemperatur. Der Manager verteilt den verfügbaren +PV-Überschuss nach Priorität auf beide Boiler, Entfeuchter, Pufferspeicher und +Batterie. -`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1` -und `Boiler 2` öffnen direkt sämtliche Bedien-, Status- und Diagnosevariablen, -ohne zuerst das Instanz-Konfigurationsformular zu zeigen. Unter `Leistung und -Energie` liegen die beiden kombinierten Zeitdiagramme und die Energiezähler. -Die Energiesummen verwenden die beschleunigte Simulationszeit. +`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`, +`Boiler 2`, `Pufferspeicher` und `Batteriespeicher` öffnen direkt die jeweiligen +Bedien-, Status- und Diagnosevariablen, ohne zuerst das +Instanz-Konfigurationsformular zu zeigen. Positive Batterieleistung bedeutet +Laden, negative Leistung Entladen. Unter `Leistung und Energie` liegen die +beiden kombinierten Zeitdiagramme und die Energiezähler. Die Energiesummen, +Temperaturmodelle und der Ladezustand verwenden die beschleunigte +Simulationszeit. ## Validierung -- Alle vier Enelix-Instanzen müssen Status 102 besitzen. -- Der Manager muss drei Verbraucher melden. -- Bei ausreichendem PV-Überschuss müssen Verbraucher schalten. -- Netzleistung, PV-Leistung, Hausverbrauch, Verbraucherleistungen, Energiesummen und Temperaturen werden archiviert. -- Die native Energieverteilung muss sechs Knoten enthalten. +- Die fünf Verbraucher müssen Status 102 besitzen; der Manager nach Lizenzaktivierung ebenfalls (vorher erwarteter Status 203). +- Der Manager muss fünf Teilnehmer aus vier unterschiedlichen Modultypen melden. +- Bei ausreichendem PV-Überschuss müssen thermische Verbraucher schalten und die Batterie laden. +- Puffertemperatur, Außentemperatur und Batterie-Ladezustand müssen sich über den Tageslauf verändern. +- Netzleistung, PV-Leistung, Hausverbrauch, EMS-Leistung, Einzelverbräuche, Speicherleistung, Energiesummen und Temperaturen werden archiviert. +- Die native Energieverteilung muss acht Knoten enthalten. - Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen. - Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben. diff --git a/examples/Demoanlage/bootstrap.php b/examples/Demoanlage/bootstrap.php index fb560b1..11b4966 100644 --- a/examples/Demoanlage/bootstrap.php +++ b/examples/Demoanlage/bootstrap.php @@ -6,6 +6,8 @@ const ENELIX_DEMO_ROOT_IDENT = 'ENELIX_DEMO_2'; const ENELIX_DEMO_MANAGER_MODULE = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}'; const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}'; const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}'; +const ENELIX_DEMO_BUFFER_MODULE = '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}'; +const ENELIX_DEMO_BATTERY_MODULE = '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}'; const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}'; const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}'; const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}'; @@ -28,15 +30,17 @@ function enelixDemoInstall(): array enelixDemoEnsureProfiles(); - $legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 90); + $legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 900); IPS_SetHidden($legacyFlow, true); $pvDisplay = enelixDemoEnsureVariable($dashboard, 'PVLeistung', 'PV-Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 20); $houseDisplay = enelixDemoEnsureVariable($dashboard, 'Hausverbrauch', 'Hausverbrauch gesamt', 2, 'ENELIX.Demo.PowerPositive', 0.0, 30); - $loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'Geregelte Verbraucher', 2, 'ENELIX.Demo.PowerPositive', 0.0, 40); + $loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'EMS-Leistung inkl. Speicher', 2, 'ENELIX.Demo.Power', 0.0, 40); $grid = enelixDemoEnsureVariable($dashboard, 'Netzleistung', 'Netzleistung', 2, 'ENELIX.Demo.Power', 0.0, 50); $boiler1Power = enelixDemoEnsureVariable($dashboard, 'Boiler1Leistung', 'Boiler 1 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 60); $boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70); $dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80); + $bufferPower = enelixDemoEnsureVariable($dashboard, 'PufferLeistung', 'Pufferspeicher Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 90); + $batteryPower = enelixDemoEnsureVariable($dashboard, 'BatterieLeistung', 'Batterieleistung', 2, 'ENELIX.Demo.Power', 0.0, 100); $powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90); $energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100); @@ -50,9 +54,11 @@ function enelixDemoInstall(): array $auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10); $hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20); $speed = enelixDemoEnsureVariable($simulation, 'Geschwindigkeit', 'Minuten je Schritt', 1, 'ENELIX.Demo.Speed', 10, 30); - $peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 7000.0, 40); - $cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 50); - $base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 60); + $dynamic = enelixDemoEnsureVariable($simulation, 'DynamischeProfile', 'Dynamische Tagesprofile', 0, '~Switch', true, 40); + $peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 9000.0, 50); + $cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Mittlere Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 60); + $base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Mittlere Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 70); + $extraLoad = enelixDemoEnsureVariable($simulation, 'Zusatzlast', 'Ungeregelte Zusatzlast', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80); $actorScript = enelixDemoEnsureScript( $technical, @@ -61,6 +67,13 @@ function enelixDemoInstall(): array " $temp1, @@ -157,6 +191,52 @@ function enelixDemoInstall(): array 'Mindestausschaltdauer' => 2, 'Mindestlaufzeit' => 0, 'PrioritaetPV' => 2, + 'PrioritaetPeak' => 3, + 'Meldeintervall' => 5, + 'VorgabeTimeout' => 30, + 'EinstellungenInVisu' => true, + 'DiagnosevariablenAnzeigen' => true, + ]); + enelixDemoConfigure($buffer, [ + 'LeistungsStufen' => json_encode([ + ['Stufe' => 1, 'Leistung' => 1800, 'Schaltkontakt_Stufe' => $buffer1], + ['Stufe' => 2, 'Leistung' => 3600, 'Schaltkontakt_Stufe' => $buffer2], + ], JSON_THROW_ON_ERROR), + 'PufferfuehlerVariableID' => $bufferTemp, + 'AussentemperaturVariableID' => $outsideTemp, + 'FusspunktVorlauftemperatur' => 34.0, + 'HeizkurvenSteigung' => 0.8, + 'HeizkurveMinimaltemperatur' => 28.0, + 'HeizkurveMaximaltemperatur' => 55.0, + 'Hysterese' => 4.0, + 'MindesttemperaturModus' => 0, + 'LastwechselSperrzeit' => 2, + 'TemperaturMaxAlter' => 30, + 'PuffertemperaturGlaetten' => false, + 'PrioritaetPV' => 3, + 'PrioritaetPeak' => 4, + 'Meldeintervall' => 5, + 'VorgabeTimeout' => 30, + 'EinstellungenInVisu' => true, + 'DiagnosevariablenAnzeigen' => true, + ]); + enelixDemoConfigure($battery, [ + 'Batterietyp' => 2, + 'Batteriemanagement' => 2, + 'MaxLadeleistungVariableID' => $batteryMaxCharge, + 'MaxEntladeleistungVariableID' => $batteryMaxDischarge, + 'LadezustandVariableID' => $batterySoc, + 'NetzleistungVariableID' => $grid, + 'IstleistungVariableID' => $batteryActual, + 'ManagementRegisterVariableID' => $batteryManagement, + 'ModusRegisterVariableID' => $batteryMode, + 'LeistungsRegisterVariableID' => $batteryRegisterPower, + 'ReserveLadezustand' => 20.0, + 'MindestLadezustand' => 10.0, + 'LadezustandHysterese' => 3.0, + 'MesswertMaxAlter' => 30, + 'Aenderungssperre' => 0, + 'PrioritaetPV' => 4, 'PrioritaetPeak' => 0, 'Meldeintervall' => 5, 'VorgabeTimeout' => 30, @@ -178,6 +258,8 @@ function enelixDemoInstall(): array enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1), enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2), enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'), + enelixDemoEnergyNode($flowBuffer, 'Pufferspeicher', 'consumer', $bufferTemp), + enelixDemoEnergyNode($flowBattery, 'Batteriespeicher', 'battery', $batterySoc), ]); IPS_SetHidden($energyDistribution, false); $oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung'); @@ -188,34 +270,47 @@ function enelixDemoInstall(): array foreach ([$boiler1, $boiler2] as $boilerId) { IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0); IPS_RequestAction($boilerId, 'Maximaltemperatur', 62.0); - IPS_RequestAction($boilerId, 'Aktiv', true); } - IPS_RequestAction($dryer, 'Aktiv', true); $manager = enelixDemoEnsureInstance($ems, 'Manager', 'Enelix Manager', ENELIX_DEMO_MANAGER_MODULE, 10); $assignment = array_map( static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true], - [$boiler1, $boiler2, $dryer] + [$boiler1, $boiler2, $dryer, $buffer, $battery] ); - enelixDemoConfigure($manager, [ + $managerProperties = [ 'NetzleistungVariableID' => $grid, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 10, 'AutomatischeSuche' => false, 'SuchbereichID' => $root, - 'VerbraucherZuordnung' => json_encode($assignment, JSON_THROW_ON_ERROR), + 'VerbraucherZuordnung' => '[]', 'KeepAlive' => 5, 'VerbraucherTimeout' => 15, 'SollwertSolarladen' => 0.0, 'Umschaltdifferenz' => 0.0, 'DiagnosevariablenAnzeigen' => true, 'LoggingEin' => false, - ]); - - foreach ([$boiler1, $boiler2, $dryer] as $consumerId) { - IPS_RequestAction($consumerId, 'Melden', true); + ]; + enelixDemoConfigure($manager, $managerProperties, [102, 203]); + $managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102; + if ($managerReady) { + $managerProperties['VerbraucherZuordnung'] = json_encode($assignment, JSON_THROW_ON_ERROR); + enelixDemoConfigure($manager, $managerProperties, [102, 203]); + $managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102; } - IPS_RequestAction($manager, 'Aktiv', true); + if (!$managerReady && IPS_GetProperty($manager, 'VerbraucherZuordnung') !== '[]') { + $managerProperties['VerbraucherZuordnung'] = '[]'; + enelixDemoConfigure($manager, $managerProperties, [203]); + } + + $consumerIds = [$boiler1, $boiler2, $dryer, $buffer, $battery]; + foreach ($consumerIds as $consumerId) { + IPS_RequestAction($consumerId, 'Aktiv', $managerReady); + if ($managerReady) { + IPS_RequestAction($consumerId, 'Melden', true); + } + } + IPS_RequestAction($manager, 'Aktiv', $managerReady); $boiler1List = enelixDemoEnsureInstance( $dashboard, @@ -231,14 +326,32 @@ function enelixDemoInstall(): array ENELIX_DEMO_DUMMY_MODULE, 120 ); - enelixDemoConfigure($boiler1List, []); - enelixDemoConfigure($boiler2List, []); + $bufferList = enelixDemoEnsureInstance( + $dashboard, + 'PufferListe', + 'Pufferspeicher', + ENELIX_DEMO_DUMMY_MODULE, + 130 + ); + $batteryList = enelixDemoEnsureInstance( + $dashboard, + 'BatterieListe', + 'Batteriespeicher', + ENELIX_DEMO_DUMMY_MODULE, + 140 + ); + foreach ([$boiler1List, $boiler2List, $bufferList, $batteryList] as $listId) { + enelixDemoConfigure($listId, []); + } enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1); enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2); + enelixDemoLinkInstanceVariables($bufferList, $buffer, $bufferTemp, 'Puffertemperatur', 'SensorPuffertemperatur'); + enelixDemoLinkInstanceVariables($batteryList, $battery, $batterySoc, 'Ladezustand', 'SensorLadezustand'); IPS_SetHidden($boiler1View, true); IPS_SetHidden($boiler2View, true); - IPS_SetHidden($boiler1, true); - IPS_SetHidden($boiler2, true); + foreach ([$boiler1, $boiler2, $buffer, $battery] as $hiddenInstanceId) { + IPS_SetHidden($hiddenInstanceId, true); + } foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) { $oldLink = enelixDemoFind($dashboard, $oldLinkIdent); @@ -248,22 +361,25 @@ function enelixDemoInstall(): array } enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100); - enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 130); - enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 70); - enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 80); + enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 150); + enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 100); + enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 110); + enelixDemoEnsureLink($simulation, 'PufferTemperaturLink', 'Puffertemperatur', $bufferTemp, 120); + enelixDemoEnsureLink($simulation, 'AussenTemperaturLink', 'Aussentemperatur', $outsideTemp, 130); + enelixDemoEnsureLink($simulation, 'BatterieLadezustandLink', 'Batterie Ladezustand', $batterySoc, 140); enelixDemoConfigureArchive([ $pvDisplay, $houseDisplay, $loadDisplay, $grid, - $boiler1Power, $boiler2Power, $dryerPower, + $boiler1Power, $boiler2Power, $dryerPower, $bufferPower, $batteryPower, $pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy, - $temp1, $temp2, + $temp1, $temp2, $bufferTemp, $outsideTemp, $batterySoc, $batteryActual, $flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer, - $flowHouse, $flowLoads, + $flowBuffer, $flowBattery, $flowHouse, $flowLoads, ]); enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [ enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'), enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'), - enelixDemoChartDataset($flowLoads, 'Geregelte Verbraucher', '#2786ac', '~Power'), + enelixDemoChartDataset($flowLoads, 'EMS-Leistung inkl. Speicher', '#2786ac', '~Power'), enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'), ], 10); enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [ @@ -279,7 +395,7 @@ function enelixDemoInstall(): array 'rootId' => $root, 'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE), 'managerId' => $manager, - 'consumerIds' => [$boiler1, $boiler2, $dryer], + 'consumerIds' => $consumerIds, 'tickScriptId' => $tickScript, ]; } @@ -301,13 +417,20 @@ function enelixDemoTick(string $source = 'Manual'): void SetValue($hourId, $hour); } + $dynamic = (bool) GetValue(enelixDemoRequiredId($simulation, 'DynamischeProfile')); + $environment = enelixDemoEnvironment( + $hour, + (float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast')), + (int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung')), + $dynamic + ); + $base = $environment['base'] + + (float) GetValue(enelixDemoRequiredId($simulation, 'Zusatzlast')); $peak = (float) GetValue(enelixDemoRequiredId($simulation, 'PVSpitze')); - $cloud = (int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung')); - $base = (float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast')); $sun = ($hour >= 6.0 && $hour <= 18.0) ? max(0.0, sin(M_PI * ($hour - 6.0) / 12.0)) : 0.0; - $pv = $peak * $sun * (1.0 - (min(100, max(0, $cloud)) * 0.0075)); + $pv = $peak * $sun * (1.0 - ($environment['cloud'] * 0.0085)); $contacts = [ 'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'), @@ -315,15 +438,46 @@ function enelixDemoTick(string $source = 'Manual'): void 'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'), 'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'), 'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'), + 'buffer1' => enelixDemoRequiredId($technical, 'PufferStufe1'), + 'buffer2' => enelixDemoRequiredId($technical, 'PufferStufe2'), + 'battery' => enelixDemoRequiredId($technical, 'BatterieIstleistung'), ]; - $powerBefore = enelixDemoControlledPower($contacts); $temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur'); $temp2 = enelixDemoRequiredId($technical, 'Boiler2Temperatur'); + $bufferTemp = enelixDemoRequiredId($technical, 'PufferTemperatur'); + $outsideTemp = enelixDemoRequiredId($technical, 'AussenTemperatur'); + $batterySoc = enelixDemoRequiredId($technical, 'BatterieLadezustand'); + $batteryMaxCharge = enelixDemoRequiredId($technical, 'BatterieMaxLaden'); + $batteryMaxDischarge = enelixDemoRequiredId($technical, 'BatterieMaxEntladen'); + $previousBatteryPower = (float) GetValue($contacts['battery']); + + SetValue($outsideTemp, round($environment['outside'], 2)); + SetValue($batteryMaxCharge, GetValue($batteryMaxCharge)); + SetValue($batteryMaxDischarge, GetValue($batteryMaxDischarge)); if ($source === 'Timer') { - enelixDemoUpdateTemperature($temp1, (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0), $minutes); - enelixDemoUpdateTemperature($temp2, (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0), $minutes); + enelixDemoUpdateTemperature( + $temp1, + (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0), + $minutes + ); + enelixDemoUpdateTemperature( + $temp2, + (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0), + $minutes + ); + enelixDemoUpdateBufferTemperature( + $bufferTemp, + (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0), + $environment['outside'], + $minutes + ); + enelixDemoUpdateBatterySoc($batterySoc, $previousBatteryPower, $minutes); + } else { + SetValue($bufferTemp, GetValue($bufferTemp)); + SetValue($batterySoc, GetValue($batterySoc)); } + $powerBefore = enelixDemoControlledPower($contacts); $gridId = enelixDemoRequiredId($dashboard, 'Netzleistung'); SetValue($gridId, round($base + $powerBefore - $pv, 1)); @@ -331,38 +485,55 @@ function enelixDemoTick(string $source = 'Manual'): void enelixDemoRequiredId($ems, 'Boiler1'), enelixDemoRequiredId($ems, 'Boiler2'), enelixDemoRequiredId($ems, 'Entfeuchter'), + enelixDemoRequiredId($ems, 'Pufferspeicher'), + enelixDemoRequiredId($ems, 'Batterie'), ]; - foreach ($consumers as $consumerId) { - IPS_RequestAction($consumerId, 'Melden', true); - } $manager = enelixDemoRequiredId($ems, 'Manager'); - IPS_RequestAction($manager, 'Regeln', true); + $managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102; + if ($managerReady) { + foreach ($consumers as $consumerId) { + IPS_RequestAction($consumerId, 'Melden', true); + } + IPS_RequestAction($manager, 'Regeln', true); + } + + $battery = $managerReady + ? enelixDemoBatteryRegisterPower( + (int) GetValue(enelixDemoRequiredId($technical, 'BatterieManagement')), + (int) GetValue(enelixDemoRequiredId($technical, 'BatterieModus')), + (float) GetValue(enelixDemoRequiredId($technical, 'BatterieRegisterLeistung')) + ) + : 0.0; + SetValue($contacts['battery'], $battery); $boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0); $boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0); $dryer = (bool) GetValue($contacts['dryer']) ? 900 : 0; - $power = $boiler1 + $boiler2 + $dryer; - $house = $base + $power; - $grid = $house - $pv; + $buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0); + $loads = $boiler1 + $boiler2 + $dryer + $buffer; + $emsPower = $loads + $battery; + $house = $base + $loads; + $grid = $house + $battery - $pv; - $pvId = enelixDemoRequiredId($dashboard, 'PVLeistung'); - $houseId = enelixDemoRequiredId($dashboard, 'Hausverbrauch'); - $loadId = enelixDemoRequiredId($dashboard, 'Verbraucherleistung'); SetValue($gridId, round($grid, 1)); - SetValue($pvId, round($pv, 1)); - SetValue($houseId, round($house, 1)); - SetValue($loadId, (float) $power); + SetValue(enelixDemoRequiredId($dashboard, 'PVLeistung'), round($pv, 1)); + SetValue(enelixDemoRequiredId($dashboard, 'Hausverbrauch'), round($house, 1)); + SetValue(enelixDemoRequiredId($dashboard, 'Verbraucherleistung'), (float) $emsPower); SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1); SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2); SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer); + SetValue(enelixDemoRequiredId($dashboard, 'PufferLeistung'), (float) $buffer); + SetValue(enelixDemoRequiredId($dashboard, 'BatterieLeistung'), $battery); SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'EnergieflussPufferkW'), round($buffer / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'EnergieflussBatteriekW'), round($battery / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3)); - SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($emsPower / 1000.0, 3)); $history = enelixDemoRequiredId($root, 'Historie'); $energyIds = [ @@ -378,7 +549,6 @@ function enelixDemoTick(string $source = 'Manual'): void enelixDemoAddEnergy($energyIds['import'], max(0.0, $grid), $hours); enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours); } - } function enelixDemoRenderFlow(array $state): string @@ -390,9 +560,15 @@ function enelixDemoRenderFlow(array $state): string $gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen'); $gridArrow = $grid < -25 ? '→' : ($grid > 25 ? '←' : '↔'); $gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import'); + $buffer = (float) ($state['buffer'] ?? 0.0); + $battery = (float) ($state['battery'] ?? 0.0); + $bufferTemp = (float) ($state['bufferTemp'] ?? 36.0); + $batterySoc = (float) ($state['batterySoc'] ?? 50.0); $b1Class = (float) $state['boiler1'] > 0 ? 'on' : ''; $b2Class = (float) $state['boiler2'] > 0 ? 'on' : ''; $dryClass = (float) $state['dryer'] > 0 ? 'on' : ''; + $bufferClass = $buffer > 0 ? 'on' : ''; + $batteryClass = abs($battery) > 25 ? 'on' : ''; return '
Enelix 2 Energiefluss
Manager regelt drei flexible Verbraucher
Simulation ' . $time . ' Uhr
-
PV-Erzeugung' . $format((float) $state['pv']) . ' W
→
Haus + flexible Lasten' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer']) . ' W
' . $gridArrow . '
' . $gridLabel . '' . $format(abs($grid)) . ' W
-
Priorität 1Boiler 1 · ' . $format((float) $state['boiler1']) . ' W' . number_format((float) $state['temp1'], 1, ',', '') . ' °C
Priorität 2Boiler 2 · ' . $format((float) $state['boiler2']) . ' W' . number_format((float) $state['temp2'], 1, ',', '') . ' °C
Priorität 3Entfeuchter · ' . $format((float) $state['dryer']) . ' Weinstufiger Verbraucher
+
Enelix 2 Energiefluss
Manager regelt fünf flexible Teilnehmer aus vier Modultypen
Simulation ' . $time . ' Uhr
+
PV-Erzeugung' . $format((float) $state['pv']) . ' W
→
Haus + EMS-Leistung' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer'] + $buffer + $battery) . ' W
' . $gridArrow . '
' . $gridLabel . '' . $format(abs($grid)) . ' W
+
Priorität 1Boiler 1 · ' . $format((float) $state['boiler1']) . ' W' . number_format((float) $state['temp1'], 1, ',', '') . ' °C
Priorität 2Boiler 2 · ' . $format((float) $state['boiler2']) . ' W' . number_format((float) $state['temp2'], 1, ',', '') . ' °C
Priorität 3Entfeuchter · ' . $format((float) $state['dryer']) . ' Weinstufiger Verbraucher
Priorität 4Pufferspeicher · ' . $format($buffer) . ' W' . number_format($bufferTemp, 1, ',', '') . ' °C · Heizkurve
Priorität 5 / Peak 1Batterie · ' . $format($battery) . ' W' . number_format($batterySoc, 1, ',', '') . ' % · bidirektional
Grundlast ' . $format((float) $state['base']) . ' WManager: ' . htmlspecialchars((string) $state['manager'], ENT_QUOTES, 'UTF-8') . '
'; } @@ -418,7 +594,65 @@ function enelixDemoControlledPower(array $contacts): int { $boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0); $boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0); - return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0); + $buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0); + $battery = (int) round((float) GetValue($contacts['battery'])); + + return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0) + $buffer + $battery; +} + +/** @return array{base: float, cloud: float, outside: float} */ +function enelixDemoEnvironment(float $hour, float $base, int $cloud, bool $dynamic): array +{ + $hour = fmod(max(0.0, $hour), 24.0); + $cloud = min(100, max(0, $cloud)); + if (!$dynamic) { + return ['base' => $base, 'cloud' => (float) $cloud, 'outside' => 6.0]; + } + + $morning = exp(-pow(($hour - 7.5) / 1.5, 2)); + $evening = exp(-pow(($hour - 19.0) / 2.2, 2)); + $night = ($hour < 5.0 || $hour >= 23.0) ? 0.65 : 0.82; + $baseFactor = $night + (0.48 * $morning) + (0.72 * $evening); + $cloudVariation = (18.0 * sin(($hour + 0.4) * 1.7)) + (8.0 * sin($hour * 4.6)); + $outside = 7.0 + (6.0 * sin(2.0 * M_PI * ($hour - 9.0) / 24.0)); + + return [ + 'base' => max(150.0, $base * $baseFactor), + 'cloud' => min(100.0, max(0.0, $cloud + $cloudVariation)), + 'outside' => $outside, + ]; +} + +function enelixDemoBatteryRegisterPower(int $management, int $mode, float $power): float +{ + if ($management !== 2) { + return 0.0; + } + if ($mode === 11) { + return max(0.0, $power); + } + if ($mode === 12) { + return -max(0.0, $power); + } + + return 0.0; +} + +function enelixDemoUpdateBufferTemperature(int $variableId, int $power, float $outside, int $minutes): void +{ + $current = (float) GetValue($variableId); + $heating = ($power / 1000.0) * 0.018 * $minutes; + $loss = max(0.0, $current - $outside) * 0.0007 * $minutes; + SetValue($variableId, round(min(65.0, max(22.0, $current + $heating - $loss)), 2)); +} + +function enelixDemoUpdateBatterySoc(int $variableId, float $power, int $minutes): void +{ + $current = (float) GetValue($variableId); + $energy = ($power / 1000.0) * ($minutes / 60.0); + $storedEnergy = $energy >= 0.0 ? $energy * 0.94 : $energy / 0.94; + $delta = ($storedEnergy / 10.0) * 100.0; + SetValue($variableId, round(min(100.0, max(5.0, $current + $delta)), 2)); } function enelixDemoUpdateTemperature(int $variableId, int $power, int $minutes): void @@ -439,6 +673,8 @@ function enelixDemoValidateRuntime(): void ENELIX_DEMO_MANAGER_MODULE, ENELIX_DEMO_BOILER_MODULE, ENELIX_DEMO_SINGLE_MODULE, + ENELIX_DEMO_BUFFER_MODULE, + ENELIX_DEMO_BATTERY_MODULE, ENELIX_DEMO_DUMMY_MODULE, ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE, ] as $moduleId) { @@ -450,10 +686,11 @@ function enelixDemoValidateRuntime(): void function enelixDemoEnsureProfiles(): void { - enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -12000, 12000, 100, 0, ' W'); - enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 12000, 100, 0, ' W'); + enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -20000, 20000, 100, 0, ' W'); + enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 20000, 100, 0, ' W'); enelixDemoEnsureProfile('ENELIX.Demo.Hour', 2, 0, 23.75, 0.25, 2, ' h'); enelixDemoEnsureProfile('ENELIX.Demo.Percent', 1, 0, 100, 5, 0, ' %'); + enelixDemoEnsureProfile('ENELIX.Demo.PercentFloat', 2, 0, 100, 0.1, 1, ' %'); enelixDemoEnsureProfile('ENELIX.Demo.Speed', 1, 1, 60, 1, 0, ' min'); enelixDemoEnsureProfile('ENELIX.Demo.Energy', 2, 0, 1000000, 0.01, 2, ' kWh'); } @@ -628,15 +865,34 @@ function enelixDemoEnsureCyclicEvent(int $scriptId, string $ident, int $seconds) return $id; } -function enelixDemoConfigure(int $instanceId, array $properties): void +function enelixDemoConfigure( + int $instanceId, + array $properties, + array $allowedStatuses = [102] +): void { foreach ($properties as $name => $value) { IPS_SetProperty($instanceId, (string) $name, $value); } IPS_ApplyChanges($instanceId); - if ((int) IPS_GetInstance($instanceId)['InstanceStatus'] !== 102) { - throw new RuntimeException('Instanz ist nach der Konfiguration nicht aktiv: ' . IPS_GetName($instanceId)); + $deadline = microtime(true) + 2.0; + do { + $status = (int) IPS_GetInstance($instanceId)['InstanceStatus']; + if ($status === 102) { + return; + } + IPS_Sleep(50); + } while (microtime(true) < $deadline); + + if (in_array($status, $allowedStatuses, true)) { + return; } + + throw new RuntimeException( + 'Instanz ist nach der Konfiguration nicht aktiv: ' + . IPS_GetName($instanceId) + . ' (Status ' . $status . ', erwartet: ' . implode(', ', $allowedStatuses) . ')' + ); } function enelixDemoConfigureArchive(array $variableIds): void @@ -713,7 +969,7 @@ function enelixDemoRemove(bool $confirmed = false): void IPS_ApplyChanges($visualization); } enelixDemoDeleteTree($root); - foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) { + foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.PercentFloat', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) { if (IPS_VariableProfileExists($profile)) { IPS_DeleteVariableProfile($profile); } @@ -771,9 +1027,14 @@ function enelixDemoConfigureEnergyDistribution(int $instanceId, array $nodes): v ]); } -function enelixDemoLinkInstanceVariables(int $categoryId, int $instanceId, int $sensorId): void -{ - enelixDemoEnsureLink($categoryId, 'SensorTemperatur', 'Gemessene Temperatur', $sensorId, 5); +function enelixDemoLinkInstanceVariables( + int $categoryId, + int $instanceId, + int $sensorId, + string $sensorName = 'Gemessene Temperatur', + string $sensorIdent = 'SensorTemperatur' +): void { + enelixDemoEnsureLink($categoryId, $sensorIdent, $sensorName, $sensorId, 5); $position = 10; foreach (IPS_GetChildrenIDs($instanceId) as $childId) { if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) { diff --git a/tests/DemoanlageBootstrapTest.php b/tests/DemoanlageBootstrapTest.php index dd451d0..faa7654 100644 --- a/tests/DemoanlageBootstrapTest.php +++ b/tests/DemoanlageBootstrapTest.php @@ -18,8 +18,12 @@ final class DemoanlageBootstrapTest extends TestCase 'boiler1' => 2400, 'boiler2' => 1500, 'dryer' => 900, + 'buffer' => 1800, + 'battery' => -1200, 'temp1' => 51.2, 'temp2' => 48.7, + 'bufferTemp' => 39.4, + 'batterySoc' => 63.5, 'manager' => 'PV', ]); @@ -27,6 +31,9 @@ final class DemoanlageBootstrapTest extends TestCase self::assertStringContainsString('Boiler 1', $html); self::assertStringContainsString('Boiler 2', $html); self::assertStringContainsString('Entfeuchter', $html); + self::assertStringContainsString('Pufferspeicher', $html); + self::assertStringContainsString('Batterie', $html); + self::assertStringContainsString('63,5 %', $html); self::assertStringContainsString('Einspeisung', $html); self::assertStringContainsString('12:30', $html); } @@ -105,6 +112,10 @@ final class DemoanlageBootstrapTest extends TestCase self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source); self::assertStringContainsString("'Boiler1Liste'", $source); self::assertStringContainsString("'Boiler2Liste'", $source); + self::assertStringContainsString("'PufferListe'", $source); + self::assertStringContainsString("'BatterieListe'", $source); + self::assertStringContainsString('ENELIX_DEMO_BUFFER_MODULE', $source); + self::assertStringContainsString('ENELIX_DEMO_BATTERY_MODULE', $source); self::assertStringContainsString("'EnergieflussPVkW'", $source); self::assertStringContainsString("'~Power'", $source); self::assertStringContainsString("'~Electricity'", $source); @@ -120,6 +131,20 @@ final class DemoanlageBootstrapTest extends TestCase "enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')", $source ); + self::assertStringContainsString( + "enelixDemoEnergyNode(\$flowBuffer, 'Pufferspeicher', 'consumer', \$bufferTemp)", + $source + ); + self::assertStringContainsString( + "enelixDemoEnergyNode(\$flowBattery, 'Batteriespeicher', 'battery', \$batterySoc)", + $source + ); + self::assertStringContainsString( + "\$consumerIds = [\$boiler1, \$boiler2, \$dryer, \$buffer, \$battery]", + $source + ); + self::assertStringContainsString("'consumerIds' => \$consumerIds", $source); + self::assertStringContainsString("'VerbraucherZuordnung' => '[]'", $source); self::assertStringContainsString( "enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'", $source @@ -135,6 +160,27 @@ final class DemoanlageBootstrapTest extends TestCase ); } + public function testDynamischeUmgebungBleibtBegrenztUndVeraendertDenLastgang(): void + { + $static = enelixDemoEnvironment(12.0, 1000.0, 30, false); + self::assertSame(['base' => 1000.0, 'cloud' => 30.0, 'outside' => 6.0], $static); + + $morning = enelixDemoEnvironment(7.5, 1000.0, 30, true); + $night = enelixDemoEnvironment(2.0, 1000.0, 30, true); + self::assertGreaterThan($night['base'], $morning['base']); + self::assertGreaterThanOrEqual(0.0, $morning['cloud']); + self::assertLessThanOrEqual(100.0, $morning['cloud']); + self::assertNotSame($morning['outside'], $night['outside']); + } + + public function testBatterieregisterWerdenMitKorrektemVorzeichenAbgebildet(): void + { + self::assertSame(1500.0, enelixDemoBatteryRegisterPower(2, 11, 1500.0)); + self::assertSame(-1500.0, enelixDemoBatteryRegisterPower(2, 12, 1500.0)); + self::assertSame(0.0, enelixDemoBatteryRegisterPower(1, 11, 1500.0)); + self::assertSame(0.0, enelixDemoBatteryRegisterPower(2, 0, 1500.0)); + } + public function testRollbackIstExplizitBestaetigungspflichtig(): void { $this->expectException(RuntimeException::class);