From 640fe537209fcb8ea56fc788430431bb0e0da1cb Mon Sep 17 00:00:00 2001 From: ENELIX Agent Date: Thu, 1 Oct 2026 16:20:36 +0000 Subject: [PATCH] feat(manager): add opt-in native V4 shadow operation telemetry --- CHANGELOG.md | 4 + Manager/module.php | 12 ++ docs/testing/Netzfahrplan-V4-Schatten.md | 83 ++++++++++ libs/ManagerNetzfahrplanV4Trait.php | 157 ++++++++++++++++++ libs/NetzfahrplanV4Betriebsdaten.php | 191 ++++++++++++++++++++++ tests/NetzfahrplanV4BetriebsdatenTest.php | 29 ++++ tests/fixtures/NativeV4Scenarios.php | 124 ++++++++++++++ 7 files changed, 600 insertions(+) create mode 100644 docs/testing/Netzfahrplan-V4-Schatten.md create mode 100644 libs/ManagerNetzfahrplanV4Trait.php create mode 100644 libs/NetzfahrplanV4Betriebsdaten.php create mode 100644 tests/NetzfahrplanV4BetriebsdatenTest.php create mode 100644 tests/fixtures/NativeV4Scenarios.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a316bca..4f6db07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert. +## Unreleased + +- Optionaler nativer V4-Betriebsdatensender fuer den Schattenversuch (standardmaessig aus), separate Diagnose und sichere Batterie-/Messquellenzuordnung. Kein neuer Fahrplan wird lokal ausgefuehrt; vollstaendige Monatspeak-/Viertelstunden-Messnachweise und deren Historienadapter bleiben Voraussetzung. + ## 0.1 Build 1 - Erste Beta - 2026-09-29 ### Enthalten diff --git a/Manager/module.php b/Manager/module.php index 1090d94..ac74605 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -11,6 +11,8 @@ 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/NetzfahrplanV4Betriebsdaten.php'; +require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php'; use Belevo\EnelixEMS\Anlagentopologie; use Belevo\EnelixEMS\EinspeiseRegler; @@ -25,6 +27,7 @@ use Belevo\EnelixEMS\StoerungsSnapshot; class Manager extends IPSModule implements ManagerSchnittstelle { use ManagerEnergieTrait; + use \Belevo\EnelixEMS\ManagerNetzfahrplanV4Trait; private const STATUS_AKTIV = 102; private const STATUS_MESSWERT_UNGUELTIG = 201; @@ -112,6 +115,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle public function Create(): void { parent::Create(); + $this->registriereNetzfahrplanV4(); $this->RegisterPropertyInteger('Rolle', 0); $this->RegisterPropertyInteger('NetzleistungVariableID', 0); @@ -232,6 +236,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle public function ApplyChanges(): void { parent::ApplyChanges(); + $this->SetTimerInterval('NetzfahrplanV4Senden', 0); $this->initialisiereLizenzInstallationID(); $this->aktualisiereVariablen(); @@ -287,6 +292,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle return; } $this->SetStatus(self::STATUS_AKTIV); + if ($this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) { + $this->SetTimerInterval('NetzfahrplanV4Senden', 60000); + } $this->synchronisiereAnlagentopologie(); if ($this->prognoseTelemetrieKonfiguriert()) { $this->SetTimerInterval( @@ -449,6 +457,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->regeln((bool) $wert); return; + case 'NetzfahrplanV4Senden': + $this->sendeNetzfahrplanV4(); + return; + case 'PrognoseSenden': $this->sendePrognoseTelemetrie(); return; diff --git a/docs/testing/Netzfahrplan-V4-Schatten.md b/docs/testing/Netzfahrplan-V4-Schatten.md new file mode 100644 index 0000000..92e33c8 --- /dev/null +++ b/docs/testing/Netzfahrplan-V4-Schatten.md @@ -0,0 +1,83 @@ +# V4: native Betriebsdaten, ausschliesslich Schattenbetrieb + +Stand 2026-10-01: Der optionale Sender und die Diagnose sind implementiert. +Kein V4-Plan wird damit uebernommen, kein Aktor geschrieben, kein V1-Sollwert +geaendert. Der Sender ist nach Installation standardmaessig AUS. + +## Konfiguration im Manager + +- NetzfahrplanV4SchattenAktiv: bool, Standard false. Eigener 60-s-Timer, + unabhaengig vom bestehenden Prognose-Timer. Fehler betreffen nur den Sendestatus. +- NetzfahrplanV4NetzladenErlaubt: bool, Standard false. Betrifft ausschliesslich + die im Schattenplan angenommene Berechtigung, keine reale Netzladefreigabe. +- NetzfahrplanV4BatterieOptionen: JSON-Objekt je Topologie-ID. Optional + SOCKapazitaet_kWh und MaxSOC_Prozent (sonst 100). Bei unterschiedlicher Nenn- + und Nutzkapazitaet ist SOCKapazitaet_kWh zwingend explizit anzugeben. +- NetzfahrplanV4MessnachweisVariableID: JSON-Stringvariable, Standard 0. + Ein vollstaendiger historischer/registerbasierter Messnachweis-Produzent + ist NOCH NICHT integriert. Fehlende Nachweise bleiben fehlend. + +Die Quelle muss der gemeinsame Netzanschluss sein. Topologiebatterien werden +nur einer eindeutigen aktiven Batterieinstanz mit derselben SOC- UND +Leistungsmessquelle zugeordnet. Externe SDL-Batterien werden nicht automatisch +hinzugefuegt. SDL-/Grundlastbereinigung bleibt eine separate, noch offene Aufgabe. +BMS-Grenzen werden mit Topologieleistungen begrenzt. Normale Betriebsreserve +und technisches Minimum werden nicht verwechselt: die hoehere Grenze gilt. + +Bei aktueller Entladehysterese wird die Entladeleistung konservativ auf 0 +begrenzt. Eine spaetere Freigabe innerhalb der 48h wird noch nicht modelliert. +Das ist sichtbar diagnostiziert und kann den Schattenplan wirtschaftlich +beschraenken; eine zeitabhaengige Hysterese-Modellierung bleibt offen. +SOC unterhalb der Reserve wird nicht kuenstlich angehoben: Diagnosefehler, +kein angeblich gueltiger Plan. Recovery-/Nachladungsmodell bleibt offen. + +## Diagnose ohne Netzwerk oder Stellbefehl + +Im Symcon-Skript nach Installation des neuen Modulstands: + +```php +RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false); + $this->RegisterPropertyBoolean('NetzfahrplanV4NetzladenErlaubt', false); + $this->RegisterPropertyString('NetzfahrplanV4BatterieOptionen', '{}'); + $this->RegisterPropertyInteger('NetzfahrplanV4MessnachweisVariableID', 0); + $this->RegisterAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}'); + $this->RegisterTimer('NetzfahrplanV4Senden', 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Senden', true);"); + } + + public function GetNetzfahrplanV4Diagnose(): string + { + try { + $manager = []; + foreach (['NetzleistungVariableID', 'MesswertMaxAlter', 'VerbraucherTimeout', + 'Lastspitzenmodus', 'NetzbezugEnergieVariableID'] as $key) { + $manager[$key] = $this->ReadPropertyInteger($key); + } + foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) { + $manager[$key] = $this->ReadPropertyFloat($key); + } + $manager['EinspeisebegrenzungAktiv'] = $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv'); + $manager['NetzladenErlaubt'] = $this->ReadPropertyBoolean('NetzfahrplanV4NetzladenErlaubt'); + $manager['Monatsgrenzen'] = json_decode($this->ReadPropertyString('Monatsgrenzen'), true, 512, JSON_THROW_ON_ERROR); + $manager['BatterieOptionen'] = json_decode($this->ReadPropertyString('NetzfahrplanV4BatterieOptionen'), true, 512, JSON_THROW_ON_ERROR); + $assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 512, JSON_THROW_ON_ERROR); + $cache = $this->leseJsonAttribut('VerbraucherCache'); + $controllers = []; + foreach ($this->aktiveVerbraucherIDs() as $id) { + if (!IPS_InstanceExists($id) + || IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') { + continue; + } + $c = ['InstanzID' => $id]; + foreach (['LadezustandVariableID', 'MaxLadeleistungVariableID', 'MaxEntladeleistungVariableID', + 'IstleistungVariableID', 'MindestLadezustand', 'ReserveLadezustand', 'MesswertMaxAlter', 'Batteriemanagement'] as $key) { + $c[$key] = IPS_GetProperty($id, $key); + } + $entry = $cache[(string) $id] ?? []; + $c['EmpfangenAm'] = $entry['EmpfangenAm'] ?? 0; + $c['Verfuegbar'] = $entry['Daten']['Verfuegbar'] ?? false; + foreach (($entry['Daten']['Zustand'] ?? []) as $state) { + if (($state['Kennung'] ?? '') === 'HystereseAktiv') { + $c['HystereseAktiv'] = $state['Wert']; + } + } + $controllers[] = $c; + } + $evidence = []; + $evidenceID = $this->ReadPropertyInteger('NetzfahrplanV4MessnachweisVariableID'); + if ($evidenceID > 0) { + if (!IPS_VariableExists($evidenceID) || IPS_GetVariable($evidenceID)['VariableType'] !== 3) { + throw new RuntimeException('Der konfigurierte Messnachweis ist keine JSON-Stringvariable.'); + } + $evidence = json_decode(GetValue($evidenceID), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($evidence)) { + throw new RuntimeException('Ungueltiger Messnachweis.'); + } + } + $read = static function (int $id): array { + if (!IPS_VariableExists($id)) { + throw new RuntimeException('Messvariable ' . $id . ' fehlt.'); + } + $before = IPS_GetVariable($id); + $value = GetValue($id); + $after = IPS_GetVariable($id); + if ($before['VariableUpdated'] !== $after['VariableUpdated']) { + throw new RuntimeException('Messwert hat sich beim Lesen geaendert; naechsten Durchlauf abwarten.'); + } + return ['value' => $value, 'updated' => (int) $after['VariableUpdated']]; + }; + return json_encode(NetzfahrplanV4Betriebsdaten::erstellen($manager, $assets, $controllers, + $read, $evidence, time()), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } catch (Throwable $error) { + return json_encode(['status' => 'invalid_inputs', 'reason' => substr($error->getMessage(), 0, 300)], JSON_THROW_ON_ERROR); + } + } + + public function GetNetzfahrplanV4Sendestatus(): string + { + return $this->ReadAttributeString('NetzfahrplanV4Sendestatus'); + } + + private function sendeNetzfahrplanV4(): void + { + if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) { + $this->WriteAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}'); + return; + } + try { + if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) { + throw new RuntimeException('Netzfahrplanberechtigung fehlt.'); + } + $snapshot = $this->GetNetzfahrplanV4Diagnose(); + $data = json_decode($snapshot, true, 512, JSON_THROW_ON_ERROR); + if (!isset($data['operation'])) { + throw new RuntimeException($data['reason'] ?? 'Betriebsdaten unvollstaendig.'); + } + // Decoding as objects preserves empty JSON dictionaries in the API contract. + $payload = json_decode($snapshot, false, 512, JSON_THROW_ON_ERROR); + if (!isset($payload->operation)) { + throw new RuntimeException('Keine konsistenten Betriebsdaten.'); + } + $id = $this->ReadAttributeString('LizenzInstallationID'); + $token = $this->ReadAttributeString('PrognoseInstallationsToken'); + if (!preg_match('/^[0-9a-f-]{36}$/i', $id) || $token === '') { + throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.'); + } + $url = 'https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4/operation'; + $handle = curl_init($url); + if ($handle === false) { + throw new RuntimeException('V4-Verbindung konnte nicht vorbereitet werden.'); + } + try { + curl_setopt_array($handle, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_POSTFIELDS => json_encode($payload->operation, JSON_THROW_ON_ERROR), + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $token]]); + $answer = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + if ($answer === false || $status !== 200) { + throw new RuntimeException('V4-Schattenanbindung HTTP ' . $status . '; bestehende Regelung unveraendert.'); + } + if (strlen($answer) > 65536) { + throw new RuntimeException('V4-Antwort ist zu gross.'); + } + $ack = json_decode($answer, true, 512, JSON_THROW_ON_ERROR); + if (!in_array($ack['status'] ?? '', ['stored', 'duplicate', 'archived_older'], true)) { + throw new RuntimeException('Unerwartete V4-Annahmebestaetigung.'); + } + } finally { + curl_close($handle); + } + $this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([ + 'status' => 'sent_shadow', 'capturedAt' => $payload->operation->observedAt, + 'acceptance' => $ack['status'], 'warnings' => $data['warnings'], 'liveEnabled' => false], JSON_THROW_ON_ERROR)); + } catch (Throwable $error) { + // Only this sender's diagnosis changes; never SetStatus, live plan or actuator commands. + $this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([ + 'status' => 'error', 'reason' => substr($error->getMessage(), 0, 300), 'liveEnabled' => false], JSON_THROW_ON_ERROR)); + } + } +} diff --git a/libs/NetzfahrplanV4Betriebsdaten.php b/libs/NetzfahrplanV4Betriebsdaten.php new file mode 100644 index 0000000..b3bf27e --- /dev/null +++ b/libs/NetzfahrplanV4Betriebsdaten.php @@ -0,0 +1,191 @@ + $max) { + throw new InvalidArgumentException($label . ': ungueltiger Zahlenwert.'); + } + return (float) $value; + } + + private static function timestamp($value): int + { + if (!is_string($value) || !preg_match('/(?:Z|[+-]\d{2}:\d{2})$/', $value)) { + throw new InvalidArgumentException('Messnachweis benoetigt einen Zeitpunkt mit Zeitzone.'); + } + return (new DateTimeImmutable($value))->getTimestamp(); + } + + /** $read returns ['value' => int|float, 'updated' => int], without changing a device. */ + private static function measurement(callable $read, int $id, int $now, int $age, string $label): array + { + if ($id <= 0) { + throw new InvalidArgumentException($label . ': Messquelle fehlt.'); + } + $m = $read($id); + if (!isset($m['updated']) || !is_int($m['updated']) || $m['updated'] > $now + || $now - $m['updated'] > $age) { + throw new InvalidArgumentException($label . ': Messwert fehlt, ist veraltet oder liegt in der Zukunft.'); + } + return ['value' => self::number($m['value'] ?? null, $label), 'updated' => $m['updated']]; + } + + /** No inference of paid peak or elapsed energy from power, reserve or forecast. */ + private static function evidence(array $input, int $now, int $meterID, array &$warnings): array + { + $peaks = []; + $past = null; + if ($input === []) { + $warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.'; + return [(object) [], null]; + } + if ($meterID <= 0 || ($input['version'] ?? null) !== 1 || ($input['meterId'] ?? null) !== 'symcon:' . $meterID) { + throw new InvalidArgumentException('Messnachweis: falsche Version oder Bezugszaehler-Zuordnung.'); + } + $measured = self::timestamp($input['measuredAt'] ?? null); + if ($measured > $now || $now - $measured > 120) { + throw new InvalidArgumentException('Messnachweis ist nicht aktuell.'); + } + $month = (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('Y-m'); + foreach (($input['measuredPeaks'] ?? []) as $key => $p) { + if (!is_string($key) || !preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $key) || $key > $month + || !in_array($p['source'] ?? '', ['meter_month_register', 'verified_month_history', 'verified_new_month'], true)) { + throw new InvalidArgumentException('Messnachweis: Monatspeak ist kein gueltiger Messnachweis.'); + } + $peaks[$key] = ['kw' => self::number($p['kw'] ?? null, 'Monatspeak', 0.0), 'source' => $p['source']]; + } + if (!array_key_exists($month, $peaks)) { + $warnings[] = 'Vollstaendiger Monatspeak fuer ' . $month . ' fehlt; Managergrenze ist kein Ersatz.'; + } + $q = intdiv($now, 900) * 900; + if (isset($input['quarterPast'])) { + $p = $input['quarterPast']; + // Stale quarter evidence remains absent, not extrapolated to the decision time. + if ($measured === $now && self::timestamp($p['start'] ?? null) === $q + && ($p['measuredSeconds'] ?? null) === $now - $q) { + $past = ['start' => gmdate('c', $q), + 'measuredSeconds' => $now - $q, + 'importKwh' => self::number($p['importKwh'] ?? null, 'Viertelstundenenergie', 0.0)]; + } else { + $warnings[] = 'Viertelstundenenergie passt nicht exakt zum Entscheidungszeitpunkt; nicht verwendet.'; + } + } + if ($now !== $q && $past === null) { + $warnings[] = 'Bisherige Energie der laufenden Viertelstunde fehlt; keine Hochrechnung als Messung.'; + } + return [(object) $peaks, $past]; + } + + /** Inputs are explicit non-secret configuration/cache fields, not full instance settings. */ + public static function erstellen(array $manager, array $assets, array $controllers, callable $read, array $evidence, int $now): array + { + $warnings = []; + $grid = self::measurement($read, (int) ($manager['NetzleistungVariableID'] ?? 0), $now, + (int) ($manager['MesswertMaxAlter'] ?? 60), 'Netzleistung'); + $gridW = $grid['value'] * self::number($manager['Netzleistungsfaktor'] ?? 1.0, 'Netzleistungsfaktor'); + $mode = $manager['Lastspitzenmodus'] ?? 0; + $import = null; + $months = []; + if ($mode === 1) { + $import = self::number($manager['Lastspitzengrenze'] ?? null, 'Managergrenze', 0.0); + } elseif ($mode === 2) { + foreach (($manager['Monatsgrenzen'] ?? []) as $row) { + $m = $row['MonatIndex'] ?? null; + if (!is_int($m) || $m < 1 || $m > 12 || isset($months[(string) $m])) { + throw new InvalidArgumentException('Monatsgrenzen fehlen oder sind doppelt.'); + } + $months[(string) $m] = self::number($row['Grenze_W'] ?? null, 'Monatsgrenze', 0.0); + } + if (count($months) !== 12) { + throw new InvalidArgumentException('Alle zwoelf Monatsgrenzen werden benoetigt.'); + } + } elseif ($mode !== 0) { + throw new InvalidArgumentException('Unbekannter Lastspitzenmodus.'); + } + $export = !empty($manager['EinspeisebegrenzungAktiv']) + ? self::number($manager['Einspeisegrenze'] ?? null, 'Einspeisegrenze', 0.0) : null; + $result = []; + $seen = []; + $used = []; + foreach ($assets as $asset) { + $id = $asset['ID'] ?? ''; + if (!is_string($id) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', $id) || isset($seen[$id])) { + throw new InvalidArgumentException('Batterie-ID fehlt oder ist doppelt.'); + } + $seen[$id] = true; + $socID = (int) ($asset['SOCVariableID'] ?? 0); + $matches = array_values(array_filter($controllers, static function (array $c) use ($socID): bool { + return $socID > 0 && ($c['LadezustandVariableID'] ?? 0) === $socID; + })); + if (count($matches) !== 1) { + throw new InvalidArgumentException($id . ': keine eindeutige aktive Batterieinstanz zur SOC-Quelle.'); + } + $c = $matches[0]; + $controllerID = $c['InstanzID']; + if (isset($used[$controllerID])) { + throw new InvalidArgumentException('Eine Batterieinstanz darf nicht doppelt bilanziert werden.'); + } + $used[$controllerID] = true; + if (($asset['LeistungVariableID'] ?? 0) !== ($c['IstleistungVariableID'] ?? 0)) { + throw new InvalidArgumentException($id . ': Topologie und Batterieinstanz verwenden verschiedene Leistungsmessungen.'); + } + $opts = $manager['BatterieOptionen'][$id] ?? []; + $nominal = self::number($asset['Nennkapazitaet_kWh'] ?? null, 'Nennkapazitaet', 0.001); + $usable = self::number($asset['Nutzkapazitaet_kWh'] ?? null, 'Nutzkapazitaet', 0.001, $nominal); + if (abs($nominal - $usable) > 1.0e-6 && !array_key_exists('SOCKapazitaet_kWh', $opts)) { + throw new InvalidArgumentException($id . ': SOC-Kapazitaetsbasis bei verschiedener Nenn-/Nutzkapazitaet bestaetigen.'); + } + $capacity = self::number($opts['SOCKapazitaet_kWh'] ?? $nominal, 'SOC-Kapazitaet', 0.001, $nominal); + $age = (int) ($c['MesswertMaxAlter'] ?? 30); + $soc = self::measurement($read, $socID, $now, $age, $id . ' SOC'); + $charge = self::measurement($read, (int) $c['MaxLadeleistungVariableID'], $now, $age, $id . ' max. Laden'); + $discharge = self::measurement($read, (int) $c['MaxEntladeleistungVariableID'], $now, $age, $id . ' max. Entladen'); + $min = max(self::number($c['MindestLadezustand'], 'BMS-Minimum', 0, 100), + self::number($c['ReserveLadezustand'], 'Betriebsreserve', 0, 100)); + $max = self::number($opts['MaxSOC_Prozent'] ?? 100.0, 'Maximal-SOC', $min, 100); + $socValue = self::number($soc['value'], 'SOC', 0, 100); + if ($socValue < $min || $socValue > $max) { + throw new InvalidArgumentException($id . ': SOC ausserhalb des Planungsbereichs; kein kuenstliches Anheben.'); + } + if (($c['EmpfangenAm'] ?? 0) > $now || $now - ($c['EmpfangenAm'] ?? 0) > (int) ($manager['VerbraucherTimeout'] ?? 60)) { + throw new InvalidArgumentException($id . ': Verbraucher-Rueckmeldung veraltet.'); + } + $maxCharge = min(self::number($charge['value'], 'Ladeleistung', 0), 1000 * self::number($asset['MaxLadeleistung_kW'], 'Nennladeleistung', 0)); + $maxDischarge = min(self::number($discharge['value'], 'Entladeleistung', 0), 1000 * self::number($asset['MaxEntladeleistung_kW'], 'Nennentladeleistung', 0)); + if (($c['Verfuegbar'] ?? false) !== true || ($c['Batteriemanagement'] ?? 0) !== 2) { + $maxCharge = $maxDischarge = 0.0; + $warnings[] = $id . ': nicht fuer Managerregelung verfuegbar; keine Batterieleistung eingeplant.'; + } + if (!empty($c['HystereseAktiv'])) { + $maxDischarge = 0.0; + $warnings[] = $id . ': aktuelle Entladesperre konservativ uebernommen; keine spaetere Freigabe erfunden.'; + } + $result[] = ['id' => $id, 'capacityKwh' => $capacity, 'socPercent' => $socValue, + 'minSocPercent' => $min, 'maxSocPercent' => $max, 'maxChargeW' => $maxCharge, + 'maxDischargeW' => $maxDischarge, 'measuredAt' => gmdate('c', min($soc['updated'], $charge['updated'], $discharge['updated'])), + 'gridCharging' => ($manager['NetzladenErlaubt'] ?? false) === true]; + } + if ($result === []) { + throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.'); + } + [$peaks, $past] = self::evidence($evidence, $now, (int) ($manager['NetzbezugEnergieVariableID'] ?? 0), $warnings); + $payload = ['version' => 1, 'observedAt' => gmdate('c', $now), 'gridW' => $gridW, + 'meteringBoundary' => 'common_pcc', 'batteries' => $result, + 'limits' => ['importW' => $import, 'exportW' => $export, 'managerMonthLimitsW' => (object) $months], + 'measuredPeaks' => $peaks, 'quarterPast' => $past]; + $payload['eventId'] = 'symcon-operation-' . hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); + return ['status' => $warnings === [] ? 'ready_shadow' : 'incomplete_shadow', 'warnings' => $warnings, 'operation' => $payload]; + } +} diff --git a/tests/NetzfahrplanV4BetriebsdatenTest.php b/tests/NetzfahrplanV4BetriebsdatenTest.php new file mode 100644 index 0000000..64f90a0 --- /dev/null +++ b/tests/NetzfahrplanV4BetriebsdatenTest.php @@ -0,0 +1,29 @@ +regeln(', '->SetStatus(', "WriteAttributeString('Netzfahrplan',"] as $forbidden) { + self::assertStringNotContainsString($forbidden, $source); + } + self::assertStringContainsString("RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false)", $source); + self::assertStringContainsString('/prognosis/planner-v4/operation', $source); + self::assertStringNotContainsString('/prognosis/schedule', $source); + } +} diff --git a/tests/fixtures/NativeV4Scenarios.php b/tests/fixtures/NativeV4Scenarios.php new file mode 100644 index 0000000..cc0ebff --- /dev/null +++ b/tests/fixtures/NativeV4Scenarios.php @@ -0,0 +1,124 @@ + 1, 'NetzbezugEnergieVariableID' => 9, + 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60, 'Lastspitzenmodus' => 1, + 'Lastspitzengrenze' => 25000.0, 'EinspeisebegrenzungAktiv' => true, 'Einspeisegrenze' => 25000.0]; + $a = [['ID' => 'ev', 'Nennkapazitaet_kWh' => 161.44, 'Nutzkapazitaet_kWh' => 161.44, + 'MaxLadeleistung_kW' => 39.0, 'MaxEntladeleistung_kW' => 30.0, 'SOCVariableID' => 2, 'LeistungVariableID' => 5]]; + $c = [['InstanzID' => 42, 'LadezustandVariableID' => 2, 'MaxLadeleistungVariableID' => 3, + 'MaxEntladeleistungVariableID' => 4, 'IstleistungVariableID' => 5, + 'ReserveLadezustand' => 15.0, 'MindestLadezustand' => 3.0, 'MesswertMaxAlter' => 30, + 'Verfuegbar' => true, 'Batteriemanagement' => 2, 'EmpfangenAm' => $now]]; + $read = static function (int $id) use ($now): array { + return ['value' => [1 => -5332.0, 2 => 19.0, 3 => 39000.0, 4 => 35000.0][$id], 'updated' => $now]; + }; + $check = static function (bool $ok): void { if (!$ok) { throw new RuntimeException('Assertion failed'); } }; + $fail = static function (callable $fn): void { + try { $fn(); } catch (InvalidArgumentException $e) { return; } + throw new RuntimeException('Expected invalid-input rejection'); + }; + $cases = []; + $build = static function ($mm = null, $aa = null, $cc = null, $r = null, $e = []) use ($m, $a, $c, $read, $now): array { + return NetzfahrplanV4Betriebsdaten::erstellen($mm ?? $m, $aa ?? $a, $cc ?? $c, $r ?? $read, $e, $now); + }; + $cases['native_mapping_and_asymmetric_limits'] = static function () use ($build, $check): void { + $x = $build()['operation']; $b = $x['batteries'][0]; + $check($x['gridW'] === -5332.0 && $b['capacityKwh'] === 161.44 && $b['minSocPercent'] === 15.0 + && $b['maxChargeW'] === 39000.0 && $b['maxDischargeW'] === 30000.0); + }; + $cases['manager_cap_not_paid_peak'] = static function () use ($build, $check): void { + $x = $build()['operation']; $check($x['limits']['importW'] === 25000.0 && (array) $x['measuredPeaks'] === [] && $x['quarterPast'] === null); + }; + $cases['empty_maps_are_objects'] = static function () use ($build, $check): void { + $x = json_decode(json_encode($build()['operation'])); $check(is_object($x->measuredPeaks) && is_object($x->limits->managerMonthLimitsW)); + }; + $cases['zero_limits_not_unlimited'] = static function () use ($build, $m, $check): void { + $m['Einspeisegrenze'] = 0.0; $m['Lastspitzengrenze'] = 0.0; $x = $build($m)['operation']; + $check($x['limits']['importW'] === 0.0 && $x['limits']['exportW'] === 0.0); + }; + $cases['disabled_limits_are_null'] = static function () use ($build, $m, $check): void { + $m['EinspeisebegrenzungAktiv'] = false; $m['Lastspitzenmodus'] = 0; $x = $build($m)['operation']; + $check($x['limits']['importW'] === null && $x['limits']['exportW'] === null); + }; + $cases['all_monthly_limits'] = static function () use ($build, $m, $check, $fail): void { + $m['Lastspitzenmodus'] = 2; $m['Monatsgrenzen'] = []; + for ($i = 1; $i <= 12; $i++) { $m['Monatsgrenzen'][] = ['MonatIndex' => $i, 'Grenze_W' => $i * 1000]; } + $x = $build($m)['operation']; $check($x['limits']['managerMonthLimitsW']->{'10'} === 10000.0); + array_pop($m['Monatsgrenzen']); $fail(static fn() => $build($m)); + }; + $cases['no_unconfirmed_soc_capacity'] = static function () use ($build, $a, $m, $fail, $check): void { + $a[0]['Nutzkapazitaet_kWh'] = 140.; $fail(static fn() => $build(null, $a)); + $m['BatterieOptionen'] = ['ev' => ['SOCKapazitaet_kWh' => 161.44]]; + $check($build($m, $a)['operation']['batteries'][0]['capacityKwh'] === 161.44); + }; + $cases['no_duplicate_controller'] = static function () use ($build, $a, $fail): void { + $a[] = array_replace($a[0], ['ID' => 'other']); $fail(static fn() => $build(null, $a)); + }; + $cases['no_ambiguous_mapping'] = static function () use ($build, $c, $fail): void { + $c[] = array_replace($c[0], ['InstanzID' => 43]); $fail(static fn() => $build(null, null, $c)); + }; + $cases['mismatched_power_source'] = static function () use ($build, $c, $fail): void { + $c[0]['IstleistungVariableID'] = 100; $fail(static fn() => $build(null, null, $c)); + }; + $cases['stale_measurements_fail'] = static function () use ($build, $read, $now, $fail): void { + $r = static function ($id) use ($read, $now) { $v = $read($id); $v['updated'] = $now - 61; return $v; }; + $fail(static fn() => $build(null, null, null, $r)); + }; + $cases['stale_consumer_cache_fails'] = static function () use ($build, $c, $now, $fail): void { + $c[0]['EmpfangenAm'] = $now - 61; $fail(static fn() => $build(null, null, $c)); + }; + $cases['soc_not_lifted_to_minimum'] = static function () use ($build, $read, $fail): void { + $r = static function ($id) use ($read) { $v = $read($id); if ($id === 2) { $v['value'] = 5.; } return $v; }; + $fail(static fn() => $build(null, null, null, $r)); + }; + $cases['unavailable_asset_no_power'] = static function () use ($build, $c, $check): void { + $c[0]['Verfuegbar'] = false; $x = $build(null, null, $c)['operation']['batteries'][0]; + $check($x['maxChargeW'] === 0.0 && $x['maxDischargeW'] === 0.0); + }; + $cases['hysteresis_not_silently_ignored'] = static function () use ($build, $c, $check): void { + $c[0]['HystereseAktiv'] = true; $x = $build(null, null, $c); + $check($x['operation']['batteries'][0]['maxDischargeW'] === 0.0 && count($x['warnings']) > 1); + }; + $cases['grid_charge_explicit_opt_in'] = static function () use ($build, $m, $check): void { + $check($build()['operation']['batteries'][0]['gridCharging'] === false); + $m['NetzladenErlaubt'] = true; $check($build($m)['operation']['batteries'][0]['gridCharging'] === true); + }; + $ev = ['version' => 1, 'meterId' => 'symcon:9', 'measuredAt' => gmdate('c', $now), + 'measuredPeaks' => ['2026-10' => ['kw' => 18.4, 'source' => 'meter_month_register']], + 'quarterPast' => ['start' => '2026-10-01T12:00:00Z', 'measuredSeconds' => 300, 'importKwh' => 0.5]]; + $cases['actual_meter_evidence_used'] = static function () use ($build, $ev, $check): void { + $x = $build(null, null, null, null, $ev); + $check($x['status'] === 'ready_shadow' && $x['operation']['measuredPeaks']->{'2026-10'}['kw'] === 18.4 && $x['operation']['quarterPast']['importKwh'] === 0.5); + }; + $cases['stale_quarter_not_extrapolated'] = static function () use ($build, $ev, $now, $check): void { + $ev['measuredAt'] = gmdate('c', $now - 1); $x = $build(null, null, null, null, $ev); + $check($x['operation']['quarterPast'] === null && count($x['warnings']) > 0); + }; + $cases['wrong_meter_evidence_fails'] = static function () use ($build, $ev, $fail): void { + $ev['meterId'] = 'symcon:100'; $fail(static fn() => $build(null, null, null, null, $ev)); + }; + $cases['planned_peak_not_evidence'] = static function () use ($build, $ev, $fail): void { + $ev['measuredPeaks']['2026-10']['source'] = 'manager_cap'; $fail(static fn() => $build(null, null, null, null, $ev)); + }; + $cases['deterministic_retry_event_id'] = static function () use ($build, $check): void { + $check($build()['operation']['eventId'] === $build()['operation']['eventId']); + }; + $passed = []; + foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; } + return $passed; + } +}