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.
207 lines
12 KiB
PHP
207 lines
12 KiB
PHP
<?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";
|