From 9394fe3eb1435ec2ef444700a647fe562eb1a07e Mon Sep 17 00:00:00 2001 From: ENELIX Agent Date: Fri, 2 Oct 2026 14:01:41 +0000 Subject: [PATCH] feat(manager): add gated short-lease V4 control trial and local fallback --- Batterie/module.php | 48 +++++- Manager/module.php | 30 ++++ docs/netplan-v4-controlled-trial.md | 96 +++++++++++ libs/BatterieNetzfahrplanV4TestTrait.php | 186 +++++++++++++++++++++ libs/ManagerNetzfahrplanV4EmpfangTrait.php | 2 +- libs/ManagerNetzfahrplanV4TestTrait.php | 177 ++++++++++++++++++++ libs/NetzfahrplanV4Regeltest.php | 168 +++++++++++++++++++ tests/NetzfahrplanV4RegeltestTest.php | 24 +++ tests/V4ControlTrial/additional_checks.php | 30 ++++ tests/V4ControlTrial/checks.php | 161 ++++++++++++++++++ 10 files changed, 918 insertions(+), 4 deletions(-) create mode 100644 docs/netplan-v4-controlled-trial.md create mode 100644 libs/BatterieNetzfahrplanV4TestTrait.php create mode 100644 libs/ManagerNetzfahrplanV4TestTrait.php create mode 100644 libs/NetzfahrplanV4Regeltest.php create mode 100644 tests/NetzfahrplanV4RegeltestTest.php create mode 100644 tests/V4ControlTrial/additional_checks.php create mode 100644 tests/V4ControlTrial/checks.php diff --git a/Batterie/module.php b/Batterie/module.php index 97c7a90..8510535 100644 --- a/Batterie/module.php +++ b/Batterie/module.php @@ -6,6 +6,7 @@ require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php'; require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php'; require_once __DIR__ . '/../libs/Nachrichtenvertrag.php'; require_once __DIR__ . '/../libs/BatterieRegler.php'; +require_once __DIR__ . '/../libs/BatterieNetzfahrplanV4TestTrait.php'; use Belevo\EnelixEMS\BatterieRegler; use Belevo\EnelixEMS\Nachrichtenvertrag; @@ -15,6 +16,7 @@ use Belevo\EnelixEMS\VerbraucherSchnittstelle; class Batterie extends IPSModule implements VerbraucherSchnittstelle { use VerbraucherBasisTrait; + use \Belevo\EnelixEMS\BatterieNetzfahrplanV4TestTrait; private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}'; private const STATUS_AKTIV = 102; @@ -68,6 +70,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle parent::Create(); $this->registriereVerbraucherBasis(); + $this->registriereV4BatterieTest(); $this->RegisterPropertyInteger('Batterietyp', BatterieRegler::TYP_UNKONFIGURIERT); $this->RegisterPropertyInteger( 'Batteriemanagement', @@ -152,6 +155,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle { parent::ApplyChanges(); + $this->V4BatterieTestStoppen(); $this->aktualisiereVariablen(); $this->registriereMesswertmeldungen(); $this->WriteAttributeBoolean('RegisterInitialisiert', false); @@ -248,7 +252,22 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle $this->aktualisiere(true); return; + case 'NetzfahrplanV4TestStart': + if (!is_string($wert)) throw new InvalidArgumentException('JSON-Testanmeldung erforderlich.'); + $this->V4BatterieTestStarten($wert); + return; + case 'NetzfahrplanV4TestCommand': + if (!is_string($wert)) throw new InvalidArgumentException('JSON-Testbefehl erforderlich.'); + $this->V4BatterieTestBefehl($wert); + return; + case 'NetzfahrplanV4TestStop': + $this->V4BatterieTestStoppen(); + return; + case 'NetzfahrplanV4TestWatchdog': + $this->aktualisiere(true); + return; case 'SichererZustand': + $this->V4BatterieTestStoppen(); $this->setzeZustand('SollwertGueltig', false); $this->schreibeRegister(0, true); $this->aktualisiere(true); @@ -287,6 +306,14 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle /** @param array $daten */ public function ManagerdatenEmpfangen(array $daten): void + { + $lock = 'ENELIX.V4.Battery.' . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterieannahme beschaeftigt.'); + try { $this->ManagerdatenEmpfangenIntern($daten); } + finally { IPS_SemaphoreLeave($lock); } + } + + private function ManagerdatenEmpfangenIntern(array $daten): void { Nachrichtenvertrag::pruefeManagerdaten($daten); if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { @@ -302,8 +329,13 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle ); } + if ($this->v4BatterieTestAktiv()) { + // Ordinary manager messages cannot overwrite or renew the independent short test lease. + $this->planeMeldung(); + return; + } $this->WriteAttributeString('Betriebsart', $daten['Betriebsart']); - $this->aktualisiere(false); + $this->aktualisiereIntern(false); $sollleistung = $daten['Sollleistung_W']; if ($sollleistung === null) { @@ -314,7 +346,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle ) ) { $this->setzeZustand('SollwertGueltig', false); - $this->aktualisiere(false); + $this->aktualisiereIntern(false); } $this->planeMeldung(); return; @@ -341,11 +373,19 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle $this->setzeZustand('Sollleistung', $sollleistung); $this->setzeZustand('SollwertGueltig', true); $this->planeVorgabeTimeout(time()); - $this->aktualisiere(false); + $this->aktualisiereIntern(false); $this->planeMeldung(); } private function aktualisiere(bool $meldungPlanen): void + { + $lock = 'ENELIX.V4.Battery.' . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 0)) return; + try { $this->aktualisiereIntern($meldungPlanen); } + finally { IPS_SemaphoreLeave($lock); } + } + + private function aktualisiereIntern(bool $meldungPlanen): void { try { $jetzt = time(); @@ -426,6 +466,8 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle $ziel = $verfuegbar && (bool) $this->leseZustand('SollwertGueltig') ? (int) $this->leseZustand('Sollleistung') : 0; + $v4Ziel = $this->v4BatterieTestZiel($messwerte, $verfuegbar, $jetzt); + if ($v4Ziel !== null) $ziel = $v4Ziel; $this->schreibeRegister($ziel, false); $this->setzeZustand('Sollleistung', $ziel); $this->setzeZustand('Verfuegbar', $verfuegbar); diff --git a/Manager/module.php b/Manager/module.php index f4b2158..b2b75ac 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -13,6 +13,7 @@ require_once __DIR__ . '/../libs/EnergieMessung.php'; require_once __DIR__ . '/../libs/ManagerEnergieTrait.php'; require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php'; require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php'; +require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4TestTrait.php'; use Belevo\EnelixEMS\Anlagentopologie; use Belevo\EnelixEMS\EinspeiseRegler; @@ -28,6 +29,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle { use ManagerEnergieTrait; use \Belevo\EnelixEMS\ManagerNetzfahrplanV4Trait; + use \Belevo\EnelixEMS\ManagerNetzfahrplanV4TestTrait; private const STATUS_AKTIV = 102; private const STATUS_MESSWERT_UNGUELTIG = 201; @@ -116,6 +118,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle { parent::Create(); $this->registriereNetzfahrplanV4(); + $this->registriereV4ManagerTest(); $this->RegisterPropertyInteger('Rolle', 0); $this->RegisterPropertyInteger('NetzleistungVariableID', 0); @@ -236,6 +239,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle public function ApplyChanges(): void { parent::ApplyChanges(); + $this->v4ManagerTestAbbrechen('Manager neu initialisiert'); $this->SetTimerInterval('NetzfahrplanV4Senden', 0); $this->stoppeNetzfahrplanV4Vorschau(); @@ -468,6 +472,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->aktualisiereNetzfahrplanV4Vorschau(); return; + case 'NetzfahrplanV4TestStart': + if (!is_string($wert)) throw new InvalidArgumentException('Explizite JSON-Testfreigabe erforderlich.'); + $this->V4ManagerTestStarten($wert); + return; + case 'NetzfahrplanV4TestStop': + $this->V4ManagerTestStoppen(); + return; + case 'NetzfahrplanV4Senden': $this->sendeNetzfahrplanV4(); return; @@ -703,6 +715,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle } private function regeln(bool $erneuern): void + { + $lock = 'ENELIX.V4.Control.' . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 0)) return; + try { + $this->regelnIntern($erneuern); + if ($this->v4ManagerTestSession() + && (!$this->GetValue('Aktiv') || IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== self::STATUS_AKTIV)) { + $this->v4ManagerTestAbbrechen('Manager nicht mehr betriebsbereit'); + } + } catch (Throwable $e) { + $this->v4ManagerTestAbbrechen('Regelungsfehler'); + throw $e; + } finally { IPS_SemaphoreLeave($lock); } + } + + private function regelnIntern(bool $erneuern): void { if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) { $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); @@ -807,6 +835,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle : $this->nichtGeregelteGeraeteText($nichtGeregelt) ); + $this->v4ManagerTestUebergabe($ergebnis, $verbraucher, $synchronisiert); $letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte'); foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) { if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) { @@ -816,6 +845,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung); $letzteSollwerte[(string) $instanzID] = $sollleistung; } catch (Throwable $fehler) { + $this->v4ManagerTestAbbrechen('Andere Verbraucher-Stellwertuebergabe fehlgeschlagen'); $stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage(); } } diff --git a/docs/netplan-v4-controlled-trial.md b/docs/netplan-v4-controlled-trial.md new file mode 100644 index 0000000..b9b0936 --- /dev/null +++ b/docs/netplan-v4-controlled-trial.md @@ -0,0 +1,96 @@ +# V4: begrenzter Regeltest (Entwicklungskandidat, NICHT in Betrieb) + +Stand 2026-10-02. Diese Erweiterung ist weder Produktionsfreigabe noch ein +Installations-/Startauftrag. Bestehende Schattenplaene und ihre `shadow_seen` +Bestaetigungen bleiben unveraendert. Ein Vorschauwert allein darf niemals +als Stellfreigabe interpretiert werden. + +## Freigabekette + +1. Serverseitige separate Allowlist `NETPLAN_V4_CONTROL_TRIAL_PLANTS` (standardmaessig + leer). Interner authentifizierter `planner/trial/arm`-Aufruf mit expliziter + Bestaetigung, neuer Sitzung, gepruefter Plan-ID/Revision, Batteriezuordnung, + Zeitfenster, Leistungsgrenzen und dokumentierter Geraete-Watchdog-Abnahme. + Die bestehenden oeffentlichen Portal-/Geraeterouten proxien diesen Aufruf NICHT. +2. `NetzfahrplanV4RegeltestErlaubt` lokal im Manager UND im Batteriemodul (jeweils + Standard false). Alte `NetzfahrplanAktiv`-Freigabe muss false bleiben. +3. Zusaetzlicher bewusster lokaler Start einer bestimmten Sitzung. Ein Reload, + Netzwiederkehr oder gespeicherter Haken startet keine Sitzung automatisch. +4. Der Manager prueft den empfangenen Schattenplan und die getrennte + `controlled_trial_authority` gemeinsam. Sie sind gebunden an Anlage, Batterie, + beide Instanz-IDs, Revision, manuelle Modellfamilie und stabilen Planungskontext. +5. Die Batterie nimmt nur einen separaten `controlled_trial_command` mit eigener + Sequenz und kurzer Gueltigkeit an. Normale Managerbefehle erneuern diese + Gueltigkeit nicht. `shadow_seen` bleibt eine Empfangs-, keine Ausfuehrungsmeldung. + +Die Serverantwort bleibt eine Schattenplan-Antwort (`liveEnabled:false`). Eine +getrennte Testfreigabe ist unter `controlledTrial` und +`controlledTrialAuthorized` sichtbar. Der Schattenmodus allein ist bei kuenftig +bewusst gestartetem Test deshalb kein Nachweis fuer 'keine Batteriebewegung'. +Die Controller-Diagnosen benennen eine angenommene Sollvorgabe, nicht bereits +physisch gemessene Umsetzung oder Ersparnis. + +## Bewusst begrenzter Pilotumfang + +- Genau eine eindeutig zugeordnete Batterie je Test; Familie manuell festgelegt. +- 30 bis 1800 Sekunden Sitzung, maximal 5000 W Laden und 5000 W Entladen. +- Server-, Manager- und Batteriegrenze koennen strenger sein; nie grosszuegig + ersetzen oder still auf 39 kW hochsetzen. +- Gesamtsollleistung, NICHT Zusatzleistung zur bereits fliessenden Batterieleistung. +- Andere Verbraucher bleiben im alten Verteiler. Ihre erwartete Leistungsaenderung + wird einmal bei der Batteriekorrektur beruecksichtigt. +- Batterie wird nur einem Stellwertpfad zugeteilt, nicht gleichzeitig dem alten + und neuen Verteiler. Ist der Test ungueltig, stoppt er und es wird eine NEUE + normale Verteilung angefordert; kein alter gespeicherter Sollwert wird restauriert. +- Physische Grenzverletzung, die innerhalb der Pilotleistung nicht behebbar ist, + beendet den Test zugunsten der bestehenden lokalen Regelung. + +## Ausfall- und Zeitverhalten + +- Serverfreigabe maximal 90 Sekunden, lokal regelmaessig frisch abgerufen. + Serverseitiger Widerruf wirkt nicht magisch sofort: Cachefrist beachten. +- Einzelner Batteriebefehl maximal 10 Sekunden; Batterie kontrolliert im laufenden + Kernel mit 1-Sekunden-Timer zusaetzlich zu Messwert-Callbacks. +- Manager liefert kurze Befehle im Test mit zusaetzlichem 2-Sekunden-Takt. +- Monotone Laufzeit und Kalenderzeit werden getrennt geprueft. Eine rueckwaerts + springende Systemuhr darf eine Sitzung nicht verlaengern. +- Abbruch widerruft die Sitzung VOR dem Nullstellversuch. Schreibfehler werden als + `stop_failed`/nicht bestaetigter Stopp sichtbar, nicht als sicherer Zustand. +- Reserve, Hysterese, aktuelle Lade-/Entladeleistung, SOC und Verfuegbarkeit werden + auch im Batteriemodul erneut geprueft. Sicherheitsreduktionen werden nicht durch + die Umschaltsperre verzoegert; Richtungswechsel laufen ueber null. +- Laufzeitsitzungen liegen nur im Buffer, nicht als automatische Wiederanlauf-Freigabe. + Ein persistenter Marker sorgt bei Wiederinitialisierung fuer einen erneuten + Nullstellversuch, falls ein Testwert zuvor ausgegeben worden sein koennte. + +WICHTIGE GRENZE: PHP-Timer und Software-Nullstellversuche funktionieren nur bei +laufendem Kernel und erreichbarem Wechselrichter. Sie sind KEIN physischer +Not-Aus und kein Beweis fuer Abschaltung bei Serverabsturz, Stromausfall oder +unterbrochener Stellwertverbindung. Dafuer ist ein separat nachgewiesener +Befehlsausfall-/Watchdogmechanismus am Geraet bzw. Gateway erforderlich. +Das Feld `NetzfahrplanV4GeraeteWatchdogNachweis` ist eine Referenz auf die gepruefte +Dokumentation/Abnahme, keine automatische Hardwarepruefung und kein Defaultwert. + +## Noch offene Freigabepunkte (nicht mit Platzhaltern umgehen) + +- Der laufende Lihrenmoos-Prognosestrom ist noch `house_total`. Fuer Testfreigabe + muss `base_load` mit nachgewiesener Quelle/SDL-Abgrenzung und + `accountingEvidenceId` aus dem geprueften Adapter vorliegen. Ein Labelwechsel + oder ein erfundener Nachweis reicht nicht; Quelle und Messbilanz pruefen. +- Geraete-/Gateway-Watchdog bei ausbleibenden Stellbefehlen nachweisen. +- Vollstaendiges Manager-/Batterie-Callbackverhalten im IP-Symcon-Kernel pruefen. + Die isolierten Tests verwenden echte neue Traits, aber simulierte IPS-Aufrufe, + Messwerte, Zeit und Register. Reale Modbus-/VGT-Stellreaktion steht aus. +- Zielcontainerpruefung des neuesten Serverstands und Feldabnahme. +- Keine durchgehende unbeaufsichtigte Produktion, kein automatischer Mehranlagenstart. + Modellvergleich, Trainingsanbindung, Mehranlagenabnahme und Betriebs-Release + bleiben weitere Aufgaben. + +## Quelltests + +`php tests/V4ControlTrial/checks.php` nur im separaten CLI-Testprozess ausfuehren, +niemals im laufenden Symcon-Skripteditor. Die Datei verweigert einen realen +Symcon-Kernel. Eine PHPUnit-Huelle bindet sie an die normale Testsuite an. + +Der Server-Test verwendet ausschliesslich synthetische Daten/Temporaerdatenbanken. +Keine Testfunktion liest Zugangsdaten oder schreibt eine reale Stellvariable. diff --git a/libs/BatterieNetzfahrplanV4TestTrait.php b/libs/BatterieNetzfahrplanV4TestTrait.php new file mode 100644 index 0000000..56a20d2 --- /dev/null +++ b/libs/BatterieNetzfahrplanV4TestTrait.php @@ -0,0 +1,186 @@ +RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false); + $this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', ''); + $this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', ''); + $this->RegisterAttributeString('NetzfahrplanV4TestStatus', '{"status":"disabled"}'); + $this->RegisterTimer('NetzfahrplanV4TestWatchdog', 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4TestWatchdog', 0);"); + } + + private function v4BatterieTestSitzung(): array + { + $s = json_decode($this->GetBuffer('V4BatteryTrialSession') ?: '{}', true); + return is_array($s) ? $s : []; + } + + private function v4BatterieTestAktiv(): bool + { + return ($this->v4BatterieTestSitzung()['active'] ?? false) === true; + } + + /** Local test arming by the bound manager; never called by ordinary forecast traffic. */ + public function V4BatterieTestStarten(string $json): void + { + $lock = 'ENELIX.V4.Battery.' . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.'); + try { $this->v4BatterieStartIntern($json); } + finally { IPS_SemaphoreLeave($lock); } + } + + private function v4BatterieStartIntern(string $json): void + { + if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Batterietest lokal nicht erlaubt.'); + $s = json_decode($json, true, 32, JSON_THROW_ON_ERROR);$now = time(); + if (!is_array($s) || ($s['kind'] ?? null) !== 'local_control_trial_consent' + || ($s['batteryInstanceId'] ?? null) !== $this->InstanceID || $this->v4BatterieTestAktiv()) { + throw new RuntimeException('Batterie-Testanmeldung ungueltig oder bereits aktiv.'); + } + NetzfahrplanV4Regeltest::uuid($s['sessionId'] ?? null); + if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $s['sessionId']) throw new RuntimeException('Testsitzung bereits beendet/verwendet.'); + NetzfahrplanV4Regeltest::uuid($s['installationId'] ?? null); + $manager = NetzfahrplanV4Regeltest::integer($s['managerId'] ?? null, 'Manager', 1, 99999); + if (!in_array($manager, $this->zugeordneteManagerIDs(), true) + || IPS_GetProperty($manager, 'NetzfahrplanV4RegeltestErlaubt') !== true) { + throw new RuntimeException('Manager nicht fuer Batterietest freigegeben/zugeordnet.'); + } + $evidence = $this->ReadPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis'); + if (strlen($evidence) < 8 || $evidence !== ($s['actuatorWatchdogEvidenceId'] ?? null)) { + throw new RuntimeException('Externer Geraete-Watchdog muss separat nachgewiesen sein.'); + } + $end = NetzfahrplanV4Regeltest::integer($s['expiresAt'] ?? null, 'Testende', $now + 1, $now + 1800); + $s['maxChargeW'] = NetzfahrplanV4Regeltest::number($s['maxChargeW'] ?? null, 'Testladen', 1, 5000); + $s['maxDischargeW'] = NetzfahrplanV4Regeltest::number($s['maxDischargeW'] ?? null, 'Testentladen', 1, 5000); + if (!is_string($s['assetId'] ?? null) || $s['assetId'] === '') throw new RuntimeException('Batterie-ID fehlt.'); + if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) { + throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.'); + } + $this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']); + $s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now; + $s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now; + $s['commandMonotonicDeadline'] = hrtime(true) / 1e9 + 10; + $this->SetBuffer('V4BatteryTrialCommand', '{}'); + $this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR)); + // Old ordinary values may not resume after the test without a NEW manager message. + $this->setzeZustand('SollwertGueltig', false); + $this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 1000); + $this->WriteAttributeString('NetzfahrplanV4TestStatus', '{"status":"armed_waiting_command","physicalResponseVerified":false}'); + } + + public function V4BatterieTestBefehl(string $json): void + { + $lock = 'ENELIX.V4.Battery.' . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.'); + try { $this->v4BatterieBefehlIntern($json); } + finally { IPS_SemaphoreLeave($lock); } + } + + private function v4BatterieBefehlIntern(string $json): void + { + $s = $this->v4BatterieTestSitzung();$now = time();$mono = hrtime(true) / 1e9; + try { + if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Batterietest gesperrt.'); + $c = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + NetzfahrplanV4Regeltest::pruefeBatteriebefehl($c, $s, $now, $mono); + $s['lastSequence'] = $c['sequence'];$s['lastWallClockAt'] = $now; + $s['commandMonotonicDeadline'] = $mono + min(10, $c['expiresAt'] - $now); + $this->SetBuffer('V4BatteryTrialCommand', json_encode($c, JSON_THROW_ON_ERROR)); + $this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR)); + $this->aktualisiereIntern(true); + if (!$this->v4BatterieTestAktiv() || $this->ReadAttributeString('Registerfehler') !== '') { + throw new RuntimeException('Batterie konnte Testbefehl nicht sicher uebernehmen.'); + } + $this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode([ + 'status' => 'controller_command_accepted', 'sessionId' => $s['sessionId'], + 'sourceShadowPlanId' => $c['sourceShadowPlanId'], 'sequence' => $c['sequence'], + 'commandExpiresAt' => $c['expiresAt'], 'actualRequestedW' => $this->ReadAttributeInteger('LetzterSollwert'), + 'physicalResponseVerified' => false], JSON_THROW_ON_ERROR)); + } catch (Throwable $e) { + $this->beendeV4BatterieTest('Befehl abgelehnt', true); + throw $e; + } + } + + /** Explicit local abort or ApplyChanges: revoke before any register action. */ + private function beendeV4BatterieTest(string $reason, bool $writeNow): void + { + $oldStatus = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true); + $active = $this->v4BatterieTestAktiv() || in_array($oldStatus['status'] ?? '', ['controller_command_accepted', 'armed_waiting_command', 'stop_pending', 'stop_failed'], true); + $this->SetBuffer('V4BatteryTrialSession', '{}');$this->SetBuffer('V4BatteryTrialCommand', '{}'); + $this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 0); + if (!$active) return; + $this->setzeZustand('SollwertGueltig', false);$this->setzeZustand('Sollleistung', 0); + $report = ['status' => 'stop_pending', 'reason' => $reason, 'physicalResponseVerified' => false]; + $this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR)); + if ($writeNow) { + try { $this->schreibeRegister(0, true);$report['status'] = 'stopped'; } + catch (Throwable $e) { + $report['status'] = 'stop_failed'; + $this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR)); + throw $e; + } + $this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR)); + } + } + + public function V4BatterieTestStoppen(): void + { + $lock = 'ENELIX.V4.Battery.' . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.'); + try { $this->beendeV4BatterieTest('Lokaler Testabbruch', true); } + finally { IPS_SemaphoreLeave($lock); } + } + + public function GetV4BatterieTestStatus(): string + { + return $this->ReadAttributeString('NetzfahrplanV4TestStatus'); + } + + /** Called inside the existing battery update, immediately before writing registers. */ + private function v4BatterieTestZiel(array $measurements, bool $available, int $now): ?int + { + if (!$this->v4BatterieTestAktiv()) return null; + $s = $this->v4BatterieTestSitzung();$mono = hrtime(true) / 1e9; + try { + if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Lokale Testfreigabe entzogen.'); + $c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true); + if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.'); + if (!$c) { + if ($mono >= $s['commandMonotonicDeadline']) throw new RuntimeException('Kein Startbefehl innerhalb Watchdog.'); + return 0; + } + $w = NetzfahrplanV4Regeltest::batterieZiel($c, $s, $measurements, [ + 'available' => $available, 'reserve' => $this->ReadPropertyFloat('ReserveLadezustand'), + 'minimum' => $this->ReadPropertyFloat('MindestLadezustand'), + 'blocked' => (bool) $this->leseZustand('HystereseAktiv'), + 'rechargeLimitW' => $this->ReadPropertyBoolean('NachladenMitMaximalleistung') ? null : $this->ReadPropertyInteger('MaximaleNachladeleistung'), + ], $now, $mono); + // Respect switching lock for increases/reversals; safety reductions and zero are immediate. + $last = $this->ReadAttributeInteger('LetzterSollwert'); + if ($now < $this->ReadAttributeInteger('AenderungFreigabeAb') && $w !== $last) { + if ($last * $w < 0) $w = 0; + elseif (abs($w) > abs($last)) $w = $last; + } + $s['lastWallClockAt'] = $now; + $this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR)); + return $w; + } catch (Throwable $e) { + $this->beendeV4BatterieTest($e->getMessage(), false); + return 0; // revoke stale value even if ordinary cached setpoint existed + } + } +} diff --git a/libs/ManagerNetzfahrplanV4EmpfangTrait.php b/libs/ManagerNetzfahrplanV4EmpfangTrait.php index 569a7ad..0761567 100644 --- a/libs/ManagerNetzfahrplanV4EmpfangTrait.php +++ b/libs/ManagerNetzfahrplanV4EmpfangTrait.php @@ -84,7 +84,7 @@ trait ManagerNetzfahrplanV4EmpfangTrait $preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time()); // Only the known, non-secret protocol envelope is cached. $keys = ['receiverProtocolVersion', 'installationId', 'checkedAt', 'liveEnabled', 'fresh', - 'pending', 'settings', 'plan', 'families', 'lastRun']; + 'pending', 'settings', 'plan', 'families', 'lastRun', 'controlledTrial']; $safe = array_intersect_key($response, array_flip($keys)); $this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode( ['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR)); diff --git a/libs/ManagerNetzfahrplanV4TestTrait.php b/libs/ManagerNetzfahrplanV4TestTrait.php new file mode 100644 index 0000000..7a052e4 --- /dev/null +++ b/libs/ManagerNetzfahrplanV4TestTrait.php @@ -0,0 +1,177 @@ +RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false); + $this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', ''); + $this->RegisterAttributeString('NetzfahrplanV4RegeltestStatus', '{"status":"disabled"}'); + $this->RegisterTimer('NetzfahrplanV4TestTick', 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);"); + } + + private function v4ManagerTestSession(): array + { + $value = json_decode($this->GetBuffer('V4ManagerTrialSession') ?: '{}', true); + return is_array($value) ? $value : []; + } + + private function v4ManagerTestBedingungen(): void + { + if (IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== 102 + || !$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt') + || !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv') + || !$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') + || $this->ReadPropertyBoolean('NetzfahrplanAktiv') || !$this->GetValue('Aktiv') + || !$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) { + throw new RuntimeException('Keine lokale Testberechtigung oder konkurrierender Fahrplan.'); + } + } + + /** Explicit kernel-local user action only. No network request can call this via the portal. */ + public function V4ManagerTestStarten(string $json): void + { + $lock = 'ENELIX.V4.Control.' . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt.'); + $armedBattery = null; + try { + $this->v4ManagerTestBedingungen(); + if ($this->v4ManagerTestSession()) throw new RuntimeException('Testsitzung bereits aktiv; nicht verlaengern.'); + $request = json_decode($json, true, 16, JSON_THROW_ON_ERROR); + if (!is_array($request) || ($request['confirmation'] ?? null) !== 'START_BOUNDED_V4_CONTROL_TRIAL') { + throw new RuntimeException('Ausdrueckliche lokale Testbestaetigung erforderlich.'); + } + $session = NetzfahrplanV4Regeltest::uuid($request['sessionId'] ?? null); + if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $session) throw new RuntimeException('Testsitzung bereits benutzt; neue Freigabe erforderlich.'); + $envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR); + $a = $envelope['response']['controlledTrial'] ?? null; + if (!is_array($a) || ($a['sessionId'] ?? null) !== $session) throw new RuntimeException('Keine passende separate Serverfreigabe empfangen.'); + [$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte(); + $batteryID = (int) ($a['batteryInstanceId'] ?? 0); + if (!in_array($batteryID, $this->aktiveVerbraucherIDs(), true) + || !IPS_InstanceExists($batteryID) + || IPS_GetInstance($batteryID)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') { + throw new RuntimeException('Testbatterie ist keine aktive zugeordnete Batterieinstanz.'); + } + $assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 32, JSON_THROW_ON_ERROR); + if (count($assets) !== 1 || $assets[0]['ID'] !== ($a['assetId'] ?? null) + || $assets[0]['SOCVariableID'] !== IPS_GetProperty($batteryID, 'LadezustandVariableID') + || $assets[0]['LeistungVariableID'] !== IPS_GetProperty($batteryID, 'IstleistungVariableID')) { + throw new RuntimeException('Physische Batteriezuordnung ist nicht eindeutig.'); + } + $now = time();$until = min(NetzfahrplanV4Planpruefung::zeit($a['expiresAt']), + $now + NetzfahrplanV4Regeltest::integer($request['durationSeconds'] ?? null, 'Lokale Testdauer', 30, 1800)); + $s = ['kind' => 'local_control_trial_consent', 'enabled' => true, 'legacyEnabled' => false, + 'sessionId' => $session, 'installationId' => $this->ReadAttributeString('LizenzInstallationID'), + 'managerId' => $this->InstanceID, 'batteryInstanceId' => $batteryID, 'assetId' => $a['assetId'], + 'expiresAt' => $until, 'maxChargeW' => NetzfahrplanV4Regeltest::number($request['maxChargeW'] ?? null, 'Testladegrenze', 1, 5000), + 'maxDischargeW' => NetzfahrplanV4Regeltest::number($request['maxDischargeW'] ?? null, 'Testentladegrenze', 1, 5000), + 'actuatorWatchdogEvidenceId' => IPS_GetProperty($batteryID, 'NetzfahrplanV4GeraeteWatchdogNachweis'), + 'sequence' => 1, 'lastWallClockAt' => $now, + 'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now]; + // Validate everything BEFORE arming either module. This rejects all normal shadow replies. + NetzfahrplanV4Regeltest::befehl($envelope['response'], $op, $actual, $s, $now, $envelope['receivedAt']); + $this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $session); + IPS_RequestAction($batteryID, 'NetzfahrplanV4TestStart', json_encode($s, JSON_THROW_ON_ERROR)); + $armedBattery = $batteryID; + $this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR)); + $this->SetTimerInterval('NetzfahrplanV4TestTick', 2000); + $this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([ + 'status' => 'armed_local_trial', 'sessionId' => $session, 'expiresAt' => $until, + 'physicalResponseVerified' => false], JSON_THROW_ON_ERROR)); + } catch (Throwable $e) { + $this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0); + if ($armedBattery !== null) IPS_RequestAction($armedBattery, 'NetzfahrplanV4TestStop', 0); + throw $e; + } finally { IPS_SemaphoreLeave($lock); } + } + + /** Revocation precedes I/O; if the device call fails its own ten-second lease still expires. */ + private function v4ManagerTestAbbrechen(string $reason): void + { + $s = $this->v4ManagerTestSession(); + $this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0); + if (!$s) return; + $result = ['status' => 'stopped_fallback_pending', 'reason' => $reason, + 'sessionId' => $s['sessionId'], 'physicalResponseVerified' => false]; + // Reissue the next ordinary target even if numerically equal to a previously cached value. + $this->WriteAttributeString('LetzteSollwerte', '{}'); + try { + IPS_RequestAction($s['batteryInstanceId'], 'NetzfahrplanV4TestStop', 0); + $result['controllerStopAccepted'] = true; + } catch (Throwable $e) { + $result['controllerStopAccepted'] = false;$result['reason'] .= '; Geraete-Abbruch fehlgeschlagen, Watchdog pruefen.'; + } + $this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode($result, JSON_THROW_ON_ERROR)); + } + + public function V4ManagerTestStoppen(): void + { + $lock = 'ENELIX.V4.Control.' . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt; Stop erneut anfordern.'); + try { $this->v4ManagerTestAbbrechen('Lokaler Testabbruch'); } + finally { IPS_SemaphoreLeave($lock); } + $this->regeln(true); // fresh ordinary command; never restore a cached pre-trial target + } + + public function GetV4ManagerTestStatus(): string + { + return $this->ReadAttributeString('NetzfahrplanV4RegeltestStatus'); + } + + /** Called under the existing regulator's serialization, after allocation but before writes. */ + private function v4ManagerTestUebergabe(array &$allocation, array $consumers, bool $synchronized): void + { + $s = $this->v4ManagerTestSession(); + if (!$s) return; + try { + $this->v4ManagerTestBedingungen();$now = time(); + if (!$synchronized || $now >= $s['expiresAt'] || hrtime(true) / 1e9 >= $s['monotonicDeadline']) { + throw new RuntimeException('Testende oder Verbraucher nicht synchronisiert.'); + } + [$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte(); + $envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR); + // Other consumers stay under the existing controller. Compensate their predicted + // change once; no double-counting of the battery's already flowing actual power. + $otherDelta = 0.0;$found = false; + foreach ($consumers as $consumer) { + $id = $consumer['InstanzID']; + if ($id === $s['batteryInstanceId']) { $found = true;continue; } + if (!array_key_exists($id, $allocation['Sollwerte'])) continue; + $d = $consumer['Daten']; + if (($d['Leistungsquelle'] ?? null) !== Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) throw new RuntimeException('Andere Verbraucherleistung nicht gemessen.'); + $otherDelta += NetzfahrplanV4Regeltest::number($allocation['Sollwerte'][$id], 'Anderer Sollwert', -1e9, 1e9) + - NetzfahrplanV4Regeltest::number($d['Istleistung_W'] ?? null, 'Andere Istleistung', -1e9, 1e9); + } + if (!$found) throw new RuntimeException('Testbatterie nicht mehr in aktiver Zuordnung.'); + $op['gridW'] += $otherDelta; + if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) { + throw new RuntimeException('Kein gueltiger empfangener Testkontext.'); + } + $command = NetzfahrplanV4Regeltest::befehl($envelope['response'], $op, $actual, $s, $now, $envelope['receivedAt']); + // A controller-side refusal stops the session; never downgrade it to an ordinary target. + IPS_RequestAction($s['batteryInstanceId'], 'NetzfahrplanV4TestCommand', json_encode($command, JSON_THROW_ON_ERROR)); + $s['sequence']++;$s['lastWallClockAt'] = $now; + $this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR)); + unset($allocation['Sollwerte'][$s['batteryInstanceId']]); // one owner of this actuator + $this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([ + 'status' => 'controller_command_sent', 'sessionId' => $s['sessionId'], + 'sourceShadowPlanId' => $command['sourceShadowPlanId'], 'command' => $command, + 'otherConsumerPredictedDeltaW' => $otherDelta, 'physicalResponseVerified' => false], JSON_THROW_ON_ERROR)); + } catch (Throwable $e) { + $this->v4ManagerTestAbbrechen($e->getMessage()); + // Stop writes zero and next NEW ordinary allocation runs on this same pass. + } + } +} diff --git a/libs/NetzfahrplanV4Regeltest.php b/libs/NetzfahrplanV4Regeltest.php new file mode 100644 index 0000000..a377053 --- /dev/null +++ b/libs/NetzfahrplanV4Regeltest.php @@ -0,0 +1,168 @@ + $max) { + throw new InvalidArgumentException($name . ': ungueltiger Wert.'); + } + return (float) $value; + } + + public static function uuid($value): string + { + if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) { + throw new InvalidArgumentException('Eindeutige Testkennung erforderlich.'); + } + return $value; + } + + public static function integer($value, string $name, int $min, int $max): int + { + if (!is_int($value) || $value < $min || $value > $max) { + throw new InvalidArgumentException($name . ': Ganzzahl erforderlich.'); + } + return $value; + } + + /** Validated server authorization plus independent LOCAL consent and physical mapping. */ + public static function befehl(array $response, array $operation, array $actual, array $local, int $now, int $receivedAt): array + { + if (($local['enabled'] ?? null) !== true || ($local['legacyEnabled'] ?? null) !== false) { + throw new InvalidArgumentException('Keine lokale Testfreigabe oder konkurrierender Fahrplan.'); + } + $installation = self::uuid($local['installationId'] ?? null); + $session = self::uuid($local['sessionId'] ?? null); + $manager = self::integer($local['managerId'] ?? null, 'Manager', 1, 99999); + $controller = self::integer($local['batteryInstanceId'] ?? null, 'Batterieinstanz', 1, 99999); + $end = self::integer($local['expiresAt'] ?? null, 'Lokales Testende', $now + 1, $now + self::MAX_TEST_SECONDS); + if (($local['lastWallClockAt'] ?? $now) > $now) throw new InvalidArgumentException('Systemzeit rueckwaerts; Test abbrechen.'); + $checked = NetzfahrplanV4Planpruefung::pruefen($response, $installation, $operation, $now, $receivedAt); + $plan = $checked['plan'];$a = $response['controlledTrial'] ?? null; + if (!is_array($a) || ($a['version'] ?? null) !== 1 || ($a['kind'] ?? null) !== 'controlled_trial_authority' + || ($a['sourcePlanRemainsShadow'] ?? null) !== true || ($a['installationId'] ?? null) !== $installation + || ($a['sessionId'] ?? null) !== $session || ($a['managerId'] ?? null) !== $manager + || ($a['batteryInstanceId'] ?? null) !== $controller || ($a['sourceShadowPlanId'] ?? null) !== $plan['planId'] + || ($a['revision'] ?? null) !== $plan['configRevision'] || ($a['family'] ?? null) !== $plan['sourceFamily']) { + throw new InvalidArgumentException('Keine passende separate Server-Testfreigabe.'); + } + $issued = NetzfahrplanV4Planpruefung::zeit($a['issuedAt'] ?? null); + $expires = NetzfahrplanV4Planpruefung::zeit($a['expiresAt'] ?? null); + $authorityAt = NetzfahrplanV4Planpruefung::zeit($a['checkedAt'] ?? null); + $authorityUntil = NetzfahrplanV4Planpruefung::zeit($a['validUntil'] ?? null); + if ($issued > $now || $expires <= $now || $expires - $issued > self::MAX_TEST_SECONDS + || $end > $expires || $authorityAt > $now + 2 || $authorityUntil <= $now + || $authorityUntil - $authorityAt > 90 || $authorityUntil > $expires) { + throw new InvalidArgumentException('Testfreigabe abgelaufen oder ungueltig.'); + } + $quality = $plan['inputQuality'] ?? []; + $evidence = $quality['accountingEvidenceId'] ?? null; + if (($quality['loadBasis'] ?? null) !== 'base_load' || !is_string($evidence) || strlen($evidence) < 8 + || $evidence !== ($a['accountingEvidenceId'] ?? null) + || !is_string($a['actuatorWatchdogEvidenceId'] ?? null) || strlen($a['actuatorWatchdogEvidenceId']) < 8 + || $a['actuatorWatchdogEvidenceId'] !== ($local['actuatorWatchdogEvidenceId'] ?? null)) { + throw new InvalidArgumentException('Anlagenbilanz oder Geraete-Watchdog nicht nachgewiesen.'); + } + if (($plan['peakCostIsEstimate'] ?? true) && ($a['acceptEstimatedPeak'] ?? null) !== true) { + throw new InvalidArgumentException('Geschaetzte Peakbasis nicht fuer Test akzeptiert.'); + } + if (count($operation['batteries']) !== 1 || $operation['batteries'][0]['id'] !== ($a['assetId'] ?? null) + || ($local['assetId'] ?? null) !== $a['assetId']) { + throw new InvalidArgumentException('Test braucht eindeutige Batteriezuordnung.'); + } + if (($a['controlContext'] ?? null) != $plan['controlContext']) { + throw new InvalidArgumentException('Testkonfiguration stimmt nicht mehr.'); + } + $preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now); + $charge = min(self::number($a['maxChargeW'] ?? null, 'Server-Ladegrenze', 1, self::MAX_TEST_POWER_W), + self::number($local['maxChargeW'] ?? null, 'Lokale Ladegrenze', 1, self::MAX_TEST_POWER_W)); + $discharge = min(self::number($a['maxDischargeW'] ?? null, 'Server-Entladegrenze', 1, self::MAX_TEST_POWER_W), + self::number($local['maxDischargeW'] ?? null, 'Lokale Entladegrenze', 1, self::MAX_TEST_POWER_W)); + $watts = (int) (max(-$discharge, min($charge, $preview['previewBatteryW']))); // truncate towards zero + $residual = $preview['actualGridW'] - $preview['actualBatteryW']; + $grid = $residual + $watts; + $p = $checked['point']; + if (($p['importLimitW'] !== null && $grid > $p['importLimitW'] + 1) + || ($p['exportLimitW'] !== null && -$grid > $p['exportLimitW'] + 1)) { + throw new InvalidArgumentException('Testleistungsgrenze reicht fuer Netzgrenze nicht; lokale Regelung hat Vorrang.'); + } + return ['kind' => 'controlled_trial_command', 'version' => 1, + 'sessionId' => $session, 'installationId' => $installation, 'assetId' => $a['assetId'], + 'managerId' => $manager, 'batteryInstanceId' => $controller, + 'sequence' => self::integer($local['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX), + 'issuedAt' => $now, 'expiresAt' => min($now + self::COMMAND_TTL_SECONDS, $authorityUntil, $end, + NetzfahrplanV4Planpruefung::zeit($p['validUntil'])), + 'watts' => $watts, 'maxChargeW' => (int) $charge, 'maxDischargeW' => (int) $discharge, + 'sourceShadowPlanId' => $plan['planId'], 'sourceStep' => $p['time'], + 'revision' => $plan['configRevision'], 'expectedGridW' => $grid, + 'executionMeaning' => 'separate_local_control_trial_not_shadow_plan_execution']; + } + + /** Independently checked by the battery before any physical register call. */ + public static function pruefeBatteriebefehl(array $command, array $session, int $now, float $monotonicNow): void + { + if (($command['kind'] ?? null) !== 'controlled_trial_command' || ($command['version'] ?? null) !== 1) { + throw new InvalidArgumentException('Vorschau ist kein Stellbefehl.'); + } + foreach (['sessionId', 'installationId', 'assetId', 'managerId', 'batteryInstanceId'] as $key) { + if (!array_key_exists($key, $session) || ($command[$key] ?? null) !== $session[$key]) { + throw new InvalidArgumentException('Befehl gehoert nicht zur lokal angemeldeten Testsitzung.'); + } + } + if (($session['active'] ?? null) !== true || $now >= ($session['expiresAt'] ?? 0) + || $monotonicNow >= ($session['monotonicDeadline'] ?? 0) + || $now < ($session['lastWallClockAt'] ?? $now)) { + throw new InvalidArgumentException('Lokale Testsitzung abgelaufen/abgebrochen.'); + } + $sent = self::integer($command['issuedAt'] ?? null, 'Befehlszeit', $now - self::COMMAND_TTL_SECONDS, $now); + $end = self::integer($command['expiresAt'] ?? null, 'Befehlsende', $now + 1, $sent + self::COMMAND_TTL_SECONDS); + if ($end > $session['expiresAt']) throw new InvalidArgumentException('Befehl verlaengert Test.'); + $seq = self::integer($command['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX); + if ($seq <= ($session['lastSequence'] ?? 0)) throw new InvalidArgumentException('Alter/doppelter Befehl verworfen.'); + $w = self::integer($command['watts'] ?? null, 'Sollwert W', -self::MAX_TEST_POWER_W, self::MAX_TEST_POWER_W); + $c = self::number($command['maxChargeW'] ?? null, 'Befehl-Ladegrenze', 1, self::MAX_TEST_POWER_W); + $d = self::number($command['maxDischargeW'] ?? null, 'Befehl-Entladegrenze', 1, self::MAX_TEST_POWER_W); + if ($c > $session['maxChargeW'] || $d > $session['maxDischargeW'] || $w > $c || $w < -$d) { + throw new InvalidArgumentException('Befehl ueberschreitet lokale Testgrenze.'); + } + self::uuid($command['sourceShadowPlanId'] ?? null); + } + + /** Re-evaluated on every battery update; no reliance on the manager's earlier clipping. */ + public static function batterieZiel(array $command, array $session, array $m, array $policy, int $now, float $monotonicNow): int + { + if ($now >= $command['expiresAt'] || $now < $session['lastWallClockAt'] + || $monotonicNow >= $session['commandMonotonicDeadline'] || $monotonicNow >= $session['monotonicDeadline']) { + throw new InvalidArgumentException('Befehls-Watchdog abgelaufen.'); + } + if (($m['Gueltig'] ?? null) !== true || ($policy['available'] ?? null) !== true) { + throw new InvalidArgumentException('Batterie nicht verfuegbar oder Messwerte ungueltig.'); + } + $soc = self::number($m['Ladezustand'] ?? null, 'SOC', 0, 100); + $reserve = self::number($policy['reserve'] ?? null, 'Reserve', 0, 100); + $minimum = self::number($policy['minimum'] ?? null, 'Minimum', 0, $reserve); + if ($soc < $minimum) throw new InvalidArgumentException('SOC unter technischer Untergrenze.'); + $charge = min($session['maxChargeW'], $command['maxChargeW'], self::number($m['MaxLaden'] ?? null, 'Max Laden', 0, 1e9)); + $discharge = min($session['maxDischargeW'], $command['maxDischargeW'], self::number($m['MaxEntladen'] ?? null, 'Max Entladen', 0, 1e9)); + if ($soc >= 99) $charge = 0; // existing battery hardware policy is more conservative than planner max 100 + if ($soc <= $reserve || ($policy['blocked'] ?? true)) $discharge = 0; + if (($soc <= $reserve || ($policy['blocked'] ?? true)) && $policy['rechargeLimitW'] !== null) { + $charge = min($charge, self::number($policy['rechargeLimitW'], 'Nachladegrenze', 0, 1e9)); + } + return (int) max(-$discharge, min($charge, $command['watts'])); + } +} diff --git a/tests/NetzfahrplanV4RegeltestTest.php b/tests/NetzfahrplanV4RegeltestTest.php new file mode 100644 index 0000000..a49725f --- /dev/null +++ b/tests/NetzfahrplanV4RegeltestTest.php @@ -0,0 +1,24 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + fclose($pipes[0]); + $out = stream_get_contents($pipes[1]);$err = stream_get_contents($pipes[2]); + fclose($pipes[1]);fclose($pipes[2]); + self::assertSame(0, proc_close($process), $out . $err); + self::assertSame('', $err); + self::assertStringContainsString('TOTAL 59 checks passed.', $out); + } +} diff --git a/tests/V4ControlTrial/additional_checks.php b/tests/V4ControlTrial/additional_checks.php new file mode 100644 index 0000000..3669c8b --- /dev/null +++ b/tests/V4ControlTrial/additional_checks.php @@ -0,0 +1,30 @@ +op['gridW']=3000.;tick($m); +check(end($b->writes)===-3000,'discharge sign and current net load compensation'); +$b->measurements['Ladezustand']=15.;$b->watchdog();check(end($b->writes)===0,'reserve stops an active discharge independently'); +[$m,$b,$s]=fixture();start($m,$s);$m->op['gridW']=3000.;tick($m);$b->state['HystereseAktiv']=true;$b->watchdog(); +check(end($b->writes)===0,'local discharge hysteresis overrides previous command'); +[$m,$b,$s]=fixture();start($m,$s);$m->op['gridW']=3000.;tick($m);$b->measurements['MaxEntladen']=500.;$b->watchdog(); +check(end($b->writes)===-500,'discharge availability decrease is immediate'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=2.;$b->watchdog(); +check(!$b->active()&&end($b->writes)===0,'technical minimum violation aborts rather than inventing SOC'); +[$m,$b,$s]=fixture();$s['maxChargeW']=1000.;start($m,$s);tick($m); +check(end($b->writes)===1000,'independent local cap can be stricter than server cap'); +[$m,$b,$s]=fixture();start($m,$s);$b->attrs['LetzterSollwert']=500;$b->attrs['AenderungFreigabeAb']=$clock+4;tick($m); +check(end($b->writes)===500,'switch lock holds power increase'); +advance(5);tick($m);check(end($b->writes)===3000,'power increase only after switching lock'); +[$m,$b,$s]=fixture();start($m,$s);$b->attrs['LetzterSollwert']=500;$b->attrs['AenderungFreigabeAb']=$clock+4;$m->op['gridW']=3000.;tick($m); +check(end($b->writes)===0,'sign reversal first passes zero during switching lock'); +[$m,$b,$s]=fixture();start($m,$s);$b->attrs['LetzterSollwert']=3000;$b->attrs['AenderungFreigabeAb']=$clock+4;$b->measurements['MaxLaden']=100.;tick($m); +check(end($b->writes)===100,'safety reduction not delayed by switching lock'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=15.;$b->props['NachladenMitMaximalleistung']=false;$b->props['MaximaleNachladeleistung']=200;$b->watchdog(); +check(end($b->writes)===200,'configured reserve-recovery charging limit remains enforced'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->licensed=false;tick($m); +check(!$m->active()&&!$b->active()&&end($b->writes)===0,'license withdrawal revokes both software sessions'); +[$m,$b,$s]=fixture();$s['durationSeconds']=30;start($m,$s);tick($m);advance(30);tick($m); +check(!$m->active()&&!$b->active()&&end($b->writes)===0,'local shorter test expiration respected'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->vars['Aktiv']=false;tick($m); +check(!$m->active()&&!$b->active(),'manual manager disable ends trial'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->vars['Aktiv']=false;$b->watchdog(); +check(!$b->active()&&end($b->writes)===0,'manual battery disable independently ends trial'); diff --git a/tests/V4ControlTrial/checks.php b/tests/V4ControlTrial/checks.php new file mode 100644 index 0000000..eb0d903 --- /dev/null +++ b/tests/V4ControlTrial/checks.php @@ -0,0 +1,161 @@ +$GLOBALS['objects'][$id]->ready?102:201, + 'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']]; } + function IPS_GetProperty($id,$key) { return $GLOBALS['objects'][$id]->props[$key]; } + function IPS_RequestAction($id,$key,$value) { + $GLOBALS['calls'][]=[$id,$key];$b=$GLOBALS['objects'][$id]; + if ($key==='NetzfahrplanV4TestStart') $b->V4BatterieTestStarten($value); + elseif ($key==='NetzfahrplanV4TestCommand') $b->V4BatterieTestBefehl($value); + elseif ($key==='NetzfahrplanV4TestStop') $b->V4BatterieTestStoppen(); + else throw new \RuntimeException('Unexpected external action in offline test'); + } + trait StoreHarness { + public array $props=[],$attrs=[],$buf=[],$timers=[],$vars=['Aktiv'=>true]; + public bool $ready=true; + private function RegisterPropertyBoolean($k,$v) { $this->props[$k]??=$v; } + private function RegisterPropertyString($k,$v) { $this->props[$k]??=$v; } + private function RegisterAttributeString($k,$v) { $this->attrs[$k]??=$v; } + private function RegisterTimer($k,$ms,$script) { $this->timers[$k]=$ms; } + private function SetTimerInterval($k,$ms) { $this->timers[$k]=$ms; } + private function ReadPropertyBoolean($k) { return $this->props[$k]; } + private function ReadPropertyString($k) { return $this->props[$k]; } + private function ReadPropertyInteger($k) { return $this->props[$k]; } + private function ReadPropertyFloat($k) { return (float)$this->props[$k]; } + private function ReadAttributeString($k) { return $this->attrs[$k]??''; } + private function ReadAttributeInteger($k) { return $this->attrs[$k]??0; } + private function WriteAttributeString($k,$v) { $this->attrs[$k]=$v; } + private function GetBuffer($k) { return $this->buf[$k]??''; } + private function SetBuffer($k,$v) { $this->buf[$k]=$v; } + private function GetValue($k) { return $this->vars[$k]; } + } + class BatteryHarness { + use StoreHarness, BatterieNetzfahrplanV4TestTrait; + public int $InstanceID=44234; + public array $state=['HystereseAktiv'=>false],$writes=[],$measurements=[]; + public bool $failRegisters=false; + public function __construct() { + $this->props=['Batteriemanagement'=>2,'ReserveLadezustand'=>15.,'MindestLadezustand'=>3., + 'NachladenMitMaximalleistung'=>true,'MaximaleNachladeleistung'=>1000, + 'LadezustandVariableID'=>1001,'IstleistungVariableID'=>1002]; + $this->measurements=['Gueltig'=>true,'Ladezustand'=>50.,'MaxLaden'=>5000.,'MaxEntladen'=>4000.]; + $this->attrs['LetzterSollwert']=0;$this->attrs['AenderungFreigabeAb']=0; + $this->registriereV4BatterieTest(); + } + private function zugeordneteManagerIDs() { return [17004]; } + private function setzeZustand($k,$v) { $this->state[$k]=$v; } + private function leseZustand($k) { return $this->state[$k]??false; } + private function schreibeRegister($w,$force) { if ($this->failRegisters) throw new \RuntimeException('synthetic register failure');$this->writes[]=$w;$this->attrs['LetzterSollwert']=$w; } + private function aktualisiereIntern($notify) { $w=$this->v4BatterieTestZiel($this->measurements,$this->vars['Aktiv'],time());if ($w!==null) $this->schreibeRegister($w,false); } + public function watchdog() { $this->aktualisiereIntern(true); } + public function active() { return $this->v4BatterieTestAktiv(); } + } + class ManagerHarness { + use StoreHarness, ManagerNetzfahrplanV4TestTrait; + public int $InstanceID=17004; + public array $op=[],$actual=['b'=>0.];public int $fallback=0; + public bool $licensed=true; + public function __construct($response,$op) { + $this->props=['NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanV4EmpfangAktiv'=>true,'NetzfahrplanAktiv'=>false, + 'AnlagenBatterien'=>json_encode([['ID'=>'b','SOCVariableID'=>1001,'LeistungVariableID'=>1002]])]; + $this->op=$op;$this->attrs=['LizenzInstallationID'=>$response['installationId'], + 'NetzfahrplanV4Empfang'=>json_encode(['response'=>$response,'receivedAt'=>time()])]; + $this->registriereV4ManagerTest(); + } + private function berechtigungLizenziert($k) { return $this->licensed; } + private function actieveDummy() {} + private function aktiveVerbraucherIDs() { return [44234]; } + private function netzfahrplanV4LokaleVorschauwerte() { return [$this->op,$this->actual]; } + private function regeln($renew) { $this->fallback++; } + public function tick(&$allocation,$consumers=null,$sync=true) { + $this->v4ManagerTestUebergabe($allocation,$consumers??[['InstanzID'=>44234,'Daten'=>[]]],$sync); + } + public function active() { return (bool)$this->v4ManagerTestSession(); } + public function command() { return json_decode($this->attrs['NetzfahrplanV4RegeltestStatus'],true)['command']??null; } + } +} +namespace { + require_once __DIR__.'/../V4Receiver/fixture.php'; + use Belevo\EnelixEMS\NetzfahrplanV4Regeltest as Gate; + use Belevo\EnelixEMS\BatteryHarness; + use Belevo\EnelixEMS\ManagerHarness; + $count=0; + function check($ok,$name) { global $count;if (!$ok) throw new RuntimeException('FAIL '.$name);$count++;echo "PASS $name\n"; } + function rejects(callable $call,$name) { try {$call();}catch(Throwable $e){check(true,$name);return;}throw new RuntimeException('FAIL did not reject '.$name); } + function fixture($permissions=true,$serverAuthority=true): array { + [$r,$id,$op,$now]=receiverFixture();$GLOBALS['clock']=$now;$GLOBALS['mono']=1000.;$GLOBALS['locks']=[];$GLOBALS['calls']=[]; + $r['plan']['inputQuality']=['loadBasis'=>'base_load','accountingEvidenceId'=>'synthetic-meter-boundary-v1']; + $r['plan']['peakCostIsEstimate']=true; + $a=['kind'=>'controlled_trial_authority','version'=>1,'sourcePlanRemainsShadow'=>true, + 'sessionId'=>'20000000-0000-4000-8000-000000000001','installationId'=>$id, + 'managerId'=>17004,'batteryInstanceId'=>44234,'assetId'=>'b', + 'sourceShadowPlanId'=>$r['plan']['planId'],'revision'=>1,'family'=>'3', + 'issuedAt'=>gmdate('c',$now),'expiresAt'=>gmdate('c',$now+300),'checkedAt'=>gmdate('c',$now),'validUntil'=>gmdate('c',$now+90), + 'maxChargeW'=>5000.,'maxDischargeW'=>5000.,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog', + 'accountingEvidenceId'=>'synthetic-meter-boundary-v1','acceptEstimatedPeak'=>true,'controlContext'=>$r['plan']['controlContext']]; + $r['controlledTrial']=$serverAuthority?$a:null; + $m=new ManagerHarness($r,$op);$b=new BatteryHarness();$GLOBALS['objects']=[17004=>$m,44234=>$b]; + $m->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;$b->props['NetzfahrplanV4RegeltestErlaubt']=$permissions; + $b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='synthetic-hardware-watchdog'; + $start=['confirmation'=>'START_BOUNDED_V4_CONTROL_TRIAL','sessionId'=>$a['sessionId'],'durationSeconds'=>300,'maxChargeW'=>5000.,'maxDischargeW'=>5000.]; + return [$m,$b,$start,$r,$op,$now]; + } + function start($m,$s) {$m->V4ManagerTestStarten(json_encode($s));} + function tick($m) {$a=['Sollwerte'=>[44234=>999]];$m->tick($a);return $a;} + function advance($s) {$GLOBALS['clock']+=$s;$GLOBALS['mono']+=$s;} + + [$m,$b,$s]=fixture(false);rejects(fn()=>start($m,$s),'default local permission blocks');check($b->writes===[],'no register writes without consent'); + [$m,$b,$s]=fixture(true,false);rejects(fn()=>start($m,$s),'ordinary shadow response never authorizes');check($calls===[],'no driver call for preview alone'); + [$m,$b,$s]=fixture();$b->props['NetzfahrplanV4RegeltestErlaubt']=false;rejects(fn()=>start($m,$s),'second battery gate independent'); + [$m,$b,$s]=fixture();$m->props['NetzfahrplanAktiv']=true;rejects(fn()=>start($m,$s),'old schedule excludes new controller'); + [$m,$b,$s,$r]=fixture();$r['plan']['inputQuality']['loadBasis']='house_total';$m->attrs['NetzfahrplanV4Empfang']=json_encode(['response'=>$r,'receivedAt'=>$clock]);rejects(fn()=>start($m,$s),'unverified aggregate accounting blocks test'); + [$m,$b,$s]=fixture();$b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='';rejects(fn()=>start($m,$s),'unknown device watchdog blocks test'); + [$m,$b,$s]=fixture();$m->ready=false;rejects(fn()=>start($m,$s),'unready manager blocks test'); + [$m,$b,$s]=fixture();$s['maxChargeW']=5001;rejects(fn()=>start($m,$s),'test limit above 5kW refused'); + [$m,$b,$s]=fixture();start($m,$s);check($m->active()&&$b->active(),'both sessions armed');check($b->writes===[],'arm itself sends no register command'); + $allocation=tick($m);check(!isset($allocation['Sollwerte'][44234]),'one actuator owner removes ordinary target'); + check(end($b->writes)===3000,'actual trait chain delivers bounded 3kW command'); + $c=$m->command();check($c['kind']==='controlled_trial_command'&&$c['expiresAt']===$clock+10,'independent command type and ten-second lease'); + check(json_decode($b->GetV4BatterieTestStatus(),true)['physicalResponseVerified']===false,'controller acceptance is not physical proof'); + advance(2);tick($m);check($m->command()['sequence']===2,'monotonic command sequence'); + rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'replayed command refused');check(!$b->active()&&end($b->writes)===0,'replay aborts and releases output'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);advance(11);$b->watchdog();check(!$b->active()&&end($b->writes)===0,'watchdog expiry drops to zero'); + [$m,$b,$s]=fixture();start($m,$s);advance(11);$b->watchdog();check(!$b->active()&&end($b->writes)===0,'missing first command expires'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$mono+=11;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'monotonic watchdog works without wall clock advance'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$clock-=1;$b->watchdog();check(!$b->active(),'backwards wall clock aborts'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$m->V4ManagerTestStoppen();check(!$m->active()&&!$b->active()&&end($b->writes)===0&&$m->fallback===1,'manual stop releases before ordinary fallback'); + rejects(fn()=>start($m,$s),'stopped local session cannot resurrect'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$b->buf=[];$b->V4BatterieTestStoppen();check(end($b->writes)===0&&!$b->active(),'runtime restart clears persisted command marker'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Gueltig']=false;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'invalid device measurements release command'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['MaxLaden']=1000.;$b->watchdog();check(end($b->writes)===1000,'device availability reduction rechecked'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=99.;$b->watchdog();check(end($b->writes)===0,'full battery cannot keep charging'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$b->props['NetzfahrplanV4RegeltestErlaubt']=false;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'battery consent withdrawn aborts'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$m->props['NetzfahrplanV4RegeltestErlaubt']=false;$a=tick($m);check(!$m->active()&&isset($a['Sollwerte'][44234])&&end($b->writes)===0,'manager consent withdrawn resumes ordinary allocation'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$m->attrs['NetzfahrplanV4Empfang']='{}';$a=tick($m);check(!$m->active()&&end($b->writes)===0,'invalidated receiver cache ends trial'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$a=['Sollwerte'=>[44234=>123]];$m->tick($a,null,false);check(!$m->active()&&end($b->writes)===0,'unsynchronized consumers abort'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$b->failRegisters=true;advance(2);tick($m);check(!$m->active()&&!$b->active(),'register failure revokes both sessions');check(json_decode($m->GetV4ManagerTestStatus(),true)['controllerStopAccepted']===false,'stop failure not disguised as safe'); + [$m,$b,$s]=fixture();start($m,$s);$a=['Sollwerte'=>[44234=>999,33333=>1000]];$m->tick($a,[['InstanzID'=>44234,'Daten'=>[]],['InstanzID'=>33333,'Daten'=>['Leistungsquelle'=>'measured','Istleistung_W'=>0]]]); + check(end($b->writes)===2000&&$a['Sollwerte'][33333]===1000,'other consumer delta counted once without takeover'); + [$m,$b,$s]=fixture();start($m,$s);$m->actual['b']=2000.;$m->op['gridW']=-1000.;tick($m);check(end($b->writes)===3000,'command is total battery power not additive'); + [$m,$b,$s]=fixture();start($m,$s);$m->actual['b']=0.;$m->op['gridW']=-40000.;tick($m);check(!$m->active(),'test cap cannot override hard export limit'); + [$m,$b,$s]=fixture();start($m,$s);tick($m);$clock+=91;$mono+=91;tick($m);check(!$m->active()&&end($b->writes)===0,'expired server observation cancels even with future prices'); + [$m,$b,$s,$r,$op,$now]=fixture();$local=['enabled'=>true,'legacyEnabled'=>false,'installationId'=>$r['installationId'],'sessionId'=>$s['sessionId'],'managerId'=>17004,'batteryInstanceId'=>44234,'assetId'=>'b','expiresAt'=>$now+300,'maxChargeW'=>5000,'maxDischargeW'=>5000,'sequence'=>1,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog']; + foreach (['sessionId','installationId','sourceShadowPlanId'] as $key) { + $bad=$r;$bad['controlledTrial'][$key]='ffffffff-ffff-4fff-8fff-ffffffffffff';rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'wrong authority binding '.$key); + } + $bad=$r;$bad['controlledTrial']['validUntil']=gmdate('c',$now+120);rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'oversized server authorization lease'); + $bad=$r;$bad['controlledTrial']['acceptEstimatedPeak']=false;rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'peak uncertainty needs explicit acceptance'); + require __DIR__ . '/additional_checks.php'; + echo "TOTAL $count checks passed. Mocked IPS, clocks and register writes only.\n"; +}