fix(manager): isolate V4 active import sources from legacy counter
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user