getTimestamp(); } private static function zahl($value, string $name, float $low = -1.0e9, float $high = 1.0e9): float { if ((!is_float($value) && !is_int($value)) || !is_finite((float) $value) || $value < $low || $value > $high) { throw new InvalidArgumentException($name . ': endlicher Zahlenwert erforderlich.'); } return (float) $value; } private 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('Ungueltige UUID.'); } return $value; } public static function dekodieren(string $raw): array { if (strlen($raw) > self::MAX_RESPONSE_BYTES) { throw new InvalidArgumentException('V4-Antwort zu gross.'); } $result = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); if (!is_array($result) || self::liste($result)) { throw new InvalidArgumentException('V4-Antwort ist kein Objekt.'); } return $result; } /** Stable local planning policy. Volatile SOC/availability are checked separately. */ public static function kontext(array $operation): array { $assets = []; foreach ($operation['batteries'] ?? [] as $b) { if (!is_array($b) || !is_string($b['id'] ?? null) || isset($assets[$b['id']])) { throw new InvalidArgumentException('Batteriezuordnung ungueltig.'); } $assets[$b['id']] = [ 'capacityKwh' => self::zahl($b['capacityKwh'] ?? null, 'Kapazitaet', .001), 'minSocPercent' => self::zahl($b['minSocPercent'] ?? null, 'Reserve', 0, 100), 'maxSocPercent' => self::zahl($b['maxSocPercent'] ?? null, 'Maximal-SOC', 0, 100), 'physicalMinSocPercent' => self::zahl($b['physicalMinSocPercent'] ?? 0.0, 'Minimum', 0, 100), 'rearmSocPercent' => self::zahl($b['rearmSocPercent'] ?? $b['minSocPercent'], 'Hysterese', 0, 100), 'gridCharging' => $b['gridCharging'] ?? null, ]; if (!is_bool($assets[$b['id']]['gridCharging'])) { throw new InvalidArgumentException('Netzladefreigabe fehlt.'); } } if (!$assets || count($assets) > 20 || !is_array($operation['limits'] ?? null)) { throw new InvalidArgumentException('Batterien oder lokale Grenzen fehlen.'); } ksort($assets); $limits = $operation['limits']; $expected = ['importW', 'exportW', 'managerMonthLimitsW']; if (count($limits) !== 3 || array_diff($expected, array_keys($limits)) || !is_array($limits['managerMonthLimitsW'])) { throw new InvalidArgumentException('Unvollstaendige lokale Grenzen.'); } foreach (['importW', 'exportW'] as $key) { if ($limits[$key] !== null) $limits[$key] = self::zahl($limits[$key], 'Grenze', 0); } foreach ($limits['managerMonthLimitsW'] as $month => $limit) { if ((string) (int) $month !== (string) $month || (int) $month < 1 || (int) $month > 12) { throw new InvalidArgumentException('Ungueltige Monatsgrenze.'); } $limits['managerMonthLimitsW'][$month] = self::zahl($limit, 'Monatsgrenze', 0); } ksort($limits['managerMonthLimitsW']); return ['batteries' => $assets, 'limits' => $limits]; } /** Authenticated endpoint must echo installation identity; old protocol is refused. */ public static function pruefen(array $response, string $installation, array $operation, int $now, int $receivedAt): array { self::uuid($installation); if (($response['receiverProtocolVersion'] ?? null) !== 1 || ($response['installationId'] ?? null) !== $installation || ($response['liveEnabled'] ?? null) !== false) { throw new InvalidArgumentException('V4-Protokoll/Anlagenzuordnung oder Schattenmodus ungueltig.'); } if ($receivedAt > $now || $now - $receivedAt > self::MAX_RECEIPT_AGE) { throw new InvalidArgumentException('Empfang veraltet; Vorschau verworfen.'); } $serverAt = self::zeit($response['checkedAt'] ?? null); if ($serverAt > $now + 5 || $now - $serverAt > self::MAX_RECEIPT_AGE) { throw new InvalidArgumentException('Serverantwort nicht aktuell.'); } if (($response['fresh'] ?? null) !== true || ($response['pending'] ?? null) !== null) { throw new InvalidArgumentException('Server hat noch keinen aktuellen freigegebenen Schattenplan.'); } $settings = $response['settings'] ?? []; $p = $response['plan'] ?? []; if (!is_array($p) || !is_int($settings['revision'] ?? null) || $settings['revision'] < 0 || ($p['schemaVersion'] ?? null) !== 2 || ($p['installationId'] ?? null) !== $installation || ($p['configRevision'] ?? null) !== $settings['revision'] || ($settings['runMode'] ?? null) !== 'shadow' || ($p['runMode'] ?? null) !== 'shadow' || ($p['liveEnabled'] ?? null) !== false || ($p['executable'] ?? null) !== true || !in_array($p['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) { throw new InvalidArgumentException('Planrevision, Status oder Schattenmodus ungueltig.'); } self::uuid($p['planId'] ?? null); if (($response['lastRun']['detail']['planId'] ?? null) !== $p['planId'] || !in_array($response['lastRun']['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) { throw new InvalidArgumentException('Letzte Rechnung passt nicht zum empfangenen Plan.'); } $families = array_column($response['families'] ?? [], 'key'); if (!is_string($p['sourceFamily'] ?? null) || !in_array($p['sourceFamily'], $families, true) || (($settings['family'] ?? null) !== 'auto' && $settings['family'] !== $p['sourceFamily'])) { throw new InvalidArgumentException('Modellfamilie unbekannt oder nicht ausgewaehlt.'); } $generated = self::zeit($p['generatedAt'] ?? null); $start = self::zeit($p['validFrom'] ?? null); $until = self::zeit($p['validUntil'] ?? null); if ($generated > $now || $now - $generated > self::MAX_PLAN_AGE || $start > $now || $until <= $now || $start !== $generated || $until - $start > 48 * 3600 || self::zeit($p['pricesKnownUntil'] ?? null) < $until) { throw new InvalidArgumentException('Plan abgelaufen, zukuenftig oder ohne bekannten Preishorizont.'); } $opAt = self::zeit($operation['observedAt'] ?? null); if ($opAt > $now || $now - $opAt > 60) { throw new InvalidArgumentException('Lokale Betriebsdaten veraltet.'); } $context = self::kontext($operation); $pc = $p['controlContext'] ?? null; if (!is_array($pc) || count($pc) !== 2 || !is_array($pc['batteries'] ?? null) || !is_array($pc['limits'] ?? null)) { throw new InvalidArgumentException('Planungskontext fehlt.'); } $policyAssets = []; foreach ($pc['batteries'] as $id => $policy) { if (!is_array($policy) || count($policy) !== 6) { throw new InvalidArgumentException('Unbekannter Batteriekontext.'); } $policyAssets[] = ['id' => $id] + $policy; } // Normalize explicitly typed numeric values; strings/bools are not numbers. $plannedContext = self::kontext(['batteries' => $policyAssets, 'limits' => $pc['limits']]); if ($plannedContext != $context) { throw new InvalidArgumentException('Lokale Batterie-/Grenzkonfiguration passt nicht zum Plan.'); } if (!is_array($p['inputRefs'] ?? null) || !is_string($p['inputRefs']['operation'] ?? null) || $p['inputRefs']['operation'] === '') { throw new InvalidArgumentException('Plan ohne nachvollziehbare Eingangsdaten.'); } self::zahl($settings['roundtripEfficiency'] ?? null, 'Wirkungsgrad', .01, 1); $points = $p['points'] ?? null; if (!is_array($points) || !self::liste($points) || count($points) < 1 || count($points) > 576) { throw new InvalidArgumentException('Ungueltige Zeitschrittliste.'); } $current = null; $last = $start; $ids = array_keys($context['batteries']); foreach ($points as $point) { $a = self::zeit($point['time'] ?? null); $b = self::zeit($point['validUntil'] ?? null); if ($a !== $last || $b <= $a || $b - $a > 300 || $b % 300 !== 0) { throw new InvalidArgumentException('Luecke/Ueberlappung oder falsches Fuenfminutenraster.'); } $last = $b; $grid = self::zahl($point['gridTargetW'] ?? null, 'Netzziel'); $bat = self::zahl($point['batteryTargetW'] ?? null, 'Batterieziel'); $base = self::zahl($point['baselineGridW'] ?? null, 'Basisnetz'); $curtail = self::zahl($point['pvCurtailmentW'] ?? null, 'PV-Abregelung', 0); if (abs($base + $bat + $curtail - $grid) > 1.0) { throw new InvalidArgumentException('Leistungsbilanz des Plans verletzt.'); } if (!in_array($point['intent'] ?? '', ['hold', 'gridCharge', 'pvCharge', 'discharge', 'export'], true)) { throw new InvalidArgumentException('Unbekannte Planabsicht.'); } $targets = $point['assetTargetsW'] ?? null; $socs = $point['socEndPercent'] ?? null; if (!is_array($targets) || !is_array($socs)) { throw new InvalidArgumentException('Batterieziele fehlen.'); } $keys = array_keys($targets);sort($keys);$socKeys = array_keys($socs);sort($socKeys); if ($keys !== $ids || $socKeys !== $ids) { throw new InvalidArgumentException('Plan enthaelt falsche Batteriezuordnung.'); } $sum = 0.0; foreach ($targets as $id => $w) { $sum += self::zahl($w, 'Einzelziel'); self::zahl($socs[$id], 'Plan-SOC', 0, 100); } if (abs($sum - $bat) > 1.0) { throw new InvalidArgumentException('Summe der Batterieziele passt nicht.'); } foreach (['importLimitW', 'exportLimitW'] as $limit) { if (!array_key_exists($limit, $point)) { throw new InvalidArgumentException('Plangrenze fehlt.'); } if ($point[$limit] !== null) { $v = self::zahl($point[$limit], 'Plangrenze', 0); if (($limit === 'importLimitW' ? $grid : -$grid) > $v + 1) { throw new InvalidArgumentException('Netzgrenze im Plan verletzt.'); } } } if ($a <= $now && $now < $b) { $current = $point; } } if ($last !== $until || $current === null) { throw new InvalidArgumentException('Kein derzeit gueltiger Planabschnitt.'); } return ['plan' => $p, 'point' => $current, 'efficiency' => (float) $settings['roundtripEfficiency']]; } /** Proposed single-battery correction only. Never use this result as a dispatch permit. */ public static function vorschau(array $checked, array $operation, array $actualBatteryW, int $now): array { $point = $checked['point'];$p = $checked['plan']; if (count($operation['batteries']) !== 1) { throw new InvalidArgumentException('Lokale Vorschau fuer mehrere Batterien noch nicht freigegeben.'); } $b = $operation['batteries'][0];$id = $b['id']; $age = $now - self::zeit($b['measuredAt'] ?? null); if ($age < 0 || $age > 60) { throw new InvalidArgumentException('Batteriemessung veraltet.'); } $grid = self::zahl($operation['gridW'], 'Netzleistung'); $battery = self::zahl($actualBatteryW[$id] ?? null, 'Batterie-Istleistung'); $soc = self::zahl($b['socPercent'], 'Aktueller SOC', $b['physicalMinSocPercent'] ?? 0, $b['maxSocPercent']); $remainingHours = (self::zeit($point['validUntil']) - $now) / 3600; if ($remainingHours <= 0) { throw new InvalidArgumentException('Planabschnitt abgelaufen.'); } $eta = sqrt($checked['efficiency']);$capacity = self::zahl($b['capacityKwh'], 'Kapazitaet', .001); $charge = min(self::zahl($b['maxChargeW'], 'Ladeleistung', 0), max(0., $b['maxSocPercent'] - $soc) * $capacity / 100 / $eta / $remainingHours * 1000); $discharge = min(self::zahl($b['maxDischargeW'], 'Entladeleistung', 0), max(0., $soc - $b['minSocPercent']) * $capacity / 100 * $eta / $remainingHours * 1000); $reasons = []; if (($b['dischargeBlocked'] ?? false) === true || $soc <= $b['minSocPercent']) { $discharge = 0.0;$reasons[] = 'Lokale Entladesperre/Reserve'; } $residual = $grid - $battery; if ($b['gridCharging'] !== true) { $charge = min($charge, max(0., -$residual));$reasons[] = 'Netzladen lokal gesperrt'; } $target = (float) $point['gridTargetW'];$limits = $operation['limits']; $import = $limits['importW'];$export = $limits['exportW']; $month = (int) (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('n'); if (isset($limits['managerMonthLimitsW'][(string) $month])) { $monthly = self::zahl($limits['managerMonthLimitsW'][(string) $month], 'Monatsgrenze', 0); $import = $import === null ? $monthly : min($import, $monthly); } if ($import !== null) $target = min($target, self::zahl($import, 'Bezugsgrenze', 0)); if ($export !== null) $target = max($target, -self::zahl($export, 'Einspeisegrenze', 0)); $raw = $battery + $target - $grid; $wanted = $raw; if (abs($target) > 1.0) { $wanted = match ($point['intent']) { 'gridCharge' => max(0., $wanted), 'pvCharge' => max(0., min(max(0., -$residual), $wanted)), 'discharge', 'export' => min(0., $wanted), 'hold' => 0., }; } // Hard connection limits override economic intent, never BMS/availability. if ($import !== null) $wanted = min($wanted, $import - $residual); if ($export !== null) $wanted = max($wanted, -$export - $residual); $clipped = min($charge, max(-$discharge, $wanted)); if (abs($clipped - $raw) > 1) $reasons[] = 'Leistung, SOC, Planabsicht oder Netzgrenze begrenzt'; $expectedGrid = $residual + $clipped; $violation = max(0., $import === null ? 0. : $expectedGrid - $import, $export === null ? 0. : -$export - $expectedGrid); if ($violation > 1) $reasons[] = 'Netzgrenze durch Batterie allein nicht erreichbar; lokale Schutzregelung erforderlich'; return ['status' => 'preview_only', 'liveEnabled' => false, 'canDispatch' => false, 'installationId' => $p['installationId'], 'planId' => $p['planId'], 'revision' => $p['configRevision'], 'family' => $p['sourceFamily'], 'checkedAt' => gmdate('c', $now), 'step' => $point['time'], 'stepValidUntil' => $point['validUntil'], 'gridTargetW' => (float) $point['gridTargetW'], 'plannedBatteryW' => (float) $point['batteryTargetW'], 'previewBatteryW' => $clipped, 'actualGridW' => $grid, 'actualBatteryW' => $battery, 'expectedGridW' => $expectedGrid, 'unmetGridLimitW' => $violation, 'reasons' => $reasons, 'inputWarnings' => $p['warnings'] ?? [], 'validityNote' => 'Nur Momentvorschau; keine Stellfreigabe und keine nachgewiesene Netzreaktion.']; } }