Files
Enelix-EMS/tests/V4FeedbackSetup/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

328 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
if (PHP_SAPI !== 'cli' || function_exists('IPS_GetKernelVersion')) {
throw new \RuntimeException('Standalone PHP CLI only; never load mocks into Symcon.');
}
require_once __DIR__ . '/../../libs/NetzfahrplanV4Einrichtung.php';
require_once __DIR__ . '/../../libs/NetzfahrplanV4RueckmeldungEinrichtung.php';
require_once __DIR__ . '/../../libs/BatterieNetzfahrplanV4RueckmeldungTrait.php';
$count = 0;
function feedbackSetupCheck(bool $ok, string $label): void
{
global $count;
if (!$ok) throw new \RuntimeException('FAIL ' . $label);
++$count;
echo 'PASS ' . $label . PHP_EOL;
}
function feedbackSetupRefuses(callable $call, string $label): void
{
try { $call(); }
catch (\Throwable $error) { feedbackSetupCheck(true, $label); return; }
throw new \RuntimeException('Expected refusal: ' . $label);
}
function feedbackSetupFixture(): array
{
$assets = [['ID' => 'battery', 'WechselrichterID' => 'battery-wr', 'Kopplung' => 'ac',
'LeistungVariableID' => 103, 'SOCVariableID' => 104, '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],
];
$installationId = '11111111-2222-4333-8444-555555555555';
$setup = NetzfahrplanV4Einrichtung::erstellen($installationId, 900,
['NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60],
[['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1000],
['ID' => 'battery-wr', 'Typ' => 'battery']],
$assets, static fn(int $id): array => $objects[$id]);
return ['capture' => $setup['capture'], 'installationId' => $installationId,
'managerId' => 900, 'batteryId' => 901, 'assets' => $assets,
'controllers' => [['InstanzID' => 901, 'IstleistungVariableID' => 103,
'LadezustandVariableID' => 104, 'NetzleistungVariableID' => 101, 'MesswertMaxAlter' => 60]]];
}
function feedbackSetupCreate(array $f): array
{
return NetzfahrplanV4RueckmeldungEinrichtung::erstellen($f['capture'], $f['installationId'],
$f['managerId'], $f['batteryId'], $f['assets'], $f['controllers']);
}
$f = feedbackSetupFixture();
$config = feedbackSetupCreate($f);
feedbackSetupCheck($config === feedbackSetupCreate($f), 'automatic feedback deterministic');
feedbackSetupCheck($config['installationId'] === $f['installationId'] && $config['managerId'] === 900
&& $config['batteryInstanceId'] === 901 && $config['assetId'] === 'battery', 'single exact plant and controller binding');
feedbackSetupCheck($config['mode'] === 'physical_sum' && !$config['allowEstimatedForTrial'], 'only separately metered physical feedback');
feedbackSetupCheck(count($config['sources']) === 2 && array_column($config['sources'], 'variableId') === [103, 101],
'battery and grid sampled exactly once without PV double count');
feedbackSetupCheck(array_column($config['sources'], 'role') === ['physical', 'grid'], 'no invented request or gateway sources');
feedbackSetupCheck($config['maxAgeSeconds'] === 60 && $config['maxSkewSeconds'] === 30, 'existing strict freshness defaults');
feedbackSetupCheck($config['idleToleranceW'] === 0.0 && $config['trackingToleranceW'] === 0.0, 'no relaxed tracking tolerance');
feedbackSetupCheck(!str_contains(json_encode($config), 'watchdog') && !str_contains(json_encode($config), 'canDispatch')
&& !str_contains(json_encode($config), 'confirmedAt'), 'setup creates neither confirmation nor control permission');
$roundtrip = json_decode(json_encode($f), true);
feedbackSetupCheck(feedbackSetupCreate($roundtrip) === $config, 'JSON float normalization stable');
$changed = $f;
$changed['controllers'][0]['MesswertMaxAlter'] = 8;
feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 8
&& feedbackSetupCreate($changed)['maxSkewSeconds'] === 8, 'controller stricter freshness preserved');
$changed = $f;
$changed['controllers'][0]['MesswertMaxAlter'] = 600;
feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 60, 'large local setting cannot relax feedback freshness');
$changed = $f;
foreach ($changed['capture']['accounting']['sources'] as &$source) {
if ($source['role'] === 'physical_storage') $source['maxAgeSeconds'] = 7;
}
unset($source);
feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 7, 'capture source stricter freshness preserved');
$changed = $f;
$changed['assets'][0]['Name'] = 'private name';
$changed['controllers'][0]['Password'] = 'do-not-export';
feedbackSetupCheck(feedbackSetupCreate($changed) === $config, 'only allowlisted nonsecret fields leave setup');
$changed = $f;
$changed['installationId'] = $changed['capture']['installationId'] = '99999999-2222-4333-8444-555555555555';
feedbackSetupCheck(NetzfahrplanV4Rueckmeldung::fingerprint(feedbackSetupCreate($changed))
!== NetzfahrplanV4Rueckmeldung::fingerprint($config), 'distinct plant cannot share feedback identity');
$changed = $f;
foreach ($changed['capture']['accounting']['sources'] as &$source) {
if ($source['role'] === 'physical_storage') $source['parentId'] = 205;
}
unset($source);
feedbackSetupCheck(NetzfahrplanV4Rueckmeldung::fingerprint(feedbackSetupCreate($changed))
!== NetzfahrplanV4Rueckmeldung::fingerprint($config), 'changed physical identity changes fingerprint');
foreach ([
static function (array &$x): void { $x['managerId']++; },
static function (array &$x): void { $x['batteryId']++; },
static function (array &$x): void { $x['assets'] = []; },
static function (array &$x): void { $x['assets'][] = $x['assets'][0]; },
static function (array &$x): void { $x['controllers'] = []; },
static function (array &$x): void { $x['controllers'][] = $x['controllers'][0]; },
static function (array &$x): void { $x['controllers'][0]['Aktiv'] = false; },
static function (array &$x): void { $x['controllers'][0]['IstleistungVariableID'] = 105; },
static function (array &$x): void { $x['controllers'][0]['LadezustandVariableID'] = 105; },
static function (array &$x): void { $x['controllers'][0]['NetzleistungVariableID'] = 105; },
static function (array &$x): void { $x['controllers'][0]['MesswertMaxAlter'] = 0; },
static function (array &$x): void { unset($x['assets'][0]['SOCVariableID']); },
static function (array &$x): void { $x['assets'][0]['SOCVariableID'] = $x['controllers'][0]['LadezustandVariableID'] = 103; },
static function (array &$x): void { $x['assets'][0]['Virtuell'] = true; },
static function (array &$x): void { $x['assets'][0]['SDLAktiv'] = true; },
static function (array &$x): void { $x['assets'][0]['Kopplung'] = 'dc'; },
static function (array &$x): void { $x['assets'][0]['ID'] = 'different'; },
static function (array &$x): void { $x['assets'][0]['Leistungsfaktor'] = -1; },
static function (array &$x): void { unset($x['capture']['accounting']['storageMode']); },
] as $index => $change) {
$changed = $f;
$change($changed);
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'ambiguous or incompatible binding ' . $index);
}
foreach (['grid', 'physical_storage'] as $role) {
foreach ([-1, 1000] as $factor) {
$changed = $f;
foreach ($changed['capture']['accounting']['sources'] as &$source) {
if ($source['role'] === $role) $source['factorToW'] = $factor;
}
unset($source);
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'controller raw W units ' . $role . ' ' . $factor);
}
}
$changed = $f;
$changed['capture']['accounting']['sources'][] = [
'key' => 'other', 'role' => 'reference', 'variableId' => 105, 'parentId' => 205,
'ident' => 'Other', 'factorToW' => 1.0, 'maxAgeSeconds' => 60, 'dependsOn' => [],
];
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'specialist accounting roles require explicit mapping');
$changed = $f;
$changed['capture']['accounting']['sources'][0]['dependsOn'] = ['grid'];
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'derived physical source cannot become automatic feedback');
$changed = $f;
$changed['capture']['extraSources'][] = [
'key' => 'soc', 'variableId' => 104, 'parentId' => 203, 'ident' => 'SOC', 'unit' => 'percent',
];
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'specialist extra sources require explicit mapping');
$GLOBALS['feedbackTestNow'] = 1791450000;
$GLOBALS['feedbackTestDeviceCalls'] = 0;
$GLOBALS['feedbackTestGetterCalls'] = 0;
$GLOBALS['feedbackTestManagerRunning'] = false;
$GLOBALS['feedbackTestGetterConfig'] = '';
$GLOBALS['feedbackTestReadSucceeds'] = true;
$GLOBALS['feedbackTestModules'] = [201 => NetzfahrplanV4Geraeteabruf::MBUS_DEVICE, 203 => NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE];
$GLOBALS['feedbackTestObjects'] = [101 => ['ParentID' => 201, 'ObjectIdent' => 'Grid'], 103 => ['ParentID' => 203, 'ObjectIdent' => 'Battery']];
$GLOBALS['feedbackTestValues'] = [101 => 1000, 103 => 250];
function time(): int { return $GLOBALS['feedbackTestNow']; }
function usleep(int $duration): void {}
function ENELIX_GetV4RueckmeldungKonfiguration(int $manager, int $battery): string
{
++$GLOBALS['feedbackTestGetterCalls'];
if ($GLOBALS['feedbackTestManagerRunning']) throw new \RuntimeException('forbidden Manager -> battery cache -> Manager cycle');
if ($manager !== 900 || $battery !== 901) throw new \RuntimeException('wrong getter invocation');
return $GLOBALS['feedbackTestGetterConfig'];
}
function IPS_SemaphoreEnter(string $key, int $timeout): bool { return true; }
function IPS_SemaphoreLeave(string $key): void {}
function IPS_VariableExists(int $id): bool { return isset($GLOBALS['feedbackTestObjects'][$id]); }
function IPS_GetVariable(int $id): array
{
return ['VariableUpdated' => time() - 120, 'VariableChanged' => time() - 180];
}
function IPS_GetObject(int $id): array { return $GLOBALS['feedbackTestObjects'][$id]; }
function GetValue(int $id): int { return $GLOBALS['feedbackTestValues'][$id]; }
function IPS_InstanceExists(int $id): bool { return isset($GLOBALS['feedbackTestModules'][$id]); }
function IPS_GetInstance(int $id): array { return ['ModuleInfo' => ['ModuleID' => $GLOBALS['feedbackTestModules'][$id]]]; }
function MBUS_UpdateValues(int $id): bool
{
++$GLOBALS['feedbackTestDeviceCalls'];
return $GLOBALS['feedbackTestReadSucceeds'];
}
function ModBus_RequestRead(int $id): bool { return MBUS_UpdateValues($id); }
final class FeedbackSetupBattery
{
use BatterieNetzfahrplanV4RueckmeldungTrait;
public int $InstanceID = 901;
public array $managers = [], $buffers = [], $timers = [];
public string $property = '{}';
public function register(): void { $this->registriereV4Rueckmeldung(); }
public function configure(): void { $this->konfiguriereV4Rueckmeldung(); }
public function tick(): void { $this->aktualisiereV4Rueckmeldung(); }
public function effective(): string { return $this->effektiveV4RueckmeldungKonfiguration(); }
private function zugeordneteManagerIDs(): array
{
if ($GLOBALS['feedbackTestManagerRunning']) throw new \RuntimeException('cache must not inspect Manager assignment');
return $this->managers;
}
protected function ReadPropertyString(string $key): string { return $this->property; }
protected function RegisterPropertyString(string $key, string $value): void {}
protected function RegisterTimer(string $key, int $interval, string $script): void { $this->timers[$key] = compact('interval', 'script'); }
protected function SetTimerInterval(string $key, int $interval): void { $this->timers[$key]['interval'] = $interval; }
protected function SetBuffer(string $key, string $value): void { $this->buffers[$key] = $value; }
protected function GetBuffer(string $key): string { return $this->buffers[$key] ?? ''; }
}
$battery = new FeedbackSetupBattery();
$battery->register();
$timerScript = $battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['script'];
feedbackSetupCheck($timerScript === "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', false);",
'native timer wrapper unchanged');
$battery->configure();
$battery->tick();
feedbackSetupCheck($battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['interval'] === 1000,
'existing sampler discovers assignment after battery setup');
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 0 && $GLOBALS['feedbackTestGetterCalls'] === 0,
'unassigned battery neither queries manager nor reads devices');
$battery->managers = [900];
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 0, 'empty manager config when learning off performs no device I/O');
$GLOBALS['feedbackTestGetterConfig'] = json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'assignment alone never produces fake fresh sample');
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 2, 'late manager assignment uses existing sampler without ApplyChanges');
$sample = json_decode($battery->GetV4BatterieRueckmeldungCache(), true);
feedbackSetupCheck($sample['batteryW'] === 250 && $sample['gridW'] === 1000, 'confirmed physical snapshot values');
feedbackSetupCheck($sample['deviceReadConfirmed'] && $sample['separatelyMeasured'] && !$sample['estimated'], 'real adapter response required');
feedbackSetupCheck($sample['sourceVariableOldestAt'] === time() - 120 && $sample['sourceOldestAt'] === time(),
'original unchanged variable timestamps retained separately from device confirmation');
feedbackSetupCheck(!$sample['canDispatch'] && !$sample['gatewayWatchdogVerified'], 'physical sampler never invents permission or watchdog');
$buffers = $battery->buffers;
$calls = $GLOBALS['feedbackTestDeviceCalls'];
$getters = $GLOBALS['feedbackTestGetterCalls'];
$GLOBALS['feedbackTestManagerRunning'] = true;
$battery->GetV4BatterieRueckmeldungCache();
feedbackSetupCheck($battery->buffers === $buffers && $GLOBALS['feedbackTestDeviceCalls'] === $calls,
'cache getter remains device I/O free and never renews timestamps');
feedbackSetupCheck($GLOBALS['feedbackTestGetterCalls'] === $getters,
'Manager -> battery cache never calls back into Manager or assignment lookup');
$GLOBALS['feedbackTestManagerRunning'] = false;
$context = json_decode($buffers['V4ConfirmedFeedbackContext'], true);
feedbackSetupCheck($context['autoMode'] && $context['propertyHash'] === hash('sha256', '{}')
&& json_decode($context['configuration'], true) === $config, 'sampler stores exact automatic probe context');
foreach ([
['autoMode', false], ['propertyHash', str_repeat('0', 64)], ['configuration', '{}'],
] as [$key, $value]) {
$brokenContext = $context;
$brokenContext[$key] = $value;
$battery->buffers['V4ConfirmedFeedbackContext'] = json_encode($brokenContext);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'cached context drift refused: ' . $key);
$battery->buffers = $buffers;
}
foreach (['installationId', 'assetId', 'managerId', 'batteryInstanceId', 'configHash'] as $key) {
$brokenSample = $sample;
$brokenSample[$key] = is_int($brokenSample[$key]) ? $brokenSample[$key] + 1 : 'wrong';
$battery->buffers['V4ConfirmedFeedback'] = json_encode($brokenSample);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'cached probe binding drift refused: ' . $key);
$battery->buffers = $buffers;
}
$battery->property = json_encode($config);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic to explicit property change invalidates old sample');
$battery->property = '{}';
$GLOBALS['feedbackTestNow'] += 3;
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic cache expires after two seconds without contacting Manager');
$GLOBALS['feedbackTestNow'] -= 3;
$battery->buffers['V4ConfirmedFeedbackMono'] = (string) (hrtime(true) / 1e9 - 3);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic monotonic cache TTL remains two seconds');
$battery->buffers = $buffers;
$changedConfig = $config;
$changedConfig['maxAgeSeconds'] = 10;
$GLOBALS['feedbackTestGetterConfig'] = json_encode($changedConfig);
$getters = $GLOBALS['feedbackTestGetterCalls'];
feedbackSetupCheck(json_decode($battery->GetV4BatterieRueckmeldungCache(), true)['configHash'] === $sample['configHash']
&& $GLOBALS['feedbackTestGetterCalls'] === $getters,
'still-fresh probe stays bound to sampled configuration; Manager drift is observed only by sampler');
$battery->tick();
feedbackSetupCheck(json_decode($battery->GetV4BatterieRueckmeldungCache(), true)['configHash']
=== NetzfahrplanV4Rueckmeldung::fingerprint($changedConfig), 'next confirmed sample adopts changed automatic config');
$GLOBALS['feedbackTestGetterConfig'] = '';
$calls = $GLOBALS['feedbackTestDeviceCalls'];
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls && $battery->buffers['V4ConfirmedFeedback'] === '',
'learning off clears automatic cache without device I/O');
$GLOBALS['feedbackTestGetterConfig'] = json_encode($config);
$battery->managers = [900, 902];
$getters = $GLOBALS['feedbackTestGetterCalls'];
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls && $GLOBALS['feedbackTestGetterCalls'] === $getters,
'ambiguous manager assignment does not choose or read a device');
$battery->managers = [900];
$battery->property = " \n" . json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) . " \n";
$explicit = $battery->property;
$getters = $GLOBALS['feedbackTestGetterCalls'];
feedbackSetupCheck($battery->effective() === $explicit, 'explicit specialist configuration retained byte for byte');
$battery->tick();
feedbackSetupCheck($battery->property === $explicit && $GLOBALS['feedbackTestGetterCalls'] === $getters,
'explicit mapping never calls manager or rewrites properties');
feedbackSetupCheck($battery->buffers['V4ConfirmedFeedbackConfig'] === hash('sha256', $explicit),
'existing explicit configuration cache hashing unchanged');
$GLOBALS['feedbackTestManagerRunning'] = true;
$battery->GetV4BatterieRueckmeldungCache();
$GLOBALS['feedbackTestManagerRunning'] = false;
feedbackSetupCheck($GLOBALS['feedbackTestGetterCalls'] === $getters, 'explicit cache also performs no Manager callbacks');
$battery->property .= ' ';
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'explicit property byte drift invalidates probe');
$battery->property = '{}';
$badBinding = $config;
$badBinding['managerId'] = 902;
$GLOBALS['feedbackTestGetterConfig'] = json_encode($badBinding);
$calls = $GLOBALS['feedbackTestDeviceCalls'];
feedbackSetupRefuses(static fn() => $battery->tick(), 'manager returned wrong binding refused');
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls, 'wrong automatic binding refused before device I/O');
$GLOBALS['feedbackTestGetterConfig'] = json_encode($config);
$GLOBALS['feedbackTestReadSucceeds'] = false;
feedbackSetupRefuses(static fn() => $battery->tick(), 'failed physical device response is never confirmed');
feedbackSetupCheck($battery->buffers['V4ConfirmedFeedback'] === '', 'failed response clears old confirmed sample');
$GLOBALS['feedbackTestReadSucceeds'] = true;
$GLOBALS['feedbackTestModules'] = [201 => 'unsupported', 203 => 'unsupported'];
$calls = $GLOBALS['feedbackTestDeviceCalls'];
feedbackSetupRefuses(static fn() => $battery->tick(), 'virtual or unsupported source parent refused');
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls, 'unsupported parents not refreshed');
feedbackSetupCheck($battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['script'] === $timerScript,
'configuration and sampling never replace native timer callback');
echo 'TOTAL ' . $count . ' feedback setup checks passed' . PHP_EOL;