1.0e9) { throw new InvalidArgumentException($label . ': endlicher Zahlenwert erforderlich.'); } return (float) $value; } private static function objectId($value): int { if (!is_int($value) || $value < 1 || $value > 99999) { throw new InvalidArgumentException('Eindeutige Instanzzuordnung erforderlich.'); } return $value; } /** A legal interval or singleton is selected, never a gap between offers. */ private static function offeredTarget(array $offers, float $desired, float $lower, float $upper): int { $best = null; foreach ($offers as $offer) { $from = is_int($offer) ? $offer : $offer['Von_W']; $until = is_int($offer) ? $offer : $offer['Bis_W']; $low = (int) ceil(max($lower, $from)); $high = (int) floor(min($upper, $until)); if ($low > $high) { continue; } $candidate = max($low, min($high, (int) $desired)); if ($best === null || [abs($candidate - $desired), abs($candidate), $candidate] < [abs($best - $desired), abs($best), $best]) { $best = $candidate; } } if ($best === null || !Nachrichtenvertrag::enthaeltLeistungswert($offers, $best)) { throw new InvalidArgumentException('Kein angebotener Batteriesollwert innerhalb aller Schutzgrenzen.'); } return $best; } /** Read-only readiness checks. Consumer synchronization and offers are checked at allocation time. * @return array{checked:array,feedback:array,preview:array} */ public static function pruefen( array $envelope, string $installationId, int $managerId, array $operation, array $actual, array $binding, int $now ): array { self::objectId($managerId); $batteryId = self::objectId($binding['batteryInstanceId'] ?? null); $assetId = $binding['assetId'] ?? null; $watchdog = $binding['watchdogEvidence'] ?? null; if ($now < 1 || $batteryId === $managerId || !is_string($assetId) || $assetId === '' || strlen($assetId) > 100 || !is_string($watchdog) || strlen(trim($watchdog)) < 8 || strlen($watchdog) > 256 || strcasecmp(trim($watchdog), 'explicit_test_plant_watchdog_waiver') === 0) { throw new InvalidArgumentException('Batteriezuordnung oder Geraete-Watchdog fehlen.'); } foreach (['installationId' => $installationId, 'managerId' => $managerId] as $key => $value) { if (isset($binding[$key]) && $binding[$key] !== $value) { throw new InvalidArgumentException('Widerspruechliche lokale Anlagenzuordnung.'); } $binding[$key] = $value; } if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null) || !is_array($operation['batteries'] ?? null) || array_keys($operation['batteries']) !== array_keys(array_values($operation['batteries'])) || count($operation['batteries']) !== 1 || ($operation['batteries'][0]['id'] ?? null) !== $assetId || count($actual) !== 1 || !array_key_exists($assetId, $actual) || array_key_exists('localPredictedOtherDeltaW', $operation)) { throw new InvalidArgumentException('Vollstaendiger unveraenderter Einzelbatterie-Kontext erforderlich.'); } $feedback = $operation['localFeedback'][$assetId] ?? []; if (!is_array($feedback)) { throw new InvalidArgumentException('Bestaetigte physische Batterierueckmeldung fehlt.'); } NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $binding, $now); if (($feedback['estimated'] ?? null) !== false || ($feedback['separatelyMeasured'] ?? null) !== true || abs(self::number($actual[$assetId], 'Batterie-Istleistung') - $feedback['batteryW']) > 0.000001 || abs(self::number($operation['gridW'] ?? null, 'Netz-Istleistung') - $feedback['gridW']) > 0.001) { throw new InvalidArgumentException('Direkt gemessene Batterie- und Netzleistung aus derselben Aufnahme erforderlich.'); } $checked = NetzfahrplanV4Planpruefung::pruefen( $envelope['response'], $installationId, $operation, $now, $envelope['receivedAt'] ); $quality = $checked['plan']['inputQuality'] ?? []; if (($quality['loadBasis'] ?? null) !== 'base_load' || !is_string($quality['accountingEvidenceId'] ?? null) || strlen($quality['accountingEvidenceId']) < 8) { throw new InvalidArgumentException('Nachvollziehbare physische Verbrauchsbilanz fehlt.'); } $preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now); if ($preview['unmetGridLimitW'] > 1) { throw new InvalidArgumentException('Netzgrenze durch Batterie allein nicht erreichbar.'); } return ['checked' => $checked, 'feedback' => $feedback, 'preview' => $preview]; } /** @return array{allocation:array,report:array} */ public static function korrigieren( array $envelope, string $installationId, int $managerId, array $operation, array $actual, array $allocation, array $consumers, bool $synchronized, array $binding, int $now ): array { $verified = self::pruefen($envelope, $installationId, $managerId, $operation, $actual, $binding, $now); $checked = $verified['checked']; $feedback = $verified['feedback']; $batteryId = $binding['batteryInstanceId']; $assetId = $binding['assetId']; if (!$synchronized || !is_array($allocation['Sollwerte'] ?? null) || !array_key_exists($batteryId, $allocation['Sollwerte'])) { throw new InvalidArgumentException('Verbraucher nicht synchronisiert oder Batterie nicht in Zuteilung.'); } if (array_keys($consumers) !== array_keys(array_values($consumers)) || count($consumers) > 500) { throw new InvalidArgumentException('Ungueltige Verbraucherliste.'); } $byId = []; foreach ($consumers as $consumer) { if (!is_array($consumer) || !is_array($consumer['Daten'] ?? null)) { throw new InvalidArgumentException('Verbraucherdaten fehlen.'); } $id = self::objectId($consumer['InstanzID'] ?? null); if (isset($byId[$id])) { throw new InvalidArgumentException('Doppelte Verbraucherzuordnung.'); } Nachrichtenvertrag::pruefeVerbraucherdaten($consumer['Daten']); $d = $consumer['Daten']; if ($d['Kopf']['AbsenderID'] !== $id || $d['Kopf']['EmpfaengerID'] !== $managerId || $d['Kopf']['Zeitpunkt'] > $now || $now - $d['Kopf']['Zeitpunkt'] > 60) { throw new InvalidArgumentException('Verbrauchermeldung falsch zugeordnet oder veraltet.'); } $byId[$id] = $d; } $battery = $byId[$batteryId] ?? null; if ($battery === null || !$battery['Verfuegbar'] || !$battery['AenderungMoeglich']) { throw new InvalidArgumentException('Batterie fehlt oder bietet keine Aenderung an.'); } $delta = 0.0; foreach ($allocation['Sollwerte'] as $id => $target) { if (!is_int($id) || !isset($byId[$id]) || !is_int($target)) { throw new InvalidArgumentException('Sollwert ohne eindeutigen Verbraucher.'); } if ($id === $batteryId) { continue; } if ($byId[$id]['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) { throw new InvalidArgumentException('Istleistung anderer zugeteilter Verbraucher muss gemessen sein.'); } $delta += self::number($target, 'Anderer Sollwert') - self::number($byId[$id]['Istleistung_W'], 'Andere Istleistung'); } $delta = self::number($delta, 'Andere Verbraucherabweichung'); $operation['gridW'] = self::number($feedback['gridW'] + $delta, 'Prognostizierte Netzleistung'); $preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now); if ($preview['unmetGridLimitW'] > 1) { throw new InvalidArgumentException('Netzgrenze durch Batterie allein nicht erreichbar.'); } $lower = -self::number($preview['maxDischargeW'] ?? null, 'Vorschau-Entladegrenze'); $upper = self::number($preview['maxChargeW'] ?? null, 'Vorschau-Ladegrenze'); $desired = self::number($preview['previewBatteryW'], 'Vorschau-Sollwert'); $residual = $operation['gridW'] - $actual[$assetId]; $import = $operation['limits']['importW']; $export = $operation['limits']['exportW']; $month = (int) (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('n'); $monthly = $operation['limits']['managerMonthLimitsW'][$month] ?? null; foreach ([$monthly, $checked['point']['importLimitW']] as $limit) { if ($limit !== null) { $import = $import === null ? $limit : min($import, $limit); } } if ($checked['point']['exportLimitW'] !== null) { $export = $export === null ? $checked['point']['exportLimitW'] : min($export, $checked['point']['exportLimitW']); } if ($import !== null) { $upper = min($upper, $import - $residual); } if ($export !== null) { $lower = max($lower, -$export - $residual); } // An offer gap must not turn hold into charging or reverse the computed intent. if ($desired >= 0) { $lower = max($lower, 0.0); } if ($desired <= 0) { $upper = min($upper, 0.0); } $target = self::offeredTarget($battery['Leistungswerte_W'], $desired, $lower, $upper); $old = self::number($allocation['Sollwerte'][$batteryId], 'Bisheriger Batteriesollwert'); $allocation['Sollwerte'][$batteryId] = $target; if (array_key_exists('Restabweichung_W', $allocation)) { $allocation['Restabweichung_W'] = (int) (self::number($allocation['Restabweichung_W'], 'Restabweichung') - ($target - $old)); } return ['allocation' => $allocation, 'report' => [ 'status' => 'allocation_corrected', 'installationId' => $installationId, 'managerId' => $managerId, 'batteryInstanceId' => $batteryId, 'assetId' => $assetId, 'planId' => $checked['plan']['planId'], 'checkedAt' => gmdate('c', $now), 'stepValidUntil' => $checked['point']['validUntil'], 'targetW' => $target, 'gridTargetW' => $preview['gridTargetW'], 'expectedGridW' => $residual + $target, 'otherConsumerPredictedDeltaW' => $delta, 'physicalResponseVerified' => false, ]]; } }