Files
Enelix-EMS/tests/V4Setup/checks.php
T
dh 74a2d6c685
Tests / test (push) Successful in 1m1s
feat(workflow): publish verified forecast controls and consolidated docs
Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
2026-10-08 10:02:58 +00:00

201 lines
12 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/../../libs/NetzfahrplanV4Einrichtung.php';
use Belevo\EnelixEMS\NetzfahrplanV4Einrichtung as Setup;
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme as Capture;
use Belevo\EnelixEMS\NetzfahrplanV4Bilanzierung as Accounting;
function setupFixture(): array
{
return [
'installationId' => '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";