feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s
Tests / test (push) Successful in 1m1s
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.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
<?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";
|
||||
Reference in New Issue
Block a user