feat(application): integrate measured-load ingestion training and planner source

This commit is contained in:
ENELIX Agent
2026-10-02 21:00:05 +00:00
parent 44362e4bf5
commit fc024fcfba
96 changed files with 8933 additions and 0 deletions
@@ -0,0 +1,9 @@
{
"libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a",
"libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9",
"libs/ManagerNetzfahrplanV4Trait.php": "13d2867d2b4fe7f8846a08d9b4b81269320db6373d44c5f731a09c21c4912ab7",
"tests/fixtures/NativeV4Scenarios.php": "56a5a0df03cb53ea69f6e199c6d2405041a329c7df540ea8bacc08bfaa8766c6",
"tests/fixtures/V4BezugszaehlerScenarios.php": "7820bab98d1249aac3fee9f015f8da500744c12bfb5b198fcc735cadf3167ec2",
"tests/NetzfahrplanV4BezugszaehlerTest.php": "65b2daa769773198859ab40d2b230b1f3c43f1c618df0c4b90a494d31efb786c",
"tests/NetzfahrplanV4BetriebsdatenTest.php": "4a4f5af4cd86797fc40a4a36a7103a26fcf1e59ab381bafa8f67d35b34419dbc"
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
// OFFLINE ONLY: no Symcon kernel, HTTP requests, timers or connected devices.
if (PHP_SAPI !== 'cli' || function_exists('IPS_GetVariable')) {
fwrite(STDERR, "This is an isolated CLI test, not a Symcon runtime script.\n");
exit(2);
}
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
$manifest = json_decode(file_get_contents(__DIR__ . '/SOURCE_MANIFEST.json'), true, 512, JSON_THROW_ON_ERROR);
foreach ($manifest as $relative => $expected) {
$path = __DIR__ . '/' . $relative;
if (!is_file($path) || hash_file('sha256', $path) !== $expected) {
throw new RuntimeException('Source manifest mismatch: ' . $relative);
}
$command = escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($path);
$lines = [];
exec($command, $lines, $code);
if ($code !== 0) {
throw new RuntimeException('PHP syntax failure: ' . $relative);
}
}
require __DIR__ . '/libs/NetzfahrplanV4Bezugszaehler.php';
require __DIR__ . '/libs/NetzfahrplanV4Betriebsdaten.php';
require __DIR__ . '/libs/ManagerNetzfahrplanV4Trait.php';
require __DIR__ . '/tests/fixtures/NativeV4Scenarios.php';
require __DIR__ . '/tests/fixtures/V4BezugszaehlerScenarios.php';
$native = \Belevo\EnelixEMS\Tests\NativeV4Scenarios::run();
$meters = \Belevo\EnelixEMS\Tests\V4BezugszaehlerScenarios::run();
if (count($native) !== 24 || count($meters) !== 20) {
throw new RuntimeException('Expected 24 native and 20 meter scenarios.');
}
foreach (array_merge($native, $meters) as $name) {
echo 'PASS ', $name, "\n";
}
echo 'PHP ', PHP_VERSION, ': 44 offline conversion scenarios passed.', "\n";
echo "NOT a full EMS suite and NOT an IP-Symcon runtime/physical test.\n";
} catch (Throwable $error) {
fwrite(STDERR, 'FAIL: ' . $error->getMessage() . "\n");
exit(1);
}
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
/** Shadow telemetry only. Does not read/apply a V4 schedule or write actuators. */
trait ManagerNetzfahrplanV4Trait
{
private function registriereNetzfahrplanV4(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4NetzladenErlaubt', false);
$this->RegisterPropertyString('NetzfahrplanV4BatterieOptionen', '{}');
$this->RegisterPropertyInteger('NetzfahrplanV4MessnachweisVariableID', 0);
$this->RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]');
$this->RegisterAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4Senden', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Senden', true);");
}
public function GetNetzfahrplanV4Diagnose(): string
{
try {
$manager = [];
foreach (['NetzleistungVariableID', 'MesswertMaxAlter', 'VerbraucherTimeout',
'Lastspitzenmodus'] as $key) {
$manager[$key] = $this->ReadPropertyInteger($key);
}
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
$manager[$key] = $this->ReadPropertyFloat($key);
}
$manager['V4ControlPlanSource'] = $this->leseJsonAttribut('Netzfahrplan')['sourceModel'] ?? 'legacy-unspecified';
$manager['EinspeisebegrenzungAktiv'] = $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv');
$manager['NetzladenErlaubt'] = $this->ReadPropertyBoolean('NetzfahrplanV4NetzladenErlaubt');
$manager['Monatsgrenzen'] = json_decode($this->ReadPropertyString('Monatsgrenzen'), true, 512, JSON_THROW_ON_ERROR);
$manager['BatterieOptionen'] = json_decode($this->ReadPropertyString('NetzfahrplanV4BatterieOptionen'), true, 512, JSON_THROW_ON_ERROR);
$manager['V4BezugszaehlerQuellen'] = json_decode(
$this->ReadPropertyString('NetzfahrplanV4BezugszaehlerQuellen'), true, 512, JSON_THROW_ON_ERROR
);
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 512, JSON_THROW_ON_ERROR);
$cache = $this->leseJsonAttribut('VerbraucherCache');
$controllers = [];
foreach ($this->aktiveVerbraucherIDs() as $id) {
if (!IPS_InstanceExists($id)
|| IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
continue;
}
$c = ['InstanzID' => $id];
foreach (['LadezustandVariableID', 'MaxLadeleistungVariableID', 'MaxEntladeleistungVariableID',
'IstleistungVariableID', 'MindestLadezustand', 'ReserveLadezustand', 'LadezustandHysterese', 'MesswertMaxAlter', 'Batteriemanagement'] as $key) {
$c[$key] = IPS_GetProperty($id, $key);
}
$entry = $cache[(string) $id] ?? [];
$c['EmpfangenAm'] = $entry['EmpfangenAm'] ?? 0;
$c['Verfuegbar'] = $entry['Daten']['Verfuegbar'] ?? false;
foreach (($entry['Daten']['Zustand'] ?? []) as $state) {
if (($state['Kennung'] ?? '') === 'HystereseAktiv') {
$c['HystereseAktiv'] = $state['Wert'];
}
}
$controllers[] = $c;
}
$evidence = [];
$evidenceID = $this->ReadPropertyInteger('NetzfahrplanV4MessnachweisVariableID');
if ($evidenceID > 0) {
if (!IPS_VariableExists($evidenceID) || IPS_GetVariable($evidenceID)['VariableType'] !== 3) {
throw new RuntimeException('Der konfigurierte Messnachweis ist keine JSON-Stringvariable.');
}
$evidence = json_decode(GetValue($evidenceID), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($evidence)) {
throw new RuntimeException('Ungueltiger Messnachweis.');
}
}
$read = static function (int $id): array {
if (!IPS_VariableExists($id)) {
throw new RuntimeException('Messvariable ' . $id . ' fehlt.');
}
$before = IPS_GetVariable($id);
$value = GetValue($id);
$after = IPS_GetVariable($id);
if ($before['VariableUpdated'] !== $after['VariableUpdated']) {
throw new RuntimeException('Messwert hat sich beim Lesen geaendert; naechsten Durchlauf abwarten.');
}
$object = IPS_GetObject($id);
return ['value' => $value, 'updated' => (int) $after['VariableUpdated'],
'ident' => $object['ObjectIdent'], 'parentID' => (int) $object['ParentID']];
};
return json_encode(NetzfahrplanV4Betriebsdaten::erstellen($manager, $assets, $controllers,
$read, $evidence, time()), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (Throwable $error) {
return json_encode(['status' => 'invalid_inputs', 'reason' => substr($error->getMessage(), 0, 300)], JSON_THROW_ON_ERROR);
}
}
public function GetNetzfahrplanV4Sendestatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4Sendestatus');
}
private function sendeNetzfahrplanV4(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
return;
}
try {
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
}
$snapshot = $this->GetNetzfahrplanV4Diagnose();
$data = json_decode($snapshot, true, 512, JSON_THROW_ON_ERROR);
if (!isset($data['operation'])) {
throw new RuntimeException($data['reason'] ?? 'Betriebsdaten unvollstaendig.');
}
// Decoding as objects preserves empty JSON dictionaries in the API contract.
$payload = json_decode($snapshot, false, 512, JSON_THROW_ON_ERROR);
if (!isset($payload->operation)) {
throw new RuntimeException('Keine konsistenten Betriebsdaten.');
}
$id = $this->ReadAttributeString('LizenzInstallationID');
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
if (!preg_match('/^[0-9a-f-]{36}$/i', $id) || $token === '') {
throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.');
}
$url = 'https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4/operation';
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('V4-Verbindung konnte nicht vorbereitet werden.');
}
try {
curl_setopt_array($handle, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_POSTFIELDS => json_encode($payload->operation, JSON_THROW_ON_ERROR),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $token]]);
$answer = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
if ($answer === false || $status !== 200) {
throw new RuntimeException('V4-Schattenanbindung HTTP ' . $status . '; bestehende Regelung unveraendert.');
}
if (strlen($answer) > 65536) {
throw new RuntimeException('V4-Antwort ist zu gross.');
}
$ack = json_decode($answer, true, 512, JSON_THROW_ON_ERROR);
if (!in_array($ack['status'] ?? '', ['stored', 'duplicate', 'archived_older'], true)) {
throw new RuntimeException('Unerwartete V4-Annahmebestaetigung.');
}
} finally {
curl_close($handle);
}
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([
'status' => 'sent_shadow', 'capturedAt' => $payload->operation->observedAt,
'acceptance' => $ack['status'], 'warnings' => $data['warnings'], 'liveEnabled' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $error) {
// Only this sender's diagnosis changes; never SetStatus, live plan or actuator commands.
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([
'status' => 'error', 'reason' => substr($error->getMessage(), 0, 300), 'liveEnabled' => false], JSON_THROW_ON_ERROR));
}
}
}
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
require_once __DIR__ . '/NetzfahrplanV4Bezugszaehler.php';
/** Read-only conversion. Meter evidence is never derived from a configured limit. */
final class NetzfahrplanV4Betriebsdaten
{
private static function number($value, string $label, float $min = -1.0e9, float $max = 1.0e9): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value)
|| $value < $min || $value > $max) {
throw new InvalidArgumentException($label . ': ungueltiger Zahlenwert.');
}
return (float) $value;
}
private static function timestamp($value): int
{
if (!is_string($value) || !preg_match('/(?:Z|[+-]\d{2}:\d{2})$/', $value)) {
throw new InvalidArgumentException('Messnachweis benoetigt einen Zeitpunkt mit Zeitzone.');
}
return (new DateTimeImmutable($value))->getTimestamp();
}
/** $read returns ['value' => int|float, 'updated' => int], without changing a device. */
private static function measurement(callable $read, int $id, int $now, int $age, string $label): array
{
if ($id <= 0) {
throw new InvalidArgumentException($label . ': Messquelle fehlt.');
}
$m = $read($id);
if (!isset($m['updated']) || !is_int($m['updated']) || $m['updated'] > $now
|| $now - $m['updated'] > $age) {
throw new InvalidArgumentException($label . ': Messwert fehlt, ist veraltet oder liegt in der Zukunft.');
}
return ['value' => self::number($m['value'] ?? null, $label), 'updated' => $m['updated']];
}
/** No inference of paid peak or elapsed energy from power, reserve or forecast. */
private static function evidence(array $input, int $now, string $meterID, array &$warnings): array
{
$peaks = [];
$past = null;
if ($input === []) {
$warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.';
return [(object) [], null];
}
if ($meterID === '' || ($input['version'] ?? null) !== 1 || ($input['meterId'] ?? null) !== $meterID) {
throw new InvalidArgumentException('Messnachweis: falsche Version oder Bezugszaehler-Zuordnung.');
}
$measured = self::timestamp($input['measuredAt'] ?? null);
if ($measured > $now || $now - $measured > 120) {
throw new InvalidArgumentException('Messnachweis ist nicht aktuell.');
}
$month = (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('Y-m');
foreach (($input['measuredPeaks'] ?? []) as $key => $p) {
if (!is_string($key) || !preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $key) || $key > $month
|| !in_array($p['source'] ?? '', ['meter_month_register', 'verified_month_history', 'verified_new_month'], true)) {
throw new InvalidArgumentException('Messnachweis: Monatspeak ist kein gueltiger Messnachweis.');
}
$peaks[$key] = ['kw' => self::number($p['kw'] ?? null, 'Monatspeak', 0.0), 'source' => $p['source']];
}
if (!array_key_exists($month, $peaks)) {
$warnings[] = 'Vollstaendiger Monatspeak fuer ' . $month . ' fehlt; Managergrenze ist kein Ersatz.';
}
$q = intdiv($now, 900) * 900;
if (isset($input['quarterPast'])) {
$p = $input['quarterPast'];
// Stale quarter evidence remains absent, not extrapolated to the decision time.
if ($measured === $now && self::timestamp($p['start'] ?? null) === $q
&& ($p['measuredSeconds'] ?? null) === $now - $q) {
$past = ['start' => gmdate('c', $q),
'measuredSeconds' => $now - $q,
'importKwh' => self::number($p['importKwh'] ?? null, 'Viertelstundenenergie', 0.0)];
} else {
$warnings[] = 'Viertelstundenenergie passt nicht exakt zum Entscheidungszeitpunkt; nicht verwendet.';
}
}
if ($now !== $q && $past === null) {
$warnings[] = 'Bisherige Energie der laufenden Viertelstunde fehlt; keine Hochrechnung als Messung.';
}
return [(object) $peaks, $past];
}
/** Inputs are explicit non-secret configuration/cache fields, not full instance settings. */
public static function erstellen(array $manager, array $assets, array $controllers, callable $read, array $evidence, int $now): array
{
$warnings = [];
$meter = NetzfahrplanV4Bezugszaehler::lesen(
$manager['V4BezugszaehlerQuellen'] ?? [], $read, $now
);
$grid = self::measurement($read, (int) ($manager['NetzleistungVariableID'] ?? 0), $now,
(int) ($manager['MesswertMaxAlter'] ?? 60), 'Netzleistung');
$gridW = $grid['value'] * self::number($manager['Netzleistungsfaktor'] ?? 1.0, 'Netzleistungsfaktor');
$mode = $manager['Lastspitzenmodus'] ?? 0;
$import = null;
$months = [];
if ($mode === 1) {
$import = self::number($manager['Lastspitzengrenze'] ?? null, 'Managergrenze', 0.0);
} elseif ($mode === 2) {
foreach (($manager['Monatsgrenzen'] ?? []) as $row) {
$m = $row['MonatIndex'] ?? null;
if (!is_int($m) || $m < 1 || $m > 12 || isset($months[(string) $m])) {
throw new InvalidArgumentException('Monatsgrenzen fehlen oder sind doppelt.');
}
$months[(string) $m] = self::number($row['Grenze_W'] ?? null, 'Monatsgrenze', 0.0);
}
if (count($months) !== 12) {
throw new InvalidArgumentException('Alle zwoelf Monatsgrenzen werden benoetigt.');
}
} elseif ($mode !== 0) {
throw new InvalidArgumentException('Unbekannter Lastspitzenmodus.');
}
$export = !empty($manager['EinspeisebegrenzungAktiv'])
? self::number($manager['Einspeisegrenze'] ?? null, 'Einspeisegrenze', 0.0) : null;
$result = [];
$seen = [];
$used = [];
foreach ($assets as $asset) {
$id = $asset['ID'] ?? '';
if (!is_string($id) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', $id) || isset($seen[$id])) {
throw new InvalidArgumentException('Batterie-ID fehlt oder ist doppelt.');
}
$seen[$id] = true;
$socID = (int) ($asset['SOCVariableID'] ?? 0);
$matches = array_values(array_filter($controllers, static function (array $c) use ($socID): bool {
return $socID > 0 && ($c['LadezustandVariableID'] ?? 0) === $socID;
}));
if (count($matches) !== 1) {
throw new InvalidArgumentException($id . ': keine eindeutige aktive Batterieinstanz zur SOC-Quelle.');
}
$c = $matches[0];
$controllerID = $c['InstanzID'];
if (isset($used[$controllerID])) {
throw new InvalidArgumentException('Eine Batterieinstanz darf nicht doppelt bilanziert werden.');
}
$used[$controllerID] = true;
if (($asset['LeistungVariableID'] ?? 0) !== ($c['IstleistungVariableID'] ?? 0)) {
throw new InvalidArgumentException($id . ': Topologie und Batterieinstanz verwenden verschiedene Leistungsmessungen.');
}
$opts = $manager['BatterieOptionen'][$id] ?? [];
$nominal = self::number($asset['Nennkapazitaet_kWh'] ?? null, 'Nennkapazitaet', 0.001);
$usable = self::number($asset['Nutzkapazitaet_kWh'] ?? null, 'Nutzkapazitaet', 0.001, $nominal);
if (abs($nominal - $usable) > 1.0e-6 && !array_key_exists('SOCKapazitaet_kWh', $opts)) {
throw new InvalidArgumentException($id . ': SOC-Kapazitaetsbasis bei verschiedener Nenn-/Nutzkapazitaet bestaetigen.');
}
$capacity = self::number($opts['SOCKapazitaet_kWh'] ?? $nominal, 'SOC-Kapazitaet', 0.001, $nominal);
$age = (int) ($c['MesswertMaxAlter'] ?? 30);
$soc = self::measurement($read, $socID, $now, $age, $id . ' SOC');
$charge = self::measurement($read, (int) $c['MaxLadeleistungVariableID'], $now, $age, $id . ' max. Laden');
$discharge = self::measurement($read, (int) $c['MaxEntladeleistungVariableID'], $now, $age, $id . ' max. Entladen');
$min = max(self::number($c['MindestLadezustand'], 'BMS-Minimum', 0, 100),
self::number($c['ReserveLadezustand'], 'Betriebsreserve', 0, 100));
$max = self::number($opts['MaxSOC_Prozent'] ?? 100.0, 'Maximal-SOC', $min, 100);
$socValue = self::number($soc['value'], 'SOC', 0, 100);
$physicalMin = self::number($c['MindestLadezustand'], 'Technisches Minimum', 0, $min);
if ($socValue < $physicalMin || $socValue > $max) {
throw new InvalidArgumentException($id . ': SOC ausserhalb des Planungsbereichs; kein kuenstliches Anheben.');
}
if (($c['EmpfangenAm'] ?? 0) > $now || $now - ($c['EmpfangenAm'] ?? 0) > (int) ($manager['VerbraucherTimeout'] ?? 60)) {
throw new InvalidArgumentException($id . ': Verbraucher-Rueckmeldung veraltet.');
}
$maxCharge = min(self::number($charge['value'], 'Ladeleistung', 0), 1000 * self::number($asset['MaxLadeleistung_kW'], 'Nennladeleistung', 0));
$maxDischarge = min(self::number($discharge['value'], 'Entladeleistung', 0), 1000 * self::number($asset['MaxEntladeleistung_kW'], 'Nennentladeleistung', 0));
if (($c['Verfuegbar'] ?? false) !== true || ($c['Batteriemanagement'] ?? 0) !== 2) {
$maxCharge = $maxDischarge = 0.0;
$warnings[] = $id . ': nicht fuer Managerregelung verfuegbar; keine Batterieleistung eingeplant.';
}
$rearm = min($max, $min + self::number($c['LadezustandHysterese'] ?? 0.0, 'Entladehysterese', 0, 100));
$blocked = !empty($c['HystereseAktiv']) || $socValue <= $min;
if ($blocked) {
$warnings[] = $id . ': Entladesperre im Modell aktiv bis zur Wiederfreigabe nach vorheriger Ladung.';
}
if ($socValue < $min) {
$warnings[] = $id . ': unter Betriebsreserve; realen SOC behalten und nur zulassige Wiederaufladung planen.';
}
$result[] = ['id' => $id, 'capacityKwh' => $capacity, 'socPercent' => $socValue,
'minSocPercent' => $min, 'maxSocPercent' => $max, 'maxChargeW' => $maxCharge,
'maxDischargeW' => $maxDischarge, 'measuredAt' => gmdate('c', min($soc['updated'], $charge['updated'], $discharge['updated'])),
'gridCharging' => ($manager['NetzladenErlaubt'] ?? false) === true,
'physicalMinSocPercent' => $physicalMin, 'recoveryAllowed' => true,
'dischargeBlocked' => $blocked, 'rearmSocPercent' => $rearm];
}
if ($result === []) {
throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.');
}
[$peaks, $past] = self::evidence($evidence, $now, $meter['meterId'], $warnings);
$payload = ['version' => 1, 'observedAt' => gmdate('c', $now), 'gridW' => $gridW,
'meteringBoundary' => 'common_pcc', 'batteries' => $result,
'limits' => ['importW' => $import, 'exportW' => $export, 'managerMonthLimitsW' => (object) $months],
'measuredPeaks' => $peaks, 'quarterPast' => $past];
// A fresh acquisition sample is an operational estimate. It does NOT
// claim a calibrated billing-period boundary or full historical coverage.
$policy = [
'adapterVersion' => 'v4-native-estimated-meter-1',
'sourcePlan' => $manager['V4ControlPlanSource'] ?? 'legacy-unspecified',
'mode' => $mode, 'importW' => $import, 'exportW' => $export,
'months' => $months,
'batteryPolicy' => array_map(static function (array $b): array {
return array_intersect_key($b, array_flip(['id', 'capacityKwh', 'minSocPercent', 'maxSocPercent', 'physicalMinSocPercent', 'rearmSocPercent']));
}, $result),
];
$payload['meterObservation'] = [
'meterId' => $meter['meterId'], 'sampleAt' => gmdate('c', $now),
'powerW' => $gridW, 'totalImportKwh' => $meter['totalKwh'],
'controlPolicyId' => hash('sha256', json_encode($policy, JSON_THROW_ON_ERROR)),
];
$payload['eventId'] = 'symcon-operation-' . hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
return ['status' => $warnings === [] ? 'ready_shadow' : 'incomplete_shadow', 'warnings' => $warnings, 'bezugszaehler' => $meter, 'operation' => $payload];
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/** Read-only active-import counter sum, independent of the legacy energy counter.
* Observation timestamps are NOT proof of a billing-quarter boundary or history.
*/
final class NetzfahrplanV4Bezugszaehler
{
public static function quellen(array $sources): array
{
if ($sources === [] || count($sources) > 8
|| array_keys($sources) !== range(0, count($sources) - 1)) {
throw new InvalidArgumentException('V4 benoetigt 1 bis 8 explizite Wirkenergie-Bezugsquellen.');
}
$result = [];
$parent = null;
foreach ($sources as $source) {
if (!is_array($source) || count($source) !== 5
|| ($source['Messgroesse'] ?? null) !== 'WirkenergieBezug') {
throw new InvalidArgumentException('V4 Bezugszaehler: Messgroesse oder Quellenschema ungueltig.');
}
$id = $source['VariableID'] ?? null;
$pid = $source['ElternID'] ?? null;
$ident = $source['Ident'] ?? null;
$factor = $source['FaktorZuKWh'] ?? null;
if (!is_int($id) || $id <= 0 || !is_int($pid) || $pid <= 0
|| !is_string($ident) || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/D', $ident)
|| (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor)
|| $factor <= 0 || $factor > 1.0e6 || isset($result[$id])) {
throw new InvalidArgumentException('V4 Bezugszaehler: ID, Ident, Einheit oder doppelte Quelle ungueltig.');
}
if ($parent !== null && $parent !== $pid) {
throw new InvalidArgumentException('V4 T1/T2 muessen zum selben physischen Bezugszaehler gehoeren.');
}
$parent = $pid;
foreach ($result as $existing) {
if ($existing['Ident'] === $ident) {
throw new InvalidArgumentException('V4 Bezugszaehler: Register doppelt angegeben.');
}
}
$result[$id] = ['VariableID' => $id, 'ElternID' => $pid, 'Ident' => $ident,
'FaktorZuKWh' => (float) $factor, 'Messgroesse' => 'WirkenergieBezug'];
}
ksort($result, SORT_NUMERIC);
return array_values($result);
}
public static function identitaet(array $sources): string
{
// Source/factor changes invalidate old measurement evidence; names do not matter.
return 'symcon-active-import:' . hash('sha256', json_encode(self::quellen($sources),
JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
}
private static function probe(array $source, callable $read, int $now, int $maxAge): array
{
$m = $read($source['VariableID']);
if (!is_array($m) || ($m['parentID'] ?? null) !== $source['ElternID']
|| ($m['ident'] ?? null) !== $source['Ident']) {
throw new InvalidArgumentException('V4 Bezugszaehler: Variable passt nicht zum konfigurierten Register.');
}
$value = $m['value'] ?? null;
$updated = $m['updated'] ?? null;
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < 0
|| !is_int($updated) || $updated <= 0 || $updated > $now || $now - $updated > $maxAge) {
throw new InvalidArgumentException('V4 Bezugszaehler: Teilwert fehlt, ist veraltet oder ungueltig.');
}
$kwh = (float) $value * $source['FaktorZuKWh'];
if (!is_finite($kwh) || $kwh > 1.0e12) {
throw new InvalidArgumentException('V4 Bezugszaehler: Energie ausserhalb des Messbereichs.');
}
return ['variableId' => $source['VariableID'], 'rawValue' => (float) $value,
'totalKwh' => $kwh, 'updatedAt' => $updated];
}
/** $read must return only value, updated, ident, parentID. Never uses an archive fallback. */
public static function lesen(array $sources, callable $read, int $now, int $maxAge = 60, int $maxSkew = 2): array
{
if ($now <= 0 || $maxAge < 1 || $maxAge > 300 || $maxSkew < 0 || $maxSkew > 5) {
throw new InvalidArgumentException('V4 Bezugszaehler: Zeitfenster ungueltig.');
}
$sources = self::quellen($sources);
$samples = [];
foreach ($sources as $source) {
$samples[] = self::probe($source, $read, $now, $maxAge);
}
// Reread all channels to reject concurrent updates, including changes in the same second.
foreach ($sources as $i => $source) {
if ($samples[$i] !== self::probe($source, $read, $now, $maxAge)) {
throw new InvalidArgumentException('V4 Bezugszaehler wurde waehrend des Lesens aktualisiert.');
}
}
$times = array_column($samples, 'updatedAt');
if (max($times) - min($times) > $maxSkew) {
throw new InvalidArgumentException('V4 Bezugszaehler: T1/T2-Zeitpunkte liegen zu weit auseinander.');
}
return ['meterId' => self::identitaet($sources), 'quantity' => 'active_import', 'unit' => 'kWh',
'totalKwh' => array_sum(array_column($samples, 'totalKwh')),
'observedAt' => gmdate('c', $now), 'sourceObservationFrom' => gmdate('c', min($times)),
'sourceObservationUntil' => gmdate('c', max($times)), 'components' => $samples,
'billingEvidence' => false, 'historyComplete' => false];
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php';
require_once __DIR__ . '/fixtures/NativeV4Scenarios.php';
final class NetzfahrplanV4BetriebsdatenTest extends TestCase
{
public function testOfflineInputScenarios(): void
{
self::assertCount(24, NativeV4Scenarios::run());
}
public function testSenderCannotChangeLivePlanOrActuators(): void
{
$source = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
foreach (['sendeManagerdaten(', 'aktualisiereNetzfahrplan(', '->regeln(', '->SetStatus(', "WriteAttributeString('Netzfahrplan',"] as $forbidden) {
self::assertStringNotContainsString($forbidden, $source);
}
self::assertStringContainsString("RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false)", $source);
self::assertStringContainsString('/prognosis/planner-v4/operation', $source);
self::assertStringNotContainsString('/prognosis/schedule', $source);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../libs/NetzfahrplanV4Bezugszaehler.php';
require_once __DIR__ . '/fixtures/V4BezugszaehlerScenarios.php';
final class NetzfahrplanV4BezugszaehlerTest extends TestCase
{
public function testReadOnlyCounterSources(): void
{
self::assertCount(20, V4BezugszaehlerScenarios::run());
}
public function testNoLegacyMeterOrActuatorFallback(): void
{
$source = file_get_contents(__DIR__ . '/../libs/NetzfahrplanV4Bezugszaehler.php');
foreach (['SetValue(', 'IPS_SetProperty(', 'RequestAction(', 'AC_Set', '53476'] as $forbidden) {
self::assertStringNotContainsString($forbidden, $source);
}
$trait = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
self::assertStringNotContainsString("'NetzbezugEnergieVariableID'", $trait);
self::assertStringContainsString("RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]')", $trait);
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\NetzfahrplanV4Betriebsdaten;
use RuntimeException;
use InvalidArgumentException;
/** Runs offline. No IPS API, network, physical devices or production settings. */
final class NativeV4Scenarios
{
public static function run(): array
{
$now = strtotime('2026-10-01T12:05:00Z');
$m = ['NetzleistungVariableID' => 1, 'NetzbezugEnergieVariableID' => 9,
'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60, 'Lastspitzenmodus' => 1,
'Lastspitzengrenze' => 25000.0, 'EinspeisebegrenzungAktiv' => true, 'Einspeisegrenze' => 25000.0];
$m['V4BezugszaehlerQuellen'] = [
['VariableID' => 6, 'ElternID' => 99, 'Ident' => 'Energy_0', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
['VariableID' => 7, 'ElternID' => 99, 'Ident' => 'Energy_1', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
];
$a = [['ID' => 'ev', 'Nennkapazitaet_kWh' => 161.44, 'Nutzkapazitaet_kWh' => 161.44,
'MaxLadeleistung_kW' => 39.0, 'MaxEntladeleistung_kW' => 30.0, 'SOCVariableID' => 2, 'LeistungVariableID' => 5]];
$c = [['InstanzID' => 42, 'LadezustandVariableID' => 2, 'MaxLadeleistungVariableID' => 3,
'MaxEntladeleistungVariableID' => 4, 'IstleistungVariableID' => 5,
'ReserveLadezustand' => 15.0, 'MindestLadezustand' => 3.0, 'MesswertMaxAlter' => 30,
'Verfuegbar' => true, 'Batteriemanagement' => 2, 'EmpfangenAm' => $now]];
$read = static function (int $id) use ($now): array {
return ['value' => [1 => -5332.0, 2 => 19.0, 3 => 39000.0, 4 => 35000.0, 6 => 3992.203, 7 => 0.0][$id],
'updated' => $now, 'parentID' => 99, 'ident' => [6 => 'Energy_0', 7 => 'Energy_1'][$id] ?? 'other'];
};
$check = static function (bool $ok): void { if (!$ok) { throw new RuntimeException('Assertion failed'); } };
$fail = static function (callable $fn): void {
try { $fn(); } catch (InvalidArgumentException $e) { return; }
throw new RuntimeException('Expected invalid-input rejection');
};
$cases = [];
$build = static function ($mm = null, $aa = null, $cc = null, $r = null, $e = []) use ($m, $a, $c, $read, $now): array {
return NetzfahrplanV4Betriebsdaten::erstellen($mm ?? $m, $aa ?? $a, $cc ?? $c, $r ?? $read, $e, $now);
};
$cases['native_mapping_and_asymmetric_limits'] = static function () use ($build, $check): void {
$x = $build()['operation']; $b = $x['batteries'][0];
$check($x['gridW'] === -5332.0 && $b['capacityKwh'] === 161.44 && $b['minSocPercent'] === 15.0
&& $b['maxChargeW'] === 39000.0 && $b['maxDischargeW'] === 30000.0);
};
$cases['manager_cap_not_paid_peak'] = static function () use ($build, $check): void {
$x = $build()['operation']; $check($x['limits']['importW'] === 25000.0 && (array) $x['measuredPeaks'] === [] && $x['quarterPast'] === null);
};
$cases['empty_maps_are_objects'] = static function () use ($build, $check): void {
$x = json_decode(json_encode($build()['operation'])); $check(is_object($x->measuredPeaks) && is_object($x->limits->managerMonthLimitsW));
};
$cases['zero_limits_not_unlimited'] = static function () use ($build, $m, $check): void {
$m['Einspeisegrenze'] = 0.0; $m['Lastspitzengrenze'] = 0.0; $x = $build($m)['operation'];
$check($x['limits']['importW'] === 0.0 && $x['limits']['exportW'] === 0.0);
};
$cases['disabled_limits_are_null'] = static function () use ($build, $m, $check): void {
$m['EinspeisebegrenzungAktiv'] = false; $m['Lastspitzenmodus'] = 0; $x = $build($m)['operation'];
$check($x['limits']['importW'] === null && $x['limits']['exportW'] === null);
};
$cases['all_monthly_limits'] = static function () use ($build, $m, $check, $fail): void {
$m['Lastspitzenmodus'] = 2; $m['Monatsgrenzen'] = [];
for ($i = 1; $i <= 12; $i++) { $m['Monatsgrenzen'][] = ['MonatIndex' => $i, 'Grenze_W' => $i * 1000]; }
$x = $build($m)['operation']; $check($x['limits']['managerMonthLimitsW']->{'10'} === 10000.0);
array_pop($m['Monatsgrenzen']); $fail(static fn() => $build($m));
};
$cases['no_unconfirmed_soc_capacity'] = static function () use ($build, $a, $m, $fail, $check): void {
$a[0]['Nutzkapazitaet_kWh'] = 140.; $fail(static fn() => $build(null, $a));
$m['BatterieOptionen'] = ['ev' => ['SOCKapazitaet_kWh' => 161.44]];
$check($build($m, $a)['operation']['batteries'][0]['capacityKwh'] === 161.44);
};
$cases['no_duplicate_controller'] = static function () use ($build, $a, $fail): void {
$a[] = array_replace($a[0], ['ID' => 'other']); $fail(static fn() => $build(null, $a));
};
$cases['no_ambiguous_mapping'] = static function () use ($build, $c, $fail): void {
$c[] = array_replace($c[0], ['InstanzID' => 43]); $fail(static fn() => $build(null, null, $c));
};
$cases['mismatched_power_source'] = static function () use ($build, $c, $fail): void {
$c[0]['IstleistungVariableID'] = 100; $fail(static fn() => $build(null, null, $c));
};
$cases['stale_measurements_fail'] = static function () use ($build, $read, $now, $fail): void {
$r = static function ($id) use ($read, $now) { $v = $read($id); $v['updated'] = $now - 61; return $v; };
$fail(static fn() => $build(null, null, null, $r));
};
$cases['stale_consumer_cache_fails'] = static function () use ($build, $c, $now, $fail): void {
$c[0]['EmpfangenAm'] = $now - 61; $fail(static fn() => $build(null, null, $c));
};
$cases['soc_below_reserve_is_not_fabricated'] = static function () use ($build, $read, $check): void {
$r = static function ($id) use ($read) { $v = $read($id); if ($id === 2) { $v['value'] = 5.; } return $v; };
$b = $build(null, null, null, $r)['operation']['batteries'][0];
$check($b['socPercent'] === 5.0 && $b['minSocPercent'] === 15.0 && $b['physicalMinSocPercent'] === 3.0 && $b['recoveryAllowed'] === true && $b['dischargeBlocked'] === true);
};
$cases['unavailable_asset_no_power'] = static function () use ($build, $c, $check): void {
$c[0]['Verfuegbar'] = false; $x = $build(null, null, $c)['operation']['batteries'][0];
$check($x['maxChargeW'] === 0.0 && $x['maxDischargeW'] === 0.0);
};
$cases['hysteresis_not_silently_ignored'] = static function () use ($build, $c, $check): void {
$c[0]['HystereseAktiv'] = true; $x = $build(null, null, $c);
$check($x['operation']['batteries'][0]['dischargeBlocked'] === true && $x['operation']['batteries'][0]['maxDischargeW'] === 30000.0 && count($x['warnings']) > 1);
};
$cases['grid_charge_explicit_opt_in'] = static function () use ($build, $m, $check): void {
$check($build()['operation']['batteries'][0]['gridCharging'] === false);
$m['NetzladenErlaubt'] = true; $check($build($m)['operation']['batteries'][0]['gridCharging'] === true);
};
$ev = ['version' => 1, 'meterId' => \Belevo\EnelixEMS\NetzfahrplanV4Bezugszaehler::identitaet($m['V4BezugszaehlerQuellen']), 'measuredAt' => gmdate('c', $now),
'measuredPeaks' => ['2026-10' => ['kw' => 18.4, 'source' => 'meter_month_register']],
'quarterPast' => ['start' => '2026-10-01T12:00:00Z', 'measuredSeconds' => 300, 'importKwh' => 0.5]];
$cases['actual_meter_evidence_used'] = static function () use ($build, $ev, $check): void {
$x = $build(null, null, null, null, $ev);
$check($x['status'] === 'ready_shadow' && $x['operation']['measuredPeaks']->{'2026-10'}['kw'] === 18.4 && $x['operation']['quarterPast']['importKwh'] === 0.5);
};
$cases['stale_quarter_not_extrapolated'] = static function () use ($build, $ev, $now, $check): void {
$ev['measuredAt'] = gmdate('c', $now - 1); $x = $build(null, null, null, null, $ev);
$check($x['operation']['quarterPast'] === null && count($x['warnings']) > 0);
};
$cases['wrong_meter_evidence_fails'] = static function () use ($build, $ev, $fail): void {
$ev['meterId'] = 'symcon:100'; $fail(static fn() => $build(null, null, null, null, $ev));
};
$cases['planned_peak_not_evidence'] = static function () use ($build, $ev, $fail): void {
$ev['measuredPeaks']['2026-10']['source'] = 'manager_cap'; $fail(static fn() => $build(null, null, null, null, $ev));
};
$cases['deterministic_retry_event_id'] = static function () use ($build, $check): void {
$check($build()['operation']['eventId'] === $build()['operation']['eventId']);
};
$cases['legacy_meter_identity_not_accepted'] = static function () use ($build, $ev, $fail): void {
$ev['meterId'] = 'symcon:9'; $fail(static fn() => $build(null, null, null, null, $ev));
};
$cases['missing_v4_sources_never_use_legacy'] = static function () use ($build, $m, $fail): void {
unset($m['V4BezugszaehlerQuellen']); $fail(static fn() => $build($m));
};
$cases['counter_snapshot_is_not_billing_evidence'] = static function () use ($build, $check): void {
$v = $build(); $check($v['bezugszaehler']['totalKwh'] === 3992.203
&& !$v['bezugszaehler']['billingEvidence'] && (array) $v['operation']['measuredPeaks'] === []);
};
$passed = [];
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
return $passed;
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\NetzfahrplanV4Bezugszaehler as Meter;
use InvalidArgumentException;
use RuntimeException;
/** Offline only: injected readings, no Symcon, archive, network or actuator access. */
final class V4BezugszaehlerScenarios
{
public static function run(): array
{
$now = 1790874000;
$sources = [
['VariableID' => 59607, 'ElternID' => 11490, 'Ident' => 'Energy_0', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
['VariableID' => 26620, 'ElternID' => 11490, 'Ident' => 'Energy_1', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
];
$samples = [
59607 => ['value' => 3992.203, 'updated' => $now, 'parentID' => 11490, 'ident' => 'Energy_0'],
26620 => ['value' => 0.0, 'updated' => $now, 'parentID' => 11490, 'ident' => 'Energy_1'],
];
$check = static function (bool $value): void { if (!$value) { throw new RuntimeException('Assertion failed'); } };
$reject = static function (callable $fn): void {
try { $fn(); } catch (InvalidArgumentException $e) { return; }
throw new RuntimeException('Expected invalid source/sample rejection');
};
$snapshot = static function ($ss = null, $mm = null) use ($sources, $samples, $now): array {
$mm = $mm ?? $samples;
return Meter::lesen($ss ?? $sources, static fn(int $id) => $mm[$id] ?? null, $now);
};
$cases = [];
$cases['zero_t2_is_valid_but_not_history'] = static function () use ($snapshot, $check): void {
$v = $snapshot();
$check(abs($v['totalKwh'] - 3992.203) < 1e-9 && !$v['historyComplete'] && !$v['billingEvidence']);
$check(!isset($v['measuredPeaks']) && !isset($v['quarterPast']));
};
$cases['both_tariffs_are_summed'] = static function () use ($snapshot, $samples, $check): void {
$samples[26620]['value'] = 27.25;
$check(abs($snapshot(null, $samples)['totalKwh'] - 4019.453) < 1e-9);
};
$cases['zero_total_not_replaced'] = static function () use ($snapshot, $samples, $check): void {
$samples[59607]['value'] = 0;
$check($snapshot(null, $samples)['totalKwh'] === 0.0);
};
$cases['source_order_does_not_change_identity'] = static function () use ($sources, $check): void {
$check(Meter::identitaet($sources) === Meter::identitaet(array_reverse($sources)));
};
$cases['changed_factor_invalidates_identity'] = static function () use ($sources, $check): void {
$old = Meter::identitaet($sources); $sources[0]['FaktorZuKWh'] = 0.001;
$check($old !== Meter::identitaet($sources));
};
$cases['changed_variable_invalidates_identity'] = static function () use ($sources, $check): void {
$old = Meter::identitaet($sources); $sources[0]['VariableID'] = 12345;
$check($old !== Meter::identitaet($sources));
};
$cases['no_fallback_to_legacy_source'] = static function () use ($snapshot, $reject): void { $reject(static fn() => $snapshot([])); };
$cases['duplicate_variable_rejected'] = static function () use ($sources, $snapshot, $reject): void {
$sources[1] = $sources[0]; $reject(static fn() => $snapshot($sources));
};
$cases['different_meter_boundary_rejected'] = static function () use ($sources, $snapshot, $reject): void {
$sources[1]['ElternID'] = 48065; $reject(static fn() => $snapshot($sources));
};
$cases['reactive_quantity_rejected'] = static function () use ($sources, $snapshot, $reject): void {
$sources[0]['Messgroesse'] = 'Blindenergie'; $reject(static fn() => $snapshot($sources));
};
$cases['explicit_conversion_required'] = static function () use ($sources, $snapshot, $reject): void {
unset($sources[0]['FaktorZuKWh']); $reject(static fn() => $snapshot($sources));
};
$cases['missing_tariff_not_zero'] = static function () use ($snapshot, $samples, $reject): void {
unset($samples[26620]); $reject(static fn() => $snapshot(null, $samples));
};
$cases['stale_zero_tariff_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[26620]['updated'] -= 61; $reject(static fn() => $snapshot(null, $samples));
};
$cases['future_reading_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[26620]['updated']++; $reject(static fn() => $snapshot(null, $samples));
};
$cases['wrong_ident_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[59607]['ident'] = 'Energy_6'; $reject(static fn() => $snapshot(null, $samples));
};
$cases['wrong_parent_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[59607]['parentID'] = 48065; $reject(static fn() => $snapshot(null, $samples));
};
$cases['invalid_numbers_rejected'] = static function () use ($snapshot, $samples, $reject): void {
foreach ([false, '3992.203', NAN, INF, -1.0] as $bad) {
$samples[59607]['value'] = $bad; $reject(static fn() => $snapshot(null, $samples));
}
};
$cases['skewed_tariff_observations_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[59607]['updated'] -= 3; $reject(static fn() => $snapshot(null, $samples));
};
$cases['concurrent_update_in_same_second_rejected'] = static function () use ($sources, $samples, $now, $reject): void {
$calls = 0;
$read = static function (int $id) use ($samples, &$calls): array {
$v = $samples[$id]; if (++$calls > 2) { $v['value'] += 0.1; } return $v;
};
$reject(static fn() => Meter::lesen($sources, $read, $now));
};
$cases['observation_not_fake_exact_billing_time'] = static function () use ($snapshot, $samples, $now, $check): void {
$samples[59607]['updated'] -= 1;
$v = $snapshot(null, $samples);
$check($v['sourceObservationFrom'] === gmdate('c', $now - 1));
$check($v['sourceObservationUntil'] === gmdate('c', $now) && $v['observedAt'] === gmdate('c', $now));
$check(!$v['billingEvidence']);
};
$passed = [];
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
return $passed;
}
}