fix(manager): isolate V4 active import sources from legacy counter
This commit is contained in:
@@ -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.
|
||||||
@@ -13,6 +13,9 @@ geaendert. Der Sender ist nach Installation standardmaessig AUS.
|
|||||||
- NetzfahrplanV4BatterieOptionen: JSON-Objekt je Topologie-ID. Optional
|
- NetzfahrplanV4BatterieOptionen: JSON-Objekt je Topologie-ID. Optional
|
||||||
SOCKapazitaet_kWh und MaxSOC_Prozent (sonst 100). Bei unterschiedlicher Nenn-
|
SOCKapazitaet_kWh und MaxSOC_Prozent (sonst 100). Bei unterschiedlicher Nenn-
|
||||||
und Nutzkapazitaet ist SOCKapazitaet_kWh zwingend explizit anzugeben.
|
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.
|
- NetzfahrplanV4MessnachweisVariableID: JSON-Stringvariable, Standard 0.
|
||||||
Ein vollstaendiger historischer/registerbasierter Messnachweis-Produzent
|
Ein vollstaendiger historischer/registerbasierter Messnachweis-Produzent
|
||||||
ist NOCH NICHT integriert. Fehlende Nachweise bleiben fehlend.
|
ist NOCH NICHT integriert. Fehlende Nachweise bleiben fehlend.
|
||||||
@@ -51,7 +54,7 @@ Nicht ungeprueft deployen: Host-CLI- und Zielsystemtests getrennt nachweisen.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"meterId": "symcon:53476",
|
"meterId": "symcon-active-import:<SHA256-der-konfigurierten-Quellen>",
|
||||||
"measuredAt": "2026-10-01T12:05:00Z",
|
"measuredAt": "2026-10-01T12:05:00Z",
|
||||||
"measuredPeaks": {
|
"measuredPeaks": {
|
||||||
"2026-10": {"kw": 18.4, "source": "meter_month_register"}
|
"2026-10": {"kw": 18.4, "source": "meter_month_register"}
|
||||||
@@ -79,5 +82,5 @@ muessen separat aktiviert werden. Keine neue Modellwahl oder Aktorfreigabe hier.
|
|||||||
|
|
||||||
## Tests
|
## 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.
|
synthetisch; keine reale HTTP-Anbindung oder Symcon-Laufzeit damit behauptet.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ trait ManagerNetzfahrplanV4Trait
|
|||||||
$this->RegisterPropertyBoolean('NetzfahrplanV4NetzladenErlaubt', false);
|
$this->RegisterPropertyBoolean('NetzfahrplanV4NetzladenErlaubt', false);
|
||||||
$this->RegisterPropertyString('NetzfahrplanV4BatterieOptionen', '{}');
|
$this->RegisterPropertyString('NetzfahrplanV4BatterieOptionen', '{}');
|
||||||
$this->RegisterPropertyInteger('NetzfahrplanV4MessnachweisVariableID', 0);
|
$this->RegisterPropertyInteger('NetzfahrplanV4MessnachweisVariableID', 0);
|
||||||
|
$this->RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]');
|
||||||
$this->RegisterAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
|
$this->RegisterAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
|
||||||
$this->RegisterTimer('NetzfahrplanV4Senden', 0,
|
$this->RegisterTimer('NetzfahrplanV4Senden', 0,
|
||||||
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Senden', true);");
|
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Senden', true);");
|
||||||
@@ -26,7 +27,7 @@ trait ManagerNetzfahrplanV4Trait
|
|||||||
try {
|
try {
|
||||||
$manager = [];
|
$manager = [];
|
||||||
foreach (['NetzleistungVariableID', 'MesswertMaxAlter', 'VerbraucherTimeout',
|
foreach (['NetzleistungVariableID', 'MesswertMaxAlter', 'VerbraucherTimeout',
|
||||||
'Lastspitzenmodus', 'NetzbezugEnergieVariableID'] as $key) {
|
'Lastspitzenmodus'] as $key) {
|
||||||
$manager[$key] = $this->ReadPropertyInteger($key);
|
$manager[$key] = $this->ReadPropertyInteger($key);
|
||||||
}
|
}
|
||||||
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
|
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
|
||||||
@@ -36,6 +37,9 @@ trait ManagerNetzfahrplanV4Trait
|
|||||||
$manager['NetzladenErlaubt'] = $this->ReadPropertyBoolean('NetzfahrplanV4NetzladenErlaubt');
|
$manager['NetzladenErlaubt'] = $this->ReadPropertyBoolean('NetzfahrplanV4NetzladenErlaubt');
|
||||||
$manager['Monatsgrenzen'] = json_decode($this->ReadPropertyString('Monatsgrenzen'), true, 512, JSON_THROW_ON_ERROR);
|
$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['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);
|
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 512, JSON_THROW_ON_ERROR);
|
||||||
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
||||||
$controllers = [];
|
$controllers = [];
|
||||||
@@ -80,7 +84,9 @@ trait ManagerNetzfahrplanV4Trait
|
|||||||
if ($before['VariableUpdated'] !== $after['VariableUpdated']) {
|
if ($before['VariableUpdated'] !== $after['VariableUpdated']) {
|
||||||
throw new RuntimeException('Messwert hat sich beim Lesen geaendert; naechsten Durchlauf abwarten.');
|
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,
|
return json_encode(NetzfahrplanV4Betriebsdaten::erstellen($manager, $assets, $controllers,
|
||||||
$read, $evidence, time()), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
$read, $evidence, time()), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use DateTimeImmutable;
|
|||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/NetzfahrplanV4Bezugszaehler.php';
|
||||||
|
|
||||||
/** Read-only conversion. Meter evidence is never derived from a configured limit. */
|
/** Read-only conversion. Meter evidence is never derived from a configured limit. */
|
||||||
final class NetzfahrplanV4Betriebsdaten
|
final class NetzfahrplanV4Betriebsdaten
|
||||||
{
|
{
|
||||||
@@ -43,7 +45,7 @@ final class NetzfahrplanV4Betriebsdaten
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** No inference of paid peak or elapsed energy from power, reserve or forecast. */
|
/** 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 = [];
|
$peaks = [];
|
||||||
$past = null;
|
$past = null;
|
||||||
@@ -51,7 +53,7 @@ final class NetzfahrplanV4Betriebsdaten
|
|||||||
$warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.';
|
$warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.';
|
||||||
return [(object) [], null];
|
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.');
|
throw new InvalidArgumentException('Messnachweis: falsche Version oder Bezugszaehler-Zuordnung.');
|
||||||
}
|
}
|
||||||
$measured = self::timestamp($input['measuredAt'] ?? null);
|
$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
|
public static function erstellen(array $manager, array $assets, array $controllers, callable $read, array $evidence, int $now): array
|
||||||
{
|
{
|
||||||
$warnings = [];
|
$warnings = [];
|
||||||
|
$meter = NetzfahrplanV4Bezugszaehler::lesen(
|
||||||
|
$manager['V4BezugszaehlerQuellen'] ?? [], $read, $now
|
||||||
|
);
|
||||||
$grid = self::measurement($read, (int) ($manager['NetzleistungVariableID'] ?? 0), $now,
|
$grid = self::measurement($read, (int) ($manager['NetzleistungVariableID'] ?? 0), $now,
|
||||||
(int) ($manager['MesswertMaxAlter'] ?? 60), 'Netzleistung');
|
(int) ($manager['MesswertMaxAlter'] ?? 60), 'Netzleistung');
|
||||||
$gridW = $grid['value'] * self::number($manager['Netzleistungsfaktor'] ?? 1.0, 'Netzleistungsfaktor');
|
$gridW = $grid['value'] * self::number($manager['Netzleistungsfaktor'] ?? 1.0, 'Netzleistungsfaktor');
|
||||||
@@ -180,12 +185,12 @@ final class NetzfahrplanV4Betriebsdaten
|
|||||||
if ($result === []) {
|
if ($result === []) {
|
||||||
throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.');
|
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,
|
$payload = ['version' => 1, 'observedAt' => gmdate('c', $now), 'gridW' => $gridW,
|
||||||
'meteringBoundary' => 'common_pcc', 'batteries' => $result,
|
'meteringBoundary' => 'common_pcc', 'batteries' => $result,
|
||||||
'limits' => ['importW' => $import, 'exportW' => $export, 'managerMonthLimitsW' => (object) $months],
|
'limits' => ['importW' => $import, 'exportW' => $export, 'managerMonthLimitsW' => (object) $months],
|
||||||
'measuredPeaks' => $peaks, 'quarterPast' => $past];
|
'measuredPeaks' => $peaks, 'quarterPast' => $past];
|
||||||
$payload['eventId'] = 'symcon-operation-' . hash('sha256', json_encode($payload, 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, 'operation' => $payload];
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ final class NetzfahrplanV4BetriebsdatenTest extends TestCase
|
|||||||
{
|
{
|
||||||
public function testOfflineInputScenarios(): void
|
public function testOfflineInputScenarios(): void
|
||||||
{
|
{
|
||||||
self::assertCount(21, NativeV4Scenarios::run());
|
self::assertCount(24, NativeV4Scenarios::run());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSenderCannotChangeLivePlanOrActuators(): void
|
public function testSenderCannotChangeLivePlanOrActuators(): void
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+17
-2
@@ -17,6 +17,10 @@ final class NativeV4Scenarios
|
|||||||
$m = ['NetzleistungVariableID' => 1, 'NetzbezugEnergieVariableID' => 9,
|
$m = ['NetzleistungVariableID' => 1, 'NetzbezugEnergieVariableID' => 9,
|
||||||
'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60, 'Lastspitzenmodus' => 1,
|
'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60, 'Lastspitzenmodus' => 1,
|
||||||
'Lastspitzengrenze' => 25000.0, 'EinspeisebegrenzungAktiv' => true, 'Einspeisegrenze' => 25000.0];
|
'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,
|
$a = [['ID' => 'ev', 'Nennkapazitaet_kWh' => 161.44, 'Nutzkapazitaet_kWh' => 161.44,
|
||||||
'MaxLadeleistung_kW' => 39.0, 'MaxEntladeleistung_kW' => 30.0, 'SOCVariableID' => 2, 'LeistungVariableID' => 5]];
|
'MaxLadeleistung_kW' => 39.0, 'MaxEntladeleistung_kW' => 30.0, 'SOCVariableID' => 2, 'LeistungVariableID' => 5]];
|
||||||
$c = [['InstanzID' => 42, 'LadezustandVariableID' => 2, 'MaxLadeleistungVariableID' => 3,
|
$c = [['InstanzID' => 42, 'LadezustandVariableID' => 2, 'MaxLadeleistungVariableID' => 3,
|
||||||
@@ -24,7 +28,8 @@ final class NativeV4Scenarios
|
|||||||
'ReserveLadezustand' => 15.0, 'MindestLadezustand' => 3.0, 'MesswertMaxAlter' => 30,
|
'ReserveLadezustand' => 15.0, 'MindestLadezustand' => 3.0, 'MesswertMaxAlter' => 30,
|
||||||
'Verfuegbar' => true, 'Batteriemanagement' => 2, 'EmpfangenAm' => $now]];
|
'Verfuegbar' => true, 'Batteriemanagement' => 2, 'EmpfangenAm' => $now]];
|
||||||
$read = static function (int $id) use ($now): array {
|
$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'); } };
|
$check = static function (bool $ok): void { if (!$ok) { throw new RuntimeException('Assertion failed'); } };
|
||||||
$fail = static function (callable $fn): void {
|
$fail = static function (callable $fn): void {
|
||||||
@@ -97,7 +102,7 @@ final class NativeV4Scenarios
|
|||||||
$check($build()['operation']['batteries'][0]['gridCharging'] === false);
|
$check($build()['operation']['batteries'][0]['gridCharging'] === false);
|
||||||
$m['NetzladenErlaubt'] = true; $check($build($m)['operation']['batteries'][0]['gridCharging'] === true);
|
$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']],
|
'measuredPeaks' => ['2026-10' => ['kw' => 18.4, 'source' => 'meter_month_register']],
|
||||||
'quarterPast' => ['start' => '2026-10-01T12:00:00Z', 'measuredSeconds' => 300, 'importKwh' => 0.5]];
|
'quarterPast' => ['start' => '2026-10-01T12:00:00Z', 'measuredSeconds' => 300, 'importKwh' => 0.5]];
|
||||||
$cases['actual_meter_evidence_used'] = static function () use ($build, $ev, $check): void {
|
$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 {
|
$cases['deterministic_retry_event_id'] = static function () use ($build, $check): void {
|
||||||
$check($build()['operation']['eventId'] === $build()['operation']['eventId']);
|
$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 = [];
|
$passed = [];
|
||||||
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
|
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
|
||||||
return $passed;
|
return $passed;
|
||||||
|
|||||||
+113
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user