fix(manager): isolate V4 active import sources from legacy counter

This commit is contained in:
ENELIX Agent
2026-10-01 16:56:39 +00:00
parent 640fe53720
commit 12174a59b4
9 changed files with 356 additions and 11 deletions
+8 -2
View File
@@ -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);
+9 -4
View File
@@ -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];
}
}
+108
View File
@@ -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];
}
}