$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'])); } }