From 708a8ecea29781a987a8f265424ac2cb9f5a0d11 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Sun, 27 Sep 2026 19:29:37 +0000 Subject: [PATCH] feat(manager): Energieaufzeichnung und Visualisierungen integrieren --- Manager/form.json | 139 +++++- Manager/module.php | 405 ++++++++++++++-- docs/module/Manager/README.md | 64 ++- examples/Demoanlage/bootstrap.php | 13 +- libs/EnergieMessung.php | 52 +++ libs/ManagerEnergieTrait.php | 542 ++++++++++++++++++++++ tests/DemoanlageBootstrapTest.php | 9 +- tests/EnergieMessungTest.php | 66 +++ tests/ManagerEnergieModulstrukturTest.php | 93 ++++ tests/ManagerModulstrukturTest.php | 11 +- tests/Symcon/modules/Manager.php | 88 +++- 11 files changed, 1426 insertions(+), 56 deletions(-) create mode 100644 libs/EnergieMessung.php create mode 100644 libs/ManagerEnergieTrait.php create mode 100644 tests/EnergieMessungTest.php create mode 100644 tests/ManagerEnergieModulstrukturTest.php diff --git a/Manager/form.json b/Manager/form.json index 0037369..f1fe230 100644 --- a/Manager/form.json +++ b/Manager/form.json @@ -321,6 +321,13 @@ "add": 0, "edit": {"type": "SelectVariable"} }, + { + "caption": "Messfaktor", + "name": "Istleistungsfaktor", + "width": "110px", + "add": 1.0, + "edit": {"type": "NumberSpinner", "digits": 4} + }, { "caption": "PV-Begrenzung", "name": "BegrenzungVariableID", @@ -534,6 +541,27 @@ {"caption": "Hybrid", "value": "hybrid"} ] } + }, + { + "caption": "Leistung", + "name": "LeistungVariableID", + "width": "140px", + "add": 0, + "edit": {"type": "SelectVariable"} + }, + { + "caption": "Messfaktor", + "name": "Leistungsfaktor", + "width": "110px", + "add": 1.0, + "edit": {"type": "NumberSpinner", "digits": 4} + }, + { + "caption": "Ladezustand", + "name": "SOCVariableID", + "width": "140px", + "add": 0, + "edit": {"type": "SelectVariable"} } ] } @@ -591,24 +619,109 @@ } ] }, + { + "type": "ExpansionPanel", + "caption": "Energieaufzeichnung", + "expanded": true, + "items": [ + { + "type": "CheckBox", + "name": "EnergieaufzeichnungAktiv", + "caption": "Leistungs- und Energiewerte automatisch aufzeichnen" + }, + { + "type": "Label", + "caption": "PV und Batterie werden aus der Anlagentopologie summiert. Der Hausverbrauch wird aus PV + Netz - Batterie berechnet. Netz: positiv Bezug; Batterie: positiv Laden." + }, + { + "type": "CheckBox", + "name": "MesswerteAnzeigen", + "caption": "Einzelne Leistungs- und Energiewerte unter dem Manager anzeigen" + }, + { + "type": "RowLayout", + "items": [ + { + "type": "Select", + "name": "LeistungsaufzeichnungMinuten", + "caption": "Leistung verdichten", + "options": [ + {"caption": "1 Minute", "value": 1}, + {"caption": "5 Minuten", "value": 5}, + {"caption": "1 Stunde", "value": 60} + ] + }, + { + "type": "NumberSpinner", + "name": "LeistungLoeschenMonate", + "caption": "Leistung loeschen nach", + "minimum": 0, + "suffix": " Monaten (0 = nie)" + } + ] + }, + { + "type": "RowLayout", + "items": [ + { + "type": "Select", + "name": "EnergieaufzeichnungMinuten", + "caption": "Energie verdichten", + "options": [ + {"caption": "1 Minute", "value": 1}, + {"caption": "5 Minuten", "value": 5}, + {"caption": "1 Stunde", "value": 60} + ] + }, + { + "type": "NumberSpinner", + "name": "EnergieVerdichtenMonate", + "caption": "Auf Tageswerte verdichten nach", + "minimum": 0, + "suffix": " Monaten" + }, + { + "type": "NumberSpinner", + "name": "EnergieLoeschenMonate", + "caption": "Energie loeschen nach", + "minimum": 0, + "suffix": " Monaten (0 = nie)" + } + ] + }, + { + "type": "CheckBox", + "name": "EnergyPieAnzeigen", + "caption": "Energy Pie unter dem Manager anzeigen", + "onChange": "IPS_RequestAction($id, 'FormEnergyPieAnzeigen', $EnergyPieAnzeigen);" + }, + { + "type": "CheckBox", + "name": "EnergiediagrammeAnzeigen", + "caption": "Leistungs- und Energiediagramm unter dem Manager anzeigen", + "onChange": "IPS_RequestAction($id, 'FormEnergiediagrammeAnzeigen', $EnergiediagrammeAnzeigen);" + }, + { + "type": "CheckBox", + "name": "FunFactsAnzeigen", + "caption": "Energy Facts unter dem Manager anzeigen", + "onChange": "IPS_RequestAction($id, 'FormFunFactsAnzeigen', $FunFactsAnzeigen);" + }, + { + "type": "CheckBox", + "name": "EnergieflussAnzeigen", + "caption": "Energiefluss unter dem Manager anzeigen", + "onChange": "IPS_RequestAction($id, 'FormEnergieflussAnzeigen', $EnergieflussAnzeigen);" + } + ] + }, { "type": "ExpansionPanel", "caption": "Prognose / Forecast", "items": [ { - "type": "SelectVariable", - "name": "PrognosePVVariableID", - "caption": "PV-Leistung" - }, - { - "type": "SelectVariable", - "name": "PrognoseHausverbrauchVariableID", - "caption": "Hausverbrauch" - }, - { - "type": "SelectVariable", - "name": "PrognoseSOCVariableID", - "caption": "Batterie-Ladezustand" + "type": "Label", + "caption": "Forecast verwendet dieselben Messquellen aus der Anlagentopologie und den daraus berechneten Hausverbrauch." }, { "type": "NumberSpinner", diff --git a/Manager/module.php b/Manager/module.php index 1906c39..e62963e 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -9,9 +9,13 @@ require_once __DIR__ . '/../libs/EinspeiseRegler.php'; require_once __DIR__ . '/../libs/Lizenzpruefung.php'; require_once __DIR__ . '/../libs/Anlagentopologie.php'; require_once __DIR__ . '/../libs/StoerungsSnapshot.php'; +require_once __DIR__ . '/../libs/EnergieMessung.php'; +require_once __DIR__ . '/../libs/ManagerEnergieTrait.php'; use Belevo\EnelixEMS\Anlagentopologie; use Belevo\EnelixEMS\EinspeiseRegler; +use Belevo\EnelixEMS\EnergieMessung; +use Belevo\EnelixEMS\ManagerEnergieTrait; use Belevo\EnelixEMS\Lizenzpruefung; use Belevo\EnelixEMS\ManagerRegler; use Belevo\EnelixEMS\ManagerSchnittstelle; @@ -20,6 +24,8 @@ use Belevo\EnelixEMS\StoerungsSnapshot; class Manager extends IPSModule implements ManagerSchnittstelle { + use ManagerEnergieTrait; + private const STATUS_AKTIV = 102; private const STATUS_MESSWERT_UNGUELTIG = 201; private const STATUS_KONFIGURATION_UNGUELTIG = 202; @@ -32,6 +38,23 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const LIZENZ_RETRY_SEKUNDEN = 3600; private const STOERUNGS_RETRY_MAX_SEKUNDEN = 900; private const VM_UPDATE = 10603; + private const ARCHIVE_MODULE_ID = '{43192F0B-135B-4CE7-A0A7-1475603F3060}'; + private const ENERGY_PIE_MODULE_ID = '{F73B8F8D-5E36-4B23-B379-4632A517A1D6}'; + private const ENERGY_FLOW_MODULE_ID = '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}'; + private const LEISTUNGS_VARIABLEN = [ + 'PVLeistungArchiv', + 'HausverbrauchLeistungArchiv', + 'NetzleistungArchiv', + 'BatterieleistungArchiv', + ]; + private const ENERGIE_VARIABLEN = [ + 'PVEnergie', + 'Hausenergie', + 'NetzbezugEnergie', + 'EinspeisungEnergie', + 'BatterieLadenEnergie', + 'BatterieEntladenEnergie', + ]; private const VERBRAUCHER_LIZENZEN = [ '{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [ 'Katalog' => 'boiler_multi', @@ -112,6 +135,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterPropertyString('AnlagenWechselrichter', '[]'); $this->RegisterPropertyString('AnlagenPVFlaechen', '[]'); $this->RegisterPropertyString('AnlagenBatterien', '[]'); + // Verdeckte Altproperties; Messquellen liegen neu ausschliesslich in der Anlagentopologie. $this->RegisterPropertyInteger('PrognosePVVariableID', 0); $this->RegisterPropertyInteger('PrognoseHausverbrauchVariableID', 0); $this->RegisterPropertyInteger('PrognoseSOCVariableID', 0); @@ -127,6 +151,24 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterPropertyInteger('StoerungsSendeintervall', 300); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); $this->RegisterPropertyBoolean('LoggingEin', false); + $this->RegisterPropertyBoolean('EnergieaufzeichnungAktiv', true); + // Verdeckte Altproperties; bestehende Konfigurationen bleiben lesbar. + $this->RegisterPropertyInteger('MessungPVLeistungVariableID', 0); + $this->RegisterPropertyInteger('MessungHausverbrauchLeistungVariableID', 0); + $this->RegisterPropertyInteger('MessungBatterieleistungVariableID', 0); + $this->RegisterPropertyFloat('MessungPVLeistungsfaktor', 1.0); + $this->RegisterPropertyFloat('MessungHausverbrauchLeistungsfaktor', 1.0); + $this->RegisterPropertyFloat('MessungBatterieleistungsfaktor', 1.0); + $this->RegisterPropertyBoolean('MesswerteAnzeigen', false); + $this->RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1); + $this->RegisterPropertyInteger('LeistungLoeschenMonate', 12); + $this->RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5); + $this->RegisterPropertyInteger('EnergieVerdichtenMonate', 12); + $this->RegisterPropertyInteger('EnergieLoeschenMonate', 0); + $this->RegisterPropertyBoolean('EnergyPieAnzeigen', false); + $this->RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false); + $this->RegisterPropertyBoolean('FunFactsAnzeigen', false); + $this->RegisterPropertyBoolean('EnergieflussAnzeigen', false); $this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10); $this->EnableAction('Aktiv'); @@ -150,6 +192,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterAttributeInteger('StoerungsLetzterErfolg', 0); $this->RegisterAttributeInteger('StoerungsNaechsterVersuch', 0); $this->RegisterAttributeInteger('StoerungsFehlversuche', 0); + $this->RegisterAttributeInteger('MessungLetzterZeitstempel', 0); + $this->RegisterAttributeInteger('MessungLetzteLeistungsaufzeichnung', 0); + $this->RegisterAttributeInteger('MessungLetzteEnergieaufzeichnung', 0); + $this->RegisterAttributeString('MessungEnergiePuffer', '{}'); // Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren. $this->RegisterTimer('Regelzyklus', 0, ''); @@ -169,6 +215,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle 0, "IPS_RequestAction(\$_IPS['TARGET'], 'StoerungenSenden', true);" ); + $this->RegisterTimer( + 'MesswerteAufzeichnen', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'MesswerteAufzeichnen', true);" + ); } public function ApplyChanges(): void @@ -180,6 +231,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle try { $this->pruefeKonfiguration(); + $this->konfiguriereEnergiearchiv(); + $this->synchronisiereEnergievisualisierungen(); } catch (Throwable $fehler) { $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); $this->SetTimerInterval('Regelzyklus', 0); @@ -187,6 +240,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->SetTimerInterval('KeepAlive', 0); $this->SetTimerInterval('PrognoseSenden', 0); $this->SetTimerInterval('StoerungenSenden', 0); + $this->SetTimerInterval('MesswerteAufzeichnen', 0); $this->protokolliere('Konfiguration', $fehler->getMessage()); return; } @@ -200,6 +254,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'StoerungenSenden', $this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 30000 : 0 ); + $messungAktiv = $this->messungKonfiguriert(); + $this->SetTimerInterval('MesswerteAufzeichnen', $messungAktiv ? 60000 : 0); + if ($messungAktiv) { + $this->zeichneMesswerteAuf(); + } $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->aktualisiereAnschlussstatus(); $geraetezugangFehlt = ($this->hatAnlagentopologie() @@ -378,6 +437,26 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->sendeStoerungsSnapshot(false); return; + case 'MesswerteAufzeichnen': + $this->zeichneMesswerteAuf(); + return; + + case 'FormEnergyPieAnzeigen': + $this->synchronisiereEnergyPie((bool) $wert); + return; + + case 'FormEnergiediagrammeAnzeigen': + $this->synchronisiereEnergiediagramme((bool) $wert); + return; + + case 'FormFunFactsAnzeigen': + $this->synchronisiereFunFacts((bool) $wert); + return; + + case 'FormEnergieflussAnzeigen': + $this->synchronisiereEnergiefluss((bool) $wert); + return; + case 'FormLizenzPruefen': if (!is_string($wert)) { throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.'); @@ -1430,6 +1509,32 @@ class Manager extends IPSModule implements ManagerSchnittstelle private function aktualisiereVariablen(): void { + $leistungsvariablen = [ + 'PVLeistungArchiv' => 'PV-Leistung', + 'HausverbrauchLeistungArchiv' => 'Hausverbrauch', + 'NetzleistungArchiv' => 'Netzleistung Aufzeichnung', + 'BatterieleistungArchiv' => 'Batterieleistung', + ]; + $energievariablen = [ + 'PVEnergie' => 'PV-Energie', + 'Hausenergie' => 'Hausenergie', + 'NetzbezugEnergie' => 'Netzbezug Energie', + 'EinspeisungEnergie' => 'Einspeisung Energie', + 'BatterieLadenEnergie' => 'Batterie geladen', + 'BatterieEntladenEnergie' => 'Batterie entladen', + ]; + $position = 200; + foreach ($leistungsvariablen as $ident => $name) { + $this->RegisterVariableFloat($ident, $name, '~Power', $position); + IPS_SetHidden($this->GetIDForIdent($ident), !$this->ReadPropertyBoolean('MesswerteAnzeigen')); + $position += 10; + } + foreach ($energievariablen as $ident => $name) { + $this->RegisterVariableFloat($ident, $name, '~Electricity', $position); + IPS_SetHidden($this->GetIDForIdent($ident), !$this->ReadPropertyBoolean('MesswerteAnzeigen')); + $position += 10; + } + if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { $this->RegisterVariableBoolean( 'NetzleistungGueltig', @@ -1495,6 +1600,249 @@ class Manager extends IPSModule implements ManagerSchnittstelle } } + private function messungKonfiguriert(): bool + { + return $this->ReadPropertyBoolean('EnergieaufzeichnungAktiv') + && $this->messquellenKonfiguriert(); + } + + private function messquellenKonfiguriert(): bool + { + try { + return $this->istNumerischeVariable($this->ReadPropertyInteger('NetzleistungVariableID')) + && $this->pvMessquellen() !== []; + } catch (Throwable $fehler) { + return false; + } + } + + /** @return list */ + private function pvMessquellen(): array + { + $wechselrichter = $this->leseAnlagenKonfigurationsliste('AnlagenWechselrichter'); + $pvFlaechen = $this->leseAnlagenKonfigurationsliste('AnlagenPVFlaechen'); + $verwendeteWechselrichter = []; + foreach ($pvFlaechen as $pvFlaeche) { + $id = trim((string) ($pvFlaeche['WechselrichterID'] ?? '')); + if ($id !== '') { + $verwendeteWechselrichter[$id] = true; + } + } + + $quellen = []; + foreach ($wechselrichter as $index => $eintrag) { + $typ = (string) ($eintrag['Typ'] ?? ''); + $id = trim((string) ($eintrag['ID'] ?? '')); + if ($verwendeteWechselrichter !== []) { + if (!isset($verwendeteWechselrichter[$id])) { + continue; + } + } elseif (!in_array($typ, ['pv', 'hybrid'], true)) { + continue; + } + $variableID = $eintrag['IstleistungVariableID'] ?? 0; + if ($variableID === 0) { + continue; + } + if (!is_int($variableID) || !$this->istNumerischeVariable($variableID)) { + throw new InvalidArgumentException( + 'Wechselrichter ' . ($index + 1) . ': Istleistung muss eine numerische Variable sein.' + ); + } + $quellen[] = [ + 'VariableID' => $variableID, + 'Faktor' => $this->anlagenMessfaktor( + $eintrag['Istleistungsfaktor'] ?? 1.0, + 'Wechselrichter ' . ($index + 1) + ), + ]; + } + + return $quellen; + } + + /** @return list */ + private function batterieMessquellen(): array + { + $quellen = []; + foreach ($this->leseAnlagenKonfigurationsliste('AnlagenBatterien') as $index => $eintrag) { + $variableID = $eintrag['LeistungVariableID'] ?? 0; + if ($variableID === 0) { + continue; + } + if (!is_int($variableID) || !$this->istNumerischeVariable($variableID)) { + throw new InvalidArgumentException( + 'Batterie ' . ($index + 1) . ': Leistung muss eine numerische Variable sein.' + ); + } + $quellen[] = [ + 'VariableID' => $variableID, + 'Faktor' => $this->anlagenMessfaktor( + $eintrag['Leistungsfaktor'] ?? 1.0, + 'Batterie ' . ($index + 1) + ), + ]; + } + + return $quellen; + } + + /** @return list */ + private function batterieSocQuellen(): array + { + $quellen = []; + foreach ($this->leseAnlagenKonfigurationsliste('AnlagenBatterien') as $index => $eintrag) { + $variableID = $eintrag['SOCVariableID'] ?? 0; + if ($variableID === 0) { + continue; + } + if (!is_int($variableID) || !$this->istNumerischeVariable($variableID)) { + throw new InvalidArgumentException( + 'Batterie ' . ($index + 1) . ': SOC muss eine numerische Variable sein.' + ); + } + $kapazitaet = $eintrag['Nutzkapazitaet_kWh'] ?? 0.0; + $quellen[] = [ + 'VariableID' => $variableID, + 'Gewicht' => is_numeric($kapazitaet) && (float) $kapazitaet > 0.0 + ? (float) $kapazitaet + : 1.0, + ]; + } + + return $quellen; + } + + /** @return list> */ + private function leseAnlagenKonfigurationsliste(string $property): array + { + $liste = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($liste) || !array_is_list($liste)) { + throw new InvalidArgumentException($property . ' muss eine JSON-Liste sein.'); + } + + return array_values(array_filter($liste, 'is_array')); + } + + /** @param mixed $wert */ + private function anlagenMessfaktor($wert, string $bezeichnung): float + { + if (!is_int($wert) && !is_float($wert)) { + throw new InvalidArgumentException($bezeichnung . ': Messfaktor muss numerisch sein.'); + } + $faktor = (float) $wert; + if (!is_finite($faktor) || $faktor === 0.0) { + throw new InvalidArgumentException($bezeichnung . ': Messfaktor muss endlich und ungleich 0 sein.'); + } + + return $faktor; + } + + /** @return array{PV: float, Haus: float, Netz: float, Batterie: float} */ + private function leseMessleistungen(): array + { + $pv = 0.0; + foreach ($this->pvMessquellen() as $quelle) { + $pv += max(0.0, (float) GetValue($quelle['VariableID']) * $quelle['Faktor']); + } + $batterie = 0.0; + foreach ($this->batterieMessquellen() as $quelle) { + $batterie += (float) GetValue($quelle['VariableID']) * $quelle['Faktor']; + } + $netz = (float) GetValue($this->ReadPropertyInteger('NetzleistungVariableID')) + * $this->ReadPropertyFloat('Netzleistungsfaktor'); + + return [ + 'PV' => $pv, + 'Haus' => EnergieMessung::hausleistung($pv, $netz, $batterie), + 'Netz' => $netz, + 'Batterie' => $batterie, + ]; + } + + private function aktuelleBatterieSoc(): ?float + { + $quellen = $this->batterieSocQuellen(); + if ($quellen === []) { + return $this->leseAnlagenKonfigurationsliste('AnlagenBatterien') === [] ? 0.0 : null; + } + $summe = 0.0; + $gewicht = 0.0; + foreach ($quellen as $quelle) { + $wert = (float) GetValue($quelle['VariableID']); + if (!is_finite($wert) || $wert < 0.0 || $wert > 100.0) { + return null; + } + $summe += $wert * $quelle['Gewicht']; + $gewicht += $quelle['Gewicht']; + } + + return $gewicht > 0.0 ? $summe / $gewicht : null; + } + + private function zeichneMesswerteAuf(): void + { + if (!$this->messungKonfiguriert()) { + return; + } + $jetzt = time(); + $leistungen = $this->leseMessleistungen(); + $letzterZeitstempel = $this->ReadAttributeInteger('MessungLetzterZeitstempel'); + $puffer = $this->leseJsonAttribut('MessungEnergiePuffer'); + if ($letzterZeitstempel > 0) { + $sekunden = min(300, max(0, $jetzt - $letzterZeitstempel)); + foreach (EnergieMessung::energieDeltas($leistungen, $sekunden) as $ident => $delta) { + $puffer[$ident] = max(0.0, (float) ($puffer[$ident] ?? 0.0)) + $delta; + } + } + $this->WriteAttributeInteger('MessungLetzterZeitstempel', $jetzt); + $this->WriteAttributeString( + 'MessungEnergiePuffer', + json_encode($puffer, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) + ); + + $leistungsintervall = $this->ReadPropertyInteger('LeistungsaufzeichnungMinuten') * 60; + $letzteLeistung = $this->ReadAttributeInteger('MessungLetzteLeistungsaufzeichnung'); + if ($letzteLeistung === 0 || $jetzt - $letzteLeistung >= $leistungsintervall) { + foreach ([ + 'PVLeistungArchiv' => 'PV', + 'HausverbrauchLeistungArchiv' => 'Haus', + 'NetzleistungArchiv' => 'Netz', + 'BatterieleistungArchiv' => 'Batterie', + ] as $ident => $quelle) { + $this->SetValue($ident, $leistungen[$quelle]); + } + $this->WriteAttributeInteger('MessungLetzteLeistungsaufzeichnung', $jetzt); + } + + $energieintervall = $this->ReadPropertyInteger('EnergieaufzeichnungMinuten') * 60; + $letzteEnergie = $this->ReadAttributeInteger('MessungLetzteEnergieaufzeichnung'); + if ($letzteEnergie === 0) { + $this->WriteAttributeInteger('MessungLetzteEnergieaufzeichnung', $jetzt); + } elseif ($jetzt - $letzteEnergie >= $energieintervall) { + foreach (self::ENERGIE_VARIABLEN as $ident) { + $this->SetValue($ident, (float) $this->GetValue($ident) + (float) ($puffer[$ident] ?? 0.0)); + } + $this->WriteAttributeString('MessungEnergiePuffer', '{}'); + $this->WriteAttributeInteger('MessungLetzteEnergieaufzeichnung', $jetzt); + } + + $funFactsID = @IPS_GetObjectIDByIdent('EnergieFunFacts', $this->InstanceID); + if (is_int($funFactsID) && $funFactsID > 0 && IPS_VariableExists($funFactsID)) { + $this->aktualisiereFunFacts($funFactsID); + } + } + + private function istNumerischeVariable(int $variableID): bool + { + if ($variableID <= 0 || !IPS_VariableExists($variableID)) { + return false; + } + $variable = IPS_GetVariable($variableID); + + return in_array((int) ($variable['VariableType'] ?? -1), [1, 2], true); + } + /** @param bool|float|int|string $wert */ private function setzeDiagnosewert(string $ident, $wert): void { @@ -1929,6 +2277,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); } } + foreach (['LeistungsaufzeichnungMinuten', 'EnergieaufzeichnungMinuten'] as $property) { + $wert = $this->ReadPropertyInteger($property); + if (!in_array($wert, [1, 5, 60], true)) { + throw new InvalidArgumentException($property . ' muss 1, 5 oder 60 Minuten betragen.'); + } + } + foreach (['LeistungLoeschenMonate', 'EnergieVerdichtenMonate', 'EnergieLoeschenMonate'] as $property) { + if ($this->ReadPropertyInteger($property) < 0) { + throw new InvalidArgumentException($property . ' darf nicht negativ sein.'); + } + } + $energieLoeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate'); + $energieVerdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate'); + if ($energieLoeschen > 0 && $energieLoeschen <= $energieVerdichten) { + throw new InvalidArgumentException('Energie darf erst nach der taeglichen Verdichtung geloescht werden.'); + } $intervall = $this->ReadPropertyInteger('PrognoseSendeintervall'); if ($intervall < 60 || $intervall > 3600) { throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.'); @@ -1937,25 +2301,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle if ($stoerungsIntervall < 60 || $stoerungsIntervall > 3600) { throw new InvalidArgumentException('StoerungsSendeintervall muss zwischen 60 und 3600 Sekunden liegen.'); } - $telemetrieIDs = [ - $this->ReadPropertyInteger('PrognosePVVariableID'), - $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID'), - $this->ReadPropertyInteger('PrognoseSOCVariableID'), - ]; - $konfigurierteIDs = array_values(array_filter($telemetrieIDs, static fn (int $id): bool => $id > 0)); - if ($konfigurierteIDs !== [] && count($konfigurierteIDs) !== count($telemetrieIDs)) { - throw new InvalidArgumentException('Fuer die Prognose muessen PV, Hausverbrauch und SOC gemeinsam konfiguriert sein.'); - } - foreach ($konfigurierteIDs as $variableID) { - if (!IPS_VariableExists($variableID)) { - throw new InvalidArgumentException('Eine konfigurierte Prognosevariable existiert nicht.'); - } - } - if ($konfigurierteIDs !== [] - && !IPS_VariableExists($this->ReadPropertyInteger('NetzleistungVariableID')) - ) { - throw new InvalidArgumentException('Die Netzleistungsvariable fuer die Prognose existiert nicht.'); - } if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0 || $this->ReadPropertyFloat('Umschaltdifferenz') > 100 ) { @@ -1981,6 +2326,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); $this->leseAnlagentopologie(); + $this->pvMessquellen(); + $this->batterieMessquellen(); + $this->batterieSocQuellen(); foreach (['PrognoseAnschluss', 'SDLAnschluss'] as $property) { $wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); if (!is_array($wert)) { @@ -2011,9 +2359,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle private function prognoseTelemetrieKonfiguriert(): bool { - return $this->ReadPropertyInteger('PrognosePVVariableID') > 0 - && $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID') > 0 - && $this->ReadPropertyInteger('PrognoseSOCVariableID') > 0; + return $this->messquellenKonfiguriert() + && $this->aktuelleBatterieSoc() !== null; } private function aktuelleNetzfahrplanVorgabe(): ?float @@ -2158,11 +2505,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.'); } + $leistungen = $this->leseMessleistungen(); + $soc = $this->aktuelleBatterieSoc(); + if ($soc === null) { + throw new RuntimeException('Der Batterie-Ladezustand ist nicht verfuegbar.'); + } $werte = [ - 'PV' => GetValue($this->ReadPropertyInteger('PrognosePVVariableID')), - 'Hausverbrauch' => GetValue($this->ReadPropertyInteger('PrognoseHausverbrauchVariableID')), - 'Netzleistung' => GetValue($this->ReadPropertyInteger('NetzleistungVariableID')), - 'SOC' => GetValue($this->ReadPropertyInteger('PrognoseSOCVariableID')), + 'PV' => $leistungen['PV'], + 'Hausverbrauch' => $leistungen['Haus'], + 'Netzleistung' => $leistungen['Netz'], + 'SOC' => $soc, ]; foreach ($werte as $name => $wert) { if (!is_int($wert) && !is_float($wert)) { @@ -2173,7 +2525,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle throw new RuntimeException('Der Prognosewert ' . $name . ' ist nicht endlich.'); } } - $werte['Netzleistung'] *= $this->ReadPropertyFloat('Netzleistungsfaktor'); if ($werte['PV'] < 0.0 || $werte['Hausverbrauch'] < 0.0 || $werte['SOC'] < 0.0 || $werte['SOC'] > 100.0 ) { diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index f42a0c8..30e4eac 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -8,13 +8,23 @@ kommen aus den Verbrauchermeldungen. ## Variablen -Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netzleistung`. +Die drei Regelungsvariablen `Aktiv`, `Betriebsart` und `Netzleistung` sind immer sichtbar. Die vom Manager gefuehrten Mess- und Energievariablen werden immer angelegt und archiviert, koennen aber gemeinsam ausgeblendet werden. | Ident | Typ / Zugriff | Beschreibung | | --- | --- | --- | | `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. | | `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. | | `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. | +| `PVLeistungArchiv` | Float / Logging | Normalisierte PV-Leistung in W. | +| `HausverbrauchLeistungArchiv` | Float / Logging | Normalisierter Hausverbrauch in W. | +| `NetzleistungArchiv` | Float / Logging | Normalisierte Netzleistung in W; positiv Bezug, negativ Einspeisung. | +| `BatterieleistungArchiv` | Float / Logging | Batterieleistung in W; positiv Laden, negativ Entladen. | +| `PVEnergie` | Float / Zaehler | Integrierte PV-Erzeugung in kWh. | +| `Hausenergie` | Float / Zaehler | Integrierter Hausverbrauch in kWh. | +| `NetzbezugEnergie` | Float / Zaehler | Integrierter Netzbezug in kWh. | +| `EinspeisungEnergie` | Float / Zaehler | Integrierte Einspeisung in kWh. | +| `BatterieLadenEnergie` | Float / Zaehler | Integrierte Batterieladung in kWh. | +| `BatterieEntladenEnergie` | Float / Zaehler | Integrierte Batterieentladung in kWh. | | `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. | | `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Bezugsgrenze in W. | | `WirksameEinspeisegrenze` | Float / Logging | Anlagenweite Einspeisegrenze in W. | @@ -54,12 +64,9 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz | `Einspeisegrenze` | Float | Maximale Einspeisung der Gesamtanlage in W; `0` bedeutet Nulleinspeisung. | | `Einspeisetoleranz` | Float | `100` W; Totband gegen pendelnde Wechselrichtervorgaben. | | `NetzfahrplanAktiv` | Boolean | `false`; lizenzierten, tarif- und prognosebasierten Netzzielwert verwenden. | -| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ, AC-Grenzen sowie optionalen Istleistungs- und Begrenzungsregistern. | +| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ, AC-Grenzen, Istleistungsmessung und optionalem Begrenzungsregister. | | `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. | -| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und Wechselrichter-ID. | -| `PrognosePVVariableID` | Integer | `0`; PV-Leistung in W fuer die Prognosetelemetrie. | -| `PrognoseHausverbrauchVariableID` | Integer | `0`; Hausverbrauch in W fuer die Prognosetelemetrie. | -| `PrognoseSOCVariableID` | Integer | `0`; Batterie-Ladezustand in Prozent. | +| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Kopplung, Leistungs- und SOC-Messung. | | `PrognoseSendeintervall` | Integer | `60` s; zulaessig sind 60 bis 3600 Sekunden. | | `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. | | `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. | @@ -67,6 +74,17 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz | `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. | | `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. | | `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. | +| `EnergieaufzeichnungAktiv` | Boolean | `true`; managergefuehrte Leistungs- und Energieaufzeichnung aktivieren. | +| `MesswerteAnzeigen` | Boolean | `false`; einzelne Manager-Messwerte in der Objektstruktur anzeigen. | +| `EnergyPieAnzeigen` | Boolean | `false`; Energy Pie unter dem Manager anlegen oder entfernen. | +| `EnergiediagrammeAnzeigen` | Boolean | `false`; native Leistungs- und Energiediagramme anlegen oder entfernen. | +| `FunFactsAnzeigen` | Boolean | `false`; responsive Energy Facts anlegen oder entfernen. | +| `EnergieflussAnzeigen` | Boolean | `false`; native Energy Distribution anlegen oder entfernen. | +| `LeistungsaufzeichnungMinuten` | Auswahl | `1`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. | +| `LeistungLoeschenMonate` | Integer | `12`; Leistungswerte nach zwoelf Monaten loeschen, `0` deaktiviert die Loeschung. | +| `EnergieaufzeichnungMinuten` | Auswahl | `5`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. | +| `EnergieVerdichtenMonate` | Integer | `12`; Energiezaehler nach zwoelf Monaten auf Tageswerte verdichten. | +| `EnergieLoeschenMonate` | Integer | `0`; Energiezaehler nicht loeschen. | Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt. @@ -74,6 +92,40 @@ Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren. +## Energieaufzeichnung und Visualisierung + +Sobald Netzleistung und mindestens eine PV-Istleistung in der Anlagentopologie +konfiguriert sind, tastet der Manager die Quellen minuetlich ab. PV-Leistung ist +die Summe der den PV-Flaechen zugeordneten Wechselrichter. Batterieleistung und +der kapazitaetsgewichtete SOC stammen aus den Batteriespeichern. Positive +Batterieleistung bedeutet Laden, negative Entladen. Der Hausverbrauch wird als +`PV + Netz - Batterie` bilanziert. Dieselben Werte versorgen ohne zweite +Eingabe auch die Prognosetelemetrie. Ausfallluecken ueber fuenf Minuten werden +nicht nachberechnet. + +Batteriespeicher bleiben als physische Komponenten samt Messquellen in der +Anlagentopologie. Ein zusaetzliches Batterieverbrauchermodul beschreibt dagegen +Regelung, Leistungsangebot und Betriebszustand; beide Rollen sind bewusst +getrennt. + +Die eigenen Variablen werden automatisch im ersten Archive Control aktiviert. +Leistungen verwenden die Standardaggregation, Energievariablen den Zaehlermodus. +Standardmaessig werden Leistungen auf eine Minute verdichtet und nach zwoelf +Monaten geloescht. Energie wird auf fuenf Minuten verdichtet, nach zwoelf +Monaten auf Tageswerte reduziert und nie geloescht. Aenderungen an diesen +Regeln werden idempotent ueber die Archiv-API gesetzt und reaggregiert. + +Vier Schalter im Bereich `Energieaufzeichnung` verwalten die +Visualisierungen idempotent. Beim Einschalten werden die Objekte direkt unter +dem Manager angelegt oder aktualisiert. Beim Ausschalten werden ausschliesslich +die vom Manager anhand ihrer festen Kennung und ihres Typs erkannten Objekte +entfernt beziehungsweise die native Energy Distribution ausgeblendet: + +- Energy Pie aus Enelix Utils mit den vier relevanten Energiezaehlern, +- zwei native IP-Symcon-Diagrammmedien fuer Leistungen und Energien, +- responsive Energy Facts mit Solar-, Netz-, Haus- und Vergleichswerten, +- native Energy Distribution fuer PV, Netz, Haus und Batterie. + ## Anlagenweite Einspeisebegrenzung Die Einspeisebegrenzung ist Bestandteil von Peak Shaving und arbeitet auf der diff --git a/examples/Demoanlage/bootstrap.php b/examples/Demoanlage/bootstrap.php index 701acf1..1f8e7fb 100644 --- a/examples/Demoanlage/bootstrap.php +++ b/examples/Demoanlage/bootstrap.php @@ -288,6 +288,7 @@ function enelixDemoInstall(): array 'ACEinspeisegrenze_kW' => 0.0, 'ACBezugsgrenze_kW' => 0.0, 'IstleistungVariableID' => $inverterActual, + 'Istleistungsfaktor' => 1.0, 'BegrenzungVariableID' => $inverterLimit, 'Begrenzungsart' => 'watt', ]]; @@ -314,13 +315,18 @@ function enelixDemoInstall(): array ), 'WechselrichterID' => 'demo-hybrid-1', 'Kopplung' => 'hybrid', + 'LeistungVariableID' => $batteryActual, + 'Leistungsfaktor' => 1.0, + 'SOCVariableID' => $batterySoc, ]]; $managerProperties = [ 'NetzleistungVariableID' => $grid, 'Netzleistungsfaktor' => 1.0, - 'PrognosePVVariableID' => $pvDisplay, - 'PrognoseHausverbrauchVariableID' => $houseDisplay, - 'PrognoseSOCVariableID' => $batterySoc, + 'MesswerteAnzeigen' => true, + 'EnergyPieAnzeigen' => true, + 'EnergiediagrammeAnzeigen' => true, + 'FunFactsAnzeigen' => true, + 'EnergieflussAnzeigen' => true, 'PrognoseSendeintervall' => 60, 'MesswertMaxAlter' => 10, 'AutomatischeSuche' => false, @@ -366,6 +372,7 @@ function enelixDemoInstall(): array } } IPS_RequestAction($manager, 'Aktiv', $managerReady); + IPS_RequestAction($manager, 'MesswerteAufzeichnen', true); $boiler1List = enelixDemoEnsureInstance( $dashboard, diff --git a/libs/EnergieMessung.php b/libs/EnergieMessung.php new file mode 100644 index 0000000..0eb31f2 --- /dev/null +++ b/libs/EnergieMessung.php @@ -0,0 +1,52 @@ + max(0.0, $pv) * $faktor, + 'Hausenergie' => max(0.0, $haus) * $faktor, + 'NetzbezugEnergie' => max(0.0, $netz) * $faktor, + 'EinspeisungEnergie' => max(0.0, -$netz) * $faktor, + 'BatterieLadenEnergie' => max(0.0, $batterie) * $faktor, + 'BatterieEntladenEnergie' => max(0.0, -$batterie) * $faktor, + ]; + } + + private static function endlich(float $wert): float + { + return is_finite($wert) ? $wert : 0.0; + } +} diff --git a/libs/ManagerEnergieTrait.php b/libs/ManagerEnergieTrait.php new file mode 100644 index 0000000..748cea0 --- /dev/null +++ b/libs/ManagerEnergieTrait.php @@ -0,0 +1,542 @@ +archivID(); + if ($archivID === 0) { + $this->protokolliere('Energiearchiv', 'Kein Archiv Control vorhanden.'); + return; + } + $aktiv = $this->ReadPropertyBoolean('EnergieaufzeichnungAktiv'); + $geaendert = []; + $leistungstyp = $this->verdichtungstyp( + $this->ReadPropertyInteger('LeistungsaufzeichnungMinuten') + ); + $energietyp = $this->verdichtungstyp( + $this->ReadPropertyInteger('EnergieaufzeichnungMinuten') + ); + foreach (self::LEISTUNGS_VARIABLEN as $ident) { + $variableID = $this->GetIDForIdent($ident); + AC_SetLoggingStatus($archivID, $variableID, $aktiv); + AC_SetAggregationType($archivID, $variableID, 0); + AC_SetGraphStatus($archivID, $variableID, $aktiv); + $regeln = [-1 => $leistungstyp]; + $loeschen = $this->ReadPropertyInteger('LeistungLoeschenMonate'); + if ($loeschen > 0) { + $regeln[$loeschen] = 7; + } + if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) { + $geaendert[] = $variableID; + } + } + + foreach (self::ENERGIE_VARIABLEN as $ident) { + $variableID = $this->GetIDForIdent($ident); + AC_SetLoggingStatus($archivID, $variableID, $aktiv); + AC_SetAggregationType($archivID, $variableID, 1); + AC_SetGraphStatus($archivID, $variableID, $aktiv); + $regeln = [-1 => $energietyp]; + $verdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate'); + if ($verdichten > 0) { + $regeln[$verdichten] = 3; + } + $loeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate'); + if ($loeschen > 0) { + $regeln[$loeschen] = 7; + } + if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) { + $geaendert[] = $variableID; + } + } + + IPS_ApplyChanges($archivID); + foreach (array_unique($geaendert) as $variableID) { + AC_ReAggregateVariable($archivID, $variableID); + } + } + + /** @param array $regeln */ + private function setzeVerdichtung(int $archivID, int $variableID, array $regeln): bool + { + ksort($regeln); + $aktuell = []; + $eintraege = AC_GetCompaction($archivID, $variableID); + if (is_array($eintraege)) { + foreach ($eintraege as $eintrag) { + $monat = (int) ($eintrag['MonthOffset'] ?? -999); + $typ = (int) ($eintrag['CompactionType'] ?? -1); + if ($monat >= -1 && $typ >= 0) { + $aktuell[$monat] = $typ; + } + } + } + ksort($aktuell); + if ($aktuell === $regeln) { + return false; + } + foreach (array_keys($aktuell) as $monat) { + AC_SetCompaction($archivID, $variableID, $monat, -1); + } + foreach ($regeln as $monat => $typ) { + AC_SetCompaction($archivID, $variableID, $monat, $typ); + } + + return true; + } + + private function verdichtungstyp(int $minuten): int + { + return [1 => 0, 5 => 1, 60 => 2][$minuten] ?? 0; + } + + private function archivID(): int + { + $archive = IPS_GetInstanceListByModuleID(self::ARCHIVE_MODULE_ID); + + return $archive === [] ? 0 : (int) $archive[0]; + } + + private function synchronisiereEnergievisualisierungen(): void + { + $this->synchronisiereEnergyPie($this->ReadPropertyBoolean('EnergyPieAnzeigen')); + $this->synchronisiereEnergiediagramme( + $this->ReadPropertyBoolean('EnergiediagrammeAnzeigen') + ); + $this->synchronisiereFunFacts($this->ReadPropertyBoolean('FunFactsAnzeigen')); + $this->synchronisiereEnergiefluss($this->ReadPropertyBoolean('EnergieflussAnzeigen')); + } + + private function synchronisiereEnergyPie(bool $anzeigen): void + { + if ($anzeigen) { + $this->erstelleEnergyPie(); + return; + } + $this->entferneInstanzUnterobjekt('EnergyPie', self::ENERGY_PIE_MODULE_ID); + } + + private function synchronisiereEnergiediagramme(bool $anzeigen): void + { + if ($anzeigen) { + $this->erstelleEnergiediagramme(); + return; + } + $this->entferneDiagrammUnterobjekt('Leistungsdiagramm'); + $this->entferneDiagrammUnterobjekt('Energiediagramm'); + } + + private function synchronisiereFunFacts(bool $anzeigen): void + { + if ($anzeigen) { + $this->erstelleFunFacts(); + return; + } + $variableID = $this->findeUnterobjekt('EnergieFunFacts'); + if ($variableID === 0) { + return; + } + if ((int) IPS_GetObject($variableID)['ObjectType'] !== 2) { + throw new RuntimeException('Unterobjekt EnergieFunFacts ist keine Variable.'); + } + $this->UnregisterVariable('EnergieFunFacts'); + } + + private function synchronisiereEnergiefluss(bool $anzeigen): void + { + if ($anzeigen) { + $this->erstelleEnergiefluss(); + return; + } + $instanzID = $this->findeUnterobjekt('Energiefluss'); + if ($instanzID === 0) { + return; + } + $objekt = IPS_GetObject($instanzID); + $modulID = $objekt['ObjectType'] === 1 + ? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID'] + : ''; + if ($modulID !== self::ENERGY_FLOW_MODULE_ID) { + throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.'); + } + IPS_SetHidden($instanzID, true); + } + + private function entferneInstanzUnterobjekt(string $ident, string $modulID): void + { + $instanzID = $this->findeUnterobjekt($ident); + if ($instanzID === 0) { + return; + } + $objekt = IPS_GetObject($instanzID); + $istModulID = $objekt['ObjectType'] === 1 + ? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID'] + : ''; + if ($istModulID !== $modulID) { + throw new RuntimeException('Unterobjekt ' . $ident . ' hat einen unpassenden Typ.'); + } + IPS_DeleteInstance($instanzID); + } + + private function entferneDiagrammUnterobjekt(string $ident): void + { + $medienID = $this->findeUnterobjekt($ident); + if ($medienID === 0) { + return; + } + $objekt = IPS_GetObject($medienID); + if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) { + throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.'); + } + IPS_DeleteMedia($medienID, true); + } + + private function erstelleEnergyPie(): string + { + if (!in_array(self::ENERGY_PIE_MODULE_ID, IPS_GetModuleList(), true)) { + throw new RuntimeException( + 'Das Modul Energy Pie aus Enelix Utils ist nicht installiert.' + ); + } + $instanzID = $this->findeUnterobjekt('EnergyPie'); + if ($instanzID === 0) { + $instanzID = IPS_CreateInstance(self::ENERGY_PIE_MODULE_ID); + IPS_SetParent($instanzID, $this->InstanceID); + IPS_SetIdent($instanzID, 'EnergyPie'); + } else { + $objekt = IPS_GetObject($instanzID); + $modulID = $objekt['ObjectType'] === 1 + ? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID'] + : ''; + if ($modulID !== self::ENERGY_PIE_MODULE_ID) { + throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.'); + } + } + IPS_SetName($instanzID, 'Energy Pie'); + IPS_SetPosition($instanzID, 500); + IPS_SetHidden($instanzID, false); + foreach ([ + 'ProduktionZaehlerID' => $this->GetIDForIdent('PVEnergie'), + 'EinspeisungZaehlerID' => $this->GetIDForIdent('EinspeisungEnergie'), + 'NetzbezugZaehlerID' => $this->GetIDForIdent('NetzbezugEnergie'), + 'VerbrauchZaehlerID' => $this->GetIDForIdent('Hausenergie'), + 'Zaehlerfaktor' => 1.0, + ] as $property => $wert) { + IPS_SetProperty($instanzID, $property, $wert); + } + IPS_ApplyChanges($instanzID); + + return 'Energy Pie wurde erstellt oder aktualisiert.'; + } + + private function erstelleEnergiefluss(): string + { + if (!in_array(self::ENERGY_FLOW_MODULE_ID, IPS_GetModuleList(), true)) { + throw new RuntimeException('Das Symcon-Modul Energy Distribution ist nicht installiert.'); + } + $instanzID = $this->findeUnterobjekt('Energiefluss'); + if ($instanzID === 0) { + $instanzID = IPS_CreateInstance(self::ENERGY_FLOW_MODULE_ID); + IPS_SetParent($instanzID, $this->InstanceID); + IPS_SetIdent($instanzID, 'Energiefluss'); + } else { + $objekt = IPS_GetObject($instanzID); + $modulID = $objekt['ObjectType'] === 1 + ? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID'] + : ''; + if ($modulID !== self::ENERGY_FLOW_MODULE_ID) { + throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.'); + } + } + IPS_SetName($instanzID, 'Energiefluss'); + IPS_SetPosition($instanzID, 540); + IPS_SetHidden($instanzID, false); + $knoten = [ + $this->energieflussKnoten('PVLeistungArchiv', 'PV-Anlage', 'solar'), + $this->energieflussKnoten('NetzleistungArchiv', 'Stromnetz', 'grid'), + $this->energieflussKnoten('HausverbrauchLeistungArchiv', 'Hausverbrauch', 'consumer'), + $this->energieflussKnoten('BatterieleistungArchiv', 'Batteriespeicher', 'battery'), + ]; + IPS_SetProperty($instanzID, 'Calculation', true); + IPS_SetProperty( + $instanzID, + 'Config', + json_encode($knoten, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) + ); + IPS_SetProperty($instanzID, 'EnableLimits', false); + IPS_SetProperty($instanzID, 'Restrictions', '[]'); + IPS_ApplyChanges($instanzID); + + return 'Der Energiefluss wurde erstellt oder aktualisiert.'; + } + + /** @return array */ + private function energieflussKnoten(string $ident, string $name, string $typ): array + { + return [ + 'VariableConfigType' => 0, + 'VariableID' => $this->GetIDForIdent($ident), + 'SecondaryVariableID' => 1, + 'Name' => $name, + 'Type' => $typ, + 'Grouped' => false, + 'CalculationType' => 0, + 'InfoVariableID' => 1, + 'Invert' => false, + ]; + } + + private function erstelleEnergiediagramme(): string + { + $leistung = [ + $this->chartDatensatz('PVLeistungArchiv', 'PV', '#F4C542', '~Power'), + $this->chartDatensatz('HausverbrauchLeistungArchiv', 'Haus', '#22A06B', '~Power'), + $this->chartDatensatz('NetzleistungArchiv', 'Netz', '#E95D5D', '~Power'), + $this->chartDatensatz('BatterieleistungArchiv', 'Batterie', '#8B5CF6', '~Power'), + ]; + $energie = [ + $this->chartDatensatz('PVEnergie', 'PV', '#F4C542', '~Electricity'), + $this->chartDatensatz('Hausenergie', 'Haus', '#22A06B', '~Electricity'), + $this->chartDatensatz('NetzbezugEnergie', 'Netzbezug', '#E95D5D', '~Electricity'), + $this->chartDatensatz('EinspeisungEnergie', 'Einspeisung', '#2F80ED', '~Electricity'), + $this->chartDatensatz('BatterieLadenEnergie', 'Batterie laden', '#8B5CF6', '~Electricity'), + $this->chartDatensatz('BatterieEntladenEnergie', 'Batterie entladen', '#F59E0B', '~Electricity'), + ]; + $this->erstelleDiagramm('Leistungsdiagramm', 'Leistungen', $leistung, 510); + $this->erstelleDiagramm('Energiediagramm', 'Energien', $energie, 520); + + return 'Die Diagramme Leistungen und Energien wurden erstellt oder aktualisiert.'; + } + /** @param list> $datensaetze */ + private function erstelleDiagramm( + string $ident, + string $name, + array $datensaetze, + int $position + ): int { + $medienID = $this->findeUnterobjekt($ident); + if ($medienID === 0) { + $medienID = IPS_CreateMedia(4); + IPS_SetParent($medienID, $this->InstanceID); + IPS_SetIdent($medienID, $ident); + } + $objekt = IPS_GetObject($medienID); + if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) { + throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.'); + } + IPS_SetName($medienID, $name); + IPS_SetPosition($medienID, $position); + IPS_SetHidden($medienID, false); + IPS_SetMediaFile($medienID, 'media/' . $medienID . '.chart', false); + IPS_SetMediaContent( + $medienID, + base64_encode(json_encode(['datasets' => $datensaetze], JSON_THROW_ON_ERROR)) + ); + + return $medienID; + } + /** @return array */ + private function chartDatensatz( + string $ident, + string $titel, + string $farbe, + string $profil + ): array { + return [ + 'variableID' => $this->GetIDForIdent($ident), + 'fillColor' => 'clear', + 'strokeColor' => $farbe, + 'timeOffset' => 0, + 'visible' => true, + 'title' => $titel, + 'type' => 'line', + 'side' => 'left', + 'profile' => $profil, + ]; + } + + private function findeUnterobjekt(string $ident): int + { + $objektID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + + return is_int($objektID) && $objektID > 0 ? $objektID : 0; + } + + private function erstelleFunFacts(): string + { + $this->RegisterVariableString('EnergieFunFacts', 'Energy Facts', '~HTMLBox', 530); + $variableID = $this->GetIDForIdent('EnergieFunFacts'); + IPS_SetHidden($variableID, false); + $this->aktualisiereFunFacts($variableID); + + return 'Energy Facts wurden erstellt oder aktualisiert.'; + } + + private function aktualisiereFunFacts(int $variableID): void + { + $archivID = $this->archivID(); + if ($archivID === 0) { + SetValue($variableID, '
Archiv nicht verfuegbar.
'); + return; + } + $heute = strtotime('today'); + $jahr = mktime(0, 0, 0, 1, 1, (int) date('Y')); + $jetzt = time(); + $pvID = $this->GetIDForIdent('PVEnergie'); + $hausID = $this->GetIDForIdent('Hausenergie'); + $bezugID = $this->GetIDForIdent('NetzbezugEnergie'); + $exportID = $this->GetIDForIdent('EinspeisungEnergie'); + $pvHeute = $this->energieSumme($archivID, $pvID, $heute, $jetzt); + $pvJahr = $this->energieSumme($archivID, $pvID, $jahr, $jetzt); + $pvTotal = max(0.0, (float) GetValue($pvID)); + [$besterTag, $besterTagZeit] = $this->maximalerTageswert($archivID, $pvID); + [$maxPV, $maxPVZeit] = $this->maximaleLeistung( + $archivID, + $this->GetIDForIdent('PVLeistungArchiv') + ); + [$maxHausTag, $maxHausTagZeit] = $this->maximalerTageswert($archivID, $hausID); + [$maxHaus, $maxHausZeit] = $this->maximaleLeistung( + $archivID, + $this->GetIDForIdent('HausverbrauchLeistungArchiv') + ); + [$maxBezug, $maxBezugZeit] = $this->maximalerTageswert($archivID, $bezugID); + [$maxExport, $maxExportZeit] = $this->maximalerTageswert($archivID, $exportID); + + $werte = [ + '{{PV_HEUTE}}' => $this->formatiereEnergie($pvHeute), + '{{PV_JAHR}}' => $this->formatiereEnergie($pvJahr), + '{{PV_TOTAL}}' => $this->formatiereEnergie($pvTotal), + '{{BESTER_TAG}}' => $this->formatiereEnergie($besterTag), + '{{BESTER_TAG_DATUM}}' => $this->formatiereDatum($besterTagZeit), + '{{MAX_PV}}' => $this->formatiereLeistung($maxPV), + '{{MAX_PV_DATUM}}' => $this->formatiereDatum($maxPVZeit), + '{{MAX_HAUS_TAG}}' => $this->formatiereEnergie($maxHausTag), + '{{MAX_HAUS_TAG_DATUM}}' => $this->formatiereDatum($maxHausTagZeit), + '{{MAX_HAUS}}' => $this->formatiereLeistung($maxHaus), + '{{MAX_HAUS_DATUM}}' => $this->formatiereDatum($maxHausZeit), + '{{MAX_BEZUG}}' => $this->formatiereEnergie($maxBezug), + '{{MAX_BEZUG_DATUM}}' => $this->formatiereDatum($maxBezugZeit), + '{{MAX_EXPORT}}' => $this->formatiereEnergie($maxExport), + '{{MAX_EXPORT_DATUM}}' => $this->formatiereDatum($maxExportZeit), + '{{HEIZOEL}}' => number_format($pvTotal / 10.0, 0, ',', "'") . ' Liter', + '{{FLUGKM}}' => number_format($pvTotal * 2.4, 0, ',', "'") . ' km', + ]; + SetValue($variableID, strtr($this->funFactsHtml(), $werte)); + } + + private function energieSumme(int $archivID, int $variableID, int $start, int $ende): float + { + $werte = @AC_GetAggregatedValues($archivID, $variableID, 1, $start, $ende, 0); + if (!is_array($werte)) { + return 0.0; + } + + return array_sum(array_map( + static fn (array $wert): float => max(0.0, (float) ($wert['Avg'] ?? 0.0)), + $werte + )); + } + + /** @return array{0: float, 1: int} */ + private function maximalerTageswert(int $archivID, int $variableID): array + { + $werte = @AC_GetAggregatedValues($archivID, $variableID, 1, 0, time(), 0); + $maximum = 0.0; + $zeit = 0; + if (is_array($werte)) { + foreach ($werte as $wert) { + $kandidat = max(0.0, (float) ($wert['Avg'] ?? 0.0)); + if ($kandidat > $maximum) { + $maximum = $kandidat; + $zeit = (int) ($wert['TimeStamp'] ?? 0); + } + } + } + + return [$maximum, $zeit]; + } + /** @return array{0: float, 1: int} */ + private function maximaleLeistung(int $archivID, int $variableID): array + { + $werte = @AC_GetAggregatedValues($archivID, $variableID, 1, 0, time(), 0); + $maximum = 0.0; + $zeit = 0; + if (is_array($werte)) { + foreach ($werte as $wert) { + $kandidat = max(0.0, (float) ($wert['Max'] ?? 0.0)); + if ($kandidat > $maximum) { + $maximum = $kandidat; + $zeit = (int) ($wert['MaxTime'] ?? $wert['TimeStamp'] ?? 0); + } + } + } + + return [$maximum, $zeit]; + } + + private function formatiereEnergie(float $wert): string + { + if ($wert >= 1000.0) { + return number_format($wert / 1000.0, 2, ',', "'") . ' MWh'; + } + + return number_format($wert, 1, ',', "'") . ' kWh'; + } + private function formatiereLeistung(float $wert): string + { + if ($wert >= 10000.0) { + return number_format($wert / 1000.0, 1, ',', "'") . ' kW'; + } + + return number_format($wert, 0, ',', "'") . ' W'; + } + + private function formatiereDatum(int $zeit): string + { + return $zeit > 0 ? date('d.m.Y', $zeit) : 'Noch keine Daten'; + } + + private function funFactsHtml(): string + { + return <<<'HTML' + +
+
SolarERZEUGUNG
+
Heute{{PV_HEUTE}}
+
Dieses Jahr{{PV_JAHR}}
+
Total{{PV_TOTAL}}
+
Bester Tag{{BESTER_TAG}}{{BESTER_TAG_DATUM}}
+
Max. Leistung{{MAX_PV}}{{MAX_PV_DATUM}}
+
+
Netz & HausSPITZENWERTE
+
Max. Tagesverbrauch{{MAX_HAUS_TAG}}{{MAX_HAUS_TAG_DATUM}}
+
Max. Hauslast{{MAX_HAUS}}{{MAX_HAUS_DATUM}}
+
Max. Tagesbezug{{MAX_BEZUG}}{{MAX_BEZUG_DATUM}}
+
Max. Einspeisung{{MAX_EXPORT}}{{MAX_EXPORT_DATUM}}
+
+
WirkungVERGLEICHE
+
{{HEIZOEL}}
rechnerisch kompensiertes Heizoel
+
{{FLUGKM}}
rechnerisch kompensierte Flugkilometer
+
Vergleichswerte auf Basis von 10 kWh pro Liter Heizoel und 2,4 Flugkilometern pro erzeugter kWh.
+
+
+HTML; + } +} diff --git a/tests/DemoanlageBootstrapTest.php b/tests/DemoanlageBootstrapTest.php index 568b9d8..7bc252d 100644 --- a/tests/DemoanlageBootstrapTest.php +++ b/tests/DemoanlageBootstrapTest.php @@ -119,9 +119,12 @@ final class DemoanlageBootstrapTest extends TestCase self::assertStringContainsString("'AnlagenWechselrichter'", $source); self::assertStringContainsString("'AnlagenPVFlaechen'", $source); self::assertStringContainsString("'AnlagenBatterien'", $source); - self::assertStringContainsString("'PrognosePVVariableID' => \$pvDisplay", $source); - self::assertStringContainsString("'PrognoseHausverbrauchVariableID' => \$houseDisplay", $source); - self::assertStringContainsString("'PrognoseSOCVariableID' => \$batterySoc", $source); + self::assertStringContainsString("'EnergyPieAnzeigen' => true", $source); + self::assertStringContainsString("'EnergiediagrammeAnzeigen' => true", $source); + self::assertStringContainsString("'FunFactsAnzeigen' => true", $source); + self::assertStringContainsString("'EnergieflussAnzeigen' => true", $source); + self::assertStringContainsString("'LeistungVariableID' => \$batteryActual", $source); + self::assertStringContainsString("'SOCVariableID' => \$batterySoc", $source); self::assertStringContainsString("'PrognoseSendeintervall' => 60", $source); self::assertStringContainsString("'IstleistungVariableID' => \$inverterActual", $source); self::assertStringContainsString("'BegrenzungVariableID' => \$inverterLimit", $source); diff --git a/tests/EnergieMessungTest.php b/tests/EnergieMessungTest.php new file mode 100644 index 0000000..8d6d4af --- /dev/null +++ b/tests/EnergieMessungTest.php @@ -0,0 +1,66 @@ + 7200.0, + 'Haus' => 3600.0, + 'Netz' => -1800.0, + 'Batterie' => 900.0, + ], 300); + + self::assertEqualsWithDelta(0.6, $werte['PVEnergie'], 0.000001); + self::assertEqualsWithDelta(0.3, $werte['Hausenergie'], 0.000001); + self::assertSame(0.0, $werte['NetzbezugEnergie']); + self::assertEqualsWithDelta(0.15, $werte['EinspeisungEnergie'], 0.000001); + self::assertEqualsWithDelta(0.075, $werte['BatterieLadenEnergie'], 0.000001); + self::assertSame(0.0, $werte['BatterieEntladenEnergie']); + } + + public function testTrenntBezugUndBatterieentladung(): void + { + $werte = EnergieMessung::energieDeltas([ + 'PV' => -10.0, + 'Haus' => -10.0, + 'Netz' => 1000.0, + 'Batterie' => -2000.0, + ], 3600); + + self::assertSame(0.0, $werte['PVEnergie']); + self::assertSame(0.0, $werte['Hausenergie']); + self::assertSame(1.0, $werte['NetzbezugEnergie']); + self::assertSame(0.0, $werte['EinspeisungEnergie']); + self::assertSame(0.0, $werte['BatterieLadenEnergie']); + self::assertSame(2.0, $werte['BatterieEntladenEnergie']); + } + + public function testBerechnetHausverbrauchAusDerAnlagenbilanz(): void + { + self::assertSame(2700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0)); + self::assertSame(3500.0, EnergieMessung::hausleistung(2000.0, 500.0, -1000.0)); + self::assertSame(0.0, EnergieMessung::hausleistung(500.0, -1200.0, 0.0)); + } + + public function testIgnoriertUngueltigeDauerUndNichtEndlicheWerte(): void + { + $werte = EnergieMessung::energieDeltas([ + 'PV' => INF, + 'Haus' => NAN, + 'Netz' => 1000.0, + 'Batterie' => -1000.0, + ], -1); + + foreach ($werte as $wert) { + self::assertSame(0.0, $wert); + } + } +} diff --git a/tests/ManagerEnergieModulstrukturTest.php b/tests/ManagerEnergieModulstrukturTest.php new file mode 100644 index 0000000..5163730 --- /dev/null +++ b/tests/ManagerEnergieModulstrukturTest.php @@ -0,0 +1,93 @@ +createVariable(2, 'Netzleistung', 0.0); + $netzleistungId = $test->createVariable(2, 'Netzleistung', -800.0); + $pvLeistungId = $test->createVariable(2, 'PV-Leistung', 4200.0); + $batterieLeistungId = $test->createVariable(2, 'Batterieleistung', 700.0); + $batterieSocId = $test->createVariable(2, 'Batterie-Ladezustand', 62.0); $einKontaktId = $test->createActionVariable('Kontakt Verbraucher 1-Stufig'); $einStufigId = $test->createInstance( '{15879A4E-D0C2-4495-83DE-46E1E462591E}', @@ -79,6 +82,8 @@ return static function (TestContext $test): void { 'ACNennleistung_kW' => 10.0, 'ACEinspeisegrenze_kW' => 8.0, 'ACBezugsgrenze_kW' => 0.0, + 'IstleistungVariableID' => $pvLeistungId, + 'Istleistungsfaktor' => 1.0, ]]; $pvKonfiguration = [[ 'ID' => 'pv-1', @@ -100,6 +105,9 @@ return static function (TestContext $test): void { 'MaxEntladeleistung_kW' => 5.0, 'WechselrichterID' => 'hybrid-1', 'Kopplung' => 'hybrid', + 'LeistungVariableID' => $batterieLeistungId, + 'Leistungsfaktor' => 1.0, + 'SOCVariableID' => $batterieSocId, ]]; $lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE')); $managerId = $test->createInstance( @@ -107,6 +115,10 @@ return static function (TestContext $test): void { 'Manager', [ 'NetzleistungVariableID' => $netzleistungId, + 'EnergyPieAnzeigen' => true, + 'EnergiediagrammeAnzeigen' => true, + 'FunFactsAnzeigen' => true, + 'EnergieflussAnzeigen' => true, 'Lizenzcode' => $lizenzcode, 'AutomatischeSuche' => false, 'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR), @@ -214,6 +226,80 @@ return static function (TestContext $test): void { $test->assertSame(['pv-1'], $topologie['sharedAcLimits'][0]['pvArrayIds'] ?? null); $test->assertSame(['bat-1'], $topologie['sharedAcLimits'][0]['batteryIds'] ?? null); }); + $test->runCase('Energiewerte, Archivregeln und Unterobjekte werden erzeugt', static function ( + TestContext $test + ) use ($managerId): void { + IPS_RequestAction($managerId, 'MesswerteAufzeichnen', true); + $test->assertSame( + 4200.0, + GetValue($test->objectByIdent('PVLeistungArchiv', $managerId)) + ); + $test->assertSame( + 2700.0, + GetValue($test->objectByIdent('HausverbrauchLeistungArchiv', $managerId)) + ); + $test->assertSame( + -800.0, + GetValue($test->objectByIdent('NetzleistungArchiv', $managerId)) + ); + $test->assertSame( + 700.0, + GetValue($test->objectByIdent('BatterieleistungArchiv', $managerId)) + ); + + $archive = IPS_GetInstanceListByModuleID( + '{43192F0B-135B-4CE7-A0A7-1475603F3060}' + ); + $test->assertTrue($archive !== []); + $archivID = (int) $archive[0]; + $leistungID = $test->objectByIdent('PVLeistungArchiv', $managerId); + $energieID = $test->objectByIdent('PVEnergie', $managerId); + $test->assertSame(true, AC_GetLoggingStatus($archivID, $leistungID)); + $test->assertSame(0, AC_GetAggregationType($archivID, $leistungID)); + $test->assertSame(1, AC_GetAggregationType($archivID, $energieID)); + + foreach (['Leistungsdiagramm', 'Energiediagramm'] as $ident) { + $medienID = $test->objectByIdent($ident, $managerId); + $test->assertSame(4, (int) IPS_GetMedia($medienID)['MediaType']); + } + $html = (string) GetValue($test->objectByIdent('EnergieFunFacts', $managerId)); + $test->assertTrue(str_contains($html, 'Solar')); + $test->assertTrue(str_contains($html, 'Netz & Haus')); + $energieflussID = $test->objectByIdent('Energiefluss', $managerId); + $test->assertSame( + '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}', + IPS_GetInstance($energieflussID)['ModuleInfo']['ModuleID'] + ); + + IPS_RequestAction($managerId, 'FormEnergieflussAnzeigen', false); + $test->assertSame(true, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']); + IPS_RequestAction($managerId, 'FormEnergieflussAnzeigen', true); + $test->assertSame(false, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']); + + foreach ([ + 'EnergyPieAnzeigen', + 'EnergiediagrammeAnzeigen', + 'FunFactsAnzeigen', + 'EnergieflussAnzeigen', + ] as $property) { + IPS_SetProperty($managerId, $property, false); + } + IPS_ApplyChanges($managerId); + foreach ([ + 'EnergyPie', + 'Leistungsdiagramm', + 'Energiediagramm', + 'EnergieFunFacts', + ] as $ident) { + $test->assertSame( + false, + @IPS_GetObjectIDByIdent($ident, $managerId), + $ident . ' wurde beim Ausschalten nicht entfernt.' + ); + } + $test->assertSame(true, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']); + }); + $zustandswert = static function (array $daten, int $instanzId, string $kennung) { foreach ($daten['Verbraucher'] ?? [] as $verbraucher) { if (($verbraucher['InstanzID'] ?? 0) !== $instanzId) {