feat(workflow): publish verified forecast controls and consolidated docs
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:
dh
2026-10-08 10:02:58 +00:00
parent af69151165
commit 74a2d6c685
82 changed files with 5054 additions and 617 deletions
+206
View File
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../libs/NetzfahrplanV4Regelung.php';
require_once __DIR__ . '/../V4Receiver/fixture.php';
use Belevo\EnelixEMS\Nachrichtenvertrag as Message;
use Belevo\EnelixEMS\NetzfahrplanV4Regelung as Control;
use Belevo\EnelixEMS\NetzfahrplanV4Planpruefung as Plan;
function controlConsumer(int $id, int $manager, int $now, int $actual, array $offers): array
{
return ['InstanzID' => $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";