'11111111-2222-4333-8444-555555555555', 'managerId' => 900, 'manager' => ['NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60], 'inverters' => [ ['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1.0], ['ID' => 'battery-wr', 'Typ' => 'battery'], ], 'batteries' => [['ID' => 'battery', 'WechselrichterID' => 'battery-wr', 'Kopplung' => 'ac', 'LeistungVariableID' => 103, 'Leistungsfaktor' => 1.0]], 'objects' => [ 101 => ['parentID' => 201, 'ident' => 'Grid', 'type' => 2], 102 => ['parentID' => 202, 'ident' => 'PV', 'type' => 2], 103 => ['parentID' => 203, 'ident' => 'Battery', 'type' => 2], ], ]; } function setupCreate(array $f): array { return Setup::erstellen($f['installationId'], $f['managerId'], $f['manager'], $f['inverters'], $f['batteries'], static fn(int $id): array => $f['objects'][$id] ?? []); } function setupRecord(array $setup, array $f, int $now, array $changes = []): array { $samples = []; $values = [101 => 1000, 102 => 5000, 103 => 2000]; foreach ($f['objects'] as $id => $o) { $samples[$id] = ['value' => $values[$id], 'updated' => $now, 'changed' => $now - 30, 'parentID' => $o['parentID'], 'ident' => $o['ident']]; $samples[$id] = array_replace($samples[$id], $changes[$id] ?? []); } return Capture::capture($setup['capture'], static fn(int $id): array => $samples[$id], static fn(): int => $now); } if (($argv[1] ?? '') === '--fixture') { $f = setupFixture(); $setup = setupCreate($f); echo json_encode(['setup' => $setup, 'captureJson' => json_encode($setup['capture'], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION), 'record' => setupRecord($setup, $f, 1791450000)], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); exit(0); } $count = 0; function setupCheck(bool $ok, string $label): void { global $count; if (!$ok) { throw new RuntimeException('FAIL ' . $label); } ++$count; echo 'PASS ' . $label . "\n"; } function setupRefuses(callable $call, string $label): void { try { $call(); } catch (InvalidArgumentException $e) { setupCheck(true, $label); return; } throw new RuntimeException('Expected refusal: ' . $label); } $f = setupFixture(); $setup = setupCreate($f); setupCheck($setup === setupCreate($f), 'idempotent deterministic setup'); setupCheck($setup['datasetId'] === $setup['dataset']['datasetId'], 'single dataset identity'); setupCheck($setup['mappingSha256'] === hash('sha256', json_encode($setup['capture'], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)), 'exact PHP capture hash'); setupCheck($setup['inventorySha256'] === $setup['capture']['reportedInventorySha256'], 'inventory identity bound to capture'); setupCheck($setup['dataset']['inventorySha256'] === $setup['inventorySha256'], 'inventory identity bound to dataset'); setupCheck($setup['dataset']['mappingSha256'] === $setup['mappingSha256'], 'mapping identity bound to dataset'); setupCheck($setup['dataset']['minimumTrainingHours'] === 24 && $setup['dataset']['minimumCoverage'] === 0.95, 'training requires sufficient measured basis'); setupCheck($setup['dataset']['historyDays'] === 30 && $setup['dataset']['maximumGapSeconds'] === 5, 'bounded history and gap defaults'); setupCheck($setup['dataset']['formula'] === 'physical_sum_v1' && $setup['dataset']['solarReference'] === null, 'ordinary physical formula'); setupCheck(count($setup['dataset']['sources']) === 3 && $setup['capture']['extraSources'] === [], 'one physical source per measured flow'); $roundtrip = json_decode(json_encode($f, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); setupCheck(setupCreate($roundtrip) === $setup, 'integer versus float JSON normalization stable'); $roundtripCapture = json_decode(json_encode($setup['capture'], JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); setupCheck(Capture::configuration($roundtripCapture) === $setup['capture'], 'stored capture float representation normalized'); $changed = $f; $changed['inverters'] = array_reverse($changed['inverters']); setupCheck(setupCreate($changed) === $setup, 'topology row order irrelevant'); $changed = $f; $changed['installationId'] = '99999999-2222-4333-8444-555555555555'; setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'other installation never shares identity'); $changed = $f; $changed['managerId']++; setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'manager identity bound'); $changed = $f; $changed['manager']['Netzleistungsfaktor'] = 1000; setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'unit changes require new dataset'); $changed = $f; $changed['objects'][103]['ident'] = 'OtherBattery'; setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'changed source ident requires new dataset'); $changed = $f; $changed['objects'][103]['parentID']++; setupCheck(setupCreate($changed)['inventorySha256'] !== $setup['inventorySha256'], 'changed parent requires new inventory'); $changed = $f; $changed['manager']['MesswertMaxAlter'] = 10; setupCheck(array_unique(array_column(setupCreate($changed)['dataset']['sources'], 'maxAgeSeconds')) === [10], 'stricter age retained'); $changed['manager']['MesswertMaxAlter'] = 300; setupCheck(array_unique(array_column(setupCreate($changed)['dataset']['sources'], 'maxAgeSeconds')) === [60], 'larger manager age does not relax default freshness'); $changed = $f; $changed['manager']['Secret'] = 'do-not-export'; $changed['inverters'][0]['Unused'] = 'do-not-export'; $changed['batteries'][0]['Name'] = 'do-not-export'; $changed['objects'][101]['credentials'] = 'do-not-export'; setupCheck(setupCreate($changed) === $setup, 'unknown fields and credentials not copied or hashed'); setupCheck(!str_contains(json_encode($setup), 'controlEnabled') && !str_contains(json_encode($setup), 'confirmedAt'), 'configuration grants no dispatch or device confirmation'); $now = 1791450000; $record = setupRecord($setup, $f, $now); setupCheck($record['mappingSha256'] === $setup['mappingSha256'], 'real capture hashes exactly match dataset'); setupCheck($record['assessment']['baseLoadCandidateW'] === 4000.0, 'physical battery removed exactly once'); setupCheck($record['assessment']['reconstructedGridW'] === 1000.0 && $record['assessment']['arithmeticBalanceErrorW'] === 0.0, 'physical-only balance reconstructs grid'); setupCheck($record['assessment']['controlledVirtualW'] === 0.0 && $record['assessment']['allocationResidualW'] === 0.0 && $record['assessment']['externalEffectW'] === 0.0, 'no invented virtual battery or SDL residual'); setupCheck($record['assessment']['status'] === 'consistent_candidate', 'physical-only observation does not require a virtual source'); setupCheck(!$record['trainingEligible'] && !$record['controlEligible'] && !$record['measurementBoundaryVerified'], 'capture grants no readiness or verified boundary'); $old = setupRecord($setup, $f, $now, [103 => ['updated' => $now - 61]]); setupCheck($old['assessment']['status'] === 'quality_hold', 'stale physical source remains blocked'); $key = array_column($setup['capture']['accounting']['sources'], 'key', 'variableId')[103]; setupCheck($old['raw'][$key]['sourceUpdatedAt'] === $now - 61, 'original stale source timestamp retained'); $wrong = setupRecord($setup, $f, $now, [103 => ['ident' => 'reused']]); setupCheck($wrong['assessment'] === null && $wrong['raw'][$key]['value'] === null, 'changed live identity cannot reuse old mapping'); $future = setupRecord($setup, $f, $now, [103 => ['updated' => $now + 1]]); setupCheck($future['assessment'] === null, 'future source not freshened'); $discharge = setupRecord($setup, $f, $now, [101 => ['value' => -3000], 103 => ['value' => -2000]]); setupCheck($discharge['assessment']['baseLoadCandidateW'] === 4000.0, 'discharge and export signs preserved'); foreach ([ ['installationId', '../not-a-plant'], ['managerId', 0], ] as [$key, $bad]) { $changed = $f; $changed[$key] = $bad; setupRefuses(static fn() => setupCreate($changed), 'invalid ' . $key); } foreach ([0, '60', true] as $bad) { $changed = $f; $changed['manager']['MesswertMaxAlter'] = $bad; setupRefuses(static fn() => setupCreate($changed), 'invalid age ' . gettype($bad)); } foreach ([0, true, '1', INF, NAN] as $bad) { $changed = $f; $changed['manager']['Netzleistungsfaktor'] = $bad; setupRefuses(static fn() => setupCreate($changed), 'invalid factor ' . gettype($bad)); } foreach ([0, 3, '2', null] as $bad) { $changed = $f; $changed['objects'][103]['type'] = $bad; setupRefuses(static fn() => setupCreate($changed), 'non-numeric source type ' . gettype($bad)); } foreach (['virtual', 'sharedStorage'] as $field) { $changed = $f; $changed['objects'][103][$field] = true; setupRefuses(static fn() => setupCreate($changed), 'explicit ' . $field . ' source rejected'); } foreach (['SDLAktiv' => true, 'SDLLeistungVariableID' => 105] as $field => $value) { $changed = $f; $changed['manager'][$field] = $value; setupRefuses(static fn() => setupCreate($changed), 'SDL requires existing explicit mapping ' . $field); } $changed = $f; unset($changed['objects'][103]); setupRefuses(static fn() => setupCreate($changed), 'missing object rejected'); $changed = $f; $changed['inverters'][0]['Typ'] = 'hybrid'; setupRefuses(static fn() => setupCreate($changed), 'ambiguous hybrid PV output rejected'); $changed = $f; $changed['batteries'][0]['Kopplung'] = 'dc'; setupRefuses(static fn() => setupCreate($changed), 'ambiguous DC power boundary rejected'); $changed = $f; $changed['batteries'][0]['LeistungVariableID'] = 101; setupRefuses(static fn() => setupCreate($changed), 'grid source cannot also be physical battery'); $changed = $f; $changed['batteries'][] = $changed['batteries'][0]; $changed['batteries'][1]['ID'] = 'second'; setupRefuses(static fn() => setupCreate($changed), 'shared inverter cannot double count physical storage'); $changed = $f; $changed['batteries'][0]['WechselrichterID'] = 'absent'; setupRefuses(static fn() => setupCreate($changed), 'missing battery inverter rejected'); $changed = $f; $changed['inverters'][] = ['ID' => 'unassigned', 'Typ' => 'battery']; setupRefuses(static fn() => setupCreate($changed), 'unassigned physical battery inverter rejected'); $changed = $f; $changed['inverters'][] = $changed['inverters'][0]; setupRefuses(static fn() => setupCreate($changed), 'duplicate inverter identifier rejected'); $changed = $f; $changed['inverters'] = [$changed['inverters'][1]]; setupRefuses(static fn() => setupCreate($changed), 'missing PV source is not invented as zero'); $c = $setup['capture']['accounting']; unset($c['storageMode']); setupRefuses(static fn() => Accounting::konfigurieren($c), 'legacy mode retains explicit virtual source requirement'); $c = $setup['capture']['accounting']; $c['sources'][0]['role'] = 'controlled_virtual'; setupRefuses(static fn() => Accounting::konfigurieren($c), 'physical-only mode cannot hide virtual allocation'); try { Setup::erstellen($f['installationId'], $f['managerId'], $f['manager'], $f['inverters'], $f['batteries'], static function (int $id): array { throw new RuntimeException('do-not-export'); }); throw new RuntimeException('Expected reader failure'); } catch (InvalidArgumentException $e) { setupCheck(!str_contains($e->getMessage(), 'do-not-export'), 'reader errors do not expose secrets'); } echo "TOTAL $count setup checks passed; synthetic metadata and measurements only.\n";