diff --git a/docs/testing/Netzfahrplan-V4-Bezugszaehler.md b/docs/testing/Netzfahrplan-V4-Bezugszaehler.md new file mode 100644 index 0000000..533aa3a --- /dev/null +++ b/docs/testing/Netzfahrplan-V4-Bezugszaehler.md @@ -0,0 +1,66 @@ +# V4: separate Wirkenergie-Bezugsquelle (Entwicklung, nicht live) + +## Lihrenmoos - Befund und geplante Zuordnung + +User hat die erfolgreiche Aktivierung der T2-Zaehlerarchivierung fuer 26620 +im Archiv 16207 bestaetigt. Der exakte Beginn einer lueckenlosen Messhistorie +ist daraus NICHT nachgewiesen; keine historische Null setzen. + +Die vorbereitete separate V4-Konfiguration fuer diese Anlage lautet: + +```json +[ + {"VariableID":59607,"ElternID":11490,"Ident":"Energy_0","FaktorZuKWh":1.0,"Messgroesse":"WirkenergieBezug"}, + {"VariableID":26620,"ElternID":11490,"Ident":"Energy_1","FaktorZuKWh":1.0,"Messgroesse":"WirkenergieBezug"} +] +``` + +Property: NetzfahrplanV4BezugszaehlerQuellen. Diese Datei setzt KEINE Property. +IDs duerfen nicht fuer andere Anlagen kopiert werden. Zuordnung ist durch +Variablenstruktur, Standardtelegramm und Leistungs-/Energiedifferenz stark +plausibilisiert; Abgleich mit Rohtelegramm/Zaehleranzeige steht noch aus. + +53476, dessen gespeicherte Historie, die alte Manager-Property +NetzbezugEnergieVariableID und alle vorhandenen Spiegelereignisse bleiben +unveraendert. V4 liest seine eigenen Quellen, erzeugt keine Spruenge in alten +Summenzaehlern und kann keine Blindenergie durch einen Faktor korrigieren. + +## Vertrag + +Jede Quelle hat eine ausdrueckliche Messgroesse und Umrechnung nach kWh. +Variable, Elterninstanz und Ident werden bei jedem Lesen geprueft. Ein +fehlender, veralteter, ungueltiger oder zeitlich unpassender Teilwert verwirft +die Summe. Ein aktueller echter Nullwert ist gueltig. Zwei aufeinanderfolgende +Lesevorgaenge reduzieren das Risiko einer gemischten Momentaufnahme; sie sind +kein atomarer Zugriff auf das physische Zaehlertelegramm. + +Die Summe wird unter bezugszaehler in der lokalen V4-Diagnose ausgegeben, mit +den Beobachtungszeitpunkten jedes Teilregisters. VariableUpdated ist ein +Symcon-Aktualisierungszeitpunkt, kein Beweis einer exakten Abrechnungsgrenze. +Die Diagnose hat deshalb billingEvidence=false und historyComplete=false. +Eine falsche Messgroessen-Deklaration kann ohne Rohtelegramm nicht automatisch +erkannt werden; Installationspruefung bleibt erforderlich. + +Ein aus Quell-IDs, Eltern-ID, Idents und Umrechnungsfaktoren gebildeter +stabiler SHA256-Schluessel bindet die spaeteren Messnachweise an genau diese +Quelle. Die Reihenfolge von T1/T2 aendert den Schluessel nicht. Quell-/Faktor- +wechsel dagegen invalidieren alte Nachweise. symcon:53476 wird nicht mehr +als V4-Messnachweis akzeptiert. Der Schluessel beweist Zuordnung, nicht den +Wahrheitsgehalt eines gelieferten Monatspeaks. + +## Noch offen + +Der Erzeuger fuer exakte Viertelstundenenergie und den vollstaendigen +Monatspeak ist weiterhin nicht implementiert. Keine Ableitung eines bereits +bezahlten Monatspeaks aus einer Managergrenze oder unvollstaendiger Historie. +Auch 15 Minuten neuer Aufzeichnung beweisen nicht den bisherigen Monatspeak. +Die vorbereitete native Summe ist noch nicht im laufenden Symcon installiert. +V4 bleibt ohne belastbare Nachweise in awaiting_inputs. + +## Nachweis dieses Schritts + +44 synthetische Offline-Szenarien mit PHP CLI 8.4.23 erfolgreich: +20 Quellen-/Summenpruefungen, 24 native Betriebsdatenkonvertierungen. +Die vom Server exportierten Betriebsdaten-, Trait- und Fixture-Dateien wurden +vor dem Test per SHA256 verglichen. Keine Symcon-Laufzeitpruefung und keine +neue Zielcontainer-/vollstaendige PHPUnit-Pruefung in diesem Schritt. diff --git a/docs/testing/Netzfahrplan-V4-Schatten.md b/docs/testing/Netzfahrplan-V4-Schatten.md index 92e33c8..2770226 100644 --- a/docs/testing/Netzfahrplan-V4-Schatten.md +++ b/docs/testing/Netzfahrplan-V4-Schatten.md @@ -13,6 +13,9 @@ geaendert. Der Sender ist nach Installation standardmaessig AUS. - NetzfahrplanV4BatterieOptionen: JSON-Objekt je Topologie-ID. Optional SOCKapazitaet_kWh und MaxSOC_Prozent (sonst 100). Bei unterschiedlicher Nenn- und Nutzkapazitaet ist SOCKapazitaet_kWh zwingend explizit anzugeben. +- NetzfahrplanV4BezugszaehlerQuellen: JSON-Liste, Standard []. Eigene, explizite + Wirkenergie-Bezugsquellen fuer V4; KEIN Rueckfall auf den Altzaehler. + Details und Lihrenmoos-Kandidat: Netzfahrplan-V4-Bezugszaehler.md. - NetzfahrplanV4MessnachweisVariableID: JSON-Stringvariable, Standard 0. Ein vollstaendiger historischer/registerbasierter Messnachweis-Produzent ist NOCH NICHT integriert. Fehlende Nachweise bleiben fehlend. @@ -51,7 +54,7 @@ Nicht ungeprueft deployen: Host-CLI- und Zielsystemtests getrennt nachweisen. ```json { "version": 1, - "meterId": "symcon:53476", + "meterId": "symcon-active-import:", "measuredAt": "2026-10-01T12:05:00Z", "measuredPeaks": { "2026-10": {"kw": 18.4, "source": "meter_month_register"} @@ -79,5 +82,5 @@ muessen separat aktiviert werden. Keine neue Modellwahl oder Aktorfreigabe hier. ## Tests -21 offline Konvertierungsszenarien plus PHPUnit-Strukturpruefungen. Werte sind +24 offline Konvertierungsszenarien und 20 Quellen-/Summen-Szenarien sowie PHPUnit-Strukturpruefungen. Werte sind synthetisch; keine reale HTTP-Anbindung oder Symcon-Laufzeit damit behauptet. diff --git a/libs/ManagerNetzfahrplanV4Trait.php b/libs/ManagerNetzfahrplanV4Trait.php index b501330..bc187a3 100644 --- a/libs/ManagerNetzfahrplanV4Trait.php +++ b/libs/ManagerNetzfahrplanV4Trait.php @@ -16,6 +16,7 @@ trait ManagerNetzfahrplanV4Trait $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);"); @@ -26,7 +27,7 @@ trait ManagerNetzfahrplanV4Trait try { $manager = []; foreach (['NetzleistungVariableID', 'MesswertMaxAlter', 'VerbraucherTimeout', - 'Lastspitzenmodus', 'NetzbezugEnergieVariableID'] as $key) { + 'Lastspitzenmodus'] as $key) { $manager[$key] = $this->ReadPropertyInteger($key); } foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) { @@ -36,6 +37,9 @@ trait ManagerNetzfahrplanV4Trait $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 = []; @@ -80,7 +84,9 @@ trait ManagerNetzfahrplanV4Trait if ($before['VariableUpdated'] !== $after['VariableUpdated']) { throw new RuntimeException('Messwert hat sich beim Lesen geaendert; naechsten Durchlauf abwarten.'); } - return ['value' => $value, 'updated' => (int) $after['VariableUpdated']]; + $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); diff --git a/libs/NetzfahrplanV4Betriebsdaten.php b/libs/NetzfahrplanV4Betriebsdaten.php index b3bf27e..1502536 100644 --- a/libs/NetzfahrplanV4Betriebsdaten.php +++ b/libs/NetzfahrplanV4Betriebsdaten.php @@ -8,6 +8,8 @@ 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 { @@ -43,7 +45,7 @@ final class NetzfahrplanV4Betriebsdaten } /** No inference of paid peak or elapsed energy from power, reserve or forecast. */ - private static function evidence(array $input, int $now, int $meterID, array &$warnings): array + private static function evidence(array $input, int $now, string $meterID, array &$warnings): array { $peaks = []; $past = null; @@ -51,7 +53,7 @@ final class NetzfahrplanV4Betriebsdaten $warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.'; return [(object) [], null]; } - if ($meterID <= 0 || ($input['version'] ?? null) !== 1 || ($input['meterId'] ?? null) !== 'symcon:' . $meterID) { + 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); @@ -92,6 +94,9 @@ final class NetzfahrplanV4Betriebsdaten 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'); @@ -180,12 +185,12 @@ final class NetzfahrplanV4Betriebsdaten if ($result === []) { throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.'); } - [$peaks, $past] = self::evidence($evidence, $now, (int) ($manager['NetzbezugEnergieVariableID'] ?? 0), $warnings); + [$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]; $payload['eventId'] = 'symcon-operation-' . hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); - return ['status' => $warnings === [] ? 'ready_shadow' : 'incomplete_shadow', 'warnings' => $warnings, 'operation' => $payload]; + return ['status' => $warnings === [] ? 'ready_shadow' : 'incomplete_shadow', 'warnings' => $warnings, 'bezugszaehler' => $meter, 'operation' => $payload]; } } diff --git a/libs/NetzfahrplanV4Bezugszaehler.php b/libs/NetzfahrplanV4Bezugszaehler.php new file mode 100644 index 0000000..dda9b72 --- /dev/null +++ b/libs/NetzfahrplanV4Bezugszaehler.php @@ -0,0 +1,108 @@ + 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]; + } +} diff --git a/tests/NetzfahrplanV4BetriebsdatenTest.php b/tests/NetzfahrplanV4BetriebsdatenTest.php index 64f90a0..adb80c8 100644 --- a/tests/NetzfahrplanV4BetriebsdatenTest.php +++ b/tests/NetzfahrplanV4BetriebsdatenTest.php @@ -13,7 +13,7 @@ final class NetzfahrplanV4BetriebsdatenTest extends TestCase { public function testOfflineInputScenarios(): void { - self::assertCount(21, NativeV4Scenarios::run()); + self::assertCount(24, NativeV4Scenarios::run()); } public function testSenderCannotChangeLivePlanOrActuators(): void diff --git a/tests/NetzfahrplanV4BezugszaehlerTest.php b/tests/NetzfahrplanV4BezugszaehlerTest.php new file mode 100644 index 0000000..41a2992 --- /dev/null +++ b/tests/NetzfahrplanV4BezugszaehlerTest.php @@ -0,0 +1,29 @@ + 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, @@ -24,7 +28,8 @@ final class NativeV4Scenarios '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][$id], 'updated' => $now]; + 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 { @@ -97,7 +102,7 @@ final class NativeV4Scenarios $check($build()['operation']['batteries'][0]['gridCharging'] === false); $m['NetzladenErlaubt'] = true; $check($build($m)['operation']['batteries'][0]['gridCharging'] === true); }; - $ev = ['version' => 1, 'meterId' => 'symcon:9', 'measuredAt' => gmdate('c', $now), + $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 { @@ -117,6 +122,16 @@ final class NativeV4Scenarios $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; diff --git a/tests/fixtures/V4BezugszaehlerScenarios.php b/tests/fixtures/V4BezugszaehlerScenarios.php new file mode 100644 index 0000000..7e08052 --- /dev/null +++ b/tests/fixtures/V4BezugszaehlerScenarios.php @@ -0,0 +1,113 @@ + 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; + } +}