$id, 'Daten' => [ 'Kopf' => ['Version' => Message::VERSION, 'AbsenderID' => $id, 'EmpfaengerID' => $manager, 'Zeitpunkt' => $now], 'Betriebsart' => Message::BETRIEBSART_PV, 'PrioritaetPV' => 1, 'PrioritaetPeak' => 1, 'Leistungswerte_W' => $offers, 'AenderungMoeglich' => true, 'Verfuegbar' => true, 'Istleistung_W' => $actual, 'Leistungsquelle' => Message::LEISTUNGSQUELLE_GEMESSEN, 'Zustand' => [['Kennung' => 'Sollleistung_W', 'Art' => 'Sollwert', 'Wert' => $actual, 'Einheit' => 'W']], ]]; } function controlFixture(): array { [$response, $installation, $op, $now] = receiverFixture(); $manager = 900; $battery = 901; $binding = ['batteryInstanceId' => $battery, 'assetId' => 'b', 'watchdogEvidence' => 'device-watchdog-reviewed-v1', 'feedbackConfigHash' => str_repeat('a', 64)]; $response['plan']['inputQuality'] = ['loadBasis' => 'base_load', 'accountingEvidenceId' => 'physical-meter-map-v1']; $feedback = ['version' => 1, 'kind' => 'local_physical_feedback', 'installationId' => $installation, 'assetId' => 'b', 'managerId' => $manager, 'batteryInstanceId' => $battery, 'checkedAt' => $now, 'sourceOldestAt' => $now, 'sourceNewestAt' => $now, 'physicalOldestAt' => $now, 'deviceReadConfirmed' => true, 'maxAgeSeconds' => 60, 'configHash' => str_repeat('a', 64), 'gridW' => $op['gridW'], 'physicalStorageW' => 0.0, 'batteryW' => 0.0, 'estimated' => false, 'separatelyMeasured' => true, 'usableForTrial' => true, 'canDispatch' => false]; $op['localFeedback'] = ['b' => $feedback]; return ['envelope' => ['response' => $response, 'receivedAt' => $now], 'installation' => $installation, 'manager' => $manager, 'operation' => $op, 'actual' => ['b' => 0.0], 'allocation' => ['Verteilbudget_W' => 6500, 'Restabweichung_W' => 0, 'Sollwerte' => [901 => 1500, 902 => 5000]], 'consumers' => [controlConsumer($battery, $manager, $now, 0, [['Von_W' => -39000, 'Bis_W' => 39000]]), controlConsumer(902, $manager, $now, 4000, [['Von_W' => 0, 'Bis_W' => 11000]])], 'synchronized' => true, 'binding' => $binding, 'now' => $now]; } function controlRun(array $f): array { return Control::korrigieren($f['envelope'], $f['installation'], $f['manager'], $f['operation'], $f['actual'], $f['allocation'], $f['consumers'], $f['synchronized'], $f['binding'], $f['now']); } function controlReady(array $f): array { return Control::pruefen($f['envelope'], $f['installation'], $f['manager'], $f['operation'], $f['actual'], $f['binding'], $f['now']); } function controlContext(array &$f): void { $f['envelope']['response']['plan']['controlContext'] = Plan::kontext($f['operation']); } $count = 0; function controlCheck(bool $ok, string $label): void { global $count; if (!$ok) { throw new RuntimeException('FAIL ' . $label); } ++$count; echo 'PASS ' . $label . "\n"; } function controlRefuses(callable $call, string $label): void { try { $call(); } catch (InvalidArgumentException $e) { controlCheck(true, $label); return; } throw new RuntimeException('Expected refusal: ' . $label); } $f = controlFixture(); $original = $f; $r = controlRun($f); controlCheck($r['allocation']['Sollwerte'][901] === 2000, 'battery follows grid target after other consumer delta'); controlCheck($r['allocation']['Sollwerte'][902] === 5000, 'other consumer target remains exact'); controlCheck($r['allocation']['Restabweichung_W'] === -500, 'allocation arithmetic reflects battery correction'); controlCheck($r['report']['expectedGridW'] === 0.0 && $r['report']['otherConsumerPredictedDeltaW'] === 1000.0, 'other consumer measured delta counted once'); controlCheck($r['report']['batteryInstanceId'] === 901 && $r['report']['assetId'] === 'b', 'report bound to selected battery'); controlCheck(!$r['report']['physicalResponseVerified'], 'calculation never claims physical response'); controlCheck($f === $original, 'inputs and source timestamps never changed'); $ready = controlReady($f); controlCheck($ready['preview']['previewBatteryW'] === 3000.0 && !$ready['preview']['canDispatch'], 'readiness checks no actuator or other allocation'); controlCheck($ready['preview']['maxChargeW'] === 39000.0 && $ready['preview']['maxDischargeW'] === 39000.0, 'preview reports bounded charge/discharge envelope'); $x = $f; $x['synchronized'] = false; controlRefuses(static fn() => controlRun($x), 'unsynchronized consumers blocked'); $x = $f; unset($x['operation']['localFeedback']); controlRefuses(static fn() => controlReady($x), 'missing verified feedback blocks readiness'); foreach (['estimated' => true, 'separatelyMeasured' => false, 'deviceReadConfirmed' => false, 'usableForTrial' => false] as $key => $value) { $x = $f; $x['operation']['localFeedback']['b'][$key] = $value; controlRefuses(static fn() => controlRun($x), 'feedback gate ' . $key); } foreach (['checkedAt' => $f['now'] - 3, 'sourceOldestAt' => $f['now'] - 61, 'maxAgeSeconds' => 61] as $key => $value) { $x = $f; $x['operation']['localFeedback']['b'][$key] = $value; controlRefuses(static fn() => controlReady($x), 'stale or relaxed feedback gate ' . $key); } $x = $f; $x['operation']['localFeedback']['b']['checkedAt']++; controlRefuses(static fn() => controlRun($x), 'future feedback rejected'); foreach (['managerId' => 800, 'batteryInstanceId' => 903, 'assetId' => 'wrong', 'installationId' => 'wrong'] as $key => $value) { $x = $f; $x['operation']['localFeedback']['b'][$key] = $value; controlRefuses(static fn() => controlRun($x), 'wrong feedback binding ' . $key); } $x = $f; $x['binding']['feedbackConfigHash'] = str_repeat('b', 64); controlRefuses(static fn() => controlRun($x), 'changed source mapping blocked'); foreach (['', 'short', 'explicit_test_plant_watchdog_waiver', ' explicit_test_plant_watchdog_waiver '] as $value) { $x = $f; $x['binding']['watchdogEvidence'] = $value; controlRefuses(static fn() => controlReady($x), 'missing watchdog or test-only waiver is no production evidence'); } $x = $f; unset($x['envelope']['response']['plan']['inputQuality']); controlRefuses(static fn() => controlReady($x), 'missing physical accounting evidence rejected'); $x = $f; $x['envelope']['response']['plan']['inputQuality']['loadBasis'] = 'net_load'; controlRefuses(static fn() => controlRun($x), 'contaminated net-load forecast rejected'); $x = $f; $x['envelope']['receivedAt'] -= 91; controlRefuses(static fn() => controlReady($x), 'expired plan receipt rejected'); $x = $f; $x['envelope']['response']['pending'] = 'new-plan'; controlRefuses(static fn() => controlRun($x), 'pending revision blocks control'); $x = $f; $x['operation']['batteries'][0]['measuredAt'] = gmdate('c', $f['now'] - 61); controlRefuses(static fn() => controlReady($x), 'stale battery limits block readiness'); $x = $f; $x['operation']['batteries'][] = $x['operation']['batteries'][0]; controlRefuses(static fn() => controlRun($x), 'multiple battery topology blocked'); $x = $f; unset($x['allocation']['Sollwerte'][901]); controlRefuses(static fn() => controlRun($x), 'battery absent from ordinary allocation blocked'); $x = $f; $x['consumers'][] = $x['consumers'][0]; controlRefuses(static fn() => controlRun($x), 'duplicate active battery entry blocked'); $x = $f; $x['actual']['b'] = 1000.0; controlRefuses(static fn() => controlRun($x), 'battery actual must come from verified snapshot'); $x = $f; $x['operation']['gridW'] += 1000.0; controlRefuses(static fn() => controlRun($x), 'pre-adjusted grid cannot double count predicted other load'); $x = $f; $x['operation']['localPredictedOtherDeltaW'] = 0.0; controlRefuses(static fn() => controlRun($x), 'previous trial delta cannot leak into ordinary correction'); $x = $f; $x['consumers'][1]['Daten']['Leistungsquelle'] = Message::LEISTUNGSQUELLE_BERECHNET; controlRefuses(static fn() => controlRun($x), 'other allocated consumers need measured actual'); $x = $f; $x['consumers'][1]['Daten']['Kopf']['Zeitpunkt'] -= 61; controlRefuses(static fn() => controlRun($x), 'stale consumer message blocked despite supplied synchronization'); $x = $f; $x['allocation']['Sollwerte'][999] = 1000; controlRefuses(static fn() => controlRun($x), 'unknown allocation target rejected'); $x = $f; $x['consumers'][0]['Daten']['AenderungMoeglich'] = false; controlRefuses(static fn() => controlRun($x), 'battery lock respected'); $x = $f; $x['consumers'][0]['Daten']['Verfuegbar'] = false; controlRefuses(static fn() => controlRun($x), 'unavailable battery blocked'); $x = $f; $x['consumers'][0]['Daten']['Leistungswerte_W'] = []; controlRefuses(static fn() => controlRun($x), 'empty battery offer blocked'); $x = $f; $x['consumers'][0]['Daten']['Leistungswerte_W'] = [['Von_W' => -1000, 'Bis_W' => 1000]]; controlCheck(controlRun($x)['report']['targetW'] === 1000, 'clipping respects advertised battery range'); $x = $f; $x['consumers'][0]['Daten']['Leistungswerte_W'] = [0, 3000, ['Von_W' => 4000, 'Bis_W' => 5000]]; controlCheck(controlRun($x)['report']['targetW'] === 3000, 'discrete offer chosen instead of unavailable gap value'); $x = $f; $x['operation']['batteries'][0]['maxChargeW'] = 500.0; controlCheck(controlRun($x)['report']['targetW'] === 500, 'current BMS limit restricts offer'); $x = $f; $x['operation']['batteries'][0]['socPercent'] = 90.0; controlCheck(controlRun($x)['report']['targetW'] === 0, 'full battery cannot charge from target'); $x['consumers'][0]['Daten']['Leistungswerte_W'] = [['Von_W' => 1000, 'Bis_W' => 5000]]; controlRefuses(static fn() => controlRun($x), 'no safe offer cannot become unoffered zero'); $x = $f; $x['actual']['b'] = 1000.0; $x['operation']['localFeedback']['b']['batteryW'] = 1000.0; $x['consumers'][0]['Daten']['Istleistung_W'] = 1000; controlCheck(controlRun($x)['report']['targetW'] === 3000, 'already flowing battery power counted once'); $x = $f; $x['operation']['gridW'] = 3000.0; $x['operation']['localFeedback']['b']['gridW'] = 3000.0; $x['operation']['batteries'][0]['socPercent'] = 15.0; controlCheck(controlRun($x)['report']['targetW'] === 0, 'reserve blocks discharge although plan requests grid compensation'); $x = $f; $x['operation']['gridW'] = 3000.0; $x['operation']['localFeedback']['b']['gridW'] = 3000.0; $x['operation']['batteries'][0]['dischargeBlocked'] = true; controlCheck(controlRun($x)['report']['targetW'] === 0, 'local discharge hysteresis remains authoritative'); $x = $f; $x['consumers'][] = controlConsumer(903, $x['manager'], $x['now'], 2000, [2000]); $x['consumers'][2]['Daten']['Leistungsquelle'] = Message::LEISTUNGSQUELLE_BERECHNET; controlCheck(controlRun($x)['report']['targetW'] === 2000, 'unallocated other consumers remain already counted in grid'); $x = $f; $x['operation']['gridW'] = 6000.0; $x['operation']['localFeedback']['b']['gridW'] = 6000.0; $x['operation']['limits']['importW'] = 4000.0; $x['operation']['batteries'][0]['maxDischargeW'] = 2000.0; controlContext($x); controlRefuses(static fn() => controlRun($x), 'unreachable hard grid limit refuses correction'); $x = $f; $x['operation']['gridW'] = 3000.0; $x['operation']['localFeedback']['b']['gridW'] = 3000.0; $x['operation']['limits']['importW'] = 2000.0; $x['consumers'][0]['Daten']['Leistungswerte_W'] = [0, ['Von_W' => -1000, 'Bis_W' => -500]]; controlContext($x); controlRefuses(static fn() => controlRun($x), 'unordered invalid offer rejected by message contract'); $x['consumers'][0]['Daten']['Leistungswerte_W'] = [['Von_W' => -1000, 'Bis_W' => -500], 0]; controlRefuses(static fn() => controlRun($x), 'offered discharge insufficient for grid boundary refused'); $x = $f; $x['operation']['batteries'][0]['gridCharging'] = false; controlContext($x); controlCheck(controlRun($x)['report']['targetW'] === 2000, 'PV-only charging respects residual after other consumers'); $source = file_get_contents(__DIR__ . '/../../libs/NetzfahrplanV4Regelung.php'); controlCheck(!preg_match('/\b(?:IPS_|SetValue|GetValue|RequestAction|curl_exec|shell_exec)/', $source), 'pure allocation helper has no I/O or commands'); echo "TOTAL $count control checks passed; synthetic plans and measurements only.\n";