diff --git a/CHANGELOG.md b/CHANGELOG.md index 050e284..18e3383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert. ## Unreleased +- Manager: SDL-/Regelenergie als separate Istleistungsquelle mit optionalem SOC, + Leistungsfaktor und getrennten Anzeigeoptionen fuer Energiefluss und Diagramme. + SDL wird unabhaengig von der Anzeige aus Hauslast und neuer Hausenergie + herausgerechnet; die Prognosetelemetrie verwendet die korrigierte Hauslast. + Bestehende Visualisierungen werden ausschliesslich um eigene SDL-Eintraege + ergaenzt. Standardmaessig deaktiviert, keine automatische Quellenmigration, + keine rueckwirkende Datenkorrektur und keine Aenderung externer SDL-Steuerungen. - Manager: Positives Budget wird bei gleicher Prioritaet nach der kleinsten naechsten erreichbaren Sollleistung verteilt. Nur bei gleicher Leistungsstufe entscheiden exakter Energiebezug und anschliessend die Instanz-ID. Einzelstufen diff --git a/Manager/form.json b/Manager/form.json index 0c19799..a837a63 100644 --- a/Manager/form.json +++ b/Manager/form.json @@ -835,6 +835,50 @@ "type": "ExpansionPanel", "caption": "SDL / VGT", "items": [ + { + "type": "CheckBox", + "name": "SDLAktiv", + "caption": "SDL aktiv", + "onChange": "IPS_RequestAction($id, 'FormSDLAktiv', $SDLAktiv);" + }, + { + "type": "ExpansionPanel", + "name": "SDLKonfiguration", + "caption": "SDL / Regelenergie Messung", + "visible": false, + "items": [ + { + "type": "SelectVariable", + "name": "SDLLeistungVariableID", + "caption": "SDL Istleistung" + }, + { + "type": "NumberSpinner", + "name": "SDLLeistungsfaktor", + "caption": "SDL Leistungsfaktor nach Watt", + "digits": 3 + }, + { + "type": "SelectVariable", + "name": "SDLSOCVariableID", + "caption": "SDL SOC in Prozent (optional)" + }, + { + "type": "CheckBox", + "name": "SDLEnergieflussAnzeigen", + "caption": "SDL mit SOC im Energiefluss anzeigen" + }, + { + "type": "CheckBox", + "name": "SDLDiagrammeAnzeigen", + "caption": "SDL mit SOC in den Diagrammen anzeigen" + }, + { + "type": "Label", + "caption": "Nach Skalierung: positiv = Laden / Bezug, negativ = Entladen / Abgabe. SDL muss separat gemessen sein und darf nicht bereits in der normalen Batterieleistung enthalten sein. Die Hauslast fuer Prognosen wird unabhaengig von den Anzeigeoptionen korrigiert." + } + ] + }, { "type": "ValidationTextBox", "name": "SDLAnschluss", diff --git a/Manager/module.php b/Manager/module.php index 8c9b984..3e56a06 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -11,6 +11,7 @@ require_once __DIR__ . '/../libs/Anlagentopologie.php'; require_once __DIR__ . '/../libs/StoerungsSnapshot.php'; require_once __DIR__ . '/../libs/EnergieMessung.php'; require_once __DIR__ . '/../libs/ManagerEnergieTrait.php'; +require_once __DIR__ . '/../libs/ManagerSDLTrait.php'; use Belevo\EnelixEMS\Anlagentopologie; use Belevo\EnelixEMS\EinspeiseRegler; @@ -25,6 +26,7 @@ use Belevo\EnelixEMS\StoerungsSnapshot; class Manager extends IPSModule implements ManagerSchnittstelle { use ManagerEnergieTrait; + use \Belevo\EnelixEMS\ManagerSDLTrait; private const STATUS_AKTIV = 102; private const STATUS_MESSWERT_UNGUELTIG = 201; @@ -46,6 +48,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'HausverbrauchLeistungArchiv', 'NetzleistungArchiv', 'BatterieleistungArchiv', + 'SDLLeistungArchiv', + 'SDLSOCArchiv', ]; private const ENERGIE_VARIABLEN = [ 'PVEnergie', @@ -54,6 +58,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'EinspeisungEnergie', 'BatterieLadenEnergie', 'BatterieEntladenEnergie', + 'SDLLadenEnergie', + 'SDLEntladenEnergie', ]; private const VERBRAUCHER_LIZENZEN = [ '{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [ @@ -146,6 +152,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterPropertyInteger('PrognoseSendeintervall', 60); $this->RegisterPropertyString('PrognoseAnschluss', '{}'); $this->RegisterPropertyString('SDLAnschluss', '{}'); + $this->RegisterPropertyBoolean('SDLAktiv', false); + $this->RegisterPropertyInteger('SDLLeistungVariableID', 0); + $this->RegisterPropertyFloat('SDLLeistungsfaktor', 1.0); + $this->RegisterPropertyInteger('SDLSOCVariableID', 0); + $this->RegisterPropertyBoolean('SDLEnergieflussAnzeigen', false); + $this->RegisterPropertyBoolean('SDLDiagrammeAnzeigen', false); $this->RegisterPropertyString('Lizenzcode', ''); // Verdeckte Altproperty fuer bestehende Instanzkonfigurationen. $this->RegisterPropertyString('LizenzAnschluss', '{}'); @@ -210,6 +222,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterAttributeString('EnergiediagrammKonfiguration', '{}'); $this->RegisterAttributeString('EnergieflussKonfiguration', '{}'); $this->RegisterAttributeBoolean('EnergieflussNeuErzeugen', false); + $this->RegisterAttributeString('SDLMesskonfiguration', ''); // Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren. $this->RegisterTimer('Regelzyklus', 0, ''); @@ -250,6 +263,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle try { $this->pruefeKonfiguration(); + $this->synchronisiereSDLMesskonfiguration(); $this->synchronisiereEnergievisualisierungen(); $this->konfiguriereEnergiearchiv(); } catch (Throwable $fehler) { @@ -430,6 +444,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->monatsgrenzenFuerFormular() ); + $this->setzeFormularParameter( + $formular['elements'], + 'SDLKonfiguration', + 'visible', + $this->ReadPropertyBoolean('SDLAktiv') + ); + return json_encode($formular, JSON_THROW_ON_ERROR); } @@ -501,6 +522,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle } return; + case 'FormSDLAktiv': + $this->UpdateFormField('SDLKonfiguration', 'visible', (bool) $wert); + return; + case 'FormEnergyPieAnzeigen': $this->synchronisiereEnergyPie((bool) $wert); return; @@ -1676,6 +1701,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'HausverbrauchLeistungArchiv' => 'Hausverbrauch', 'NetzleistungArchiv' => 'Netzleistung Aufzeichnung', 'BatterieleistungArchiv' => 'Batterieleistung', + 'SDLLeistungArchiv' => 'SDL Istleistung', ]; $energievariablen = [ 'PVEnergie' => 'PV-Energie', @@ -1684,6 +1710,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'EinspeisungEnergie' => 'Einspeisung Energie', 'BatterieLadenEnergie' => 'Batterie geladen', 'BatterieEntladenEnergie' => 'Batterie entladen', + 'SDLLadenEnergie' => 'SDL geladen', + 'SDLEntladenEnergie' => 'SDL entladen', ]; $position = 200; foreach ($leistungsvariablen as $ident => $name) { @@ -1697,6 +1725,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle $position += 10; } + $this->RegisterVariableFloat('SDLSOCArchiv', 'SDL Ladezustand', '~Intensity.100', 390); + IPS_SetHidden($this->GetIDForIdent('SDLSOCArchiv'), !$this->ReadPropertyBoolean('MesswerteAnzeigen')); + if ($this->ReadPropertyBoolean('SDLAktiv')) { + $this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120); + } + if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { $this->RegisterVariableBoolean( 'NetzleistungGueltig', @@ -1755,6 +1789,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle } foreach (self::DIAGNOSE_VARIABLEN as $ident) { + if ($ident === 'SDLStatus' && $this->ReadPropertyBoolean('SDLAktiv')) { + continue; + } $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { $this->UnregisterVariable($ident); @@ -2030,7 +2067,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle ]; } - /** @return array{PV: float, Haus: float, Netz: float, Batterie: float} */ + /** @return array{PV: float, Haus: float, Netz: float, Batterie: float, SDL: float} */ private function leseMessleistungen(): array { $pv = 0.0; @@ -2043,12 +2080,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle } $netz = (float) GetValue($this->ReadPropertyInteger('NetzleistungVariableID')) * $this->ReadPropertyFloat('Netzleistungsfaktor'); + $sdl = $this->aktuelleSDLLeistung(); return [ 'PV' => $pv, - 'Haus' => EnergieMessung::hausleistung($pv, $netz, $batterie), + 'Haus' => EnergieMessung::hausleistung($pv, $netz, $batterie, $sdl), 'Netz' => $netz, 'Batterie' => $batterie, + 'SDL' => $sdl, ]; } @@ -2073,7 +2112,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle } /** @return array|null */ - private function leseEnergieZaehlerDeltas(): ?array + private function leseEnergieZaehlerDeltas( + float $sdlLaden = 0.0, + float $sdlEntladen = 0.0, + bool $hausVollstaendig = true + ): ?array { $quellen = $this->energieZaehlerquellen(); if ($quellen === null) { @@ -2102,6 +2145,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle $delta = $stand - (float) $alt[$schluessel]; if ($delta >= 0.0) { $deltas[$ident] += $delta; + } elseif ($this->ReadPropertyBoolean('SDLAktiv')) { + $hausVollstaendig = false; } } } @@ -2113,13 +2158,17 @@ class Manager extends IPSModule implements ManagerSchnittstelle return array_fill_keys(self::ENERGIE_VARIABLEN, 0.0); } - $deltas['Hausenergie'] = EnergieMessung::hausenergie( + $deltas['SDLLadenEnergie'] = $sdlLaden; + $deltas['SDLEntladenEnergie'] = $sdlEntladen; + $deltas['Hausenergie'] = $hausVollstaendig ? EnergieMessung::hausenergie( $deltas['PVEnergie'], $deltas['NetzbezugEnergie'], $deltas['EinspeisungEnergie'], $deltas['BatterieLadenEnergie'], - $deltas['BatterieEntladenEnergie'] - ); + $deltas['BatterieEntladenEnergie'], + $sdlLaden, + $sdlEntladen + ) : 0.0; return $deltas; } @@ -2130,13 +2179,31 @@ class Manager extends IPSModule implements ManagerSchnittstelle return; } $jetzt = time(); - $leistungen = $this->leseMessleistungen(); + try { + $leistungen = $this->leseMessleistungen(); + } catch (Throwable $fehler) { + // Keine falsche Hauslast aufzeichnen oder ueber die Messluecke integrieren. + $this->WriteAttributeInteger('MessungLetzterZeitstempel', 0); + $this->WriteAttributeString('MessungZaehlerstaende', '{}'); + $this->setzeDiagnosewert('SDLStatus', 'Fehler: ' . $fehler->getMessage()); + $this->protokolliere('Energiemessung', $fehler->getMessage()); + return; + } $letzterZeitstempel = $this->ReadAttributeInteger('MessungLetzterZeitstempel'); $puffer = $this->leseJsonAttribut('MessungEnergiePuffer'); - $energieDeltas = $this->leseEnergieZaehlerDeltas(); + $dauer = $letzterZeitstempel > 0 ? max(0, $jetzt - $letzterZeitstempel) : 0; + $sdlIntervallGueltig = $letzterZeitstempel > 0 && $dauer <= 300; + $integriert = EnergieMessung::energieDeltas( + $leistungen, + $this->ReadPropertyBoolean('SDLAktiv') && !$sdlIntervallGueltig ? 0 : min(300, $dauer) + ); + $energieDeltas = $this->leseEnergieZaehlerDeltas( + $integriert['SDLLadenEnergie'], + $integriert['SDLEntladenEnergie'], + !$this->ReadPropertyBoolean('SDLAktiv') || $sdlIntervallGueltig + ); if ($energieDeltas === null && $letzterZeitstempel > 0) { - $sekunden = min(300, max(0, $jetzt - $letzterZeitstempel)); - $energieDeltas = EnergieMessung::energieDeltas($leistungen, $sekunden); + $energieDeltas = $integriert; } foreach ($energieDeltas ?? [] as $ident => $delta) { $puffer[$ident] = max(0.0, (float) ($puffer[$ident] ?? 0.0)) + $delta; @@ -2155,10 +2222,15 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'HausverbrauchLeistungArchiv' => 'Haus', 'NetzleistungArchiv' => 'Netz', 'BatterieleistungArchiv' => 'Batterie', + 'SDLLeistungArchiv' => 'SDL', ] as $ident => $quelle) { $this->SetValue($ident, $leistungen[$quelle]); } $this->WriteAttributeInteger('MessungLetzteLeistungsaufzeichnung', $jetzt); + $soc = $this->aktuelleSDLSOC(); + if ($soc !== null) { + $this->SetValue('SDLSOCArchiv', $soc); + } } $energieintervall = $this->ReadPropertyInteger('EnergieaufzeichnungMinuten') * 60; @@ -2672,11 +2744,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'Prognosestatus', $this->ReadPropertyBoolean('PrognoseAktiv') ? 'Wartet' : 'NichtVerwendet' ); - foreach (['SDLAnschluss' => 'SDLStatus'] as $property => $variable) { - $konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); - $aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true; - $this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet'); - } + $this->aktualisiereSDLStatus(); $this->setzeDiagnosewert( 'Stoerueberwachungsstatus', $this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 'Wartet' : 'NichtVerwendet' @@ -2685,6 +2753,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle private function pruefeKonfiguration(): void { + $this->pruefeSDLKonfiguration(); foreach (['MesswertMaxAlter', 'KeepAlive', 'VerbraucherTimeout'] as $property) { if ($this->ReadPropertyInteger($property) <= 0) { throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index 2993dbd..7123ead 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -73,6 +73,12 @@ Die drei Regelungsvariablen `Aktiv`, `Betriebsart` und `Netzleistung` sind immer | `PrognoseSendeintervall` | Integer | `60` s; Intervall fuer den Upload aktueller Messwerte, zulaessig sind 60 bis 3600 Sekunden. | | `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. | | `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. | +| `SDLAktiv` | Boolean | `false`; separat gemessene SDL-/Regelenergie in der Anlagenbilanz beruecksichtigen. Keine SDL-Steuerung. | +| `SDLLeistungVariableID` | Integer | `0`; bei aktiver SDL erforderliche numerische Istleistungsquelle. | +| `SDLLeistungsfaktor` | Float | `1`; Umrechnung nach W, positiv Laden/Bezug, negativ Entladen/Abgabe. Fuer kW `1000`, bei umgekehrtem Vorzeichen negativ. | +| `SDLSOCVariableID` | Integer | `0`; optionaler separater SDL-Ladezustand in Prozent, 0 bis 100. | +| `SDLEnergieflussAnzeigen` | Boolean | `false`; SDL mit optionalem SOC als eigenen Speicher im aktivierten Energiefluss anzeigen. | +| `SDLDiagrammeAnzeigen` | Boolean | `false`; SDL-Leistung, Lade-/Entladeenergie und optionalen SOC in aktivierten Diagrammen anzeigen. | | `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. | | `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. | | `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. | @@ -134,6 +140,59 @@ entfernt beziehungsweise die native Energy Distribution ausgeblendet: - responsive Energy Facts mit Solar-, Netz-, Haus- und Vergleichswerten, - native Energy Distribution fuer PV, Netz, Haus und Batterie. +## SDL / Regelenergie + +`SDL aktiv` blendet die Quellen- und Anzeigeauswahl ein. Die SDL-Istleistung +beschreibt einen **separat gemessenen AC-Zweig am selben Netzanschlusspunkt**. +Sie darf weder in der normalen Batterieleistung noch in einer PV-Leistung +enthalten sein. Eine identische Variablen-ID fuer SDL und Netz/PV/Batterie wird +abgewiesen. Ueberschneidungen in extern gebildeten Summen muss die +Anlagenkonfiguration ausschliessen. Ein gemeinsamer Speicher mit nur einem +Gesamtleistungsmesswert kann damit nicht in EMS- und SDL-Anteile zerlegt werden. + +Bei aktiver SDL lautet die Hauslast `max(0, PV + Netz - Batterie - SDL)`. +Die Korrektur geschieht vor der Begrenzung auf null und unabhaengig davon, +ob SDL angezeigt wird. Diese Hauslast wird an den Prognosedienst gesendet; +Netzleistung und der kapazitaetsgewichtete SOC der normalen Batterien bleiben +unveraendert. SDL-SOC ersetzt keinen Batterie-SOC und wird nicht als +planbare EMS-Speicherkapazitaet exportiert. Ungueltige SDL-Leistungswerte +unterbrechen die Messaufzeichnung und den jeweiligen Telemetrieversand, +statt als null in die Hauslast einzugehen. Der SDL-Status ist bei aktiver +SDL auch ohne eingeschaltete Diagnosevariablen sichtbar. + +`SDLLeistungArchiv` zeichnet Watt auf, `SDLSOCArchiv` gueltige Prozentwerte. +`SDLLadenEnergie` und `SDLEntladenEnergie` integrieren die getrennten Richtungen +in kWh. Die Abtastung erfolgt wie bei den anderen Leistungen minuetlich, nicht +als abrechnungsgenaue SDL-Energiemessung. Auch bei vorhandenen physikalischen +Energiezaehlern wird die Hausenergie um die im selben Intervall integrierte +SDL-Ladung vermindert und um SDL-Entladung erhoeht. Bei Messluecken ueber +fuenf Minuten, Quellenwechseln oder Zaehlerresets wird keine unvollstaendige +Hausenergie nachberechnet; Historie und bereits erfasste Zaehler bleiben erhalten. + +Die Anzeigeoptionen wirken beim Anwenden direkt auf die vorhandenen +Manager-Visualisierungen. Nur die eigenen SDL-Knoten und -Datensaetze werden +ergaenzt oder entfernt. Andere Knoten, Farben, Datensaetze und sonstige +Anpassungen bleiben bestehen. Im Energiefluss verwendet SDL dieselbe W/kW- +Einheit wie die anderen Manager-Knoten; ein ungueltiger SOC wird dort nicht +als aktueller Zusatzwert angezeigt. Ohne separaten SDL-Knoten enthaelt der +Manager-Sammelknoten Batteriespeicher die normale Batterie plus SDL, damit +die dargestellte Leistungsbilanz vollstaendig bleibt. Im Leistungsdiagramm +erscheint die signierte SDL-Istleistung, im Energiediagramm erscheinen Laden +und Entladen getrennt. SOC verwendet in beiden Diagrammen eine eigene rechte +Prozentachse. Die allgemeinen Schalter fuer Energiefluss, Diagramme und +Energieaufzeichnung bleiben erforderlich. + +Migration: SDL bleibt nach dem Modulupdate standardmaessig aus. Bestehende +Installationen behalten damit ihre bisherige Bilanz. Erst Quellen und +Vorzeichen pruefen, SDL aktivieren und anwenden. Historische Hauslasten oder +bereits trainierte Prognosen werden nicht rueckwirkend korrigiert; neue +Prognoseeingaben sind ab Aktivierung bereinigt. Ausschalten entfernt nur +SDL-Anzeigen, nicht die aufgezeichnete Historie. Der alte `SDLAnschluss` +bleibt lesbar; externe SDL-Regelung und Stellbefehle werden nicht veraendert. + +Native Darstellungsvertraege: [Energy Distribution](https://www.symcon.de/en/service/documentation/module-reference/energy/energy-distribution/) +und [Diagramme](https://www.symcon.de/en/service/documentation/basics/media/charts/). + ## Anlagenweite Einspeisebegrenzung Die Einspeisebegrenzung ist Bestandteil von Peak Shaving und arbeitet auf der diff --git a/libs/EnergieMessung.php b/libs/EnergieMessung.php index cd9d009..7ce16e9 100644 --- a/libs/EnergieMessung.php +++ b/libs/EnergieMessung.php @@ -8,11 +8,11 @@ final class EnergieMessung { private const WATTSEKUNDEN_PRO_KWH = 3600000.0; - public static function hausleistung(float $pv, float $netz, float $batterie): float + public static function hausleistung(float $pv, float $netz, float $batterie, float $sdl = 0.0): float { return max( 0.0, - self::endlich($pv) + self::endlich($netz) - self::endlich($batterie) + self::endlich($pv) + self::endlich($netz) - self::endlich($batterie) - self::endlich($sdl) ); } @@ -21,7 +21,9 @@ final class EnergieMessung float $netzbezug, float $netzeinspeisung, float $batterieLaden, - float $batterieEntladen + float $batterieEntladen, + float $sdlLaden = 0.0, + float $sdlEntladen = 0.0 ): float { return max( 0.0, @@ -30,18 +32,22 @@ final class EnergieMessung + self::endlich($batterieEntladen) - self::endlich($netzeinspeisung) - self::endlich($batterieLaden) + - self::endlich($sdlLaden) + + self::endlich($sdlEntladen) ); } /** - * @param array{PV: float, Haus: float, Netz: float, Batterie: float} $leistungen + * @param array{PV: float, Haus: float, Netz: float, Batterie: float, SDL?: float} $leistungen * @return array{ * PVEnergie: float, * Hausenergie: float, * NetzbezugEnergie: float, * EinspeisungEnergie: float, * BatterieLadenEnergie: float, - * BatterieEntladenEnergie: float + * BatterieEntladenEnergie: float, + * SDLLadenEnergie: float, + * SDLEntladenEnergie: float * } */ public static function energieDeltas(array $leistungen, int $sekunden): array @@ -51,6 +57,7 @@ final class EnergieMessung $haus = self::endlich($leistungen['Haus']); $netz = self::endlich($leistungen['Netz']); $batterie = self::endlich($leistungen['Batterie']); + $sdl = self::endlich($leistungen['SDL'] ?? 0.0); return [ 'PVEnergie' => max(0.0, $pv) * $faktor, @@ -59,6 +66,8 @@ final class EnergieMessung 'EinspeisungEnergie' => max(0.0, -$netz) * $faktor, 'BatterieLadenEnergie' => max(0.0, $batterie) * $faktor, 'BatterieEntladenEnergie' => max(0.0, -$batterie) * $faktor, + 'SDLLadenEnergie' => max(0.0, $sdl) * $faktor, + 'SDLEntladenEnergie' => max(0.0, -$sdl) * $faktor, ]; } diff --git a/libs/ManagerEnergieTrait.php b/libs/ManagerEnergieTrait.php index 2a8e8ab..a19fb43 100644 --- a/libs/ManagerEnergieTrait.php +++ b/libs/ManagerEnergieTrait.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace Belevo\EnelixEMS; +use RuntimeException; +use Throwable; + trait ManagerEnergieTrait { private function konfiguriereEnergiearchiv(): void @@ -23,9 +26,13 @@ trait ManagerEnergieTrait ); foreach (self::LEISTUNGS_VARIABLEN as $ident) { $variableID = $this->GetIDForIdent($ident); - AC_SetLoggingStatus($archivID, $variableID, $aktiv); + $loggen = $aktiv && (!str_starts_with($ident, 'SDL') || $this->ReadPropertyBoolean('SDLAktiv')) + && ($ident !== 'SDLSOCArchiv' || $this->ReadPropertyInteger('SDLSOCVariableID') > 0); + if (!$this->setzeArchivLogging($archivID, $variableID, $loggen)) { + continue; + } AC_SetAggregationType($archivID, $variableID, 0); - AC_SetGraphStatus($archivID, $variableID, $aktiv); + AC_SetGraphStatus($archivID, $variableID, $loggen); $regeln = [-1 => $leistungstyp]; $loeschen = $this->ReadPropertyInteger('LeistungLoeschenMonate'); if ($loeschen > 0) { @@ -38,7 +45,9 @@ trait ManagerEnergieTrait foreach (self::ENERGIE_VARIABLEN as $ident) { $variableID = $this->GetIDForIdent($ident); - AC_SetLoggingStatus($archivID, $variableID, $aktiv); + if (!$this->setzeArchivLogging($archivID, $variableID, $aktiv)) { + continue; + } AC_SetAggregationType($archivID, $variableID, 1); AC_SetGraphStatus($archivID, $variableID, $aktiv); $regeln = [-1 => $energietyp]; @@ -66,7 +75,9 @@ trait ManagerEnergieTrait $istEnergie = str_contains($ident, 'Energie') || str_contains($ident, 'BatterieLaden_') || str_contains($ident, 'BatterieEntladen_'); - AC_SetLoggingStatus($archivID, $variableID, $aktiv); + if (!$this->setzeArchivLogging($archivID, $variableID, $aktiv)) { + continue; + } AC_SetAggregationType($archivID, $variableID, $istEnergie ? 1 : 0); AC_SetGraphStatus($archivID, $variableID, $aktiv); $regeln = [-1 => $istEnergie ? $energietyp : $leistungstyp]; @@ -93,6 +104,17 @@ trait ManagerEnergieTrait } } + private function setzeArchivLogging(int $archivID, int $variableID, bool $aktiv): bool + { + // Archiv-Metadaten sind nur bei aktivierter Aufzeichnung aenderbar. + if (!$aktiv && AC_GetLoggingStatus($archivID, $variableID)) { + AC_SetGraphStatus($archivID, $variableID, false); + } + AC_SetLoggingStatus($archivID, $variableID, $aktiv); + + return $aktiv; + } + /** @param array $regeln */ private function setzeVerdichtung(int $archivID, int $variableID, array $regeln): bool { @@ -292,6 +314,7 @@ trait ManagerEnergieTrait } if (!$neuErzeugen) { $this->visualisierungsKonfiguration('EnergieflussKonfiguration'); + $this->synchronisiereSDLEnergiefluss($instanzID); IPS_SetHidden($instanzID, false); $this->aktualisiereEnergieflusswerte(); @@ -374,6 +397,7 @@ trait ManagerEnergieTrait IPS_SetProperty($instanzID, 'Restrictions', '[]'); IPS_ApplyChanges($instanzID); $this->WriteAttributeBoolean('EnergieflussNeuErzeugen', false); + $this->synchronisiereSDLEnergiefluss($instanzID, true); $this->aktualisiereEnergieflusswerte($verbraucher); return 'Der Energiefluss wurde erstellt oder aktualisiert.'; @@ -564,8 +588,17 @@ trait ManagerEnergieTrait ); $this->setzeEnergieflussLeistungswert( 'EnergieflussBatterie', - $leistungen['Batterie'] + $leistungen['Batterie'] + ($this->sdlAnzeigen('SDLEnergieflussAnzeigen') ? 0.0 : $leistungen['SDL']) ); + $this->setzeEnergieflussLeistungswert('EnergieflussSDL', $leistungen['SDL']); + $soc = $this->aktuelleSDLSOC(); + if ($soc !== null) { + $this->SetValue('SDLSOCArchiv', $soc); + } + $flussID = $this->findeUnterobjekt('Energiefluss'); + if ($flussID > 0) { + $this->synchronisiereSDLEnergiefluss($flussID); + } } private function setzeEnergieflussLeistungswert(string $ident, float $watt): void @@ -742,8 +775,10 @@ trait ManagerEnergieTrait ); } - $this->erstelleDiagramm('Leistungsdiagramm', 'Leistungen', $leistung, 510); - $this->erstelleDiagramm('Energiediagramm', 'Energien', $energie, 520); + $leistungID = $this->erstelleDiagramm('Leistungsdiagramm', 'Leistungen', $leistung, 510); + $energieID = $this->erstelleDiagramm('Energiediagramm', 'Energien', $energie, 520); + $this->synchronisiereSDLDiagramm($leistungID, false); + $this->synchronisiereSDLDiagramm($energieID, true); return 'Die Diagramme Leistungen und Energien wurden erstellt oder aktualisiert.'; } diff --git a/libs/ManagerSDLTrait.php b/libs/ManagerSDLTrait.php new file mode 100644 index 0000000..3855e75 --- /dev/null +++ b/libs/ManagerSDLTrait.php @@ -0,0 +1,211 @@ +ReadPropertyBoolean('SDLAktiv')) { + return; + } + $leistungID = $this->ReadPropertyInteger('SDLLeistungVariableID'); + if (!$this->istNumerischeVariable($leistungID)) { + throw new InvalidArgumentException('SDL benoetigt eine numerische Istleistungsvariable.'); + } + $this->anlagenMessfaktor($this->ReadPropertyFloat('SDLLeistungsfaktor'), 'SDL'); + $socID = $this->ReadPropertyInteger('SDLSOCVariableID'); + if ($socID !== 0 && (!$this->istNumerischeVariable($socID) || $socID === $leistungID)) { + throw new InvalidArgumentException('SDL-SOC muss eine separate numerische Variable sein.'); + } + $andereQuellen = array_merge($this->pvMessquellen(), $this->batterieMessquellen()); + $andereQuellen[] = ['VariableID' => $this->ReadPropertyInteger('NetzleistungVariableID')]; + foreach ($andereQuellen as $quelle) { + if ($leistungID === $quelle['VariableID']) { + throw new InvalidArgumentException( + 'SDL-Leistung darf nicht zugleich als PV-, Netz- oder Batterieleistung bilanziert werden.' + ); + } + } + } + + private function synchronisiereSDLMesskonfiguration(): void + { + $neu = json_encode([ + $this->ReadPropertyBoolean('SDLAktiv'), + $this->ReadPropertyInteger('SDLLeistungVariableID'), + $this->ReadPropertyFloat('SDLLeistungsfaktor'), + ], JSON_THROW_ON_ERROR); + $alt = $this->ReadAttributeString('SDLMesskonfiguration'); + if ($neu !== $alt && ($alt !== '' || $this->ReadPropertyBoolean('SDLAktiv'))) { + // Bereits erfasste Energien bleiben erhalten; keine Rueckrechnung mit neuen Quellen. + $this->WriteAttributeInteger('MessungLetzterZeitstempel', 0); + $this->WriteAttributeString('MessungZaehlerstaende', '{}'); + $this->WriteAttributeInteger('MessungLetzteLeistungsaufzeichnung', 0); + } + $this->WriteAttributeString('SDLMesskonfiguration', $neu); + } + + private function aktuelleSDLLeistung(): float + { + if (!$this->ReadPropertyBoolean('SDLAktiv')) { + return 0.0; + } + $variableID = $this->ReadPropertyInteger('SDLLeistungVariableID'); + if (!$this->istNumerischeVariable($variableID)) { + throw new RuntimeException('Die SDL-Istleistung ist nicht verfuegbar.'); + } + $wert = GetValue($variableID); + if ((!is_int($wert) && !is_float($wert)) || !is_finite((float) $wert)) { + throw new RuntimeException('Die SDL-Istleistung ist keine endliche Zahl.'); + } + $leistung = (float) $wert * $this->ReadPropertyFloat('SDLLeistungsfaktor'); + if (!is_finite($leistung)) { + throw new RuntimeException('Die skalierte SDL-Istleistung ist nicht endlich.'); + } + if ($this->ReadPropertyInteger('SDLSOCVariableID') === 0 || $this->aktuelleSDLSOC() !== null) { + $this->setzeDiagnosewert('SDLStatus', 'Aktiv'); + } + + return $leistung; + } + + private function aktuelleSDLSOC(): ?float + { + if (!$this->ReadPropertyBoolean('SDLAktiv')) { + return null; + } + $variableID = $this->ReadPropertyInteger('SDLSOCVariableID'); + if ($variableID === 0) { + return null; + } + if ($this->istNumerischeVariable($variableID)) { + $wert = (float) GetValue($variableID); + if (is_finite($wert) && $wert >= 0.0 && $wert <= 100.0) { + return $wert; + } + } + $this->setzeDiagnosewert('SDLStatus', 'Fehler: SDL-SOC muss zwischen 0 und 100 Prozent liegen.'); + + return null; + } + + private function sdlAnzeigen(string $property): bool + { + return $this->ReadPropertyBoolean('SDLAktiv') && $this->ReadPropertyBoolean($property); + } + + private function aktualisiereSDLStatus(): void + { + if (!$this->ReadPropertyBoolean('SDLAktiv')) { + $this->setzeDiagnosewert('SDLStatus', 'NichtVerwendet'); + return; + } + try { + $this->aktuelleSDLLeistung(); + $this->aktuelleSDLSOC(); + } catch (Throwable $fehler) { + $this->setzeDiagnosewert('SDLStatus', 'Fehler: ' . $fehler->getMessage()); + } + } + + private function synchronisiereSDLEnergiefluss(int $instanzID, bool $profilAktualisieren = false): void + { + $anzeigen = $this->sdlAnzeigen('SDLEnergieflussAnzeigen'); + $variableID = $this->findeUnterobjekt('EnergieflussSDL'); + if (!$anzeigen && $variableID === 0) { + return; + } + if ($anzeigen && ($variableID === 0 || $profilAktualisieren)) { + $variableID = $this->energieflussLeistungsvariable( + 'EnergieflussSDL', 'Energiefluss SDL', $this->energieflussLeistungsprofil(), 635 + ); + } + $alt = json_decode(IPS_GetProperty($instanzID, 'Config'), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($alt) || !array_is_list($alt)) { + throw new RuntimeException('Die bestehende Energiefluss-Konfiguration ist keine Liste.'); + } + $neu = []; + $gefunden = false; + $socID = $this->aktuelleSDLSOC() !== null + ? $this->GetIDForIdent('SDLSOCArchiv') : 1; + foreach ($alt as $knoten) { + if ((int) ($knoten['VariableID'] ?? 0) !== $variableID) { + $neu[] = $knoten; + } elseif ($anzeigen && !$gefunden) { + $knoten['InfoVariableID'] = $socID; + $neu[] = $knoten; + $gefunden = true; + } + } + if ($anzeigen && !$gefunden) { + $neu[] = $this->energieflussKnoten($variableID, 'SDL / Regelenergie', 'storage', false, $socID); + } + if ($neu !== $alt) { + IPS_SetProperty($instanzID, 'Config', json_encode($neu, JSON_THROW_ON_ERROR)); + IPS_ApplyChanges($instanzID); + } + } + + private function synchronisiereSDLDiagramm(int $medienID, bool $energie): void + { + $idents = $energie + ? ['SDLLadenEnergie', 'SDLEntladenEnergie', 'SDLSOCArchiv'] + : ['SDLLeistungArchiv', 'SDLSOCArchiv']; + $verwaltet = array_map(fn (string $ident): int => $this->GetIDForIdent($ident), $idents); + $gewuenscht = []; + if ($this->sdlAnzeigen('SDLDiagrammeAnzeigen')) { + if ($energie) { + $gewuenscht[] = $this->chartDatensatz( + 'SDLLadenEnergie', 'SDL laden', '#007C91', '~Electricity', 'bar' + ); + $gewuenscht[] = $this->chartDatensatz( + 'SDLEntladenEnergie', 'SDL entladen', '#C43E1C', '~Electricity', 'bar' + ); + } else { + $gewuenscht[] = $this->chartDatensatz( + 'SDLLeistungArchiv', 'SDL Istleistung', '#007C91', '~Power' + ); + } + if ($this->ReadPropertyInteger('SDLSOCVariableID') > 0) { + $soc = $this->chartDatensatz('SDLSOCArchiv', 'SDL SOC', '#C43E1C', '~Intensity.100'); + $soc['side'] = 'right'; + $gewuenscht[] = $soc; + } + } + $inhalt = json_decode((string) base64_decode(IPS_GetMediaContent($medienID), true), true); + if (!is_array($inhalt) || !isset($inhalt['datasets']) || !is_array($inhalt['datasets'])) { + if (!$this->sdlAnzeigen('SDLDiagrammeAnzeigen')) { + return; + } + throw new RuntimeException('Das bestehende Diagramm hat keine gueltigen Datensaetze.'); + } + $alt = $inhalt['datasets']; + $neu = []; + foreach ($alt as $datensatz) { + $id = (int) ($datensatz['variableID'] ?? 0); + if (!in_array($id, $verwaltet, true)) { + $neu[] = $datensatz; + continue; + } + foreach ($gewuenscht as $index => $ziel) { + if ($id === $ziel['variableID']) { + $neu[] = $datensatz; + unset($gewuenscht[$index]); + break; + } + } + } + $neu = array_merge($neu, array_values($gewuenscht)); + if ($neu !== $alt) { + $inhalt['datasets'] = $neu; + IPS_SetMediaContent($medienID, base64_encode(json_encode($inhalt, JSON_THROW_ON_ERROR))); + } + } +} diff --git a/tests/EnergieMessungTest.php b/tests/EnergieMessungTest.php index 6844758..efeabbc 100644 --- a/tests/EnergieMessungTest.php +++ b/tests/EnergieMessungTest.php @@ -24,6 +24,8 @@ final class EnergieMessungTest extends TestCase self::assertEqualsWithDelta(0.15, $werte['EinspeisungEnergie'], 0.000001); self::assertEqualsWithDelta(0.075, $werte['BatterieLadenEnergie'], 0.000001); self::assertSame(0.0, $werte['BatterieEntladenEnergie']); + self::assertSame(0.0, $werte['SDLLadenEnergie']); + self::assertSame(0.0, $werte['SDLEntladenEnergie']); } public function testTrenntBezugUndBatterieentladung(): void @@ -50,23 +52,100 @@ final class EnergieMessungTest extends TestCase self::assertSame(0.0, EnergieMessung::hausleistung(500.0, -1200.0, 0.0)); } + public function testBeruecksichtigtSDLVorDerBegrenzungDerHausleistung(): void + { + self::assertSame(1700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0, 1000.0)); + self::assertSame(3700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0, -1000.0)); + self::assertSame(1000.0, EnergieMessung::hausleistung(0.0, -1000.0, 0.0, -2000.0)); + self::assertSame(0.0, EnergieMessung::hausleistung(500.0, 0.0, 0.0, 1000.0)); + self::assertSame( + EnergieMessung::hausleistung(4200.0, -800.0, 700.0), + EnergieMessung::hausleistung(4200.0, -800.0, 700.0, 0.0) + ); + } + public function testBerechnetHausenergieAusDenZaehlerdeltas(): void { self::assertSame(8.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0)); self::assertSame(0.0, EnergieMessung::hausenergie(1.0, 0.0, 5.0, 0.0, 0.0)); } + public function testBereinigtHausenergieUmSDLLadungUndEntladung(): void + { + self::assertSame(5.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, 3.0, 0.0)); + self::assertSame(11.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, 0.0, 3.0)); + self::assertSame(7.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, 3.0, 2.0)); + self::assertSame(1.0, EnergieMessung::hausenergie(0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 2.0)); + self::assertSame(0.0, EnergieMessung::hausenergie(1.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0)); + self::assertSame( + EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0), + EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, 0.0, 0.0) + ); + } + + public function testIntegriertSDLGetrenntVonBatterieUndHaus(): void + { + $leistungen = [ + 'PV' => 7200.0, + 'Haus' => 3600.0, + 'Netz' => -1800.0, + 'Batterie' => 900.0, + 'SDL' => 1800.0, + ]; + $laden = EnergieMessung::energieDeltas($leistungen, 300); + self::assertEqualsWithDelta(0.15, $laden['SDLLadenEnergie'], 0.000001); + self::assertSame(0.0, $laden['SDLEntladenEnergie']); + self::assertEqualsWithDelta(0.075, $laden['BatterieLadenEnergie'], 0.000001); + self::assertEqualsWithDelta(0.3, $laden['Hausenergie'], 0.000001); + + $leistungen['SDL'] = -1800.0; + $entladen = EnergieMessung::energieDeltas($leistungen, 300); + self::assertSame(0.0, $entladen['SDLLadenEnergie']); + self::assertEqualsWithDelta(0.15, $entladen['SDLEntladenEnergie'], 0.000001); + self::assertSame($laden['Hausenergie'], $entladen['Hausenergie']); + self::assertSame($laden['BatterieLadenEnergie'], $entladen['BatterieLadenEnergie']); + + $leistungen['SDL'] = 0.0; + $mitNull = EnergieMessung::energieDeltas($leistungen, 300); + unset($leistungen['SDL']); + self::assertSame($mitNull, EnergieMessung::energieDeltas($leistungen, 300)); + } + + public function testIgnoriertNichtEndlicheSDLWerte(): void + { + foreach ([INF, -INF, NAN] as $ungueltig) { + self::assertSame(2700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0, $ungueltig)); + self::assertSame(8.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, $ungueltig, $ungueltig)); + $werte = EnergieMessung::energieDeltas([ + 'PV' => 0.0, + 'Haus' => 1000.0, + 'Netz' => 1000.0, + 'Batterie' => 0.0, + 'SDL' => $ungueltig, + ], 3600); + + self::assertSame(0.0, $werte['SDLLadenEnergie']); + self::assertSame(0.0, $werte['SDLEntladenEnergie']); + self::assertSame(1.0, $werte['Hausenergie']); + } + } + public function testIgnoriertUngueltigeDauerUndNichtEndlicheWerte(): void { - $werte = EnergieMessung::energieDeltas([ - 'PV' => INF, - 'Haus' => NAN, - 'Netz' => 1000.0, - 'Batterie' => -1000.0, - ], -1); + foreach ([-1, 0] as $dauer) { + foreach ([-1000.0, 1000.0] as $sdl) { + $werte = EnergieMessung::energieDeltas([ + 'PV' => INF, + 'Haus' => NAN, + 'Netz' => 1000.0, + 'Batterie' => -1000.0, + 'SDL' => $sdl, + ], $dauer); - foreach ($werte as $wert) { - self::assertSame(0.0, $wert); + foreach ($werte as $wert) { + self::assertSame(0.0, $wert); + } + } } } } diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index 3a6d5c9..5835a8f 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -51,6 +51,12 @@ final class ManagerModulstrukturTest extends TestCase 'EinspeisebegrenzungAktiv', 'Einspeisegrenze', 'PrognoseAktiv', + 'SDLAktiv', + 'SDLLeistungVariableID', + 'SDLLeistungsfaktor', + 'SDLSOCVariableID', + 'SDLEnergieflussAnzeigen', + 'SDLDiagrammeAnzeigen', 'PrognoseLizenzInformation', 'NetzfahrplanAktiv', 'NetzbezugEnergieVariableID', diff --git a/tests/ManagerSDLTest.php b/tests/ManagerSDLTest.php new file mode 100644 index 0000000..6cb27f9 --- /dev/null +++ b/tests/ManagerSDLTest.php @@ -0,0 +1,425 @@ +manager = new Manager(42); + $this->manager->Create(); + foreach ([101 => 1000.0, 102 => 5000.0, 103 => 200.0, 104 => 1500.0, 105 => 70.0] as $id => $value) { + ManagerSDLRuntime::variable($id, $value); + } + $this->properties([ + 'NetzleistungVariableID' => 101, + 'AnlagenWechselrichter' => $this->json([ + ['ID' => 'pv', 'Typ' => 'pv', 'IstleistungVariableID' => 102], + ]), + 'AnlagenBatterien' => $this->json([ + ['ID' => 'battery', 'LeistungVariableID' => 103], + ]), + 'SDLLeistungVariableID' => 104, + 'SDLSOCVariableID' => 105, + ]); + foreach ([ + 'SDLLeistungArchiv', 'SDLSOCArchiv', 'SDLLadenEnergie', 'SDLEntladenEnergie', + ] as $ident) { + ManagerSDLRuntime::child(42, $ident); + } + ManagerSDLRuntime::child(42, 'SDLStatus', 3); + } + + public function testSDLByDefaultInactiveDoesNotRequireSources(): void + { + self::assertFalse($this->manager->testProperties['SDLAktiv']); + self::assertFalse($this->manager->testProperties['SDLEnergieflussAnzeigen']); + self::assertFalse($this->manager->testProperties['SDLDiagrammeAnzeigen']); + unset(ManagerSDLRuntime::$variables[104], ManagerSDLRuntime::$variables[105]); + + $this->invoke('pruefeSDLKonfiguration'); + $powers = $this->invoke('leseMessleistungen'); + + self::assertSame(0.0, $powers['SDL']); + self::assertSame(5800.0, $powers['Haus']); + self::assertNull($this->invoke('aktuelleSDLSOC')); + } + + /** @dataProvider signedPowerProvider */ + public function testSDLPowerIsExcludedFromHouseConsumption(float $raw, float $factor, float $sdl, float $house): void + { + $this->properties(['SDLAktiv' => true, 'SDLLeistungsfaktor' => $factor]); + ManagerSDLRuntime::variable(104, $raw); + $this->invoke('pruefeSDLKonfiguration'); + + self::assertSame([ + 'PV' => 5000.0, 'Haus' => $house, 'Netz' => 1000.0, 'Batterie' => 200.0, 'SDL' => $sdl, + ], $this->invoke('leseMessleistungen')); + } + + public static function signedPowerProvider(): array + { + return [ + 'charging' => [1500.0, 1.0, 1500.0, 4300.0], + 'discharging' => [-1500.0, 1.0, -1500.0, 7300.0], + 'kilowatts' => [2.5, 1000.0, 2500.0, 3300.0], + 'inverted source' => [1500.0, -1.0, -1500.0, 7300.0], + ]; + } + + /** @dataProvider duplicatePowerProvider */ + public function testRejectsSDLPowerAlsoUsedByAnotherEnergySource(int $duplicateID): void + { + $this->properties(['SDLAktiv' => true, 'SDLLeistungVariableID' => $duplicateID]); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('nicht zugleich'); + $this->invoke('pruefeSDLKonfiguration'); + } + + public static function duplicatePowerProvider(): array + { + return ['grid' => [101], 'PV' => [102], 'battery' => [103]]; + } + + public function testRejectsSOCUsingThePowerVariable(): void + { + $this->properties(['SDLAktiv' => true, 'SDLSOCVariableID' => 104]); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('separate numerische Variable'); + $this->invoke('pruefeSDLKonfiguration'); + } + + /** @dataProvider invalidFactorProvider */ + public function testRejectsInvalidSDLFactors(float $factor): void + { + $this->properties(['SDLAktiv' => true, 'SDLLeistungsfaktor' => $factor]); + $this->expectException(InvalidArgumentException::class); + $this->invoke('pruefeSDLKonfiguration'); + } + + public static function invalidFactorProvider(): array + { + return ['zero' => [0.0], 'infinite' => [INF], 'not a number' => [NAN]]; + } + + public function testMissingSDLPowerFailsWithoutReturningHouseConsumption(): void + { + $this->properties(['SDLAktiv' => true]); + unset(ManagerSDLRuntime::$variables[104]); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('nicht verfuegbar'); + $this->invoke('leseMessleistungen'); + } + + /** @dataProvider invalidPowerProvider */ + public function testNonfiniteOrNonnumericPowerDoesNotBecomeZero($value): void + { + $this->properties(['SDLAktiv' => true]); + ManagerSDLRuntime::variable(104, $value); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('keine endliche Zahl'); + $this->invoke('leseMessleistungen'); + } + + public static function invalidPowerProvider(): array + { + return ['infinite' => [INF], 'negative infinite' => [-INF], 'not a number' => [NAN], 'string' => ['1500']]; + } + + public function testScaledSDLPowerOverflowIsRejected(): void + { + $this->properties(['SDLAktiv' => true, 'SDLLeistungsfaktor' => 2.0]); + ManagerSDLRuntime::variable(104, PHP_FLOAT_MAX); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('skalierte SDL-Istleistung'); + $this->invoke('leseMessleistungen'); + } + + public function testSDLPowerMustBeANumericVariable(): void + { + $this->properties(['SDLAktiv' => true]); + ManagerSDLRuntime::variable(104, '1500', 3); + $this->expectException(InvalidArgumentException::class); + $this->invoke('pruefeSDLKonfiguration'); + } + + /** @dataProvider socProvider */ + public function testSDLSOCBoundaries(float $value, ?float $expected): void + { + $this->properties(['SDLAktiv' => true]); + ManagerSDLRuntime::variable(105, $value); + self::assertSame($expected, $this->invoke('aktuelleSDLSOC')); + if ($expected === null) { + self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus')); + } + } + + public static function socProvider(): array + { + return [ + 'empty' => [0.0, 0.0], 'full' => [100.0, 100.0], 'normal' => [37.5, 37.5], + 'negative' => [-0.1, null], 'overfull' => [100.1, null], 'infinite' => [INF, null], 'NaN' => [NAN, null], + ]; + } + + public function testSDLSOCMayBeOmittedAndDisappearingSourceIsIgnored(): void + { + $this->properties(['SDLAktiv' => true, 'SDLSOCVariableID' => 0]); + $this->invoke('pruefeSDLKonfiguration'); + self::assertNull($this->invoke('aktuelleSDLSOC')); + + $this->properties(['SDLSOCVariableID' => 105]); + unset(ManagerSDLRuntime::$variables[105]); + self::assertNull($this->invoke('aktuelleSDLSOC')); + self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus')); + } + + public function testPowerReadingPreservesSOCErrorUntilRecovery(): void + { + $this->properties(['SDLAktiv' => true]); + ManagerSDLRuntime::variable(105, 101.0); + $this->invoke('aktuelleSDLLeistung'); + self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus')); + $this->invoke('aktuelleSDLLeistung'); + self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus')); + ManagerSDLRuntime::variable(105, 47.5); + $this->invoke('aktuelleSDLLeistung'); + self::assertSame('Aktiv', $this->manager->GetValue('SDLStatus')); + } + + public function testSDLSOCDoesNotChangeWeightedNormalBatterySOC(): void + { + ManagerSDLRuntime::variable(106, 20.0); + ManagerSDLRuntime::variable(107, 80.0); + $this->properties([ + 'SDLAktiv' => true, + 'AnlagenBatterien' => $this->json([ + ['ID' => 'small', 'SOCVariableID' => 106, 'Nutzkapazitaet_kWh' => 10.0], + ['ID' => 'large', 'SOCVariableID' => 107, 'Nutzkapazitaet_kWh' => 30.0], + ]), + ]); + + self::assertSame(65.0, $this->invoke('aktuelleBatterieSoc')); + self::assertSame(70.0, $this->invoke('aktuelleSDLSOC')); + ManagerSDLRuntime::variable(105, 0.0); + self::assertSame(65.0, $this->invoke('aktuelleBatterieSoc')); + } + + public function testCounterDeltasExcludeSDLChargingAndIncludeDischarging(): void + { + $this->configureCounters(); + $baseline = $this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]); + self::assertCount(8, $baseline); + self::assertSame(array_fill_keys(array_keys($baseline), 0.0), $baseline); + $this->advanceCounters(); + + $deltas = $this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]); + + self::assertEqualsWithDelta(2.1, $deltas['Hausenergie'], 0.000001); + self::assertEqualsWithDelta(2.0, $deltas['PVEnergie'], 0.000001); + self::assertSame(0.6, $deltas['SDLLadenEnergie']); + self::assertSame(0.2, $deltas['SDLEntladenEnergie']); + self::assertEqualsWithDelta(0.4, $deltas['BatterieLadenEnergie'], 0.000001); + self::assertEqualsWithDelta(0.1, $deltas['BatterieEntladenEnergie'], 0.000001); + } + + public function testMissingSDLIntervalDoesNotPolluteHouseEnergyOrTheNextInterval(): void + { + $this->configureCounters(); + $this->invoke('leseEnergieZaehlerDeltas'); + $this->advanceCounters(); + $deltas = $this->invoke('leseEnergieZaehlerDeltas', [0.0, 0.0, false]); + + self::assertSame(0.0, $deltas['Hausenergie']); + self::assertEqualsWithDelta(2.0, $deltas['PVEnergie'], 0.000001); + self::assertEqualsWithDelta(1.0, $deltas['NetzbezugEnergie'], 0.000001); + ManagerSDLRuntime::variable(201, 102.5); + $next = $this->invoke('leseEnergieZaehlerDeltas', [0.1, 0.0, true]); + self::assertEqualsWithDelta(0.4, $next['Hausenergie'], 0.000001); + } + + public function testCounterResetSuppressesHouseEnergyForActiveSDL(): void + { + $this->configureCounters(); + $this->invoke('leseEnergieZaehlerDeltas'); + $this->advanceCounters(); + ManagerSDLRuntime::variable(202, 0.0); + $deltas = $this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]); + self::assertSame(0.0, $deltas['Hausenergie']); + self::assertSame(0.0, $deltas['NetzbezugEnergie']); + self::assertSame(0.6, $deltas['SDLLadenEnergie']); + } + + public function testWithoutConfiguredCountersReturnsNull(): void + { + $this->properties(['SDLAktiv' => true]); + self::assertNull($this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2])); + } + + public function testChangingSDLSourceResetsBaselinesButKeepsCollectedEnergy(): void + { + $this->properties(['SDLAktiv' => true]); + $this->invoke('synchronisiereSDLMesskonfiguration'); + $this->manager->testAttributes['MessungLetzterZeitstempel'] = 123; + $this->manager->testAttributes['MessungLetzteLeistungsaufzeichnung'] = 100; + $this->manager->testAttributes['MessungZaehlerstaende'] = '{"pv:pv":5.0}'; + $this->manager->testAttributes['MessungEnergiePuffer'] = '{"Hausenergie":1.5}'; + + $this->invoke('synchronisiereSDLMesskonfiguration'); + self::assertSame(123, $this->manager->testAttributes['MessungLetzterZeitstempel']); + $this->properties(['SDLLeistungsfaktor' => -1.0]); + $this->invoke('synchronisiereSDLMesskonfiguration'); + + self::assertSame(0, $this->manager->testAttributes['MessungLetzterZeitstempel']); + self::assertSame(0, $this->manager->testAttributes['MessungLetzteLeistungsaufzeichnung']); + self::assertSame('{}', $this->manager->testAttributes['MessungZaehlerstaende']); + self::assertSame('{"Hausenergie":1.5}', $this->manager->testAttributes['MessungEnergiePuffer']); + } + + /** @dataProvider chartProvider */ + public function testChartMergePreservesCustomizationsAndOnlyRemovesSDL(bool $energy): void + { + $this->properties(['SDLAktiv' => true, 'SDLDiagrammeAnzeigen' => true]); + $powerIdent = $energy ? 'SDLLadenEnergie' : 'SDLLeistungArchiv'; + $custom = ['variableID' => 999, 'title' => 'Custom series', 'strokeColor' => '#ABCDEF']; + $sdl = ['variableID' => $this->manager->GetIDForIdent($powerIdent), 'title' => 'Own SDL name', 'visible' => false]; + ManagerSDLRuntime::$media[301] = base64_encode($this->json([ + 'datasets' => [$custom, $sdl], 'customOption' => ['keep' => true], + ])); + + $this->invoke('synchronisiereSDLDiagramm', [301, $energy]); + $chart = $this->chart(); + self::assertSame($custom, $chart['datasets'][0]); + self::assertSame($sdl, $chart['datasets'][1]); + self::assertSame(['keep' => true], $chart['customOption']); + self::assertCount($energy ? 4 : 3, $chart['datasets']); + $soc = $chart['datasets'][count($chart['datasets']) - 1]; + self::assertSame($this->manager->GetIDForIdent('SDLSOCArchiv'), $soc['variableID']); + self::assertSame('right', $soc['side']); + if ($energy) { + self::assertSame('bar', $chart['datasets'][2]['type']); + self::assertSame($this->manager->GetIDForIdent('SDLEntladenEnergie'), $chart['datasets'][2]['variableID']); + } + + $writes = ManagerSDLRuntime::$writes; + $this->invoke('synchronisiereSDLDiagramm', [301, $energy]); + self::assertSame($writes, ManagerSDLRuntime::$writes); + + $this->properties(['SDLDiagrammeAnzeigen' => false]); + $this->invoke('synchronisiereSDLDiagramm', [301, $energy]); + self::assertSame([$custom], $this->chart()['datasets']); + self::assertSame(['keep' => true], $this->chart()['customOption']); + } + + public static function chartProvider(): array + { + return ['power chart' => [false], 'energy chart' => [true]]; + } + + public function testEnergyFlowMergePreservesCustomNodesAndIsIdempotent(): void + { + $this->properties(['SDLAktiv' => true, 'SDLEnergieflussAnzeigen' => true]); + $custom = ['VariableID' => 999, 'Name' => 'Custom consumer', 'Type' => 'consumer', 'Invert' => true]; + ManagerSDLRuntime::$properties[302]['Config'] = $this->json([$custom]); + + $this->invoke('synchronisiereSDLEnergiefluss', [302]); + $nodes = $this->flow(); + self::assertSame($custom, $nodes[0]); + self::assertCount(2, $nodes); + self::assertSame('storage', $nodes[1]['Type']); + self::assertSame($this->manager->GetIDForIdent('EnergieflussSDL'), $nodes[1]['VariableID']); + self::assertSame($this->manager->GetIDForIdent('SDLSOCArchiv'), $nodes[1]['InfoVariableID']); + self::assertTrue(ManagerSDLRuntime::$hidden[$nodes[1]['VariableID']]); + + $nodes[1]['Name'] = 'My SDL'; + $nodes[1]['Invert'] = true; + ManagerSDLRuntime::$properties[302]['Config'] = $this->json($nodes); + $writes = ManagerSDLRuntime::$writes; + $this->invoke('synchronisiereSDLEnergiefluss', [302]); + self::assertSame($nodes, $this->flow()); + self::assertSame($writes, ManagerSDLRuntime::$writes); + + $this->properties(['SDLSOCVariableID' => 0]); + $this->invoke('synchronisiereSDLEnergiefluss', [302]); + self::assertSame(1, $this->flow()[1]['InfoVariableID']); + self::assertSame('My SDL', $this->flow()[1]['Name']); + + $this->properties(['SDLAktiv' => false]); + $this->invoke('synchronisiereSDLEnergiefluss', [302]); + self::assertSame([$custom], $this->flow()); + } + + private function configureCounters(): void + { + $this->properties([ + 'SDLAktiv' => true, + 'NetzbezugEnergieVariableID' => 202, + 'NetzeinspeisungEnergieVariableID' => 203, + 'AnlagenWechselrichter' => $this->json([ + ['ID' => 'pv', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'ErzeugungsenergieVariableID' => 201], + ]), + 'AnlagenBatterien' => $this->json([ + ['ID' => 'battery', 'LeistungVariableID' => 103, 'LadeenergieVariableID' => 204, 'EntladeenergieVariableID' => 205], + ]), + ]); + foreach ([201 => 100.0, 202 => 10.0, 203 => 5.0, 204 => 10.0, 205 => 8.0] as $id => $value) { + ManagerSDLRuntime::variable($id, $value); + } + } + + private function advanceCounters(): void + { + foreach ([201 => 102.0, 202 => 11.0, 203 => 5.2, 204 => 10.4, 205 => 8.1] as $id => $value) { + ManagerSDLRuntime::variable($id, $value); + } + } + + private function properties(array $properties): void + { + $this->manager->testProperties = array_replace($this->manager->testProperties, $properties); + } + + private function invoke(string $name, array $arguments = []) + { + $method = new ReflectionMethod(Manager::class, $name); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + return $method->invokeArgs($this->manager, $arguments); + } + + private function json(array $value): string + { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + } + + private function chart(): array + { + return json_decode(base64_decode(ManagerSDLRuntime::$media[301]), true, 512, JSON_THROW_ON_ERROR); + } + + private function flow(): array + { + return json_decode(ManagerSDLRuntime::$properties[302]['Config'], true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Support/ManagerSDLRuntime.php b/tests/Support/ManagerSDLRuntime.php new file mode 100644 index 0000000..ac5bd61 --- /dev/null +++ b/tests/Support/ManagerSDLRuntime.php @@ -0,0 +1,173 @@ + $type, 'Value' => $value]; + + return $id; + } + + public static function child(int $parent, string $ident, int $type = 2): int + { + if (!isset(self::$children[$parent][$ident])) { + $id = self::$nextID++; + self::$children[$parent][$ident] = $id; + self::variable($id, $type === 3 ? '' : 0.0, $type); + } + + return self::$children[$parent][$ident]; + } +} + +class IPSModule +{ + public int $InstanceID; + public array $testProperties = []; + public array $testAttributes = []; + + public function __construct(int $instanceID) + { + $this->InstanceID = $instanceID; + } + + public function Create(): void + { + } + + public function __call(string $method, array $arguments) + { + if (preg_match('/^(Register|Read|Write)(Property|Attribute)(Boolean|Integer|Float|String)$/', $method, $parts)) { + $storage = $parts[2] === 'Property' ? 'testProperties' : 'testAttributes'; + $ident = $arguments[0]; + if ($parts[1] === 'Register') { + $this->{$storage}[$ident] = $this->{$storage}[$ident] ?? $arguments[1]; + + return null; + } + if ($parts[1] === 'Write') { + $this->{$storage}[$ident] = $arguments[1]; + + return null; + } + if (!array_key_exists($ident, $this->{$storage})) { + throw new RuntimeException('Unregistered test property or attribute: ' . $ident); + } + + return $this->{$storage}[$ident]; + } + if (preg_match('/^RegisterVariable(Boolean|Integer|Float|String)$/', $method, $parts)) { + $types = ['Boolean' => 0, 'Integer' => 1, 'Float' => 2, 'String' => 3]; + + return ManagerSDLRuntime::child($this->InstanceID, $arguments[0], $types[$parts[1]]); + } + + throw new BadMethodCallException('Unsupported Symcon test call: ' . $method); + } + + public function EnableAction(string $ident): void + { + } + + public function RegisterTimer(string $ident, int $interval, string $script): void + { + } + + public function GetIDForIdent(string $ident): int + { + if (!isset(ManagerSDLRuntime::$children[$this->InstanceID][$ident])) { + throw new RuntimeException('Unknown test child: ' . $ident); + } + + return ManagerSDLRuntime::$children[$this->InstanceID][$ident]; + } + + public function SetValue(string $ident, $value): void + { + ManagerSDLRuntime::$variables[$this->GetIDForIdent($ident)]['Value'] = $value; + } + + public function GetValue(string $ident) + { + return GetValue($this->GetIDForIdent($ident)); + } +} + +function IPS_VariableExists(int $variableID): bool +{ + return isset(ManagerSDLRuntime::$variables[$variableID]); +} + +function IPS_GetVariable(int $variableID): array +{ + if (!IPS_VariableExists($variableID)) { + throw new RuntimeException('Unknown test variable: ' . $variableID); + } + + return ManagerSDLRuntime::$variables[$variableID]; +} + +function GetValue(int $variableID) +{ + return IPS_GetVariable($variableID)['Value']; +} + +function IPS_GetObjectIDByIdent(string $ident, int $parentID) +{ + return ManagerSDLRuntime::$children[$parentID][$ident] ?? false; +} + +function IPS_GetProperty(int $instanceID, string $property) +{ + return ManagerSDLRuntime::$properties[$instanceID][$property]; +} + +function IPS_SetProperty(int $instanceID, string $property, $value): void +{ + ManagerSDLRuntime::$properties[$instanceID][$property] = $value; + ManagerSDLRuntime::$writes[] = ['property', $instanceID, $property]; +} + +function IPS_ApplyChanges(int $instanceID): void +{ + ManagerSDLRuntime::$writes[] = ['apply', $instanceID]; +} + +function IPS_GetMediaContent(int $mediaID): string +{ + return ManagerSDLRuntime::$media[$mediaID]; +} + +function IPS_SetMediaContent(int $mediaID, string $content): void +{ + ManagerSDLRuntime::$media[$mediaID] = $content; + ManagerSDLRuntime::$writes[] = ['media', $mediaID]; +} + +function IPS_SetHidden(int $objectID, bool $hidden): void +{ + ManagerSDLRuntime::$hidden[$objectID] = $hidden; +} diff --git a/tests/Symcon/modules/Manager.php b/tests/Symcon/modules/Manager.php index 8ec14ad..5757b9e 100644 --- a/tests/Symcon/modules/Manager.php +++ b/tests/Symcon/modules/Manager.php @@ -431,6 +431,97 @@ return static function (TestContext $test): void { } }); + $test->runCase('SDL korrigiert Hauslast und ergaenzt bestehende Anzeigen idempotent', static function ( + TestContext $test + ) use ($managerId): void { + $leistungID = $test->createVariable(2, 'SDL Istleistung', -1.2); + $socID = $test->createVariable(2, 'SDL SOC', 47.5); + $flussID = $test->objectByIdent('Energiefluss', $managerId); + $chartID = $test->objectByIdent('Leistungsdiagramm', $managerId); + $flussVorher = json_decode(IPS_GetProperty($flussID, 'Config'), true, 512, JSON_THROW_ON_ERROR); + $chartVorher = json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR); + try { + IPS_SetProperty($managerId, 'SDLAktiv', true); + IPS_SetProperty($managerId, 'SDLLeistungVariableID', $leistungID); + IPS_SetProperty($managerId, 'SDLLeistungsfaktor', -1000.0); + IPS_SetProperty($managerId, 'SDLSOCVariableID', $socID); + IPS_SetProperty($managerId, 'SDLEnergieflussAnzeigen', true); + IPS_SetProperty($managerId, 'SDLDiagrammeAnzeigen', true); + IPS_ApplyChanges($managerId); + $test->assertSame(1500.0, GetValue($test->objectByIdent('HausverbrauchLeistungArchiv', $managerId))); + $test->assertSame(1200.0, GetValue($test->objectByIdent('SDLLeistungArchiv', $managerId))); + $test->assertSame(47.5, GetValue($test->objectByIdent('SDLSOCArchiv', $managerId))); + $test->assertSame(1.2, GetValue($test->objectByIdent('EnergieflussSDL', $managerId))); + $test->assertSame('Aktiv', GetValue($test->objectByIdent('SDLStatus', $managerId))); + $fluss = json_decode(IPS_GetProperty($flussID, 'Config'), true, 512, JSON_THROW_ON_ERROR); + $test->assertSame($flussVorher, array_slice($fluss, 0, count($flussVorher))); + $test->assertSame(count($flussVorher) + 1, count($fluss)); + $test->assertSame($test->objectByIdent('SDLSOCArchiv', $managerId), $fluss[count($fluss) - 1]['InfoVariableID']); + $chart = json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR); + $test->assertSame($chartVorher['datasets'], array_slice($chart['datasets'], 0, count($chartVorher['datasets']))); + $test->assertSame('right', $chart['datasets'][count($chart['datasets']) - 1]['side']); + $test->assertSame('~Intensity.100', $chart['datasets'][count($chart['datasets']) - 1]['profile']); + IPS_ApplyChanges($managerId); + $test->assertSame($fluss, json_decode(IPS_GetProperty($flussID, 'Config'), true, 512, JSON_THROW_ON_ERROR)); + $test->assertSame($chart, json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR)); + + foreach ([0 => ['~Power', 1200.0], 1 => ['Enelix.Power.kW', 1.2]] as $einheit => $erwartet) { + IPS_SetProperty($managerId, 'EnergieflussAnzeigen', false); + IPS_ApplyChanges($managerId); + IPS_SetProperty($managerId, 'EnergieflussLeistungseinheit', $einheit); + IPS_SetProperty($managerId, 'EnergieflussAnzeigen', true); + IPS_ApplyChanges($managerId); + $flussID = $test->objectByIdent('Energiefluss', $managerId); + $sdlID = $test->objectByIdent('EnergieflussSDL', $managerId); + $test->assertSame($erwartet[0], IPS_GetVariable($sdlID)['VariableProfile']); + $test->assertSame($erwartet[1], GetValue($sdlID)); + } + + IPS_SetProperty($managerId, 'SDLEnergieflussAnzeigen', false); + IPS_SetProperty($managerId, 'SDLDiagrammeAnzeigen', false); + IPS_ApplyChanges($managerId); + $test->assertSame($flussVorher, json_decode(IPS_GetProperty($flussID, 'Config'), true, 512, JSON_THROW_ON_ERROR)); + $test->assertSame($chartVorher, json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR)); + $test->assertSame(1500.0, GetValue($test->objectByIdent('HausverbrauchLeistungArchiv', $managerId))); + $test->assertEquals(1.9, GetValue($test->objectByIdent('EnergieflussBatterie', $managerId)), 0.000001); + + IPS_SetProperty($managerId, 'SDLSOCVariableID', 0); + IPS_SetProperty($managerId, 'SDLDiagrammeAnzeigen', true); + IPS_ApplyChanges($managerId); + $chart = json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR); + $test->assertSame(count($chartVorher['datasets']) + 1, count($chart['datasets'])); + IPS_SetProperty($managerId, 'EnergieaufzeichnungAktiv', false); + $test->assertTrue(IPS_ApplyChanges($managerId)); + $archivID = IPS_GetInstanceListByModuleID('{43192F0B-135B-4CE7-A0A7-1475603F3060}')[0]; + foreach (['SDLLeistungArchiv', 'SDLSOCArchiv', 'SDLLadenEnergie', 'SDLEntladenEnergie'] as $ident) { + $test->assertSame(false, AC_GetLoggingStatus($archivID, $test->objectByIdent($ident, $managerId))); + } + } finally { + IPS_SetProperty($managerId, 'EnergieaufzeichnungAktiv', true); + IPS_SetProperty($managerId, 'SDLAktiv', false); + IPS_SetProperty($managerId, 'SDLEnergieflussAnzeigen', false); + IPS_SetProperty($managerId, 'SDLDiagrammeAnzeigen', false); + IPS_ApplyChanges($managerId); + } + $test->assertSame(2700.0, GetValue($test->objectByIdent('HausverbrauchLeistungArchiv', $managerId))); + }); + $test->runCase('SDL weist fehlende und doppelte Leistungsquellen zurueck', static function ( + TestContext $test + ) use ($managerId, $batterieLeistungId): void { + try { + IPS_SetProperty($managerId, 'SDLAktiv', true); + foreach ([0, $batterieLeistungId] as $quelle) { + IPS_SetProperty($managerId, 'SDLLeistungVariableID', $quelle); + IPS_ApplyChanges($managerId); + $test->assertInstanceStatus($managerId, 202); + } + } finally { + IPS_SetProperty($managerId, 'SDLAktiv', false); + IPS_SetProperty($managerId, 'SDLLeistungVariableID', 0); + IPS_ApplyChanges($managerId); + } + }); + $zustandswert = static function (array $daten, int $instanzId, string $kennung) { foreach ($daten['Verbraucher'] ?? [] as $verbraucher) { if (($verbraucher['InstanzID'] ?? 0) !== $instanzId) {