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
+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());
// 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));
+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']));
}
}