feat(manager): add gated short-lease V4 control trial and local fallback

This commit is contained in:
ENELIX Agent
2026-10-02 14:01:41 +00:00
parent b5c1fa6be2
commit 9394fe3eb1
10 changed files with 918 additions and 4 deletions
+45 -3
View File
@@ -6,6 +6,7 @@ require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php'; require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php'; require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/BatterieRegler.php'; require_once __DIR__ . '/../libs/BatterieRegler.php';
require_once __DIR__ . '/../libs/BatterieNetzfahrplanV4TestTrait.php';
use Belevo\EnelixEMS\BatterieRegler; use Belevo\EnelixEMS\BatterieRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag; use Belevo\EnelixEMS\Nachrichtenvertrag;
@@ -15,6 +16,7 @@ use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class Batterie extends IPSModule implements VerbraucherSchnittstelle class Batterie extends IPSModule implements VerbraucherSchnittstelle
{ {
use VerbraucherBasisTrait; use VerbraucherBasisTrait;
use \Belevo\EnelixEMS\BatterieNetzfahrplanV4TestTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}'; private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102; private const STATUS_AKTIV = 102;
@@ -68,6 +70,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
parent::Create(); parent::Create();
$this->registriereVerbraucherBasis(); $this->registriereVerbraucherBasis();
$this->registriereV4BatterieTest();
$this->RegisterPropertyInteger('Batterietyp', BatterieRegler::TYP_UNKONFIGURIERT); $this->RegisterPropertyInteger('Batterietyp', BatterieRegler::TYP_UNKONFIGURIERT);
$this->RegisterPropertyInteger( $this->RegisterPropertyInteger(
'Batteriemanagement', 'Batteriemanagement',
@@ -152,6 +155,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
{ {
parent::ApplyChanges(); parent::ApplyChanges();
$this->V4BatterieTestStoppen();
$this->aktualisiereVariablen(); $this->aktualisiereVariablen();
$this->registriereMesswertmeldungen(); $this->registriereMesswertmeldungen();
$this->WriteAttributeBoolean('RegisterInitialisiert', false); $this->WriteAttributeBoolean('RegisterInitialisiert', false);
@@ -248,7 +252,22 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$this->aktualisiere(true); $this->aktualisiere(true);
return; 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': case 'SichererZustand':
$this->V4BatterieTestStoppen();
$this->setzeZustand('SollwertGueltig', false); $this->setzeZustand('SollwertGueltig', false);
$this->schreibeRegister(0, true); $this->schreibeRegister(0, true);
$this->aktualisiere(true); $this->aktualisiere(true);
@@ -287,6 +306,14 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
/** @param array<string, mixed> $daten */ /** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void 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); Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { 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->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false); $this->aktualisiereIntern(false);
$sollleistung = $daten['Sollleistung_W']; $sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) { if ($sollleistung === null) {
@@ -314,7 +346,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
) )
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(false); $this->aktualisiereIntern(false);
} }
$this->planeMeldung(); $this->planeMeldung();
return; return;
@@ -341,11 +373,19 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$this->setzeZustand('Sollleistung', $sollleistung); $this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true); $this->setzeZustand('SollwertGueltig', true);
$this->planeVorgabeTimeout(time()); $this->planeVorgabeTimeout(time());
$this->aktualisiere(false); $this->aktualisiereIntern(false);
$this->planeMeldung(); $this->planeMeldung();
} }
private function aktualisiere(bool $meldungPlanen): void 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 { try {
$jetzt = time(); $jetzt = time();
@@ -426,6 +466,8 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$ziel = $verfuegbar && (bool) $this->leseZustand('SollwertGueltig') $ziel = $verfuegbar && (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung') ? (int) $this->leseZustand('Sollleistung')
: 0; : 0;
$v4Ziel = $this->v4BatterieTestZiel($messwerte, $verfuegbar, $jetzt);
if ($v4Ziel !== null) $ziel = $v4Ziel;
$this->schreibeRegister($ziel, false); $this->schreibeRegister($ziel, false);
$this->setzeZustand('Sollleistung', $ziel); $this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar); $this->setzeZustand('Verfuegbar', $verfuegbar);
+30
View File
@@ -13,6 +13,7 @@ require_once __DIR__ . '/../libs/EnergieMessung.php';
require_once __DIR__ . '/../libs/ManagerEnergieTrait.php'; require_once __DIR__ . '/../libs/ManagerEnergieTrait.php';
require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php'; require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php'; require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4TestTrait.php';
use Belevo\EnelixEMS\Anlagentopologie; use Belevo\EnelixEMS\Anlagentopologie;
use Belevo\EnelixEMS\EinspeiseRegler; use Belevo\EnelixEMS\EinspeiseRegler;
@@ -28,6 +29,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
{ {
use ManagerEnergieTrait; use ManagerEnergieTrait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4Trait; use \Belevo\EnelixEMS\ManagerNetzfahrplanV4Trait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4TestTrait;
private const STATUS_AKTIV = 102; private const STATUS_AKTIV = 102;
private const STATUS_MESSWERT_UNGUELTIG = 201; private const STATUS_MESSWERT_UNGUELTIG = 201;
@@ -116,6 +118,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
{ {
parent::Create(); parent::Create();
$this->registriereNetzfahrplanV4(); $this->registriereNetzfahrplanV4();
$this->registriereV4ManagerTest();
$this->RegisterPropertyInteger('Rolle', 0); $this->RegisterPropertyInteger('Rolle', 0);
$this->RegisterPropertyInteger('NetzleistungVariableID', 0); $this->RegisterPropertyInteger('NetzleistungVariableID', 0);
@@ -236,6 +239,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
public function ApplyChanges(): void public function ApplyChanges(): void
{ {
parent::ApplyChanges(); parent::ApplyChanges();
$this->v4ManagerTestAbbrechen('Manager neu initialisiert');
$this->SetTimerInterval('NetzfahrplanV4Senden', 0); $this->SetTimerInterval('NetzfahrplanV4Senden', 0);
$this->stoppeNetzfahrplanV4Vorschau(); $this->stoppeNetzfahrplanV4Vorschau();
@@ -468,6 +472,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->aktualisiereNetzfahrplanV4Vorschau(); $this->aktualisiereNetzfahrplanV4Vorschau();
return; 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': case 'NetzfahrplanV4Senden':
$this->sendeNetzfahrplanV4(); $this->sendeNetzfahrplanV4();
return; return;
@@ -703,6 +715,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle
} }
private function regeln(bool $erneuern): void 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)) { if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) {
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
@@ -807,6 +835,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
: $this->nichtGeregelteGeraeteText($nichtGeregelt) : $this->nichtGeregelteGeraeteText($nichtGeregelt)
); );
$this->v4ManagerTestUebergabe($ergebnis, $verbraucher, $synchronisiert);
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte'); $letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) { foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) { if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
@@ -816,6 +845,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung); $this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung);
$letzteSollwerte[(string) $instanzID] = $sollleistung; $letzteSollwerte[(string) $instanzID] = $sollleistung;
} catch (Throwable $fehler) { } catch (Throwable $fehler) {
$this->v4ManagerTestAbbrechen('Andere Verbraucher-Stellwertuebergabe fehlgeschlagen');
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage(); $stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
} }
} }
+96
View File
@@ -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.
+186
View File
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
/** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */
trait BatterieNetzfahrplanV4TestTrait
{
private function registriereV4BatterieTest(): void
{
$this->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
}
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ trait ManagerNetzfahrplanV4EmpfangTrait
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time()); $preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
// Only the known, non-secret protocol envelope is cached. // Only the known, non-secret protocol envelope is cached.
$keys = ['receiverProtocolVersion', 'installationId', 'checkedAt', 'liveEnabled', 'fresh', $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)); $safe = array_intersect_key($response, array_flip($keys));
$this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode( $this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode(
['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR)); ['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR));
+177
View File
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
/** Local consent + server commissioning authority. NEVER executes a preview alone. */
trait ManagerNetzfahrplanV4TestTrait
{
private function registriereV4ManagerTest(): void
{
$this->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.
}
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
/** Separate, explicitly authorized commissioning commands. A preview is NEVER permission. */
final class NetzfahrplanV4Regeltest
{
public const MAX_TEST_SECONDS = 1800;
public const MAX_TEST_POWER_W = 5000;
public const COMMAND_TTL_SECONDS = 10;
public static function number($value, string $name, float $min, float $max): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < $min || $value > $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']));
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4RegeltestTest extends TestCase
{
public function testIsolatedControlledTrialStateMachines(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4ControlTrial/checks.php'],
[0 => ['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);
}
}
@@ -0,0 +1,30 @@
<?php
// Included only by the offline harness. No device or network calls.
[$m,$b,$s]=fixture();start($m,$s);$m->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');
+161
View File
@@ -0,0 +1,161 @@
<?php
// CLI ONLY. All IPS functions and register writes below are synthetic; no live kernel/network.
namespace Belevo\EnelixEMS {
if (\function_exists('IPS_GetKernelVersion')) throw new \RuntimeException('Offline test must NEVER run in Symcon.');
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4TestTrait.php';
require_once __DIR__ . '/../../libs/BatterieNetzfahrplanV4TestTrait.php';
class Lizenzpruefung { public const NETZFAHRPLAN='grid_schedule'; }
class Nachrichtenvertrag { public const LEISTUNGSQUELLE_GEMESSEN='measured'; }
function time() { return $GLOBALS['clock']; }
function hrtime($asNumber=true) { return (int)($GLOBALS['mono']*1e9); }
function IPS_SemaphoreEnter($key,$wait) { if (!empty($GLOBALS['locks'][$key])) return false;$GLOBALS['locks'][$key]=true;return true; }
function IPS_SemaphoreLeave($key) { unset($GLOBALS['locks'][$key]); }
function IPS_InstanceExists($id) { return isset($GLOBALS['objects'][$id]); }
function IPS_GetInstance($id) { return ['InstanceStatus'=>$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";
}