feat(manager): add opt-in native V4 shadow operation telemetry
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
|
Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Optionaler nativer V4-Betriebsdatensender fuer den Schattenversuch (standardmaessig aus), separate Diagnose und sichere Batterie-/Messquellenzuordnung. Kein neuer Fahrplan wird lokal ausgefuehrt; vollstaendige Monatspeak-/Viertelstunden-Messnachweise und deren Historienadapter bleiben Voraussetzung.
|
||||||
|
|
||||||
## 0.1 Build 1 - Erste Beta - 2026-09-29
|
## 0.1 Build 1 - Erste Beta - 2026-09-29
|
||||||
|
|
||||||
### Enthalten
|
### Enthalten
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ require_once __DIR__ . '/../libs/Anlagentopologie.php';
|
|||||||
require_once __DIR__ . '/../libs/StoerungsSnapshot.php';
|
require_once __DIR__ . '/../libs/StoerungsSnapshot.php';
|
||||||
require_once __DIR__ . '/../libs/EnergieMessung.php';
|
require_once __DIR__ . '/../libs/EnergieMessung.php';
|
||||||
require_once __DIR__ . '/../libs/ManagerEnergieTrait.php';
|
require_once __DIR__ . '/../libs/ManagerEnergieTrait.php';
|
||||||
|
require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php';
|
||||||
|
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php';
|
||||||
|
|
||||||
use Belevo\EnelixEMS\Anlagentopologie;
|
use Belevo\EnelixEMS\Anlagentopologie;
|
||||||
use Belevo\EnelixEMS\EinspeiseRegler;
|
use Belevo\EnelixEMS\EinspeiseRegler;
|
||||||
@@ -25,6 +27,7 @@ use Belevo\EnelixEMS\StoerungsSnapshot;
|
|||||||
class Manager extends IPSModule implements ManagerSchnittstelle
|
class Manager extends IPSModule implements ManagerSchnittstelle
|
||||||
{
|
{
|
||||||
use ManagerEnergieTrait;
|
use ManagerEnergieTrait;
|
||||||
|
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4Trait;
|
||||||
|
|
||||||
private const STATUS_AKTIV = 102;
|
private const STATUS_AKTIV = 102;
|
||||||
private const STATUS_MESSWERT_UNGUELTIG = 201;
|
private const STATUS_MESSWERT_UNGUELTIG = 201;
|
||||||
@@ -112,6 +115,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
public function Create(): void
|
public function Create(): void
|
||||||
{
|
{
|
||||||
parent::Create();
|
parent::Create();
|
||||||
|
$this->registriereNetzfahrplanV4();
|
||||||
|
|
||||||
$this->RegisterPropertyInteger('Rolle', 0);
|
$this->RegisterPropertyInteger('Rolle', 0);
|
||||||
$this->RegisterPropertyInteger('NetzleistungVariableID', 0);
|
$this->RegisterPropertyInteger('NetzleistungVariableID', 0);
|
||||||
@@ -232,6 +236,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
public function ApplyChanges(): void
|
public function ApplyChanges(): void
|
||||||
{
|
{
|
||||||
parent::ApplyChanges();
|
parent::ApplyChanges();
|
||||||
|
$this->SetTimerInterval('NetzfahrplanV4Senden', 0);
|
||||||
|
|
||||||
$this->initialisiereLizenzInstallationID();
|
$this->initialisiereLizenzInstallationID();
|
||||||
$this->aktualisiereVariablen();
|
$this->aktualisiereVariablen();
|
||||||
@@ -287,6 +292,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->SetStatus(self::STATUS_AKTIV);
|
$this->SetStatus(self::STATUS_AKTIV);
|
||||||
|
if ($this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
|
||||||
|
$this->SetTimerInterval('NetzfahrplanV4Senden', 60000);
|
||||||
|
}
|
||||||
$this->synchronisiereAnlagentopologie();
|
$this->synchronisiereAnlagentopologie();
|
||||||
if ($this->prognoseTelemetrieKonfiguriert()) {
|
if ($this->prognoseTelemetrieKonfiguriert()) {
|
||||||
$this->SetTimerInterval(
|
$this->SetTimerInterval(
|
||||||
@@ -449,6 +457,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$this->regeln((bool) $wert);
|
$this->regeln((bool) $wert);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
case 'NetzfahrplanV4Senden':
|
||||||
|
$this->sendeNetzfahrplanV4();
|
||||||
|
return;
|
||||||
|
|
||||||
case 'PrognoseSenden':
|
case 'PrognoseSenden':
|
||||||
$this->sendePrognoseTelemetrie();
|
$this->sendePrognoseTelemetrie();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# V4: native Betriebsdaten, ausschliesslich Schattenbetrieb
|
||||||
|
|
||||||
|
Stand 2026-10-01: Der optionale Sender und die Diagnose sind implementiert.
|
||||||
|
Kein V4-Plan wird damit uebernommen, kein Aktor geschrieben, kein V1-Sollwert
|
||||||
|
geaendert. Der Sender ist nach Installation standardmaessig AUS.
|
||||||
|
|
||||||
|
## Konfiguration im Manager
|
||||||
|
|
||||||
|
- NetzfahrplanV4SchattenAktiv: bool, Standard false. Eigener 60-s-Timer,
|
||||||
|
unabhaengig vom bestehenden Prognose-Timer. Fehler betreffen nur den Sendestatus.
|
||||||
|
- NetzfahrplanV4NetzladenErlaubt: bool, Standard false. Betrifft ausschliesslich
|
||||||
|
die im Schattenplan angenommene Berechtigung, keine reale Netzladefreigabe.
|
||||||
|
- 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.
|
||||||
|
- NetzfahrplanV4MessnachweisVariableID: JSON-Stringvariable, Standard 0.
|
||||||
|
Ein vollstaendiger historischer/registerbasierter Messnachweis-Produzent
|
||||||
|
ist NOCH NICHT integriert. Fehlende Nachweise bleiben fehlend.
|
||||||
|
|
||||||
|
Die Quelle muss der gemeinsame Netzanschluss sein. Topologiebatterien werden
|
||||||
|
nur einer eindeutigen aktiven Batterieinstanz mit derselben SOC- UND
|
||||||
|
Leistungsmessquelle zugeordnet. Externe SDL-Batterien werden nicht automatisch
|
||||||
|
hinzugefuegt. SDL-/Grundlastbereinigung bleibt eine separate, noch offene Aufgabe.
|
||||||
|
BMS-Grenzen werden mit Topologieleistungen begrenzt. Normale Betriebsreserve
|
||||||
|
und technisches Minimum werden nicht verwechselt: die hoehere Grenze gilt.
|
||||||
|
|
||||||
|
Bei aktueller Entladehysterese wird die Entladeleistung konservativ auf 0
|
||||||
|
begrenzt. Eine spaetere Freigabe innerhalb der 48h wird noch nicht modelliert.
|
||||||
|
Das ist sichtbar diagnostiziert und kann den Schattenplan wirtschaftlich
|
||||||
|
beschraenken; eine zeitabhaengige Hysterese-Modellierung bleibt offen.
|
||||||
|
SOC unterhalb der Reserve wird nicht kuenstlich angehoben: Diagnosefehler,
|
||||||
|
kein angeblich gueltiger Plan. Recovery-/Nachladungsmodell bleibt offen.
|
||||||
|
|
||||||
|
## Diagnose ohne Netzwerk oder Stellbefehl
|
||||||
|
|
||||||
|
Im Symcon-Skript nach Installation des neuen Modulstands:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
print_r(json_decode(ENELIX_GetNetzfahrplanV4Diagnose(17004), true));
|
||||||
|
echo ENELIX_GetNetzfahrplanV4Sendestatus(17004);
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Diagnose enthaelt KEINE Tokens oder sonstigen Zugangsdaten. Sie ist auch
|
||||||
|
bei ausgeschaltetem Sender verfuegbar. Die Instanz-ID ist fuer Lihrenmoos aus
|
||||||
|
der bestehenden Konfiguration bekannt, nicht fuer andere Anlagen zu kopieren.
|
||||||
|
Nicht ungeprueft deployen: Host-CLI- und Zielsystemtests getrennt nachweisen.
|
||||||
|
|
||||||
|
## Messnachweis-Vertrag (Beispiel, KEINE realen Messwerte)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"meterId": "symcon:53476",
|
||||||
|
"measuredAt": "2026-10-01T12:05:00Z",
|
||||||
|
"measuredPeaks": {
|
||||||
|
"2026-10": {"kw": 18.4, "source": "meter_month_register"}
|
||||||
|
},
|
||||||
|
"quarterPast": {
|
||||||
|
"start": "2026-10-01T12:00:00Z",
|
||||||
|
"measuredSeconds": 300,
|
||||||
|
"importKwh": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Zulaessige Herkunft: meter_month_register, verified_month_history oder
|
||||||
|
verified_new_month. Der Nachweis-Produzent muss deren Wahrheit garantieren.
|
||||||
|
Managergrenzen sind NIE ein gemessener Monatspeak. Der Monatswert muss den
|
||||||
|
vollstaendigen bisherigen Abrechnungsmonat abdecken. Fuer quarterPast muessen
|
||||||
|
Zaehlerzeitpunkt, abgedeckte Sekunden und Entscheidungszeitpunkt exakt passen;
|
||||||
|
veraltete Energie wird nicht hochgerechnet oder als aktuelle Messung ausgegeben.
|
||||||
|
Die kumulative kWh-Variable allein erfuellt diesen Vertrag noch nicht.
|
||||||
|
|
||||||
|
Die V4-API meldet bei fehlendem Monatspeak/quarterPast weiterhin awaiting_inputs.
|
||||||
|
Ein HTTP stored bestaetigt nur den Eingang der Telemetrie, nicht einen gueltigen
|
||||||
|
Fahrplan und schon gar nicht dessen Ausfuehrung. Backend und Portal-Bruecke
|
||||||
|
muessen separat aktiviert werden. Keine neue Modellwahl oder Aktorfreigabe hier.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
21 offline Konvertierungsszenarien plus PHPUnit-Strukturpruefungen. Werte sind
|
||||||
|
synthetisch; keine reale HTTP-Anbindung oder Symcon-Laufzeit damit behauptet.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?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->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', 'NetzbezugEnergieVariableID'] as $key) {
|
||||||
|
$manager[$key] = $this->ReadPropertyInteger($key);
|
||||||
|
}
|
||||||
|
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
|
||||||
|
$manager[$key] = $this->ReadPropertyFloat($key);
|
||||||
|
}
|
||||||
|
$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);
|
||||||
|
$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', '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.');
|
||||||
|
}
|
||||||
|
return ['value' => $value, 'updated' => (int) $after['VariableUpdated']];
|
||||||
|
};
|
||||||
|
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,191 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/** 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, int $meterID, array &$warnings): array
|
||||||
|
{
|
||||||
|
$peaks = [];
|
||||||
|
$past = null;
|
||||||
|
if ($input === []) {
|
||||||
|
$warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.';
|
||||||
|
return [(object) [], null];
|
||||||
|
}
|
||||||
|
if ($meterID <= 0 || ($input['version'] ?? null) !== 1 || ($input['meterId'] ?? null) !== 'symcon:' . $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 = [];
|
||||||
|
$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);
|
||||||
|
if ($socValue < $min || $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.';
|
||||||
|
}
|
||||||
|
if (!empty($c['HystereseAktiv'])) {
|
||||||
|
$maxDischarge = 0.0;
|
||||||
|
$warnings[] = $id . ': aktuelle Entladesperre konservativ uebernommen; keine spaetere Freigabe erfunden.';
|
||||||
|
}
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
if ($result === []) {
|
||||||
|
throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.');
|
||||||
|
}
|
||||||
|
[$peaks, $past] = self::evidence($evidence, $now, (int) ($manager['NetzbezugEnergieVariableID'] ?? 0), $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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(21, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+124
@@ -0,0 +1,124 @@
|
|||||||
|
<?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];
|
||||||
|
$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][$id], 'updated' => $now];
|
||||||
|
};
|
||||||
|
$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_not_lifted_to_minimum'] = static function () use ($build, $read, $fail): void {
|
||||||
|
$r = static function ($id) use ($read) { $v = $read($id); if ($id === 2) { $v['value'] = 5.; } return $v; };
|
||||||
|
$fail(static fn() => $build(null, null, null, $r));
|
||||||
|
};
|
||||||
|
$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]['maxDischargeW'] === 0.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' => 'symcon:9', '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']);
|
||||||
|
};
|
||||||
|
$passed = [];
|
||||||
|
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
|
||||||
|
return $passed;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user