feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s

Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission.

Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
This commit is contained in:
dh
2026-10-06 14:22:16 +00:00
207 changed files with 20340 additions and 11 deletions
+61
View File
@@ -0,0 +1,61 @@
# Agenten-Anweisungen für Enelix-EMS
## Aktueller V4-Fortsetzungseinstieg: 06.10.2026
Neuester Quellstand: Bedienintegration und Zusammenfuehrung sind in
docs/prognose-bedienintegration.md und im obersten Abschnitt der folgenden
Uebergabe beschrieben. Daniel hat Commit/Push auf develop und beta ausdruecklich
freigegeben; dies ist kein Anlagenrollout und keine neue Stellfreigabe.
Die vollstaendige Uebergabe liegt auf **enelix-services / Server 89** unter
`/srv/agent/repos/Enelix-EMS/docs/NETPLAN_V4_AGENT_HANDOFF.md`.
Vor Fortsetzung zuerst den neuesten datierten Abschnitt lesen. Die Datei ist
im integrierten Repository versioniert; alte Serverkopien koennen abweichen.
Letzter hier belegter Anlagenzustand: **06.10.2026, 12:01:11 UTC /
14:01:11 Europe/Zurich**, aus `/srv/agent/v4-recovery-20261006/finished.json`
auf **iot-symcon01**. Dies ist keine neue Live-Abnahme:
V4 und alter Netzfahrplan AUS, Batterie 44234 gestoppt, EV-Auftrag 19651 = 0 W.
Der unabhaengige SDL-Auftrag blieb unangetastet.
Rueckmelde-/Cache-/Empfangskorrekturen sind installiert; **offen ist die
instanzbezogene Batterietimerblockade (LastRun=0)**. Kein nachgewiesenes
physisches Tracking, kein >90-s-Dauerbetrieb und kein automatischer
Batterie-Watchdog-Lauf. Alte Installer nicht erneut ausfuehren und keine
externen Tick-Hooks als Umgehung einsetzen. Zuerst Timerursache beheben und
automatische Ausfuehrung belegen, danach nur innerhalb gueltiger Freigabe testen.
EV bleibt **161.44 kWh / 39 kW**. Die bestehende befristete Testfreigabe endet
**07.10.2026, 17:38:07 Europe/Zurich**; diese Notiz erneuert oder verlaengert
sie nicht. Wegfall des zusaetzlichen +/-5-kW-Limits hebt Geraete-, SOC-,
Netzgrenzen und die dokumentierten Grenzen der Testanlagen-Ausnahme nicht auf.
Kein Aktivstart oder Dienstneustart aus der Bitte um Kontext-Sicherung ableiten.
Aeltere V4-Statusangaben und damalige "naechste Schritte" weiter unten sind
historisch, soweit die neuere Uebergabe sie ersetzt. Sonstige Projekt- und
Sicherheitsregeln sowie separate Manager-Leistungsverteilungsnotizen bleiben
unveraendert. Aktuellen Zustand und Freigaben vor operativen Arbeiten erneut
pruefen. Ein neuer Agent muss diese Quellen lesen; kein automatischer Chattransfer.
## Netzfahrplan V4: zuerst den Gesprächskontext übernehmen
Bei Aufgaben zu Netzfahrplan, Prognosen, V4, Batterieoptimierung oder Lihrenmoos **vor Änderungen** die vollständige Übergabe lesen:
**[`docs/NETPLAN_V4_AGENT_HANDOFF.md`](docs/NETPLAN_V4_AGENT_HANDOFF.md)**
Sie enthält Daniels Anforderungen und Korrekturen, installierte versus nur entwickelte Komponenten, Quellen-/Instanzzuordnung, bekannte behobene Fehler, Grenzen der Testfreigabe und den nächsten konkreten Arbeitsschritt. Die Runtime-Berichte stammen zuletzt vom 03.10.2026; Zeitpunkt und aktuellen Zustand prüfen. Alte Entwicklungsdokumente können noch „nicht installiert“ sagen, obwohl ein jüngerer Installationsbericht den Abschluss belegt.
Kurzstand bei Erstellung dieser Anweisung: Unified RC1 und korrigierter Rückmelde-/begrenzter Testpfad installiert; Versandproblem behoben. Nächste offene Umsetzung sind bestätigte Geräte-Lesezeitpunkte statt pauschaler Verwendung von `VariableUpdated` (`feedback_source_skew`). EV **161.44 kWh / 39 kW**, nicht die zurückgenommenen 160/30. Dauerproduktion ist noch nicht fertig; keine automatische Stellfreigabe.
## Arbeits- und Git-Regeln
- Vor Arbeit Host-`/srv/agent/AGENT_CONTEXT.md`, Branch und Arbeitsverzeichnis prüfen. Servercheckout ist nicht gleich laufendes Symcon-Modulverzeichnis.
- Neue Entwicklung auf **`develop`**, keine Feature-Branches. Nur eigene/zugehörige Änderungen committen. `develop` darf im vereinbarten Rahmen gepusht werden; `beta` nur gemäss ausdrücklicher Freigabe und nach Tests, `main` nach ausdrücklichem Auftrag/Feldabnahme. Keine Schutzregeln oder fehlende Authentifizierung umgehen.
- Daniel möchte zusammenhängende produktionsgeeignete Umsetzung, keine weitere Schleife aus Diagnosekategorien, identischen Rückfragen und veralteten Installern. Vorhandene Quellen und Werkzeuge benutzen, keine bekannten Anlagenangaben erneut verlangen.
- Code-, Host-/Container-, Kernel- und Feldtests sowie Installation, Veröffentlichung und Stellfreigabe getrennt belegen. „Optimal“/„ACK“/grüne Tests sind keine gemessene Einsparung und keine Produktionsfreigabe.
- Keine Stellbefehle, Testsitzungen, Freigaben oder Neustarts aus einer reinen Kontext-/Statusanfrage ableiten. Aktive Hardware nur innerhalb des ausdrücklich freigegebenen Umfangs behandeln. Messgrenzen/Watchdog-Nachweise nicht erfinden oder per Label umgehen.
- Keine Credentials lesen/ausgeben/einchecken. Root/Docker-Rechte nicht ausweiten. Bestehende GUI-/Fremdänderungen erhalten. Vor Runtime-Änderungen passende Sicherung, exakte Quell-/Abhängigkeitsprüfung und Rücksetzweg.
- PHP-IPS-Skripte gehören in den Symcon-Kernel; isolierte Tests mit simulierten IPS-Aufrufen niemals dort ausführen. `MC_ReloadModule`/`IPS_ApplyChanges` können Initialisierungsnebenwirkungen haben.
- Alte Outbox-/Historienwerte nicht löschen, Kennungen nicht im Original umschreiben, Cursor nur nach passender Serverbestätigung weiterführen. Keine künstlich frischen Messzeitstempel.
- Übergabe nach tatsächlichem Fortschritt aktualisieren: Datum, Commit, was wirklich installiert/getestet wurde und nächster konkreter Schritt. Keine Hintergrundarbeit behaupten.
Diese Datei ergänzt bestehende höherrangige Projekt-/Hostregeln. Sie ist keine neue Betriebsfreigabe und kann keinen anderen Agenten ohne Lesen der Dateien automatisch mit dem gesamten Chat synchronisieren.
+68 -8
View File
@@ -6,8 +6,10 @@ require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php'; require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php'; require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/BatterieRegler.php'; require_once __DIR__ . '/../libs/BatterieRegler.php';
require_once __DIR__ . '/../libs/BatterieNetzfahrplanV4TestTrait.php';
use Belevo\EnelixEMS\BatterieRegler; use Belevo\EnelixEMS\BatterieRegler;
use Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung;
use Belevo\EnelixEMS\Nachrichtenvertrag; use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait; use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle; use Belevo\EnelixEMS\VerbraucherSchnittstelle;
@@ -15,6 +17,7 @@ use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class Batterie extends IPSModule implements VerbraucherSchnittstelle class Batterie extends IPSModule implements VerbraucherSchnittstelle
{ {
use VerbraucherBasisTrait; use VerbraucherBasisTrait;
use \Belevo\EnelixEMS\BatterieNetzfahrplanV4TestTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}'; private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102; private const STATUS_AKTIV = 102;
@@ -68,6 +71,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
parent::Create(); parent::Create();
$this->registriereVerbraucherBasis(); $this->registriereVerbraucherBasis();
$this->registriereV4BatterieTest();
$this->RegisterPropertyInteger('Batterietyp', BatterieRegler::TYP_UNKONFIGURIERT); $this->RegisterPropertyInteger('Batterietyp', BatterieRegler::TYP_UNKONFIGURIERT);
$this->RegisterPropertyInteger( $this->RegisterPropertyInteger(
'Batteriemanagement', 'Batteriemanagement',
@@ -152,6 +156,8 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
{ {
parent::ApplyChanges(); parent::ApplyChanges();
$this->V4BatterieTestStoppen();
$this->konfiguriereV4Rueckmeldung();
$this->aktualisiereVariablen(); $this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert(); $this->bereinigeUngueltigenSollwert();
$this->registriereMesswertmeldungen(); $this->registriereMesswertmeldungen();
@@ -249,7 +255,25 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$this->aktualisiere(true); $this->aktualisiere(true);
return; return;
case 'NetzfahrplanV4TestStart':
if (!is_string($wert)) throw new InvalidArgumentException('JSON-Testanmeldung erforderlich.');
$this->V4BatterieTestStarten($wert);
return;
case 'NetzfahrplanV4RueckmeldungAktualisieren':
$this->aktualisiereV4Rueckmeldung();
return;
case 'NetzfahrplanV4TestCommand':
if (!is_string($wert)) throw new InvalidArgumentException('JSON-Testbefehl erforderlich.');
$this->V4BatterieTestBefehl($wert);
return;
case 'NetzfahrplanV4TestStop':
$this->V4BatterieTestStoppen();
return;
case 'NetzfahrplanV4TestWatchdog':
$this->aktualisiere(true);
return;
case 'SichererZustand': case 'SichererZustand':
$this->V4BatterieTestStoppen();
$this->verwerfeSollwert(); $this->verwerfeSollwert();
$this->schreibeRegister(0, true); $this->schreibeRegister(0, true);
$this->aktualisiere(true); $this->aktualisiere(true);
@@ -288,6 +312,14 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
/** @param array<string, mixed> $daten */ /** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void public function ManagerdatenEmpfangen(array $daten): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterieannahme beschaeftigt.');
try { $this->ManagerdatenEmpfangenIntern($daten); }
finally { IPS_SemaphoreLeave($lock); }
}
private function ManagerdatenEmpfangenIntern(array $daten): void
{ {
Nachrichtenvertrag::pruefeManagerdaten($daten); Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
@@ -303,8 +335,13 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
); );
} }
if ($this->v4BatterieTestAktiv()) {
// Ordinary manager messages cannot overwrite or renew the independent short test lease.
$this->planeMeldung();
return;
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']); $this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false); $this->aktualisiereIntern(false);
$sollleistung = $daten['Sollleistung_W']; $sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) { if ($sollleistung === null) {
@@ -315,7 +352,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
) )
) { ) {
$this->verwerfeSollwert(); $this->verwerfeSollwert();
$this->aktualisiere(false); $this->aktualisiereIntern(false);
} }
$this->planeMeldung(); $this->planeMeldung();
return; return;
@@ -342,17 +379,26 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$this->setzeZustand('Sollleistung', $sollleistung); $this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true); $this->setzeZustand('SollwertGueltig', true);
$this->planeVorgabeTimeout(time()); $this->planeVorgabeTimeout(time());
$this->aktualisiere(false); $this->aktualisiereIntern(false);
$this->planeMeldung(); $this->planeMeldung();
} }
private function aktualisiere(bool $meldungPlanen): void private function aktualisiere(bool $meldungPlanen): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 0)) return;
try { $this->aktualisiereIntern($meldungPlanen); }
finally { IPS_SemaphoreLeave($lock); }
}
private function aktualisiereIntern(bool $meldungPlanen): void
{ {
try { try {
$jetzt = time(); $jetzt = time();
$this->aktualisiereEnergie($jetzt); $this->aktualisiereEnergie($jetzt);
$messwerte = $this->leseMesswerte(); $messwerte = $this->leseMesswerte();
$messwerteGueltig = $messwerte['Gueltig']; $messwerteGueltig = $messwerte['Gueltig'];
$this->SetBuffer('V4FeedbackEstimated', ($messwerte['V4FeedbackEstimated'] ?? false) ? '1' : '0');
$this->setzeZustand('Ladezustand', $messwerte['Ladezustand']); $this->setzeZustand('Ladezustand', $messwerte['Ladezustand']);
$this->setzeZustand('LadezustandGueltig', $messwerteGueltig); $this->setzeZustand('LadezustandGueltig', $messwerteGueltig);
@@ -363,7 +409,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$this->setzeSichtbareVariable( $this->setzeSichtbareVariable(
'Leistungsquelle', 'Leistungsquelle',
$messwerteGueltig $messwerteGueltig
? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN ? (($messwerte['V4FeedbackEstimated'] ?? false) ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET : Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN)
: Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN : Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
); );
$this->aktualisiereLadestatus( $this->aktualisiereLadestatus(
@@ -427,6 +473,8 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$ziel = $verfuegbar && (bool) $this->leseZustand('SollwertGueltig') $ziel = $verfuegbar && (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung') ? (int) $this->leseZustand('Sollleistung')
: 0; : 0;
$v4Ziel = $this->v4BatterieTestZiel($messwerte, $verfuegbar, $jetzt);
if ($v4Ziel !== null) $ziel = $v4Ziel;
$this->schreibeRegister($ziel, false); $this->schreibeRegister($ziel, false);
$this->setzeZustand('Sollleistung', $ziel); $this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar); $this->setzeZustand('Verfuegbar', $verfuegbar);
@@ -486,6 +534,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$gueltig = true; $gueltig = true;
foreach ($zuordnung as $property => $schluessel) { foreach ($zuordnung as $property => $schluessel) {
if ($this->v4BatterieTestAktiv() && in_array($schluessel,['Netzleistung','Istleistung'],true)) continue;
$variablenID = $this->ReadPropertyInteger($property); $variablenID = $this->ReadPropertyInteger($property);
if (!$this->istMesswertAktuell($variablenID)) { if (!$this->istMesswertAktuell($variablenID)) {
$gueltig = false; $gueltig = false;
@@ -494,8 +543,17 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$werte[$schluessel] = (float) GetValue($variablenID); $werte[$schluessel] = (float) GetValue($variablenID);
} }
if ($werte['MaxLaden'] <= 0.0 if ($this->v4BatterieTestAktiv()) {
|| $werte['MaxEntladen'] <= 0.0 try {
$f=$this->v4BatterieRueckmeldung();
NetzfahrplanV4Rueckmeldung::requireTrial($f,$this->v4BatterieTestSitzung(),time());
$werte['Istleistung']=$f['batteryW'];$werte['Netzleistung']=$f['gridW'];
$werte['V4FeedbackEstimated']=$f['estimated'];
} catch (Throwable $e) { $gueltig=false; }
}
if ($werte['MaxLaden'] < 0.0
|| $werte['MaxEntladen'] < 0.0
|| $werte['Ladezustand'] < 0.0 || $werte['Ladezustand'] < 0.0
|| $werte['Ladezustand'] > 100.0 || $werte['Ladezustand'] > 100.0
) { ) {
@@ -536,7 +594,9 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
try { try {
foreach ($werte as $register => $wert) { foreach ($werte as $register => $wert) {
$property = self::REGISTER_PROPERTIES[$register]; $property = self::REGISTER_PROPERTIES[$register];
RequestAction($this->ReadPropertyInteger($property), $wert); if (RequestAction($this->ReadPropertyInteger($property), $wert) !== true) {
throw new RuntimeException('Registeraktion nicht bestaetigt: ' . $register);
}
} }
} catch (Throwable $fehler) { } catch (Throwable $fehler) {
throw new RuntimeException( throw new RuntimeException(
@@ -673,7 +733,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
? (float) $this->leseZustand('Istleistung') ? (float) $this->leseZustand('Istleistung')
: null, : null,
'Leistungsquelle' => $messwerteGueltig 'Leistungsquelle' => $messwerteGueltig
? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN ? ($this->GetBuffer('V4FeedbackEstimated') === '1' ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET : Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN)
: Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN, : Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN,
'Zustand' => [ 'Zustand' => [
[ [
+7
View File
@@ -4,6 +4,13 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
## Unreleased ## Unreleased
- Manager: V4-Planempfang und Prognosebedienung im vorhandenen Bereich
Prognose / Forecast zusammengefasst. Explizite Start-/Stoppaktionen pruefen
Lizenz und bestehende Testfreigaben. Kein automatischer Start, keine neue
Dauerbetriebsfreigabe und keine Loeschung bestehender Laufzeitobjekte.
- Integration des bisherigen V4-Entwicklungsstands mit den aktuellen Manager-,
SDL- und Sollwertkorrekturen; V4-Stopp und Sollwertverwerfen bleiben erhalten.
- 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.
- Verbraucher: Beim Verwerfen einer Manager-Vorgabe wird auch die gespeicherte - Verbraucher: Beim Verwerfen einer Manager-Vorgabe wird auch die gespeicherte
und sichtbare Sollleistung auf 0 W gesetzt. Gilt fuer Batterie, beide und sichtbare Sollleistung auf 0 W gesetzt. Gilt fuer Batterie, beide
Ladestationen, Pufferspeicher, Verbraucher 1-Stufig, Waermepumpe und Ladestationen, Pufferspeicher, Verbraucher 1-Stufig, Waermepumpe und
+1
View File
@@ -799,6 +799,7 @@
}, },
{ {
"type": "ExpansionPanel", "type": "ExpansionPanel",
"name": "PrognoseForecast",
"caption": "Prognose / Forecast", "caption": "Prognose / Forecast",
"items": [ "items": [
{ {
+117
View File
@@ -12,6 +12,11 @@ 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/ManagerSDLTrait.php'; require_once __DIR__ . '/../libs/ManagerSDLTrait.php';
require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4TestTrait.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4AktivtestTrait.php';
require_once __DIR__ . '/../libs/ManagerPrognoseFormular.php';
use Belevo\EnelixEMS\Anlagentopologie; use Belevo\EnelixEMS\Anlagentopologie;
use Belevo\EnelixEMS\EinspeiseRegler; use Belevo\EnelixEMS\EinspeiseRegler;
@@ -19,6 +24,7 @@ use Belevo\EnelixEMS\EnergieMessung;
use Belevo\EnelixEMS\ManagerEnergieTrait; use Belevo\EnelixEMS\ManagerEnergieTrait;
use Belevo\EnelixEMS\Lizenzpruefung; use Belevo\EnelixEMS\Lizenzpruefung;
use Belevo\EnelixEMS\ManagerRegler; use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\ManagerPrognoseFormular;
use Belevo\EnelixEMS\ManagerSchnittstelle; use Belevo\EnelixEMS\ManagerSchnittstelle;
use Belevo\EnelixEMS\Nachrichtenvertrag; use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\StoerungsSnapshot; use Belevo\EnelixEMS\StoerungsSnapshot;
@@ -27,6 +33,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
{ {
use ManagerEnergieTrait; use ManagerEnergieTrait;
use \Belevo\EnelixEMS\ManagerSDLTrait; use \Belevo\EnelixEMS\ManagerSDLTrait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4Trait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4TestTrait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4AktivtestTrait;
private const STATUS_AKTIV = 102; private const STATUS_AKTIV = 102;
private const STATUS_MESSWERT_UNGUELTIG = 201; private const STATUS_MESSWERT_UNGUELTIG = 201;
@@ -118,6 +127,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
public function Create(): void public function Create(): void
{ {
parent::Create(); parent::Create();
$this->registriereNetzfahrplanV4();
$this->registriereV4ManagerTest();
$this->registriereV4ManagerAktivtest();
$this->RegisterPropertyInteger('Rolle', 0); $this->RegisterPropertyInteger('Rolle', 0);
$this->RegisterPropertyInteger('NetzleistungVariableID', 0); $this->RegisterPropertyInteger('NetzleistungVariableID', 0);
@@ -257,8 +269,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
public function ApplyChanges(): void public function ApplyChanges(): void
{ {
parent::ApplyChanges(); parent::ApplyChanges();
$this->v4ManagerTestAbbrechen('Manager neu initialisiert');
$this->SetTimerInterval('NetzfahrplanV4Senden', 0);
$this->stoppeNetzfahrplanV4Vorschau();
$this->initialisiereLizenzInstallationID(); $this->initialisiereLizenzInstallationID();
$this->konfiguriereNetzfahrplanV4Daten();
$this->aktualisiereVariablen(); $this->aktualisiereVariablen();
try { try {
@@ -315,6 +331,10 @@ 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', 90000);
}
$this->konfiguriereNetzfahrplanV4Vorschau();
$this->synchronisiereAnlagentopologie(); $this->synchronisiereAnlagentopologie();
if ($this->prognoseTelemetrieKonfiguriert()) { if ($this->prognoseTelemetrieKonfiguriert()) {
$this->SetTimerInterval( $this->SetTimerInterval(
@@ -451,6 +471,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->ReadPropertyBoolean('SDLAktiv') $this->ReadPropertyBoolean('SDLAktiv')
); );
$formular = ManagerPrognoseFormular::integrieren($formular, $this->prognoseBedienzustand());
return json_encode($formular, JSON_THROW_ON_ERROR); return json_encode($formular, JSON_THROW_ON_ERROR);
} }
@@ -489,6 +510,48 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->regeln(false); $this->regeln(false);
return; return;
case 'NetzfahrplanV4Empfangen':
$this->empfangeNetzfahrplanV4();
return;
case 'NetzfahrplanV4VorschauAktualisieren':
$this->aktualisiereNetzfahrplanV4Vorschau();
return;
case 'NetzfahrplanV4TestStart':
if (!is_string($wert)) throw new InvalidArgumentException('Explizite JSON-Testfreigabe erforderlich.');
$this->V4ManagerTestStarten($wert);
return;
case 'NetzfahrplanV4TestStop':
$this->V4ManagerTestStoppen();
return;
case 'NetzfahrplanV4Aktivtest':
$this->V4ManagerAktivtestSchalten((bool) $wert);
return;
case 'FormNetzfahrplanSchalten':
if (!is_bool($wert)) {
throw new InvalidArgumentException('Netzfahrplan-Schaltung muss ein boolescher Wert sein.');
}
if ($wert && !ManagerPrognoseFormular::startErlaubt($this->prognoseBedienzustand())) {
throw new RuntimeException('Netzfahrplan nicht freigegeben. Gespeicherte Konfiguration, Lizenz und Testfreigabe pruefen.');
}
try {
$this->V4ManagerAktivtestSchalten($wert);
} finally {
$this->aktualisierePrognoseBedienung();
}
return;
case 'NetzfahrplanV4DatenErfassen':
$this->ErfasseNetzfahrplanV4Daten();
return;
case 'NetzfahrplanV4Senden':
$this->sendeNetzfahrplanV4();
return;
case 'PrognoseSenden': case 'PrognoseSenden':
$this->sendePrognoseTelemetrie(); $this->sendePrognoseTelemetrie();
return; return;
@@ -520,6 +583,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if (!$aktiv) { if (!$aktiv) {
$this->UpdateFormField('NetzfahrplanAktiv', 'value', false); $this->UpdateFormField('NetzfahrplanAktiv', 'value', false);
} }
$this->aktualisierePrognoseBedienung($aktiv);
return; return;
case 'FormSDLAktiv': case 'FormSDLAktiv':
@@ -727,6 +791,28 @@ class Manager extends IPSModule implements ManagerSchnittstelle
} }
private function regeln(bool $erneuern): void private function regeln(bool $erneuern): void
{
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 0)) return;
try {
$trialBefore=(bool)$this->v4ManagerTestSession();
$this->regelnIntern($erneuern);
if ($trialBefore && !$this->v4ManagerTestSession() && $this->GetValue('Aktiv')
&& IPS_GetInstance($this->InstanceID)['InstanceStatus'] === self::STATUS_AKTIV) {
$this->WriteAttributeString('LetzteSollwerte','{}');
$this->regelnIntern(true); // exactly one fresh fallback, no cached pre-trial target
}
if ($this->v4ManagerTestSession()
&& (!$this->GetValue('Aktiv') || IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== self::STATUS_AKTIV)) {
$this->v4ManagerTestAbbrechen('Manager nicht mehr betriebsbereit');
}
} catch (Throwable $e) {
$this->v4ManagerTestAbbrechen('Regelungsfehler');
throw $e;
} finally { IPS_SemaphoreLeave($lock); }
}
private function regelnIntern(bool $erneuern): void
{ {
if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) { if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) {
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
@@ -831,6 +917,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
: $this->nichtGeregelteGeraeteText($nichtGeregelt) : $this->nichtGeregelteGeraeteText($nichtGeregelt)
); );
$this->v4ManagerTestUebergabe($ergebnis, $verbraucher, $synchronisiert);
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte'); $letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) { foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) { if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
@@ -840,6 +927,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung); $this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung);
$letzteSollwerte[(string) $instanzID] = $sollleistung; $letzteSollwerte[(string) $instanzID] = $sollleistung;
} catch (Throwable $fehler) { } catch (Throwable $fehler) {
$this->v4ManagerTestAbbrechen('Andere Verbraucher-Stellwertuebergabe fehlgeschlagen');
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage(); $stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
} }
} }
@@ -2600,6 +2688,35 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$aktiv && $netzfahrplanLizenziert $aktiv && $netzfahrplanLizenziert
&& $this->peakShavingLizenziert($lizenzcode) && $this->peakShavingLizenziert($lizenzcode)
); );
$this->aktualisierePrognoseBedienung(null, $lizenzcode);
}
private function prognoseBedienzustand(?bool $aktiv = null, ?string $lizenzcode = null): array
{
return [
'prognoseAktiv' => ($aktiv ?? true) && $this->ReadPropertyBoolean('PrognoseAktiv'),
'lizenziert' => $this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN, $lizenzcode),
'senderAktiv' => $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv'),
'empfangAktiv' => $this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv'),
'messdatenAktiv' => $this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv'),
'legacyAktiv' => $this->ReadPropertyBoolean('NetzfahrplanAktiv'),
'managerAktiv' => $this->GetValue('Aktiv')
&& IPS_GetInstance($this->InstanceID)['InstanceStatus'] === self::STATUS_AKTIV,
'testErlaubt' => $this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt'),
'aktivtestErlaubt' => $this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'),
'watchdogVerzichtErlaubt' => $this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt'),
'aktivtestAktiv' => (bool) $this->GetValue('NetzfahrplanV4Aktivtest'),
'ende' => $this->ReadAttributeInteger('NetzfahrplanV4AktivtestEnde'),
'jetzt' => time(),
];
}
private function aktualisierePrognoseBedienung(?bool $aktiv = null, ?string $lizenzcode = null): void
{
$zustand = $this->prognoseBedienzustand($aktiv, $lizenzcode);
$this->UpdateFormField('PrognoseNetzfahrplanStart', 'enabled', ManagerPrognoseFormular::startErlaubt($zustand));
$this->UpdateFormField('PrognoseNetzfahrplanStatus', 'caption', ManagerPrognoseFormular::statustext($zustand));
$this->UpdateFormField('NetzfahrplanV4EmpfangAktiv', 'enabled', $zustand['lizenziert'] || $zustand['empfangAktiv']);
} }
private function prognoseLizenzStatustext(bool $aktiv, bool $lizenziert): string private function prognoseLizenzStatustext(bool $aktiv, bool $lizenziert): string
+387
View File
@@ -0,0 +1,387 @@
# ENELIX Netzfahrplan V4 – verbindlicher Gesprächs- und Arbeitskontext
## Integration 06.10.2026: Freigabe fuer develop und beta, kein Anlagenrollout
Daniel hat nach der Bedienintegration Merge, Commit und Push auf **develop und
beta** ausdruecklich beauftragt. Der getestete Integrationsstand verbindet die
22 lokalen V4-Commits bis ce525a1 mit origin/develop bis 5437f3f und den
zugehoerigen V4-/Formularkorrekturen. Originalarbeitsverzeichnisse bleiben
unangetastet; Integration in /srv/agent/prognose-integration-20261006/repo auf
ubuntu. Keine Feature-Branches, kein Force-Push, main bleibt unveraendert.
Die Bedienung liegt im bisherigen Bereich Prognose / Forecast; der separate
V4-Bereich entfaellt. Einzelheiten und verbleibender Umfang stehen in
docs/prognose-bedienintegration.md. Es bleibt der vorhandene begrenzte Testpfad,
kein regulaerer Dauerbetrieb. Keine Laufzeitordner geloescht, keine Dienste
neu gestartet, keine Module geladen und kein neuer Stellversuch ausgefuehrt.
Die unten dokumentierte Batterietimerblockade bleibt offen. Die ausdrueckliche
Branchfreigabe ersetzt keine physische Anlagenabnahme oder Stellfreigabe.
Isoliert bestanden: 357 PHPUnit-Tests / 1758 Assertions, Syntax aller 150 PHP-
Dateien, 310 Backend-Tests, 16 Portal-Tests und die Bedien-/V4-Einzelchecks.
Kein neuer Symcon-Kernel-/GUI-Test, weil Docker im Agentzugang nicht nutzbar war.
Eine lokale Backend-Aenderung, die ungepruefte Messkonfigurationen automatisch
mit accountingEvidenceId versah, wurde nach fehlgeschlagenem Schutztest nicht
uebernommen; Originalarbeitskopie und laufendes Backend bleiben erhalten.
Commit-Autor ist gemaess bestehender Teamklarstellung dh_Agent <dh@belevo.ch>,
konfiguriertes Gitea-Konto dh. Die Repository-Abfrage bestaetigte Pushrecht;
die Profilabfrage war mangels read:user-Scope nicht verfuegbar. Fuer den
tatsaechlichen Veroeffentlichungsstand die Remote-Refs und den Merge-Commit
pruefen, nicht historische Ahead-/Behind-Angaben weiter unten verwenden.
## Kontext-Sicherung 06.10.2026: Lesereihenfolge und Quellen
Auf Daniels ausdruecklichen Wunsch wird der Arbeitsstand dauerhaft verlinkt.
Fuer den Laufzeitstand ist der unmittelbar folgende Abschnitt **06.10.2026,
12:01 UTC** massgeblich, nicht die aelteren Abschnitte 1, 10, 11 oder 15.
Insbesondere ist der bestaetigte Geraeteabruf bereits implementiert/installiert;
der naechste Schritt ist die dort belegte instanzbezogene Batterietimerblockade.
Der Abschlussnachweis `/srv/agent/v4-recovery-20261006/finished.json` auf
iot-symcon01 wurde erneut gelesen, aber kein neuer Stellversuch ausgefuehrt.
Git-Nachkontrolle in dieser Kontext-Sicherung: nach erfolgreichem Fetch steht
das Server-89-develop **22 Commits vor / 3 hinter origin/develop**; Remote-Stand
bei Fetch `58f91e4`. Kein sicherer Fast-Forward. Bestehende fremde/lokale
Aenderungen bleiben erhalten; kein Merge, Projekt-Commit oder Projekt-Push.
Das ist vom anderen Checkout auf ubuntu und dessen separat dokumentierter
Manager-Leistungsverteilung zu unterscheiden.
Die mitgesendeten Screenshots haben kein bestaetigtes Aufnahmedatum und sind
historische Anschauung, kein neuer Laufzeitnachweis. Der Aktualisierungsdialog
ist keine Freigabe, lokale Modulaenderungen zu verwerfen. Es wurden keine
Module aktualisiert, Dienste neu gestartet oder Anlagenfreigaben veraendert.
## Fortsetzung 06.10.2026, 12:01 UTC: Code installiert, Batterietimer blockieren Live-Abnahme
- Daniel hat nach der ausdruecklichen Rueckfrage den Test ohne zusaetzliches +/-5-kW-Limit bestaetigt. Dynamische Geraetegrenzen (hier bis 39 kW), SOC- und Netzgrenzen bleiben verbindlich. Die urspruengliche Freigabe endet unveraendert am 07.10.2026 um 17:38:07 Europe/Zurich (1791387487). Kein Neustart der 48-h-Frist. Der fehlende unabhaengige Hardware-Watchdog bleibt nur durch die bereits genehmigte Testanlagen-Ausnahme abgedeckt.
- Zusaetzliche allgemeine Arbeitsfreigabe: Routinemaessige Schreibzugriffe auf Lihrenmoos, tunel100 und Server89 benoetigen keine erneute Rueckfrage. Das hebt Sicherheitsgrenzen, technische Berechtigungen, Git-Regeln und konkrete Anlagenfreigaben nicht auf.
- Der vorstehend beschriebene Kandidat wurde inzwischen installiert und getestet. Weitere eng begrenzte Korrekturen dieser Fortsetzung: (1) Batteriebefehl meldet den konkreten Ablehnungsgrund; (2) physische Geraeteabfrage aus dem Batterie-Stellmutex getrennt, eigenstaendiger 1-s-Samplingtimer mit nicht wartender Lesesperre; (3) Stellpfad nutzt nur unveraenderten bestaetigten Cache, maximal 2 s alt nach Wand- und monotoner Zeit, mit Konfigurations-/Quellenbindungspruefung; (4) passendes Server-Pending fuehrt auch vor Aktivstart zu 15-s-Retry statt zum Verpassen des fertigen Plans, HTTP-429 behaelt normalen Backoff. Cache-90-s-Grenze und physische Zeitstempel wurden nicht verlaengert oder erfunden.
- Live-Installationen mit SHA-256-Pruefung und Backups: Fehlerdiagnose 11:31:18 UTC; Sampler 11:38:43 UTC; Empfang um 11:50 UTC. Arbeits- und Sicherungsordner: /srv/agent/v4-recovery-20261006 auf iot-symcon01. Berichte deployed.json, sampler-deployed.json, receiver-deployed.json und test-results.json. Geaenderte Quellen und Tests sind auch im bestehenden develop-Arbeitsverzeichnis auf enelix-services, fremde Aenderungen sind erhalten.
- Letzter Aktivversuch: 11:50:52 UTC, Session 13395dbe-0dbb-42b8-9063-511bd7166f41. Manager meldete danach controller_command_sent (seq 5, 18693 W); Batterie wurde wegen feedback_confirmed_cache_stale_or_invalid gestoppt. Danach Managerabbruch wegen ungueltiger lokaler Betriebsdaten, controllerStopAccepted=true. Kein belastbarer Nachweis von physischem Tracking oder >90-s-Dauerbetrieb. Ein gesendeter Befehl ist ausdruecklich keine Anlagenabnahme.
- KONKRETE OFFENE BLOCKADE: Saemtliche Timer der Batterieinstanz 44234 zeigen LastRun=0. Meldezyklus (5000 ms) und neuer Rueckmeldungstimer (1000 ms) sind lange ueberfaellig, Running=false. Manager- und uebrige Anlagentimer laufen. Instanzstatus 102; Instanz und alle Elternobjekte nicht deaktiviert; keine dauerhaft belegten Skriptthreads nachgewiesen. IPS_ApplyChanges(44234) bei bestaetigtem Null-Stopp um 11:57:31 UTC gab TRUE zurueck, hat die Timer aber nicht repariert. Die Ursache ist NICHT geklaert. Auch der Batterie-Watchdog hat bisher keinen automatischen Lauf nachgewiesen. Deshalb kein weiterer Aktivstart und keine Umgehung durch einen externen Tick-Hook.
- Weitere beobachtete Abbruchbedingung: Durch Reloads/Messluecken fehlte zeitweise laufende Viertelstundenenergie; der Planner verwirft Luecken >120 s weiterhin korrekt. Nach einem Quartalswechsel wurden wieder optimale Plaene empfangen. Zum Schluss meldete der Betriebssender zudem veraltete Verbraucher-Rueckmeldung, passend zum nicht laufenden Batterie-Meldezyklus. Keine Energiestaende, Cursor oder Datenbankwerte wurden in dieser Fortsetzung umgeschrieben.
- Endzustand 12:01:11 UTC: Schalter 46716=false, alter Netzfahrplan=false, Batterie status=stopped, EV-Sollwert 19651=0 W. SDL-Sollwert war 7600 W aus dem unabhaengigen bestehenden Betrieb und wurde nicht auf null gezwungen. Kein physischer V4-Regelungsnachweis. Verbindlicher Abschlussbericht: /srv/agent/v4-recovery-20261006/finished.json.
- Alle temporaeren Start-, Deploy- und Diagnosehooks wurden aus 12555.ips.php entfernt. Original aus /srv/agent/12555.ips.php.v4hook-backup wiederhergestellt, SHA-256 c702a4947ae200b23c3eecccabeb843ad6a55e97509292584ca2de779d5e4940. Nur v4viewsRun des urspruenglichen getrennten Beobachters verbleibt. Inhaltsuche in /var/lib/symcon/scripts fand keine Verweise auf die V4-Recovery-/Continuation-Hooks. Keine unbeobachtete Wiederanlaufautomatik hinterlassen.
- Tests erneut erfolgreich: 96 Regel-/Cache-/Lockpruefungen, 32 Empfangspruefungen, 8 Rueckmeldungs-Empfangspruefungen, 9 Geraeteabrufpruefungen und 1 Test der echten Registermethode mit synthetischen Aktionen, insgesamt 146. Syntaxpruefungen erfolgreich; git diff --check sauber. Das sind automatisierte Tests, kein Hardwareabnahmetest.
- Git bleibt develop, ahead 22 / behind 1 gegen origin/develop, kein sicherer Fast-Forward. Keine Projekt-Commits, Merges oder Pushes. Konto nicht fuer Schreibaktion verifiziert; weder beta noch main geaendert. Die fehlende Live-Abnahme schliesst eine Beta-Freigabe zusaetzlich aus.
- Naechster fachlicher Schritt: Instanzbezogene Symcon-Timerblockade beheben, automatische Sampler-/Melde-/Watchdog-Ausfuehrung belegen und erst dann einen kontrollierten Neustart des V4-Tests versuchen. Ein etwa erforderlicher Symcon-Dienstneustart betrifft weitere Anlagenfunktionen und wurde hier NICHT ausgefuehrt. Danach physisches Tracking, Planwechsel >90 s und bestaetigten Null-Stopp nachweisen. Alte Startmarker oder Installationshooks keinesfalls einfach loeschen/reaktivieren.
## Fortsetzung 06.10.2026: gepruefter Kandidat, Live-Eingriff noch gesperrt
- Daniel hat die Fortsetzung und die Aufhebung der 5-kW-Testgrenze beauftragt. Die bestehende 48-h-Frist, SOC-/Netz-/Geraetegrenzen und der bereits bekannte fehlende unabhaengige Hardware-Watchdog bleiben unveraendert.
- Aktuell auf iot-symcon01 gelesen: Refresh-Deployment 07:43:14 UTC; Full-Power-Deployment 08:46:59 UTC. Diese waren schon vor dieser Fortsetzung installiert. Snapshot 09:30:41 UTC: Schalter AUS, Batterie disabled, Manager stopped_fallback_pending wegen ungueltiger lokaler Betriebsdaten. Keine physische V4-Regelung belegt.
- Neuer Kandidat korrigiert ignorierte FALSE-Rueckgaben von IPS_RequestAction/RequestAction (Start, Command, Stop und Register), den Vor-I/O-Zeitvergleich beim Batterie-Scharfschalten und den blockierenden HTTP-Refresh im Regel-Tick. Separater Empfangstimer: 10 s Prueftakt, aktiver Abruf fruehestens nach 45 s, Fehler-Retry 15 s; Cache-Lebensdauer und Serverzeit bleiben maximal 90 s.
- Kandidat liegt auf develop im Arbeitsverzeichnis und isoliert unter /srv/agent/v4-continuation-20261006 auf Lihrenmoos. 87 Regeltest-, 29 Empfangs-, 8 Rueckmeldungspruefungen plus 1 echter Modulmethoden-Test mit synthetischen Aktionen bestanden; 5 geaenderte Live-PHP-Dateien syntaxgeprueft. Keine Hardwaretests daraus ableiten. Testbericht: test-results.json im Stage.
- Neue Tests decken insbesondere FALSE ohne Exception, Sekundengrenze beim Start, bytegleich behaltenen aktiven Cache, 15-s-Retry, 90-s-Ablauf und zukuenftige Empfangszeit ab. git diff --check erfolgreich.
- Die automatische Sicherheitspruefung hat die Ausfuehrung von /srv/agent/activate_v4_continuation.py abgelehnt, weil diese Live-Installation und Aktivstart koppelt. Der Benutzer wurde um ausdrueckliche Freigabe fuer Installation und Start ohne 5-kW-Limit bis zum bisherigen Ende (07.10.2026 17:38 Europe/Zurich) gebeten. NICHT ueber andere Werkzeuge umgehen. Die Aktivierung wurde nicht ausgefuehrt; 12555.ips.php ist unveraendert.
- Achtung: Der bestehende temporaere v4_direct_battery_command_hook.php sendet alle 30 s den letzten Managerbefehl erneut. Das kann den Replay-Schutz ausloesen. Vor freigegebenem Neustart kontrolliert entfernen. Vorbereitete Aktivierung sichert den alten Timer und ersetzt alte Schreib-/Diagnosehooks durch eine einmalige gesicherte Installation, genau einen Startversuch und reine Beobachtung. Sie ist noch nicht ausgefuehrt. Bei Live-Drift abbrechen.
- Git nach erfolgreichem Fetch: develop ist 22 Commits vor und 1 Commit hinter origin/develop (fremder Commit 37addf4). Fremde Lade-/GUI-Aenderungen sind erhalten. Kein Fast-Forward moeglich; kein Commit, Merge oder Push in dieser Fortsetzung. Kandidatenpatch: /srv/agent/v4-continuation-20261006.patch auf enelix-services.
- Naechster Schritt erst nach Freigabe: Stage/Live-Hashes und Zustand erneut pruefen, Kandidat installieren, genau einen Startversuch beobachten und dessen echte Batterie-Antwort pruefen. Danach >90 s mit Planwechsel, reale Leistungs-/Netzreaktion und sicheren Nullstell-Stopp nachweisen. Erst anschliessend regulaeren Testlauf belassen und temporaere Hooks entfernen. Kein stabiler Aktivbetrieb zugesichert.
Übergabe erstellt am 04.10.2026 auf ausdrücklichen Wunsch von Daniel Häfliger (BELEVO AG). Sie fasst die Anforderungen, Entscheidungen, Fehlerbehebungen und den zuletzt belegten Stand dieses Gesprächs zusammen. Sie ist **kein wörtliches Chatarchiv, keine neue Stellfreigabe und kein Nachweis der Produktionsreife**.
**Zeitbezug:** Die letzten hier belegten Betriebsberichte stammen vom **03.10.2026, ca. 14:02 Uhr Europe/Zurich**. Am 04.10. wurden für diese Übergabe die genannten Berichte und der Git-Stand erneut gelesen, aber keine neue Anlagenabnahme durchgeführt. Alte Messzahlen deshalb niemals als heutige Live-Werte ausgeben.
**Fortsetzung Prognose am 04.10.2026:** Der aktuelle Runtime-Stand wurde gelesen, ohne die alten Diagnose- oder Installationsschritte zu wiederholen. Der abgeleitete Datensatz `lihrenmoos-physical-published-v2` ist `model_ready` und erfüllt mit mehr als 24 nutzbaren äquivalenten Stunden die Trainingsschwelle. Die V4-Einstellungen stehen seit Revision 2 in `shadow` auf `forecastSource=corrected_profile` und `measurementDataset=lihrenmoos-physical-published-v2`; `liveEnabled` und Stellfreigabe bleiben aus. Der erste echte Lauf zeigte einen kompatibilitätsbedingten Stopp bei mikrosekundengenauen `observedAt`-Zeitstempeln. Die Korrektur rundet ausschliesslich die kausale Verfügbarkeit von Prognoseereignissen auf die nächste volle Sekunde auf; Rohmessungen bleiben streng ganzsekündlich. 310 Python- und 16 Portal-Tests sind erfolgreich. Repository und Runtime-Build-Kontext enthalten bytegleich den geprüften Fix; der laufende Container enthält ihn noch nicht, weil beide Agentzugänge weder Docker-Socket noch `sudo` erhalten. Der vorbereitete, syntaktisch geprüfte Root-Rollout liegt unter `/home/agent/services/netplan-v4-shadow/commissioning/finish_corrected_forecast_rollout.sh` und baut/testet vor dem Austausch, hält das vorige Image als Rollback fest und ändert keine Stellfreigabe. Daniel hat danach einen Aktivbetrieb angefragt; der aktuelle V4-Kern ist jedoch technisch `shadow-only`, und die weiterhin unbelegte Rückmeldung (`feedback_source_skew`, `usableForTrial=false`, `canDispatch=false`) verbietet ein blosses Umstellen. Keine Live-Freigabe wurde erteilt oder eingebaut. Git-Klarstellung durch Daniel: Commit-Autor `dh_Agent <dh@belevo.ch>`, Gitea-Konto `dh`; Commit/Push auf `develop` und `beta` am 04.10.2026 ausdrücklich freigegeben. Der Agentzugang besitzt derzeit keine HTTPS-Schreibanmeldung für `dh`.
**Fortsetzung bestätigte Rückmeldung am 04.10.2026:** Daniels Freigabe des modellierten `virtual_split` gilt für den ausdrücklich begrenzten Feldtest. Die physische Rückmeldung wurde um synchrone, durch den Gerätetreiber bestätigte Lesevorgänge erweitert: M-Bus verwendet `MBUS_UpdateValues`, ModBus Device und ModBus Address verwenden `ModBus_RequestRead`. Nur ein erfolgreicher Geräteaufruf erzeugt `confirmedAt`; `VariableUpdated` bleibt getrennte Herkunftsinformation. Die Regeltest-Gates verlangen `deviceReadConfirmed=true`. Das reproduzierbare inkrementelle Paket liegt auf der Testanlage unter `/srv/agent/netplan-v4-confirmed-feedback-20261004`; 18 Paketprüfsummen, 37 Rückmeldetests, 7 Geräteabruf-Tests und 71 Regeltest-Prüfungen sind erfolgreich. Es ist noch **nicht im IP-Symcon-Kernel installiert**, weil der lokale JSON-RPC-Zugang ohne Anmeldung mit HTTP 401 antwortet. Die Installation selbst lässt alten Netzfahrplan und beide Regeltestfreigaben aus. Ein realer Stellversuch bleibt zusätzlich durch den nicht belegten unabhängigen Geräte-Watchdog und die fehlende Serverfreigabe blockiert; der vorhandene Symcon-`VorgabeTimeout` ist kein unabhängiger Hardware-Nachweis. Auf `enelix-services` und der Testanlage ist kein nutzbarer Git-HTTPS-Credential-Helper hinterlegt; die Prüfung des Tunnel-Servers lief zweimal in eine externe Connector-Freigabezeitüberschreitung. Tokeninhalt wurde weder gelesen noch ausgegeben. Der Commit des Folgepakets ist lokal mit der vereinbarten Identitaet `dh_Agent <dh@belevo.ch>` erstellt. Der atomare Push auf `develop` und `beta` scheiterte ohne Teilaktualisierung, weil fuer `https://dh@git.belevo.ch` kein Passwort/Token bereitstand.
**Fortsetzung Aktivbereitschaft am 04.10.2026:** Die reale Stellkette wurde in den gespeicherten Testanlagen-Settings bis zu den Aktionszielen geprüft. Die ENELIX-Batterieinstanz 44234 schreibt über das Aktionsskript 31800 auf den EV-Sollwerteingang 19651 des Gateways 58448. Dieses Gateway verteilt im Zwei-Sekunden-Takt auf zwei GoodWe-Pfade über reine SetValue-Aktionsskripte und auf drei SolarEdge-Modbus-Adressen. Der ENELIX-`VorgabeTimeout` setzt bei laufendem Symcon nach 30 Sekunden auf 0 W zurück. Weder im Gateway noch in den drei GoodWe-Aktionsskripten wurde jedoch ein vom Symcon-Kernel unabhängiger Geräte-Watchdog gefunden; auch die SolarEdge-Modbus-Konfiguration weist nur Poll-/Write-Parameter, keinen Geräte-Timeout aus. Ein unbeaufsichtigter 1-2-Tage-Stellbetrieb bleibt deshalb gesperrt. Beide lokalen Regeltestfreigaben sind weiterhin aus, es wurde kein Stellbefehl ausgelöst. Der bestätigte Rückmeldeinstaller ist für einen gestoppten Kernel vorbereitet und geprüft, konnte mangels privilegierter `systemctl`-Berechtigung des Connector-Benutzers aber noch nicht ausgeführt werden. Auf `tunel100` ist ein Credential-Helper für das Konto `dh` vorhanden und ein `develop`-Dry-Run war erfolgreich; die danach nötige Connector-Freigabe lief erneut ab, bevor der lokale 19-Commit-Stand übertragen und veröffentlicht werden konnte.
## 1. Zuerst lesen: Wo wir tatsächlich stehen
- Daniel möchte die **fertige, produktionsgeeignete Anwendung**, nicht weitere isolierte Sammler, Diagnosekategorien oder wiederholte Bestätigungsrunden. Er hat die fortlaufende Umsetzung mehrfach beauftragt. Probleme im Code selbst beheben, Tests und Auslieferung bündeln; ihn nur für wirklich notwendige Root-/Symcon-Ausführung oder echte Anlagenfreigaben einbeziehen.
- Datenaufnahme, bestätigter Versand, historische Aufbereitung, Modellaufbau, V4-Optimierer, Planempfang und ein ausdrücklich begrenzter Regeltest sind implementiert. Der zuletzt installierte Teil verbindet korrigierte physische Rückmeldung mit Vorschau und dem **begrenzten** Test-Stellpfad.
- Der Versandblocker durch `100.0`/`100` ist **behoben und der Nachversand hat aufgeholt**. Nicht erneut beim Cursor oder der alten Kennungsdiagnose anfangen.
- Die letzte Feedback-Installation ist **erfolgreich abgeschlossen**, nicht mehr `waiting_for_registration`. Bericht: `corrected_feedback_installed_trial_disabled`, Rückmeldung `unavailable`, Grund **`feedback_source_skew`**.
- **Nächster konkreter Entwicklungsauftrag:** bestätigte, zum tatsächlich gelesenen Wert passende Geräte-Lesezeitpunkte an den lokalen Rückmeldepfad anschliessen. Bisher wird `VariableUpdated` als Live-Zeitbasis verwendet; unveränderte Modbus-Werte werden aber nicht zwingend bei jedem erfolgreichen Abruf erneut publiziert. Geräteabruf, Variablenpublikation und aktueller Lesezugriff müssen auseinandergehalten werden.
- Keine künstliche Zeitstempelverjüngung, pauschale Toleranzerhöhung, Nullersetzung, historische Interpolation als Live-Messung oder Rückkehr zum festgehaltenen virtuellen EV-Wert.
- **Weiterhin keine produktive V4-Batterieausführung:** alter intelligenter Netzfahrplan AUS; beide lokalen Regeltestfreigaben AUS; kein gestarteter Stellversuch. Bestehende lokale Regelung und SDL laufen unabhängig weiter.
- Ein uneingeschränkter Produktions-Dauerregler ist **noch offene Umsetzung**, nicht nur ein fehlender Haken. Automatik, reale Modellqualität, Geräteausfallverhalten und Mehranlagenfreigabe haben ebenfalls verbleibende Grenzen.
- Letzter fachlicher EMS-Commit: **`9878197`**, `develop`, bei Übergabeprüfung sauber und **15 Commits vor lokalem `origin/develop`**. Kein Remote-Fetch in dieser Prüfung. Vorherige Pushversuche scheiterten an Authentifizierung. Dokumentations-Commits dieser Übergabe können danach folgen.
## 2. Arbeitsweise und Freigaben
Daniel ist technisch versiert, will aber keine unnötige manuelle Kleinarbeit. Deutsch, Schweizer Schreibweise, konkrete nutzbare Befehle. Ein abgeschlossener Test ist nicht automatisch Installation, Installation nicht Stellfreigabe, `optimal` nicht reale Einsparung. Immer getrennt berichten: implementiert / isoliert getestet / Zielcontainer getestet / installiert / im Kernel geprüft / am Gerät erprobt / veröffentlicht.
**Nicht wieder in die alte Schleife zurückfallen:**
1. Keine neuen Diagnosekategorien als Ersatz für die eigentliche Integration.
2. Keine erneuten Fragen nach bereits bestätigten EV-Kapazitäten oder pauschal immer wieder nach Wechselrichtertypen. Bestehende Quellen nutzen. Eine tatsächlich fehlende sicherheitsrelevante Eigenschaft darf trotzdem nicht erfunden werden.
3. Nicht einfach „24 Stunden warten“, solange ein Software- oder Übertragungsfehler das Training verhindert.
4. Keine alten Einzelinstaller erneut ausführen; neueren Quellstand und parallele GUI-Arbeit nicht überschreiben.
5. Nicht behaupten, im Hintergrund weiterzuarbeiten. Nur tatsächlich ausgeführte Arbeit und belegte Ergebnisse berichten.
6. Die Regel „ein einziger konkreter Punkt“ betraf Einträge in **Offene Punkte**, nicht Entwicklungsaufträge. Implementierungsaufträge dürfen zusammenhängende Anforderungen bündeln.
**Git:** Keine Feature-Branches. Entwicklung auf `develop`; nach Tests `beta`; nach Feldtest `main`. Daniel hat im Gespräch Commit/Push für `develop` und damals auch `beta` ausdrücklich freigegeben. Diese Freigabe nicht als Auftrag interpretieren, einen aktuell ungetesteten Kandidaten blind auf `beta`/`main` zu schieben. `develop`-Push ohne erneute Nachfrage im bestehenden Rahmen erlaubt. Nicht force-pushen, nicht unzusammenhängende Änderungen übernehmen, keine Zugangsdaten verlangen/ausgeben. Historische Agenten-Commitidentität war `ENELIX Agent <agent@enelix.invalid>`; keine globale Git-Konfiguration ungeprüft ändern.
Die aktuelle Nachricht beauftragt **Kontextübergabe**. Sie erteilt keine zusätzliche Anlagen-, Runtime- oder Stellfreigabe. Bei späterer Fortsetzung greifen die bestehenden Sicherheits- und Branchregeln weiter.
## 3. Ursprüngliches fachliches Ziel
### 3.1 Prognosefamilien und Erweiterbarkeit
Im GUI manuell eine Familie wählen oder `auto`:
| Familienschlüssel / Fahrplan | PV | Last | Fahrplanvariable |
|---|---|---|---|
| `3` | `prog_var_1` | `prog_var_2` | `prog_var_3` |
| `13` | `prog_var_10` | `prog_var_11` | `prog_var_13` |
| `23` | `prog_var_21` | `prog_var_22` | `prog_var_23` |
Die neun Zahlen sind nicht neun unabhängige Fahrplanmodelle. Daniel akzeptierte die Auswahl der vollständigen Modellfamilie. Registry und Datenadapter so gestalten, dass weitere Familien und später weitere flexible Anlagen ergänzt werden können. Nicht behaupten, drei bereits vorhandene Lastmethoden seien identisch mit den neu aufgebauten physischen Profilen; Datenquelle und Modellversion müssen sichtbar sein.
`auto` soll jene Familie wählen, die in der jüngeren Vergangenheit **wirtschaftlich am besten abgeschnitten hätte**, mit gleichen Anfangsbedingungen, Tarifen, SOC-/Restenergiebewertung und Randbedingungen. Keine Auswahl allein nach R², keine Zukunftsinformation im historischen Replay. Mindestabdeckung, Mindestdauer, Rückblick und Wechselmarge verhindern häufige/schlecht belegte Wechsel.
### 3.2 Netzfahrplan / Optimierung
- Bis zu **48 Stunden**, grundsätzlich **5-Minuten-Schritte** (höchstens 576), rollende Neuberechnung.
- Ausgangsverlauf: korrekt abgegrenzte Verbraucherlast minus PV plus separat berücksichtigte externe Flüsse. Darauf flexible Verbraucher optimieren; aktuell EV-Batterie, später Ladestationen, Boiler usw.
- Batterie: aktueller virtueller EV-SOC, dessen zugehörige EV-Gesamtkapazität, aktuelle maximale Lade-/Entladeleistung, Min-/Max-SOC, Reserve, Hysterese, Verfügbarkeit und lokale Einschränkungen. Keine zukünftige Energie vor dem Laden nutzen.
- **90 % Roundtrip-Wirkungsgrad**, nicht zweimal 90 %. Implementierung verteilt Verluste mit `sqrt(0.9)` auf Laden/Entladen.
- Netzladen ist ausdrücklich erlaubt/gewünscht, wenn freigegeben und wirtschaftlich sinnvoll. Nicht generell Nachtbezug verbieten oder tagsüber Entladung verbieten.
- Kosten pro Intervall: Bezugsenergie mal Bezugspreis minus Einspeiseenergie mal Einspeisepreis. Leistungswerte in W/kW korrekt über Zeit in kWh umrechnen. Gesamtkosten minimieren, nicht mathematisch auf exakt null zwingen; negative Kosten sind möglich, nicht garantiert.
- Statische oder dynamische, in der Anlage konfigurierte Bezugspreise und Einspeisevergütungen verwenden. GUI-Preisänderungen speichern und Neuberechnung anfordern. PV-Vergütung, Netzladekosten, Verluste und Peakpreis gehören in dieselbe Wirtschaftlichkeitsbetrachtung.
- Wenn dynamische Preise noch nicht veröffentlicht sind, den **ausführbaren wirtschaftlichen Horizont auf den bekannten Preisbereich begrenzen** (`published_only`); kein frei erfundener Preis für den Rest der 48 h. Volle Prognose und ausführbaren Preishorizont unterscheiden; Ende passend zur vollständigen Abrechnungsviertelstunde.
- Restenergie am Horizont fair bewerten/absichern, damit die Batterie nicht am Planende oder zu fast wertlosen Mittagstarifen grundlos leerverkauft wird.
- Bezug/Einspeisung sowie Laden/Entladen nicht als unphysikalische gleichzeitige Arbitrage zulassen.
- Einspeisebegrenzung, zulässige Abregelung/entgangene Vergütung und lokale harte Netzgrenzen berücksichtigen. Einen unerreichbaren Plan nicht als eingehalten darstellen.
### 3.3 Monatspeak
- Leistungstarif auf den relevanten **15-Minuten-Netzbezug**; persistenter Monatsmaximalwert, nicht momentane Spitzenleistung. Für den laufenden Monat zusätzliche Kosten nur für Erhöhung über den bereits erreichten Peak; Monatswechsel korrekt behandeln.
- Bereits verstrichene Energie der aktuellen Viertelstunde mitnehmen. Fehlende Messbasis nicht durch eine konfigurierte Grenze ersetzen.
- Am Monatsanfang nicht künstlich jede Aktivität verhindern: Restmonatsbewertung/empirische Aussicht ist vorgesehen, mit ausgewiesener Unsicherheit. Ein erwarteter künftiger Peak ist **keine bereits bezahlte kostenlose Freigabe**.
- Anlagen können **keine feste Bezugsgrenze** haben. Falls im Manager statische oder monatliche Peakgrenzen konfiguriert sind, diese zusätzlich respektieren.
- Tarifeinheiten und tatsächliche Abrechnungsdefinition prüfen; gemessene, geschätzte und nur konfigurierte Peakwerte getrennt kennzeichnen.
### 3.4 Training, Reaktion und Validierung
Tägliches oder wöchentliches Nachtraining nach GUI-Einstellung; Parameter-/Preisänderungen sollen Neuberechnung auslösen. Getrennte versionierte Historien und Modelle, keine alten belasteten Daten als bereinigt umbenennen. Ein Cold-Start-Modell nach Mindestdatenbasis ist noch keine validierte Feldprognose. Vergleich gegen die bisherige Batterie-Betriebsweise, nicht nur gegen „Anlage ohne Batterie“.
## 4. Lihrenmoos: bestätigte Anlage und massgebliche Korrekturen
- Installation: **`e3a08f9e-af12-4695-99bd-8b51c0520021`**.
- Symcon Manager **`17004`**, ENELIX-Batterieinstanz **`44234`**, virtuelles Asset **`anlage01-virtual-ev`**, bestehendes EV-/SDL-Gateway **`58448`**.
- **Massgeblich bestätigt: EV-Kapazität `161.44 kWh`, EV-Leistung `39 kW`.** Die vorher genannten `160 kWh`, `30 kW` und „je 10 kW pro WR“ hat Daniel ausdrücklich als Erinnerungskorrektur zurückgenommen. Daraus keine neue Verteilung auf Einzelgeräte ableiten.
- Virtueller EV-SOC und die EV-Kapazität bilden ein gemeinsames Energiekonto. Keine SOC-/Energie-Resets oder Umrechnung auf 322 kWh. **SDL-Reserve ist bereits ausgegliedert** und darf nicht nochmals von EV abgezogen werden.
- Vom Nutzer genannte physische Ausstattung: zwei GoodWe à **50 kW AC**, je **156 kWh** Batterie; SolarEdge **10 kW AC / 10 kWh** Batterie. Summe physische Kapazitäten rechnerisch 322 kWh, nicht die EV-Kapazität. AC-Nennleistung ist kein unabhängiger Nachweis jeder aktuell zulässigen Batterie-Ladeleistung.
- Dach-PV ungefähr **20 kWp Ost + 20 kWp West**.
- Exakte Gerätetypen/Firmware, jede AC-/DC-Messgrenze und autonomes Verhalten beim Befehlsausfall sind durch diese Nennwerte nicht automatisch bestätigt. Vorhandene Dokumente/Quellen verwenden, statt Angaben zu erfinden oder Daniel wiederholt pauschal zu befragen.
- Historischer Tarif-Snapshot: Bezug `CKW Dynamisch Home`, statischer Fallbackwert 0.246 CHF/kWh, Einspeisung „Preis selber eingeben“ 0.10 CHF/kWh, Peakparameter 5.0. **Nicht als aktuelle Tarifvorgabe neu setzen**, vor Verwendung aktuelle Konfiguration lesen.
## 5. Bilanzierung und SDL: nicht wieder die alte Fehlerquelle einbauen
Der alte Prognosesender verwendete sinngemäss `PV + Netz − virtuelle EV-Batterieleistung`. Weil nur die virtuelle EV-Batterie in der Topologie stand, konnte **SDL als Hausverbrauch gelernt** werden. Zusätzlich hält das bestehende Gateway unter bestimmten Bedingungen gefilterte virtuelle EV-/SDL-Istleistungen fest und schreibt sie erneut. Neuer Variablenzeitstempel allein machte diese Werte nicht zur frischen physikalischen Messung.
**Neue Grundidee, bei passender elektrischer Messgrenze:**
`Verbraucherlast = Netzbezug + PV-Erzeugung − gesamte physische Batterieladung`
`Grundlast = Verbraucherlast − separat geplante flexible Verbraucher`
Grundlast bedeutet hier zeitabhängiger nicht separat geplanter Verbrauch, **keine Konstante**. Nicht separat geplante Boiler/Wallboxen bleiben vorerst darin. Später beim Übergang zu eigenständiger Planung genau einmal herausrechnen.
**SolarEdge-Sonderfall:** PV-Variable 20335 war bereits aus skaliertem AC-Ausgang plus Batterieleistung berechnet und auf null begrenzt. Neuer Lastadapter `solar_terminal_v1` verwendet den AC-Ausgang aus 37975/41853 direkt und zieht den SolarEdge-Batteriesaldo nicht nochmals ab. Herkunft, Vorzeichen, AC/DC und Null-Clipping beachten. Algebraischer Bilanzschluss allein ist kein unabhängiger Zählernachweis.
SDL wird **nicht vom V4-Flexibilitätsoptimierer gesteuert**. Bekannten externen Fahrplan berücksichtigen; bei unbekanntem Verlauf nicht still null einsetzen. Die vorhandene Schattenplanung kann den aktuellen SDL-Auftrag als ausdrücklich gekennzeichnete Fortschreibung verwenden. Das ist kein veröffentlichter Zukunftsfahrplan. Für Dauerproduktion sind belastbare externe Grenzen/Reservebehandlung und Konfliktauflösung noch zu prüfen. Ein aktueller SDL-Wert null garantiert keine SDL-freie kommende halbe Stunde.
Bestehendes Gateway: `/var/lib/symcon/modules/Symcon_Belevo_Energiemanagement_testing/Bat_EV_SDL_V4/module.php`. Aktionsbrücke historisch `scripts/31800.ips.php`. `State=false` war dort **kein nachgewiesener Nullstell-/Notstopp**. EV-Stopp darf nicht pauschal unabhängige SDL-Aufträge löschen. Virtuelle Energiekonten/Filter nicht ungeprüft umstellen.
## 6. Quellzuordnung für die nächste Arbeit
Werte/Identitäten mit aktueller Quellenkonfiguration abgleichen; Tabelle ist zuletzt geprüfte Zuordnung, keine Erlaubnis, Register umzulegen.
| Grösse | Variable | Ursprung/Umrechnung |
|---|---:|---|
| Original-Netzleistung | 40348 | Parent 11490, `Power_8`, kW ×1000 → W, Bezug positiv |
| Netz-Anzeige | 49301 | Abgeleitet; nicht mit Originalquelle verwechseln |
| GoodWe 1 PV | 48459 | Parent 19742, `A_3_3_35301`, W |
| GoodWe 2 PV | 53802 | Parent 57658, `A_3_3_35301`, W |
| GoodWe 1 Batterie physisch | 47725 | Parent 19742, `A_6_3_35182`, Faktor −1 → Laden positiv |
| GoodWe 2 Batterie physisch | 35724 | Parent 57658, `A_6_3_35182`, Faktor −1 |
| SolarEdge Batterie physisch | 21447 | Parent 30789, `Value`, Faktor +1 |
| SolarEdge abgeleitete PV | 20335 | Parent 36915; nicht unabhängige PV-Messung |
| SolarEdge AC-Rohwert | 37975 | Parent 35514, `Value`, ReadAddress 40083 |
| SolarEdge AC-Skalierung | 41853 | Parent 48996, `Value`, ReadAddress 40084 |
| Virtuelle EV-Istleistung | 52020 | Parent 58448, `Aktuelle_Leistung_EV`; Filterhaltewert möglich |
| Virtuelle SDL-Istleistung | 25085 | Parent 58448, `Aktuelle_Leistung_SDL` |
| EV-Auftrag | 19651 | Parent 58448, `Nennleistung_Soll_EV` |
| SDL-Auftrag | 38943 | Parent 58448, `Nennleistung_Soll_SDL` |
| Gateway aktiv | 23483 | Parent 58448, `State`; kein Gerätewatchdog-Nachweis |
| EV-SOC | 32871 | Parent 58448, `SoC_EV` |
| SDL-SOC | 23879 | Parent 58448, `SDL_Pos` |
| EV verfügbare Ladeleistung | 50230 | Parent 58448, `P_EV_laden` |
| EV verfügbare Entladeleistung | 43899 | Parent 58448, `P_EV_entladen` |
| GoodWe 1 / 2 / SolarEdge SOC | 27361 / 23109 / 51938 | Originalzuordnung siehe Capture-Konfiguration |
| Wirkenergie Bezug T1 | 59607 | Parent 11490, `Energy_0`, kWh |
| Wirkenergie Bezug T2 | 26620 | Parent 11490, `Energy_1`; Zählerarchivierung aktiviert |
**Nicht wieder 53476 als nachgewiesenen Bezugszähler verwenden:** Im Gespräch zeigte 53476 / Quelle 32797 (`Energy_6`) einen unplausibel anderen Verlauf gegenüber Netzleistung und T1. Alte Historie 53476 wurde bewusst nicht überschrieben. Archivinstanz 16207. Neuzeitlicher Peak muss aus geeigneter T1/T2-Summe bzw. ausdrücklich zugelassener vollständiger Viertelstundenschätzung stammen.
Poller laut zuletzt gelesenem Snapshot: GoodWe 1 Parent 19742 `60000 ms`, GoodWe 2 Parent 57658 `5000 ms`; SolarEdge Batterie Parent 30789 `2000 ms`, AC Parent 35514 `1000 ms`, Skalierung Parent 48996 `20000 ms`. M-Bus Parent 11490 hatte `Interval=0`, trotzdem laufend frische Originalwerte. Nicht daraus schliessen, die Erfassung sei aus; zusätzliche bestehende Aufrufwege prüfen. Poller nie allein als erfolgreichen Geräteabruf interpretieren.
## 7. Hosts, Code, Dienste und Berechtigungen
### enelix-services / Connector Server_89
- Host `enelix-services`, IPv4 `87.106.60.89`, Agentbenutzer `agent`. Erlaubte Wurzeln `/home/agent/services`, `/srv/agent`.
- Entwicklung: **`/srv/agent/repos/Enelix-EMS`**, Gitea `ENELIX/Enelix-EMS` auf `git.belevo.ch`. Weiteres Projekt `ENELIX/Enelix-Utils`; historische Referenz `dh/Symcon_Belevo_Energiemanagement_testing`. Andere Hosts/Klone nicht ohne Nachweis gleichsetzen.
- Versionierter neuer Servercode: **`services/netplan-v4/`** im EMS-Repo.
- Runtime/Build-Staging: **`/home/agent/services/netplan-v4-shadow`**, SQLite `data/netplan-v4.sqlite`, Compose `compose.yaml`, interner V4-Port 9100.
- Bisheriger Forecast/API-Dienst: `/home/agent/services/prognosis-manager-enelix2`; Forecast Engine interner Port 9000. Nicht durch neue V4-Arbeit unbemerkt ersetzen.
- Portal: `/home/agent/services/license`, `server.mjs`, `public/`, `compose.yaml`. Parallele GUI-Arbeit möglich. Unified-RC1 aktualisierte nur V4 und zugehöriges Planner-JS, nicht pauschal alle Portalprozesse.
- Vor Veränderungen `/srv/agent/AGENT_CONTEXT.md` lesen. Agent hatte zuletzt keinen Docker-Socket-/Root-Zugriff; den Nutzer Root-Ausführung nur für tatsächlich benötigte Deployment-Schritte erledigen lassen. Keine Berechtigungen auf Docker-Socket ausweiten.
### iot-symcon01 / Connector Testanlage_Lihrenmoos
- IP-Symcon 8.0, Dienst `symcon.service`, Kerneldateien `/var/lib/symcon`, Logs `/var/log/symcon`, Programme `/usr/share/symcon`.
- Installiertes EMS-Repo: `/var/lib/symcon/modules/Enelix-EMS`. Das ist produktionsnah laufender Code, nicht der Entwicklungscheckout auf Server_89.
- Staging, Tests, Diagnose: `/srv/agent/netplan-v4-...`.
- Hostregeln `/srv/agent/AGENT_CONTEXT.md` lesen. Keine allgemeinen Settings-/Credential-Exports. Für gezielte schreibgeschützte Zustandsprüfung nur erlaubte Felder extrahieren, niemals ganze secret-bearing Konfiguration ausgeben.
- **PHP mit IPS-Funktionen gehört in Symcon**, nicht in die Linux-Bash und nicht in gewöhnliches CLI-PHP. Isolierte CLI-Tests mit simulierten IPS-Funktionen umgekehrt niemals im echten Kernel ausführen.
- `MC_ReloadModule`/`IPS_ApplyChanges` können bestehende Initialisierungen und deren Nebenwirkungen ausführen. Nicht pauschal als ohne Auswirkungen versprechen. Code- und Settings-Sicherung, Hash-/Driftprüfung, Rücksetzweg nötig.
- Bei verzögerter Modulregistrierung wurde ein Wiederholen angefordert. Die letzte Installation ist inzwischen fertig: **jetzt nicht wiederholen**.
## 8. Installierter Daten-/Prognosepfad
1. Manager erfasst 23 numerische Quellen alle 30 s, mit originalen Zeitstempeln und Qualitätsmerkmalen. Keine zusätzlichen Modbus-Abfragen durch den bisherigen Sammler.
2. Lokale append-only Tagesdateien/Outbox. Versand regulär höchstens einmal/min, bis 120 Aufnahmen pro Batch; geordneter, bestätigter Cursor. Backoff bei Fehlern; Originale bleiben erhalten. `scheduled` kann reguläre Sendepause oder Backoff bedeuten; getrennte letzte erfolgreiche Bestätigung beachten.
3. Server-Datensatz **`lihrenmoos-physical-v1`** speichert Originalaufnahmen. Abgeleiteter Datensatz **`lihrenmoos-physical-published-v2`** referenziert dieselben Daten für die verbesserte historische Zeitbehandlung.
4. `equal_endpoint_v1`: begrenzte rückblickende Schätzung bei gleichen Werten an getrennten Originalzeitpunkten. Verfügbarkeit der späteren Bestätigung wird mitgeführt; keine Leckage in damalige Entscheidungen. Unterschiedliche Werte, lange Ausfälle, widersprüchliche Quellen oder Sammellücken nicht unbemerkt überbrücken. Live-Grenzen unverändert.
5. Worker verarbeitet im 5-Minuten-Takt. Mindestbasis 24 **verwertbare äquivalente Stunden**, nicht bloss seit Start verstrichene Zeit. Neue Tagesprofile, versionierte Modelle, konfigurierbare tägliche/wöchentliche Neubildung. Kaltstart/Validierung sind getrennt.
6. Neue Datenquelle muss als `corrected_profile` mit passendem `measurementDataset` gewählt werden, sobald verwendbar; zuletzt blieb **`forecastSource=legacy`, `family=3`**. Nicht bei Installation automatisch umgeschaltet. `optimal` auf der Legacy-Quelle ist kein Nachweis der neuen Prognosequalität.
7. Wirtschaftlicher Vergleich Unified-RC1 ist angeschlossen, aber v1 ist **eingefrorener Tagesplan + simulierte lokale Netzzielnachführung**, nicht vollständiges rollendes MPC-Replay. Eine netzladefähige Batterie; gleiche Anfangsbedingungen, Restenergie, Verluste und einmalige Monatspeakabrechnung. Historischer SDL-Auftrag als gekennzeichnete Schätzung. Vollständige Modellautomatik/Produktversprechen nicht über diesen Scope hinaus behaupten.
8. Archivierung ist implementiert, **AUS als Standard** (native `NetzfahrplanV4ArchivAktiv`, serverseitig `NETPLAN_V4_ARCHIVE_ENABLED`). Verifizierte komprimierte Archive ersetzen kein externes Backup und lösen keine unendliche Speicherkapazität. Keine unbestätigten Outbox-Daten löschen.
## 9. Behobene Fehler – nicht erneut aufrollen
- Früherer numpy-Fehler `assignment destination is read-only`, falscher Import `battery_optimizer_new`, Dateirechte im unprivilegierten Container: damalige Deployment-/Codefehler, nicht aktuelle Blocker.
- Direkt nach Start `Connection refused`, danach HTTP 409 „bereits ein Lauf aktiv“: Start-/Parallelitätszustand, nicht durch wiederholte Neustarts lösen.
- Native Betriebsdaten fehlten; anschließend Sender installiert. Tarifimporter und Portal-Rate-Limit wurden repariert. Alte 429-Meldungen nicht ohne neue Evidenz zur heutigen Ursache erklären.
- Klasse `NetzfahrplanV4Messaufnahme` doppelt aus Staging und Modulpfad geladen: Installer auf eindeutige installierte Bibliothek umgestellt. `require_once` verhindert nicht verschiedene Dateien mit derselben Klasse.
- Gemischter 120er-Block: 45 importierte/75 native Aufnahmen, keine doppelten/rückwärtslaufenden Zeiten, aber andere Kennung allein durch `splitToleranceW:100.0` vs `100`. Normalisierung korrigiert; beide vollständigen Konfigurationen exakt geprüft. Kompatibilität nur für diese anlagen- und datensatzgebundene Gleichwertigkeit registriert; Ursprungskennung/Evidence gespeichert. Keine Hashs in Originaljournalen ersetzt, kein Cursor-Sprung.
- **Nachweis der Reparatur:** am 03.10. 12:58 Schweizer Zeit 1'782 statt 1'320 Aufnahmen, jüngste Aufnahme 12:58:09, 25 s alt, vollständiger ehemals blockierter Batch vorhanden, Manager `acknowledged`/Fehlerzahl 0. Quelle `[F]` unten. Keine Garantie über den danach liegenden Zeitraum ableiten.
- Historische Datenqualität verbesserte sich von 0 brauchbaren Fenstern auf 104/8.66 h, nach Nachversand 130/10.82 h; zuletzt Worker 140/11.65 h. Prozentangaben zur zeitlichen Abdeckung sind **keine Messgenauigkeit und keine Prognosegüte**.
## 10. Installierter Rückmelde- und begrenzter Stellpfad
Gemeinsamer Code: `NetzfahrplanV4Rueckmeldung.php`, `BatterieNetzfahrplanV4RueckmeldungTrait.php`, `ManagerNetzfahrplanV4EmpfangTrait.php`, `ManagerNetzfahrplanV4TestTrait.php`, `BatterieNetzfahrplanV4TestTrait.php`, `NetzfahrplanV4Regeltest.php`, Manager-/Batterie-Module.
- Zweifaches Lesen mit Identitäts- und Originalzeitprüfung; momentan Live-Alter maximal 60 s, Quellenversatz maximal 30 s.
- Modus in Lihrenmoos **`virtual_split`**, **`allowEstimatedForTrial=false`**. Auch gültige Rückmeldung oder Vorschau erteilt keine Freigabe.
- Wenn SDL-Auftrag exakt null, physische Batteriesumme statt gefilterter virtueller EV-Anzeige verwenden; unveränderter Nullauftrag ist Zustand, kein Messgeräte-Heartbeat. Eine neue EV-Vorgabe bedeutet noch keine sofortige physische Reaktion.
- Bei SDL ungleich null: zeitliche Abdeckung von Auftragsänderung, Tracking-Toleranz und explizite Schätzkennzeichnung. Gegenläufige EV-/SDL-Flüsse sind nicht eindeutig identifizierbar und bleiben für diesen Test gesperrt.
- Netzziel wird in **Gesamt-Batterieleistung**, nicht zusätzlichen Ladeauftrag übersetzt. Erwartete Änderungen anderer Verbraucher genau einmal berücksichtigen.
- Separate interne Server-Testsitzung, leere Default-Allowlist `NETPLAN_V4_CONTROL_TRIAL_PLANTS`, manuelle Familie, Anlagen-/Asset-/Instanz-/Revisionsbindung, beide lokalen Freigaben `NetzfahrplanV4RegeltestErlaubt`, bewusster lokaler Start, gültiger Plan, Bilanz- und Gerätewatchdog-Nachweis.
- Test max. 30 min / 5 kW je Richtung; Serverauthority maximal 90 s; Batteriebefehl maximal 10 s; Batterie-Softwarewatchdog 1 s, Manager-Testtakt 2 s. Nicht einfach Limits für Produktion hochsetzen.
- Batterie liest vor Ausgabe unabhängig neu und prüft SOC, Reserve, Hysterese, Verfügbarkeit, Sperrzeiten und Netzgrenzen. Unmögliches Netzziel → Testabbruch, keine Erfolgsbehauptung.
- Abbruch widerruft Sitzung vor Nullstellversuch; Stoppfehler bleibt sichtbar. Danach maximal eine **frisch berechnete** normale Zuteilung, keine Rückkehr zu gecachtem Test-/Vor-Testwert. Keine Wiederbelebung nach Neustart oder doppeltem Befehl.
- `shadow_seen` bedeutet Empfang, **nicht ausgeführt**. `canDispatch=false` in Vorschau bedeutet keine Vorschau-Freigabe. Bei einer separat gestarteten Testsitzung wäre `runMode=shadow` alleine trotzdem kein hinreichender Nachweis für null Stellbefehle.
- Softwarewatchdog funktioniert nur bei laufendem Kernel/Kommunikation. Kein Nachweis für Rechnerabsturz, Stromausfall oder unerreichbaren Wechselrichter. Bereits vorhandenes Verhalten prüfen, nicht erfinden und nicht Schutzgates entfernen.
## 11. Aktueller Blocker: bestätigter Geräteabruf statt falscher Zeitbasis
Letzter Installationsbericht `[A]`:
```
status: corrected_feedback_installed_trial_disabled
feedback.status: unavailable
feedback.reason: feedback_source_skew
batteryW: null
gridW: null
usableForTrial: false
canDispatch: false
```
Das ist **kein Installationsfehler**. Die Meldung „Modellanteil: nein“ im Installer ist im Fehlerfall irreführend: fehlendes Feld wird als false dargestellt, obwohl die Berechnung gar nicht erfolgreich war. Kleine UI-/Diagnosekorrektur mitnehmen.
Letzte Zeituntersuchung `[B]` (237 Aufnahmen / 2 h): 48 Aufnahmen mit Versatz >30 s. Netz median 1 s/max 2 s, GoodWe 1 median 1 s/max 48 s, GoodWe 2 median 1 s/max 46 s, SolarEdge median 12 s/max 59 s; SolarEdge in 223 Aufnahmen unter den ältesten Quellen. Keine der vier Quellen in genau diesem Zeitfenster über 60 s. Beispiel 13:58:38: drei Quellen neu, SolarEdge 31 s alt → Ablehnung. Frühere mehrminütige Lücken trotzdem nicht damit wegargumentieren.
**Nächste Umsetzung, ohne neue Diagnosekategorie:**
1. Installierten Gerätemodultyp und unterstützten Leseweg verifizieren. Im Gespräch wurde `ModBus_RequestRead()` als möglicher Ansatz genannt. API-/Erfolgs-/Zeitsemantik und Unterschied `ModBus Address` vs `ModBus Device` anhand offizieller aktueller Dokumentation und vorhandenem Code prüfen; nicht voraussetzen, dass ein boolescher Erfolg alle Register atomar aktualisiert oder einen unabhängigen Gerätezeitpunkt liefert.
2. Erfolgreiche vollständige Antwort mit genau den daraus stammenden Werten, Zuordnung, Status und Bestätigungszeit binden. `VariableUpdated`, Geräteabrufbestätigung und Sammlerzeit getrennt erhalten.
3. Begrenzte Abfragefrequenz, Timeout, Serialisierung, Kommunikationslast und Lesekohärenz berücksichtigen. Keine Netz-I/O in jedem Vorschau-/Regelcallback vervielfachen, keine blockierenden Abfragen unter dem Stellregler-Lock. Existierende erfolgreiche Polls nach Möglichkeit nutzen.
4. Wenn keine echte Bestätigung vorliegt, unsicher/gesperrt bleiben. Keine künstlichen `SetValue`-Herzschläge und keine permissive Freigabe bloss wegen des Pollerintervalls.
5. In bestehenden Batterie-/Managerpfad integrieren; Tests: unveränderte Werte mit erfolgreichem Abruf, fehlgeschlagene/partielle Antwort, Zeitversatz, Race, Wiederholung, Neustart, erneute unabhängige Batterieprüfung und Rückfall.
6. Zusammenhängenden Kandidaten bauen und testen; neue Runtime-Installation nur im bekannten gesicherten Verfahren. Bisherige Installer nicht nochmals ausführen.
Diese Arbeit wurde am Gesprächsende **noch nicht implementiert oder installiert**. Sie ist nicht durch die vorliegende Dokumentationsübergabe erledigt.
## 12. Produktionsreife: verbleibende echte Arbeiten
- Verlässlicher bestätigter Live-Messpfad wie oben; unklares AC/DC/externes SDL nicht durch Labels als verifiziert deklarieren.
- Neue Lastprofile mit tatsächlicher ausreichender Datenbasis/Validierung aufbauen und gezielt zur Schattenrechnung schalten. Aktuelle Einstellungen/Modelle vorher lesen, nicht alte Momentaufnahmen fortschreiben.
- **Dauerbetrieb** mit sauberem Authority-/Zustands-/Neustart-/Fallback-Verhalten implementieren. Bisher nur begrenzter Pilot, keine fertige kontinuierliche Produktionsfreigabe.
- Reale Callback-/Registerstrecke und separat freigegebener Stellversuch; korrekte Vorzeichen, verfügbare Leistungen, Netzziel/Peak-/Exportgrenzen, Abschalten/Verbindungsausfall, lokale Priorität und SDL-Konflikte prüfen.
- Modellautomatik über den deklarierten Frozen-Day-v1-Scope hinaus nur nach Erweiterung/Nachweis bewerben; Grenzen transparent halten.
- Lebenszyklus/Backups/Recovery/Observability, Mehranlagenisolierung und parametrisierte Inbetriebnahme. Einige Installer sind bewusst auf Lihrenmoos gebunden; für andere Anlagen nicht IDs blind übernehmen.
- Git-Authentifizierung/Veröffentlichung lösen; `develop` → geprüfte `beta` → Feldabnahme `main`. Browser-Login, Tailscale oder Portforwarding stellen nicht automatisch Git-Schreibauthentifizierung bereit. Keine Veröffentlichung behaupten, die nicht passiert ist.
## 13. Wichtige Meilensteine und nicht erneut auszuführende Installer
| Paket | Status bis Gesprächsende |
|---|---|
| V4-Schattenserver / Native Operation / Tarife | Installiert, Betrieb historisch belegt |
| Passiver Empfänger `/srv/agent/netplan-v4-receiver-stage/install.php` | Installiert, automatische `shadow_seen`-ACKs belegt |
| Rohsammler `/srv/agent/netplan-v4-data-capture-stage` | Separater früherer Sammler; Originaldateien erhalten |
| Leseexport `/srv/agent/netplan-v4-data-review-stage` | Einmaliger Export wegen restriktiver Originalrechte; heute keine erneute allgemeine Exportpflicht |
| Getrennter Beobachter `/srv/agent/netplan-v4-separated-observer-stage` | 23 Quellen, Dateien gezielt lesbar; weiterhin Originalbestand |
| Managerdaten `/srv/agent/netplan-v4-application-build/install.php` | Installiert, Klasse-/Kennungsfehler später behoben |
| `deploy_history_timing.py` | Installiert, neue Historienversion v2 |
| `deploy_unified_release.py --approve-numeric-mapping-compatibility` + `/srv/agent/netplan-v4-unified-release/install.php` | Installiert, Aliasfreigabe exakt registriert, Nachversand aufgeholt |
| `/srv/agent/netplan-v4-feedback-stage/install.php` | **Fertig installiert nach einmaliger Registrierungsschleife**, aktuell Zeitversatzprüfung |
Fachliche Commitfolge (lokales `develop`; nicht als Remote-/Runtime-Gleichstand missverstehen):
`b5c1fa6` Empfänger, `9394fe3` begrenzter Test, `e0e52d2` Bilanzierung, `76f8b06` Capture, `44362e4` getrennte Beobachtung, `fe3f46b` Datenanwendung, `6d9aba7` Klassenladen, `5890093` Historienzeit, `6c91d6b` Zahlennormalisierung, `8eb9688` Unified RC1, **`9878197` korrigierte Rückmelde-/Testkette**. Einige weitere Commits können dazwischenliegen; `git log` ist massgeblich.
## 14. Quellenindex und Tests – zuerst Nachweise lesen, nicht neu installieren
**[A] Letzte erfolgreiche Rückmeldeinstallation (Testhost):**
`/srv/agent/netplan-v4-feedback-stage/INSTALL_RESULT.json` – Ende `2026-10-03T11:59:58Z`. Der frühere `waiting_for_registration`-Bericht ist überholt. Die Sicherung stammt vom ersten Dateidurchlauf, nicht vom zweiten `already_installed`-Durchlauf.
**[B] Letzte Zeit-/Pipeline-Auswertung (Server):**
`/home/agent/services/qa/feedback-timing-20261003/TIMING_REVIEW.json` – `2026-10-03T12:02:13Z`, 140 brauchbare Fenster/11.646 h, Quelle v2 noch `collecting`.
**[C] Unified-Serverinstallation:**
`/home/agent/services/netplan-v4-shadow/unified-releases/20261003T105407.036202Z/REPORT.json`; Runtime `unified-rc1`, Mappingkompatibilität registriert, Einstellungen unverändert.
**[D] Unified-Managerinstallation (Testhost):**
`/srv/agent/netplan-v4-unified-release/INSTALL_RESULT.json`.
**[E] Historieninstallation:**
`/home/agent/services/netplan-v4-shadow/history-timing-releases/20261003T092245.175100Z/REPORT.json`.
**[F] Erfolgreiches Aufholen des Datenwegs:**
`/home/agent/services/qa/unified-release-20261003/FLOW-20261003T105834.324945Z.json`.
**[G] Installiertes und versioniertes Verhalten:**
`docs/netplan-v4-corrected-feedback.md`, `docs/netplan-v4-unified-release.md`, `docs/netplan-v4-history-timing.md`, `docs/netplan-v4-numeric-mapping-fix.md`, `docs/netplan-v4-application.md`, `docs/netplan-v4-controlled-trial.md`, `docs/netplan-v4-accounting.md`. Alte „noch nicht installiert“-Abschnitte dieser Entwicklungsdokumente durch aktuelle Installationsberichte einordnen; nicht mit echten Runtime-Nachweisen verwechseln.
**[H] Rückmelde-/Testsuite (Testhost):**
`/srv/agent/netplan-v4-feedback-stage/PREPARATION.json`, `TEST_RESULTS.txt`, `INSTALLER_TEST_RESULTS.json`, `ORDINARY_OFFER_TEST.json`, `MANIFEST.json`, `PACKAGE_HASHES.json`, `feedback-config.json`.
125 isolierte Funktionsprüfungen, 4 vollständige Modul-Linkageprüfungen, 10 Installationsszenarien, 13'824 übereinstimmende normale Leistungsangebotsfälle. Keine Aussage, dass dieselbe Zahl Tests im echten Kernel/Wechselrichter gelaufen sei.
**[I] Unified-Suite (Server):**
`/home/agent/services/qa/unified-release-20261003/PYTHON_TEST_RESULTS.txt`, `NODE_TEST_RESULTS.txt`, `PUBLICATION.json`, `DELIVERY_STATUS.json`.
306 Python- und 16 Portaltests; native separate Tests. Spätere Änderungen brauchen erneute relevante Tests, nicht bloss Übernahme dieser Zahlen.
**[J] Wiederaufbau und Code:**
EMS `examples/V4CorrectedFeedback/`, `examples/V4UnifiedRelease/`, `services/netplan-v4/commissioning/`. Staging ist nicht dieselbe Quelle wie das laufende Modul. Dateien, Hashs und Abhängigkeiten vor Änderungen vergleichen; Paralleländerungen abbrechen statt überschreiben.
**[K] Quellen-/Konfigurationsdiagnose (Testhost):**
`/srv/agent/netplan-v4-application-build/inspect_source_update_policy.py`, `inspect_delivery_state.py`, `MAPPING_IDENTITY_DIAGNOSIS.json`, `MAPPING_EQUIVALENCE.json`; `netplan-v4-feedback-stage/review_current_feedback_state.py`. Diese Skripte zuerst lesen; manche erzeugen nur einen Bericht, manche lesen gespeicherte statt live aktuelle Settings. Alte Settings-Snapshots können eine bereits installierte Eigenschaft noch nicht enthalten.
**Sicherheitsnotiz:** In einem alten Upload-Skript wurden Klartextzugangsdaten bemerkt. Nicht lesen, verbreiten, in Dokumentation/Git übernehmen oder für neue Integration verwenden. Separate autorisierte Rotation/Secret-Auslagerung bleibt Aufgabe; die Werte gehören nicht in diese Übergabe.
## 15. Übernahmeroutine für einen neuen Agenten
1. Host-`AGENT_CONTEXT.md`, Repo-`AGENTS.md` und diese Datei vollständig lesen; bei fehlendem Remote-Stand zuerst den Servercheckout prüfen.
2. `git status`/Branch/letzte Commits sowie `[A]`, `[B]`, `[C]` lesen. Zeitstempel nennen; keine alten Zähler als heutige Werte ausgeben. Diese Übernahme selbst ändert keine Anlage.
3. Nur notwendige gezielte Read-only-Prüfungen. Kein weiterer vollständiger Neustart der Analyse oder Wiederholung bereits erledigter Installer.
4. Bei Fortsetzungsauftrag direkt Abschnitt 11 umsetzen und anschliessend echte Restarbeiten aus Abschnitt 12 abarbeiten. Anforderungen, Testfall, Implementierung und Rollout in einem Paket halten.
5. Nach jedem Abschluss diese Übergabe mit Datum, Codecommit, tatsächlichem Installations-/Teststatus und nächstem konkreten Arbeitsschritt ergänzen. Keine gescheiterten Schritte in „fertig“ umdeuten. Bei Widerspruch aktuelle geprüfte Laufzeit und ausdrückliche spätere Nutzerkorrektur nennen.
**Kurzübernahme:** „Netzfahrplan V4 in ENELIX fortsetzen. Datenkennung/Versand repariert, Unified RC1 und korrigierter Feedback-/Testpfad installiert. Aktuell `feedback_source_skew`; bestätigte Geräte-Lesezeitpunkte in die bestehende lokale Rückmeldung integrieren. EV 161.44 kWh/39 kW. Keine neue Diagnosekategorie, keine alten Installer, kein automatischer Stelltest. Dauerproduktion ist noch nicht fertig; Tests/Abnahme/Veröffentlichung sauber getrennt halten.“
+99
View File
@@ -0,0 +1,99 @@
# V4: getrennte Last-/Batterie-/SDL-Bilanz (Kandidat, keine Stellfreigabe)
## Status 2026-10-02
`NetzfahrplanV4Bilanzierung` ist ein reiner Rechen-/Validierungsbaustein. Noch keine
Einbindung in die laufende Telemetrie, den Trainer oder den Kontrolltest. Er ruft
keine IPS-, Netzwerk-, Archiv- oder Stellfunktionen auf. Ergebnis ist immer
`controlEligible=false`, auch bei geschlossener mathematischer Bilanz.
34 isolierte PHP-Pruefungen wurden mit der gleichen SHA256-Datei auf iot-symcon01
bestanden. Das ist kein Nachweis fuer die reale Hardware-/Messgrenzenzuordnung
und kein vollstaendiger PHPUnit- oder Symcon-Kernel-Test.
## Gefundener Unterschied in Lihrenmoos
Installierter Manager `Manager/module.php::leseMessleistungen()` verwendet:
Haus = PV + Netz - Summe der konfigurierten Batterie-Istleistungen
Konfiguriert ist hier die virtuelle EV-Leistung 52020, nicht die gesamte physische
Batterieleistung. Daher bleibt der externe SDL-Anteil und eine allfaellige
Abweichung der virtuellen Aufteilung im an den Prognosedienst gesendeten Hauswert.
`sendePrognoseTelemetrie()` sendet genau diesen errechneten Wert. Die parallel
existierenden Hausvariablen 46714 und 40713 sind NICHT automatisch diese Quelle.
Der bisherige Gateway `Bat_EV_SDL_V4` liest die physische Batterieleistung aus
47725, 35724 und 36380 und invertiert diese Vorzeichen. EV und SDL sind daraus
abgeleitete, separat gefilterte virtuelle Konten, keine zwei unabhaengigen Zaehler.
Die Filter koennen alte Teilwerte halten. `UpdateActualPowerSplit` aktualisiert die
Ausgabevariablen trotzdem. Ein neuer Ausgabezeitstempel beweist deshalb nicht die
Frische aller darunter liegenden Originalmessungen.
## Kandidat fuer die korrekte Bilanz
Mit einheitlicher Messgrenze und Vorzeichen Batterie+ = Laden, Netz+ = Bezug:
Verbraucherlast = Netz + PV - gesamte physische Batterieleistung
Grundlast = Verbraucherlast - separat geplante flexible Verbraucher
Zuordnungsrest = physische Batterie - virtuell steuerbare Batterie - externe virtuelle Konten
Externer Netzeffekt = externe virtuelle Konten + Zuordnungsrest
Netz = Grundlast + flexible Last + steuerbare Batterie + externer Netzeffekt - PV
Der Zuordnungsrest wird nicht erneut als Hausverbrauch trainiert, sondern separat
angezeigt. Eine grosse Abweichung sperrt die Datenqualitaet. Gegenlaeufige virtuelle
Konten (z.B. EV +42 kW, SDL -40 kW, physisch +2 kW) bleiben korrekt getrennt.
Spaetere Ladestationen/Boiler sind ueber `flexible_load` erweiterbar. Solange sie
nicht separat geplant werden, verbleiben sie in der nicht separat steuerbaren Last.
Keine Messgroesse wird doppelt subtrahiert. Ein negatives Grundlastergebnis wird
nicht heimlich auf null begrenzt. Numerische Nullen sind echte Werte; fehlende,
nicht endliche oder umtypisierte Werte sind keine Nullen.
## Originalzeit und Datenqualitaet
Jede Quelle hat explizite Variable/Parent/Ident, Faktor, maximales Alter und optionale
Ursprungsabhaengigkeiten. Alle Quellen werden zweimal gelesen. Wertewechsel waehrend
der Aufnahme werden abgewiesen; Zeitstempel werden nie kuenstlich verjuengt.
Abhaengigkeiten sind zyklenfrei, doppelte Quellen werden verhindert.
Eine virtuelle Summenvariable erbt das aelteste Datum ihrer physischen Eingangsquellen.
Konfigurierbare Alters-/Synchronitaetspruefung ist erforderlich. Diagnoseberechnungen
mit ungeeigneten Daten bleiben `quality_hold`, nicht `base_load`-Trainingsfreigabe.
Die momentanen Beispielgrenzen (60 s Alter, 30 s Versatz) sind konservative
Diagnoseparameter, keine garantierten Geraeteeigenschaften.
Die konkrete Solar-PV-Herkunft sowie AC/DC-/Wirkungsgradgrenzen aller Hybridgeraete
sind noch zu verifizieren. Eine algebraisch geschlossene Gleichung beweist diese
physische Messgrenze NICHT. Die vorgeschlagene Quellkonfiguration ist nicht freigegeben.
## Migration in die Prognose (noch offen)
1. Originalmessquellen und Messgrenze dokumentieren und Qualitaet ueberwachen.
2. Korrigierte Grundlast und externe Kanaele als NEUE versionierte Zeitreihen erfassen.
3. Bestehende Historien nicht ueberschreiben oder als bereinigte Historie umbenennen.
Rekonstruktion nur bei ausreichenden zeitlich passenden Originaldaten.
4. Prognosemodell auf der korrigierten Basis trainieren/validieren.
5. Zukunfts-SDL separat liefern. Unbekannt ist nicht null; eine geschaetzte Annahme
muss explizit bezeichnet werden und darf nicht als veroeffentlichter SDL-Fahrplan gelten.
6. Erst nach diesen Pruefungen eine echte Bilanzierungsreferenz im Trial-Gate verwenden.
`mappingId` aus dieser Klasse ist ein Konfigurationsfingerabdruck, KEIN solcher Nachweis.
## Gateway-Ausfallverhalten (nicht getestet/freigegeben)
Quelltextpruefung des installierten Alt-Gateways ergab keinen Frischetest fuer
`Nennleistung_Soll_EV` im Weg `Update -> ApplySetpoints`. Der zwischengespeicherte
EV-Sollwert wird weiter verwendet, solange die Gateway-Schleife laeuft. Der Eingang
wird von `scripts/31800.ips.php` geschrieben; dieser enthaelt ebenfalls keine Lease.
Der vorhandene 30-s-Timeout des EMS-Batterieadapters ist ein Schutz innerhalb von
Symcon, nicht ein unabhaengiger Wechselrichter-Timeout bei Rechnerausfall.
`State=false` im Alt-Gateway beendet `Update` ohne dort Nullwerte zu schreiben.
Diesen Schalter nicht als nachgewiesenen Hardware-Notstopp verwenden.
Keine Kommunikationsunterbrechung, kein Abschalttest und keine neue Stellwertausgabe
wurden an der realen Anlage durchgefuehrt. Auch aus dem fehlenden Code kann NICHT
gefolgert werden, dass das Geraet selbst keinen Timeout hat. Exakte Typen/Firmware
und das wirksame geraeteseitige Verhalten muessen noch festgestellt werden.
Wichtig fuer spaetere Anpassung: Ein EV-Befehlsablauf darf nicht pauschal den
externen SDL-Auftrag loeschen. Ein Kernel-interner Timer ersetzt wiederum keinen
Schutz bei komplettem Kernel-/Rechner-/Kommunikationsausfall.
+131
View File
@@ -0,0 +1,131 @@
# V4 application data and forecast integration
## Implemented application path
`ManagerNetzfahrplanV4DatenTrait` records the configured raw power/SOC/counter sources
inside the existing Manager. This replaces the need for permanent standalone
observation categories once the native delivery path has been accepted. It never
issues device commands. New acquisition properties default disabled.
The private outbox at `data/enelix-v4/<installationId>` is append-only and delivery
is acknowledged by dataset and capture time. Unacknowledged data survives network
failures and 429 responses. The native payload budget is below the existing portal
1 MiB request-body limit. Existing authentication and V4 rate limiting are reused.
Server application source is now versioned under `services/netplan-v4`, rather than
existing only as an untracked server working tree. No live measurements, databases,
credentials, dependency binaries or settings exports are included in this directory.
The existing worker handles per-plant dataset ingestion, physical five-minute
rollup, model training, model storage and corrected-load substitution into the V4
optimizer. It continues to consume the existing PV forecasts and price/operation
inputs. The public device route can append measurements but cannot alter source
meaning, register a dataset, change live permissions or arm a controlled trial.
## Explicit settings and model meaning
- `forecastSource=legacy` preserves the existing forecast input by default.
- `forecastSource=corrected_profile` plus `measurementDataset` uses the trained
corrected physical load. Missing models are reported; legacy house values are
not substituted silently.
- `trainingCadence=daily|weekly` is now connected to real profile fitting in the
application worker, independently from the optimizer refresh frequency.
- Corrected load profiles are tagged `physical-profile-v1`: robust daily profile,
recent-day profile and weekday/weekend profile paired with PV families 3/13/23.
They are not claimed to be the previous load models unchanged. Economic automatic
selection still requires the separate, not yet finished cost-replay integration.
- Initial models are explicitly bootstrap models. Later candidates record causal
holdout errors; an overlapping validation window cannot certify a promotion.
- Current SDL request persistence is only a labelled shadow scenario. Unknown or
stale SDL is not silently zero, and the scenario is not a guaranteed future
schedule or a robust SDL-aware production control policy.
## Lihrenmoos package scope
Effective EV capacity 161.44 kWh and power 39 kW remain unchanged; SDL reserve is
already excluded. The prepared dataset `lihrenmoos-physical-v1` uses a configured
physical estimate with SolarEdge signed terminal power counted once. It requires
at least 24 equivalent usable hours before the first bootstrap profile; the
history window is 28 days. Coverage policy 95% with maximum 10-second unsupported
portion is an explicit modelling assumption. Gaps and source ages remain visible.
It is not an independent electrical metering-boundary proof.
Server entry point on the service host:
python3 /home/agent/services/netplan-v4-shadow/commissioning/deploy_application.py \
--plant e3a08f9e-af12-4695-99bd-8b51c0520021 --apply
Default without --apply checks source only. The explicit apply command rebuilds
and tests V4 in Python 3.11, tests portal routing, backs up the V4 database and
recreates only V4 and the portal. It configures the dataset but DOES NOT select it
for the existing plan automatically. No forecasts/tariff importer or controllers
are restarted by the server command. A previous-image rollback is prepared.
Then, inside Lihrenmoos Symcon:
require '/srv/agent/netplan-v4-application-build/install.php';
This is a data-only patch of the currently installed passive manager, with file
backup and library reload. It does NOT install the development control-trial
changes in Manager/Batterie. The first runtime invocation imports at most 48h of
the existing 23-channel observer as a durable backlog, preserving the original
files, then activates acquisition and delivery. If Symcon module registration is
not immediately available, the installer reports that a single repeat is needed.
The two temporary standalone samplers are not stopped automatically by this
initial package; disable them only after native batch acceptance and history
continuity are confirmed. This package creates no new root diagnostic category.
## Validation performed before deployment
219 Python tests passed in the isolated host QA runtime, including actual synthetic
measurement -> profile -> existing optimizer -> stored shadow plan. 16 Node proxy
checks passed. 21 PHP data-path checks and 5 installer scenarios passed with mocked
IPS/HTTP and temporary files on the test host. Full staged PHP syntax passed.
No new container or new manager data integration has yet been run in production.
Evidence: service `APPLICATION_PIPELINE_TEST_RESULTS.txt`,
`APPLICATION_PORTAL_TEST_RESULTS.txt`, `commissioning/application-source/RELEASE.json`;
test host `netplan-v4-application-build/PREPARATION.json` and `TEST_RESULTS.txt`.
## Remaining product scope (do not disguise as complete)
This integrates application data and load forecasting, not completed production
commissioning. Economic replay-based automatic family selection, full corrected
feedback/control integration, long-term outbox/server retention and real live/
failure acceptance remain open. Existing trial gates are unchanged. No sensor,
accounting or hardware evidence identifier is fabricated by data ingestion.
## Installer class-path correction (2026-10-03)
The original commissioning installer loaded `source/libs/NetzfahrplanV4Messaufnahme.php`
while importing observation history. A later Manager callback loads the installed
copy at `modules/Enelix-EMS/libs/NetzfahrplanV4Messaufnahme.php`. PHP's once-only file
inclusion does not deduplicate class declarations across those different files.
The dependent `NetzfahrplanV4Bilanzierung` class is subject to the same collision.
The tested installer-only fix is preserved in
`examples/V4ApplicationData/application-class-loading.patch` (SHA256
`6d53d74d4059bb1ae3a446350637a4f4a4a73cc2fd64d2fc2f0967bd23ec7173`).
When building a commissioning package, load both classes only from the installed
module tree, after validating their expected source hashes. Before any require,
check both already-loaded origins without autoload. Reject a foreign staged origin
with a controlled error; do not hide it by wrapping the class declaration in a guard.
Include `installed_capture_bootstrap.php` in the package checksum map. Do not change
the installed-module manifest or bless unrelated source changes.
The Lihrenmoos staging entrypoint has this fix applied, with backups of its original
installer and checksum map. No running modules, data files, timers, native callbacks,
server components or actuator settings were changed by the repair. Actual completion
still requires the user's fresh Symcon invocation of the corrected installer.
Validation: the original two-path failure was reproduced with the real PHP classes.
Thirteen additional isolated installer scenarios passed, including callback/reload
loading order, preloaded dependencies, history import, repeat/finished import,
foreign-class refusal, partial-import preservation, source drift, registration wait
and first installation. These are PHP CLI tests with mocked IPS in temporary trees,
not a completed test in the actual Symcon kernel.
Evidence on the test host:
`/srv/agent/netplan-v4-application-build/class-loading-fix/TEST_RESULTS.json` and
`FIX_RESULT.json`. The test harness and exact candidate source are retained there.
+96
View File
@@ -0,0 +1,96 @@
# V4: begrenzter Regeltest (Entwicklungskandidat, NICHT in Betrieb)
Stand 2026-10-02. Diese Erweiterung ist weder Produktionsfreigabe noch ein
Installations-/Startauftrag. Bestehende Schattenplaene und ihre `shadow_seen`
Bestaetigungen bleiben unveraendert. Ein Vorschauwert allein darf niemals
als Stellfreigabe interpretiert werden.
## Freigabekette
1. Serverseitige separate Allowlist `NETPLAN_V4_CONTROL_TRIAL_PLANTS` (standardmaessig
leer). Interner authentifizierter `planner/trial/arm`-Aufruf mit expliziter
Bestaetigung, neuer Sitzung, gepruefter Plan-ID/Revision, Batteriezuordnung,
Zeitfenster, Leistungsgrenzen und dokumentierter Geraete-Watchdog-Abnahme.
Die bestehenden oeffentlichen Portal-/Geraeterouten proxien diesen Aufruf NICHT.
2. `NetzfahrplanV4RegeltestErlaubt` lokal im Manager UND im Batteriemodul (jeweils
Standard false). Alte `NetzfahrplanAktiv`-Freigabe muss false bleiben.
3. Zusaetzlicher bewusster lokaler Start einer bestimmten Sitzung. Ein Reload,
Netzwiederkehr oder gespeicherter Haken startet keine Sitzung automatisch.
4. Der Manager prueft den empfangenen Schattenplan und die getrennte
`controlled_trial_authority` gemeinsam. Sie sind gebunden an Anlage, Batterie,
beide Instanz-IDs, Revision, manuelle Modellfamilie und stabilen Planungskontext.
5. Die Batterie nimmt nur einen separaten `controlled_trial_command` mit eigener
Sequenz und kurzer Gueltigkeit an. Normale Managerbefehle erneuern diese
Gueltigkeit nicht. `shadow_seen` bleibt eine Empfangs-, keine Ausfuehrungsmeldung.
Die Serverantwort bleibt eine Schattenplan-Antwort (`liveEnabled:false`). Eine
getrennte Testfreigabe ist unter `controlledTrial` und
`controlledTrialAuthorized` sichtbar. Der Schattenmodus allein ist bei kuenftig
bewusst gestartetem Test deshalb kein Nachweis fuer 'keine Batteriebewegung'.
Die Controller-Diagnosen benennen eine angenommene Sollvorgabe, nicht bereits
physisch gemessene Umsetzung oder Ersparnis.
## Bewusst begrenzter Pilotumfang
- Genau eine eindeutig zugeordnete Batterie je Test; Familie manuell festgelegt.
- 30 bis 1800 Sekunden Sitzung, maximal 5000 W Laden und 5000 W Entladen.
- Server-, Manager- und Batteriegrenze koennen strenger sein; nie grosszuegig
ersetzen oder still auf 39 kW hochsetzen.
- Gesamtsollleistung, NICHT Zusatzleistung zur bereits fliessenden Batterieleistung.
- Andere Verbraucher bleiben im alten Verteiler. Ihre erwartete Leistungsaenderung
wird einmal bei der Batteriekorrektur beruecksichtigt.
- Batterie wird nur einem Stellwertpfad zugeteilt, nicht gleichzeitig dem alten
und neuen Verteiler. Ist der Test ungueltig, stoppt er und es wird eine NEUE
normale Verteilung angefordert; kein alter gespeicherter Sollwert wird restauriert.
- Physische Grenzverletzung, die innerhalb der Pilotleistung nicht behebbar ist,
beendet den Test zugunsten der bestehenden lokalen Regelung.
## Ausfall- und Zeitverhalten
- Serverfreigabe maximal 90 Sekunden, lokal regelmaessig frisch abgerufen.
Serverseitiger Widerruf wirkt nicht magisch sofort: Cachefrist beachten.
- Einzelner Batteriebefehl maximal 10 Sekunden; Batterie kontrolliert im laufenden
Kernel mit 1-Sekunden-Timer zusaetzlich zu Messwert-Callbacks.
- Manager liefert kurze Befehle im Test mit zusaetzlichem 2-Sekunden-Takt.
- Monotone Laufzeit und Kalenderzeit werden getrennt geprueft. Eine rueckwaerts
springende Systemuhr darf eine Sitzung nicht verlaengern.
- Abbruch widerruft die Sitzung VOR dem Nullstellversuch. Schreibfehler werden als
`stop_failed`/nicht bestaetigter Stopp sichtbar, nicht als sicherer Zustand.
- Reserve, Hysterese, aktuelle Lade-/Entladeleistung, SOC und Verfuegbarkeit werden
auch im Batteriemodul erneut geprueft. Sicherheitsreduktionen werden nicht durch
die Umschaltsperre verzoegert; Richtungswechsel laufen ueber null.
- Laufzeitsitzungen liegen nur im Buffer, nicht als automatische Wiederanlauf-Freigabe.
Ein persistenter Marker sorgt bei Wiederinitialisierung fuer einen erneuten
Nullstellversuch, falls ein Testwert zuvor ausgegeben worden sein koennte.
WICHTIGE GRENZE: PHP-Timer und Software-Nullstellversuche funktionieren nur bei
laufendem Kernel und erreichbarem Wechselrichter. Sie sind KEIN physischer
Not-Aus und kein Beweis fuer Abschaltung bei Serverabsturz, Stromausfall oder
unterbrochener Stellwertverbindung. Dafuer ist ein separat nachgewiesener
Befehlsausfall-/Watchdogmechanismus am Geraet bzw. Gateway erforderlich.
Das Feld `NetzfahrplanV4GeraeteWatchdogNachweis` ist eine Referenz auf die gepruefte
Dokumentation/Abnahme, keine automatische Hardwarepruefung und kein Defaultwert.
## Noch offene Freigabepunkte (nicht mit Platzhaltern umgehen)
- Der laufende Lihrenmoos-Prognosestrom ist noch `house_total`. Fuer Testfreigabe
muss `base_load` mit nachgewiesener Quelle/SDL-Abgrenzung und
`accountingEvidenceId` aus dem geprueften Adapter vorliegen. Ein Labelwechsel
oder ein erfundener Nachweis reicht nicht; Quelle und Messbilanz pruefen.
- Geraete-/Gateway-Watchdog bei ausbleibenden Stellbefehlen nachweisen.
- Vollstaendiges Manager-/Batterie-Callbackverhalten im IP-Symcon-Kernel pruefen.
Die isolierten Tests verwenden echte neue Traits, aber simulierte IPS-Aufrufe,
Messwerte, Zeit und Register. Reale Modbus-/VGT-Stellreaktion steht aus.
- Zielcontainerpruefung des neuesten Serverstands und Feldabnahme.
- Keine durchgehende unbeaufsichtigte Produktion, kein automatischer Mehranlagenstart.
Modellvergleich, Trainingsanbindung, Mehranlagenabnahme und Betriebs-Release
bleiben weitere Aufgaben.
## Quelltests
`php tests/V4ControlTrial/checks.php` nur im separaten CLI-Testprozess ausfuehren,
niemals im laufenden Symcon-Skripteditor. Die Datei verweigert einen realen
Symcon-Kernel. Eine PHPUnit-Huelle bindet sie an die normale Testsuite an.
Der Server-Test verwendet ausschliesslich synthetische Daten/Temporaerdatenbanken.
Keine Testfunktion liest Zugangsdaten oder schreibt eine reale Stellvariable.
+108
View File
@@ -0,0 +1,108 @@
# V4 corrected physical feedback -> local bounded control
## Scope of this delivery
The shared physical feedback reader is now connected to the actual Manager preview,
the existing explicit bounded-trial command path, and an independent reread in the
Battery module immediately before hardware output. This is not a new observer.
The installer leaves both local trial permissions disabled and cannot arm a server
trial. Normal operation continues through the existing local controller.
Lihrenmoos binding: Manager 17004, Battery 44234, virtual asset
`anlage01-virtual-ev`. Effective 161.44 kWh / 39 kW unchanged. Source definitions
come from the existing versioned capture config and current saved topology, not
from new guessed device models. No old histories, sender cursors, virtual energy
accounts, SDL requests, archive policies or inverter polling are changed.
## Feedback and meaning
`NetzfahrplanV4Rueckmeldung` checks original value timestamps and object identities
in two read passes. Live max age is bounded to 60s and inter-source skew to 30s.
History interpolation/publication estimates are not accepted as live feedback.
The mapping fingerprint normalizes numbers and ordering, so a JSON property round
trip does not create a new identity. A mapping change cancels an active session.
Two adapters exist: sum of explicitly assigned physical meters, and a virtual
EV/SDL partition model. For the latter, gateway state and current requests are
read but unchanged zero commands are not mistaken for failed sensor heartbeats.
When SDL request is exactly zero, current physical battery power is used rather
than the filtered virtual EV display. A new EV request does not invent an immediate
physical response or force the measured sign toward the desired sign.
When SDL is nonzero, source timestamps must cover the latest request change and
physical tracking error must remain within the configured small tolerance. The
partition is explicitly estimated, not separately measured. Opposing EV/SDL flows
cannot be uniquely identified; they remain ineligible for trial control. Unknown
or stale physical sources never fall back to held filtered EV values.
`allowEstimatedForTrial` is false in the prepared Lihrenmoos config. Configuring
feedback or seeing a valid preview therefore does NOT accept the estimate for a
trial and does NOT satisfy accounting/device-watchdog evidence or other gates.
This adapter does not claim to resolve arbitrary simultaneous SDL activity.
## Actual control integration
The same coherent grid/battery snapshot is used in the Manager. Battery command
is a TOTAL power, not a delta added to the existing command. Planned changes in
other consumers are counted once and remain explicitly separate from measured grid.
A separate explicit server authority, both local consents, accounting evidence,
device-watchdog evidence, a deliberate session start and fresh plan remain required.
The existing trial is limited to 1800s / 5000W per direction; individual command
leases remain <=10s and cannot be renewed by replay or ordinary manager messages.
The local feedback mapping fingerprint and grid caps are bound to the session.
Immediately before writing, Battery rereads physical feedback and applies current
SOC, reserve, hysteresis, availability and change-lock constraints. Its resulting
command must still meet the bound grid limits; an unreachable target revokes the
trial rather than claiming that the planned grid value was achieved. It cannot
claim physical performance merely because a register call returned successfully.
Abort revokes the lease and attempts zero, and the Manager wrapper then runs at
most one fresh ordinary allocation, clearing cached pre-trial targets. It does
not recurse forever or revive an old plan. A failed stop stays explicitly failed.
The watchdog still requires a functioning kernel. Independent hardware failure
behavior is NOT certified by software tests.
Outside a V4 session the existing battery power path is unchanged. The compatible
BatterieRegler library includes the already developed optional reserve charging
limit; with its default (maximal charge) 13,824 old/new ordinary-offer test cases
match exactly. That comparison is not an on-device dynamics test.
## Installation
A single prepared Symcon script is the next runtime action:
require '/srv/agent/netplan-v4-feedback-stage/install.php';
It hash-checks nine changed files and all version-matched dependencies, backs up
existing source, installs dependencies before modules and reloads the ENELIX
library. Reload/ApplyChanges may execute existing initialization routines; this
is not promised to be a zero-effect library reload. It configures only the new
feedback mapping on Battery. No server redeployment is required. No shared classes
are included from staging, avoiding the earlier duplicate-class problem.
A delayed module-registration result requests one repeat. A valid installation
can still report an unavailable feedback sample; that is not reinterpreted as zero.
Any preexisting trial permission, unsaved change, or concurrent source change stops
the installer. Source write failure rolls back its own changed files; reload errors
are reported for review and do not automatically authorize anything.
## Validation and remaining acceptance
125 isolated PHP functional checks cover the reader, real receiver, actual trial
traits with a simulated register driver, the extracted actual Manager fallback
wrapper, and the real Battery message builder with optional diagnostic variables absent.
Source quality is stored internally; a last partition estimate is not relabelled as a
measured value just because a trial ends or a diagnostic variable is hidden. Four full module linkage checks and ten isolated installer scenarios also
pass. Full PHP syntax and dependency hashes are checked. Neither these fixtures nor
the ordinary-offer comparison run the real Symcon kernel or an inverter.
Evidence: test host `/srv/agent/netplan-v4-feedback-stage/PREPARATION.json`,
`TEST_RESULTS.txt`, `INSTALLER_TEST_RESULTS.json`, `ORDINARY_OFFER_TEST.json`.
Runtime installation, real feedback reception, and a separately authorized field
trial remain outstanding. This finishes the code connection for bounded control;
it is NOT an unrestricted continuous-production controller or a hardware
commissioning certificate. Corrected model selection and actual real-world savings
must be checked against their own data. Existing safety gates are not bypassed.
+67
View File
@@ -0,0 +1,67 @@
# Passive V4 data capture - user inventory and raw acquisition
Status 2026-10-02: source implementation, CLI/mocked tests and staged user installation.
Not part of the live manager, not a control or training release.
## Lihrenmoos user-reported inventory (not applied to running configuration)
- GoodWe 1/2: each 50 kW AC, each 156 kWh reported physical battery capacity.
- SolarEdge: 10 kW AC, 10 kWh reported physical battery capacity.
- EV allocation: 30 kW / 160 kWh total, understood as 10 kW EV per inverter.
- PV roof: approximately 20 kWp east and 20 kWp west. Tilt/individual strings unknown.
- Nominal battery sum: 322 kWh. 162 kWh nominal difference to EV allocation is NOT a
verified usable SDL capacity. AC ratings are not verified battery charge/discharge ratings.
- Exact types, firmware, common AC meter boundary and device watchdog remain unverified.
- Current saved manager topology still reports 161.44 kWh / 39 kW. Gateway currently
lists 130/130/8 kWh and 57/57/5 kW. These may use a different usable/nominal basis.
Do not rebase virtual SOC or change current controls based on these notes alone.
## Why collect before boundary approval
Record the actual original numerical channels now, with source metadata and timestamps.
The algebraic base-load candidate is useful for diagnosis, but NEVER a certified base-load
training sample until the physical AC/DC boundary and historical recipe are verified.
No dependence on hardware-watchdog approval for passive recording.
21 channels: PCC power, legacy displayed PCC power, PV for three inverters, three physical
battery powers, EV/SDL virtual powers and SOCs, three physical SOCs, dynamic EV limits,
T1/T2 import counters, EV/SDL requested setpoints (READ ONLY).
A cyclic script reads values already present in Symcon every 30 seconds. It does not issue
additional Modbus polls. Original VariableUpdated/VariableChanged are retained; these are
Symcon timestamps, not independent device-measurement timestamps. Two-pass reads detect
concurrent changes; missing/non-numeric/re-used channels remain explicit quality failures.
## Storage and interpretation
Daily append-only raw-YYYYMMDD.jsonl in the separate capture stage data directory, UTC
capture times plus original source timestamps, immutable mapping/inventory snapshots by
SHA256. Raw errors and quality_hold are recorded too; nothing is invented as zero.
The raw observations are a source for later documented 5-minute aggregation, not themselves
aligned 5-minute means. No interpolation, training publication or future SDL assumption.
A 512 MiB journal quota, 64 MiB daily limit, 256 KiB record cap and 100 MiB free-space
reserve bound recording. Limits fail visibly without deleting old data. File permissions
0640; no upload credentials, request bodies, complete instance configurations or secrets.
A separate status variable and STATUS.json show latest capture and failures. Process restart
retains files; an incomplete final JSONL row is preserved for manual review, not extended.
## Install / stop
Staged user entry point: /srv/agent/netplan-v4-data-capture-stage/install.php
Execute in the Symcon PHP editor (not a Linux shell):
require '/srv/agent/netplan-v4-data-capture-stage/install.php';
Installer checks exact source hashes and numeric source identities before creating a NEW
root category ENELIX_V4_PASSIVE_CAPTURE, a status string, collector script and stop script.
IPS_SetScriptTimer enables only this new script at 30 seconds. Repeating installation is
idempotent. Unrelated/edited scripts or object identity collisions are not overwritten.
Stop using the newly created script 'Nur diese Messaufnahme stoppen'; it disables only
this collection timer, retains collected files, and does not stop the manager or battery.
No module reload, existing archive change, existing timer/poller change, service restart,
forecast selection, battery reservation or actuator command is included.
Current tests: 36 core/file tests + 19 mocked native lifecycle tests with PHP CLI on the
test host. First real capture and future timer execution require the user activation.
These tests do not certify hardware mappings or a Symcon-kernel run.
+90
View File
@@ -0,0 +1,90 @@
# Historical publication timing (2026-10-03)
## Implemented scope
`services/netplan-v4/netplan_v4/history_timing.py` implements optional, bounded
retrospective estimates between consecutive identical source values at DIFFERENT
original publication timestamps. It is used by the existing measurement pipeline,
model training and forecast source substitution. It is NOT a real-time estimator,
a measurement certificate, an actuator authority or a repair of device communications.
No source `maxAgeSeconds` is changed. When a regular hold expires, only its tail
until the next qualifying original publication may be estimated. Strict processing
remains the default. The following forbid the estimate: unequal endpoints,
nonfinite/missing observations, conflicting same-timestamp values, collector gaps,
invalid source intervals or a span beyond the explicit source-specific bound.
There is no extrapolation past the last observation and no fabricated zero.
Each window reports `publicationEstimatedSeconds`, per-source estimated portions,
uncovered-source seconds and `availableNotBefore`. Training/validation must not use
a confirmation or receipt before it was actually available. Backfilled windows
cannot establish a supposedly causal holdout at a date before their availability.
All physical windows remain configured estimates; even full support is not proof
that the physical waveform stayed constant between the two endpoint readings.
## Evidence and model assumptions
Read-only source configuration projection on the test server, snapshot
2026-10-03T07:47:24Z: GoodWe 1 instance 19742 (ModBus Device) has Poller 60000 ms;
GoodWe 2 instance 57658 has Poller 5000 ms; SolarEdge scale instance 48996
(ModBus Address) has Poller 20000 ms. No polling setting was changed.
Projection program: /srv/agent/netplan-v4-application-build/inspect_source_update_policy.py.
Official Symcon documentation accessed on 2026-10-03:
https://www.symcon.de/de/service/dokumentation/modulreferenz/geraete/modbus-rtu-tcp/vorlagen/
It documents value publication for ModBus Device on changes or when the variable is
older than 60 seconds, including ordinary and virtual addresses. VariableUpdated
is therefore not automatically the last successful device poll. The documentation
is not a proof that a particular installed device link was healthy.
Explicit Lihrenmoos HISTORICAL estimate bounds:
- GoodWe 1 PV/physical storage: equal original publications at most 130 s apart
(60 s publication suppression + configured 60 s poll + 10 s scheduling allowance).
- GoodWe 2 PV/physical storage: at most 75 s (60 + 5 + 10).
- SolarEdge scale: equal scale publications at most 90 s apart. This is a modelling
bound based on the configured 20 s polling and observed sparse scale publication,
not a manufacturer guarantee and not proof of the same internal implementation
as ModBus Device. Raw AC power remains under the original strict limit.
The 10 s margins are explicit scheduling assumptions, NOT measured timing guarantees.
Gaps of many minutes (e.g. the previously observed GoodWe gaps above 600 s) are NOT
bridged. Nonzero changes are never interpolated by this policy.
## Versioned dataset without restarting collection
`lihrenmoos-physical-published-v2` references immutable observations in
`lihrenmoos-physical-v1`. No observations are copied, deleted, relabelled or resent.
Registration requires the same installation and identical original mapping,
formula, coverage and training configuration. Cycles/reference chains and device
uploads directly into a derived dataset are rejected. Original ingestion continues
unchanged in the Manager. The 24 usable-hour minimum stays in force.
Models/windows are stored separately for v1/v2 and record their timing policy. An
existing selected model/family is not automatically switched by this release.
The current SDL request still uses the strict current-state requirements; no
historical bridge grants a future SDL schedule or a live battery permission.
## Tests and release
254 isolated Python tests passed (219 previous + 35 new including simulated
installation/recovery). Includes the actual service pipeline from referenced
observations to model and optimizer using synthetic records. No new target-container
or field-history evaluation has been run with this release yet; do not infer an
improved real coverage percentage or production acceptance from unit tests.
Evidence: /home/agent/services/qa/history-timing-20261003/ALL_TEST_RESULTS.txt.
Prepared command on enelix-services (root required for Docker):
python3 /home/agent/services/netplan-v4-shadow/commissioning/deploy_history_timing.py \
--plant e3a08f9e-af12-4695-99bd-8b51c0520021 --apply
Default without --apply validates staged source only. The apply path backs up source
files, builds and runs the exact target tests, backs up the V4 SQLite database, and
recreates ONLY the V4 service. A failed test restores source without a service
restart; failed post-deploy validation attempts the previous image and preserves
additive data. Concurrently modified files are not silently overwritten.
No portal/forecast/tariff/Symcon restart, no new sensor requests, no change to
161.44 kWh / 39 kW, SOC accounting, old raw samplers or actuator permissions.
The new dataset is registered but not selected for the active forecast. A root
execution and successful report are still required to install it. Previous release
manifests remain historical records; do not bypass them to reapply an older build.
+52
View File
@@ -0,0 +1,52 @@
# Numeric mapping identity defect - 2026-10-03
## Confirmed from installed code and read-only evidence
The pending batch contains 120 observations: zero duplicate timestamps, zero
backwards pairs, but 75 identity failures. The initial 45 observations in this
batch are imported observer records; later native captures use the saved manager
configuration. Evidence: test-host `PENDING_DELIVERY_REVIEW.json`.
The configured mapping fingerprint is
`517d1907631c7f152096e21759bb3452cdd0b4b1b73f91f1c7cd3bd62d8aaa8b`.
Reproducing native configuration loading generates
`3a1a9cf42aae49f55385d2350da74cae780ae12f170e4976849591e48a393994`.
The sole difference in the compared configurations is numeric representation of
`accounting.splitToleranceW`: prepared float 100.0 versus saved integer 100.
Both inventory fingerprints and all measurement definitions match.
`NetzfahrplanV4Bilanzierung::konfigurieren` validated this field using a function
which returns a float, but discarded the normalized return value. Other numeric
factors were already assigned from that validator.
## Tested source correction
Assign the returned float to `splitToleranceW`. Equal numeric settings now retain
the original fingerprint through a plain JSON property round trip. Actual numeric
changes still produce distinct fingerprints, and boolean/string values are still
rejected. Input configurations are not mutated.
42 isolated PHP checks passed on the test host: 34 accounting regressions plus
8 numeric identity regressions. A separate check with the actual prepared capture
configuration reproduces the configured fingerprint after the property round trip.
Evidence: `/srv/agent/netplan-v4-application-build/NUMERIC_MAPPING_TEST_RESULTS.json`
and `NUMERIC_MAPPING_TEST_RESULTS.txt`. Library SHA256:
`ffda2a03dbb226a4405a2ad66704c080a9dc475ce64282ad3f0d7d60034ab199`.
## Not deployed / recovery still incomplete
No installed library, original outbox, cursor, server validator, settings, dataset
registration or actuator permission was changed in this turn. Future-capture
normalization alone does not recover already stored native observations. The
server correctly rejects an entire batch containing a mismatched mapping.
An untested proposal to send distinct immutable dataset versions in separate
batches was removed from the development source and preserved only as a draft at
`/home/agent/services/qa/numeric-mapping-20261003/UNTESTED_SENDER_DRAFT.patch`.
Metadata preparation files are not an installation or permission to ingest into
an unregistered dataset. Tool writes for the complete server recovery package were
blocked; no alternative deployment or identity-check bypass was performed.
Next recovery must preserve original records and provenance, retain exact
per-dataset identity validation, acknowledge only accepted data and include tests
for the mixed 45/75 batch, retries, partial ACKs and no cursor/data loss. Do not
skip the rejected block, rewrite hashes in raw journals or re-run old installers.
+48
View File
@@ -0,0 +1,48 @@
# V4 plan reception and local preview (observation only)
This module does not execute a battery command. The existing regulator and its
setpoints remain unchanged. `NetzfahrplanAktiv` must remain false.
The optional `NetzfahrplanV4EmpfangAktiv` property defaults to false. When enabled
with the existing shadow sender, one bounded GET is made before the next native
operation POST, avoiding reading the plan immediately after queuing its replan.
A successful inspection acknowledges `shadow_seen` once per plan/step. This is
not `applied`. An HTTP failure clears the local preview; it does not change the
old control mode or its connection credentials.
The V4 service must first support `receiverProtocolVersion=1`, installation IDs
in envelope and plan, per-input event references, and the stable controlContext
used at calculation time. Old or cross-plant responses are deliberately refused.
The client checks fresh/lastRun/pending status, exact configuration revision,
model-family registry, known price coverage, contiguous half-open five-minute
intervals, asset mapping and power balance. Numerical feasibility checks are
not evidence of economic performance or permission to control devices.
A cache watchdog refreshes the diagnostic display every 10 seconds, without any
network requests. Cached response older than 90 seconds, plan older than 900
seconds, an expired step, invalid local measurement or policy change invalidates
the preview. Missing targets are JSON null and '-' in the display, never zero.
Restart/ApplyChanges clears the cache. Existing control functions are not edited.
The preview is currently limited to ONE battery. Multiple batteries are still
accepted by the server planner; local allocation is explicitly unsupported in
this stage. Preview feedback uses actual grid and battery power, local capacity,
available power, minimum/maximum SOC, discharge hysteresis, network-charging
permission and import/export/month limits. It exposes any remaining grid-limit
violation instead of pretending the battery can resolve it. It is a momentary
calculation only, NOT the commissioned real-time regulator. PV curtailment and
external SDL/base-load separation must still be validated before any live test.
Two read-only variables below the manager expose a HTML summary and detailed
JSON. No EnableAction, consumer RequestAction, SendData or actor property writes
are used. The existing form.json and web portal assets are not modified.
Offline checks:
php tests/V4Receiver/protocol_checks.php
php tests/V4Receiver/receiver_checks.php
The PHPUnit wrapper runs these in separate subprocesses, so fake IPS and curl
functions cannot contaminate other module tests. The mocked transport uses no
real credentials or connections. Full Symcon kernel testing and installation
are separate commissioning steps; these unit tests are not field acceptance.
+88
View File
@@ -0,0 +1,88 @@
# Separate physical load / EV-SDL observation (NO dispatch or training permission)
## Implemented 2026-10-02
- `NetzfahrplanV4Messsicht` recalculates independent physical-load, physical-storage,
unfiltered modelled allocation and SolarEdge-origin views from explicitly identified
raw snapshots. Quality failures in held virtual accounts do not veto otherwise
consistent physical inputs. Missing/stale physical inputs still invalidate their
respective view. No automatic relaxation of age/skew limits.
- EV/SDL model: delta = physical - requestedEV - requestedSDL; distribute delta in
proportion to the absolute requested powers. The SDL component is obtained from
physical minus modelled EV to preserve numerical closure. This is an accounting
convention, NOT independently measured EV and SDL and NOT a verified response.
- Command changes more recent than the oldest physical feedback are flagged. Unknown
change timestamps, tracking mismatch, aged/missing origins and idle unallocated
power remain explicit. There is no held-value filter and no forced convergence of
displayed power to the requested value. All proposals keep `canDispatch=false`.
- Actual legacy filter outputs are displayed solely for comparison. They are not
reused as inputs of physical-load or unfiltered partition calculations. No changes
to the legacy filter, physical driver, EV/SDL setpoints, SOC integration or accounts.
## New read-only origin finding
Selected event 38921 writes legacy PV variable 20335 using:
a = 10 ** GetValue(41853) * GetValue(37975) + GetValue(21447)
if (a < 0) a = 0
SetValue(20335, a)
Arithmetic statements were inspected without exposing arbitrary scripts/credentials.
Verified saved configuration: variable 37975 parent 35514, register 40083; scale
41853 parent 48996, register 40084; battery 21447 parent 30789. An independently
updated 20335 is therefore not an independent PV reading and cannot certify all
input timestamps. A second constrained arithmetic read confirmed the condition
`if ($a < 0)`; this clamp applies to the legacy calculated PV value, not to the
new signed terminal-power observation. Neither proves the full AC/DC boundary.
The staged observer additionally records the ORIGINAL mantissa and exponent, not
only their periodically rewritten derived PV result. The alternative candidate
uses the scaled terminal power ONCE for this inverter (removing its derived-PV
and battery terms). GoodWe AC/DC boundaries and full-site physical boundary are
still unverified. This is not a definitive corrected house meter or live forecast.
## Standalone installation / operational scope
Stage on test host: `/srv/agent/netplan-v4-separated-observer-stage`.
User invocation inside Symcon only:
require '/srv/agent/netplan-v4-separated-observer-stage/install.php';
Creates only a NEW root category, three textual diagnostic variables, observe and
stop scripts and a NEW 30-second timer. Reads 23 existing numeric variables twice;
no Modbus polling requests, network calls, archive settings, existing module or
control timer updates. Existing 21-channel raw collector continues untouched.
New daily journals contain the selected numeric raw snapshot and derived views.
A new immutable mapping/inventory reference binds the corrected user EV allocation
161.44 kWh / 39 kW. Original collector metadata and its correction addendum remain
unchanged. No rescaling or resetting of virtual SOC/energy accounts.
Derived journal and LATEST/STATUS reports are 0644 WITHIN mode-0700 private stage
and data directories, allowing the authorized agent to inspect these selected
new outputs without repeated manual exports. No permissions on original journals,
Symcon directories or unrelated data are changed. Quotas reuse 64 MiB/day, 512 MiB
in total and 100 MiB disk reserve; exceeding them stops recording visibly and does
not delete old data. No network transfer or cloud trainer is activated.
## Validation and limitations
40 pure view checks + 32 native-mock checks passed on PHP 8.3 CLI at test host.
Regression: 36 raw-capture + 19 native-capture + 34 accounting checks passed with
new additive `raw_power`/`scale` units. Total 161 isolated checks. Full relevant PHP
source lint passed. Not a full PHPUnit run, Symcon kernel trial or hardware test.
Tests cover opposite EV/SDL requests, held output, non-unique partition labelling,
negative terminal power, scale/sentinel validity, old dependency timestamps, changed
commands, missing source, no duplicate native objects, unchanged existing signals,
append preservation, source drift, private output restrictions and stop behavior.
The observer is PREPARED, not started by the assistant. It does not replace the
installed receiver or its existing preview calculation. The preview/driver must
only be switched after measurement-boundary and feedback policy acceptance. Model
training and five-minute reconstruction must consume the new versioned snapshots
separately; there is no training eligibility or zero future-SDL assumption here.
Device-side command-loss protection and exact model/firmware identification remain
open. Official SolarEdge PDF fetch was unavailable (403/error); no device-specific
register suitability or watchdog behavior was inferred from a third-party source.
+77
View File
@@ -0,0 +1,77 @@
# V4 Unified RC1 - actual scope and release boundary
This release consolidates application data recovery, native delivery status, economic
family comparison, and optional verified archival. It is NOT a production actuation
release. No claim that all original product requirements or hardware acceptance are
complete. Sources are in the existing develop branch, not feature branches.
## Delivered code
* Audited recovery for the exact 100.0/100 splitToleranceW serialization defect.
The internal operator endpoint validates the two complete JSON representations,
their exact SHA256, installation, inventory, and every field. Only that numeric
representation may differ. Device-supplied arbitrary aliases are never accepted.
Existing rows/measurements are not rewritten. The original received mapping and
proof reference are recorded with the observation. A mixed 45/75 block and retries
are regression tested. Adoption requires an explicit deployment flag.
* Native normalization prevents recurrence. The sender preserves a separate last
successful receipt even during scheduled/backoff states, reports safe numeric HTTP
status (no response or credential text), and continues trying to deliver a durable
prefix if the current capture cannot be appended. No cursor reset or skipped block.
* Economic comparison is actually connected, not an empty score list. At the start
of a local calendar day the worker freezes comparable plans for families 3/13/23
with the then-known forecasts, prices and common battery/limit state. Completed
days are evaluated against subsequently acquired physical residual data. Initial
energy, losses, common terminal energy valuation and 15-minute demand charge are
included; monthly maxima are charged once over a comparison period, not per day.
GUI lookback/minimum days/coverage/switch margin are applied by the selector.
* v1 economic scope: one grid-charge-enabled battery, frozen day-ahead plans, with
local grid-target tracking simulated during the day. It is NOT a full receding-
horizon replay. Historical external SDL uses the then-recorded SDL request and is
clearly marked as an estimate, not independently measured delivery. Missing
actuals, unmatched contexts and breaches cannot become zero cost or a successful
comparison. Broader device topologies stay collecting; no fake equivalence.
* Lossless archive implementation and tests are present for acknowledged old native
outbox days and old server observations/plans. Archives are verified before plain
rows/files are retired. Active plans, acknowledged plans and unacknowledged data
are excluded. Archiving is OFF by default; enable only with an explicit storage
policy (native NetzfahrplanV4ArchivAktiv; server NETPLAN_V4_ARCHIVE_ENABLED=1).
Compressed archives are retained: this is not an external backup or infinite disk.
## Deployment is not data/model/actuator acceptance
Server entry: commissioning/deploy_unified_release.py. Default checks sources only.
--apply builds and tests a separate candidate context with no network/production
mounts before replacing the V4 service. It preserves the running plant allowlist,
backs up SQLite consistently, and uses a pinned built image. Only the V4 planner JS
asset is updated; unrelated GUI work, portal runtime, tariffs, forecasting, Symcon
and actuators are untouched. Rollback restores previous source/image; additive audit
rows remain. Database schema changes do not overwrite old observations.
--approve-numeric-mapping-compatibility explicitly authorizes the demonstrated pair
of equivalent configuration representations. Without it, strict original validation
remains and backlog containing the legacy serialization is still rejected.
Lihrenmoos native stage: /srv/agent/netplan-v4-unified-release/install.php.
This patches three data-only libraries, reloads the existing library, compares all
pre-existing properties and lets the existing data timer resume. No raw replay,
force-skip or actuator callback from the installer. Archiving stays disabled by default.
## Remaining product work - do not label as complete
1. The full production dispatcher is not installed. Earlier controlled-trial code
still has bounded pilot grants; continuous production authority and complete
corrected physical/virtual feedback integration are NOT delivered by this RC.
2. The actual host/container/kernel and field acceptance of this release are not
replaced by CLI mocks. Real source validity and independent command-loss behavior
are not asserted by a measurement algebra or an override.
3. The replay limitation above must be accepted as a beta feature, or extended to
continuous rolling reoptimization and additional asset types before advertising
the original full automatic-selection requirement.
4. Native multi-plant commissioning, external backups, retained archive lifecycle,
model quality on field data and publication authentication remain operational
release criteria. The tests do not demonstrate actual cost savings.
Do not re-run obsolete piecemeal installers after this release, bypass their source
checks, turn the previous grid schedule on, or turn 'productionReady' true manually.
The retained EV capacity is 161.44 kWh / 39 kW; SDL reserve is already outside EV.
+111
View File
@@ -0,0 +1,111 @@
# Prognosebedienung im bestehenden Manager
Stand: 06.10.2026. Gepruefter Integrationsstand zur von Daniel freigegebenen
Veroeffentlichung auf develop und beta. Kein Deployment und keine neue Stellfreigabe.
## Umfang
Der separate Formularbereich `V4 Planempfang / Prognosemanager` entfaellt.
Empfang, Planpruefung, Lizenzportal-Verweis und explizites Ein-/Ausschalten
stehen im bestehenden Bereich `Prognose / Forecast`.
Der neue Formularaufruf `FormNetzfahrplanSchalten` delegiert an den vorhandenen
`V4ManagerAktivtestSchalten`-Pfad. Er prueft beim Start nochmals gespeicherte
Prognosekonfiguration, Netzfahrplanlizenz, Managerstatus und bestehende lokale
Testfreigaben. Ein Formularaufruf allein erteilt keine Berechtigung. Ausschalten
bleibt auch nach Lizenzverlust moeglich. Der bestehende Stopp beinhaltet eine
frisch berechnete normale Regelung nach Widerruf der V4-Sitzung; er ist kein
anlagenweiter Not-Aus und veraendert keine unabhaengigen SDL-Auftraege.
Die Anzeige nennt weiterhin Testbetrieb und das bestehende Testende. Es gibt
keine neue Dauerbetriebsfreigabe, keinen Reset der 48-Stunden-Frist und keine
Aenderung an Watchdog-Ausnahmen, Messwertgrenzen oder Geraeteschutzpruefungen.
## Kompatibilitaet
- Keine Properties, Attribute, Objekt-IDs, Timer, Datenpfade oder Backend-APIs werden migriert.
- Prognosemodelle, Optimierer, Planempfang, ACKs, Messaufnahme und Versand bleiben unveraendert.
- Die vorhandene Portalpruefung fuer `grid_schedule` bleibt massgeblich. Keine Portal- oder Lizenzbestellung wird geaendert.
- `NetzfahrplanAktiv` bedeutet weiterhin den alten Regler. Bei vorhandener V4-Konfiguration wird dessen ausgeschalteter Schalter ausgeblendet. Ein eingeschalteter Altregler bleibt zum Ausschalten sichtbar.
- Nicht auf V4 eingerichtete Installationen behalten den bisherigen Schalter. Diese Aenderung provisioniert keine neue V4-Installation.
- `PrognoseAktiv` wird nicht zum automatischen V4-Startsignal. Bestehende Aufnahme-/Sender-Properties und die bisherige Laufzeitvariable bleiben erhalten.
## Dateien
- `Manager/form.json`: stabiler Anker fuer den bestehenden Prognosebereich.
- `Manager/module.php`: Formularintegration und gepruefter Bedienaufruf.
- `libs/ManagerPrognoseFormular.php`: reine Formularzusammenstellung ohne I/O.
- `libs/ManagerNetzfahrplanV4EmpfangTrait.php`: nur bisherigen Formulargenerator entfernt; Empfangscode unveraendert.
- `tests/PrognoseFormular/`: Formular- und echte RequestAction-Pruefungen mit simulierten IPS-/Stellaufrufen.
- `tests/V4Receiver/receiver_checks.php`: Strukturpruefung an die zusammengefuehrte Oberflaeche angepasst.
## Tests
Isoliert im PHP-8.3.6-CLI auf einer Quellkopie, nicht im Symcon-Kernel:
| Suite | Erfolgreiche Pruefungen |
|---|---:|
| PrognoseFormular/checks.php | 59 |
| PrognoseFormular/manager_checks.php | 26 |
| V4Receiver/receiver_checks.php | 32 |
| V4ControlTrial/checks.php | 96 |
| V4Feedback/receiver_checks.php | 8 |
| V4Feedback/device_read_checks.php | 9 |
| V4ControlTrial/register_checks.php | 1 |
| Gesamt | 231 |
Zusaetzliche Gesamttests des zusammengefuehrten Stands am 06.10.2026:
- PHP 8.3.6 / PHPUnit 9.6.36: 357 Tests, 1758 Assertions erfolgreich.
- Syntaxpruefung: alle 150 PHP-Dateien erfolgreich.
- Backend: 310 Python-Tests erfolgreich mit den vorhandenen QA-Abhaengigkeiten.
- Portal: 16 Node-Tests erfolgreich.
- Konfliktmarker- und Patch-Pruefung erfolgreich.
Die Pruefungen liefen in isolierten Quellkopien. Die PHP-Laufzeit wurde nur im
Agent-Arbeitsordner entpackt, ohne Systeminstallation. Kein Symcon-Kernel-/GUI-
oder Hardwaretest; der vorhandene Docker-Zugang war nicht nutzbar.
Eine vorbestehende, nicht committe Aenderung an measurement_pipeline.py wurde
bewusst nicht uebernommen: Sie erzeugte fuer jede konfigurierte Messgrenze ein
accountingEvidenceId trotz measurementBoundaryVerified=false und verletzte den
bestehenden Backendtest. Die Datei bleibt im Integrationsstand bytegleich zum
bisherigen versionierten V4-Stand. Die fremde Arbeitskopie und das laufende
Backend wurden nicht veraendert. Eine spaetere Freigabe dieser Messgrenze braucht
einen eigenstaendigen fachlichen Nachweis, keine Anpassung des Tests an das Label.
## Offener Abschluss
Der Auftrag ist mit dieser Bedienintegration noch nicht vollstaendig umgesetzt:
- Die dokumentierte Batterietimerblockade und die fehlende Anlagenabnahme werden hier nicht behoben.
- Ein regulaerer, lizenzierter Dauerbetrieb anstelle des begrenzten Testpfads ist nicht implementiert.
- Die separaten Laufzeitvariablen und Diagnoseordner werden noch nicht entfernt oder ausgeblendet.
- In gespeicherten Settings vom 06.10.2026, 13:06:07 UTC sind nur die Kategorien 21196 (`ENELIX_V4_SEPARATED_OBSERVATION`) und 57590 (`ENELIX_V4_PASSIVE_CAPTURE`) eindeutig als V4-Diagnosebereiche erkannt. Darin liegen weiterhin Beobachtungs-/Aufnahmeskripte. Keine pauschale Loeschung nach Namen.
- Der allgemeine `Testordner` 10249 enthaelt auch Abrechnung, Schnittstellen und weitere nicht zu dieser Integration gehoerende Objekte. Er bleibt unangetastet.
- Vor einem Rollout aktuelle Moduldateien sichern, mit dem getesteten Stand vergleichen und Abhaengigkeiten pruefen. Keine pauschalen Modulupdates, Reloads oder Dienstneustarts aus diesem Dokument ableiten.
- Die Divergenz wurde in einem separaten develop-Checkout auf ubuntu zusammengefuehrt: Server-89-Historie bis ce525a1 und origin/develop bis 5437f3f. Die Originalarbeitsverzeichnisse mit fremden Aenderungen bleiben erhalten. Daniel hat Merge, Commit und Push auf develop und beta ausdruecklich freigegeben; main bleibt unveraendert.
## Uebergabe und Ruecksetzung
Die fruehere Einzelpatch-Uebergabe ist durch den zusammengefuehrten Quellstand
ersetzt. Die Ausgangshistorie und die zugehoerigen lokalen V4-Quellen sind unter
/srv/agent/prognose-integration-20261006 gesichert. Der gepruefte Kandidat
enthaelt keine temporaren Live-Hooks, Zugangsdaten oder fremden uncommitteten
Ladestationsaenderungen. Ruecksetzungen nur additiv nach Driftpruefung; keine
fremden Aenderungen verwerfen. Fuer einen spaeteren Runtime-Rollout sind eine
eigene Sicherung, Abhaengigkeitspruefung und Anlagenabnahme erforderlich.
## Vorschlag PR-Text
**Titel:** Manager: Prognosebedienung im bestehenden Forecast-Bereich buendeln
**Aenderung:** Separaten V4-Formularbereich entfernen, bestehende Funktionen
unter Prognose / Forecast anbieten und explizite Bedienaktionen zusaetzlich
gegen Lizenz-, Konfigurations- und Freigabestatus pruefen.
**Unveraendert:** Prognose-/Optimierungsbackend, Empfangsprotokoll, Anlagenlimits,
befristete Testfreigaben, Datenhistorie und SDL. Keine automatische Aktivierung.
**Pruefung:** 357 PHPUnit-Tests, 310 Backend-Tests, 16 Portal-Tests sowie die
oben genannten isolierten Bedien-/V4-Checks; kein Hardware- oder Symcon-GUI-Abnahmetest.
@@ -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.
+96
View File
@@ -0,0 +1,96 @@
# 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.
- 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.
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.
Der erweiterte Sender uebermittelt Entladesperre und Wiederfreigabe-SOC separat
von der maximalen Entladeleistung. Der neue Python-Rechenkern darf die Sperre
im Zukunftsplan erst nach vorherigem Laden bis zur Wiederfreigabe aufheben.
SOC unterhalb der Betriebsreserve, aber oberhalb des technischen Minimums,
bleibt unveraendert erhalten: Es ist Wiederaufladung statt erfundener Energie
vorgesehen. Unterhalb des technischen Minimums wird kein normaler Plan erzeugt.
Zusaetzlich wird meterObservation mit frischen Empfangswerten der Netzleistung
und T1/T2-Summe uebermittelt. Dessen laufende Viertelstundenintegration ist eine
explizite Planungsschaetzung, KEIN verifizierter Abrechnungsnachweis. Verwendung
von Schaetzungen muss im V4-Dienst ausdruecklich aktiviert werden. Historische
Peakannahmen werden getrennt von verifizierten Maxima gespeichert.
Diese Protokollerweiterung benoetigt zuerst den passenden Backendstand. Der
bisherige laufende V4-Container und die Testanlage sind noch nicht aktualisiert.
Offline-Szenarien und Quelltextpruefungen ersetzen keinen Symcon-Laufzeittest.
## 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-active-import:<SHA256-der-konfigurierten-Quellen>",
"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
24 offline Konvertierungsszenarien und 20 Quellen-/Summen-Szenarien sowie PHPUnit-Strukturpruefungen. Werte sind
synthetisch; keine reale HTTP-Anbindung oder Symcon-Laufzeit damit behauptet.
@@ -0,0 +1,71 @@
--- a/install.php
+++ b/install.php
@@ -19,6 +19,8 @@
}
$before=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$report['files']=v4StageInstall(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
+ require_once __DIR__.'/installed_capture_bootstrap.php';
+ $report['classLoading']=v4ApplicationLoadInstalledCapture('/var/lib/symcon/modules/Enelix-EMS',__DIR__.'/MANIFEST.json');
$controls=[];
foreach (IPS_GetModuleList() as $mid) if ((IPS_GetModule($mid)['Prefix']??'')==='MC') $controls=array_merge($controls,IPS_GetInstanceListByModuleID($mid));
if (count($controls)!==1||!function_exists('MC_ReloadModule')) throw new RuntimeException('Module Control nicht eindeutig.');
@@ -38,7 +40,7 @@
$marker=$dir.'/observer-import.json';
if (!is_file($marker)&&!IPS_GetProperty($manager,'NetzfahrplanV4MessdatenAktiv')) {
if (glob($dir.'/raw-*.jsonl')!==[]) throw new RuntimeException('Messdaten ohne Importabschluss vorhanden; nicht doppelt importieren.');
- require_once __DIR__.'/source/libs/NetzfahrplanV4Messaufnahme.php';
+ // Reuse the hash-checked installed class; never load the staged copy.
$count=0;$last=0;$cutoff=time()-172800;
$files=glob('/srv/agent/netplan-v4-separated-observer-stage/data/raw-*.jsonl');sort($files,SORT_STRING);
foreach ($files as $file) {
--- /dev/null
+++ b/installed_capture_bootstrap.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+/** Installer-only bootstrap. Load the same files the installed Manager uses.
+ * No IPS calls, source edits, state changes, or autoload of an unknown class.
+ */
+function v4ApplicationLoadInstalledCapture(string $target, string $manifestFile): array
+{
+ if (is_link($target) || realpath($target) !== $target || !is_dir($target)) {
+ throw new RuntimeException('Unerwartetes Modulverzeichnis.');
+ }
+ $manifest = json_decode((string) file_get_contents($manifestFile), true, 64, JSON_THROW_ON_ERROR);
+ if (($manifest['mode'] ?? null) !== 'data_application_only') {
+ throw new RuntimeException('Unerwarteter Installationsumfang.');
+ }
+ $classes = [
+ 'Belevo\\EnelixEMS\\NetzfahrplanV4Bilanzierung' => 'libs/NetzfahrplanV4Bilanzierung.php',
+ 'Belevo\\EnelixEMS\\NetzfahrplanV4Messaufnahme' => 'libs/NetzfahrplanV4Messaufnahme.php',
+ ];
+ // Check BOTH origins before loading either file, including the transitive dependency.
+ foreach ($classes as $class => $relative) {
+ $path = $target . '/' . $relative;
+ $expected = $manifest['files'][$relative]['after'] ?? null;
+ if (!is_string($expected) || !preg_match('/^[a-f0-9]{64}$/D', $expected)
+ || is_link($path) || !is_file($path) || realpath($path) !== $path
+ || hash_file('sha256', $path) !== $expected) {
+ throw new RuntimeException('Installierte Messbibliothek weicht vom geprueften Paket ab: ' . $relative);
+ }
+ if (class_exists($class, false)) {
+ $origin = (new ReflectionClass($class))->getFileName();
+ if ($origin !== $path) {
+ throw new RuntimeException('Messbibliothek bereits aus anderem Pfad geladen. Installationsaufruf separat ausfuehren, ohne weitere Sammler-Includes.');
+ }
+ }
+ }
+ foreach ($classes as $class => $relative) {
+ $path = $target . '/' . $relative;
+ if (!class_exists($class, false)) {
+ require_once $path;
+ }
+ if (!class_exists($class, false) || (new ReflectionClass($class))->getFileName() !== $path) {
+ throw new RuntimeException('Installierte Messbibliothek konnte nicht eindeutig geladen werden.');
+ }
+ }
+ return ['source' => 'installed_module_only', 'stagedClassesLoaded' => false,
+ 'files' => array_values($classes)];
+}
@@ -0,0 +1,41 @@
{
"scope": "confirmed_feedback_trial_disabled",
"managerId": 17004,
"batteryInstanceId": 44234,
"files": {
"libs/NetzfahrplanV4Rueckmeldung.php": {
"before": "a7459a208c4dd84ffe3a166d266dcd00216f3660fe60e5ee61d5042d0b840631",
"after": "0b261f6534c5ca9864018ea4c2caf97e6ce6913d92f13dcc926383b4f401a673"
},
"libs/NetzfahrplanV4Geraeteabruf.php": {
"before": null,
"after": "2b69a2ed27554139db6c996c906a4afe430918381e6359d23e8bb3369cd9be47"
},
"libs/BatterieNetzfahrplanV4RueckmeldungTrait.php": {
"before": "24cf0654f1a6bd545d64ce5f115f309b16d8ffca5172ec64fb37c390c6f001a7",
"after": "1de81499da07ad814c98ba778ca728e34817bdfcf29cc7e518f7b5f8fd7733b7"
}
},
"testSupport": {
"libs/BatterieNetzfahrplanV4TestTrait.php": "80474ff24cf9b2d32dfe481f0ce26bb94584c458b0fa3de232dec3897b64a4b2",
"libs/NetzfahrplanV4Regeltest.php": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249",
"libs/ManagerNetzfahrplanV4TestTrait.php": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18",
"libs/NetzfahrplanV4Planpruefung.php": "24b935d80bad4ce2d9047b6777aaf74a18773236ca91a2844a0907012f4fadf4"
},
"tests": {
"tests/V4Feedback/checks.php": "b1aacc540ba11adabdd140aaded9e2968d1328f261ca15756e6002db3427330a",
"tests/V4Feedback/device_read_checks.php": "5c5d9e50dd1d0f80cb01a2d95e63018eeec558ba78c07afde208e3c0ef304cc3",
"tests/V4ControlTrial/checks.php": "2f12e94aeb207e6939a645f2f45b5f54e57c18530360ed3b16aa52ff89a1ef39",
"tests/V4ControlTrial/additional_checks.php": "df01119f2b9c0befe1d14ae6b6ddf39c75d3687b669142e9b24b5f9f4821f2c3",
"tests/V4ControlTrial/feedback_checks.php": "e87d41dff8e3ecd414a59b3dd7a234b34183d37acca8cddaf847b82eae92e60c",
"tests/V4Receiver/fixture.php": "0eeb65e63250aff5ca364a82c2b2a71f9f229be2d9d412e3037014979deb793a"
},
"configurationSha256": "e393c1f01b30febc1d0785233474be43e98d849b152b4b2450b72efa6b73d540",
"dependencies": {
"libs/NetzfahrplanV4Regeltest.php": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249",
"libs/ManagerNetzfahrplanV4TestTrait.php": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18",
"libs/ManagerNetzfahrplanV4EmpfangTrait.php": "936939a3e4a5a5d54fdff68f6fc689a9400e8406aecd5ac09ebc8a6b569c0b66",
"Batterie/module.php": "3d5fcd83e4e7d999d5ac949fe357b492d5661ffa76639d2ca57a3f3934eabdbe",
"Manager/module.php": "cbbb33a8f71dda8cc80a4fc01cd3d6154da8153b5523306b2da723bb44f2dd79"
}
}
+18
View File
@@ -0,0 +1,18 @@
# V4 confirmed feedback for Lihrenmoos
This incremental package adds synchronous device reads before V4 feedback is evaluated.
- Grid input 40348 is refreshed through its M-Bus device.
- GoodWe and SolarEdge inputs are refreshed through their ModBus parent instances.
- A successful driver call is required; cached variable timestamps alone are not accepted.
- Daniel Haefliger accepted the existing `virtual_split` model for the bounded field test on 2026-10-04.
- The installer keeps the legacy schedule and both V4 trial permissions disabled.
- A real control trial still requires separate server authorization and evidence of an independent device-side watchdog.
Build a private staging directory:
```sh
python3 examples/V4ConfirmedFeedback/build_stage.py /srv/agent/netplan-v4-confirmed-feedback
```
Run the staged PHP checks before copying the package to the test plant. Execute `install.php` only from the IP-Symcon script editor on Lihrenmoos.
@@ -0,0 +1,49 @@
"""Create the reviewed Lihrenmoos confirmed-feedback package; never installs into Symcon."""
from pathlib import Path
import hashlib
import json
import sys
here = Path(__file__).resolve().parent
repo = here.parents[1]
if len(sys.argv) != 2:
raise SystemExit("Usage: python3 build_stage.py NEW_PRIVATE_STAGING_DIRECTORY")
target = Path(sys.argv[1]).resolve()
allowed_roots = [Path("/srv/agent"), Path("/home/agent/services/qa")]
if not any(target != root and target.is_relative_to(root) for root in allowed_roots):
raise SystemExit("Use a private agent staging directory, not live module paths")
if target.exists():
raise SystemExit("Destination must not exist")
manifest = json.loads((here / "MANIFEST.json").read_text())
contents = {}
for group in ("files", "testSupport", "tests"):
for name, record in manifest[group].items():
expected = record["after"] if group == "files" else record
source = repo / name
if source.is_symlink() or not source.resolve().is_relative_to(repo):
raise SystemExit("Unsafe source: " + name)
data = source.read_bytes()
if hashlib.sha256(data).hexdigest() != expected:
raise SystemExit("Reviewed source changed: " + name)
contents["source/" + name] = data
for name, expected in manifest["dependencies"].items():
if hashlib.sha256((repo / name).read_bytes()).hexdigest() != expected:
raise SystemExit("Dependency changed: " + name)
for name in ("install.php", "file_installer.php", "MANIFEST.json", "feedback-config.json", "README.md"):
contents[name] = (here / name).read_bytes()
if hashlib.sha256(contents["feedback-config.json"]).hexdigest() != manifest["configurationSha256"]:
raise SystemExit("Configuration changed")
target.mkdir(parents=True, mode=0o700)
for name, data in contents.items():
path = target / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
path.chmod(0o640)
(target / "PACKAGE_HASHES.json").write_text(
json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in contents.items()}, indent=2) + "\n"
)
print("Private confirmed-feedback package created:", target, "; both trial permissions remain disabled.")
@@ -0,0 +1,71 @@
{
"version": 1,
"installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021",
"assetId": "anlage01-virtual-ev",
"managerId": 17004,
"batteryInstanceId": 44234,
"mode": "virtual_split",
"allowEstimatedForTrial": true,
"maxSkewSeconds": 30,
"maxAgeSeconds": 60,
"idleToleranceW": 50.0,
"trackingToleranceW": 200.0,
"sources": [
{
"key": "grid",
"variableId": 40348,
"parentId": 11490,
"ident": "Power_8",
"factorToW": 1000,
"role": "grid"
},
{
"key": "physical_goodwe1",
"variableId": 47725,
"parentId": 19742,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_goodwe2",
"variableId": 35724,
"parentId": 57658,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_solaredge",
"variableId": 21447,
"parentId": 30789,
"ident": "Value",
"factorToW": 1,
"role": "physical"
},
{
"key": "ev_requested",
"variableId": 19651,
"parentId": 58448,
"ident": "Nennleistung_Soll_EV",
"factorToW": 1.0,
"role": "ev_request"
},
{
"key": "sdl_requested",
"variableId": 38943,
"parentId": 58448,
"ident": "Nennleistung_Soll_SDL",
"factorToW": 1.0,
"role": "sdl_request"
},
{
"key": "gateway_active",
"role": "gateway_active",
"variableId": 23483,
"parentId": 58448,
"ident": "State",
"factorToW": 1.0
}
]
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/** Atomic incremental installer for the confirmed-feedback update. */
function v4ConfirmedWrite(string $path, string $content, int $mode = 0644): void
{
if (is_link($path)) {
throw new RuntimeException('Symlink wird nicht ersetzt.');
}
$temp = tempnam(dirname($path), '.v4-confirmed-');
if ($temp === false) {
throw new RuntimeException('Temporaere Datei nicht verfuegbar.');
}
try {
if (file_put_contents($temp, $content, LOCK_EX) !== strlen($content)
|| !chmod($temp, $mode)
|| !rename($temp, $path)) {
throw new RuntimeException('Atomarer Dateitausch fehlgeschlagen.');
}
} finally {
if (is_file($temp)) {
unlink($temp);
}
}
}
function v4ConfirmedFiles(string $stage, string $target): array
{
$allowed = [
'libs/NetzfahrplanV4Rueckmeldung.php',
'libs/NetzfahrplanV4Geraeteabruf.php',
'libs/BatterieNetzfahrplanV4RueckmeldungTrait.php',
];
$manifest = json_decode((string) file_get_contents($stage . '/MANIFEST.json'), true, 32, JSON_THROW_ON_ERROR);
if (($manifest['scope'] ?? null) !== 'confirmed_feedback_trial_disabled'
|| count($manifest['files'] ?? []) !== count($allowed)
|| array_diff(array_keys($manifest['files']), $allowed)
|| array_diff($allowed, array_keys($manifest['files']))) {
throw new RuntimeException('Unerwarteter Dateiumfang.');
}
if (realpath($target) !== $target || is_link($target)) {
throw new RuntimeException('Modulpfad unerwartet.');
}
foreach ($manifest['dependencies'] as $name => $hash) {
if (!preg_match('~^(libs/[A-Za-z0-9]+\\.php|Batterie/module\\.php|Manager/module\\.php)$~D', $name)
|| is_link($target . '/' . $name)
|| hash_file('sha256', $target . '/' . $name) !== $hash) {
throw new RuntimeException('Abhaengigkeit wurde parallel geaendert: ' . $name);
}
}
$before = [];
$source = [];
$already = true;
foreach ($allowed as $name) {
$path = $target . '/' . $name;
$candidate = $stage . '/source/' . $name;
if (is_link($path) || is_link($candidate) || realpath(dirname($path)) !== $target . '/' . dirname($name)) {
throw new RuntimeException('Unerwarteter Dateipfad.');
}
$source[$name] = (string) file_get_contents($candidate);
if (hash('sha256', $source[$name]) !== $manifest['files'][$name]['after']) {
throw new RuntimeException('Paketpruefsumme geaendert: ' . $name);
}
token_get_all($source[$name], TOKEN_PARSE);
$before[$name] = is_file($path) ? file_get_contents($path) : null;
$hash = $before[$name] === null ? null : hash('sha256', $before[$name]);
if (!in_array($hash, [$manifest['files'][$name]['before'], $manifest['files'][$name]['after']], true)) {
throw new RuntimeException('Paralleler Modulstand; nichts ueberschrieben: ' . $name);
}
if ($hash !== $manifest['files'][$name]['after']) {
$already = false;
}
}
if ($already) {
return ['status' => 'already_installed', 'backup' => null, 'filesChanged' => []];
}
$backup = $stage . '/backups/' . gmdate('Ymd\\THis\\Z') . '-' . bin2hex(random_bytes(4));
if (!mkdir($backup, 0700, true)) {
throw new RuntimeException('Sicherung fehlgeschlagen.');
}
foreach ($allowed as $name) {
if ($before[$name] !== null) {
$destination = $backup . '/' . $name;
if (!is_dir(dirname($destination))) {
mkdir(dirname($destination), 0700, true);
}
v4ConfirmedWrite($destination, $before[$name], 0600);
}
}
v4ConfirmedWrite(
$backup . '/MANIFEST.json',
json_encode($manifest, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n",
0600
);
$written = [];
try {
foreach ($allowed as $name) {
if ((is_file($target . '/' . $name) ? file_get_contents($target . '/' . $name) : null) !== $before[$name]) {
throw new RuntimeException('Parallele Aenderung waehrend Installation.');
}
if ($before[$name] === $source[$name]) {
continue;
}
v4ConfirmedWrite($target . '/' . $name, $source[$name]);
$written[] = $name;
}
} catch (Throwable $error) {
foreach (array_reverse($written) as $name) {
$path = $target . '/' . $name;
if (!is_link($path) && is_file($path)
&& hash_file('sha256', $path) === $manifest['files'][$name]['after']) {
if ($before[$name] === null) {
unlink($path);
} else {
v4ConfirmedWrite($path, $before[$name]);
}
}
}
throw $error;
}
return ['status' => 'installed', 'backup' => $backup, 'filesChanged' => $written];
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
// Execute only in Lihrenmoos Symcon. Installs confirmed reads and accepted virtual_split mapping; never starts a trial.
if (!function_exists('IPS_GetKernelVersion')) {
throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
}
require_once __DIR__ . '/file_installer.php';
$manager = 17004;
$battery = 44234;
$locked = false;
$changedMapping = false;
$oldMapping = null;
$report = [
'scope' => 'confirmed_feedback_trial_disabled',
'startedAt' => gmdate('c'),
'actuatorPermissionGranted' => false,
'serverUpdateRequired' => true,
'acceptedModel' => 'virtual_split',
];
try {
$hashes = json_decode((string) file_get_contents(__DIR__ . '/PACKAGE_HASHES.json'), true, 32, JSON_THROW_ON_ERROR);
foreach ($hashes as $name => $hash) {
if (str_contains($name, '..') || str_starts_with($name, '/') || is_link(__DIR__ . '/' . $name)
|| hash_file('sha256', __DIR__ . '/' . $name) !== $hash) {
throw new RuntimeException('Vorbereitetes Paket veraendert.');
}
}
foreach ([
$manager => '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
$battery => '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}',
] as $id => $module) {
if (!IPS_InstanceExists($id) || IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== $module) {
throw new RuntimeException('Modulzuordnung ungueltig.');
}
if (IPS_HasChanges($id)) {
throw new RuntimeException('Offene Aenderungen zuerst speichern oder verwerfen.');
}
}
$beforeManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$beforeBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
if (($beforeManager['NetzfahrplanAktiv'] ?? null) !== false
|| ($beforeManager['NetzfahrplanV4RegeltestErlaubt'] ?? false) !== false
|| ($beforeBattery['NetzfahrplanV4RegeltestErlaubt'] ?? false) !== false) {
throw new RuntimeException('Alte Fahrplanausgabe und beide Regeltestfreigaben muessen AUS bleiben.');
}
$configuration = json_decode((string) file_get_contents(__DIR__ . '/feedback-config.json'), true, 32, JSON_THROW_ON_ERROR);
$manifest = json_decode((string) file_get_contents(__DIR__ . '/MANIFEST.json'), true, 32, JSON_THROW_ON_ERROR);
if (($configuration['managerId'] ?? null) !== $manager
|| ($configuration['batteryInstanceId'] ?? null) !== $battery
|| ($configuration['mode'] ?? null) !== 'virtual_split'
|| ($configuration['allowEstimatedForTrial'] ?? null) !== true
|| hash_file('sha256', __DIR__ . '/feedback-config.json') !== $manifest['configurationSha256']) {
throw new RuntimeException('Falsche Rueckmeldekonfiguration.');
}
$desired = json_encode($configuration, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
$previousConfiguration = $configuration;
$previousConfiguration['allowEstimatedForTrial'] = false;
$previousDesired = json_encode($previousConfiguration, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
$oldMapping = $beforeBattery['NetzfahrplanV4RueckmeldungKonfiguration'] ?? '{}';
if (!in_array($oldMapping, [$previousDesired, $desired], true)) {
throw new RuntimeException('Abweichende Rueckmeldekonfiguration nicht ueberschrieben.');
}
$controls = [];
foreach (IPS_GetModuleList() as $module) {
if ((IPS_GetModule($module)['Prefix'] ?? '') === 'MC') {
$controls = array_merge($controls, IPS_GetInstanceListByModuleID($module));
}
}
if (count($controls) !== 1 || !function_exists('MC_ReloadModule')) {
throw new RuntimeException('Module Control nicht eindeutig.');
}
$locked = IPS_SemaphoreEnter('ENELIX.V4.ConfirmedFeedbackInstall', 3000);
if (!$locked) {
throw new RuntimeException('Installation bereits aktiv.');
}
$report['files'] = v4ConfirmedFiles(__DIR__, '/var/lib/symcon/modules/Enelix-EMS');
if (MC_ReloadModule($controls[0], 'Enelix-EMS') === false) {
throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
}
$afterManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$afterBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
foreach ([[$beforeManager, $afterManager], [$beforeBattery, $afterBattery]] as $pair) {
foreach ($pair[0] as $key => $value) {
if (!array_key_exists($key, $pair[1]) || $pair[1][$key] !== $value) {
throw new RuntimeException('Bestehender Parameter beim Reload veraendert: ' . $key);
}
}
}
if (!function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
$report['status'] = 'waiting_for_registration';
echo "Dateien installiert. Modulregistrierung wird abgeschlossen; diesen Aufruf einmal wiederholen. Keine Stellfreigabe.\n";
} else {
if ($afterBattery['NetzfahrplanV4RueckmeldungKonfiguration'] !== $desired) {
IPS_SetProperty($battery, 'NetzfahrplanV4RueckmeldungKonfiguration', $desired);
$changedMapping = true;
IPS_ApplyChanges($battery);
}
$finalManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$finalBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
foreach ([[$beforeManager, $finalManager], [$beforeBattery, $finalBattery]] as $pair) {
foreach ($pair[0] as $key => $value) {
if ($key !== 'NetzfahrplanV4RueckmeldungKonfiguration'
&& (!array_key_exists($key, $pair[1]) || $pair[1][$key] !== $value)) {
throw new RuntimeException('Bestehender Parameter unerwartet veraendert: ' . $key);
}
}
}
if (($finalManager['NetzfahrplanAktiv'] ?? null) !== false
|| ($finalManager['NetzfahrplanV4RegeltestErlaubt'] ?? null) !== false
|| ($finalBattery['NetzfahrplanV4RegeltestErlaubt'] ?? null) !== false) {
throw new RuntimeException('Unerwartete Stellfreigabe nach Installation.');
}
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldung($battery), true, 32, JSON_THROW_ON_ERROR);
$report['feedback'] = array_intersect_key($feedback, array_flip([
'status', 'kind', 'method', 'reason', 'reasons', 'estimated', 'batteryW', 'gridW',
'sourceOldestAt', 'sourceVariableOldestAt', 'confirmedAt', 'checkedAt', 'configHash',
'deviceReadConfirmed', 'usableForTrial', 'canDispatch',
]));
$report['status'] = 'confirmed_feedback_installed_trial_disabled';
$report['originalParametersPreserved'] = true;
echo "OK: Geraeteabfrage bestaetigt die physischen Messwerte; virtual_split ist fuer den begrenzten Regeltest akzeptiert.\n";
echo 'Rueckmeldung: ' . ($feedback['status'] ?? 'available')
. '; Geraeteabfrage: ' . (($feedback['deviceReadConfirmed'] ?? false) ? 'bestaetigt' : 'nicht bestaetigt')
. '; Modellanteil: ' . (($feedback['estimated'] ?? false) ? 'ja' : 'nein') . ".\n";
if (isset($feedback['reason'])) {
echo 'Aktueller Hinweis: ' . $feedback['reason'] . "\n";
}
echo "Beide Regeltestfreigaben AUS. Kein Stellbefehl ohne separat nachgewiesenen Geraete-Watchdog und Serverfreigabe.\n";
}
} catch (Throwable $error) {
$report['status'] = 'needs_review';
$report['reason'] = substr($error->getMessage(), 0, 260);
if ($changedMapping && $oldMapping !== null) {
try {
IPS_SetProperty($battery, 'NetzfahrplanV4RueckmeldungKonfiguration', $oldMapping);
IPS_ApplyChanges($battery);
$report['mappingRestored'] = true;
} catch (Throwable $ignored) {
$report['mappingRestored'] = false;
}
}
echo 'FEHLER: ' . $report['reason'] . "\nKeine V4-Stellfreigabe erteilt.\n";
} finally {
$report['finishedAt'] = gmdate('c');
v4ConfirmedWrite(__DIR__ . '/INSTALL_RESULT.json', json_encode($report, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n", 0644);
if ($locked) {
IPS_SemaphoreLeave('ENELIX.V4.ConfirmedFeedbackInstall');
}
echo 'CONFIRMED FEEDBACK INSTALL REPORT: ' . __DIR__ . "/INSTALL_RESULT.json\n";
}
@@ -0,0 +1,65 @@
{
"scope": "corrected_feedback_trial_disabled",
"managerId": 17004,
"batteryInstanceId": 44234,
"files": {
"libs/BatterieNetzfahrplanV4RueckmeldungTrait.php": {
"before": null,
"after": "24cf0654f1a6bd545d64ce5f115f309b16d8ffca5172ec64fb37c390c6f001a7"
},
"libs/BatterieNetzfahrplanV4TestTrait.php": {
"before": null,
"after": "80474ff24cf9b2d32dfe481f0ce26bb94584c458b0fa3de232dec3897b64a4b2"
},
"libs/BatterieRegler.php": {
"before": "035988c54d4d0cc3a59d234f1c08a8edbf5fcac42e09a6bcea61d3dd8270771b",
"after": "beecc9c817b722336367e1c574c1a38afc8f3f5346442170d67c101b980adf7b"
},
"libs/ManagerNetzfahrplanV4EmpfangTrait.php": {
"before": "d083cfa6ae8e8026c134d2b53f34bb6fbe342fb7332a3e7da1b881de33e13e97",
"after": "936939a3e4a5a5d54fdff68f6fc689a9400e8406aecd5ac09ebc8a6b569c0b66"
},
"libs/ManagerNetzfahrplanV4TestTrait.php": {
"before": null,
"after": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18"
},
"libs/NetzfahrplanV4Regeltest.php": {
"before": null,
"after": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249"
},
"libs/NetzfahrplanV4Rueckmeldung.php": {
"before": null,
"after": "a7459a208c4dd84ffe3a166d266dcd00216f3660fe60e5ee61d5042d0b840631"
},
"Batterie/module.php": {
"before": "34503645f7e21dd1e5bf0a3f754f6592175dd67f9a263bf431f3801209555401",
"after": "3d5fcd83e4e7d999d5ac949fe357b492d5661ffa76639d2ca57a3f3934eabdbe"
},
"Manager/module.php": {
"before": "83e80883c8f9b817c0af5e239b20bcb019c7cf7f7943ee5bcc6493ac5a743532",
"after": "cbbb33a8f71dda8cc80a4fc01cd3d6154da8153b5523306b2da723bb44f2dd79"
}
},
"configurationSha256": "f66cfb9ebf5e02259b902f876a4c78756a51b3f2f2c240fd92c0f01161268c56",
"dependencies": {
"libs/NetzfahrplanV4Planpruefung.php": "24b935d80bad4ce2d9047b6777aaf74a18773236ca91a2844a0907012f4fadf4",
"libs/Nachrichtenvertrag.php": "215ad7d4c5240b39b48cb7672f5057afa532aafdb34039862a1a22985a9ee839",
"libs/VerbraucherBasisTrait.php": "10fd9f54283c378104bce11d623d5581558355d26fc2f85bf04b316542874507",
"libs/VerbraucherSchnittstelle.php": "6329d423dca7b551f01fd884204be45be43f92cd6da6e4faedf2c3c9b4654a15",
"libs/ManagerNetzfahrplanV4Trait.php": "e27a3595e4ed8cdb62e2561149ef6a50adb9194e0239eb25c1216aee2716fa88",
"libs/ManagerNetzfahrplanV4DatenTrait.php": "59ae0ae283839ca82c58db9c071dcf28d0ab95e44f7a74c6473aca837ce416c4",
"libs/NetzfahrplanV4Datenarchiv.php": "57ef7a60c273c5270b3e93ff5528d7d294e708725c9edfe81ac7ed819587f983",
"libs/NetzfahrplanV4Messaufnahme.php": "ebe873bb714041e505a4f500b34c9bae3a5a0b93ac459161584ced758507a6e9",
"libs/NetzfahrplanV4Bilanzierung.php": "ffda2a03dbb226a4405a2ad66704c080a9dc475ce64282ad3f0d7d60034ab199",
"libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9",
"libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a",
"libs/ManagerEnergieTrait.php": "2f89b0ad31ee6dd99180a7225173036eb6212b2ea7157e57fee23f0f8b441a51",
"libs/EnergieMessung.php": "16c64e1cbefde81c25f913aaca6546f0d30cc8f2fddecea0768a3760a98e97d2",
"libs/StoerungsSnapshot.php": "c871c1dd58d7fb1ef6c06985de066518688ef080f3cbdadda192d66cedcf49e1",
"libs/Anlagentopologie.php": "1b7e95d103a3048ed38c464a424f2f12821a147b269e149fd6b89bca84878c47",
"libs/Lizenzpruefung.php": "cb93cb49b93ed3ebb2060dba28b5b2dec2f29548d6ccff1afd9c7cb4624ad77e",
"libs/EinspeiseRegler.php": "8b982be5b95275c6dd6611a2bf2b1f89b991ba53cf94e46df0eed3249add65e0",
"libs/ManagerRegler.php": "608a4abebbe45da37a16316a530fa3f5ab21cb880bb341acbdcd3a9aaa93c6ee",
"libs/ManagerSchnittstelle.php": "0aae8c8c85eb252c577fcb0b5a50a12f3247e4a0bb083579122854f6c35878b3"
}
}
@@ -0,0 +1,25 @@
"""Recreate a reviewed Lihrenmoos package from this commit; never install in Symcon."""
from pathlib import Path
import hashlib,json,sys
here=Path(__file__).resolve().parent;repo=here.parents[1]
if len(sys.argv)!=2:raise SystemExit('Usage: python3 build_stage.py NEW_PRIVATE_STAGING_DIRECTORY')
target=Path(sys.argv[1]).resolve()
allowed=[Path('/srv/agent'),Path('/home/agent/services/qa')]
if not any(target!=p and target.is_relative_to(p) for p in allowed):raise SystemExit('Use a private agent staging directory, not live module paths')
if target.exists():raise SystemExit('Destination must not exist')
m=json.loads((here/'MANIFEST.json').read_text());contents={}
for n,v in m['files'].items():
p=repo/n
if p.is_symlink() or not p.resolve().is_relative_to(repo):raise SystemExit('Unsafe source')
data=p.read_bytes()
if hashlib.sha256(data).hexdigest()!=v['after']:raise SystemExit('Reviewed source changed: '+n)
contents['source/'+n]=data
for n,v in m['dependencies'].items():
if hashlib.sha256((repo/n).read_bytes()).hexdigest()!=v:raise SystemExit('Dependency changed: '+n)
for n in ['install.php','file_installer.php','MANIFEST.json','feedback-config.json']:contents[n]=(here/n).read_bytes()
if hashlib.sha256(contents['feedback-config.json']).hexdigest()!=m['configurationSha256']:raise SystemExit('Configuration changed')
target.mkdir(parents=True,mode=0o700)
for n,data in contents.items():
p=target/n;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);p.chmod(0o640)
(target/'PACKAGE_HASHES.json').write_text(json.dumps({n:hashlib.sha256(v).hexdigest() for n,v in contents.items()},indent=2)+'\n')
print('Private staging package created:',target,'; no running module, settings or control changes.')
@@ -0,0 +1,71 @@
{
"version": 1,
"installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021",
"assetId": "anlage01-virtual-ev",
"managerId": 17004,
"batteryInstanceId": 44234,
"mode": "virtual_split",
"allowEstimatedForTrial": false,
"maxSkewSeconds": 30,
"maxAgeSeconds": 60,
"idleToleranceW": 50.0,
"trackingToleranceW": 200.0,
"sources": [
{
"key": "grid",
"variableId": 40348,
"parentId": 11490,
"ident": "Power_8",
"factorToW": 1000,
"role": "grid"
},
{
"key": "physical_goodwe1",
"variableId": 47725,
"parentId": 19742,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_goodwe2",
"variableId": 35724,
"parentId": 57658,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_solaredge",
"variableId": 21447,
"parentId": 30789,
"ident": "Value",
"factorToW": 1,
"role": "physical"
},
{
"key": "ev_requested",
"variableId": 19651,
"parentId": 58448,
"ident": "Nennleistung_Soll_EV",
"factorToW": 1.0,
"role": "ev_request"
},
{
"key": "sdl_requested",
"variableId": 38943,
"parentId": 58448,
"ident": "Nennleistung_Soll_SDL",
"factorToW": 1.0,
"role": "sdl_request"
},
{
"key": "gateway_active",
"role": "gateway_active",
"variableId": 23483,
"parentId": 58448,
"ident": "State",
"factorToW": 1.0
}
]
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/** File-only installation helpers. No module classes loaded from the staging tree. */
function v4FeedbackWrite(string $path, string $content, int $mode=0644): void
{
if (is_link($path)) throw new RuntimeException('Symlink wird nicht ersetzt.');
$temp=tempnam(dirname($path),'.v4-feedback-');
if ($temp===false) throw new RuntimeException('Temporaere Datei nicht verfuegbar.');
try {
if (file_put_contents($temp,$content,LOCK_EX)!==strlen($content) || !chmod($temp,$mode) || !rename($temp,$path)) throw new RuntimeException('Atomarer Dateitausch fehlgeschlagen.');
} finally { if(is_file($temp))unlink($temp); }
}
function v4FeedbackFiles(string $stage,string $target): array
{
$allowed=['libs/NetzfahrplanV4Rueckmeldung.php','libs/BatterieNetzfahrplanV4RueckmeldungTrait.php','libs/NetzfahrplanV4Regeltest.php',
'libs/BatterieNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4EmpfangTrait.php','libs/BatterieRegler.php','Batterie/module.php','Manager/module.php'];
$m=json_decode((string)file_get_contents($stage.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR);
if (($m['scope']??null)!=='corrected_feedback_trial_disabled' || count($m['files']??[])!==count($allowed) || array_diff(array_keys($m['files']),$allowed)) throw new RuntimeException('Unerwarteter Dateiumfang.');
if (realpath($target)!==$target || is_link($target)) throw new RuntimeException('Modulpfad unerwartet.');
foreach($m['dependencies'] as $name=>$hash) {
if (!preg_match('~^libs/[A-Za-z0-9]+\.php$~D',$name) || is_link($target.'/'.$name) || hash_file('sha256',$target.'/'.$name)!==$hash) throw new RuntimeException('Abhaengigkeit wurde parallel geaendert: '.$name);
}
$before=[];$source=[];$already=true;
// Explicit dependency order. Both top-level modules are replaced only after all dependencies.
foreach($allowed as$name) {
$path=$target.'/'.$name;$candidate=$stage.'/source/'.$name;
if (is_link($path)||is_link($candidate)||realpath(dirname($path))!==$target.'/'.dirname($name)) throw new RuntimeException('Unerwarteter Dateipfad.');
$source[$name]=(string)file_get_contents($candidate);
if (hash('sha256',$source[$name])!==$m['files'][$name]['after']) throw new RuntimeException('Paketpruefsumme geaendert: '.$name);
token_get_all($source[$name],TOKEN_PARSE);
$before[$name]=is_file($path)?file_get_contents($path):null;
$hash=$before[$name]===null?null:hash('sha256',$before[$name]);
if (!in_array($hash,[$m['files'][$name]['before'],$m['files'][$name]['after']],true)) throw new RuntimeException('Paralleler Modulstand; nichts ueberschrieben: '.$name);
if($hash!==$m['files'][$name]['after'])$already=false;
}
if($already)return ['status'=>'already_installed','backup'=>null];
$backup=$stage.'/backups/'.gmdate('Ymd\THis\Z').'-'.bin2hex(random_bytes(4));
if(!mkdir($backup,0700,true))throw new RuntimeException('Sicherung fehlgeschlagen.');
foreach($allowed as$name) {
if($before[$name]!==null) {
$dest=$backup.'/'.$name;if(!is_dir(dirname($dest)))mkdir(dirname($dest),0700,true);
v4FeedbackWrite($dest,$before[$name],0600);
}
}
v4FeedbackWrite($backup.'/MANIFEST.json',json_encode($m,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0600);
$written=[];
try {
foreach($allowed as$name) {
if((is_file($target.'/'.$name)?file_get_contents($target.'/'.$name):null)!==$before[$name])throw new RuntimeException('Parallele Aenderung waehrend Installation.');
if($before[$name]===$source[$name])continue;
v4FeedbackWrite($target.'/'.$name,$source[$name]);$written[]=$name;
}
} catch(Throwable $e) {
foreach(array_reverse($written)as$name) {
$p=$target.'/'.$name;
if(!is_link($p)&&is_file($p)&&hash_file('sha256',$p)===$m['files'][$name]['after']) {
if($before[$name]===null)unlink($p);else v4FeedbackWrite($p,$before[$name]);
}
}
throw $e;
}
return ['status'=>'installed','backup'=>$backup,'filesChanged'=>$written];
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
// Execute only in Lihrenmoos Symcon. Installs code and readonly feedback mapping; never starts a trial.
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
require_once __DIR__.'/file_installer.php';
$manager=17004;$battery=44234;$locked=false;$changedMapping=false;$oldMapping=null;
$report=['scope'=>'corrected_feedback_trial_disabled','startedAt'=>gmdate('c'),'actuatorPermissionGranted'=>false,'serverUpdateRequired'=>false];
try {
$hashes=json_decode((string)file_get_contents(__DIR__.'/PACKAGE_HASHES.json'),true,32,JSON_THROW_ON_ERROR);
foreach($hashes as$name=>$hash) {
if(str_contains($name,'..')||str_starts_with($name,'/')||is_link(__DIR__.'/'.$name)||hash_file('sha256',__DIR__.'/'.$name)!==$hash)throw new RuntimeException('Vorbereitetes Paket veraendert.');
}
foreach([$manager=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',$battery=>'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}']as$id=>$module) {
if(!IPS_InstanceExists($id)||IPS_GetInstance($id)['ModuleInfo']['ModuleID']!==$module)throw new RuntimeException('Modulzuordnung ungueltig.');
if(IPS_HasChanges($id))throw new RuntimeException('Offene Aenderungen zuerst speichern oder verwerfen.');
}
$beforeM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$beforeB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
if(($beforeM['NetzfahrplanAktiv']??null)!==false || ($beforeM['NetzfahrplanV4RegeltestErlaubt']??false)!==false || ($beforeB['NetzfahrplanV4RegeltestErlaubt']??false)!==false)throw new RuntimeException('Alte Fahrplanausgabe und beide Regeltestfreigaben muessen AUS bleiben.');
$c=json_decode((string)file_get_contents(__DIR__.'/feedback-config.json'),true,32,JSON_THROW_ON_ERROR);
$manifest=json_decode((string)file_get_contents(__DIR__.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR);
if(($c['managerId']??null)!==$manager || ($c['batteryInstanceId']??null)!==$battery || ($c['allowEstimatedForTrial']??null)!==false
|| hash_file('sha256',__DIR__.'/feedback-config.json')!==$manifest['configurationSha256'])throw new RuntimeException('Falsche Rueckmeldekonfiguration.');
$desired=json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION);
$oldMapping=$beforeB['NetzfahrplanV4RueckmeldungKonfiguration']??'{}';
if(!in_array($oldMapping,['','{}',$desired],true))throw new RuntimeException('Abweichende Rueckmeldekonfiguration nicht ueberschrieben.');
$controls=[];foreach(IPS_GetModuleList()as$module)if((IPS_GetModule($module)['Prefix']??'')==='MC')$controls=array_merge($controls,IPS_GetInstanceListByModuleID($module));
if(count($controls)!==1||!function_exists('MC_ReloadModule'))throw new RuntimeException('Module Control nicht eindeutig.');
$locked=IPS_SemaphoreEnter('ENELIX.V4.FeedbackInstall',3000);if(!$locked)throw new RuntimeException('Installation bereits aktiv.');
$report['files']=v4FeedbackFiles(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
if(MC_ReloadModule($controls[0],'Enelix-EMS')===false)throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
$afterM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$afterB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
foreach([[$beforeM,$afterM],[$beforeB,$afterB]]as$pair)foreach($pair[0]as$key=>$value)if(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value)throw new RuntimeException('Bestehender Parameter beim Reload veraendert: '.$key);
if(!array_key_exists('NetzfahrplanV4RueckmeldungKonfiguration',$afterB)||!array_key_exists('NetzfahrplanV4RegeltestErlaubt',$afterM)
||!function_exists('ENELIX_GetV4BatterieRueckmeldung')||!function_exists('ENELIX_GetV4ManagerTestStatus')) {
$report['status']='waiting_for_registration';
echo "Dateien installiert. Modulregistrierung wird abgeschlossen; diesen Aufruf einmal wiederholen. Keine Stellfreigabe.\n";
} else {
if($afterM['NetzfahrplanV4RegeltestErlaubt']!==false||$afterB['NetzfahrplanV4RegeltestErlaubt']!==false)throw new RuntimeException('Unerwartete Testfreigabe.');
if($afterB['NetzfahrplanV4RueckmeldungKonfiguration']!==$desired) {
IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$desired);$changedMapping=true;IPS_ApplyChanges($battery);
}
$finalM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$finalB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
foreach([[$beforeM,$finalM],[$beforeB,$finalB]]as$pair)foreach($pair[0]as$key=>$value)if($key!=='NetzfahrplanV4RueckmeldungKonfiguration'&&(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value))throw new RuntimeException('Bestehender Parameter unerwartet veraendert: '.$key);
if(($finalM['NetzfahrplanAktiv']??null)!==false||($finalM['NetzfahrplanV4RegeltestErlaubt']??null)!==false||($finalB['NetzfahrplanV4RegeltestErlaubt']??null)!==false)throw new RuntimeException('Unerwartete Stellfreigabe nach Installation.');
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($battery),true,32,JSON_THROW_ON_ERROR);
$report['feedback']=array_intersect_key($f,array_flip(['status','kind','method','reason','reasons','estimated','batteryW','gridW','sourceOldestAt','checkedAt','configHash','usableForTrial','canDispatch']));
$report['status']='corrected_feedback_installed_trial_disabled';$report['originalParametersPreserved']=true;
echo "OK: Korrigierte physische Rueckmeldung mit Manager-Vorschau und Batterietreiber verbunden.\n";
echo 'Rueckmeldung: '.($f['status']??'available').'; Modellanteil: '.(($f['estimated']??false)?'ja':'nein').".\n";
if(isset($f['reason']))echo 'Aktueller Hinweis: '.$f['reason']."\n";
echo "Beide Regeltestfreigaben AUS. Keine V4-Ausgabe aktiviert; vorhandene Regelung, Energiekonten und Datensender bleiben bestehen.\n";
}
} catch(Throwable $e) {
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,260);
if($changedMapping && $oldMapping!==null) {
try{IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$oldMapping);IPS_ApplyChanges($battery);$report['mappingRestored']=true;}catch(Throwable $ignored){$report['mappingRestored']=false;}
}
echo 'FEHLER: '.$report['reason']."\nKeine V4-Stellfreigabe erteilt.\n";
} finally {
$report['finishedAt']=gmdate('c');v4FeedbackWrite(__DIR__.'/INSTALL_RESULT.json',json_encode($report,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0644);
if($locked)IPS_SemaphoreLeave('ENELIX.V4.FeedbackInstall');
echo 'FEEDBACK INSTALL REPORT: '.__DIR__."/INSTALL_RESULT.json\n";
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/runtime.php';
/** Creates ONLY dedicated capture objects. No module reload, device settings or archives. */
function v4captureEnsure(int $parent, string $ident, int $type, string $name, string $marker, array &$created): int
{
$existing = v4captureChild($parent, $ident);
if ($existing !== null) {
v4captureAssertObject($existing, $parent, $ident, $type, $marker);
return $existing;
}
$id = match ($type) { 0 => IPS_CreateCategory(), 2 => IPS_CreateVariable(3), 3 => IPS_CreateScript(0) };
if (!is_int($id) || $id <= 0) throw new RuntimeException('Cannot create capture object');
$created[] = $id;
if (!IPS_SetParent($id, $parent) || !IPS_SetIdent($id, $ident) || !IPS_SetName($id, $name) || !IPS_SetInfo($id, $marker)) {
throw new RuntimeException('Cannot label capture object');
}
return $id;
}
function v4captureScript(int $id, string $content, array $created): void
{
$current = IPS_GetScriptContent($id);
if ($current === $content) return;
if (!in_array($id, $created, true)) throw new RuntimeException('Existing capture script changed; not overwritten');
if (!IPS_SetScriptContent($id, $content) || IPS_GetScriptContent($id) !== $content) throw new RuntimeException('Cannot store capture script');
}
function v4captureInstall(string $stage): array
{
if (!function_exists('IPS_GetKernelVersion') || version_compare(IPS_GetKernelVersion(),'8.0','<')) {
throw new RuntimeException('Nur im IP-Symcon-Skripteditor der Testanlage ausfuehren.');
}
$c = v4captureConfig($stage); $marker = v4captureMarker($c);
if (!IPS_InstanceExists($c['managerId'])
|| IPS_GetInstance($c['managerId'])['ModuleInfo']['ModuleID'] !== '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}') {
throw new RuntimeException('Expected manager not found');
}
// Verify layout only; stale/missing sensor updates remain diagnostic raw data, not fake zeros.
foreach (array_merge($c['accounting']['sources'], $c['extraSources']) as $s) {
if (!IPS_VariableExists($s['variableId'])) throw new RuntimeException('Expected source missing: ' . $s['key']);
$o = IPS_GetObject($s['variableId']); $v = IPS_GetVariable($s['variableId']);
if ($o['ParentID'] !== $s['parentId'] || $o['ObjectIdent'] !== $s['ident'] || !in_array($v['VariableType'],[1,2],true)) {
throw new RuntimeException('Source identity changed: ' . $s['key']);
}
}
$data = $stage . '/data';
if (is_link($data) || !is_dir($data) || !is_writable($data)) throw new RuntimeException('Dedicated capture directory not writable');
$key = 'ENELIX.V4.CaptureInstall.' . $c['installationId'];
if (!IPS_SemaphoreEnter($key, 3000)) throw new RuntimeException('Capture installation busy');
$created = []; $script = null; $timerSet = false;
$report = ['startedAt' => gmdate('c'), 'scope' => 'passive_raw_capture_only', 'controlEnabled' => false,
'existingModulesChanged' => false, 'archivesChanged' => false, 'existingHistoriesChanged' => false];
try {
$category = v4captureEnsure(0,'ENELIX_V4_PASSIVE_CAPTURE',0,'V4 - separate Messaufnahme (keine Steuerung)',$marker,$created);
v4captureEnsure($category,'CaptureStatus',2,'Aufzeichnungsstatus (nur lesen)',$marker,$created);
$script = v4captureEnsure($category,'RawCapture',3,'Messwerte alle 30 Sekunden erfassen',$marker,$created);
$stop = v4captureEnsure($category,'StopCapture',3,'Nur diese Messaufnahme stoppen',$marker,$created);
$include = '<?php' . "\n" . 'require_once ' . var_export($stage . '/source/examples/V4DataCapture/runtime.php',true) . ';' . "\n";
v4captureScript($script, $include . 'v4captureRun(' . var_export($stage,true) . ', ' . $category . ');' . "\n", $created);
v4captureScript($stop, $include . 'v4captureStop(' . var_export($stage,true) . ', ' . $category . ');' . "\n", $created);
$first = v4captureRun($stage, $category);
if ($first['status'] !== 'recorded') throw new RuntimeException('Capture not stored: ' . ($first['reason'] ?? $first['status']));
// This new timer never invokes a manager, gateway, battery or Modbus function.
if (!IPS_SetScriptTimer($script, 30)) throw new RuntimeException('Cannot start dedicated capture timer');
$timerSet = true;
$report += ['status' => 'capture_installed', 'categoryId' => $category, 'scriptId' => $script,
'stopScriptId' => $stop, 'intervalSeconds' => 30, 'firstCapture' => $first,
'futureTimerExecutionVerified' => false];
echo "OK: Separate Rohdatenaufnahme eingerichtet, alle 30 Sekunden.\n";
echo 'Erste Aufnahme: ' . $first['capturedAt'] . '; Quellen: ' . $first['sourceCount'] . ".\n";
echo 'Bilanzqualitaet: ' . $first['accountingQuality'] . " (noch keine Trainings-/Stellfreigabe).\n";
echo "Bestehende Manager-, Batterie-, Archiv- und Pollingkonfiguration unveraendert.\n";
} catch (Throwable $e) {
$report['status'] = 'needs_review'; $report['reason'] = substr($e->getMessage(),0,300);
if ($timerSet && $script !== null) IPS_SetScriptTimer($script,0);
// Never delete unrelated objects or stored data during recovery.
echo 'FEHLER: ' . $report['reason'] . "\n";
} finally {
$report['createdObjects'] = $created; $report['finishedAt'] = gmdate('c');
try { v4captureAtomicJson($stage . '/INSTALL_RESULT.json', $report); }
finally { IPS_SemaphoreLeave($key); }
echo 'CAPTURE REPORT: ' . $stage . "/INSTALL_RESULT.json\n";
}
return $report;
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/libs/NetzfahrplanV4Messaufnahme.php';
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme;
/** Native bindings for a dedicated passive capture category. No network or actuator calls. */
function v4captureConfig(string $stage): array
{
if (!is_dir($stage) || is_link($stage) || realpath($stage) !== $stage) throw new RuntimeException('Invalid capture stage');
$manifest = json_decode((string)file_get_contents($stage . '/MANIFEST.json'), true, 64, JSON_THROW_ON_ERROR);
$expected = ['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php',
'source/examples/V4DataCapture/runtime.php','source/examples/V4DataCapture/install.php',
'config.json','USER_REPORTED_INVENTORY.json'];
if (($manifest['scope'] ?? null) !== 'passive_raw_capture_only' || array_keys($manifest['hashes'] ?? []) !== $expected) {
throw new RuntimeException('Unexpected capture manifest');
}
foreach ($expected as $name) {
$p = $stage . '/' . $name;
if (is_link($p) || !is_file($p) || realpath($p) !== $p || hash_file('sha256', $p) !== $manifest['hashes'][$name]) {
throw new RuntimeException('Reviewed capture source changed: ' . $name);
}
}
$c = json_decode((string)file_get_contents($stage . '/config.json'), true, 64, JSON_THROW_ON_ERROR);
if (($c['managerId'] ?? null) !== 17004 || ($c['installationId'] ?? null) !== 'e3a08f9e-af12-4695-99bd-8b51c0520021'
|| ($c['reportedInventorySha256'] ?? null) !== $manifest['hashes']['USER_REPORTED_INVENTORY.json']) {
throw new RuntimeException('Wrong commissioning installation or inventory');
}
return NetzfahrplanV4Messaufnahme::configuration($c);
}
function v4captureMarker(array $c): string
{
return 'ENELIX passive raw capture v1|' . $c['installationId'];
}
function v4captureChild(int $parent, string $ident): ?int
{
$found = null;
foreach (IPS_GetChildrenIDs($parent) as $id) {
if (IPS_GetObject($id)['ObjectIdent'] !== $ident) continue;
if ($found !== null) throw new RuntimeException('Duplicate capture object');
$found = $id;
}
return $found;
}
function v4captureAssertObject(int $id, int $parent, string $ident, int $type, string $marker): void
{
$o = IPS_GetObject($id);
if ($o['ParentID'] !== $parent || $o['ObjectIdent'] !== $ident || $o['ObjectType'] !== $type || $o['ObjectInfo'] !== $marker) {
throw new RuntimeException('Unexpected capture object ownership');
}
if ($type === 2 && IPS_GetVariable($id)['VariableType'] !== 3) throw new RuntimeException('Capture status is not a string');
}
function v4captureAtomicJson(string $file, array $value): void
{
if (is_link($file)) throw new RuntimeException('Capture report symlink refused');
$tmp = tempnam(dirname($file), '.capture-');
if ($tmp === false) throw new RuntimeException('Cannot create report');
try {
$data = json_encode($value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n";
if (file_put_contents($tmp, $data, LOCK_EX) !== strlen($data) || !chmod($tmp, 0640) || !rename($tmp, $file)) {
throw new RuntimeException('Cannot store capture report');
}
} finally { if (is_file($tmp)) unlink($tmp); }
}
function v4captureKeepSnapshot(string $path, string $bytes): void
{
if (is_link($path)) throw new RuntimeException('Metadata symlink refused');
if (is_file($path)) {
if (hash_file('sha256', $path) !== hash('sha256', $bytes)) throw new RuntimeException('Immutable capture metadata differs');
return;
}
$h = fopen($path, 'xb');
if ($h === false) throw new RuntimeException('Cannot store capture metadata');
try {
if (fwrite($h, $bytes) !== strlen($bytes) || !fflush($h) || !chmod($path, 0640)) throw new RuntimeException('Capture metadata write failed');
} finally { fclose($h); }
}
function v4captureRead(int $id): array
{
if (!IPS_VariableExists($id)) throw new RuntimeException('Missing source');
$v = IPS_GetVariable($id); $o = IPS_GetObject($id);
if (!in_array($v['VariableType'], [1,2], true)) throw new RuntimeException('Non-numeric source');
// Capture layer reads all sources twice; source timestamps are NOT replaced with now.
return ['value' => GetValue($id), 'updated' => (int)$v['VariableUpdated'], 'changed' => (int)$v['VariableChanged'],
'parentID' => $o['ParentID'], 'ident' => $o['ObjectIdent']];
}
function v4captureRun(string $stage, int $category): array
{
if (!function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Run inside IP-Symcon only');
$c = v4captureConfig($stage); $marker = v4captureMarker($c);
v4captureAssertObject($category, 0, 'ENELIX_V4_PASSIVE_CAPTURE', 0, $marker);
$statusId = v4captureChild($category, 'CaptureStatus');
if ($statusId === null) throw new RuntimeException('Capture status missing');
v4captureAssertObject($statusId, $category, 'CaptureStatus', 2, $marker);
$lock = 'ENELIX.V4.RawCapture.' . $c['installationId'];
if (!IPS_SemaphoreEnter($lock, 0)) return ['status' => 'busy', 'controlEnabled' => false];
try {
try {
$record = NetzfahrplanV4Messaufnahme::capture($c, 'v4captureRead', static fn(): int => time());
v4captureKeepSnapshot($stage . '/data/mapping-' . $record['mappingSha256'] . '.json', json_encode($c, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
v4captureKeepSnapshot($stage . '/data/inventory-' . $record['reportedInventorySha256'] . '.json', (string)file_get_contents($stage . '/USER_REPORTED_INVENTORY.json'));
$path = NetzfahrplanV4Messaufnahme::append($stage . '/data', $record);
$status = ['status' => 'recorded', 'capturedAt' => $record['capturedAt'], 'journal' => $path,
'sourceCount' => count($record['raw']), 'rawIssueCount' => count($record['issues']),
'accountingQuality' => $record['assessment']['status'] ?? 'unavailable',
'qualityIssues' => $record['assessment']['issues'] ?? $record['issues'],
'baseLoadCandidateW' => $record['assessment']['baseLoadCandidateW'] ?? null,
'sourceSkewSeconds' => $record['assessment']['sourceSkewSeconds'] ?? null,
'trainingEligible' => false, 'controlEnabled' => false, 'measurementBoundaryVerified' => false];
v4captureAtomicJson($stage . '/data/STATUS.json', $status);
} catch (Throwable $e) {
$status = ['status' => 'capture_error', 'at' => gmdate('c'), 'reason' => substr($e->getMessage(),0,250),
'trainingEligible' => false, 'controlEnabled' => false];
try { v4captureAtomicJson($stage . '/data/STATUS.json', $status); } catch (Throwable $ignored) { }
}
// Only this newly created diagnosis variable, never a meter or controller variable.
SetValue($statusId, json_encode($status, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
return $status;
} finally { IPS_SemaphoreLeave($lock); }
}
function v4captureStop(string $stage, int $category): void
{
$c = v4captureConfig($stage); $marker = v4captureMarker($c);
v4captureAssertObject($category, 0, 'ENELIX_V4_PASSIVE_CAPTURE', 0, $marker);
$script = v4captureChild($category, 'RawCapture'); $status = v4captureChild($category, 'CaptureStatus');
if ($script === null || $status === null) throw new RuntimeException('Capture objects missing');
v4captureAssertObject($script, $category, 'RawCapture', 3, $marker);
v4captureAssertObject($status, $category, 'CaptureStatus', 2, $marker);
if (!IPS_SetScriptTimer($script, 0)) throw new RuntimeException('Cannot stop capture timer');
$r = ['status' => 'stopped', 'at' => gmdate('c'), 'oldDataRetained' => true, 'controlEnabled' => false];
SetValue($status, json_encode($r, JSON_THROW_ON_ERROR));
v4captureAtomicJson($stage . '/data/STATUS.json', $r);
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/runtime.php';
function v4viewsEnsure(int $parent,string $ident,int $type,string $name,string $marker,array &$created): int
{
$id=v4viewsChild($parent,$ident);if($id!==null){v4viewsAssert($id,$parent,$ident,$type,$marker);return $id;}
$id=match($type){0=>IPS_CreateCategory(),2=>IPS_CreateVariable(3),3=>IPS_CreateScript(0)};
if(!is_int($id)||$id<=0)throw new RuntimeException('Cannot create observer object');$created[]=$id;
if(!IPS_SetParent($id,$parent)||!IPS_SetIdent($id,$ident)||!IPS_SetName($id,$name)||!IPS_SetInfo($id,$marker))throw new RuntimeException('Cannot label observer object');
return $id;
}
function v4viewsScript(int $id,string $source,array $created): void
{
if(IPS_GetScriptContent($id)===$source)return;
if(!in_array($id,$created,true))throw new RuntimeException('Existing observer script differs; not overwritten');
if(!IPS_SetScriptContent($id,$source)||IPS_GetScriptContent($id)!==$source)throw new RuntimeException('Cannot write observer script');
}
function v4viewsInstall(string $stage): array
{
if(!function_exists('IPS_GetKernelVersion')||version_compare(IPS_GetKernelVersion(),'8.0','<'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
[$c,$p]=v4viewsConfig($stage);$marker=v4viewsMarker($c);
if(!IPS_InstanceExists($c['managerId'])||IPS_GetInstance($c['managerId'])['ModuleInfo']['ModuleID']!=='{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}')throw new RuntimeException('Expected manager missing');
foreach(array_merge($c['accounting']['sources'],$c['extraSources']) as $s){
if(!IPS_VariableExists($s['variableId']))throw new RuntimeException('Expected observation source missing');
$o=IPS_GetObject($s['variableId']);$v=IPS_GetVariable($s['variableId']);
if($o['ParentID']!==$s['parentId']||$o['ObjectIdent']!==$s['ident']||!in_array($v['VariableType'],[1,2],true))throw new RuntimeException('Source identity differs');
}
if(is_link($stage.'/data')||!is_dir($stage.'/data')||!is_writable($stage.'/data')||(fileperms($stage.'/data')&0077)!==0)throw new RuntimeException('Private data directory required');
$lock='ENELIX.V4.ViewsInstall.'.$c['installationId'];if(!IPS_SemaphoreEnter($lock,3000))throw new RuntimeException('Observer installation busy');
$created=[];$script=null;$newTimer=false;$report=['scope'=>'passive_separated_observation','startedAt'=>gmdate('c'),
'oldCollectorChanged'=>false,'modulesChanged'=>false,'pollingChanged'=>false,'socAccountsChanged'=>false,'trainingEnabled'=>false,'controlEnabled'=>false];
try{
$cat=v4viewsEnsure(0,'ENELIX_V4_SEPARATED_OBSERVATION',0,'V4 - getrennte Last und EV-SDL (nur Beobachtung)',$marker,$created);
foreach(['PhysicalLoad'=>'Physische Last - Kandidat, keine Trainingsfreigabe','AllocationModel'=>'EV-SDL-Modell - KEINE separate Messung','ObservationStatus'=>'Beobachtungsstatus'] as $key=>$name)v4viewsEnsure($cat,$key,2,$name,$marker,$created);
$script=v4viewsEnsure($cat,'Observe',3,'Getrennte Messsicht alle 30 Sekunden',$marker,$created);
$stop=v4viewsEnsure($cat,'Stop',3,'Nur diese zusaetzliche Beobachtung stoppen',$marker,$created);
$include='<?php'."\n".'require_once '.var_export($stage.'/source/examples/V4SeparatedObservation/runtime.php',true).';'."\n";
v4viewsScript($script,$include.'v4viewsRun('.var_export($stage,true).', '.$cat.');'."\n",$created);
v4viewsScript($stop,$include.'v4viewsStop('.var_export($stage,true).', '.$cat.');'."\n",$created);
$first=v4viewsRun($stage,$cat);if($first['status']!=='recorded')throw new RuntimeException('First derived observation failed');
if(!IPS_SetScriptTimer($script,30))throw new RuntimeException('Cannot activate observer timer');$newTimer=true;
$report+=['status'=>'observer_installed','categoryId'=>$cat,'scriptId'=>$script,'stopId'=>$stop,'firstObservation'=>$first,'futureTimerExecutionVerified'=>false];
echo "OK: Getrennte Last-/EV-SDL-Beobachtung eingerichtet, alle 30 Sekunden.\n";
echo 'Erste Aufnahme: '.$first['capturedAt'].'; Quellen: '.$first['sourceCount'].".\n";
echo 'Physische Last: '.$first['physicalLoadStatus'].'; EV-/SDL-Modell: '.$first['allocationModelStatus'].".\n";
echo "Keine Trainings-/Stellfreigabe. Bisheriger Sammler, Manager, Gateway, SDL und Polling unveraendert.\n";
}catch(Throwable $e){
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,180);
if($newTimer&&$script!==null)IPS_SetScriptTimer($script,0);
echo 'FEHLER: '.$report['reason']."\n";
}finally{
$report['createdObjects']=$created;$report['finishedAt']=gmdate('c');
try{v4viewsJson($stage.'/INSTALL_RESULT.json',$report);}finally{IPS_SemaphoreLeave($lock);}
echo 'OBSERVATION REPORT: '.$stage."/INSTALL_RESULT.json\n";
}
return $report;
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/** Separate read-only observations. Only newly owned objects/files may be changed. */
function v4viewsConfig(string $stage): array
{
if (!is_dir($stage) || is_link($stage) || realpath($stage)!==$stage || (fileperms($stage)&0077)!==0) throw new RuntimeException('Private observer stage required');
$m=json_decode((string)file_get_contents($stage.'/MANIFEST.json'),true,64,JSON_THROW_ON_ERROR);
$files=['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php','source/libs/NetzfahrplanV4Messsicht.php',
'source/examples/V4SeparatedObservation/runtime.php','source/examples/V4SeparatedObservation/install.php','config.json','policy.json','INVENTORY.json'];
if (($m['scope']??null)!=='passive_separated_observation' || array_keys($m['hashes']??[])!==$files) throw new RuntimeException('Unexpected observer manifest');
foreach($files as $name){$p=$stage.'/'.$name;if(is_link($p)||!is_file($p)||realpath($p)!==$p||hash_file('sha256',$p)!==$m['hashes'][$name])throw new RuntimeException('Observer source drift: '.$name);}
require_once $stage.'/source/libs/NetzfahrplanV4Messsicht.php';
$c=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::configuration(json_decode((string)file_get_contents($stage.'/config.json'),true,64,JSON_THROW_ON_ERROR));
$p=\Belevo\EnelixEMS\NetzfahrplanV4Messsicht::policy(json_decode((string)file_get_contents($stage.'/policy.json'),true,64,JSON_THROW_ON_ERROR),$c);
if($c['reportedInventorySha256']!==$m['hashes']['INVENTORY.json'])throw new RuntimeException('Inventory identity differs');
return [$c,$p];
}
function v4viewsMarker(array $c): string { return 'ENELIX separated physical observer v1|'.$c['installationId']; }
function v4viewsChild(int $parent,string $ident): ?int
{
$result=null;foreach(IPS_GetChildrenIDs($parent) as $id)if(IPS_GetObject($id)['ObjectIdent']===$ident){if($result!==null)throw new RuntimeException('Duplicate observer object');$result=$id;}
return $result;
}
function v4viewsAssert(int $id,int $parent,string $ident,int $type,string $marker): void
{
$o=IPS_GetObject($id);if($o['ParentID']!==$parent||$o['ObjectIdent']!==$ident||$o['ObjectType']!==$type||$o['ObjectInfo']!==$marker)throw new RuntimeException('Observer object ownership mismatch');
if($type===2 && IPS_GetVariable($id)['VariableType']!==3)throw new RuntimeException('Observer diagnosis must be string');
}
function v4viewsRead(int $id): array
{
if(!IPS_VariableExists($id))throw new RuntimeException('Missing numeric source');
$a=IPS_GetVariable($id);$o=IPS_GetObject($id);
if(!in_array($a['VariableType'],[1,2],true))throw new RuntimeException('Non-numeric source');
return ['value'=>GetValue($id),'updated'=>(int)$a['VariableUpdated'],'changed'=>(int)$a['VariableChanged'],'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
}
function v4viewsJson(string $path,array $data): void
{
if(is_link($path)||realpath(dirname($path))!==dirname($path)||(fileperms(dirname($path))&0077)!==0)throw new RuntimeException('Private observer output required');
$tmp=tempnam(dirname($path),'.views-');if($tmp===false)throw new RuntimeException('Cannot create observer report');
try{$json=json_encode($data,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT|JSON_PRESERVE_ZERO_FRACTION)."\n";
if(file_put_contents($tmp,$json,LOCK_EX)!==strlen($json)||!chmod($tmp,0644)||!rename($tmp,$path))throw new RuntimeException('Cannot store observer report');
}finally{if(is_file($tmp))unlink($tmp);}
}
function v4viewsRun(string $stage,int $category): array
{
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Run inside Symcon');
[$c,$policy]=v4viewsConfig($stage);$marker=v4viewsMarker($c);
v4viewsAssert($category,0,'ENELIX_V4_SEPARATED_OBSERVATION',0,$marker);
$ids=[];foreach(['PhysicalLoad','AllocationModel','ObservationStatus'] as $key){
$id=v4viewsChild($category,$key);if($id===null)throw new RuntimeException('Observer object missing');v4viewsAssert($id,$category,$key,2,$marker);$ids[$key]=$id;}
$lock='ENELIX.V4.SeparatedView.'.$c['installationId'];if(!IPS_SemaphoreEnter($lock,0))return ['status'=>'busy','controlEnabled'=>false];
try{
try{
$raw=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::capture($c,'v4viewsRead',static fn():int=>time());
$view=\Belevo\EnelixEMS\NetzfahrplanV4Messsicht::calculate($raw,$c,$policy);
$record=['schemaVersion'=>1,'kind'=>'physical_and_allocation_observation','installationId'=>$c['installationId'],'capturedAt'=>$raw['capturedAt'],
'capture'=>$raw,'views'=>$view,'trainingEligible'=>false,'controlEnabled'=>false];
$dir=$stage.'/data';if(is_link($dir)||realpath($dir)!==$dir||(fileperms($dir)&0077)!==0)throw new RuntimeException('Private data directory required');
$path=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::append($dir,$record);
// Derived-only files readable to authorized agent through a mode-0700 stage/data directory.
// Original collector files and permissions are never touched.
if(!chmod($path,0644))throw new RuntimeException('Cannot expose selected derived observation');
$status=['status'=>'recorded','capturedAt'=>$raw['capturedAt'],'sourceCount'=>count($raw['raw']),'journal'=>$path,
'physicalLoadStatus'=>$view['physicalLoad']['status'],'allocationModelStatus'=>$view['allocationModel']['status'],
'solarOriginStatus'=>$view['solarOrigin']['status'],'trainingEligible'=>false,'controlEnabled'=>false];
v4viewsJson($dir.'/LATEST.json',$record);
SetValue($ids['PhysicalLoad'],json_encode(['captureAt'=>$raw['capturedAt'],'physical'=>$view['physicalLoad'],'solarAcAlternative'=>$view['solarAcAlternative'],'trainingEligible'=>false],JSON_THROW_ON_ERROR));
SetValue($ids['AllocationModel'],json_encode(['captureAt'=>$raw['capturedAt'],'model'=>$view['allocationModel'],'storage'=>$view['storageFeedback'],'canDispatch'=>false],JSON_THROW_ON_ERROR));
}catch(Throwable $e){
$status=['status'=>'observation_error','at'=>gmdate('c'),'errorType'=>get_class($e),'sourceLine'=>$e->getLine(),'trainingEligible'=>false,'controlEnabled'=>false];
// Never leave a stale numeric proposal in the new diagnosis variables.
SetValue($ids['PhysicalLoad'],json_encode($status,JSON_THROW_ON_ERROR));SetValue($ids['AllocationModel'],json_encode($status,JSON_THROW_ON_ERROR));
try{v4viewsJson($stage.'/data/LATEST.json',$status);}catch(Throwable $ignored){}
}
SetValue($ids['ObservationStatus'],json_encode($status,JSON_THROW_ON_ERROR));v4viewsJson($stage.'/data/STATUS.json',$status);return $status;
}finally{IPS_SemaphoreLeave($lock);}
}
function v4viewsStop(string $stage,int $category): void
{
[$c,$p]=v4viewsConfig($stage);$marker=v4viewsMarker($c);v4viewsAssert($category,0,'ENELIX_V4_SEPARATED_OBSERVATION',0,$marker);
$id=v4viewsChild($category,'Observe');if($id===null)throw new RuntimeException('Observer script missing');v4viewsAssert($id,$category,'Observe',3,$marker);
if(!IPS_SetScriptTimer($id,0))throw new RuntimeException('Cannot stop observer');
$s=['status'=>'stopped','at'=>gmdate('c'),'controlEnabled'=>false,'existingCollectorChanged'=>false];
foreach(['PhysicalLoad','AllocationModel','ObservationStatus'] as $key){$var=v4viewsChild($category,$key);if($var===null)throw new RuntimeException('Observer status missing');v4viewsAssert($var,$category,$key,2,$marker);SetValue($var,json_encode($s,JSON_THROW_ON_ERROR));}
v4viewsJson($stage.'/data/STATUS.json',$s);v4viewsJson($stage.'/data/LATEST.json',$s);
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
function v4UnifiedWrite(string $path,string $text,int $mode=0644):void
{
$tmp=tempnam(dirname($path),'.v4-rc-');if($tmp===false)throw new RuntimeException('Temporaere Datei fehlt.');
try {if(file_put_contents($tmp,$text,LOCK_EX)!==strlen($text)||!chmod($tmp,$mode)||!rename($tmp,$path))throw new RuntimeException('Datei konnte nicht geschrieben werden.');}
finally {if(is_file($tmp))unlink($tmp);}
}
function v4UnifiedFiles(string $stage,string $target):array
{
$m=json_decode(file_get_contents($stage.'/MANIFEST.json'),true,64,JSON_THROW_ON_ERROR);
$names=['libs/NetzfahrplanV4Datenarchiv.php','libs/NetzfahrplanV4Bilanzierung.php','libs/ManagerNetzfahrplanV4DatenTrait.php'];
if($m['scope']!=='unified_native_data_only'||array_keys($m['files'])!==$names)throw new RuntimeException('Falscher Installationsumfang.');
$old=[];$new=[];$complete=true;
foreach($names as$name){
$p=$target.'/'.$name;$s=$stage.'/source/'.$name;
if(is_link($p)||is_link($s)||realpath(dirname($p))!==realpath($target).'/libs')throw new RuntimeException('Unerwarteter Dateipfad.');
$new[$name]=file_get_contents($s);token_get_all($new[$name],TOKEN_PARSE);
if(hash('sha256',$new[$name])!==$m['files'][$name]['after'])throw new RuntimeException('Vorbereitete Datei geaendert.');
$old[$name]=is_file($p)?file_get_contents($p):null;$h=$old[$name]===null?null:hash('sha256',$old[$name]);
if($h!==$m['files'][$name]['after'])$complete=false;
if($h!==$m['files'][$name]['before']&&$h!==$m['files'][$name]['after'])throw new RuntimeException('Parallele Modulaenderung; nichts ueberschrieben.');
}
if($complete)return ['status'=>'already_installed'];
foreach($names as$name)if(($old[$name]===null?null:hash('sha256',$old[$name]))!==$m['files'][$name]['before'])throw new RuntimeException('Teilstand zuerst pruefen.');
$backup=$stage.'/backups/'.gmdate('YmdTHis').'-'.bin2hex(random_bytes(4));
if(!mkdir($backup.'/libs',0700,true))throw new RuntimeException('Sicherung fehlt.');
foreach($names as$name)if($old[$name]!==null)v4UnifiedWrite($backup.'/'.$name,$old[$name],0600);
v4UnifiedWrite($backup.'/MANIFEST.json',json_encode($m,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0600);
$written=[];
try {foreach($names as$name){$p=$target.'/'.$name;if((is_file($p)?file_get_contents($p):null)!==$old[$name])throw new RuntimeException('Parallele Dateiaenderung.');v4UnifiedWrite($p,$new[$name]);$written[]=$name;}}
catch(Throwable $e){foreach(array_reverse($written)as$name){$p=$target.'/'.$name;if(hash_file('sha256',$p)!==$m['files'][$name]['after'])continue;if($old[$name]===null)unlink($p);else v4UnifiedWrite($p,$old[$name]);}throw $e;}
return ['status'=>'installed','backup'=>$backup];
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
require_once __DIR__.'/file_installer.php';
$manager=17004;$lock=false;$report=['scope'=>'unified_native_data_only','startedAt'=>gmdate('c'),'newActuatorPermission'=>false,'cursorReset'=>false,'outboxRewritten'=>false];
try {
$package=json_decode(file_get_contents(__DIR__.'/PACKAGE_HASHES.json'),true,64,JSON_THROW_ON_ERROR);
foreach($package as$name=>$hash)if(str_contains($name,'..')||str_starts_with($name,'/')||is_link(__DIR__.'/'.$name)||hash_file('sha256',__DIR__.'/'.$name)!==$hash)throw new RuntimeException('Installationspaket wurde geaendert.');
if(!IPS_InstanceExists($manager)||IPS_GetInstance($manager)['ModuleInfo']['ModuleID']!=='{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}')throw new RuntimeException('Erwarteter Manager fehlt.');
if(IPS_HasChanges($manager))throw new RuntimeException('Offene Manageraenderungen zuerst speichern oder verwerfen.');
if(IPS_GetProperty($manager,'NetzfahrplanAktiv')!==false||IPS_GetProperty($manager,'NetzfahrplanV4MessdatenAktiv')!==true)throw new RuntimeException('Erwartet: Datenaufnahme EIN, alter Netzfahrplan AUS.');
$controls=[];foreach(IPS_GetModuleList()as$mid)if((IPS_GetModule($mid)['Prefix']??'')==='MC')$controls=array_merge($controls,IPS_GetInstanceListByModuleID($mid));
if(count($controls)!==1||!function_exists('MC_ReloadModule'))throw new RuntimeException('Module Control nicht eindeutig.');
$lock=IPS_SemaphoreEnter('ENELIX.V4.UnifiedRelease',3000);if(!$lock)throw new RuntimeException('Installation bereits aktiv.');
$before=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$report['files']=v4UnifiedFiles(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
// No staged application classes are loaded into this script context.
if(MC_ReloadModule($controls[0],'Enelix-EMS')===false)throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
$after=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
foreach($before as$key=>$value)if(!array_key_exists($key,$after)||$after[$key]!==$value)throw new RuntimeException('Bestehender Parameter unerwartet geaendert: '.$key);
$report['status']='native_data_candidate_installed';$report['parametersUnchanged']=true;
echo "OK: Einheitliche Messdatenkennung und dauerhafte Versandbestaetigung installiert. Archivierung vorbereitet, standardmaessig AUS.\n";
echo "Der bestehende Datentimer setzt den Versand fort. Sendezeiger und Originalbestand wurden nicht umgeschrieben.\n";
echo "Keine V4-Stellfreigabe und kein Wechsel der Prognosequelle.\n";
} catch(Throwable $e) {
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,250);
echo 'FEHLER: '.$report['reason']."\nKeine neue Stellfreigabe.\n";
} finally {
$report['finishedAt']=gmdate('c');v4UnifiedWrite(__DIR__.'/INSTALL_RESULT.json',json_encode($report,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0644);
if($lock)IPS_SemaphoreLeave('ENELIX.V4.UnifiedRelease');
echo 'UNIFIED MANAGER REPORT: '.__DIR__."/INSTALL_RESULT.json\n";
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
require_once __DIR__.'/NetzfahrplanV4Geraeteabruf.php';
/** Shared confirmed physical reading used by receiver and battery before a trial command. */
trait BatterieNetzfahrplanV4RueckmeldungTrait
{
private function registriereV4Rueckmeldung(): void
{
$this->RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}');
$this->RegisterTimer('NetzfahrplanV4RueckmeldungAktualisieren', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', 0);");
}
private function konfiguriereV4Rueckmeldung(): void
{
$this->SetBuffer('V4ConfirmedFeedback', '');
$this->SetBuffer('V4ConfirmedFeedbackConfig', '');
$this->SetBuffer('V4ConfirmedFeedbackMono', '');
$configured = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration')), ['', '{}'], true);
$this->SetTimerInterval('NetzfahrplanV4RueckmeldungAktualisieren', $configured ? 1000 : 0);
}
/** No transport I/O under the battery control lock. Original sample times never move. */
private function v4BatterieRueckmeldung(): array
{
$json = $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
$sample = json_decode($this->GetBuffer('V4ConfirmedFeedback'), true);
$mono = (float) $this->GetBuffer('V4ConfirmedFeedbackMono');
$now = time();$age = hrtime(true) / 1e9 - $mono;
if (!is_array($sample) || !is_int($sample['checkedAt'] ?? null)
|| $sample['checkedAt'] > $now || $now - $sample['checkedAt'] > 2
|| $age < 0 || $age > 2
|| $this->GetBuffer('V4ConfirmedFeedbackConfig') !== hash('sha256', $json)) {
throw new \RuntimeException('feedback_confirmed_cache_stale_or_invalid');
}
$config = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
if (($config['batteryInstanceId'] ?? null) !== $this->InstanceID
|| !in_array($config['managerId'] ?? 0, $this->zugeordneteManagerIDs(), true)) {
throw new \RuntimeException('feedback_cache_binding_invalid');
}
foreach ($config['sources'] as $source) {
if (!IPS_VariableExists($source['variableId'])) throw new \RuntimeException('feedback_variable_missing');
$object = IPS_GetObject($source['variableId']);
if ($object['ParentID'] !== $source['parentId'] || $object['ObjectIdent'] !== $source['ident']) {
throw new \RuntimeException('feedback_cache_source_changed');
}
}
return $sample;
}
/** Independent acquisition; never waits behind another read or takes the actuator lock. */
private function aktualisiereV4Rueckmeldung(): void
{
$json=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
if (strlen($json)>65536) throw new \RuntimeException('feedback_configuration_too_large');
$c=json_decode($json,true,32,JSON_THROW_ON_ERROR);
if (!is_array($c) || ($c['batteryInstanceId']??null)!==$this->InstanceID) throw new \RuntimeException('feedback_battery_binding_invalid');
if (!in_array($c['managerId']??0,$this->zugeordneteManagerIDs(),true)) throw new \RuntimeException('feedback_manager_binding_invalid');
$lock='ENELIX.V4.Feedback.'.$this->InstanceID;
if (!IPS_SemaphoreEnter($lock,0)) return;
try {
$reader=static function(int $id):array {
if (!IPS_VariableExists($id)) throw new \RuntimeException('feedback_variable_missing');
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);$value=GetValue($id);
return ['value'=>$value,'updated'=>$v['VariableUpdated'],'changed'=>$v['VariableChanged'],
'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
};
$result=null;
for ($attempt=1;$attempt<=3;++$attempt) {
try {
$result=NetzfahrplanV4Geraeteabruf::snapshot(
$c,
static function(int $id):string {
if (!IPS_InstanceExists($id)) throw new \RuntimeException('feedback_parent_instance_missing');
return (string)(IPS_GetInstance($id)['ModuleInfo']['ModuleID']??'');
},
static function(int $id,string $module):bool {
if ($module===NetzfahrplanV4Geraeteabruf::MBUS_DEVICE) {
if (!function_exists('MBUS_UpdateValues') && !function_exists(__NAMESPACE__.'\\MBUS_UpdateValues')) throw new \RuntimeException('feedback_mbus_refresh_unavailable');
return MBUS_UpdateValues($id)===true;
}
if (in_array($module,[NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE,NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS],true)) {
if (!function_exists('ModBus_RequestRead') && !function_exists(__NAMESPACE__.'\\ModBus_RequestRead')) throw new \RuntimeException('feedback_modbus_refresh_unavailable');
return ModBus_RequestRead($id)===true;
}
return false;
},
$reader,
static fn():int=>time(),
static function(int $microseconds):void { usleep($microseconds); }
);
break;
} catch (\InvalidArgumentException $e) {
if (!str_starts_with($e->getMessage(),'feedback_source_changed_after_confirmed_read:') || $attempt>=3) throw $e;
usleep(150000);
}
}
if (!is_array($result)) throw new \RuntimeException('feedback_snapshot_unavailable');
$this->SetBuffer('V4ConfirmedFeedback',json_encode($result,JSON_THROW_ON_ERROR));
$this->SetBuffer('V4ConfirmedFeedbackConfig', hash('sha256', $json));
$this->SetBuffer('V4ConfirmedFeedbackMono', (string) (hrtime(true) / 1e9));
} catch (\Throwable $error) {
$this->SetBuffer('V4ConfirmedFeedback', '');
throw $error;
} finally {
IPS_SemaphoreLeave($lock);
}
}
/** Read only. A returned current value is NOT actuator permission. */
public function GetV4BatterieRueckmeldung(): string
{
try {
try { $r=$this->v4BatterieRueckmeldung(); }
catch (\Throwable) {
$this->aktualisiereV4Rueckmeldung();
$r=$this->v4BatterieRueckmeldung();
}
}
catch (\Throwable $e) { $r=['kind'=>'local_physical_feedback','status'=>'unavailable','reason'=>substr($e->getMessage(),0,200),
'batteryW'=>null,'gridW'=>null,'usableForTrial'=>false,'canDispatch'=>false,'deviceReadConfirmed'=>false]; }
return json_encode($r,JSON_THROW_ON_ERROR);
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
require_once __DIR__ . '/BatterieNetzfahrplanV4RueckmeldungTrait.php';
/** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */
trait BatterieNetzfahrplanV4TestTrait
{
use BatterieNetzfahrplanV4RueckmeldungTrait;
private function registriereV4BatterieTest(): void
{
$this->registriereV4Rueckmeldung();
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4AktivtestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt', false);
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
$this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', '');
$this->RegisterAttributeString('NetzfahrplanV4TestStatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4TestWatchdog', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4TestWatchdog', 0);");
}
private function v4BatterieTestSitzung(): array
{
$s = json_decode($this->GetBuffer('V4BatteryTrialSession') ?: '{}', true);
return is_array($s) ? $s : [];
}
private function v4BatterieTestAktiv(): bool
{
return ($this->v4BatterieTestSitzung()['active'] ?? false) === true;
}
/** Local test arming by the bound manager; never called by ordinary forecast traffic. */
public function V4BatterieTestStarten(string $json): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->v4BatterieStartIntern($json); }
finally { IPS_SemaphoreLeave($lock); }
}
private function v4BatterieStartIntern(string $json): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Batterietest lokal nicht erlaubt.');
$s = json_decode($json, true, 32, JSON_THROW_ON_ERROR);$now = time();
$activeTest = ($s['kind'] ?? null) === 'local_active_test_consent';
if (!is_array($s)
|| !in_array($s['kind'] ?? null, ['local_control_trial_consent', 'local_active_test_consent'], true)
|| ($s['batteryInstanceId'] ?? null) !== $this->InstanceID || $this->v4BatterieTestAktiv()) {
throw new RuntimeException('Batterie-Testanmeldung ungueltig oder bereits aktiv.');
}
NetzfahrplanV4Regeltest::uuid($s['sessionId'] ?? null);
if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $s['sessionId']) throw new RuntimeException('Testsitzung bereits beendet/verwendet.');
NetzfahrplanV4Regeltest::uuid($s['installationId'] ?? null);
$manager = NetzfahrplanV4Regeltest::integer($s['managerId'] ?? null, 'Manager', 1, 99999);
$managerPermission = $activeTest ? 'NetzfahrplanV4AktivtestErlaubt' : 'NetzfahrplanV4RegeltestErlaubt';
if (!in_array($manager, $this->zugeordneteManagerIDs(), true)
|| IPS_GetProperty($manager, $managerPermission) !== true) {
throw new RuntimeException('Manager nicht fuer Batterietest freigegeben/zugeordnet.');
}
$evidence = $this->ReadPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis');
$watchdogWaived = $activeTest
&& $this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')
&& ($s['watchdogWaived'] ?? null) === true
&& ($s['actuatorWatchdogEvidenceId'] ?? null) === 'explicit_test_plant_watchdog_waiver';
if (!$watchdogWaived && (strlen($evidence) < 8 || $evidence !== ($s['actuatorWatchdogEvidenceId'] ?? null))) {
throw new RuntimeException('Externer Geraete-Watchdog muss separat nachgewiesen sein.');
}
$maxDuration = $activeTest
? NetzfahrplanV4Regeltest::MAX_ACTIVE_TEST_SECONDS
: NetzfahrplanV4Regeltest::MAX_TEST_SECONDS;
$end = NetzfahrplanV4Regeltest::integer($s['expiresAt'] ?? null, 'Testende', $now + 1, $now + $maxDuration);
$s['maxChargeW'] = NetzfahrplanV4Regeltest::number($s['maxChargeW'] ?? null, 'Testladen', 1, 1e9);
$s['maxDischargeW'] = NetzfahrplanV4Regeltest::number($s['maxDischargeW'] ?? null, 'Testentladen', 1, 1e9);
if (!is_string($s['assetId'] ?? null) || $s['assetId'] === '') throw new RuntimeException('Batterie-ID fehlt.');
if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) {
throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.');
}
$feedback = $this->v4BatterieRueckmeldung();
$now = time();
if ($now >= $end) throw new RuntimeException('Testsitzung waehrend Geraeteabruf abgelaufen.');
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']);
$s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now;
$s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now;
$s['commandMonotonicDeadline'] = hrtime(true) / 1e9 + 10;
$this->SetBuffer('V4BatteryTrialCommand', '{}');
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
// Old ordinary values may not resume after the test without a NEW manager message.
$this->setzeZustand('SollwertGueltig', false);
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 1000);
$this->WriteAttributeString('NetzfahrplanV4TestStatus', '{"status":"armed_waiting_command","physicalResponseVerified":false}');
}
public function V4BatterieTestBefehl(string $json): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->v4BatterieBefehlIntern($json); }
finally { IPS_SemaphoreLeave($lock); }
}
private function v4BatterieBefehlIntern(string $json): void
{
$s = $this->v4BatterieTestSitzung();$now = time();$mono = hrtime(true) / 1e9;
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| (($s['kind'] ?? null) === 'local_active_test_consent'
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
throw new RuntimeException('Batterietest gesperrt.');
}
$c = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
NetzfahrplanV4Regeltest::pruefeBatteriebefehl($c, $s, $now, $mono);
$s['lastSequence'] = $c['sequence'];$s['lastWallClockAt'] = $now;
$s['commandMonotonicDeadline'] = $mono + min(10, $c['expiresAt'] - $now);
$this->SetBuffer('V4BatteryTrialCommand', json_encode($c, JSON_THROW_ON_ERROR));
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
$this->aktualisiereIntern(true);
if (!$this->v4BatterieTestAktiv() || $this->ReadAttributeString('Registerfehler') !== '') {
$state = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
$detail = $this->ReadAttributeString('Registerfehler') ?: ($state['reason'] ?? '');
throw new RuntimeException('Batterie konnte Testbefehl nicht sicher uebernehmen: ' . substr($detail, 0, 300));
}
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode([
'status' => 'controller_command_accepted', 'sessionId' => $s['sessionId'],
'sourceShadowPlanId' => $c['sourceShadowPlanId'], 'sequence' => $c['sequence'],
'commandExpiresAt' => $c['expiresAt'], 'actualRequestedW' => $this->ReadAttributeInteger('LetzterSollwert'),
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->beendeV4BatterieTest('Befehl abgelehnt: ' . substr($e->getMessage(), 0, 500), true);
throw $e;
}
}
/** Explicit local abort or ApplyChanges: revoke before any register action. */
private function beendeV4BatterieTest(string $reason, bool $writeNow): void
{
$oldStatus = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
$active = $this->v4BatterieTestAktiv() || in_array($oldStatus['status'] ?? '', ['controller_command_accepted', 'armed_waiting_command', 'stop_pending', 'stop_failed'], true);
$this->SetBuffer('V4BatteryTrialSession', '{}');$this->SetBuffer('V4BatteryTrialCommand', '{}');
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 0);
if (!$active) return;
$this->setzeZustand('SollwertGueltig', false);$this->setzeZustand('Sollleistung', 0);
$report = ['status' => 'stop_pending', 'reason' => $reason, 'physicalResponseVerified' => false];
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
if ($writeNow) {
try { $this->schreibeRegister(0, true);$report['status'] = 'stopped'; }
catch (Throwable $e) {
$report['status'] = 'stop_failed';
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
throw $e;
}
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
}
}
public function V4BatterieTestStoppen(): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->beendeV4BatterieTest('Lokaler Testabbruch', true); }
finally { IPS_SemaphoreLeave($lock); }
}
public function GetV4BatterieTestStatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4TestStatus');
}
/** Called inside the existing battery update, immediately before writing registers. */
private function v4BatterieTestZiel(array $measurements, bool $available, int $now): ?int
{
if (!$this->v4BatterieTestAktiv()) return null;
$s = $this->v4BatterieTestSitzung();
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| (($s['kind'] ?? null) === 'local_active_test_consent'
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
throw new RuntimeException('Lokale Testfreigabe entzogen.');
}
$feedback = $this->v4BatterieRueckmeldung();
// The confirmed device read may cross a wall-clock second. All freshness,
// watchdog and switching decisions below use the post-read clocks.
$now = time();$mono = hrtime(true) / 1e9;
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
$c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true);
if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.');
if (!$c) {
if ($mono >= $s['commandMonotonicDeadline']) throw new RuntimeException('Kein Startbefehl innerhalb Watchdog.');
return 0;
}
$w = NetzfahrplanV4Regeltest::batterieZiel($c, $s, $measurements, [
'available' => $available, 'reserve' => $this->ReadPropertyFloat('ReserveLadezustand'),
'minimum' => $this->ReadPropertyFloat('MindestLadezustand'),
'blocked' => (bool) $this->leseZustand('HystereseAktiv'),
'rechargeLimitW' => $this->ReadPropertyBoolean('NachladenMitMaximalleistung') ? null : $this->ReadPropertyInteger('MaximaleNachladeleistung'),
], $now, $mono);
// Respect switching lock for increases/reversals; safety reductions and zero are immediate.
$last = $this->ReadAttributeInteger('LetzterSollwert');
if ($now < $this->ReadAttributeInteger('AenderungFreigabeAb') && $w !== $last) {
if ($last * $w < 0) $w = 0;
elseif (abs($w) > abs($last)) $w = $last;
}
// Final hardware-policy/ramp clipping must not invalidate the binding or grid limits.
if (($c['feedbackConfigHash'] ?? null) !== $feedback['configHash']) throw new RuntimeException('feedback_configuration_changed');
$expectedGrid = $feedback['gridW'] - $feedback['batteryW'] + $w;
if (($c['importLimitW'] !== null && $expectedGrid > $c['importLimitW'] + 1)
|| ($c['exportLimitW'] !== null && -$expectedGrid > $c['exportLimitW'] + 1)) {
throw new RuntimeException('final_battery_clamp_cannot_meet_grid_limit');
}
$s['lastWallClockAt'] = $now;
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
return $w;
} catch (Throwable $e) {
$this->beendeV4BatterieTest($e->getMessage(), false);
return 0; // revoke stale value even if ordinary cached setpoint existed
}
}
}
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
/** Locally approved, time-boxed operation of validated V4 shadow plans on a test plant. */
trait ManagerNetzfahrplanV4AktivtestTrait
{
private const V4_AKTIVTEST_DAUER_SEKUNDEN = 172800;
private const V4_AKTIVTEST_WATCHDOG_VERZICHT = 'explicit_test_plant_watchdog_waiver';
private function registriereV4ManagerAktivtest(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4AktivtestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt', false);
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestBeginn', 0);
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestEnde', 0);
$this->RegisterVariableBoolean(
'NetzfahrplanV4Aktivtest',
'V4 Prognosemanager aktiv (Testbetrieb)',
'~Switch',
952
);
$this->EnableAction('NetzfahrplanV4Aktivtest');
}
public function V4ManagerAktivtestSchalten(bool $aktiv): void
{
if (!$aktiv) {
$this->SetValue('NetzfahrplanV4Aktivtest', false);
$this->V4ManagerTestStoppen();
return;
}
if ($this->GetValue('NetzfahrplanV4Aktivtest') && $this->v4ManagerTestSession()) {
return;
}
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) {
throw new RuntimeException('Regelung beschaeftigt.');
}
$armedBattery = null;
try {
$this->v4ManagerTestBedingungen();
if (!$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')) {
throw new RuntimeException('Aktivtest oder Watchdog-Verzicht ist lokal nicht freigegeben.');
}
if ($this->v4ManagerTestSession()) {
throw new RuntimeException('Eine V4-Regelsitzung ist bereits aktiv.');
}
$envelope = json_decode(
$this->ReadAttributeString('NetzfahrplanV4Empfang'),
true,
64,
JSON_THROW_ON_ERROR
);
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
throw new RuntimeException('Kein gueltiger aktueller V4-Plan empfangen.');
}
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 32, JSON_THROW_ON_ERROR);
if (!is_array($assets) || count($assets) !== 1 || !is_string($assets[0]['ID'] ?? null)) {
throw new RuntimeException('Aktivtest braucht genau eine eindeutig konfigurierte Batterie.');
}
$asset = $assets[0];
$batteryID = $this->v4AktivtestBatterieinstanz($asset);
$now = time();
$start = $this->ReadAttributeInteger('NetzfahrplanV4AktivtestBeginn');
$until = $this->ReadAttributeInteger('NetzfahrplanV4AktivtestEnde');
if ($start === 0 && $until === 0) {
$start = $now;
$until = $now + self::V4_AKTIVTEST_DAUER_SEKUNDEN;
$this->WriteAttributeInteger('NetzfahrplanV4AktivtestBeginn', $start);
$this->WriteAttributeInteger('NetzfahrplanV4AktivtestEnde', $until);
}
if ($start <= 0 || $until <= $now
|| $until - $start > self::V4_AKTIVTEST_DAUER_SEKUNDEN) {
throw new RuntimeException('Die einmalige 48-Stunden-Aktivtestfreigabe ist abgelaufen.');
}
[$maxChargeW, $maxDischargeW] = $this->v4AktivtestLeistungsgrenzen($operation, $asset['ID']);
$session = $this->v4AktivtestUuid();
$s = [
'kind' => 'local_active_test_consent',
'enabled' => true,
'legacyEnabled' => false,
'sessionId' => $session,
'installationId' => $this->ReadAttributeString('LizenzInstallationID'),
'managerId' => $this->InstanceID,
'batteryInstanceId' => $batteryID,
'assetId' => $asset['ID'],
'startedAt' => $start,
'expiresAt' => $until,
'maxChargeW' => $maxChargeW,
'maxDischargeW' => $maxDischargeW,
'watchdogWaived' => true,
'actuatorWatchdogEvidenceId' => self::V4_AKTIVTEST_WATCHDOG_VERZICHT,
'feedbackConfigHash' => $operation['localFeedback'][$asset['ID']]['configHash'] ?? '',
'gridLimits' => NetzfahrplanV4Planpruefung::kontext($operation)['limits'],
'sequence' => 1,
'lastWallClockAt' => $now,
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now,
];
$response = $this->v4AktivtestAntwort($envelope['response'], $s, $now);
NetzfahrplanV4Regeltest::befehl(
$response,
$operation,
$actual,
$s,
$now,
$envelope['receivedAt']
);
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $session);
$armedBattery = $batteryID;
$this->v4ManagerBatterieAktion($batteryID, 'NetzfahrplanV4TestStart', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetTimerInterval('NetzfahrplanV4TestTick', 2000);
$this->SetValue('NetzfahrplanV4Aktivtest', true);
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
'status' => 'active_test_started',
'sessionId' => $session,
'expiresAt' => $until,
'watchdogWaivedForTestPlant' => true,
'physicalResponseVerified' => false,
], JSON_THROW_ON_ERROR));
} catch (Throwable $error) {
$this->SetValue('NetzfahrplanV4Aktivtest', false);
$this->SetBuffer('V4ManagerTrialSession', '{}');
$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
if ($armedBattery !== null) {
$this->v4ManagerBatterieAktion($armedBattery, 'NetzfahrplanV4TestStop', 0);
}
throw $error;
} finally {
IPS_SemaphoreLeave($lock);
}
}
private function v4AktivtestLeistungsgrenzen(array $operation, string $assetId): array
{
foreach ($operation['batteries'] ?? [] as $battery) {
if (($battery['id'] ?? null) !== $assetId) {
continue;
}
return [
NetzfahrplanV4Regeltest::number($battery['maxChargeW'] ?? null, 'Anlagen-Ladegrenze', 1, 1e9),
NetzfahrplanV4Regeltest::number($battery['maxDischargeW'] ?? null, 'Anlagen-Entladegrenze', 1, 1e9),
];
}
throw new RuntimeException('Dynamische Leistungsgrenzen der Testbatterie fehlen.');
}
private function v4AktivtestBatterieinstanz(array $asset): int
{
$matches = [];
foreach ($this->aktiveVerbraucherIDs() as $id) {
if (!IPS_InstanceExists($id)
|| IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
continue;
}
if (($asset['SOCVariableID'] ?? null) === IPS_GetProperty($id, 'LadezustandVariableID')
&& ($asset['LeistungVariableID'] ?? null) === IPS_GetProperty($id, 'IstleistungVariableID')) {
$matches[] = $id;
}
}
if (count($matches) !== 1) {
throw new RuntimeException('Physische Batteriezuordnung ist nicht eindeutig.');
}
return $matches[0];
}
private function v4AktivtestAntwort(array $response, array $session, int $now): array
{
$plan = $response['plan'] ?? null;
if (!is_array($plan)) {
throw new RuntimeException('V4-Plan fehlt.');
}
$validUntil = min($now + 90, (int) $session['expiresAt']);
$quality = $plan['inputQuality'] ?? [];
$response['controlledTrial'] = [
'version' => 1,
'kind' => 'controlled_trial_authority',
'sourcePlanRemainsShadow' => true,
'installationId' => $session['installationId'],
'sessionId' => $session['sessionId'],
'managerId' => $session['managerId'],
'batteryInstanceId' => $session['batteryInstanceId'],
'assetId' => $session['assetId'],
'sourceShadowPlanId' => $plan['planId'] ?? null,
'revision' => $plan['configRevision'] ?? null,
'family' => $plan['sourceFamily'] ?? null,
'issuedAt' => gmdate('c', (int) $session['startedAt']),
'expiresAt' => gmdate('c', (int) $session['expiresAt']),
'checkedAt' => gmdate('c', $now),
'validUntil' => gmdate('c', $validUntil),
'maxChargeW' => $session['maxChargeW'],
'maxDischargeW' => $session['maxDischargeW'],
'accountingEvidenceId' => $quality['accountingEvidenceId'] ?? null,
'actuatorWatchdogEvidenceId' => self::V4_AKTIVTEST_WATCHDOG_VERZICHT,
'acceptEstimatedPeak' => true,
'controlContext' => $plan['controlContext'] ?? null,
];
return $response;
}
private function v4AktivtestUuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4)
. '-' . substr($hex, 16, 4) . '-' . substr($hex, 20);
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
require_once __DIR__ . '/NetzfahrplanV4Datenarchiv.php';
/** Application measurement acquisition + acknowledged durable delivery. No device writes. */
trait ManagerNetzfahrplanV4DatenTrait
{
private function registriereNetzfahrplanV4Daten(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4MessdatenAktiv', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4ArchivAktiv', false);
$this->RegisterPropertyString('NetzfahrplanV4Messkonfiguration', '{}');
$this->RegisterPropertyString('NetzfahrplanV4Datensatz', '');
$this->RegisterAttributeString('NetzfahrplanV4DatenCursor', '{}');
$this->RegisterAttributeString('NetzfahrplanV4LetzteDatenquittung', '{}');
$this->RegisterAttributeString('NetzfahrplanV4ArchivTag', '');
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersion', 0);
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
$this->RegisterAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
$this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'V4 Messdaten und Modellversorgung', '', 199);
$this->RegisterTimer('NetzfahrplanV4DatenErfassen', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4DatenErfassen', true);");
}
private function konfiguriereNetzfahrplanV4Daten(): void
{
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 0);
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
try {
$this->v4DatenKonfiguration();
if ($this->ReadAttributeInteger('NetzfahrplanV4DatenVersion')<1) {
// One retry after the data-only upgrade, never a cursor reset or a device write.
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersion', 1);
}
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 30000);
} catch (\Throwable $e) {
$this->v4DatenStatus(['status'=>'configuration_error','errorType'=>get_class($e)]);
}
}
private function v4DatenKonfiguration(): array
{
$id=$this->ReadAttributeString('LizenzInstallationID');
$dataset=$this->ReadPropertyString('NetzfahrplanV4Datensatz');
if (!preg_match('/^[a-zA-Z0-9_-]{1,80}$/D',$dataset)
|| !preg_match('/^[0-9a-f-]{36}$/D',$id)) throw new \RuntimeException('Dataset/installation not configured');
$c=json_decode($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration'),true,64,JSON_THROW_ON_ERROR);
$c=NetzfahrplanV4Messaufnahme::configuration($c);
if ($c['installationId']!==$id || ($c['managerId']??null)!==$this->InstanceID) throw new \RuntimeException('Measurement mapping belongs to another manager');
return $c;
}
private function v4DatenVerzeichnis(): string
{
$base=rtrim(IPS_GetKernelDir(),'/').'/data/enelix-v4';
$directory=$base.'/'.$this->ReadAttributeString('LizenzInstallationID');
// Fixed application-owned location; no configurable path or arbitrary file access.
foreach ([$base,$directory] as $p) {
if (is_link($p)) throw new \RuntimeException('Measurement directory symlink refused');
if (!is_dir($p) && !mkdir($p,0700,true) && !is_dir($p)) throw new \RuntimeException('Measurement directory unavailable');
if ((fileperms($p)&0007)!==0) throw new \RuntimeException('Measurement directory must remain private');
}
return $directory;
}
private function v4DatenStatus(array $data): void
{
$receipt=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
$data['lastAcknowledgement']=$receipt;
$this->SetValue('NetzfahrplanV4Datenstatus',json_encode($data+['checkedAt'=>gmdate('c'),
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'controlEnabled'=>false],JSON_THROW_ON_ERROR));
}
public function ErfasseNetzfahrplanV4Daten(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
$lock='ENELIX.V4.ApplicationData.'.$this->InstanceID;
if (!IPS_SemaphoreEnter($lock,0)) return;
try {
$c=$this->v4DatenKonfiguration();
$reader=static function(int $id):array {
if (!IPS_VariableExists($id)) throw new \RuntimeException('Missing measurement');
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);
if (!in_array($v['VariableType'],[1,2],true)) throw new \RuntimeException('Numeric source required');
return ['value'=>GetValue($id),'updated'=>(int)$v['VariableUpdated'],'changed'=>(int)$v['VariableChanged'],
'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
};
$dir=$this->v4DatenVerzeichnis();
$archiveResult=null;
if ($this->ReadPropertyBoolean('NetzfahrplanV4ArchivAktiv') && $this->ReadAttributeString('NetzfahrplanV4ArchivTag')!==gmdate('Y-m-d')) {
try {
$archiveCursor=json_decode($this->ReadAttributeString('NetzfahrplanV4DatenCursor'),true,32,JSON_THROW_ON_ERROR);
$archiveResult=NetzfahrplanV4Datenarchiv::run($dir,$archiveCursor,time());
if ($archiveResult['archived']<2 && ($archiveResult['status']??null)!=='writer_busy')$this->WriteAttributeString('NetzfahrplanV4ArchivTag',gmdate('Y-m-d'));
} catch (\Throwable $e) { $archiveResult=['status'=>'archive_error','errorType'=>get_class($e),'originalsRetained'=>true]; }
}
try {
$r=NetzfahrplanV4Messaufnahme::capture($c,$reader,static fn():int=>time());
NetzfahrplanV4Messaufnahme::append($dir,$r);
$result=['status'=>'recorded','capturedAt'=>$r['capturedAt'],'sourceCount'=>count($r['raw']),
'rawIssues'=>count($r['issues']),'measurementBoundaryVerified'=>false];
} catch (\Throwable $e) {
// Storage/capture failure must not prevent retrying the already durable outbox.
$result=['status'=>'capture_error','errorType'=>get_class($e),'dataRetained'=>true];
}
if ($archiveResult!==null)$result['archive']=$archiveResult;
$last=$this->ReadAttributeInteger('NetzfahrplanV4DatenVersuch');
$failures=min(5,$this->ReadAttributeInteger('NetzfahrplanV4DatenFehlerzahl'));
// Planner inputs expire after 120 s. Drain an acknowledged backlog faster, but a
// failed HTTP attempt still backs off to 60 s. Once caught up, normal upload is 60 s.
$lastAck=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
$acceptedEpoch=strtotime((string)($lastAck['acceptedThrough']??''));
$backlog=$acceptedEpoch===false || time()-$acceptedEpoch>120;
$retry=$failures>0?60:($backlog?15:60);
if (time()>=$last+$retry) {
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch',time());
try {
$result['delivery']=$this->v4DatenUebertragen($dir);
if (($result['delivery']['status']??null)==='acknowledged') {
$this->WriteAttributeString('NetzfahrplanV4LetzteDatenquittung',json_encode($result['delivery']+['receivedAt'=>gmdate('c')],JSON_THROW_ON_ERROR));
}
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',0);
} catch (\Throwable $e) {
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',$failures+1);
$httpCode=(int)$e->getCode();
$result['delivery']=['status'=>'retry_pending','errorType'=>get_class($e),
'httpStatus'=>$httpCode>=100&&$httpCode<=599?$httpCode:null,'dataRetained'=>true,
'nextAttemptAt'=>gmdate('c',time()+60)];
}
} else $result['delivery']=['status'=>'scheduled','nextAttemptAt'=>gmdate('c',$last+$retry)];
$this->v4DatenStatus($result);
} catch (\Throwable $e) {
// Sensor/configuration exceptions never enter the actuator or module-status path.
$this->v4DatenStatus(['status'=>'capture_error','errorType'=>get_class($e)]);
} finally { IPS_SemaphoreLeave($lock); }
}
/** Returns only complete records from the append-only prefix; cursor moves after matching ACK. */
private function v4DatenUebertragen(string $dir): array
{
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new \RuntimeException('Forecast licence missing');
$cursor=json_decode($this->ReadAttributeString('NetzfahrplanV4DatenCursor'),true,32,JSON_THROW_ON_ERROR);
$day=$cursor['day']??'';$offset=$cursor['offset']??0;
if (!is_string($day) || ($day!==''&&!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$day)) || !is_int($offset)||$offset<0) throw new \RuntimeException('Invalid data cursor');
$files=glob($dir.'/raw-*.jsonl');
if ($files===false||count($files)>400) throw new \RuntimeException('Measurement journal bounds exceeded');
sort($files,SORT_STRING);
$lastAck=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
$acceptedEpoch=strtotime((string)($lastAck['acceptedThrough']??''));
if ($acceptedEpoch===false) $acceptedEpoch=null;
$records=[];$candidate=$cursor;$bytes=0;$lastTime=null;$lastEpoch=null;$skippedDuplicateTimestamps=0;$skippedAcknowledgedTimestamps=0;
foreach ($files as $file) {
$name=basename($file);
if (!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$name)||($day!==''&&$name<$day)) continue;
if (is_link($file)||!is_file($file)||realpath($file)!==$file) throw new \RuntimeException('Invalid measurement journal');
$h=fopen($file,'rb');if($h===false)throw new \RuntimeException('Cannot open measurement journal');
try {
$size=fstat($h)['size'];$position=$name===$day?$offset:0;
if ($position>$size||fseek($h,$position)!==0) throw new \RuntimeException('Measurement journal shortened');
while (ftell($h)<$size) {
$line=fgets($h,min(262146,$size-ftell($h)+1));
if ($line===false||!str_ends_with($line,"\n")) break;
if (strlen($line)>262144) throw new \RuntimeException('Oversized measurement');
if (count($records)>=120||$bytes+strlen($line)>900000) break 2;
$r=json_decode($line,false,64,JSON_THROW_ON_ERROR);
if (!is_object($r)||($r->installationId??null)!==$this->ReadAttributeString('LizenzInstallationID')
||($r->kind??null)!=='raw_accounting_capture') throw new \RuntimeException('Journal identity invalid');
$epoch=strtotime((string)($r->capturedAt??''));
if ($epoch===false) throw new \RuntimeException('Journal capture timestamp invalid');
$candidate=['day'=>$name,'offset'=>ftell($h)];
if ($acceptedEpoch!==null && $epoch<=$acceptedEpoch) { $skippedAcknowledgedTimestamps++;continue; }
$bytes+=strlen($line);
if ($lastEpoch!==null && $epoch<$lastEpoch) throw new \RuntimeException('Measurement journal timestamp moved backwards');
if ($lastEpoch!==null && $epoch===$lastEpoch) { $skippedDuplicateTimestamps++;continue; }
$records[]=$r;$lastTime=$r->capturedAt;$lastEpoch=$epoch;
}
} finally { fclose($h); }
}
if ($records===[]) return ['status'=>'up_to_date'];
$id=$this->ReadAttributeString('LizenzInstallationID');$token=$this->ReadAttributeString('PrognoseInstallationsToken');
if ($token==='') throw new \RuntimeException('Existing device token unavailable');
$payload=json_encode(['version'=>1,'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'records'=>$records],JSON_THROW_ON_ERROR);
$h=curl_init('https://license.enelix.ch/api/v1/installations/'.rawurlencode($id).'/prognosis/planner-v4/measurements');
if ($h===false) throw new \RuntimeException('Cannot prepare data transfer');
try {
curl_setopt_array($h,[CURLOPT_POST=>true,CURLOPT_RETURNTRANSFER=>true,CURLOPT_FOLLOWLOCATION=>false,
CURLOPT_CONNECTTIMEOUT=>2,CURLOPT_TIMEOUT=>8,CURLOPT_SSL_VERIFYPEER=>true,CURLOPT_SSL_VERIFYHOST=>2,
CURLOPT_HTTPHEADER=>['Content-Type: application/json','Accept: application/json','Authorization: Bearer '.$token],
CURLOPT_POSTFIELDS=>$payload]);
$answer=curl_exec($h);$code=(int)curl_getinfo($h,CURLINFO_HTTP_CODE);
if ($answer===false||$code!==200||strlen($answer)>65536) throw new \RuntimeException('Measurement delivery not acknowledged',$code);
$ack=json_decode($answer,true,32,JSON_THROW_ON_ERROR);
if (!in_array($ack['status']??null,['stored','duplicate'],true)
||($ack['datasetId']??null)!==$this->ReadPropertyString('NetzfahrplanV4Datensatz')
||($ack['acceptedThrough']??null)!==$lastTime) throw new \RuntimeException('Measurement acknowledgement does not match batch');
$this->WriteAttributeString('NetzfahrplanV4DatenCursor',json_encode($candidate,JSON_THROW_ON_ERROR));
return ['status'=>'acknowledged','records'=>count($records),'acceptedThrough'=>$lastTime,
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),
'journalDuplicateTimestampsSkipped'=>$skippedDuplicateTimestamps,
'journalAcknowledgedTimestampsSkipped'=>$skippedAcknowledgedTimestamps];
} finally { curl_close($h); }
}
}
+272
View File
@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
/** Independent preview only: no SendData, consumer RequestAction or legacy plan writes. */
trait ManagerNetzfahrplanV4EmpfangTrait
{
private function registriereNetzfahrplanV4Empfang(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4EmpfangAktiv', false);
$this->RegisterAttributeString('NetzfahrplanV4Empfang', '{}');
$this->RegisterAttributeString('NetzfahrplanV4EmpfangStatus', '{}');
$this->RegisterAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
$this->RegisterAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
$this->RegisterVariableString('NetzfahrplanV4Vorschau', 'V4 Sollwert-Vorschau (KEINE Stellfreigabe)', '~HTMLBox', 950);
$this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'V4 Vorschau Diagnose (nur lesen)', '', 951);
$this->RegisterTimer('NetzfahrplanV4VorschauAktualisieren', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4VorschauAktualisieren', true);");
$this->RegisterTimer('NetzfahrplanV4EmpfangAktualisieren', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Empfangen', true);");
}
private function stoppeNetzfahrplanV4Vorschau(): void
{
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', 0);
$this->SetTimerInterval('NetzfahrplanV4EmpfangAktualisieren', 0);
$this->WriteAttributeString('NetzfahrplanV4Empfang', '{}');
$this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', '{"status":"not_received"}');
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
$this->zeigeNetzfahrplanV4Vorschau(['status' => 'not_received', 'reason' => 'Neu initialisiert; keinen alten Sollwert weiterverwenden.']);
}
private function konfiguriereNetzfahrplanV4Vorschau(): void
{
$enabled = $this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
&& $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
&& !$this->ReadPropertyBoolean('NetzfahrplanAktiv');
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', $enabled ? 10000 : 0);
$this->SetTimerInterval('NetzfahrplanV4EmpfangAktualisieren', $enabled ? 10000 : 0);
$this->aktualisiereNetzfahrplanV4Vorschau();
}
/** Called before the next operation upload, so the preceding completed plan can be seen. */
private function empfangeNetzfahrplanV4(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')) return;
$lock = 'ENELIX.V4.Receive.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 0)) return;
try {
$now = time();
if ($now < $this->ReadAttributeInteger('NetzfahrplanV4NaechsterAbruf')) return;
$session = method_exists($this, 'v4ManagerTestSession') ? $this->v4ManagerTestSession() : [];
$interval = ($session['kind'] ?? null) === 'local_active_test_consent' ? 45 : 60;
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', $now + $interval);
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
throw new RuntimeException('Schatten-Sender fehlt oder alter Fahrplan ist eingeschaltet.');
}
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
}
$response = $this->netzfahrplanV4EmpfangHttp('GET', '');
$receipt = time();
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
throw new RuntimeException('Freigabe waehrend des Abrufs geaendert.');
}
// Validate the candidate before replacing the last accepted envelope. During an
// active test a failed refresh may keep the previous envelope, but its original
// receivedAt is never changed, so invalid traffic can never extend its lifetime.
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$checked = NetzfahrplanV4Planpruefung::pruefen($response,
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $receipt);
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
// Only the known, non-secret protocol envelope is cached.
$keys = ['receiverProtocolVersion', 'installationId', 'checkedAt', 'liveEnabled', 'fresh',
'pending', 'settings', 'plan', 'families', 'lastRun', 'controlledTrial'];
$safe = array_intersect_key($response, array_flip($keys));
$this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode(
['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR));
$state = ['status' => 'received_shadow', 'at' => gmdate('c', $receipt), 'acknowledged' => false];
$ack = ['planId' => $preview['planId'], 'revision' => $preview['revision'], 'step' => $preview['step'], 'status' => 'shadow_seen'];
$last = json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteBestaetigung'), true);
if ($last !== $ack) {
try {
$answer = $this->netzfahrplanV4EmpfangHttp('POST', '/ack', $ack);
if (($answer['status'] ?? null) !== 'recorded') throw new RuntimeException('Empfangsbestaetigung abgelehnt.');
$this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', json_encode($ack, JSON_THROW_ON_ERROR));
$state['acknowledged'] = true;
} catch (Throwable $error) {
$state['acknowledgementError'] = substr($error->getMessage(), 0, 200);
}
} else {
$state['acknowledged'] = true;
}
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR));
$this->aktualisiereNetzfahrplanV4Vorschau();
} catch (Throwable $error) {
$keepPrevious = false;
$previous = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true);
if (is_array($previous) && is_int($previous['receivedAt'] ?? null)) {
$session = method_exists($this, 'v4ManagerTestSession') ? $this->v4ManagerTestSession() : [];
$keepPrevious = (($session['kind'] ?? null) === 'local_active_test_consent')
&& $previous['receivedAt'] <= time()
&& time() - $previous['receivedAt'] <= NetzfahrplanV4Planpruefung::MAX_RECEIPT_AGE;
}
$pendingRetry = is_array($response ?? null)
&& ($response['installationId'] ?? null) === $this->ReadAttributeString('LizenzInstallationID')
&& ($response['pending'] ?? null) !== null;
if ($keepPrevious || $pendingRetry) {
// Also catch a completed plan before the next operation starts another calculation.
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', time() + 15);
}
if (!$keepPrevious) {
$this->WriteAttributeString('NetzfahrplanV4Empfang', '{}');
}
$state = ['status' => 'rejected_or_unavailable', 'at' => gmdate('c'),
'reason' => substr($error->getMessage(), 0, 300), 'previousEnvelopeRetained' => $keepPrevious];
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR));
$this->zeigeNetzfahrplanV4Vorschau($state);
} finally {
IPS_SemaphoreLeave($lock);
}
}
/** Fixed authenticated read/ack endpoints only; never operation, settings, replan or dispatch. */
private function netzfahrplanV4EmpfangHttp(string $method, string $suffix, ?array $body = null): array
{
if (!(($method === 'GET' && $suffix === '' && $body === null)
|| ($method === 'POST' && $suffix === '/ack' && ($body['status'] ?? null) === 'shadow_seen'))) {
throw new RuntimeException('Unzulaessiger V4-Empfangsaufruf.');
}
$id = $this->ReadAttributeString('LizenzInstallationID');
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $id)
|| !preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) {
throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.');
}
$handle = curl_init('https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4' . $suffix);
if ($handle === false) throw new RuntimeException('V4-Empfang konnte nicht vorbereitet werden.');
$response = '';
try {
$options = [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token,
'Accept: application/json', 'Content-Type: application/json', 'Cache-Control: no-cache'],
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int {
if (strlen($response) + strlen($chunk) > NetzfahrplanV4Planpruefung::MAX_RESPONSE_BYTES) return 0;
$response .= $chunk;return strlen($chunk);
}];
if ($body !== null) $options[CURLOPT_POSTFIELDS] = json_encode($body, JSON_THROW_ON_ERROR);
curl_setopt_array($handle, $options);
$ok = curl_exec($handle);$code = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
if ($ok === false || $code !== 200) throw new RuntimeException('V4-Planempfang HTTP ' . $code . '; kein gueltiger Vorschauwert.');
return NetzfahrplanV4Planpruefung::dekodieren($response);
} finally {
curl_close($handle);
}
}
/** Reads independently of the old control loop. Never manufactures fresh timestamps. */
private function netzfahrplanV4LokaleVorschauwerte(): array
{
$d = json_decode($this->GetNetzfahrplanV4Diagnose(), true, 64, JSON_THROW_ON_ERROR);
if (!is_array($d['operation'] ?? null)) throw new RuntimeException('Lokale Betriebsdaten ungueltig; Vorschau gesperrt.');
$topology = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR);
$actual = [];$now = time();
foreach ($topology as $asset) {
$controllers=[];
foreach ($this->aktiveVerbraucherIDs() as $controller) {
if (IPS_InstanceExists($controller)
&& IPS_GetInstance($controller)['ModuleInfo']['ModuleID']==='{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}'
&& IPS_GetProperty($controller,'IstleistungVariableID')===($asset['LeistungVariableID']??null)
&& IPS_GetProperty($controller,'LadezustandVariableID')===($asset['SOCVariableID']??null)) $controllers[]=$controller;
}
if (count($controllers)>1) throw new RuntimeException('feedback_controller_ambiguous');
if (count($controllers)===1 && function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
$controller=$controllers[0];$configured=IPS_GetProperty($controller,'NetzfahrplanV4RueckmeldungKonfiguration');
if (is_string($configured) && !in_array(trim($configured),['','{}'],true)) {
if (count($topology)!==1) throw new RuntimeException('feedback_multi_asset_adapter_required');
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($controller),true,32,JSON_THROW_ON_ERROR);
$feedbackNow=time();
if (($f['status']??null)==='unavailable') {
$reason=is_string($f['reason']??null)?$f['reason']:'unknown';
throw new RuntimeException('feedback_unavailable:'.substr($reason,0,180));
}
if (($f['installationId']??null)!==$this->ReadAttributeString('LizenzInstallationID')
|| ($f['assetId']??null)!==$asset['ID'] || ($f['managerId']??null)!==$this->InstanceID
|| ($f['batteryInstanceId']??null)!==$controller || !is_numeric($f['batteryW']??null)
|| !is_numeric($f['gridW']??null) || !is_int($f['checkedAt']??null)
|| $feedbackNow-$f['checkedAt']>2 || $f['checkedAt']>$feedbackNow) {
throw new RuntimeException('feedback_invalid_no_legacy_fallback');
}
$actual[$asset['ID']]=$f['batteryW'];$d['operation']['gridW']=$f['gridW'];
$d['operation']['localFeedback'][$asset['ID']]=$f;
continue;
}
}
// Legacy value remains display-only until an explicit physical feedback mapping exists.
$id = (int) ($asset['LeistungVariableID'] ?? 0);
if (!IPS_VariableExists($id)) throw new RuntimeException('Batterie-Istleistung fehlt.');
$before = IPS_GetVariable($id);$v = GetValue($id);$after = IPS_GetVariable($id);
$factor = $asset['Leistungsfaktor'] ?? null;
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v)
|| (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor) || $factor == 0
|| $before['VariableUpdated'] !== $after['VariableUpdated']
|| $after['VariableUpdated'] > $now || $now - $after['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) {
throw new RuntimeException('Batterie-Istleistung fehlt, ist veraltet oder inkonsistent.');
}
$actual[$asset['ID']] = (float) $v * $factor;
}
return [$d['operation'], $actual];
}
public function GetNetzfahrplanV4Vorschau(): string
{
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
throw new RuntimeException('V4-Empfang ausgeschaltet.');
}
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) throw new RuntimeException('Alter Fahrplan ist aktiv; V4-Vorschau gesperrt.');
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
throw new RuntimeException('Noch kein gueltiger V4-Plan empfangen.');
}
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$checked = NetzfahrplanV4Planpruefung::pruefen($envelope['response'],
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $envelope['receivedAt']);
$out = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
$out['localFeedback'] = $operation['localFeedback'] ?? [];
$out['feedbackMeaning'] = isset($operation['localFeedback']) ? 'fresh_physical_or_explicit_partition_model' : 'legacy_display_only_not_for_trial';
$out['reception'] = json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true);
} catch (Throwable $error) {
$out = ['status' => 'unavailable', 'reason' => substr($error->getMessage(), 0, 300),
'reception' => json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true)];
}
return json_encode(array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null],
$out, ['canDispatch' => false, 'liveEnabled' => false]), JSON_THROW_ON_ERROR);
}
private function aktualisiereNetzfahrplanV4Vorschau(): void
{
$this->zeigeNetzfahrplanV4Vorschau(json_decode($this->GetNetzfahrplanV4Vorschau(), true, 64, JSON_THROW_ON_ERROR));
}
private function zeigeNetzfahrplanV4Vorschau(array $value): void
{
$value = array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null], $value,
['canDispatch' => false, 'liveEnabled' => false]);
$escape = static fn($v): string => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$html = '<h3>V4: NUR VORSCHAU - KEINE STELLFREIGABE</h3><table>';
foreach (['status' => 'Status', 'planId' => 'Plan-ID', 'family' => 'Modellfamilie', 'step' => 'Zeitschritt (UTC)',
'stepValidUntil' => 'Gueltig bis', 'gridTargetW' => 'Geplantes Netzziel W', 'plannedBatteryW' => 'Geplante Batterie W',
'previewBatteryW' => 'Lokal berechnete Batterie W (nicht ausgegeben)', 'reason' => 'Ablehnungsgrund'] as $key => $label) {
$item = $value[$key] ?? '-';if (is_float($item)) $item = number_format($item, 1, '.', "'");
$html .= '<tr><th>' . $escape($label) . '</th><td>' . $escape($item) . '</td></tr>';
}
$html .= '</table><p>Batterie + = Laden, - = Entladen. Netz + = Bezug, - = Einspeisung.</p>';
foreach ($value['reasons'] ?? [] as $reason) $html .= '<p>' . $escape($reason) . '</p>';
$html .= '<p>Empfangsbestaetigung ist keine Ausfuehrungsbestaetigung.</p>';
$this->SetValue('NetzfahrplanV4VorschauJSON', json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
$this->SetValue('NetzfahrplanV4Vorschau', $html);
}
}
+223
View File
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
/** Local consent + server commissioning authority. NEVER executes a preview alone. */
trait ManagerNetzfahrplanV4TestTrait
{
private function registriereV4ManagerTest(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
$this->RegisterAttributeString('NetzfahrplanV4RegeltestStatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4TestTick', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);");
}
private function v4ManagerTestSession(): array
{
$value = json_decode($this->GetBuffer('V4ManagerTrialSession') ?: '{}', true);
return is_array($value) ? $value : [];
}
private function v4ManagerTestBedingungen(): void
{
if (IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== 102
|| !$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
|| $this->ReadPropertyBoolean('NetzfahrplanAktiv') || !$this->GetValue('Aktiv')
|| !$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
throw new RuntimeException('Keine lokale Testberechtigung oder konkurrierender Fahrplan.');
}
}
/** Explicit kernel-local user action only. No network request can call this via the portal. */
public function V4ManagerTestStarten(string $json): void
{
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt.');
$armedBattery = null;
try {
$this->v4ManagerTestBedingungen();
if ($this->v4ManagerTestSession()) throw new RuntimeException('Testsitzung bereits aktiv; nicht verlaengern.');
$request = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
if (!is_array($request) || ($request['confirmation'] ?? null) !== 'START_BOUNDED_V4_CONTROL_TRIAL') {
throw new RuntimeException('Ausdrueckliche lokale Testbestaetigung erforderlich.');
}
$session = NetzfahrplanV4Regeltest::uuid($request['sessionId'] ?? null);
if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $session) throw new RuntimeException('Testsitzung bereits benutzt; neue Freigabe erforderlich.');
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
$a = $envelope['response']['controlledTrial'] ?? null;
if (!is_array($a) || ($a['sessionId'] ?? null) !== $session) throw new RuntimeException('Keine passende separate Serverfreigabe empfangen.');
[$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$batteryID = (int) ($a['batteryInstanceId'] ?? 0);
if (!in_array($batteryID, $this->aktiveVerbraucherIDs(), true)
|| !IPS_InstanceExists($batteryID)
|| IPS_GetInstance($batteryID)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
throw new RuntimeException('Testbatterie ist keine aktive zugeordnete Batterieinstanz.');
}
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 32, JSON_THROW_ON_ERROR);
if (count($assets) !== 1 || $assets[0]['ID'] !== ($a['assetId'] ?? null)
|| $assets[0]['SOCVariableID'] !== IPS_GetProperty($batteryID, 'LadezustandVariableID')
|| $assets[0]['LeistungVariableID'] !== IPS_GetProperty($batteryID, 'IstleistungVariableID')) {
throw new RuntimeException('Physische Batteriezuordnung ist nicht eindeutig.');
}
$now = time();$until = min(NetzfahrplanV4Planpruefung::zeit($a['expiresAt']),
$now + NetzfahrplanV4Regeltest::integer($request['durationSeconds'] ?? null, 'Lokale Testdauer', 30, 1800));
$s = ['kind' => 'local_control_trial_consent', 'enabled' => true, 'legacyEnabled' => false,
'sessionId' => $session, 'installationId' => $this->ReadAttributeString('LizenzInstallationID'),
'managerId' => $this->InstanceID, 'batteryInstanceId' => $batteryID, 'assetId' => $a['assetId'],
'expiresAt' => $until, 'maxChargeW' => NetzfahrplanV4Regeltest::number($request['maxChargeW'] ?? null, 'Testladegrenze', 1, 1e9),
'maxDischargeW' => NetzfahrplanV4Regeltest::number($request['maxDischargeW'] ?? null, 'Testentladegrenze', 1, 1e9),
'actuatorWatchdogEvidenceId' => IPS_GetProperty($batteryID, 'NetzfahrplanV4GeraeteWatchdogNachweis'),
'feedbackConfigHash' => $op['localFeedback'][$a['assetId']]['configHash'] ?? '',
'gridLimits' => NetzfahrplanV4Planpruefung::kontext($op)['limits'],
'sequence' => 1, 'lastWallClockAt' => $now,
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now];
// Validate everything BEFORE arming either module. This rejects all normal shadow replies.
NetzfahrplanV4Regeltest::befehl($envelope['response'], $op, $actual, $s, $now, $envelope['receivedAt']);
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $session);
$armedBattery = $batteryID;
$this->v4ManagerBatterieAktion($batteryID, 'NetzfahrplanV4TestStart', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetTimerInterval('NetzfahrplanV4TestTick', 2000);
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
'status' => 'armed_local_trial', 'sessionId' => $session, 'expiresAt' => $until,
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
if ($armedBattery !== null) $this->v4ManagerBatterieAktion($armedBattery, 'NetzfahrplanV4TestStop', 0);
throw $e;
} finally { IPS_SemaphoreLeave($lock); }
}
/** Revocation precedes I/O; if the device call fails its own ten-second lease still expires. */
private function v4ManagerTestAbbrechen(string $reason): void
{
$s = $this->v4ManagerTestSession();
$this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
$this->SetValue('NetzfahrplanV4Aktivtest', false);
if (!$s) return;
$result = ['status' => 'stopped_fallback_pending', 'reason' => $reason,
'sessionId' => $s['sessionId'], 'physicalResponseVerified' => false];
// Reissue the next ordinary target even if numerically equal to a previously cached value.
$this->WriteAttributeString('LetzteSollwerte', '{}');
try {
$this->v4ManagerBatterieAktion($s['batteryInstanceId'], 'NetzfahrplanV4TestStop', 0);
$result['controllerStopAccepted'] = true;
} catch (Throwable $e) {
$result['controllerStopAccepted'] = false;$result['reason'] .= '; Geraete-Abbruch fehlgeschlagen, Watchdog pruefen.';
}
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode($result, JSON_THROW_ON_ERROR));
}
public function V4ManagerTestStoppen(): void
{
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt; Stop erneut anfordern.');
try { $this->v4ManagerTestAbbrechen('Lokaler Testabbruch'); }
finally { IPS_SemaphoreLeave($lock); }
$this->regeln(true); // fresh ordinary command; never restore a cached pre-trial target
}
public function GetV4ManagerTestStatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4RegeltestStatus');
}
private function v4ManagerBatterieAktion(int $id, string $ident, $value): void
{
if (IPS_RequestAction($id, $ident, $value) !== true) {
throw new RuntimeException('Batterieaktion nicht bestaetigt: ' . $ident);
}
}
/** Called under the existing regulator's serialization, after allocation but before writes. */
private function v4ManagerTestUebergabe(array &$allocation, array $consumers, bool $synchronized): void
{
$s = $this->v4ManagerTestSession();
if (!$s) return;
try {
$this->v4ManagerTestBedingungen();$now = time();
$activeTest = ($s['kind'] ?? null) === 'local_active_test_consent';
// The ordinary commissioning trial still requires a fully synchronized consumer set.
// In the explicitly approved 48h active test, V4 is the sole actuator owner: stale or
// unrelated consumers receive no new Manager target and their physical effect is seen
// through the verified grid feedback on the next V4 cycle.
if ((!$activeTest && !$synchronized) || $now >= $s['expiresAt'] || hrtime(true) / 1e9 >= $s['monotonicDeadline']) {
throw new RuntimeException($activeTest ? 'Testende erreicht.' : 'Testende oder Verbraucher nicht synchronisiert.');
}
[$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
// Confirmed device reads can cross a wall-clock second. Validate their checkedAt
// against a post-read clock, never against the timestamp captured before I/O.
$now = time();
if ($now >= $s['expiresAt'] || hrtime(true) / 1e9 >= $s['monotonicDeadline']) {
throw new RuntimeException('Testende erreicht.');
}
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
// Other consumers stay under the existing controller. Compensate their predicted
// change once; no double-counting of the battery's already flowing actual power.
// The explicitly approved active test owns its bound battery directly. It must not
// depend on the legacy allocation list as long as the confirmed V4 feedback still
// proves the same battery binding and is usable for the trial.
$otherDelta = 0.0;$found = false;
if ($activeTest) {
$feedback = $op['localFeedback'][$s['assetId']] ?? null;
$found = is_array($feedback)
&& ($feedback['batteryInstanceId'] ?? null) === $s['batteryInstanceId']
&& ($feedback['usableForTrial'] ?? false) === true
&& ($feedback['deviceReadConfirmed'] ?? false) === true;
}
foreach ($consumers as $consumer) {
$id = $consumer['InstanzID'];
if ($id === $s['batteryInstanceId']) { $found = true;continue; }
if (!array_key_exists($id, $allocation['Sollwerte'])) continue;
if ($activeTest) {
unset($allocation['Sollwerte'][$id]);
continue;
}
$d = $consumer['Daten'];
if (($d['Leistungsquelle'] ?? null) !== Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) throw new RuntimeException('Andere Verbraucherleistung nicht gemessen.');
$otherDelta += NetzfahrplanV4Regeltest::number($allocation['Sollwerte'][$id], 'Anderer Sollwert', -1e9, 1e9)
- NetzfahrplanV4Regeltest::number($d['Istleistung_W'] ?? null, 'Andere Istleistung', -1e9, 1e9);
}
if (!$found) throw new RuntimeException('Testbatterie nicht mehr in aktiver Zuordnung.');
$op['gridW'] += $otherDelta;
$op['localPredictedOtherDeltaW']=$otherDelta;
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
throw new RuntimeException('Kein gueltiger empfangener Testkontext.');
}
$response = $envelope['response'];
if (($s['kind'] ?? null) === 'local_active_test_consent') {
if (!$this->GetValue('NetzfahrplanV4Aktivtest')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')) {
throw new RuntimeException('Aktivtestfreigabe entzogen.');
}
$response = $this->v4AktivtestAntwort($response, $s, $now);
}
$command = NetzfahrplanV4Regeltest::befehl($response, $op, $actual, $s, $now, $envelope['receivedAt']);
// A controller-side refusal stops the session; never downgrade it to an ordinary target.
$this->v4ManagerBatterieAktion($s['batteryInstanceId'], 'NetzfahrplanV4TestCommand', json_encode($command, JSON_THROW_ON_ERROR));
$s['sequence']++;$s['lastWallClockAt'] = $now;
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
unset($allocation['Sollwerte'][$s['batteryInstanceId']]); // one owner of this actuator
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
'status' => 'controller_command_sent', 'sessionId' => $s['sessionId'],
'sourceShadowPlanId' => $command['sourceShadowPlanId'], 'command' => $command,
'otherConsumerPredictedDeltaW' => $otherDelta, 'otherConsumersFrozen' => $activeTest,
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->v4ManagerTestAbbrechen($e->getMessage());
// Stop writes zero and next NEW ordinary allocation runs on this same pass.
}
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/ManagerNetzfahrplanV4EmpfangTrait.php';
require_once __DIR__ . '/ManagerNetzfahrplanV4DatenTrait.php';
/** Shadow telemetry only. Does not read/apply a V4 schedule or write actuators. */
trait ManagerNetzfahrplanV4Trait
{
use ManagerNetzfahrplanV4EmpfangTrait;
use ManagerNetzfahrplanV4DatenTrait;
private function registriereNetzfahrplanV4(): void
{
$this->registriereNetzfahrplanV4Empfang();
$this->registriereNetzfahrplanV4Daten();
$this->RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false);
$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);");
}
public function GetNetzfahrplanV4Diagnose(): string
{
try {
$manager = [];
foreach (['NetzleistungVariableID', 'MesswertMaxAlter', 'VerbraucherTimeout',
'Lastspitzenmodus'] as $key) {
$manager[$key] = $this->ReadPropertyInteger($key);
}
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
$manager[$key] = $this->ReadPropertyFloat($key);
}
$manager['V4ControlPlanSource'] = $this->leseJsonAttribut('Netzfahrplan')['sourceModel'] ?? 'legacy-unspecified';
$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);
$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 = [];
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', 'LadezustandHysterese', '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;
if (function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
$configured = IPS_GetProperty($id, 'NetzfahrplanV4RueckmeldungKonfiguration');
if (is_string($configured) && !in_array(trim($configured), ['', '{}'], true)) {
try {
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldung($id), true, 32, JSON_THROW_ON_ERROR);
$checkedAt = $feedback['checkedAt'] ?? null;
if (($feedback['usableForTrial'] ?? false) === true
&& ($feedback['deviceReadConfirmed'] ?? false) === true
&& is_int($checkedAt) && $checkedAt <= time()
&& time() - $checkedAt <= max(2, (int) ($c['MesswertMaxAlter'] ?? 30))) {
$c['EmpfangenAm'] = $checkedAt;
$c['Verfuegbar'] = true;
}
} catch (Throwable) {
// Keep the ordinary cache state; V4 remains fail-safe if direct feedback is unavailable.
}
}
}
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.');
}
$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);
} 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;
}
// Read the preceding completed plan BEFORE sending a new operation/replan trigger.
$this->empfangeNetzfahrplanV4();
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));
}
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use LogicException;
/** Form composition only. Existing configuration, control authority and backend stay unchanged. */
final class ManagerPrognoseFormular
{
public static function startErlaubt(array $zustand): bool
{
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
'testErlaubt', 'aktivtestErlaubt', 'watchdogVerzichtErlaubt'] as $feld) {
if (($zustand[$feld] ?? false) !== true) {
return false;
}
}
return ($zustand['legacyAktiv'] ?? true) === false
&& ($zustand['aktivtestAktiv'] ?? true) === false
&& isset($zustand['ende'], $zustand['jetzt'])
&& is_int($zustand['ende']) && is_int($zustand['jetzt'])
&& ($zustand['ende'] === 0 || $zustand['ende'] > $zustand['jetzt']);
}
public static function statustext(array $zustand): string
{
$text = ($zustand['aktivtestAktiv'] ?? false)
? 'Netzfahrplan: befristeter Testbetrieb eingeschaltet.'
: 'Netzfahrplan: Steuerung ausgeschaltet.';
if ($zustand['legacyAktiv'] ?? false) {
$text = 'Bisheriger Netzfahrplan eingeschaltet; neuer Netzfahrplan gesperrt.';
}
if (!($zustand['lizenziert'] ?? false)) {
$text .= ' Netzfahrplanlizenz fehlt.';
}
$ende = $zustand['ende'] ?? 0;
if (is_int($ende) && $ende > 0) {
$text .= ' Bestehendes Testende: ' . gmdate('d.m.Y H:i:s', $ende) . ' UTC.';
if ($ende <= ($zustand['jetzt'] ?? 0)) {
$text .= ' Testfreigabe abgelaufen.';
}
}
return $text . ' Kein freigegebener Dauerbetrieb. Bestehende Laufzeit-, Messwert- und Geraeteschutzpruefungen bleiben wirksam.';
}
public static function integrieren(array $formular, array $zustand): array
{
$gefunden = false;
$v4Konfiguriert = ($zustand['senderAktiv'] ?? false)
|| ($zustand['empfangAktiv'] ?? false) || ($zustand['messdatenAktiv'] ?? false);
foreach ($formular['elements'] as &$element) {
if (($element['name'] ?? null) !== 'PrognoseForecast') {
continue;
}
if ($gefunden) {
throw new LogicException('Prognosebereich ist nicht eindeutig.');
}
$gefunden = true;
foreach ($element['items'] as &$feld) {
if (($feld['name'] ?? null) === 'NetzfahrplanAktiv' && $v4Konfiguriert) {
// An active legacy controller remains visible so it can still be disabled.
$feld['visible'] = (bool) ($zustand['legacyAktiv'] ?? false);
$feld['enabled'] = (bool) ($zustand['legacyAktiv'] ?? false);
$feld['caption'] = 'Bisheriger Netzfahrplan (konkurrierende Steuerung)';
}
}
unset($feld);
$element['items'][] = [
'type' => 'Button', 'name' => 'PrognoseLizenzVerwalten',
'caption' => 'Prognose und Netzfahrplan im Lizenzportal verwalten', 'link' => true,
'onClick' => "echo 'https://license.enelix.ch';",
];
$element['items'][] = [
'type' => 'CheckBox', 'name' => 'NetzfahrplanV4EmpfangAktiv',
'caption' => 'Aktuellen Netzfahrplan empfangen und anzeigen',
'enabled' => (bool) (($zustand['lizenziert'] ?? false) || ($zustand['empfangAktiv'] ?? false)),
];
$element['items'][] = [
'type' => 'Label', 'name' => 'PrognoseNetzfahrplanStatus',
'caption' => self::statustext($zustand),
];
$element['items'][] = [
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanStart',
'caption' => 'Netzfahrplan einschalten (Testbetrieb)',
'enabled' => self::startErlaubt($zustand),
'onClick' => "IPS_RequestAction(\$id, 'FormNetzfahrplanSchalten', true);",
];
$element['items'][] = [
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanStop',
'caption' => 'Netzfahrplan ausschalten', 'enabled' => true,
'onClick' => "IPS_RequestAction(\$id, 'FormNetzfahrplanSchalten', false);",
];
$element['items'][] = [
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanPruefen',
'caption' => 'Plan jetzt pruefen',
'onClick' => "IPS_RequestAction(\$id, 'NetzfahrplanV4Empfangen', true);",
];
}
unset($element);
if (!$gefunden) {
throw new LogicException('Prognosebereich fehlt; Formular nicht erweitert.');
}
return $formular;
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
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
{
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, string $meterID, array &$warnings): array
{
$peaks = [];
$past = null;
if ($input === []) {
$warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.';
return [(object) [], null];
}
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);
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 = [];
$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');
$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);
$physicalMin = self::number($c['MindestLadezustand'], 'Technisches Minimum', 0, $min);
if ($socValue < $physicalMin || $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.';
}
$rearm = min($max, $min + self::number($c['LadezustandHysterese'] ?? 0.0, 'Entladehysterese', 0, 100));
$blocked = !empty($c['HystereseAktiv']) || $socValue <= $min;
if ($blocked) {
$warnings[] = $id . ': Entladesperre im Modell aktiv bis zur Wiederfreigabe nach vorheriger Ladung.';
}
if ($socValue < $min) {
$warnings[] = $id . ': unter Betriebsreserve; realen SOC behalten und nur zulassige Wiederaufladung planen.';
}
$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,
'physicalMinSocPercent' => $physicalMin, 'recoveryAllowed' => true,
'dischargeBlocked' => $blocked, 'rearmSocPercent' => $rearm];
}
if ($result === []) {
throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.');
}
[$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];
// A fresh acquisition sample is an operational estimate. It does NOT
// claim a calibrated billing-period boundary or full historical coverage.
$policy = [
'adapterVersion' => 'v4-native-estimated-meter-1',
'sourcePlan' => $manager['V4ControlPlanSource'] ?? 'legacy-unspecified',
'mode' => $mode, 'importW' => $import, 'exportW' => $export,
'months' => $months,
'batteryPolicy' => array_map(static function (array $b): array {
return array_intersect_key($b, array_flip(['id', 'capacityKwh', 'minSocPercent', 'maxSocPercent', 'physicalMinSocPercent', 'rearmSocPercent']));
}, $result),
];
$payload['meterObservation'] = [
'meterId' => $meter['meterId'], 'sampleAt' => gmdate('c', $now),
'powerW' => $gridW, 'totalImportKwh' => $meter['totalKwh'],
'controlPolicyId' => hash('sha256', json_encode($policy, 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, '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];
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/** Pure accounting candidate. No IPS, archive changes, network or control permission.
* Battery-positive = charging. Grid-positive = import. PV-positive = production.
* Physical storage is subtracted ONCE from house load. Virtual allocation is a
* separate ledger, not a second physical meter. Mapping accuracy requires review.
*/
final class NetzfahrplanV4Bilanzierung
{
private static function number($v, string $name, float $min = -1.0e9, float $max = 1.0e9): float
{
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v) || $v < $min || $v > $max) {
throw new InvalidArgumentException($name . ': ungueltiger Zahlenwert.');
}
return (float) $v;
}
private static function listArray(array $a): bool
{
return $a === [] || array_keys($a) === range(0, count($a)-1);
}
public static function konfigurieren(array $c): array
{
$fields = ['version','sources','maxSkewSeconds','splitToleranceW'];
if (count($c) !== count($fields) || array_diff($fields,array_keys($c)) || $c['version'] !== 1
|| !is_array($c['sources']) || !self::listArray($c['sources']) || count($c['sources']) < 3 || count($c['sources']) > 80
|| !is_int($c['maxSkewSeconds']) || $c['maxSkewSeconds'] < 0 || $c['maxSkewSeconds'] > 60) {
throw new InvalidArgumentException('Bilanzkonfiguration unvollstaendig.');
}
// Preserve a stable numeric representation across JSON property round-trips.
$c['splitToleranceW'] = self::number($c['splitToleranceW'], 'Zuordnungstoleranz W', 0, 500);
$seen=[];$keys=[];$roles=[];
foreach ($c['sources'] as &$s) {
if (!is_array($s) || count($s)!==8 || array_diff(['key','role','variableId','parentId','ident','factorToW','maxAgeSeconds','dependsOn'],array_keys($s))) {
throw new InvalidArgumentException('Explizite Quellenzuordnung erforderlich.');
}
if (!is_string($s['key']) || !preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/D',$s['key']) || isset($keys[$s['key']])
|| !in_array($s['role'], ['grid','pv','physical_storage','controlled_virtual','external_virtual','flexible_load','reference'],true)
|| !is_int($s['variableId']) || $s['variableId']<=0 || isset($seen[$s['variableId']])
|| !is_int($s['parentId']) || $s['parentId']<=0 || !is_string($s['ident']) || strlen($s['ident'])>100
|| !is_int($s['maxAgeSeconds']) || $s['maxAgeSeconds']<1 || $s['maxAgeSeconds']>300) {
throw new InvalidArgumentException('Doppelte oder ungueltige Bilanzquelle.');
}
$s['factorToW']=self::number($s['factorToW'],'Einheiten-/Vorzeichenfaktor',-1e6,1e6);
if ($s['factorToW']==0 || !is_array($s['dependsOn']) || !self::listArray($s['dependsOn']) || count($s['dependsOn'])>40) {
throw new InvalidArgumentException('Abhaengigkeiten oder Faktor fehlen.');
}
foreach ($s['dependsOn'] as $dep) if (!is_string($dep)) throw new InvalidArgumentException('Quellenschluessel erforderlich.');
if (count(array_unique($s['dependsOn']))!==count($s['dependsOn'])) throw new InvalidArgumentException('Doppelte Abhaengigkeit.');
sort($s['dependsOn']);$keys[$s['key']]=true;$seen[$s['variableId']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1;
}
unset($s);
if (($roles['grid']??0)!==1 || !isset($roles['pv'],$roles['physical_storage'],$roles['controlled_virtual'])) {
throw new InvalidArgumentException('Netz, PV, physische und steuerbare virtuelle Batterie muessen explizit zugeordnet sein.');
}
$graph=[];foreach ($c['sources'] as $s) $graph[$s['key']]=$s['dependsOn'];
$visit=function($key,$stack=[]) use (&$visit,$graph): void {
if (!array_key_exists($key,$graph) || isset($stack[$key])) throw new InvalidArgumentException('Fehlende oder zyklische Ursprungsquelle.');
$stack[$key]=true;foreach ($graph[$key] as $dep) $visit($dep,$stack);
};
foreach (array_keys($graph) as $key) $visit($key);
usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key']));return $c;
}
/** Reader: {value:number, updated:int, parentID:int, ident:string}. */
public static function aufnehmen(array $configuration, callable $read, int $now): array
{
if ($now<=0) throw new InvalidArgumentException('Ungueltige Aufnahmezeit.');
$c=self::konfigurieren($configuration);$samples=[];$sources=[];$issues=[];
$probe=static function(array $s) use ($read): array {
$v=$read($s['variableId']);
if (!is_array($v) || ($v['parentID']??null)!==$s['parentId'] || ($v['ident']??null)!==$s['ident']
|| !is_int($v['updated']??null) || $v['updated']<=0) throw new InvalidArgumentException('Quelle/Originalzeitpunkt ungueltig: '.$s['key']);
return ['raw'=>self::number($v['value']??null,$s['key']), 'updated'=>$v['updated']];
};
foreach ($c['sources'] as $s) {$sources[$s['key']]=$s;$samples[$s['key']]=$probe($s);}
foreach ($c['sources'] as $s) if ($samples[$s['key']]!==$probe($s)) throw new InvalidArgumentException('Aufnahme waehrend des Lesens geaendert: '.$s['key']);
$origin=function($key) use (&$origin,$sources,$samples): int {
$stamp=$samples[$key]['updated'];foreach ($sources[$key]['dependsOn'] as $dep) $stamp=min($stamp,$origin($dep));return $stamp;
};
$totals=array_fill_keys(['grid','pv','physical_storage','controlled_virtual','external_virtual','flexible_load','reference'],0.0);
$out=[];$times=[];
foreach ($sources as $key=>$s) {
$t=$origin($key);$w=$samples[$key]['raw']*$s['factorToW'];
if (!is_finite($w) || abs($w)>1e9) throw new InvalidArgumentException('Leistung ausserhalb des Bereichs.');
if ($samples[$key]['updated']>$now || $t>$now) $issues[]='future_source:'.$key;
if ($now-$t>$s['maxAgeSeconds']) $issues[]='stale_origin:'.$key;
if (in_array($s['role'],['pv','flexible_load'],true) && $w<0) $issues[]='negative_unsigned_source:'.$key;
$totals[$s['role']]+=$w;
if ($s['role']!=='reference') $times[]=$t;
$out[$key]=['variableId'=>$s['variableId'],'role'=>$s['role'],'powerW'=>$w,'sourceUpdatedAt'=>$samples[$key]['updated'],'oldestOriginAt'=>$t];
}
$skew=max($times)-min($times);if ($skew>$c['maxSkewSeconds']) $issues[]='source_time_skew';
$load=$totals['grid']+$totals['pv']-$totals['physical_storage'];
$base=$load-$totals['flexible_load'];
// This channel preserves unallocated dynamics instead of hiding them in house load.
$residual=$totals['physical_storage']-$totals['controlled_virtual']-$totals['external_virtual'];
$external=$totals['external_virtual']+$residual;
if ($base<0) $issues[]='negative_base_load';
if (abs($residual)>$c['splitToleranceW']) $issues[]='virtual_physical_allocation_mismatch';
$reconstructed=$base+$totals['flexible_load']+$totals['controlled_virtual']+$external-$totals['pv'];
$identity=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
return ['version'=>1,'status'=>$issues===[]?'consistent_candidate':'quality_hold',
'capturedAt'=>gmdate('c',$now),'sourceObservedFrom'=>gmdate('c',min($times)),'sourceObservedUntil'=>gmdate('c',max($times)),
'sourceSkewSeconds'=>$skew,'mappingId'=>'accounting-map:'.$identity,
'baseLoadCandidateW'=>$base,'consumerLoadCandidateW'=>$load,'pvW'=>$totals['pv'],'gridW'=>$totals['grid'],
'physicalStorageW'=>$totals['physical_storage'],'controlledVirtualW'=>$totals['controlled_virtual'],
'flexibleLoadW'=>$totals['flexible_load'],'reportedExternalW'=>$totals['external_virtual'],
'allocationResidualW'=>$residual,'externalEffectW'=>$external,'reconstructedGridW'=>$reconstructed,
'arithmeticBalanceErrorW'=>$reconstructed-$totals['grid'],'issues'=>$issues,'sources'=>$out,
'controlEligible'=>false,'meteringBoundaryVerified'=>false,'forecastHistoryMigrated'=>false,
'note'=>'Algebraic closure is not independent meter validation. No relabelling of old forecasts, no future SDL assumption.'];
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
/** Lossless archival of fully acknowledged OLD outbox days. No device/API calls. */
final class NetzfahrplanV4Datenarchiv
{
public static function run(string $directory, array $cursor, int $now): array
{
$day=$cursor['day']??'';
if ($day==='') return ['archived'=>0];
if (!is_string($day)||!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$day)
||!is_int($cursor['offset']??null)||$cursor['offset']<0
||is_link($directory)||realpath($directory)!==$directory) {
throw new \RuntimeException('Invalid archive cursor/directory');
}
$cutoff='raw-'.gmdate('Ymd',$now-7*86400).'.jsonl';
$archive=$directory.'/archive';
if (is_link($archive)) throw new \RuntimeException('Archive symlink refused');
if (!is_dir($archive)&&!mkdir($archive,0700)) throw new \RuntimeException('Archive directory unavailable');
if ((fileperms($archive)&0007)!==0) throw new \RuntimeException('Archive must remain private');
$lockPath=$directory.'/.writer.lock';
if (is_link($lockPath)) throw new \RuntimeException('Writer lock symlink refused');
$lock=fopen($lockPath,'c+b');
if ($lock===false) throw new \RuntimeException('Archive lock unavailable');
$done=[];
try {
if (!flock($lock,LOCK_EX|LOCK_NB)) return ['archived'=>0,'status'=>'writer_busy'];
foreach (glob($directory.'/raw-*.jsonl')?:[] as $file) {
$name=basename($file);
if (!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$name)||$name>=$day||$name>=$cutoff) continue;
if (is_link($file)||realpath($file)!==$file||!is_file($file)) throw new \RuntimeException('Unexpected journal');
$before=stat($file);
if (!$before||$before['size']>67108864) throw new \RuntimeException('Archive input bounds');
$digest=hash_file('sha256',$file);
if (!is_string($digest)) throw new \RuntimeException('Cannot hash journal');
$target=$archive.'/'.$name.'.'.$digest.'.gz';
if (is_link($target)) throw new \RuntimeException('Archive target symlink');
if (!is_file($target)) {
$tmp=tempnam($archive,'.pending-');
if ($tmp===false) throw new \RuntimeException('Cannot create archive');
try {
chmod($tmp,0600);$in=fopen($file,'rb');$gz=gzopen($tmp,'wb6');
if ($in===false||$gz===false) {if(is_resource($in))fclose($in);if(is_resource($gz))gzclose($gz);throw new \RuntimeException('Cannot open archive streams');}
try {
while (!feof($in)) {
$block=fread($in,65536);
if ($block===false) throw new \RuntimeException('Archive read failed');
$offset=0;
while($offset<strlen($block)) {$n=gzwrite($gz,substr($block,$offset));if($n===false||$n===0)throw new \RuntimeException('Archive write failed');$offset+=$n;}
}
} finally {fclose($in);gzclose($gz);}
$sync=fopen($tmp,'r+b');
if ($sync===false) throw new \RuntimeException('Cannot sync archive');
try {if(!fsync($sync))throw new \RuntimeException('Archive sync failed');} finally {fclose($sync);}
if (!rename($tmp,$target)) throw new \RuntimeException('Archive rename failed');
} finally {if(is_file($tmp))unlink($tmp);}
}
$gz=gzopen($target,'rb');if($gz===false)throw new \RuntimeException('Cannot verify archive');
$hash=hash_init('sha256');$bytes=0;
try {while(!gzeof($gz)){$block=gzread($gz,65536);if($block===false)throw new \RuntimeException('Archive verification read failed');$bytes+=strlen($block);if($bytes>$before['size'])throw new \RuntimeException('Oversized decompressed archive');hash_update($hash,$block);}}
finally {gzclose($gz);}
clearstatcache(true,$file);$after=stat($file);
if ($bytes!==$before['size']||hash_final($hash)!==$digest||!$after
||$after['size']!==$before['size']||$after['ino']!==$before['ino']||$after['mtime']!==$before['mtime']
||hash_file('sha256',$file)!==$digest) throw new \RuntimeException('Archive mismatch; original retained');
$directoryHandle=fopen($archive,'r');
if ($directoryHandle===false) throw new \RuntimeException('Cannot sync archive directory');
try {if(!fsync($directoryHandle))throw new \RuntimeException('Archive directory sync failed');} finally {fclose($directoryHandle);}
if (!unlink($file)) throw new \RuntimeException('Verified original could not be retired');
$done[]=$name;
if (count($done)>=2) break;
}
} finally {flock($lock,LOCK_UN);fclose($lock);}
return ['archived'=>count($done),'compressedOriginalsRetained'=>true,'unacknowledgedDaysTouched'=>false];
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
/**
* Binds values to explicitly successful device reads.
*
* Transport calls, object metadata and the clock are injected so the complete
* coordination can be tested without IP-Symcon or real devices.
*/
final class NetzfahrplanV4Geraeteabruf
{
public const MBUS_DEVICE = '{B53BE2E5-892D-4537-94AC-EAC68A469188}';
public const MODBUS_DEVICE = '{90BF9234-B44C-7EB3-280B-FBD6075ADDAA}';
public const MODBUS_ADDRESS = '{CB197E50-273D-4535-8C91-BB35273E3CA5}';
public static function snapshot(
array $config,
callable $moduleId,
callable $refresh,
callable $reader,
callable $clock,
?callable $pause = null
): array {
$config = NetzfahrplanV4Rueckmeldung::configuration($config);
$groups = [];
$sourcesById = [];
foreach ($config['sources'] as $source) {
$sourcesById[$source['variableId']] = $source;
if (in_array($source['role'], ['grid', 'physical'], true)) {
$groups[$source['parentId']][] = $source;
}
}
$snapshot = [];
foreach ($groups as $parentId => $sources) {
$module = $moduleId($parentId);
if (!in_array($module, [self::MBUS_DEVICE, self::MODBUS_DEVICE, self::MODBUS_ADDRESS], true)) {
throw new InvalidArgumentException('feedback_refresh_module_unsupported:' . $sources[0]['key']);
}
$confirmed = false;
for ($attempt = 1; $attempt <= 4; ++$attempt) {
if ($refresh($parentId, $module) === true) {
$confirmed = true;
break;
}
if ($attempt < 4 && $pause !== null) {
$pause(200000);
}
}
if (!$confirmed) {
throw new InvalidArgumentException('feedback_device_read_failed:' . $sources[0]['key']);
}
$confirmedAt = $clock();
if (!is_int($confirmedAt) || $confirmedAt < 1) {
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
}
foreach ($sources as $source) {
$record = $reader($source['variableId']);
if (!is_array($record)) {
throw new InvalidArgumentException('feedback_reader_invalid:' . $source['key']);
}
$record['confirmedAt'] = $confirmedAt;
$record['confirmedModuleId'] = $module;
$snapshot[$source['variableId']] = $record;
}
}
foreach ($config['sources'] as $source) {
if (!isset($snapshot[$source['variableId']])) {
$record = $reader($source['variableId']);
if (!is_array($record)) {
throw new InvalidArgumentException('feedback_reader_invalid:' . $source['key']);
}
$snapshot[$source['variableId']] = $record;
}
}
foreach ($snapshot as $variableId => $record) {
$again = $reader($variableId);
// The variable may legitimately receive a newer process value immediately after
// the explicit device read. Keep the confirmed snapshot, but require the source
// identity to remain stable so a reconfigured/reparented variable is never accepted.
$identityKeys = ['parentID', 'ident'];
if (!is_array($again) || array_intersect_key($again, array_flip($identityKeys))
!== array_intersect_key($record, array_flip($identityKeys))) {
throw new InvalidArgumentException(
'feedback_source_changed_after_confirmed_read:' . $sourcesById[$variableId]['key']
);
}
}
$checkedAt = $clock();
if (!is_int($checkedAt) || $checkedAt < 1) {
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
}
foreach ($snapshot as $record) {
if (isset($record['confirmedAt']) && $record['confirmedAt'] > $checkedAt) {
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
}
}
return NetzfahrplanV4Rueckmeldung::read(
$config,
static fn(int $variableId): array => $snapshot[$variableId],
$checkedAt
);
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Bilanzierung.php';
/** Passive, versioned RAW capture. Never grants training/control eligibility.
* Reader supplies numeric values only; no configuration, credentials or network.
*/
final class NetzfahrplanV4Messaufnahme
{
public static function configuration(array $c): array
{
if (($c['version'] ?? null) !== 1 || !is_string($c['installationId'] ?? null)
|| !preg_match('/^[0-9a-f-]{36}$/D', $c['installationId'])
|| !is_array($c['extraSources'] ?? null) || count($c['extraSources']) > 60
|| !is_string($c['reportedInventorySha256'] ?? null)
|| !preg_match('/^[0-9a-f]{64}$/D', $c['reportedInventorySha256'])) {
throw new InvalidArgumentException('Invalid capture configuration');
}
$c['accounting'] = NetzfahrplanV4Bilanzierung::konfigurieren($c['accounting'] ?? []);
$keys = []; $ids = [];
foreach ($c['accounting']['sources'] as $s) { $keys[$s['key']] = true; $ids[$s['variableId']] = true; }
foreach ($c['extraSources'] as $s) {
if (count($s) !== 5 || !is_string($s['key'] ?? null)
|| !preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/D', $s['key']) || isset($keys[$s['key']])
|| !is_int($s['variableId'] ?? null) || $s['variableId'] < 1 || isset($ids[$s['variableId']])
|| !is_int($s['parentId'] ?? null) || $s['parentId'] < 1
|| !is_string($s['ident'] ?? null) || strlen($s['ident']) > 100
|| !in_array($s['unit'] ?? null, ['W','kWh','percent','raw_power','scale'], true)) {
throw new InvalidArgumentException('Invalid or duplicate raw source');
}
$keys[$s['key']] = true; $ids[$s['variableId']] = true;
}
usort($c['extraSources'], static fn(array $a, array $b): int => strcmp($a['key'], $b['key']));
return $c;
}
private static function readSource(array $s, callable $read): array
{
try {
$r = $read($s['variableId']);
if (!is_array($r)) throw new RuntimeException('Missing reading');
} catch (Throwable $e) {
return ['value' => null, 'sourceUpdatedAt' => null, 'sourceChangedAt' => null, 'issues' => ['read_failed']];
}
$issues = [];
$v = $r['value'] ?? null;
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v) || abs((float)$v) > 1.0e12) {
$v = null; $issues[] = 'invalid_numeric_value';
}
$at = $r['updated'] ?? null;
if (!is_int($at) || $at < 1) { $at = null; $issues[] = 'missing_original_timestamp'; }
$changed = $r['changed'] ?? null;
if (!is_int($changed) || $changed < 0) $changed = null;
if (($r['parentID'] ?? null) !== $s['parentId'] || ($r['ident'] ?? null) !== $s['ident']) {
// Do not record a numeric value from an object re-used for a different purpose.
$v = null; $issues[] = 'source_identity_mismatch';
}
return ['value' => $v, 'sourceUpdatedAt' => $at, 'sourceChangedAt' => $changed, 'issues' => $issues];
}
public static function capture(array $config, callable $read, callable $clock): array
{
$c = self::configuration($config);
$start = $clock();
if (!is_int($start) || $start <= 0) throw new InvalidArgumentException('Integer UTC epoch required');
$sources = array_merge($c['accounting']['sources'], $c['extraSources']);
$first = []; $second = []; $raw = []; $issues = [];
foreach ($sources as $s) $first[$s['key']] = self::readSource($s, $read);
foreach ($sources as $s) $second[$s['key']] = self::readSource($s, $read);
$end = $clock();
if (!is_int($end) || $end < $start) throw new RuntimeException('Acquisition clock moved backwards');
foreach ($sources as $s) {
$key = $s['key']; $r = $first[$key];
if ($r !== $second[$key]) {
$r['issues'][] = 'changed_during_capture';
$r['secondValue'] = $second[$key]['value'];
$r['secondUpdatedAt'] = $second[$key]['sourceUpdatedAt'];
}
if ($r['sourceUpdatedAt'] !== null && $r['sourceUpdatedAt'] > $end) $r['issues'][] = 'future_source';
$r['sourceAgeSeconds'] = $r['sourceUpdatedAt'] === null ? null : $end - $r['sourceUpdatedAt'];
$raw[$key] = ['variableId' => $s['variableId']] + $r;
foreach ($r['issues'] as $issue) $issues[] = $key . ':' . $issue;
}
$assessment = null; $accountingUsable = true; $samples = [];
foreach ($c['accounting']['sources'] as $s) {
$r = $raw[$s['key']];
if ($r['issues'] !== []) $accountingUsable = false;
$samples[$s['variableId']] = ['value' => $r['value'], 'updated' => $r['sourceUpdatedAt'], 'parentID' => $s['parentId'], 'ident' => $s['ident']];
}
if ($accountingUsable) {
try { $assessment = NetzfahrplanV4Bilanzierung::aufnehmen($c['accounting'], static fn(int $id): array => $samples[$id], $end); }
catch (Throwable $e) { $issues[] = 'accounting_input_invalid'; }
} else { $issues[] = 'accounting_snapshot_unusable'; }
if ($end - $start > 5) $issues[] = 'long_acquisition';
return ['schemaVersion' => 1, 'kind' => 'raw_accounting_capture', 'installationId' => $c['installationId'],
'captureStartedAt' => gmdate('c', $start), 'capturedAt' => gmdate('c', $end),
'mappingSha256' => hash('sha256', json_encode($c, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)),
'reportedInventorySha256' => $c['reportedInventorySha256'],
'timestampMeaning' => 'Symcon VariableUpdated; not proof of an independent device timestamp',
'raw' => $raw, 'issues' => $issues, 'assessment' => $assessment,
'trainingEligible' => false, 'controlEligible' => false, 'measurementBoundaryVerified' => false,
'futureSdl' => 'unknown_no_assumption', 'historicalDataRewritten' => false];
}
/** Append-only daily JSONL with exclusive lock, bounds and crash-tail detection.
* No retention deletion; quota stops capture visibly, never deletes old histories.
*/
public static function append(string $directory, array $record, int $quota = 536870912): string
{
if (!is_dir($directory) || is_link($directory) || realpath($directory) !== $directory || $quota < 1) {
throw new RuntimeException('Invalid dedicated data directory');
}
$json = json_encode($record, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) . "\n";
if (strlen($json) > 262144) throw new RuntimeException('Capture exceeds record limit');
$epoch = strtotime($record['capturedAt'] ?? '');
if ($epoch === false) throw new RuntimeException('Capture timestamp missing');
$file = $directory . '/raw-' . gmdate('Ymd', $epoch) . '.jsonl';
$lockPath = $directory . '/.writer.lock';
if (is_link($lockPath) || is_link($file)) throw new RuntimeException('Symbolic link refused');
$lock = fopen($lockPath, 'c+b');
if ($lock === false) throw new RuntimeException('Capture lock unavailable');
try {
if (!flock($lock, LOCK_EX | LOCK_NB)) throw new RuntimeException('Capture writer busy');
clearstatcache(); $used = 0; $files = glob($directory . '/raw-*.jsonl');
if ($files === false || count($files) > 400) throw new RuntimeException('Data directory bounds exceeded');
foreach ($files as $p) {
if (is_link($p)) throw new RuntimeException('Symbolic journal refused');
$size = filesize($p); if ($size === false) throw new RuntimeException('Cannot size journal');
$used += $size;
}
if ($used + strlen($json) > $quota) throw new RuntimeException('Capture quota reached; no data deleted');
$free = disk_free_space($directory);
if ($free === false || $free < 104857600 + strlen($json)) throw new RuntimeException('Insufficient free disk; capture paused');
$h = fopen($file, 'c+b');
if ($h === false) throw new RuntimeException('Cannot open capture journal');
try {
fseek($h, 0, SEEK_END); $old = ftell($h);
if ($old === false || $old + strlen($json) > 67108864) throw new RuntimeException('Daily capture bounds exceeded');
if ($old > 0) {
fseek($h, -1, SEEK_END);
if (fread($h, 1) !== "\n") throw new RuntimeException('Incomplete journal tail; preserve for review');
}
fseek($h, 0, SEEK_END); $n = 0;
try {
while ($n < strlen($json)) {
$written = fwrite($h, substr($json, $n));
if ($written === false || $written === 0) throw new RuntimeException('Capture write incomplete');
$n += $written;
}
if (!fflush($h)) throw new RuntimeException('Capture flush failed');
} catch (Throwable $e) { ftruncate($h, $old); fflush($h); throw $e; }
if (!chmod($file, 0640)) throw new RuntimeException('Cannot restrict journal permissions');
} finally { fclose($h); }
return $file;
} finally { flock($lock, LOCK_UN); fclose($lock); }
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
use InvalidArgumentException;
/** Independent physical-load and virtual-allocation views. Observation ONLY.
* The unfiltered partition is an ACCOUNTING MODEL, not two measured powers.
* No IPS calls, device writes, SOC integration, training or dispatch permission.
*/
final class NetzfahrplanV4Messsicht
{
private static function numeric($v): bool
{
return (is_int($v) || is_float($v)) && is_finite((float)$v) && abs($v) <= 1e12;
}
public static function policy(array $p, array $c): array
{
$expected = ['version','evRequestKey','sdlRequestKey','extraMaxAgeSeconds','trackingToleranceW','solarReference'];
if (count($p) !== count($expected) || array_diff($expected,array_keys($p)) || $p['version'] !== 1
|| !is_int($p['extraMaxAgeSeconds']) || $p['extraMaxAgeSeconds'] < 1 || $p['extraMaxAgeSeconds'] > 300
|| !self::numeric($p['trackingToleranceW']) || $p['trackingToleranceW'] < 0 || $p['trackingToleranceW'] > 1000) {
throw new InvalidArgumentException('Invalid measurement-view policy');
}
$extra = array_column($c['extraSources'], null, 'key');
foreach (['evRequestKey','sdlRequestKey'] as $k) {
if (!is_string($p[$k]) || !isset($extra[$p[$k]]) || $extra[$p[$k]]['unit'] !== 'W') {
throw new InvalidArgumentException('Explicit separate request channels required');
}
}
if ($p['evRequestKey'] === $p['sdlRequestKey']) throw new InvalidArgumentException('Duplicate request channel');
if ($p['solarReference'] !== null) {
$s=$p['solarReference']; $keys=['pvKey','batteryKey','rawKey','scaleKey'];
if (!is_array($s) || count($s)!==4 || array_diff($keys,array_keys($s))) throw new InvalidArgumentException('Invalid solar-origin policy');
foreach ($keys as $k) if (!is_string($s[$k])) throw new InvalidArgumentException('Invalid solar-origin key');
$primary=array_column($c['accounting']['sources'],null,'key');
if (($primary[$s['pvKey']]['role']??null)!=='pv' || ($primary[$s['batteryKey']]['role']??null)!=='physical_storage'
|| ($extra[$s['rawKey']]['unit']??null)!=='raw_power' || ($extra[$s['scaleKey']]['unit']??null)!=='scale') {
throw new InvalidArgumentException('Solar references must be explicit original sources');
}
}
return $p;
}
public static function calculate(array $r, array $config, array $policy): array
{
$c=NetzfahrplanV4Messaufnahme::configuration($config);$p=self::policy($policy,$c);
$mapping=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
if (($r['kind']??null)!=='raw_accounting_capture' || ($r['schemaVersion']??null)!==1
|| ($r['installationId']??null)!==$c['installationId'] || ($r['mappingSha256']??null)!==$mapping
|| ($r['reportedInventorySha256']??null)!==$c['reportedInventorySha256']) throw new InvalidArgumentException('Wrong capture identity');
foreach (['capturedAt','captureStartedAt'] as $k) {
if (!is_string($r[$k]??null) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$r[$k])) throw new InvalidArgumentException('Explicit UTC capture time required');
$date=new \DateTimeImmutable($r[$k]);$err=\DateTimeImmutable::getLastErrors();
if ($err!==false && ($err['warning_count']||$err['error_count'])) throw new InvalidArgumentException('Invalid capture time');
$time[$k]=$date->getTimestamp();
}
$now=$time['capturedAt'];$duration=$now-$time['captureStartedAt'];
if ($duration<0) throw new InvalidArgumentException('Reversed acquisition clock');
$sources=array_column(array_merge($c['accounting']['sources'],$c['extraSources']),null,'key');
$v=[];
foreach ($sources as $key=>$s) {
$raw=$r['raw'][$key]??[]; $issues=[]; $value=$raw['value']??null; $updated=$raw['sourceUpdatedAt']??null;
if (($raw['variableId']??null)!==$s['variableId']) {$issues[]='identity';$value=null;}
if (!self::numeric($value)) {$issues[]='not_numeric';$value=null;}
if (!is_int($updated) || $updated<=0) {$issues[]='timestamp';$updated=null;}
if (($raw['issues']??null)!==[]) $issues[]='capture_quality'; // never copy arbitrary source text
if ($updated!==null && ($updated>$now || $now-$updated>($s['maxAgeSeconds']??$p['extraMaxAgeSeconds']))) $issues[]='source_age';
$scaled=$value===null?null:(float)$value*($s['factorToW']??1.0);
if ($scaled!==null && (!is_finite($scaled)||abs($scaled)>1e12)) {$scaled=null;$issues[]='converted_value';}
$changed=$raw['sourceChangedAt']??null;
if (!is_int($changed)||$changed<0||$updated===null||$changed>$updated) $changed=null;
$v[$key]=['value'=>$scaled,'updated'=>$updated,'changed'=>$changed,'issues'=>$issues];
}
$closure=function(array $keys)use(&$closure,$sources):array {
$out=[];foreach($keys as $key){if(!isset($sources[$key]))throw new InvalidArgumentException('Missing origin');$out[]=$key;
foreach($closure($sources[$key]['dependsOn']??[]) as $dep)$out[]=$dep;}
return array_values(array_unique($out));
};
$quality=function(array $keys)use($closure,$v,$c,$duration):array {
$keys=$closure($keys);$issues=[];$ts=[];
foreach($keys as $key){foreach($v[$key]['issues'] as $i)$issues[]=$key.':'.$i;if($v[$key]['updated']!==null)$ts[]=$v[$key]['updated'];}
$skew=$ts===[]?null:max($ts)-min($ts);
if($duration>5)$issues[]='long_acquisition';
if($skew!==null && $skew>$c['accounting']['maxSkewSeconds'])$issues[]='source_time_skew';
return ['status'=>$issues===[]?'consistent_candidate':'quality_hold','issues'=>$issues,'oldestSourceUpdatedAt'=>$ts===[]?null:min($ts),'sourceSkewSeconds'=>$skew];
};
$byRole=[];foreach($c['accounting']['sources'] as $s)$byRole[$s['role']][]=$s['key'];
$physical=$byRole['physical_storage'];$pv=$byRole['pv'];$grid=$byRole['grid'];$flex=$byRole['flexible_load']??[];
$sum=function(array $keys)use($v):?float { $total=0.0;foreach($keys as $key){if($v[$key]['value']===null)return null;$total+=$v[$key]['value'];}return $total;};
$storage=$quality($physical);$storage['physicalStorageCandidateW']=$sum($physical);
$solar=['status'=>'not_configured','acTerminalCandidateW'=>null,'reconstructedPvCandidateW'=>null,'derivedPvDifferenceW'=>null,'issues'=>['origin_not_configured']];
$loadKeys=array_merge($grid,$pv,$physical,$flex);$sr=$p['solarReference'];
if($sr!==null){
$origin=[$sr['rawKey'],$sr['scaleKey'],$sr['batteryKey']];$loadKeys=array_merge($loadKeys,$origin);
$solar=$quality($origin);$raw=$v[$sr['rawKey']]['value'];$sf=$v[$sr['scaleKey']]['value'];
$solar+=['acTerminalCandidateW'=>null,'reconstructedPvCandidateW'=>null,'derivedPvDifferenceW'=>null];
// int16 invalid sentinel excluded, including scale sentinel; no unsigned guessing.
if($raw===null||$sf===null||floor($raw)!==$raw||$raw<=-32768||$raw>32767||floor($sf)!==$sf||$sf < -6||$sf>6){
$solar['status']='quality_hold';$solar['issues'][]='invalid_raw_or_scale';
}else{
$ac=$raw*(10**(int)$sf);
if(abs($ac)>1e9){$solar['status']='quality_hold';$solar['issues'][]='scaled_power_range';}
else{$solar['acTerminalCandidateW']=$ac;$b=$v[$sr['batteryKey']]['value'];
if($b!==null)$solar['reconstructedPvCandidateW']=$ac+$b;
$reported=$v[$sr['pvKey']]['value'];
if($reported!==null&&$solar['reconstructedPvCandidateW']!==null)$solar['derivedPvDifferenceW']=$reported-max(0.0,$solar['reconstructedPvCandidateW']);}
}
}
$load=$quality($loadKeys);$net=$sum($grid);$pvSum=$sum($pv);$phys=$sum($physical);$fl=$sum($flex);
$candidate=($net===null||$pvSum===null||$phys===null||$fl===null)?null:$net+$pvSum-$phys-$fl;
if($candidate!==null&&$candidate<0){$load['status']='quality_hold';$load['issues'][]='negative_load';}
foreach(array_merge($pv,$flex) as $key)if($v[$key]['value']!==null&&$v[$key]['value']<0){$load['status']='quality_hold';$load['issues'][]='negative_unsigned:'.$key;}
if($sr!==null&&$solar['status']!=='consistent_candidate'){$load['status']='quality_hold';$load['issues'][]='solar_origin_unverified_value';}
$load+=['loadCandidateW'=>$candidate,'gridW'=>$net,'pvCandidateW'=>$pvSum,'physicalStorageCandidateW'=>$phys,'flexibleLoadW'=>$fl,
'virtualAllocationUsed'=>false,'measurementBoundaryVerified'=>false];
// AC terminal alternative eliminates SolarEdge battery/PV cancellation and zero-clamp artefact.
$alternative=null;$altQuality=null;
if($sr!==null){
$altKeys=array_values(array_diff(array_merge($grid,$pv,$physical,$flex),[$sr['pvKey'],$sr['batteryKey']]));
$altKeys=array_merge($altKeys,[$sr['rawKey'],$sr['scaleKey']]);$altQuality=$quality($altKeys);
$pvRest=$sum(array_values(array_diff($pv,[$sr['pvKey']])));$bRest=$sum(array_values(array_diff($physical,[$sr['batteryKey']])));
if($net!==null&&$pvRest!==null&&$bRest!==null&&$fl!==null&&$solar['acTerminalCandidateW']!==null){
$alternative=$net+$pvRest-$bRest+$solar['acTerminalCandidateW']-$fl;
if($alternative<0){$altQuality['status']='quality_hold';$altQuality['issues'][]='negative_load';}
}else{$altQuality['status']='quality_hold';$altQuality['issues'][]='solar_ac_unavailable';}
foreach(array_merge(array_values(array_diff($pv,[$sr['pvKey']])),$flex) as $key)if($v[$key]['value']!==null&&$v[$key]['value']<0){$altQuality['status']='quality_hold';$altQuality['issues'][]='negative_unsigned:'.$key;}
$altQuality+=['loadCandidateW'=>$alternative,'measurementBoundaryVerified'=>false,'solarBatterySubtractedAgain'=>false];
}
$requestKeys=[$p['evRequestKey'],$p['sdlRequestKey']];$alloc=$quality(array_merge($physical,$requestKeys));
$ev=$v[$p['evRequestKey']]['value'];$sdl=$v[$p['sdlRequestKey']]['value'];$evModel=null;$sdlModel=null;$unallocated=$phys;$delta=null;
foreach($requestKeys as $key){
if($v[$key]['changed']===null){$alloc['issues'][]='request_change_time_unknown:'.$key;$alloc['status']='quality_hold';}
elseif($storage['oldestSourceUpdatedAt']!==null && $v[$key]['changed']>$storage['oldestSourceUpdatedAt']){$alloc['issues'][]='request_newer_than_feedback:'.$key;$alloc['status']='quality_hold';}
}
if($phys!==null&&$ev!==null&&$sdl!==null){
$abs=abs($ev)+abs($sdl);$delta=$phys-$ev-$sdl;
if($abs>=1.0){$evModel=$ev+$delta*abs($ev)/$abs;$sdlModel=$phys-$evModel;$unallocated=0.0;
if(abs($delta)>$p['trackingToleranceW']){$alloc['issues'][]='physical_request_tracking_error';$alloc['status']='quality_hold';}
}else{$alloc['issues'][]='idle_power_unallocated';$alloc['status']='quality_hold';}
}
$reportedEv=$sum($byRole['controlled_virtual']);$reportedSdl=$sum($byRole['external_virtual']??[]);
$reportedResidual=($phys===null||$reportedEv===null||$reportedSdl===null)?null:$phys-$reportedEv-$reportedSdl;
$alloc+=['physicalStorageCandidateW'=>$phys,'requestedEvW'=>$ev,'requestedSdlW'=>$sdl,'unfilteredModelEvW'=>$evModel,
'unfilteredModelSdlW'=>$sdlModel,'unallocatedPhysicalW'=>$unallocated,'physicalRequestErrorW'=>$delta,
'reportedEvW'=>$reportedEv,'reportedSdlW'=>$reportedSdl,'reportedAllocationResidualW'=>$reportedResidual,
'reportedVirtualQuality'=>$quality(array_merge($byRole['controlled_virtual'],$byRole['external_virtual']??[])),
'underlyingTimesMeaning'=>'Symcon VariableUpdated; not independently measured EV/SDL channel times',
'reportedAllocationWithinTolerance'=>$reportedResidual!==null&&abs($reportedResidual)<=$c['accounting']['splitToleranceW'],
'separatelyMeasured'=>false,'method'=>'request_weighted_residual_unfiltered_not_unique_measurement'];
return ['schemaVersion'=>1,'kind'=>'separated_measurement_views','installationId'=>$c['installationId'],'capturedAt'=>$r['capturedAt'],
'mappingSha256'=>$mapping,'policySha256'=>hash('sha256',json_encode($p,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION)),
'physicalLoad'=>$load,'storageFeedback'=>$storage,'allocationModel'=>$alloc,'solarOrigin'=>$solar,'solarAcAlternative'=>$altQuality,
'trainingEligible'=>false,'canDispatch'=>false,'controlEnabled'=>false,'socAccountsChanged'=>false,'futureSdl'=>'unknown_no_assumption',
'note'=>'Observation only. Source times are Symcon update times. Algebraic partition is not separate EV/SDL measurement. GoodWe and combined AC/DC boundaries remain unverified.'];
}
}
+310
View File
@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
/** Pure observation-only validator. No IPS calls, transport, timers or actuator writes. */
final class NetzfahrplanV4Planpruefung
{
public const MAX_RESPONSE_BYTES = 2000000;
public const MAX_PLAN_AGE = 900;
public const MAX_RECEIPT_AGE = 90;
private static function liste(array $value): bool
{
return $value === [] || array_keys($value) === range(0, count($value) - 1);
}
public static function zeit($value): int
{
if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D', $value)) {
throw new InvalidArgumentException('Zeitpunkt ohne eindeutige Zeitzone.');
}
$time = new DateTimeImmutable($value);
$errors = DateTimeImmutable::getLastErrors();
if ($errors !== false && ($errors['warning_count'] || $errors['error_count'])) {
throw new InvalidArgumentException('Ungueltiger Kalenderzeitpunkt.');
}
return $time->getTimestamp();
}
private static function zahl($value, string $name, float $low = -1.0e9, float $high = 1.0e9): float
{
if ((!is_float($value) && !is_int($value)) || !is_finite((float) $value) || $value < $low || $value > $high) {
throw new InvalidArgumentException($name . ': endlicher Zahlenwert erforderlich.');
}
return (float) $value;
}
private static function uuid($value): string
{
if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) {
throw new InvalidArgumentException('Ungueltige UUID.');
}
return $value;
}
public static function dekodieren(string $raw): array
{
if (strlen($raw) > self::MAX_RESPONSE_BYTES) {
throw new InvalidArgumentException('V4-Antwort zu gross.');
}
$result = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
if (!is_array($result) || self::liste($result)) {
throw new InvalidArgumentException('V4-Antwort ist kein Objekt.');
}
return $result;
}
/** Stable local planning policy. Volatile SOC/availability are checked separately. */
public static function kontext(array $operation): array
{
$assets = [];
foreach ($operation['batteries'] ?? [] as $b) {
if (!is_array($b) || !is_string($b['id'] ?? null) || isset($assets[$b['id']])) {
throw new InvalidArgumentException('Batteriezuordnung ungueltig.');
}
$assets[$b['id']] = [
'capacityKwh' => self::zahl($b['capacityKwh'] ?? null, 'Kapazitaet', .001),
'minSocPercent' => self::zahl($b['minSocPercent'] ?? null, 'Reserve', 0, 100),
'maxSocPercent' => self::zahl($b['maxSocPercent'] ?? null, 'Maximal-SOC', 0, 100),
'physicalMinSocPercent' => self::zahl($b['physicalMinSocPercent'] ?? 0.0, 'Minimum', 0, 100),
'rearmSocPercent' => self::zahl($b['rearmSocPercent'] ?? $b['minSocPercent'], 'Hysterese', 0, 100),
'gridCharging' => $b['gridCharging'] ?? null,
];
if (!is_bool($assets[$b['id']]['gridCharging'])) {
throw new InvalidArgumentException('Netzladefreigabe fehlt.');
}
}
if (!$assets || count($assets) > 20 || !is_array($operation['limits'] ?? null)) {
throw new InvalidArgumentException('Batterien oder lokale Grenzen fehlen.');
}
ksort($assets);
$limits = $operation['limits'];
$expected = ['importW', 'exportW', 'managerMonthLimitsW'];
if (count($limits) !== 3 || array_diff($expected, array_keys($limits)) || !is_array($limits['managerMonthLimitsW'])) {
throw new InvalidArgumentException('Unvollstaendige lokale Grenzen.');
}
foreach (['importW', 'exportW'] as $key) {
if ($limits[$key] !== null) $limits[$key] = self::zahl($limits[$key], 'Grenze', 0);
}
foreach ($limits['managerMonthLimitsW'] as $month => $limit) {
if ((string) (int) $month !== (string) $month || (int) $month < 1 || (int) $month > 12) {
throw new InvalidArgumentException('Ungueltige Monatsgrenze.');
}
$limits['managerMonthLimitsW'][$month] = self::zahl($limit, 'Monatsgrenze', 0);
}
ksort($limits['managerMonthLimitsW']);
return ['batteries' => $assets, 'limits' => $limits];
}
/** Authenticated endpoint must echo installation identity; old protocol is refused. */
public static function pruefen(array $response, string $installation, array $operation, int $now, int $receivedAt): array
{
self::uuid($installation);
if (($response['receiverProtocolVersion'] ?? null) !== 1
|| ($response['installationId'] ?? null) !== $installation
|| ($response['liveEnabled'] ?? null) !== false) {
throw new InvalidArgumentException('V4-Protokoll/Anlagenzuordnung oder Schattenmodus ungueltig.');
}
if ($receivedAt > $now || $now - $receivedAt > self::MAX_RECEIPT_AGE) {
throw new InvalidArgumentException('Empfang veraltet; Vorschau verworfen.');
}
$serverAt = self::zeit($response['checkedAt'] ?? null);
if ($serverAt > $now + 5 || $now - $serverAt > self::MAX_RECEIPT_AGE) {
throw new InvalidArgumentException('Serverantwort nicht aktuell.');
}
if (($response['fresh'] ?? null) !== true || ($response['pending'] ?? null) !== null) {
throw new InvalidArgumentException('Server hat noch keinen aktuellen freigegebenen Schattenplan.');
}
$settings = $response['settings'] ?? [];
$p = $response['plan'] ?? [];
if (!is_array($p) || !is_int($settings['revision'] ?? null) || $settings['revision'] < 0
|| ($p['schemaVersion'] ?? null) !== 2 || ($p['installationId'] ?? null) !== $installation
|| ($p['configRevision'] ?? null) !== $settings['revision']
|| ($settings['runMode'] ?? null) !== 'shadow'
|| ($p['runMode'] ?? null) !== 'shadow' || ($p['liveEnabled'] ?? null) !== false
|| ($p['executable'] ?? null) !== true
|| !in_array($p['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) {
throw new InvalidArgumentException('Planrevision, Status oder Schattenmodus ungueltig.');
}
self::uuid($p['planId'] ?? null);
if (($response['lastRun']['detail']['planId'] ?? null) !== $p['planId']
|| !in_array($response['lastRun']['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) {
throw new InvalidArgumentException('Letzte Rechnung passt nicht zum empfangenen Plan.');
}
$families = array_column($response['families'] ?? [], 'key');
if (!is_string($p['sourceFamily'] ?? null) || !in_array($p['sourceFamily'], $families, true)
|| (($settings['family'] ?? null) !== 'auto' && $settings['family'] !== $p['sourceFamily'])) {
throw new InvalidArgumentException('Modellfamilie unbekannt oder nicht ausgewaehlt.');
}
$generated = self::zeit($p['generatedAt'] ?? null);
$start = self::zeit($p['validFrom'] ?? null);
$until = self::zeit($p['validUntil'] ?? null);
if ($generated > $now || $now - $generated > self::MAX_PLAN_AGE || $start > $now || $until <= $now
|| $start !== $generated || $until - $start > 48 * 3600
|| self::zeit($p['pricesKnownUntil'] ?? null) < $until) {
throw new InvalidArgumentException('Plan abgelaufen, zukuenftig oder ohne bekannten Preishorizont.');
}
$opAt = self::zeit($operation['observedAt'] ?? null);
if ($opAt > $now || $now - $opAt > 60) {
throw new InvalidArgumentException('Lokale Betriebsdaten veraltet.');
}
$context = self::kontext($operation);
$pc = $p['controlContext'] ?? null;
if (!is_array($pc) || count($pc) !== 2 || !is_array($pc['batteries'] ?? null) || !is_array($pc['limits'] ?? null)) {
throw new InvalidArgumentException('Planungskontext fehlt.');
}
$policyAssets = [];
foreach ($pc['batteries'] as $id => $policy) {
if (!is_array($policy) || count($policy) !== 6) {
throw new InvalidArgumentException('Unbekannter Batteriekontext.');
}
$policyAssets[] = ['id' => $id] + $policy;
}
// Normalize explicitly typed numeric values; strings/bools are not numbers.
$plannedContext = self::kontext(['batteries' => $policyAssets, 'limits' => $pc['limits']]);
if ($plannedContext != $context) {
throw new InvalidArgumentException('Lokale Batterie-/Grenzkonfiguration passt nicht zum Plan.');
}
if (!is_array($p['inputRefs'] ?? null) || !is_string($p['inputRefs']['operation'] ?? null)
|| $p['inputRefs']['operation'] === '') {
throw new InvalidArgumentException('Plan ohne nachvollziehbare Eingangsdaten.');
}
self::zahl($settings['roundtripEfficiency'] ?? null, 'Wirkungsgrad', .01, 1);
$points = $p['points'] ?? null;
if (!is_array($points) || !self::liste($points) || count($points) < 1 || count($points) > 576) {
throw new InvalidArgumentException('Ungueltige Zeitschrittliste.');
}
$current = null;
$last = $start;
$ids = array_keys($context['batteries']);
foreach ($points as $point) {
$a = self::zeit($point['time'] ?? null);
$b = self::zeit($point['validUntil'] ?? null);
if ($a !== $last || $b <= $a || $b - $a > 300 || $b % 300 !== 0) {
throw new InvalidArgumentException('Luecke/Ueberlappung oder falsches Fuenfminutenraster.');
}
$last = $b;
$grid = self::zahl($point['gridTargetW'] ?? null, 'Netzziel');
$bat = self::zahl($point['batteryTargetW'] ?? null, 'Batterieziel');
$base = self::zahl($point['baselineGridW'] ?? null, 'Basisnetz');
$curtail = self::zahl($point['pvCurtailmentW'] ?? null, 'PV-Abregelung', 0);
if (abs($base + $bat + $curtail - $grid) > 1.0) {
throw new InvalidArgumentException('Leistungsbilanz des Plans verletzt.');
}
if (!in_array($point['intent'] ?? '', ['hold', 'gridCharge', 'pvCharge', 'discharge', 'export'], true)) {
throw new InvalidArgumentException('Unbekannte Planabsicht.');
}
$targets = $point['assetTargetsW'] ?? null;
$socs = $point['socEndPercent'] ?? null;
if (!is_array($targets) || !is_array($socs)) {
throw new InvalidArgumentException('Batterieziele fehlen.');
}
$keys = array_keys($targets);sort($keys);$socKeys = array_keys($socs);sort($socKeys);
if ($keys !== $ids || $socKeys !== $ids) {
throw new InvalidArgumentException('Plan enthaelt falsche Batteriezuordnung.');
}
$sum = 0.0;
foreach ($targets as $id => $w) {
$sum += self::zahl($w, 'Einzelziel');
self::zahl($socs[$id], 'Plan-SOC', 0, 100);
}
if (abs($sum - $bat) > 1.0) {
throw new InvalidArgumentException('Summe der Batterieziele passt nicht.');
}
foreach (['importLimitW', 'exportLimitW'] as $limit) {
if (!array_key_exists($limit, $point)) {
throw new InvalidArgumentException('Plangrenze fehlt.');
}
if ($point[$limit] !== null) {
$v = self::zahl($point[$limit], 'Plangrenze', 0);
if (($limit === 'importLimitW' ? $grid : -$grid) > $v + 1) {
throw new InvalidArgumentException('Netzgrenze im Plan verletzt.');
}
}
}
if ($a <= $now && $now < $b) {
$current = $point;
}
}
if ($last !== $until || $current === null) {
throw new InvalidArgumentException('Kein derzeit gueltiger Planabschnitt.');
}
return ['plan' => $p, 'point' => $current, 'efficiency' => (float) $settings['roundtripEfficiency']];
}
/** Proposed single-battery correction only. Never use this result as a dispatch permit. */
public static function vorschau(array $checked, array $operation, array $actualBatteryW, int $now): array
{
$point = $checked['point'];$p = $checked['plan'];
if (count($operation['batteries']) !== 1) {
throw new InvalidArgumentException('Lokale Vorschau fuer mehrere Batterien noch nicht freigegeben.');
}
$b = $operation['batteries'][0];$id = $b['id'];
$age = $now - self::zeit($b['measuredAt'] ?? null);
if ($age < 0 || $age > 60) {
throw new InvalidArgumentException('Batteriemessung veraltet.');
}
$grid = self::zahl($operation['gridW'], 'Netzleistung');
$battery = self::zahl($actualBatteryW[$id] ?? null, 'Batterie-Istleistung');
$soc = self::zahl($b['socPercent'], 'Aktueller SOC', $b['physicalMinSocPercent'] ?? 0, $b['maxSocPercent']);
$remainingHours = (self::zeit($point['validUntil']) - $now) / 3600;
if ($remainingHours <= 0) {
throw new InvalidArgumentException('Planabschnitt abgelaufen.');
}
$eta = sqrt($checked['efficiency']);$capacity = self::zahl($b['capacityKwh'], 'Kapazitaet', .001);
$charge = min(self::zahl($b['maxChargeW'], 'Ladeleistung', 0),
max(0., $b['maxSocPercent'] - $soc) * $capacity / 100 / $eta / $remainingHours * 1000);
$discharge = min(self::zahl($b['maxDischargeW'], 'Entladeleistung', 0),
max(0., $soc - $b['minSocPercent']) * $capacity / 100 * $eta / $remainingHours * 1000);
$reasons = [];
if (($b['dischargeBlocked'] ?? false) === true || $soc <= $b['minSocPercent']) {
$discharge = 0.0;$reasons[] = 'Lokale Entladesperre/Reserve';
}
$residual = $grid - $battery;
if ($b['gridCharging'] !== true) {
$charge = min($charge, max(0., -$residual));$reasons[] = 'Netzladen lokal gesperrt';
}
$target = (float) $point['gridTargetW'];$limits = $operation['limits'];
$import = $limits['importW'];$export = $limits['exportW'];
$month = (int) (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('n');
if (isset($limits['managerMonthLimitsW'][(string) $month])) {
$monthly = self::zahl($limits['managerMonthLimitsW'][(string) $month], 'Monatsgrenze', 0);
$import = $import === null ? $monthly : min($import, $monthly);
}
if ($import !== null) $target = min($target, self::zahl($import, 'Bezugsgrenze', 0));
if ($export !== null) $target = max($target, -self::zahl($export, 'Einspeisegrenze', 0));
$raw = $battery + $target - $grid;
$wanted = $raw;
if (abs($target) > 1.0) {
$wanted = match ($point['intent']) {
'gridCharge' => max(0., $wanted),
'pvCharge' => max(0., min(max(0., -$residual), $wanted)),
'discharge', 'export' => min(0., $wanted),
'hold' => 0.,
};
}
// Hard connection limits override economic intent, never BMS/availability.
if ($import !== null) $wanted = min($wanted, $import - $residual);
if ($export !== null) $wanted = max($wanted, -$export - $residual);
$clipped = min($charge, max(-$discharge, $wanted));
if (abs($clipped - $raw) > 1) $reasons[] = 'Leistung, SOC, Planabsicht oder Netzgrenze begrenzt';
$expectedGrid = $residual + $clipped;
$violation = max(0., $import === null ? 0. : $expectedGrid - $import, $export === null ? 0. : -$export - $expectedGrid);
if ($violation > 1) $reasons[] = 'Netzgrenze durch Batterie allein nicht erreichbar; lokale Schutzregelung erforderlich';
return ['status' => 'preview_only', 'liveEnabled' => false, 'canDispatch' => false,
'installationId' => $p['installationId'], 'planId' => $p['planId'], 'revision' => $p['configRevision'],
'family' => $p['sourceFamily'], 'checkedAt' => gmdate('c', $now), 'step' => $point['time'],
'stepValidUntil' => $point['validUntil'], 'gridTargetW' => (float) $point['gridTargetW'],
'plannedBatteryW' => (float) $point['batteryTargetW'], 'previewBatteryW' => $clipped,
'actualGridW' => $grid, 'actualBatteryW' => $battery, 'expectedGridW' => $expectedGrid,
'unmetGridLimitW' => $violation, 'reasons' => $reasons, 'inputWarnings' => $p['warnings'] ?? [],
'validityNote' => 'Nur Momentvorschau; keine Stellfreigabe und keine nachgewiesene Netzreaktion.'];
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
/** Separate, explicitly authorized commissioning commands. A preview is NEVER permission. */
final class NetzfahrplanV4Regeltest
{
public const MAX_TEST_SECONDS = 1800;
public const MAX_ACTIVE_TEST_SECONDS = 172800;
public const COMMAND_TTL_SECONDS = 10;
public static function number($value, string $name, float $min, float $max): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < $min || $value > $max) {
throw new InvalidArgumentException($name . ': ungueltiger Wert.');
}
return (float) $value;
}
public static function uuid($value): string
{
if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) {
throw new InvalidArgumentException('Eindeutige Testkennung erforderlich.');
}
return $value;
}
public static function integer($value, string $name, int $min, int $max): int
{
if (!is_int($value) || $value < $min || $value > $max) {
throw new InvalidArgumentException($name . ': Ganzzahl erforderlich.');
}
return $value;
}
/** Validated server authorization plus independent LOCAL consent and physical mapping. */
public static function befehl(array $response, array $operation, array $actual, array $local, int $now, int $receivedAt): array
{
if (($local['enabled'] ?? null) !== true || ($local['legacyEnabled'] ?? null) !== false) {
throw new InvalidArgumentException('Keine lokale Testfreigabe oder konkurrierender Fahrplan.');
}
$installation = self::uuid($local['installationId'] ?? null);
$session = self::uuid($local['sessionId'] ?? null);
$manager = self::integer($local['managerId'] ?? null, 'Manager', 1, 99999);
$controller = self::integer($local['batteryInstanceId'] ?? null, 'Batterieinstanz', 1, 99999);
$activeTest = ($local['kind'] ?? null) === 'local_active_test_consent';
$maxSeconds = $activeTest ? self::MAX_ACTIVE_TEST_SECONDS : self::MAX_TEST_SECONDS;
$end = self::integer($local['expiresAt'] ?? null, 'Lokales Testende', $now + 1, $now + $maxSeconds);
if (($local['lastWallClockAt'] ?? $now) > $now) throw new InvalidArgumentException('Systemzeit rueckwaerts; Test abbrechen.');
$checked = NetzfahrplanV4Planpruefung::pruefen($response, $installation, $operation, $now, $receivedAt);
$plan = $checked['plan'];$a = $response['controlledTrial'] ?? null;
if (!is_array($a) || ($a['version'] ?? null) !== 1 || ($a['kind'] ?? null) !== 'controlled_trial_authority'
|| ($a['sourcePlanRemainsShadow'] ?? null) !== true || ($a['installationId'] ?? null) !== $installation
|| ($a['sessionId'] ?? null) !== $session || ($a['managerId'] ?? null) !== $manager
|| ($a['batteryInstanceId'] ?? null) !== $controller || ($a['sourceShadowPlanId'] ?? null) !== $plan['planId']
|| ($a['revision'] ?? null) !== $plan['configRevision'] || ($a['family'] ?? null) !== $plan['sourceFamily']) {
throw new InvalidArgumentException('Keine passende separate Server-Testfreigabe.');
}
$issued = NetzfahrplanV4Planpruefung::zeit($a['issuedAt'] ?? null);
$expires = NetzfahrplanV4Planpruefung::zeit($a['expiresAt'] ?? null);
$authorityAt = NetzfahrplanV4Planpruefung::zeit($a['checkedAt'] ?? null);
$authorityUntil = NetzfahrplanV4Planpruefung::zeit($a['validUntil'] ?? null);
if ($issued > $now || $expires <= $now || $expires - $issued > $maxSeconds
|| $end > $expires || $authorityAt > $now + 2 || $authorityUntil <= $now
|| $authorityUntil - $authorityAt > 90 || $authorityUntil > $expires) {
throw new InvalidArgumentException('Testfreigabe abgelaufen oder ungueltig.');
}
$quality = $plan['inputQuality'] ?? [];
$evidence = $quality['accountingEvidenceId'] ?? null;
$watchdogBound = is_string($a['actuatorWatchdogEvidenceId'] ?? null)
&& strlen($a['actuatorWatchdogEvidenceId']) >= 8
&& $a['actuatorWatchdogEvidenceId'] === ($local['actuatorWatchdogEvidenceId'] ?? null);
$watchdogWaived = $activeTest && ($local['watchdogWaived'] ?? null) === true
&& ($a['actuatorWatchdogEvidenceId'] ?? null) === 'explicit_test_plant_watchdog_waiver';
if (($quality['loadBasis'] ?? null) !== 'base_load' || !is_string($evidence) || strlen($evidence) < 8
|| $evidence !== ($a['accountingEvidenceId'] ?? null)
|| (!$watchdogBound && !$watchdogWaived)) {
throw new InvalidArgumentException('Anlagenbilanz oder Geraete-Watchdog nicht nachgewiesen.');
}
if (($plan['peakCostIsEstimate'] ?? true) && ($a['acceptEstimatedPeak'] ?? null) !== true) {
throw new InvalidArgumentException('Geschaetzte Peakbasis nicht fuer Test akzeptiert.');
}
if (count($operation['batteries']) !== 1 || $operation['batteries'][0]['id'] !== ($a['assetId'] ?? null)
|| ($local['assetId'] ?? null) !== $a['assetId']) {
throw new InvalidArgumentException('Test braucht eindeutige Batteriezuordnung.');
}
if (($a['controlContext'] ?? null) != $plan['controlContext']) {
throw new InvalidArgumentException('Testkonfiguration stimmt nicht mehr.');
}
$feedback = $operation['localFeedback'][$a['assetId']] ?? [];
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $local, $now);
if (abs(self::number($actual[$a['assetId']] ?? null,'Batterie Ist',-1e9,1e9) - self::number($feedback['batteryW'],'Rueckmeldung',-1e9,1e9)) > 0.000001) throw new InvalidArgumentException('feedback_power_not_from_verified_snapshot');
$otherDelta=self::number($operation['localPredictedOtherDeltaW']??0.0,'Andere Verbraucherabweichung',-1e9,1e9);
if (abs($operation['gridW']-$feedback['gridW']-$otherDelta)>0.001) throw new InvalidArgumentException('feedback_grid_not_from_same_snapshot');
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
$battery = $operation['batteries'][0];
$charge = min(self::number($a['maxChargeW'] ?? null, 'Server-Ladegrenze', 1, 1e9),
self::number($local['maxChargeW'] ?? null, 'Lokale Ladegrenze', 1, 1e9),
self::number($battery['maxChargeW'] ?? null, 'Anlagen-Ladegrenze', 1, 1e9));
$discharge = min(self::number($a['maxDischargeW'] ?? null, 'Server-Entladegrenze', 1, 1e9),
self::number($local['maxDischargeW'] ?? null, 'Lokale Entladegrenze', 1, 1e9),
self::number($battery['maxDischargeW'] ?? null, 'Anlagen-Entladegrenze', 1, 1e9));
$watts = (int) (max(-$discharge, min($charge, $preview['previewBatteryW']))); // truncate towards zero
$residual = $preview['actualGridW'] - $preview['actualBatteryW'];
$grid = $residual + $watts;
$p = $checked['point'];
if (($p['importLimitW'] !== null && $grid > $p['importLimitW'] + 1)
|| ($p['exportLimitW'] !== null && -$grid > $p['exportLimitW'] + 1)) {
throw new InvalidArgumentException('Testleistungsgrenze reicht fuer Netzgrenze nicht; lokale Regelung hat Vorrang.');
}
return ['kind' => 'controlled_trial_command', 'version' => 1,
'sessionId' => $session, 'installationId' => $installation, 'assetId' => $a['assetId'],
'managerId' => $manager, 'batteryInstanceId' => $controller,
'sequence' => self::integer($local['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX),
'issuedAt' => $now, 'expiresAt' => min($now + self::COMMAND_TTL_SECONDS, $authorityUntil, $end,
NetzfahrplanV4Planpruefung::zeit($p['validUntil'])),
'watts' => $watts, 'maxChargeW' => (int) $charge, 'maxDischargeW' => (int) $discharge,
'sourceShadowPlanId' => $plan['planId'], 'sourceStep' => $p['time'],
'revision' => $plan['configRevision'], 'expectedGridW' => $grid,
'feedbackConfigHash' => $feedback['configHash'], 'feedbackEstimated' => $feedback['estimated'],
'feedbackSourceOldestAt' => $feedback['sourceOldestAt'],
'importLimitW' => $p['importLimitW'], 'exportLimitW' => $p['exportLimitW'],
'executionMeaning' => $activeTest
? 'explicit_time_boxed_test_plant_operation'
: 'separate_local_control_trial_not_shadow_plan_execution'];
}
/** Independently checked by the battery before any physical register call. */
public static function pruefeBatteriebefehl(array $command, array $session, int $now, float $monotonicNow): void
{
if (($command['kind'] ?? null) !== 'controlled_trial_command' || ($command['version'] ?? null) !== 1) {
throw new InvalidArgumentException('Vorschau ist kein Stellbefehl.');
}
foreach (['sessionId', 'installationId', 'assetId', 'managerId', 'batteryInstanceId'] as $key) {
if (!array_key_exists($key, $session) || ($command[$key] ?? null) !== $session[$key]) {
throw new InvalidArgumentException('Befehl gehoert nicht zur lokal angemeldeten Testsitzung.');
}
}
if (($session['active'] ?? null) !== true || $now >= ($session['expiresAt'] ?? 0)
|| $monotonicNow >= ($session['monotonicDeadline'] ?? 0)
|| $now < ($session['lastWallClockAt'] ?? $now)) {
throw new InvalidArgumentException('Lokale Testsitzung abgelaufen/abgebrochen.');
}
$sent = self::integer($command['issuedAt'] ?? null, 'Befehlszeit', $now - self::COMMAND_TTL_SECONDS, $now);
$end = self::integer($command['expiresAt'] ?? null, 'Befehlsende', $now + 1, $sent + self::COMMAND_TTL_SECONDS);
if ($end > $session['expiresAt']) throw new InvalidArgumentException('Befehl verlaengert Test.');
$seq = self::integer($command['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX);
if ($seq <= ($session['lastSequence'] ?? 0)) throw new InvalidArgumentException('Alter/doppelter Befehl verworfen.');
$sessionCharge = self::number($session['maxChargeW'] ?? null, 'Sitzungs-Ladegrenze', 1, 1e9);
$sessionDischarge = self::number($session['maxDischargeW'] ?? null, 'Sitzungs-Entladegrenze', 1, 1e9);
$w = self::integer($command['watts'] ?? null, 'Sollwert W', -(int) $sessionDischarge, (int) $sessionCharge);
$c = self::number($command['maxChargeW'] ?? null, 'Befehl-Ladegrenze', 1, $sessionCharge);
$d = self::number($command['maxDischargeW'] ?? null, 'Befehl-Entladegrenze', 1, $sessionDischarge);
if ($w > $c || $w < -$d) {
throw new InvalidArgumentException('Befehl ueberschreitet lokale Testgrenze.');
}
if (!is_string($command['feedbackConfigHash'] ?? null) || !preg_match('/^[a-f0-9]{64}$/D', $command['feedbackConfigHash'])
|| $command['feedbackConfigHash'] !== ($session['feedbackConfigHash'] ?? null)) throw new InvalidArgumentException('feedback_command_binding_invalid');
foreach (['importLimitW','exportLimitW'] as $limit) {
if (!array_key_exists($limit,$command)) throw new InvalidArgumentException('feedback_command_limits_missing');
if ($command[$limit] !== null) self::number($command[$limit],$limit,0,1e9);
}
$l=$session['gridLimits'] ?? null;
if (!is_array($l) || !array_key_exists('importW',$l) || !array_key_exists('exportW',$l) || !is_array($l['managerMonthLimitsW']??null)) throw new InvalidArgumentException('session_grid_limits_missing');
$month=(int)(new \DateTimeImmutable('@'.$now))->setTimezone(new \DateTimeZone('Europe/Zurich'))->format('n');
$import=$l['importW'];$monthly=$l['managerMonthLimitsW'][$month]??null;
if ($monthly!==null) $import=$import===null?$monthly:min($import,$monthly);
foreach (['importLimitW'=>$import,'exportLimitW'=>$l['exportW']] as $key=>$limit) {
if ($limit!==null) {
self::number($limit,$key,0,1e9);
if ($command[$key]===null || $command[$key]>$limit) throw new InvalidArgumentException('command_relaxes_local_grid_limit');
}
}
self::uuid($command['sourceShadowPlanId'] ?? null);
}
/** Re-evaluated on every battery update; no reliance on the manager's earlier clipping. */
public static function batterieZiel(array $command, array $session, array $m, array $policy, int $now, float $monotonicNow): int
{
if ($now >= $command['expiresAt'] || $now < $session['lastWallClockAt']
|| $monotonicNow >= $session['commandMonotonicDeadline'] || $monotonicNow >= $session['monotonicDeadline']) {
throw new InvalidArgumentException('Befehls-Watchdog abgelaufen.');
}
if (($m['Gueltig'] ?? null) !== true || ($policy['available'] ?? null) !== true) {
throw new InvalidArgumentException('Batterie nicht verfuegbar oder Messwerte ungueltig.');
}
$soc = self::number($m['Ladezustand'] ?? null, 'SOC', 0, 100);
$reserve = self::number($policy['reserve'] ?? null, 'Reserve', 0, 100);
$minimum = self::number($policy['minimum'] ?? null, 'Minimum', 0, $reserve);
if ($soc < $minimum) throw new InvalidArgumentException('SOC unter technischer Untergrenze.');
$charge = min($session['maxChargeW'], $command['maxChargeW'], self::number($m['MaxLaden'] ?? null, 'Max Laden', 0, 1e9));
$discharge = min($session['maxDischargeW'], $command['maxDischargeW'], self::number($m['MaxEntladen'] ?? null, 'Max Entladen', 0, 1e9));
if ($soc >= 99) $charge = 0; // existing battery hardware policy is more conservative than planner max 100
if ($soc <= $reserve || ($policy['blocked'] ?? true)) $discharge = 0;
if (($soc <= $reserve || ($policy['blocked'] ?? true)) && $policy['rechargeLimitW'] !== null) {
$charge = min($charge, self::number($policy['rechargeLimitW'], 'Nachladegrenze', 0, 1e9));
}
return (int) max(-$discharge, min($charge, $command['watts']));
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/** Fresh physical feedback. Pure reader: no writes, history reconstruction or actuator authority. */
final class NetzfahrplanV4Rueckmeldung
{
private static function number($v, float $lo, float $hi): float
{
if ((!is_int($v) && !is_float($v)) || !is_finite((float)$v) || $v<$lo || $v>$hi) {
throw new InvalidArgumentException('feedback_numeric_invalid');
}
return (float)$v;
}
private static function ordered(array $v): array
{
if (!array_is_list($v)) ksort($v, SORT_STRING);
foreach ($v as &$x) if (is_array($x)) $x=self::ordered($x);
unset($x);return $v;
}
public static function configuration(array $c): array
{
$keys=['version','installationId','assetId','managerId','batteryInstanceId','mode','allowEstimatedForTrial',
'maxSkewSeconds','maxAgeSeconds','idleToleranceW','trackingToleranceW','sources'];
if (count($c)!==count($keys) || array_diff($keys,array_keys($c)) || $c['version']!==1
|| !is_string($c['installationId']) || !preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/D',$c['installationId'])
|| !is_string($c['assetId']) || $c['assetId']==='' || strlen($c['assetId'])>100
|| !in_array($c['mode'],['physical_sum','virtual_split'],true) || !is_bool($c['allowEstimatedForTrial'])) {
throw new InvalidArgumentException('feedback_configuration_invalid');
}
foreach (['managerId','batteryInstanceId'] as $k) if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>99999) throw new InvalidArgumentException('feedback_binding_invalid');
if ($c['managerId']===$c['batteryInstanceId']) throw new InvalidArgumentException('feedback_binding_invalid');
foreach (['maxSkewSeconds'=>30,'maxAgeSeconds'=>60] as $k=>$max) {
if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>$max) throw new InvalidArgumentException('feedback_freshness_invalid');
}
$c['idleToleranceW']=self::number($c['idleToleranceW'],0,100);
$c['trackingToleranceW']=self::number($c['trackingToleranceW'],0,500);
if (!is_array($c['sources']) || !array_is_list($c['sources']) || count($c['sources'])<2 || count($c['sources'])>40) throw new InvalidArgumentException('feedback_sources_invalid');
$ids=[];$names=[];$roles=[];
foreach ($c['sources'] as &$s) {
$fields=['key','role','variableId','parentId','ident','factorToW'];
if (!is_array($s) || count($s)!==count($fields) || array_diff($fields,array_keys($s))
|| !is_string($s['key']) || !preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D',$s['key'])
|| isset($names[$s['key']]) || !in_array($s['role'],['grid','physical','ev_request','sdl_request','gateway_active'],true)
|| !is_int($s['variableId']) || $s['variableId']<1 || $s['variableId']>99999 || isset($ids[$s['variableId']])
|| !is_int($s['parentId']) || $s['parentId']<1 || !is_string($s['ident']) || strlen($s['ident'])>100) throw new InvalidArgumentException('feedback_source_identity_invalid');
$s['factorToW']=self::number($s['factorToW'],-1e6,1e6);
if ($s['factorToW']==0 || ($s['role']==='gateway_active' && $s['factorToW']!==1.0)) throw new InvalidArgumentException('feedback_factor_invalid');
$ids[$s['variableId']]=true;$names[$s['key']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1;
}
unset($s);
if (($roles['grid']??0)!==1 || ($roles['physical']??0)<1) throw new InvalidArgumentException('feedback_measurements_required');
foreach (['ev_request','sdl_request','gateway_active'] as $role) {
if (($roles[$role]??0)!==($c['mode']==='virtual_split'?1:0)) throw new InvalidArgumentException('feedback_partition_sources_required');
}
usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key']));
return self::ordered($c);
}
public static function fingerprint(array $c): string
{
return hash('sha256',json_encode(self::configuration($c),JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
}
/** Reader supplies value, original updated/changed, parentID, ident. Never trusts collector timestamp alone. */
public static function read(array $config, callable $reader, int $now): array
{
$c=self::configuration($config);
if ($now<1) throw new InvalidArgumentException('feedback_time_invalid');
$first=[];$values=[];$times=[];$physicalTimes=[];$variableTimes=[];$changed=[];
foreach ($c['sources'] as $s) {
$r=$reader($s['variableId']);
if (!is_array($r) || ($r['parentID']??null)!==$s['parentId'] || ($r['ident']??null)!==$s['ident']
|| !is_int($r['updated']??null) || $r['updated']<1 || $r['updated']>$now
|| !is_int($r['changed']??null) || $r['changed']<0 || $r['changed']>$r['updated']
|| (in_array($s['role'],['grid','physical'],true)
&& (!is_int($r['confirmedAt']??null) || $r['confirmedAt']<1 || $r['confirmedAt']>$now
|| $r['updated']>$r['confirmedAt']))) throw new InvalidArgumentException('feedback_origin_invalid:'.$s['key']);
$first[$s['key']]=array_intersect_key($r,array_flip(['value','updated','changed','parentID','ident','confirmedAt']));
if ($s['role']==='gateway_active') {
if (($r['value']??null)!==true) throw new InvalidArgumentException('feedback_gateway_inactive');
} else {
$w=self::number($r['value']??null,-1e9,1e9)*$s['factorToW'];
$values[$s['role']]=($values[$s['role']]??0)+self::number($w,-1e9,1e9);
}
// VariableUpdated is retained as provenance. Freshness comes only from a successful device response.
if (in_array($s['role'],['grid','physical'],true)) {
if ($now-$r['confirmedAt']>$c['maxAgeSeconds']) throw new InvalidArgumentException('feedback_stale_physical:'.$s['key']);
$times[]=$r['confirmedAt'];$variableTimes[]=$r['updated'];
if ($s['role']==='physical') $physicalTimes[]=$r['confirmedAt'];
} else $changed[$s['role']]=$r['changed'];
}
foreach ($c['sources'] as $s) {
$second=$reader($s['variableId']);
if (!is_array($second) || array_intersect_key($second,array_flip(['value','updated','changed','parentID','ident','confirmedAt']))!==$first[$s['key']]) {
throw new InvalidArgumentException('feedback_changed_during_read:'.$s['key']);
}
}
if (max($times)-min($times)>$c['maxSkewSeconds']) throw new InvalidArgumentException('feedback_source_skew');
$physical=(float)$values['physical'];$power=$physical;$external=0.0;$estimated=$c['mode']==='virtual_split';$reasons=[];$tracking=null;
if ($estimated) {
if ($changed['gateway_active']>min($physicalTimes) || $changed['sdl_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_external_transition_not_observed');
$ev=(float)$values['ev_request'];$sdl=(float)$values['sdl_request'];$tracking=$physical-$ev-$sdl;
if ($sdl===0.0) {
// No external commanded contribution. Physical response may lag a NEW EV command.
$power=$physical;
if ($ev===0.0 && abs($physical)>$c['idleToleranceW']) throw new InvalidArgumentException('feedback_unassigned_idle_power');
} else {
if ($changed['ev_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_ev_transition_not_observed');
if (abs($tracking)>$c['trackingToleranceW']) throw new InvalidArgumentException('feedback_partition_not_tracking');
$power=$ev+$tracking*abs($ev)/(abs($ev)+abs($sdl));$external=$physical-$power;
if ($ev*$sdl<0) $reasons[]='opposing_virtual_flows_not_identifiable';
}
if (!$c['allowEstimatedForTrial']) $reasons[]='modelled_feedback_not_accepted_for_trial';
}
return ['version'=>1,'kind'=>'local_physical_feedback','installationId'=>$c['installationId'],'assetId'=>$c['assetId'],
'managerId'=>$c['managerId'],'batteryInstanceId'=>$c['batteryInstanceId'],'checkedAt'=>$now,
'sourceOldestAt'=>min($times),'sourceNewestAt'=>max($times),'physicalOldestAt'=>min($physicalTimes),
'sourceSkewSeconds'=>max($times)-min($times),'sourceVariableOldestAt'=>min($variableTimes),
'sourceVariableNewestAt'=>max($variableTimes),'deviceReadConfirmed'=>true,'maxAgeSeconds'=>$c['maxAgeSeconds'],
'configHash'=>self::fingerprint($c),'gridW'=>(float)$values['grid'],'physicalStorageW'=>$physical,
'batteryW'=>$power,'externalModelW'=>$external,'trackingErrorW'=>$tracking,
'estimated'=>$estimated,'separatelyMeasured'=>!$estimated,'usableForTrial'=>$reasons===[],
'reasons'=>$reasons,'method'=>$estimated?'physical_request_partition_unfiltered':'physical_sum',
'canDispatch'=>false,'gatewayWatchdogVerified'=>false,'heldFilterUsed'=>false];
}
public static function requireTrial(array $f, array $binding, int $now): void
{
foreach (['installationId','assetId','managerId','batteryInstanceId'] as $key) {
if (!array_key_exists($key,$binding) || ($f[$key]??null)!==$binding[$key]) throw new InvalidArgumentException('feedback_wrong_binding');
}
if (($f['kind']??null)!=='local_physical_feedback' || ($f['version']??null)!==1 || ($f['usableForTrial']??null)!==true
|| ($f['deviceReadConfirmed']??null)!==true
|| !is_int($f['checkedAt']??null) || $f['checkedAt']>$now || $now-$f['checkedAt']>2
|| !is_int($f['sourceOldestAt']??null) || !is_int($f['maxAgeSeconds']??null)
|| $f['maxAgeSeconds']<1 || $f['maxAgeSeconds']>60 || $f['sourceOldestAt']>$now || $now-$f['sourceOldestAt']>$f['maxAgeSeconds']
|| !is_string($f['configHash']??null) || !preg_match('/^[a-f0-9]{64}$/D',$f['configHash'])) throw new InvalidArgumentException('feedback_not_usable_for_trial');
if (isset($binding['feedbackConfigHash']) && $binding['feedbackConfigHash']!==$f['configHash']) throw new InvalidArgumentException('feedback_configuration_changed');
self::number($f['batteryW']??null,-1e9,1e9);self::number($f['gridW']??null,-1e9,1e9);
}
}
+5
View File
@@ -0,0 +1,5 @@
data/
.git/
__pycache__/
**/__pycache__/
*.log
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
data/
*-reports/
application-releases/
.env
*.env
+25
View File
@@ -0,0 +1,25 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY netplan_v4 ./netplan_v4
COPY tests ./tests
COPY run_tests.py install_hooks.py runtime_preflight.py Dockerfile .
COPY integrations ./integrations
COPY gui ./gui
COPY release_preflight.py forecast_acceptance.py deploy_integrated_shadow.py approved_previous_assets.json ./
COPY acceptance ./acceptance
COPY commissioning/deploy_application.py ./commissioning/deploy_application.py
COPY commissioning/deploy_history_timing.py ./commissioning/deploy_history_timing.py
COPY commissioning/deploy_unified_release.py ./commissioning/deploy_unified_release.py
# Host sources can be 0600/0700. COPY makes them root-owned.
# Normalize only packaged application code; never change host secrets or sockets.
RUN find /app -type d -exec chmod 0755 {} + \
&& find /app -type f -exec chmod 0644 {} +
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NETPLAN_V4_TEST_REPORT_DIR=/tmp/test-results
USER 1000:1000
# Fail the build early if the actual unprivileged runtime cannot read the code.
RUN python /app/runtime_preflight.py
CMD ["uvicorn", "netplan_v4.service:from_environment", "--factory", "--host", "0.0.0.0", "--port", "9100", "--workers", "1"]
+106
View File
@@ -0,0 +1,106 @@
{
"sourceHashes": {
".dockerignore": "fab6861d98f34e54646ae966b237e792fc0a0b4f95f1df3b62a1f062cbb8f790",
"Dockerfile": "7d5945ad5b17396bb3554e9c1602510cc2d89fa784f2f634514d076ef9f2ec33",
"acceptance/Dockerfile.php": "715a2d232d7f901bd6ca1f2e453b7b7f48fc1d7f49bff8944a7d277a8dc30062",
"acceptance/check_forecast.py": "ccee58ec1078767a580f15f895506eceeb15e13b54e8eb9b4905cc03ba14ccd5",
"acceptance/forecast-src/SOURCE_MANIFEST.json": "8103775396c58d82921e2e2a1513ee185ae2431200763717544ff9b1da181fe5",
"acceptance/forecast-src/main.py": "4060564a4a33400ef6b8547633fc4c97caa3f674494d8cfc53a7aae0ed011b6c",
"acceptance/forecast-src/methods/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"acceptance/forecast-src/methods/battery_optimizer.py": "27e7404d3bf4a2511e022232c2f6877adc0db014bdeb132a2a13cd4949d4d5e6",
"acceptance/forecast-src/methods/common.py": "0fe5c9bc3fa8b6d40f0f9db36843623c6e469c150ed25899e3176355d5bb1db8",
"acceptance/forecast-src/methods/var_1.py": "6a7fc3aaf904442aba44bd211d89e4ba54f10485f54441a1239de19e91fa5fe4",
"acceptance/forecast-src/methods/var_10.py": "a3caf21387620687646775e6b0bf85c97af8bc6d0a48e3d779e9684d310333b7",
"acceptance/forecast-src/methods/var_11.py": "1f783e57fee22761e8e5439caef48e275876a7ebf8380467d9b71ad2aa1b8edc",
"acceptance/forecast-src/methods/var_13.py": "f12407cd056a1f28f47ab93b1262c62627c80d4765e75dd495e2c19e8f8e2ad9",
"acceptance/forecast-src/methods/var_2.py": "5bdfd61bb108368890ff1b920f60499eabf6363c6380637100380eb0e28f0c6a",
"acceptance/forecast-src/methods/var_21.py": "e2e3361d57fae8379dcce89cd98595a0d56d23decd1073d94474302b53ff8e15",
"acceptance/forecast-src/methods/var_22.py": "cd51104bf98c686360c037cb74ff0a40bb748f24e52575eb3a3ef9932fac8cd6",
"acceptance/forecast-src/methods/var_23.py": "d356d4078723a59e6cfad5ade631883cf22e7abd9caf38cec046e26b0580c678",
"acceptance/forecast-src/methods/var_3.py": "87662e491ba33e170f3bcfdbd8dd2e54630073fcf4cb6e2e8ab768f9a6d95984",
"acceptance/forecast-src/model_isolation.py": "db33ee8e9583cf006a5224a9f9efeed874ce04144d74f1b1bb25852c614468c5",
"acceptance/forecast-src/netplan_v4_publisher.py": "cb8efe10d2799215b347985c53c96a6420e5461fff4c4e232b6f918d8bff2161",
"acceptance/forecast-src/requirements.txt": "1f4731380246b4b08e5666ce736d9f24063128d2fc0737a061b36ac93952c083",
"acceptance/forecast-src/shared_utils.py": "271489e491305d97706e0a4b5c8745bcb01e19628a0cee71da15512ee2d85e57",
"acceptance/forecast-src/soc_diagnostics.py": "06ce7c55aa69875df94471a9fe47ff3d95da372192b737b9a0ff6493503ac15a",
"acceptance/forecast-src/telemetry_quality.py": "bb959d08d2d50d5597dc10b47a35d510e43ba8bfa755db236652071b57ad1810",
"acceptance/forecast-src/tests/test_battery_optimizer.py": "5b8fa3185672530c072a8cfe506ac8d4878846efbb2ee818b93ee04d57ca7cc8",
"acceptance/forecast-src/tests/test_load_forecast.py": "000903a3691297dd7cfc160b3702825a6f04d53ae9d745dc08f7bfadec06465c",
"acceptance/forecast-src/tests/test_model_isolation.py": "4dadcc7541181a57badc337fa33100c0ba2b9fd20b7dd36b87326eeeb285cf30",
"acceptance/forecast-src/tests/test_telemetry_integrity.py": "8e6a200d6a108d309b8c5ceba15fdb1653644789875648c4284b75170d56b5ce",
"acceptance/php-src/SOURCE_MANIFEST.json": "3f8add37ac99ebbb0b3e77093ee586e5deedce14643190c673e7a3dd942d2a50",
"acceptance/php-src/check.php": "92599dc10d0b8b0bb97cab3c8ae8fbefd084c8cd65992f0e848e8042385cf473",
"acceptance/php-src/libs/ManagerNetzfahrplanV4Trait.php": "13d2867d2b4fe7f8846a08d9b4b81269320db6373d44c5f731a09c21c4912ab7",
"acceptance/php-src/libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9",
"acceptance/php-src/libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a",
"acceptance/php-src/tests/NetzfahrplanV4BetriebsdatenTest.php": "4a4f5af4cd86797fc40a4a36a7103a26fcf1e59ab381bafa8f67d35b34419dbc",
"acceptance/php-src/tests/NetzfahrplanV4BezugszaehlerTest.php": "65b2daa769773198859ab40d2b230b1f3c43f1c618df0c4b90a494d31efb786c",
"acceptance/php-src/tests/fixtures/NativeV4Scenarios.php": "56a5a0df03cb53ea69f6e199c6d2405041a329c7df540ea8bacc08bfaa8766c6",
"acceptance/php-src/tests/fixtures/V4BezugszaehlerScenarios.php": "7820bab98d1249aac3fee9f015f8da500744c12bfb5b198fcc735cadf3167ec2",
"acceptance/read_native_forecasts.py": "1092413b70714c2e12e3af2697a8d3eb7a70efb5965295c20efc92eaf647c77f",
"approved_previous_assets.json": "0fd70ccba10d2970a187dca1be3690461aa143eb29d7e5c244a97951e275cfb0",
"commissioning/APPLICATION_STATUS.md": "efec16d79ccd8cb7531c3c135bca5bb38381123666a45cae30d9631183071abc",
"commissioning/application-source/server-dataset.json": "844c7b76370f451af172c79a876b8d298236e7dd25b613fc41e0b05338565f6d",
"commissioning/deploy_application.py": "8fabbbbf41e00be677a6f109690758035bbe098f9161d9c44865b28ab7f1bd4e",
"commissioning/deploy_history_timing.py": "7769a473927bb10acf1ba1aef902e6b14fdf26b548d58056b557e6e4c33f62eb",
"commissioning/deploy_unified_release.py": "d5f7831fa556288f23747b6b186a77b18836d6c22d88acce2f1b4dc786ccbf4e",
"compose.portal-bridge.yaml": "4299d9de0e8707777052589e4097744c712698ff254a8bdc886041d2c4925197",
"compose.yaml": "aae681e18be81633589926db93b27043fca983e255f2f62108ad38df42e1d607",
"deploy_integrated_shadow.py": "ec8b324dd5041f89ee84849937f27e9feffb80c6301efe48e9504b326e1f7898",
"forecast_acceptance.py": "cc018773c65b61e37f13dfafe2c53b71eec87311d9f3abb514e78cd5917251f9",
"gui/netplan-v4.css": "216959a2d90f346a167a4ac809e6cf96c00461abd9853f5c5b5052d6e34d7efd",
"gui/netplan-v4.html": "296a247a7a9724dbe8c873372b1d5536ec02ea6eede823c1545e1d8749d64755",
"gui/netplan-v4.js": "245af626f3322dcaec5afbbc9772707ee6de00f07fd939e59709e7349f2d12d9",
"install_hooks.py": "1c16586f970742c994cd0cf9ffb41e921ad34f64fa6043fef89d6bdd686799f3",
"integrations/NetzfahrplanV4.php": "8ecb7311c6db5998db5fa1b03bd70536bf1fb29f4fea45d257824c033358baf3",
"integrations/netplan-v4-bridge.mjs": "f67c28a148b9233da44be817940425265ca98278dc5e04cd36942432febf8dc7",
"integrations/netplan_v4_publisher.py": "cb8efe10d2799215b347985c53c96a6420e5461fff4c4e232b6f918d8bff2161",
"netplan_v4/__init__.py": "8fe1793927dcdd2d15d0ce1bd3f53b759c0622d6754b5b8efdbb9764e76fde48",
"netplan_v4/battery_model.py": "2142f9173e872da8718ce3f5fa6a0062bd4f2f8ed75d42889bc41e2bd00ef332",
"netplan_v4/controlled_trial.py": "4d4bd8ed97050b3bf5872c839e4bcc2fc9dcb9f2e0a8c66a4611bbd50bf9e1c4",
"netplan_v4/domain.py": "03aadfb6d69f349041c872e105b3ae31e880b507a630f82bdd774948a618b39b",
"netplan_v4/economic_replay.py": "bcf1822486e5330495775fe3df63b27d3159147a09a24759d0f9227547561302",
"netplan_v4/forecast_quality.py": "b656868538cb822dc1dec97c7726bcc47d78744a1db3ce02a11d259ba9c2a840",
"netplan_v4/history_timing.py": "d2992e15d8d1a4f79d068405eefba4d4b37a4326afb09064cfd7c098822ec63f",
"netplan_v4/mapping_identity.py": "199bd4b80c618e0d3d8c5f0a7a9168820022cffa403ee6861ac2e3b933f9fc4d",
"netplan_v4/measurement_pipeline.py": "32e9b245c0e35f1047f08cd6286193ebcb2db267c11cefe36885bcc3d9c8ca7f",
"netplan_v4/meter_runtime.py": "43275072a212351fa35d34ed2f4932a259112d516d594fad19a7e2b3fd44c547",
"netplan_v4/metering.py": "9ed4d3747de76f646d7be603cbdf37a3fc971109605705017c6644c0baa80987",
"netplan_v4/optimizer.py": "bf1ad3dcadf10c84e76f6758525fc1309b9f665853c660a1107c1347f1ce9063",
"netplan_v4/peak_policy.py": "5ac706655041efb964d4217b5c66a5454aa10348f45d29a98f1a736f3a860576",
"netplan_v4/receiver_contract.py": "a43bec2bc2ad8d621f6e85594013b6da3bee1a6ff8ef523f25dafa04672f401c",
"netplan_v4/retention.py": "a902024f00fdc22452e391917c6064a9fa30d60c30cebd0cdd192ce8bc1a0cc3",
"netplan_v4/selection.py": "8a2fd034b7a74c9d00d12e12cd76da113c29c3458541c89831c25874c98298ad",
"netplan_v4/service.py": "80f0c835a8269a78ba62d52dea3da67a664f6b786627ed38ac02674b6f0f491f",
"netplan_v4/store.py": "cb6a87e6f08f8f36840e6e1fd0e7b8f09c1bcb012193ce1d8819c5f963eab3b2",
"release_preflight.py": "85755aea15daa4709b29838fa25b96cf4aef0166114a8b8474ef0d04422927d5",
"requirements.txt": "0b6febdec6a430645b1a17068c19799f9bc451b0b5b174ea119f026063f92464",
"run_tests.py": "17192e693fb8a97be1f0a2f166568d84e86056d4a7ff97f94dcbe1f4391719a4",
"runtime_preflight.py": "69b0fa8925c00cbd2399375301c63acc60f6dd5fe1438ad4458eaa07f9d087a1",
"tests/manager_protocol.php": "043c763f578d176b09224170690c1fb0a204c7e821b3687fbcf33895355e6f93",
"tests/portal.test.mjs": "4e08acda7cbf5eac5b9e3d8d032ca1403250a993ac022e94f440d54f65f90f0a",
"tests/test_application_deployment.py": "9fa667e08f9003dd942c6bbb1e8b77321f88b8d4ec2c042f91450e7d34bdeee5",
"tests/test_battery_recovery.py": "5185416073b4144f00c643acba48dc93be29d03163de10b1f29a8fd84c191ca3",
"tests/test_container_access.py": "181aa47e5c09235ae45a25e3221fc6871bbf89c49ce541599e9b8f7da8570df9",
"tests/test_controlled_trial.py": "8988396ddc709dc8d6bb039f0c4d1ab50efb47e455975f35de5b0a305bdbfe64",
"tests/test_controlled_trial_pipeline.py": "df3d8d6d816c77cdcb2f15d4caaf61fb52e919e56c1c842902b0808f54788e0d",
"tests/test_delivery.py": "ca36fbb6fccc7e89ffbf7147bb8f2b6ed2a5614f270ac87499c6360ccd95af09",
"tests/test_economic_replay.py": "a3b41f9d8d296f889814c985c357d16efa3b6fd6ae683f2324ec53cd662629c3",
"tests/test_forecast_acceptance.py": "d2305dee8ccd8e633ca5b44b497dde519b715369f5a5572b864919fa2bdddd76",
"tests/test_forecast_quality.py": "461750d0b91d5fe21ec5b0a7d97a83e1bfe76fd6893950281be4753dac3baf82",
"tests/test_history_timing.py": "7a11d52c779cd25f44bc2bb56a758b862154e2c8df6987046e18eace46ed51a2",
"tests/test_history_timing_deployment.py": "7508d24b70c1fa4c33e21fbad4e99d5bff23e262d91222df5b0405e937c96d2e",
"tests/test_integrated_deployment.py": "ac4b1cf56a4222b423fa78d1870ccb8cf02a07a3ad2119a950dd009ea157e883",
"tests/test_mapping_recovery.py": "5c85eedf7ae780c8efa11ced856a7816ce24df0347d01e1a08e19f6dc101468e",
"tests/test_measurement_pipeline.py": "761887486922762dd12a1b3beb00f41e0000ec3cbfe3b101484d0fe8f2364eb4",
"tests/test_metering.py": "6c7af6e624c93cde4b6cca00e66fc8fec0b72a047ac77e16778dc1926194a69d",
"tests/test_peak_release.py": "223f4374111cfab99ef352a71a7b91a7f71abc4ed126f2c180b138d1bd390d3d",
"tests/test_receiver_contract.py": "21daccb358fa62a983d2292d5de2b9b3c7300a08e314e850749316a24d3d254e",
"tests/test_release_preflight.py": "e9f0dfc3dd752c2cc25908e47781554bf71e2e1af4f4d2514621fab37205ca68",
"tests/test_retention.py": "dfe62da159be4b103643d61c10307395e22612c65017aaadd469d50a65398b51",
"tests/test_unified_release.py": "3665b3ffd0872e1956312ac16ab9527802ebc396e52ae0d2954bad53890d5236",
"tests/test_v4.py": "c35db22a864aa0afc4d0abc357ae854f038e86de8e3001760c0e63ce036a7763"
},
"scope": "application_source_only",
"productionReady": false
}
@@ -0,0 +1,7 @@
FROM php:8.3-cli
WORKDIR /check
COPY php-src/ ./
RUN find /check -type d -exec chmod 0755 {} + \
&& find /check -type f -exec chmod 0644 {} +
USER 1000:1000
CMD ["php", "/check/check.php"]
@@ -0,0 +1,36 @@
"""Offline candidate-forecast tests: no telemetry, model loading or publication.
Run in an unprivileged, networkless test container with no live data volumes.
"""
from pathlib import Path
import ast
import hashlib
import json
import sys
import unittest
def main():
root = Path('/app/forecast')
sys.path.insert(0, str(root))
manifest = json.loads((root / 'SOURCE_MANIFEST.json').read_text())
for relative, expected in manifest.items():
p = root / relative
if Path(relative).is_absolute() or '..' in Path(relative).parts or not p.resolve().is_relative_to(root):
raise ValueError('Unsafe manifest path')
raw = p.read_bytes()
if hashlib.sha256(raw).hexdigest() != expected:
raise ValueError('Test image source checksum mismatch')
if p.suffix == '.py':
ast.parse(raw, filename=str(p))
import pandas, numpy, scipy, sklearn
versions = {'python': sys.version.split()[0], 'pandas': pandas.__version__,
'numpy': numpy.__version__, 'scipy': scipy.__version__, 'sklearn': sklearn.__version__}
print('Candidate forecast runtime:', json.dumps(versions), flush=True)
suite = unittest.defaultTestLoader.discover(str(root / 'tests'))
result = unittest.TextTestRunner(verbosity=2).run(suite)
print('Forecast tests only; no live data, no publication, no training job.', flush=True)
return 0 if result.wasSuccessful() else 1
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,25 @@
{
"main.py": "4060564a4a33400ef6b8547633fc4c97caa3f674494d8cfc53a7aae0ed011b6c",
"methods/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"methods/battery_optimizer.py": "27e7404d3bf4a2511e022232c2f6877adc0db014bdeb132a2a13cd4949d4d5e6",
"methods/common.py": "0fe5c9bc3fa8b6d40f0f9db36843623c6e469c150ed25899e3176355d5bb1db8",
"methods/var_1.py": "6a7fc3aaf904442aba44bd211d89e4ba54f10485f54441a1239de19e91fa5fe4",
"methods/var_10.py": "a3caf21387620687646775e6b0bf85c97af8bc6d0a48e3d779e9684d310333b7",
"methods/var_11.py": "1f783e57fee22761e8e5439caef48e275876a7ebf8380467d9b71ad2aa1b8edc",
"methods/var_13.py": "f12407cd056a1f28f47ab93b1262c62627c80d4765e75dd495e2c19e8f8e2ad9",
"methods/var_2.py": "5bdfd61bb108368890ff1b920f60499eabf6363c6380637100380eb0e28f0c6a",
"methods/var_21.py": "e2e3361d57fae8379dcce89cd98595a0d56d23decd1073d94474302b53ff8e15",
"methods/var_22.py": "cd51104bf98c686360c037cb74ff0a40bb748f24e52575eb3a3ef9932fac8cd6",
"methods/var_23.py": "d356d4078723a59e6cfad5ade631883cf22e7abd9caf38cec046e26b0580c678",
"methods/var_3.py": "87662e491ba33e170f3bcfdbd8dd2e54630073fcf4cb6e2e8ab768f9a6d95984",
"model_isolation.py": "db33ee8e9583cf006a5224a9f9efeed874ce04144d74f1b1bb25852c614468c5",
"netplan_v4_publisher.py": "cb8efe10d2799215b347985c53c96a6420e5461fff4c4e232b6f918d8bff2161",
"requirements.txt": "1f4731380246b4b08e5666ce736d9f24063128d2fc0737a061b36ac93952c083",
"shared_utils.py": "271489e491305d97706e0a4b5c8745bcb01e19628a0cee71da15512ee2d85e57",
"soc_diagnostics.py": "06ce7c55aa69875df94471a9fe47ff3d95da372192b737b9a0ff6493503ac15a",
"telemetry_quality.py": "bb959d08d2d50d5597dc10b47a35d510e43ba8bfa755db236652071b57ad1810",
"tests/test_battery_optimizer.py": "5b8fa3185672530c072a8cfe506ac8d4878846efbb2ee818b93ee04d57ca7cc8",
"tests/test_load_forecast.py": "000903a3691297dd7cfc160b3702825a6f04d53ae9d745dc08f7bfadec06465c",
"tests/test_model_isolation.py": "4dadcc7541181a57badc337fa33100c0ba2b9fd20b7dd36b87326eeeb285cf30",
"tests/test_telemetry_integrity.py": "8e6a200d6a108d309b8c5ceba15fdb1653644789875648c4284b75170d56b5ce"
}
@@ -0,0 +1,908 @@
from netplan_v4_publisher import publish_forecasts as _v4_publish_forecasts
from model_isolation import collect_predictions
import os
import sqlite3
import json
import time
import datetime
import traceback
import warnings
import subprocess
import sys
import threading
import numpy as np
import pandas as pd
import pytz
from influxdb_client import InfluxDBClient, Point, WritePrecision
from influxdb_client.client.write_api import SYNCHRONOUS
from soc_diagnostics import battery_soc_points
from telemetry_quality import require_recent_telemetry, sanitize_measured_frame
try:
from influxdb_client.client.warnings import MissingPivotFunction
warnings.simplefilter("ignore", MissingPivotFunction)
except Exception:
pass
import methods.var_1 as v1
import methods.var_2 as v2
import methods.var_3 as v3
import methods.var_10 as v10
import methods.var_11 as v11
import methods.var_13 as v13
import methods.var_21 as v21
import methods.var_22 as v22
import methods.var_23 as v23
INFLUX_URL = os.getenv("INFLUX_URL", "http://influxdb:8086")
INFLUX_TOKEN = os.environ["INFLUX_TOKEN"]
INFLUX_ORG = os.getenv("INFLUX_ORG", "belevo")
INFLUX_BUCKET = os.getenv("INFLUX_BUCKET", "energy_data")
SQLITE_DB_PATH = os.getenv("SQLITE_DB_PATH", "/app/data/users.db")
HISTORY_START = os.getenv("FORECAST_HISTORY_START", "1970-01-01T00:00:00Z")
QUALITY_LOOKBACK_DAYS = int(os.getenv("FORECAST_QUALITY_LOOKBACK_DAYS", "14"))
LOCAL_TZ = pytz.timezone(os.getenv("TZ", "Europe/Zurich"))
INFLUX_TIMEOUT_MS = int(os.getenv("FORECAST_INFLUX_TIMEOUT_MS", "120000"))
FORECAST_TIMEOUT_SECONDS = int(os.getenv("FORECAST_RUN_TIMEOUT_SECONDS", "900"))
FORECAST_HORIZON_HOURS = max(24, min(72, int(os.getenv("FORECAST_HORIZON_HOURS", "48"))))
TRAINING_TIMEOUT_SECONDS = int(os.getenv("FORECAST_TRAINING_TIMEOUT_SECONDS", "3600"))
FORECAST_STALE_SECONDS = int(os.getenv("FORECAST_STALE_SECONDS", "5400"))
WATCHDOG_INTERVAL_SECONDS = int(os.getenv("FORECAST_WATCHDOG_INTERVAL_SECONDS", "300"))
TRAINING_HOUR = int(os.getenv("FORECAST_TRAINING_HOUR", "2"))
LAST_SUCCESS_PATH = os.getenv("FORECAST_LAST_SUCCESS_PATH", "/tmp/forecast_engine_last_success.json")
LAST_TRAINING_PATH = os.getenv("FORECAST_LAST_TRAINING_PATH", "/app/data/forecast_training_status.json")
last_trained_day = None
_FORECAST_PROCESS_LOCK = threading.Lock()
_TRAINING_THREAD = None
MODEL_MODULES = {1: v1, 2: v2, 3: v3, 10: v10, 11: v11, 13: v13, 21: v21, 22: v22, 23: v23}
QUALITY_TARGETS = {1: "PV", 10: "PV", 21: "PV", 2: "Hausverbrauch", 11: "Hausverbrauch", 22: "Hausverbrauch"}
def active(config, n):
return bool(int(config.get(f"prog_var_{n}", config.get(f"var_{n}", 0)) or 0))
def get_configs():
conn = sqlite3.connect(SQLITE_DB_PATH)
conn.row_factory = sqlite3.Row
columns = {row["name"] for row in conn.execute("PRAGMA table_info(anlagen_meta)")}
if "batt_grid_charging_enabled" not in columns:
conn.execute(
"ALTER TABLE anlagen_meta ADD COLUMN batt_grid_charging_enabled INTEGER NOT NULL DEFAULT 0"
)
conn.commit()
rows = conn.execute("SELECT * FROM anlagen_meta").fetchall()
conn.close()
configs = []
for r in rows:
d = dict(r)
try:
d["daecher"] = json.loads(d.get("daecher") or "[]")
except Exception:
d["daecher"] = []
for key, default in [
("ac_leistung", 10.0),
("batt_capacity_kwh", 0.0),
("batt_power_kw", 0.0),
("tarif_bezug_fest", 0.30),
("tarif_einspeisung_fest", 0.10),
("tarif_peak_fest", 5.0),
]:
raw_value = d.get(key)
d[key] = float(default if raw_value is None or raw_value == "" else raw_value)
configs.append(d)
return configs
def _time_literal(dt):
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def _query_df(query):
client = InfluxDBClient(
url=INFLUX_URL,
token=INFLUX_TOKEN,
org=INFLUX_ORG,
timeout=INFLUX_TIMEOUT_MS,
)
try:
df = client.query_api().query_data_frame(org=INFLUX_ORG, query=query)
finally:
client.close()
if isinstance(df, list):
df = pd.concat(df, ignore_index=True) if df else pd.DataFrame()
if df is None or df.empty or "_time" not in df.columns:
return pd.DataFrame()
df["_time"] = pd.to_datetime(df["_time"], utc=True).dt.tz_localize(None)
return df
def _pivot_frame(df, fields):
if df.empty:
return pd.DataFrame()
out = df.set_index("_time")
keep = [c for c in fields if c in out.columns]
out = out[keep] if keep else pd.DataFrame(index=out.index)
out = out.apply(pd.to_numeric, errors="coerce")
out = out[~out.index.duplicated(keep="last")]
return out.sort_index().resample("5min").mean(numeric_only=True)
def _tariff_frame(df, config=None):
if df is None or df.empty:
return pd.DataFrame()
df = df.copy()
if "_time" in df.columns:
df["_time"] = pd.to_datetime(df["_time"], errors="coerce")
df = df.dropna(subset=["_time"]).set_index("_time")
if df.empty:
return pd.DataFrame()
price = df["price_chf_kwh"] if "price_chf_kwh" in df.columns else df.get("_value")
if price is None:
return pd.DataFrame()
price = pd.to_numeric(price, errors="coerce")
model = df.get("tariff_model", pd.Series("", index=df.index)).astype(str).str.lower()
typ = df.get("type", pd.Series("", index=df.index)).astype(str).str.lower()
tariff_name = df.get("tariff_name", pd.Series("", index=df.index)).astype(str).str.lower()
provider = df.get("provider", pd.Series("", index=df.index)).astype(str).str.lower()
key = (model + " " + tariff_name + " " + provider).str.lower()
rows = pd.DataFrame({"price": price, "key": key, "type": typ}, index=df.index)
rows = rows[pd.notna(rows["price"])].sort_index()
if rows.empty:
return pd.DataFrame()
cfg = config or {}
import_choice = str(cfg.get("tarif_bezug", "") or "").lower()
export_choice = str(cfg.get("tarif_einspeisung", "") or "").lower()
out = pd.DataFrame(index=rows.index.unique().sort_values())
import_base = (
rows["type"].str.contains("consumption|import|bezug", regex=True, na=False)
| rows["key"].str.contains("dynamic|dynamisch|home|business", regex=True, na=False)
)
if "business" in import_choice:
import_mask = import_base & rows["key"].str.contains("business|gewerbe|commercial", regex=True, na=False)
elif "home" in import_choice or "privat" in import_choice:
import_mask = import_base & rows["key"].str.contains("home|privat|private", regex=True, na=False)
elif "dynam" in import_choice:
import_mask = import_base
else:
import_mask = pd.Series(False, index=rows.index)
if not import_mask.any() and "dynam" in import_choice:
import_mask = import_base
export_base = (
rows["type"].str.contains("feed|einspeis|export", regex=True, na=False)
| rows["key"].str.contains("referenzmarktpreis|marktpreis|reference|feed", regex=True, na=False)
)
if "referenz" in export_choice or "marktpreis" in export_choice or "market" in export_choice:
export_mask = export_base & rows["key"].str.contains("referenzmarktpreis|marktpreis|reference|belevo", regex=True, na=False)
else:
export_mask = rows["key"].str.contains("standard_feedin|ckw statisch", regex=True, na=False) & export_base
if not export_mask.any() and ("referenz" in export_choice or "marktpreis" in export_choice or "market" in export_choice):
export_mask = export_base
if import_mask.any():
out["import_price"] = rows.loc[import_mask, "price"].groupby(level=0).last()
if export_mask.any():
out["export_price"] = rows.loc[export_mask, "price"].groupby(level=0).last()
if out.empty:
return out
return out.sort_index().resample("5min").mean().ffill().bfill()
def fetch_influx_frames(config, training):
aid = config["anlagen_id"]
start = HISTORY_START if training else "-14d"
future_stop = _time_literal(datetime.datetime.utcnow() + datetime.timedelta(hours=FORECAST_HORIZON_HOURS))
q_tel = f'''
from(bucket: "{INFLUX_BUCKET}")
|> range(start: {start})
|> filter(fn: (r) => r["_measurement"] == "api_telemetry")
|> filter(fn: (r) => r["anlagen_id"] == "{aid}")
|> filter(fn: (r) => r["_field"] == "PV" or r["_field"] == "Hausverbrauch" or r["_field"] == "Netzleistung" or r["_field"] == "SOC")
|> filter(fn: (r) => not exists r["data_type"] or (r["data_type"] != "forecast" and r["data_type"] != "forecast_snapshot"))
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false, timeSrc: "_start")
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
'''
df_tel = _pivot_frame(_query_df(q_tel), ["PV", "Hausverbrauch", "Netzleistung", "SOC"])
q_wea = f'''
from(bucket: "{INFLUX_BUCKET}")
|> range(start: {start}, stop: {future_stop})
|> filter(fn: (r) => r["_measurement"] == "weather_forecast")
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
'''
df_wea = _pivot_frame(_query_df(q_wea), ["temp_c", "temperature", "cloud", "cloud_cover", "precip_mm", "wind_kph", "chance_of_snow"])
if "temperature" in df_wea.columns and "temp_c" not in df_wea.columns:
df_wea["temp_c"] = df_wea["temperature"]
if "cloud_cover" in df_wea.columns and "cloud" not in df_wea.columns:
df_wea["cloud"] = df_wea["cloud_cover"]
if "cloud" in df_wea.columns and "cloud_cover" not in df_wea.columns:
df_wea["cloud_cover"] = df_wea["cloud"]
q_tar = f'''
from(bucket: "{INFLUX_BUCKET}")
|> range(start: -7d, stop: {future_stop})
|> filter(fn: (r) => r["_measurement"] == "tariffs")
|> filter(fn: (r) => r["_field"] == "price_chf_kwh")
'''
df_tar = _tariff_frame(_query_df(q_tar), config)
return df_tel, df_wea, df_tar
def _consistent_tail(df):
if df.empty or "PV" not in df.columns or "Hausverbrauch" not in df.columns:
return df
probe = df[["PV", "Hausverbrauch"]].copy()
filled = probe.interpolate(limit=3, limit_direction="both")
valid = filled.notna().all(axis=1)
if not valid.any():
return df.iloc[0:0]
run = 0
last_break = -1
for i, ok in enumerate(valid.to_numpy()):
if ok:
run = 0
else:
run += 1
if run >= 4:
last_break = i
if last_break >= 0:
after = np.where(valid.iloc[last_break + 1:].to_numpy())[0]
if len(after):
return df.iloc[last_break + 1 + after[0]:]
return df.iloc[0:0]
return df.loc[valid[valid].index[0]:]
def _longest_consistent_segment(df, column):
if df.empty or column not in df.columns:
return pd.DataFrame()
series = pd.to_numeric(df[column], errors="coerce")
valid = series.notna().to_numpy()
if not valid.any():
return pd.DataFrame()
best_start = best_end = None
start = 0
gap = 0
for i, ok in enumerate(valid):
if ok:
gap = 0
else:
gap += 1
if gap >= 4:
end = i - gap
if end >= start and (best_start is None or end - start > best_end - best_start):
best_start, best_end = start, end
start = i + 1
gap = 0
end = len(valid) - 1
if end >= start and (best_start is None or end - start > best_end - best_start):
best_start, best_end = start, end
if best_start is None:
return pd.DataFrame()
segment = df.iloc[best_start:best_end + 1].copy()
first = pd.to_numeric(segment[column], errors="coerce").first_valid_index()
last = pd.to_numeric(segment[column], errors="coerce").last_valid_index()
if first is None or last is None:
return pd.DataFrame()
return segment.loc[first:last]
def _add_time_features(df):
hour = df.index.hour + df.index.minute / 60.0
doy = df.index.dayofyear
df["hour_float"] = hour
df["hour_sin"] = np.sin(2 * np.pi * hour / 24.0)
df["hour_cos"] = np.cos(2 * np.pi * hour / 24.0)
df["sin_year"] = np.sin(2 * np.pi * doy / 365.25)
df["cos_year"] = np.cos(2 * np.pi * doy / 365.25)
df["weekday"] = df.index.weekday
df["is_weekday"] = (df.index.weekday < 5).astype(int)
return df
def _fill_defaults(df, history):
defaults = {
"PV": np.nan,
"Hausverbrauch": np.nan,
"SOC": np.nan,
"Netzleistung": np.nan,
"temp_c": 15.0,
"cloud": 20.0,
"cloud_cover": 20.0,
"precip_mm": 0.0,
"wind_kph": 0.0,
"chance_of_snow": 0.0,
"import_price": np.nan,
"export_price": np.nan,
}
for col, default in defaults.items():
if col not in df.columns:
df[col] = default
df[col] = pd.to_numeric(df[col], errors="coerce")
# Measurements are never interpolated/filled here. Outages are not zero load,
# zero PV, a fresh SOC, or a measured grid peak. Weather defaults are separate.
df = sanitize_measured_frame(df)
for col, default in defaults.items():
if col not in ["PV", "Hausverbrauch", "SOC", "Netzleistung"]:
df[col] = df[col].ffill().bfill()
if np.isfinite(default):
df[col] = df[col].fillna(default)
if "cloud_cover" in df.columns:
df["cloud"] = df["cloud"].fillna(df["cloud_cover"])
df["import_price"] = df["import_price"].fillna(0.30)
df["export_price"] = df["export_price"].fillna(0.10)
return _add_time_features(df)
def build_data_object(config, training=False):
now = datetime.datetime.utcnow().replace(second=0, microsecond=0)
now = now - datetime.timedelta(minutes=now.minute % 5)
df_tel, df_wea, df_tar = fetch_influx_frames(config, training)
frames = [f for f in [df_tel, df_wea, df_tar] if not f.empty]
combined = frames[0] if frames else pd.DataFrame()
for f in frames[1:]:
combined = combined.join(f, how="outer")
combined = combined.sort_index()
full_hist_raw = combined.loc[:now - datetime.timedelta(minutes=5)] if not combined.empty else pd.DataFrame()
# A trailing telemetry gap must not discard all earlier valid observations.
hist_raw = full_hist_raw.copy()
# Freshness is determined from measurements, not the outer-joined weather grid.
recent_raw = sanitize_measured_frame(df_tel.loc[:now - datetime.timedelta(minutes=5)].copy()) if not df_tel.empty else pd.DataFrame()
start_hist = hist_raw.index.min().floor("5min") if training and not hist_raw.empty else now - datetime.timedelta(days=14)
idx_hist = pd.date_range(start=start_hist, end=now - datetime.timedelta(minutes=5), freq="5min")
df_hist = pd.DataFrame(index=idx_hist).join(hist_raw, how="left")
df_hist = _fill_defaults(df_hist, history=True)
df_load_training = pd.DataFrame()
if training and "Hausverbrauch" in full_hist_raw.columns:
load_segment = _longest_consistent_segment(full_hist_raw, "Hausverbrauch")
if not load_segment.empty:
idx_load = pd.date_range(
start=load_segment.index.min().floor("5min"),
end=load_segment.index.max().floor("5min"),
freq="5min",
)
df_load_training = pd.DataFrame(index=idx_load).join(load_segment, how="left")
df_load_training = _fill_defaults(df_load_training, history=True)
df_pv_training = pd.DataFrame()
if training and "PV" in full_hist_raw.columns:
pv_first = full_hist_raw["PV"].first_valid_index()
if pv_first is not None:
idx_pv = pd.date_range(
start=pv_first.floor("5min"),
end=now - datetime.timedelta(minutes=5),
freq="5min",
)
df_pv_training = pd.DataFrame(index=idx_pv).join(full_hist_raw, how="left")
df_pv_training = _fill_defaults(df_pv_training, history=True)
idx_fut = pd.date_range(start=now, periods=FORECAST_HORIZON_HOURS * 12, freq="5min")
fut_raw = combined.reindex(combined.index.union(idx_fut)).sort_index() if not combined.empty else pd.DataFrame(index=idx_fut)
df_fut = pd.DataFrame(index=idx_fut).join(fut_raw, how="left")
df_fut = _fill_defaults(df_fut, history=False)
month_hist = df_hist[(df_hist.index.year == now.year) & (df_hist.index.month == now.month)]
current_peak_kw = 0.0
if not month_hist.empty and "Netzleistung" in month_hist.columns:
measured_grid = pd.to_numeric(month_hist["Netzleistung"], errors="coerce").dropna()
measured_peak = measured_grid.resample(
"15min", origin="start_day", label="left", closed="left"
).mean()
if not measured_peak.empty:
current_peak_kw = max(0.0, float(measured_peak.max()) / 1000.0)
if current_peak_kw <= 0.0 and not month_hist.empty and {"Hausverbrauch", "PV"}.issubset(month_hist.columns):
fallback_residual = (month_hist["Hausverbrauch"] - month_hist["PV"]).resample(
"15min", origin="start_day", label="left", closed="left"
).mean()
if not fallback_residual.empty:
current_peak_kw = max(0.0, float(fallback_residual.max()) / 1000.0)
min_soc = float(
config.get("batt_min_soc", config.get("batt_min_soc_percent", 0.0)) or 0.0
)
current_soc = max(0.0, min(100.0, min_soc))
current_soc_source = "safe_minimum"
current_soc_age_minutes = None
if "SOC" in full_hist_raw.columns:
soc_values = pd.to_numeric(full_hist_raw["SOC"], errors="coerce").dropna()
if not soc_values.empty:
latest_soc_time = pd.Timestamp(soc_values.index[-1])
current_soc_age_minutes = max(
0.0,
(pd.Timestamp(now) - latest_soc_time).total_seconds() / 60.0,
)
try:
max_soc_age_minutes = max(
5.0,
float(config.get("batt_soc_max_age_minutes", 30.0) or 30.0),
)
except (TypeError, ValueError):
max_soc_age_minutes = 30.0
if current_soc_age_minutes <= max_soc_age_minutes:
current_soc = float(soc_values.iloc[-1])
current_soc_source = "telemetry"
current_soc = max(0.0, min(100.0, current_soc))
return {
"config": config,
"metadata": config,
"now": now,
"df_hist": df_hist,
"df_recent_raw": recent_raw,
"df_load_training": df_load_training,
"df_pv_training": df_pv_training,
"df_fut": df_fut,
"current_soc_perc": current_soc,
"current_soc_source": current_soc_source,
"current_soc_age_minutes": current_soc_age_minutes,
"current_month_peak_kw": current_peak_kw,
}
def run_training():
for config in get_configs():
if not any(active(config, n) for n in MODEL_MODULES):
continue
print(f"Training Anlage {config['anlagen_id']}...")
data_obj = build_data_object(config, training=True)
for n, module in MODEL_MODULES.items():
if active(config, n) and hasattr(module, "train"):
try:
target = QUALITY_TARGETS.get(n)
if target:
require_recent_telemetry(data_obj, [target])
print(f" var_{n}: {module.train(data_obj)}")
except Exception:
print(f" var_{n}: Training fehlgeschlagen")
traceback.print_exc()
def _forecast_point(aid, field, t, value):
return (
Point("api_telemetry")
.tag("anlagen_id", aid)
.tag("data_type", "forecast")
.field(field, float(value))
.time(t.to_pydatetime(), WritePrecision.S)
)
def _snapshot_point(aid, field, run_hour, t, value):
return (
Point("api_telemetry")
.tag("anlagen_id", aid)
.tag("data_type", "forecast_snapshot")
.tag("run_hour", run_hour)
.field(f"{field}_run_{run_hour}", float(value))
.time(t.to_pydatetime(), WritePrecision.S)
)
def _quality_query(aid, forecast_field, target_field):
stop = _time_literal(datetime.datetime.utcnow() - datetime.timedelta(minutes=10))
return f'''
from(bucket: "{INFLUX_BUCKET}")
|> range(start: -{QUALITY_LOOKBACK_DAYS}d, stop: {stop})
|> filter(fn: (r) => r["_measurement"] == "api_telemetry")
|> filter(fn: (r) => r["anlagen_id"] == "{aid}")
|> filter(fn: (r) => r["_field"] == "{target_field}" or r["_field"] == "{forecast_field}")
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
|> group()
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
'''
def _forecast_quality(aid, variant, target_field):
forecast_field = f"prog_var_{variant}"
df = _query_df(_quality_query(aid, forecast_field, target_field))
if df.empty or forecast_field not in df.columns or target_field not in df.columns:
return None
pair = df[[target_field, forecast_field]].apply(pd.to_numeric, errors="coerce").dropna()
if len(pair) < 3:
return None
actual = pair[target_field].to_numpy(dtype=float)
pred = pair[forecast_field].to_numpy(dtype=float)
ss_res = float(np.sum((actual - pred) ** 2))
ss_tot = float(np.sum((actual - np.mean(actual)) ** 2))
r2 = None if ss_tot <= 0 else 1.0 - (ss_res / ss_tot)
mae = float(np.mean(np.abs(actual - pred)))
rmse = float(np.sqrt(np.mean((actual - pred) ** 2)))
return {"r2": r2, "mae": mae, "rmse": rmse, "samples": int(len(pair))}
def _quality_point(aid, variant, target, metrics):
p = (
Point("forecast_metrics")
.tag("anlagen_id", aid)
.tag("forecast", f"prog_var_{variant}")
.tag("target", target)
.field("samples", int(metrics["samples"]))
.field("mae", float(metrics["mae"]))
.field("rmse", float(metrics["rmse"]))
.time(datetime.datetime.utcnow(), WritePrecision.S)
)
if metrics["r2"] is not None:
p.field("r2", float(metrics["r2"]))
return p
def write_quality_metrics(write_api, aid, active_variants):
points = []
for variant, target in QUALITY_TARGETS.items():
if variant not in active_variants:
continue
try:
metrics = _forecast_quality(aid, variant, target)
if metrics:
points.append(_quality_point(aid, variant, target, metrics))
r2_text = "nan" if metrics["r2"] is None else f"{metrics['r2']:.3f}"
print(f"R2 Anlage {aid} prog_var_{variant}: {r2_text} / samples={metrics['samples']}")
except Exception:
print(f"R2 Anlage {aid} prog_var_{variant}: Berechnung fehlgeschlagen")
traceback.print_exc()
if points:
try:
write_api.write(bucket=INFLUX_BUCKET, org=INFLUX_ORG, record=points)
except Exception:
print(f"R2 Anlage {aid}: Schreiben der Qualitaetswerte fehlgeschlagen")
traceback.print_exc()
def run_forecast(only_anlagen_id=None, manual=False):
configs = get_configs()
if only_anlagen_id:
configs = [c for c in configs if c.get("anlagen_id") == only_anlagen_id]
client = InfluxDBClient(
url=INFLUX_URL,
token=INFLUX_TOKEN,
org=INFLUX_ORG,
timeout=INFLUX_TIMEOUT_MS,
)
write_api = client.write_api(write_options=SYNCHRONOUS)
completed = []
failures = []
try:
for config in configs:
aid = config["anlagen_id"]
try:
data_obj = build_data_object(config, training=False)
targets = []
if any(active(config, n) for n in (1, 3, 10, 13, 21, 23)):
targets.append("PV")
if any(active(config, n) for n in (2, 3, 11, 13, 22, 23)):
targets.append("Hausverbrauch")
if float(config.get("batt_capacity_kwh", 0.0) or 0.0) > 0 and any(active(config, n) for n in (3, 13, 23)):
targets.append("SOC")
# Fail BEFORE model prediction, snapshot publication or V1/V4 plan writes.
data_obj["telemetry_quality"] = require_recent_telemetry(data_obj, targets)
soc_age = data_obj.get("current_soc_age_minutes")
soc_age_text = "keine Messung" if soc_age is None else f"{soc_age:.1f} min"
print(
f"Forecast Anlage {aid}: Batterie-SOC {data_obj['current_soc_perc']:.1f}% "
f"({data_obj['current_soc_source']}, Alter {soc_age_text})."
)
forecasts, model_errors = collect_predictions(
data_obj, config,
{1: v1, 2: v2, 10: v10, 11: v11, 21: v21, 22: v22}, active,
)
data_obj['forecast_model_status'] = model_errors
for variant, detail in model_errors.items():
print(f"Forecast Anlage {aid} prog_var_{variant}: unavailable ({detail['errorType']}); keine Nullwerte eingesetzt.")
p_1, p_2, p_10, p_11, p_21, p_22 = (
forecasts[n] for n in (1, 2, 10, 11, 21, 22)
)
# ENELIX_V4_SHADOW_BRIDGE
_v4_publish_forecasts(config, [(3,p_1,p_2),(13,p_10,p_11),(23,p_21,p_22)])
p_3 = v3.predict(data_obj, p_1, p_2) if active(config, 3) and p_1 and p_2 else {}
p_13 = v13.predict(data_obj, p_10, p_11) if active(config, 13) and p_10 and p_11 else {}
p_23 = v23.predict(data_obj, p_21, p_22) if active(config, 23) and p_21 and p_22 else {}
forecast_sets = [
(1, p_1), (2, p_2), (3, p_3),
(10, p_10), (11, p_11), (13, p_13),
(21, p_21), (22, p_22), (23, p_23),
]
run_hour = str(int(datetime.datetime.now(LOCAL_TZ).strftime("%H")))
points = []
for variant_id, pv_src, load_src, grid_src in [
(3, p_1, p_2, p_3),
(13, p_10, p_11, p_13),
(23, p_21, p_22, p_23),
]:
if grid_src and pv_src and load_src:
points.extend(battery_soc_points(data_obj, variant_id, pv_src, load_src, grid_src))
active_variants = set()
battery_plans = data_obj.get("battery_plans", {})
for t in data_obj["df_fut"].index:
for n, values in forecast_sets:
if t in values:
field = f"prog_var_{n}"
value = float(values[t])
points.append(_forecast_point(aid, field, t, value))
points.append(_snapshot_point(aid, field, run_hour, t, value))
if n in battery_plans and t in battery_plans[n].get("battery", {}):
points.append(_forecast_point(
aid,
f"prog_var_{n}_battery",
t,
float(battery_plans[n]["battery"][t]),
))
active_variants.add(n)
if points:
write_api.write(bucket=INFLUX_BUCKET, org=INFLUX_ORG, record=points)
print(f"Forecast Anlage {aid}: {len(points)} Punkte geschrieben inkl. run_{run_hour}.")
else:
print(f"Forecast Anlage {aid}: keine aktiven Prognosen oder keine Daten.")
write_quality_metrics(write_api, aid, active_variants)
completed.append(aid)
except Exception as exc:
failures.append({"anlagen_id": aid, "error": f"{type(exc).__name__}: {exc}"})
print(f"Forecast Anlage {aid}: Lauf fehlgeschlagen.")
traceback.print_exc()
finally:
client.close()
if failures:
raise RuntimeError(f"Forecast-Fehler: {failures}")
return {"completed": completed, "manual": bool(manual)}
def _write_json_atomic(path, payload):
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
tmp_path = f"{path}.tmp.{os.getpid()}"
try:
with open(tmp_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
os.replace(tmp_path, path)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def _read_json(path):
try:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
except Exception:
return {}
def _last_success_age_seconds():
stamp = _read_json(LAST_SUCCESS_PATH).get("timestamp")
if not stamp:
return None
try:
then = datetime.datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
now = datetime.datetime.now(datetime.timezone.utc)
return max(0.0, (now - then).total_seconds())
except Exception:
return None
def _child_environment(training=False):
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
if training:
max_threads = str(max(1, int(env.get("FORECAST_TRAINING_MAX_THREADS", "1"))))
for name in (
"OMP_NUM_THREADS",
"OPENBLAS_NUM_THREADS",
"MKL_NUM_THREADS",
"NUMEXPR_NUM_THREADS",
"LOKY_MAX_CPU_COUNT",
):
env[name] = max_threads
return env
def _run_child(mode, timeout_seconds, only_anlagen_id=None):
command = [sys.executable, "-u", os.path.abspath(__file__), mode]
if only_anlagen_id:
command.append(str(only_anlagen_id))
label = "Training" if mode == "--train-once" else "Forecast"
print(f"{label}-Kindprozess startet (Timeout {timeout_seconds}s).")
process = subprocess.Popen(command, env=_child_environment(training=mode == "--train-once"))
try:
return_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
print(f"{label}-Kindprozess hat das Zeitlimit erreicht und wird beendet.")
process.terminate()
try:
process.wait(timeout=15)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=15)
return 124
if return_code != 0:
print(f"{label}-Kindprozess beendet mit Status {return_code}.")
return return_code
def run_forecast_isolated(only_anlagen_id=None, source="scheduler"):
if not _FORECAST_PROCESS_LOCK.acquire(blocking=False):
print(f"Forecast-Aufruf ({source}) uebersprungen: bereits ein Lauf aktiv.")
return {"status": "busy", "source": source}
try:
return_code = _run_child("--forecast-once", FORECAST_TIMEOUT_SECONDS, only_anlagen_id)
return {
"status": "ok" if return_code == 0 else "error",
"source": source,
"return_code": return_code,
}
finally:
_FORECAST_PROCESS_LOCK.release()
def _training_worker(day_text):
global _TRAINING_THREAD
started = datetime.datetime.now(datetime.timezone.utc).isoformat()
_write_json_atomic(LAST_TRAINING_PATH, {
"day": day_text,
"status": "running",
"started_at": started,
})
return_code = 1
error = None
try:
return_code = _run_child("--train-once", TRAINING_TIMEOUT_SECONDS)
except Exception as exc:
error = f"{type(exc).__name__}: {exc}"
traceback.print_exc()
finally:
status = "ok" if return_code == 0 else ("timeout" if return_code == 124 else "error")
payload = {
"day": day_text,
"status": status,
"started_at": started,
"finished_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"return_code": return_code,
}
if error:
payload["error"] = error
_write_json_atomic(LAST_TRAINING_PATH, payload)
print(f"Nachttraining beendet: {status}.")
_TRAINING_THREAD = None
def start_training_async(day):
global _TRAINING_THREAD
if _TRAINING_THREAD is not None and _TRAINING_THREAD.is_alive():
print("Nachttraining laeuft bereits.")
return False
day_text = day.isoformat()
_TRAINING_THREAD = threading.Thread(target=_training_worker, args=(day_text,), daemon=True)
_TRAINING_THREAD.start()
return True
def _watchdog_loop():
while True:
time.sleep(max(60, WATCHDOG_INTERVAL_SECONDS))
try:
age = _last_success_age_seconds()
if age is None or age > FORECAST_STALE_SECONDS:
age_text = "unbekannt" if age is None else f"{age / 60.0:.1f} Minuten"
print(f"Forecast-Watchdog: letzter erfolgreicher Lauf {age_text}; neuer Lauf wird gestartet.")
run_forecast_isolated(source="watchdog")
except Exception:
print("Forecast-Watchdog: Pruefung fehlgeschlagen.")
traceback.print_exc()
# BEGIN EMS RESIMULATE HTTP SERVER
_RESIMULATE_SERVER_STARTED = False
def _start_resimulate_server():
global _RESIMULATE_SERVER_STARTED
if _RESIMULATE_SERVER_STARTED:
return
_RESIMULATE_SERVER_STARTED = True
import json as _json
import os as _os
import threading as _threading
import traceback as _traceback
import urllib.parse as _urlparse
from http.server import BaseHTTPRequestHandler as _BaseHTTPRequestHandler, ThreadingHTTPServer as _ThreadingHTTPServer
class _Handler(_BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def _send(self, code, body):
raw = _json.dumps(body).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
self._handle()
def do_POST(self):
self._handle()
def _handle(self):
try:
parsed = _urlparse.urlparse(self.path)
if parsed.path == "/health":
age = _last_success_age_seconds()
healthy = age is not None and age <= FORECAST_STALE_SECONDS
self._send(200 if healthy else 503, {
"status": "ok" if healthy else "stale",
"last_success_age_seconds": age,
"training": _read_json(LAST_TRAINING_PATH),
})
return
if parsed.path not in ("/run_now", "/resimulate"):
self._send(404, {"detail": "not found"})
return
params = _urlparse.parse_qs(parsed.query)
anlagen_id = (params.get("anlagen_id") or [None])[0]
result = run_forecast_isolated(only_anlagen_id=anlagen_id, source="http")
result["anlagen_id"] = anlagen_id
code = 200 if result["status"] == "ok" else (409 if result["status"] == "busy" else 500)
self._send(code, result)
except Exception as exc:
_traceback.print_exc()
self._send(500, {"detail": str(exc)})
port = int(_os.getenv("FORECAST_ENGINE_HTTP_PORT", "9000"))
server = _ThreadingHTTPServer(("0.0.0.0", port), _Handler)
thread = _threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
print(f"Forecast Resimulate HTTP Server startet auf Port {port}.")
# END EMS RESIMULATE HTTP SERVER
def main():
_start_resimulate_server()
global last_trained_day
print("Forecast Engine startet.")
threading.Thread(target=_watchdog_loop, daemon=True).start()
run_forecast_isolated(source="startup")
while True:
try:
now_local = datetime.datetime.now(LOCAL_TZ)
if now_local.hour == TRAINING_HOUR and last_trained_day != now_local.date():
if start_training_async(now_local.date()):
last_trained_day = now_local.date()
next_run = (now_local + datetime.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
time.sleep(max(60.0, (next_run - now_local).total_seconds()))
run_forecast_isolated(source="scheduler")
except Exception:
traceback.print_exc()
time.sleep(60)
if __name__ == "__main__":
if len(sys.argv) >= 2 and sys.argv[1] == "--forecast-once":
selected_anlage = sys.argv[2] if len(sys.argv) >= 3 else None
run_forecast(only_anlagen_id=selected_anlage, manual=True)
_write_json_atomic(LAST_SUCCESS_PATH, {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"anlagen_id": selected_anlage,
"pid": os.getpid(),
})
elif len(sys.argv) >= 2 and sys.argv[1] == "--train-once":
run_training()
else:
main()
@@ -0,0 +1,240 @@
import numpy as np
import pandas as pd
from scipy.optimize import Bounds, LinearConstraint, milp
from scipy.sparse import lil_matrix
DT_H = 5.0 / 60.0
def train_artifact(kind):
return {"trained": True, "type": "battery_48h_cost_milp_v3", "source": kind}
def _cfg_float(config, key, default):
try:
value = config.get(key, default)
return float(default if value is None or value == "" else value)
except Exception:
return float(default)
def _cfg_bool(config, key, default=False):
value = config.get(key, default)
if value is None or value == "":
return bool(default)
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "ja", "on"}
def _use_dynamic(config, key):
value = str(config.get(key, "") or "").strip().lower()
return any(token in value for token in ("dynam", "marktpreis", "referenzmarktpreis", "market"))
def _price(data_obj, timestamp, column, fallback, dynamic_enabled):
if not dynamic_enabled:
return fallback
frame = data_obj["df_fut"]
if column in frame.columns and timestamp in frame.index:
try:
value = float(frame.at[timestamp, column])
if np.isfinite(value):
return value
except Exception:
pass
return fallback
def _battery_meta(config, data_obj):
cap_kwh = _cfg_float(config, "batt_capacity_kwh", 0.0)
max_power_w = _cfg_float(config, "batt_power_kw", 0.0) * 1000.0
min_soc = _cfg_float(config, "batt_min_soc", _cfg_float(config, "batt_min_soc_percent", 0.0))
max_soc = _cfg_float(config, "batt_max_soc", _cfg_float(config, "batt_max_soc_percent", 100.0))
start_soc_value = data_obj.get("current_soc_perc")
if start_soc_value is None:
start_soc_value = min_soc
try:
start_soc = float(start_soc_value)
except (TypeError, ValueError):
start_soc = min_soc
min_soc = max(0.0, min(100.0, min_soc))
max_soc = max(min_soc, min(100.0, max_soc))
start_soc = max(min_soc, min(max_soc, start_soc))
charge_eff = max(0.01, min(1.0, _cfg_float(config, "batt_charge_efficiency", 0.95)))
discharge_eff = max(0.01, min(1.0, _cfg_float(config, "batt_discharge_efficiency", 0.95)))
return cap_kwh, max_power_w, min_soc, max_soc, start_soc, charge_eff, discharge_eff
def _quarter_groups(index):
groups = {}
for position, timestamp in enumerate(index):
quarter = timestamp.floor("15min") if hasattr(timestamp, "floor") else position // 3
groups.setdefault(quarter, []).append(position)
return list(groups.values())
def _fallback_plan(index, residual_w):
grid = {timestamp: float(value) for timestamp, value in zip(index, residual_w)}
battery = {timestamp: 0.0 for timestamp in index}
return {"grid": grid, "battery": battery, "solver": "fallback"}
def optimize_battery_plan(data_obj, pv_dict, load_dict):
config = data_obj["config"]
cap_kwh, max_power_w, min_soc, max_soc, start_soc, charge_eff, discharge_eff = _battery_meta(config, data_obj)
index = list(data_obj["df_fut"].index)
if not index:
return {"grid": {}, "battery": {}, "solver": "empty"}
load_w = np.array([max(0.0, float(load_dict.get(t, 0.0))) for t in index])
pv_w = np.array([max(0.0, float(pv_dict.get(t, 0.0))) for t in index])
residual_w = load_w - pv_w
if cap_kwh <= 0.0 or max_power_w <= 0.0:
return _fallback_plan(index, residual_w)
import_fixed = _cfg_float(config, "tarif_bezug_fest", 0.30)
export_fixed = _cfg_float(config, "tarif_einspeisung_fest", 0.10)
import_dynamic = _use_dynamic(config, "tarif_bezug")
export_dynamic = _use_dynamic(config, "tarif_einspeisung")
import_price = np.array([
_price(data_obj, t, "import_price", import_fixed, import_dynamic) for t in index
])
export_price = np.array([
_price(data_obj, t, "export_price", export_fixed, export_dynamic) for t in index
])
n = len(index)
imp, exp, charge, discharge, curtail, soc = 0, n, 2 * n, 3 * n, 4 * n, 5 * n
peak = 6 * n + 1
battery_mode = peak + 1
grid_mode = battery_mode + n
variable_count = grid_mode + n
max_import_w = max(float(load_w.max(initial=0.0)) + max_power_w, max_power_w, 1.0)
configured_import_limit = _cfg_float(config, "grid_import_limit_w", 0.0)
if configured_import_limit > 0.0:
max_import_w = min(max_import_w, configured_import_limit)
max_export_w = max(float(pv_w.max(initial=0.0)) + max_power_w, max_power_w, 1.0)
configured_export_limit = _cfg_float(config, "grid_export_limit_w", 0.0)
if configured_export_limit > 0.0:
max_export_w = min(max_export_w, configured_export_limit)
lower = np.zeros(variable_count)
upper = np.full(variable_count, np.inf)
upper[imp:imp + n] = max_import_w
upper[exp:exp + n] = max_export_w
upper[charge:charge + n] = max_power_w
if not _cfg_bool(config, "batt_grid_charging_enabled", False):
upper[charge:charge + n] = np.minimum(max_power_w, np.maximum(0.0, pv_w - load_w))
upper[discharge:discharge + n] = max_power_w
upper[curtail:curtail + n] = pv_w
reserve_soc = max(
min_soc,
min(100.0, _cfg_float(config, "batt_economic_reserve_soc_percent", 10.0)),
)
economic_min_soc = max(min_soc, min(start_soc, reserve_soc))
min_kwh = cap_kwh * economic_min_soc / 100.0
max_kwh = cap_kwh * max_soc / 100.0
lower[soc:soc + n + 1] = min_kwh
upper[soc:soc + n + 1] = max_kwh
current_peak_kw = max(0.0, float(data_obj.get("current_month_peak_kw", 0.0) or 0.0))
lower[peak] = current_peak_kw
upper[peak] = max(current_peak_kw, max_import_w / 1000.0)
upper[battery_mode:battery_mode + n] = 1.0
upper[grid_mode:grid_mode + n] = 1.0
objective = np.zeros(variable_count)
objective[imp:imp + n] = import_price * DT_H / 1000.0
objective[exp:exp + n] = -export_price * DT_H / 1000.0
degradation = max(0.0, _cfg_float(config, "batt_degradation_chf_kwh", 0.03))
objective[charge:charge + n] = (degradation / 2.0 + 1e-7) * DT_H / 1000.0
objective[discharge:discharge + n] = (degradation / 2.0 + 1e-7) * DT_H / 1000.0
objective[curtail:curtail + n] = 1e-9 * DT_H / 1000.0
objective[peak] = max(0.0, _cfg_float(config, "tarif_peak_fest", 0.0))
terminal_value = _cfg_float(config, "batt_terminal_value_chf_kwh", np.median(import_price))
objective[soc + n] = -max(0.0, terminal_value) * discharge_eff
equality_rows = 2 * n + 1
equality = lil_matrix((equality_rows, variable_count), dtype=float)
equality_rhs = np.zeros(equality_rows)
for i in range(n):
equality[i, imp + i] = 1.0
equality[i, exp + i] = -1.0
equality[i, charge + i] = -1.0
equality[i, discharge + i] = 1.0
equality[i, curtail + i] = -1.0
equality_rhs[i] = residual_w[i]
row = n + i
equality[row, soc + i] = -1.0
equality[row, soc + i + 1] = 1.0
equality[row, charge + i] = -charge_eff * DT_H / 1000.0
equality[row, discharge + i] = DT_H / (1000.0 * discharge_eff)
equality[2 * n, soc] = 1.0
equality_rhs[2 * n] = cap_kwh * start_soc / 100.0
quarter_groups = _quarter_groups(pd.Index(index))
inequality_rows = 4 * n + len(quarter_groups)
inequality = lil_matrix((inequality_rows, variable_count), dtype=float)
inequality_upper = np.zeros(inequality_rows)
row = 0
for i in range(n):
inequality[row, charge + i] = 1.0
inequality[row, battery_mode + i] = -max_power_w
row += 1
inequality[row, discharge + i] = 1.0
inequality[row, battery_mode + i] = max_power_w
inequality_upper[row] = max_power_w
row += 1
inequality[row, imp + i] = 1.0
inequality[row, grid_mode + i] = -max_import_w
row += 1
inequality[row, exp + i] = 1.0
inequality[row, grid_mode + i] = max_export_w
inequality_upper[row] = max_export_w
row += 1
for group in quarter_groups:
for i in group:
inequality[row, imp + i] = 1.0 / (len(group) * 1000.0)
inequality[row, peak] = -1.0
row += 1
integrality = np.zeros(variable_count, dtype=int)
integrality[battery_mode:battery_mode + n] = 1
integrality[grid_mode:grid_mode + n] = 1
constraints = [
LinearConstraint(equality.tocsr(), equality_rhs, equality_rhs),
LinearConstraint(inequality.tocsr(), -np.inf, inequality_upper),
]
result = milp(
objective,
integrality=integrality,
bounds=Bounds(lower, upper),
constraints=constraints,
options={"time_limit": max(5.0, _cfg_float(config, "batt_optimizer_timeout_seconds", 30.0))},
)
if not result.success or result.x is None:
return _fallback_plan(index, residual_w)
grid_values = result.x[imp:imp + n] - result.x[exp:exp + n]
battery_values = result.x[charge:charge + n] - result.x[discharge:discharge + n]
threshold_w = max(25.0, max_power_w * 0.005)
grid_values[np.abs(grid_values) < threshold_w] = 0.0
battery_values[np.abs(battery_values) < threshold_w] = 0.0
return {
"grid": {t: float(v) for t, v in zip(index, grid_values)},
"battery": {t: float(v) for t, v in zip(index, battery_values)},
"solver": "scipy-milp",
"objective_chf": float(result.fun),
"planned_peak_kw": float(result.x[peak]),
"start_soc_percent": float(start_soc),
"economic_min_soc_percent": float(economic_min_soc),
}
def optimize_grid_setpoint(data_obj, pv_dict, load_dict, forecast_id=None):
plan = optimize_battery_plan(data_obj, pv_dict, load_dict)
if forecast_id is not None:
data_obj.setdefault("battery_plans", {})[int(forecast_id)] = plan
return plan["grid"]
@@ -0,0 +1,32 @@
import os
import joblib
MODEL_DIR = "/app/data/models"
def model_path(aid, forecast_id):
os.makedirs(MODEL_DIR, exist_ok=True)
return os.path.join(MODEL_DIR, f"forecast_var_{forecast_id}_{aid}.pkl")
def save_model(aid, forecast_id, artifact):
path = model_path(aid, forecast_id)
tmp_path = f"{path}.tmp.{os.getpid()}"
try:
joblib.dump(artifact, tmp_path)
os.replace(tmp_path, path)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
return path
def load_model(aid, forecast_id):
path = model_path(aid, forecast_id)
if not os.path.exists(path):
return None
try:
return joblib.load(path)
except Exception as exc:
print(f"Modell {path} konnte nicht geladen werden: {exc}")
return None
@@ -0,0 +1,66 @@
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from methods.common import load_model, save_model
from shared_utils import calc_pure_math_pv, roof_features
FORECAST_ID = 1
FEATURES = [
"math_pv",
"temp_c",
"cloud",
"hour_sin",
"hour_cos",
"sin_year",
"cos_year",
"pv_kwp_total",
"roof_azimuth_sin",
"roof_azimuth_cos",
"roof_tilt_avg",
"roof_south_factor",
]
def _features(frame, config):
out = frame.copy()
out["math_pv"] = [calc_pure_math_pv(config, t) for t in out.index]
rf = roof_features(config)
for k, v in rf.items():
out[k] = v
for col, default in [("temp_c", 15.0), ("cloud", 20.0)]:
if col not in out.columns:
out[col] = default
out[col] = pd.to_numeric(out[col], errors="coerce").ffill().bfill().fillna(default)
return out[FEATURES].astype(float)
def train(data_obj):
config = data_obj["config"]
aid = config["anlagen_id"]
df = data_obj.get("df_pv_training", data_obj["df_hist"]).copy()
if "PV" not in df.columns:
return {"trained": False, "reason": "PV fehlt"}
X = _features(df, config)
y = pd.to_numeric(df["PV"], errors="coerce")
valid = X.notna().all(axis=1) & y.notna()
X, y = X.loc[valid], y.loc[valid]
if len(X) < 288:
return {"trained": False, "reason": "zu wenig Daten", "samples": int(len(X))}
model = RandomForestRegressor(n_estimators=400, max_depth=18, min_samples_leaf=2, random_state=42, n_jobs=-1)
model.fit(X, y)
path = save_model(aid, FORECAST_ID, {"model": model, "features": FEATURES})
return {"trained": True, "samples": int(len(X)), "path": path}
def predict(data_obj):
config = data_obj["config"]
aid = config["anlagen_id"]
artifact = load_model(aid, FORECAST_ID)
X = _features(data_obj["df_fut"], config)
if artifact and "model" in artifact:
values = artifact["model"].predict(X)
else:
values = X["math_pv"].to_numpy()
ac_limit = float(config.get("ac_leistung", 10.0) or 10.0) * 1000.0
return {t: max(0.0, min(float(v), ac_limit)) for t, v in zip(data_obj["df_fut"].index, values)}
@@ -0,0 +1,12 @@
from telemetry_quality import repeat_daily_profile
import datetime
from methods.common import save_model
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, 10, {"type": "repeat_pv_24h"})}
def predict(data_obj):
return repeat_daily_profile(data_obj["df_hist"], data_obj["df_fut"].index, 'PV')
@@ -0,0 +1,12 @@
from telemetry_quality import repeat_daily_profile
import datetime
from methods.common import save_model
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, 11, {"type": "repeat_load_24h"})}
def predict(data_obj):
return repeat_daily_profile(data_obj["df_hist"], data_obj["df_fut"].index, 'Hausverbrauch')
@@ -0,0 +1,13 @@
from methods.battery_optimizer import optimize_grid_setpoint, train_artifact
from methods.common import save_model
FORECAST_ID = 13
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, FORECAST_ID, train_artifact("var_10_11"))}
def predict(data_obj, pv_dict, load_dict):
return optimize_grid_setpoint(data_obj, pv_dict, load_dict, forecast_id=13)
@@ -0,0 +1,169 @@
import datetime
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from methods.common import load_model, save_model
from telemetry_quality import profile_source_value
FORECAST_ID = 2
FEATURES = [
"temp_c",
"cloud",
"hour_sin",
"hour_cos",
"weekday",
"is_weekday",
"load_24h_ago",
"load_7d_ago",
"energy_24h_rolling",
"load_3d_same_time_mean",
"load_7d_same_time_mean",
]
def _history_features(df):
out = df.copy()
out["load_24h_ago"] = out["Hausverbrauch"].shift(288)
out["load_7d_ago"] = out["Hausverbrauch"].shift(2016)
out["energy_24h_rolling"] = (out["Hausverbrauch"] * 5 / 60 / 1000).shift(1).rolling(288).sum()
same_time_lags = [out["Hausverbrauch"].shift(288 * d) for d in range(1, 8)]
out["load_3d_same_time_mean"] = pd.concat(same_time_lags[:3], axis=1).mean(axis=1)
out["load_7d_same_time_mean"] = pd.concat(same_time_lags, axis=1).mean(axis=1)
for col, default in [("temp_c", 15.0), ("cloud", 20.0)]:
if col not in out.columns:
out[col] = default
out[col] = pd.to_numeric(out[col], errors="coerce").ffill().bfill().fillna(default)
return out
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
df = data_obj.get("df_load_training", data_obj["df_hist"]).copy()
if "Hausverbrauch" not in df.columns:
return {"trained": False, "reason": "Hausverbrauch fehlt"}
df = _history_features(df)
X = df[FEATURES].astype(float)
y = pd.to_numeric(df["Hausverbrauch"], errors="coerce")
valid = X.notna().all(axis=1) & y.notna()
X, y = X.loc[valid], y.loc[valid]
if len(X) < 288:
return {"trained": False, "reason": "zu wenig Daten", "samples": int(len(X))}
validation_day = X.index.max().normalize()
validation_mask = X.index >= validation_day
if int(validation_mask.sum()) < 144:
validation_day -= datetime.timedelta(days=1)
validation_mask = (X.index >= validation_day) & (X.index < validation_day + datetime.timedelta(days=1))
score = None
if int((~validation_mask).sum()) >= 288 and int(validation_mask.sum()) >= 96:
eval_model = HistGradientBoostingRegressor(
max_iter=900,
max_depth=12,
learning_rate=0.02,
min_samples_leaf=8,
random_state=42,
)
eval_model.fit(X.loc[~validation_mask], y.loc[~validation_mask])
score = float(eval_model.score(X.loc[validation_mask], y.loc[validation_mask]))
model = HistGradientBoostingRegressor(
max_iter=1200,
max_depth=12,
learning_rate=0.02,
min_samples_leaf=8,
random_state=42,
)
model.fit(X, y)
path = save_model(
aid,
FORECAST_ID,
{"model": model, "features": FEATURES, "r2_last_day": score},
)
return {
"trained": True,
"samples": int(len(X)),
"r2_last_day": score,
"path": path,
}
def _history_value(history, ts):
if history is None or history.empty or ts not in history.index or "Hausverbrauch" not in history.columns:
return None
value = history.at[ts, "Hausverbrauch"]
return float(value) if pd.notna(value) and np.isfinite(value) and value >= 0 else None
def _same_time_values(history, t, days):
values = []
for day in range(1, days + 1):
value = _history_value(history, t - datetime.timedelta(days=day))
if value is not None and np.isfinite(value):
values.append(max(0.0, value))
return values
def predict(data_obj):
aid = data_obj["config"]["anlagen_id"]
artifact = load_model(aid, FORECAST_ID)
model = artifact["model"] if artifact and "model" in artifact else None
try:
score = float(artifact.get("r2_last_day")) if artifact and artifact.get("r2_last_day") is not None else 0.0
except (TypeError, ValueError):
score = 0.0
model_weight = min(0.35, max(0.0, score) * 0.35) if np.isfinite(score) else 0.0
frames = [
frame
for frame in (
data_obj.get("df_load_training"),
data_obj.get("df_hist"),
data_obj.get("df_recent_raw"),
)
if frame is not None and not frame.empty and "Hausverbrauch" in frame.columns
]
history = pd.concat(frames).sort_index() if frames else pd.DataFrame(columns=["Hausverbrauch"])
history = history[~history.index.duplicated(keep="last")]
fut = data_obj["df_fut"]
res = {}
for t in fut.index:
same_values = _same_time_values(history, t, 7)
load_24 = _history_value(history, t - datetime.timedelta(days=1))
load_7d = _history_value(history, t - datetime.timedelta(days=7))
if load_24 is None:
load_24 = load_7d if load_7d is not None else profile_source_value(history, t, "Hausverbrauch")
if load_7d is None:
load_7d = load_24
same_median = float(np.median(same_values)) if same_values else load_7d
profile = max(0.0, (0.50 * load_24) + (0.30 * load_7d) + (0.20 * same_median))
window = history.loc[
t - datetime.timedelta(days=1):t - datetime.timedelta(minutes=5),
"Hausverbrauch",
]
energy = float((window.sum() * 5 / 60) / 1000.0) if not window.empty else 0.0
same_3 = same_values[:3]
row = pd.DataFrame([[
float(fut.at[t, "temp_c"]),
float(fut.at[t, "cloud"]),
float(fut.at[t, "hour_sin"]),
float(fut.at[t, "hour_cos"]),
int(fut.at[t, "weekday"]),
int(fut.at[t, "is_weekday"]),
load_24,
load_7d,
energy,
float(np.mean(same_3)) if same_3 else profile,
float(np.mean(same_values)) if same_values else profile,
]], columns=FEATURES)
pred = profile
if model is not None and model_weight > 0.0:
model_pred = max(0.0, float(model.predict(row)[0]))
model_pred = min(max(model_pred, profile * 0.25), max(500.0, profile * 3.0))
pred = ((1.0 - model_weight) * profile) + (model_weight * model_pred)
res[t] = pred
history.loc[t, "Hausverbrauch"] = pred
return res
@@ -0,0 +1,62 @@
import datetime
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from methods.common import load_model, save_model
FORECAST_ID = 21
FEATURES = ["temp_c", "hour_cos", "pv_24h_ago"]
def _feature_frame(df):
out = df.copy()
if "temp_c" not in out.columns:
out["temp_c"] = 15.0
out["temp_c"] = pd.to_numeric(out["temp_c"], errors="coerce").ffill().bfill().fillna(15.0)
out["hour_float"] = out.index.hour + out.index.minute / 60.0
out["hour_cos"] = np.cos(2 * np.pi * out["hour_float"] / 24.0)
if "PV" in out.columns:
out["pv_24h_ago"] = out["PV"].shift(288)
return out
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
df = _feature_frame(data_obj.get("df_pv_training", data_obj["df_hist"]).copy())
if "PV" not in df.columns:
return {"trained": False, "reason": "PV fehlt"}
df = df.dropna(subset=FEATURES + ["PV"])
if len(df) < 288:
return {"trained": False, "reason": "zu wenig Daten", "samples": int(len(df))}
last_day = df.index.max().normalize()
train_df = df[df.index < last_day]
test_df = df[(df.index >= last_day) & (df.index < last_day + datetime.timedelta(days=1))]
score = None
if len(train_df) >= 288 and len(test_df) >= 12:
eval_model = RandomForestRegressor(n_estimators=300, max_depth=15, random_state=42)
eval_model.fit(train_df[FEATURES], train_df["PV"])
score = float(eval_model.score(test_df[FEATURES], test_df["PV"]))
print(f"[var_21] R2 PV letzter kompletter Tag: {score:.3f}")
model = RandomForestRegressor(n_estimators=300, max_depth=15, random_state=42)
model.fit(df[FEATURES], df["PV"])
path = save_model(aid, FORECAST_ID, {"model": model, "features": FEATURES, "r2_last_day": score})
return {"trained": True, "samples": int(len(df)), "features": FEATURES, "r2_last_day": score, "path": path}
def predict(data_obj):
aid = data_obj["config"]["anlagen_id"]
artifact = load_model(aid, FORECAST_ID)
model = artifact["model"] if artifact and "model" in artifact else None
hist = data_obj["df_hist"]
fut = data_obj["df_fut"].copy()
res = {}
for t in fut.index:
t_24 = t - datetime.timedelta(days=1)
pv_24 = float(hist.at[t_24, "PV"]) if t_24 in hist.index else 0.0
row = pd.DataFrame([[float(fut.at[t, "temp_c"]), float(fut.at[t, "hour_cos"]), pv_24]], columns=FEATURES)
pred = float(model.predict(row)[0]) if model is not None else pv_24
res[t] = max(0.0, pred)
return res
@@ -0,0 +1,121 @@
import datetime
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.inspection import permutation_importance
from methods.common import load_model, save_model
FORECAST_ID = 22
FEATURES = ["temp_c", "hour_cos", "load_24h_ago", "load_7d_ago", "energy_24h_rolling", "weekday"]
def _feature_frame(df):
out = df.copy()
if "temp_c" not in out.columns:
out["temp_c"] = 15.0
out["temp_c"] = pd.to_numeric(out["temp_c"], errors="coerce").ffill().bfill().fillna(15.0)
out["hour_float"] = out.index.hour + out.index.minute / 60.0
out["hour_cos"] = np.cos(2 * np.pi * out["hour_float"] / 24.0)
out["weekday"] = out.index.weekday
if "Hausverbrauch" in out.columns:
out["load_24h_ago"] = out["Hausverbrauch"].shift(288)
out["load_7d_ago"] = out["Hausverbrauch"].shift(2016)
out["energy_5m_kwh"] = out["Hausverbrauch"] * (5 / 60) / 1000
out["energy_24h_rolling"] = out["energy_5m_kwh"].shift(1).rolling(window=288).sum()
out = out.drop(columns=["energy_5m_kwh"])
return out
def _history_value(history, recent, reference, ts):
for frame in (history, recent, reference):
if frame is not None and not frame.empty and ts in frame.index and "Hausverbrauch" in frame.columns:
value = frame.at[ts, "Hausverbrauch"]
if pd.notna(value):
return float(value)
return None
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
df = _feature_frame(data_obj.get("df_load_training", data_obj["df_hist"]).copy())
if "Hausverbrauch" not in df.columns:
return {"trained": False, "reason": "Hausverbrauch fehlt"}
df = df.dropna(subset=FEATURES + ["Hausverbrauch"])
if len(df) < 288:
return {"trained": False, "reason": "zu wenig Daten", "samples": int(len(df))}
last_day = df.index.max().normalize()
train_df = df[df.index < last_day]
test_df = df[(df.index >= last_day) & (df.index < last_day + datetime.timedelta(days=1))]
score = None
importance = {}
if len(train_df) >= 288 and len(test_df) >= 12:
eval_model = HistGradientBoostingRegressor(max_iter=2500, max_depth=25, learning_rate=0.01, min_samples_leaf=1, random_state=42)
eval_model.fit(train_df[FEATURES], train_df["Hausverbrauch"])
score = float(eval_model.score(test_df[FEATURES], test_df["Hausverbrauch"]))
print(f"[var_22] R2 Hausverbrauch letzter kompletter Tag: {score:.3f}")
try:
perm = permutation_importance(eval_model, test_df[FEATURES], test_df["Hausverbrauch"], n_repeats=10, random_state=42)
order = perm.importances_mean.argsort()[::-1]
importance = {FEATURES[i]: float(perm.importances_mean[i]) for i in order}
print("[var_22] Feature-Wichtigkeit Hausverbrauch:")
for name, val in importance.items():
print(f" {name}: {val:.4f}")
except Exception as exc:
print(f"[var_22] permutation_importance nicht berechnet: {exc}")
model = HistGradientBoostingRegressor(max_iter=2500, max_depth=25, learning_rate=0.01, min_samples_leaf=1, random_state=42)
model.fit(df[FEATURES], df["Hausverbrauch"])
path = save_model(aid, FORECAST_ID, {"model": model, "features": FEATURES, "r2_last_day": score, "importance": importance})
return {"trained": True, "samples": int(len(df)), "features": FEATURES, "r2_last_day": score, "path": path}
def predict(data_obj):
aid = data_obj["config"]["anlagen_id"]
artifact = load_model(aid, FORECAST_ID)
model = artifact["model"] if artifact and "model" in artifact else None
try:
score = float(artifact.get("r2_last_day")) if artifact and artifact.get("r2_last_day") is not None else 0.0
except (TypeError, ValueError):
score = 0.0
model_weight = min(0.35, max(0.0, score) * 0.35) if np.isfinite(score) else 0.0
hist = data_obj["df_hist"].copy()
recent = data_obj.get("df_recent_raw", pd.DataFrame())
reference = data_obj.get("df_load_training", pd.DataFrame())
fut = data_obj["df_fut"]
res = {}
for t in fut.index:
t_24 = t - datetime.timedelta(days=1)
t_7d = t - datetime.timedelta(days=7)
load_7d = _history_value(hist, recent, reference, t_7d)
load_24 = _history_value(hist, recent, reference, t_24)
if load_24 is None:
load_24 = load_7d if load_7d is not None else 0.0
if load_7d is None:
load_7d = load_24
ref_end = t - datetime.timedelta(days=7)
ref_start = ref_end - datetime.timedelta(days=1)
if not reference.empty and "Hausverbrauch" in reference.columns:
window = reference.loc[ref_start:ref_end - datetime.timedelta(minutes=5), "Hausverbrauch"]
else:
window = pd.Series(dtype=float)
if window.empty and "Hausverbrauch" in recent.columns:
window = recent.loc[t - datetime.timedelta(days=1):t - datetime.timedelta(minutes=5), "Hausverbrauch"]
roll_energy = float((window.sum() * 5 / 60) / 1000.0) if not window.empty else 0.0
row = pd.DataFrame([[
float(fut.at[t, "temp_c"]),
float(fut.at[t, "hour_cos"]),
load_24,
load_7d,
roll_energy,
int(fut.at[t, "weekday"]),
]], columns=FEATURES)
profile = max(0.0, (0.65 * load_24) + (0.35 * load_7d))
pred = profile
if model is not None and model_weight > 0.0:
model_pred = max(0.0, float(model.predict(row)[0]))
pred = ((1.0 - model_weight) * profile) + (model_weight * model_pred)
res[t] = pred
hist.loc[t, "Hausverbrauch"] = pred
return res
@@ -0,0 +1,13 @@
from methods.battery_optimizer import optimize_grid_setpoint, train_artifact
from methods.common import save_model
FORECAST_ID = 23
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, FORECAST_ID, train_artifact("var_21_22"))}
def predict(data_obj, pv_dict, load_dict):
return optimize_grid_setpoint(data_obj, pv_dict, load_dict, forecast_id=23)
@@ -0,0 +1,13 @@
from methods.battery_optimizer import optimize_grid_setpoint, train_artifact
from methods.common import save_model
FORECAST_ID = 3
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, FORECAST_ID, train_artifact("var_1_2"))}
def predict(data_obj, pv_dict, load_dict):
return optimize_grid_setpoint(data_obj, pv_dict, load_dict, forecast_id=3)
@@ -0,0 +1,45 @@
"""Forecast family fault isolation; no IO, fabricated samples or model switching."""
from collections.abc import Mapping
from math import isfinite
from numbers import Real
def collect_predictions(data, config, models, enabled):
"""Return complete finite PV/load series independently for each requested model.
A model with insufficient history must not prevent other valid families being
published. It is omitted, not filled with zeros or replaced by another model.
Global raw-telemetry checks still run BEFORE this helper in run_forecast.
"""
expected = tuple(data['df_fut'].index)
if not expected or len(set(expected)) != len(expected):
raise ValueError('Nonempty unique future interval index required')
expected_keys = set(expected)
predictions, errors = {}, {}
requested = 0
for variant, model in models.items():
predictions[variant] = {}
if not enabled(config, variant):
continue
requested += 1
try:
values = model.predict(data)
if not isinstance(values, Mapping) or set(values) != expected_keys:
raise ValueError('Missing, extra or non-matching forecast timestamps')
cleaned = {}
for at in expected:
value = values[at]
if isinstance(value, bool) or not isinstance(value, Real):
raise ValueError('Non-numeric forecast power')
value = float(value)
if not isfinite(value) or value < 0:
raise ValueError('Invalid nonnegative forecast power')
cleaned[at] = value
predictions[variant] = cleaned
except Exception as error:
# Expected data failures and unexpected model failures are visible,
# but model exception text may contain filesystem paths. Do not leak it.
errors[variant] = {'status': 'unavailable', 'errorType': type(error).__name__}
if requested and all(not p for p in predictions.values()):
raise ValueError('All requested PV/load models failed; no forecasts published')
return predictions, errors
@@ -0,0 +1,108 @@
"""Stdlib-only, explicit opt-in publisher for the existing ENELIX services.
No credentials are printed. Existing V1 computation survives shadow-service errors.
Legacy UTC-naive forecast indices are explicitly interpreted as UTC here.
"""
from datetime import datetime,timezone
from hashlib import sha256
from uuid import uuid4
from urllib.parse import urlparse
from urllib.request import Request,urlopen
import json,logging,math,os
def timestamp(value):
if hasattr(value,'to_pydatetime'):value=value.to_pydatetime()
if isinstance(value,str):value=datetime.fromisoformat(value.replace('Z','+00:00'))
if value.tzinfo is None:value=value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat()
def tariff_id(label):
if not isinstance(label,str) or not label.strip():raise ValueError('Exact tariff label required')
return 'legacy:'+sha256(label.encode()).hexdigest()[:32]
def envelope(at=None):return {'version':1,'eventId':str(uuid4()),'observedAt':timestamp(at or datetime.now(timezone.utc))}
def tariff_payload(config,at=None):
result=envelope(at)
for side,prefix in (('import','tarif_bezug'),('export','tarif_einspeisung')):
mode={'statisch':'static','dynamisch':'dynamic','static':'static','dynamic':'dynamic'}.get(config.get(prefix+'_modus'))
if mode is None:raise ValueError('Explicit tariff mode required; not inferred from name')
item={'mode':mode,'tariffId':tariff_id(config[prefix])}
if mode=='static':
value=config.get(prefix+'_fest')
if value is None or isinstance(value,bool) or not math.isfinite(float(value)):raise ValueError('Static price missing/invalid')
item['staticChfKwh']=float(value)
result[side]=item
peak=config.get('tarif_peak_fest')
if peak is None or isinstance(peak,bool) or not math.isfinite(float(peak)) or float(peak)<0:raise ValueError('Explicit peak price required')
result['peakChfKwMonth']=float(peak)
return result
def forecast_payload(forecasts,at=None,load_basis='house_total',trained_until=None):
result=envelope(at);result['families']={}
for key,pv,load in forecasts:
if not pv or not load:continue
if set(pv)!=set(load):raise ValueError('PV/load timestamps differ')
result['families'][str(key)]={'loadBasis':load_basis,'trainedUntil':trained_until,
'points':[{'time':timestamp(t),'pvW':float(pv[t]),'loadW':float(load[t])} for t in sorted(pv)]}
if not result['families']:raise ValueError('No forecast families supplied')
return result
def ckw_payload(label,rows,publication_timestamp=None,at=None):
"""Provider-delimited integrated price intervals only. No scalar price replication."""
result=envelope(at);periods=[]
units={'CHF_kWh':'CHF/kWh','CHF/kWh':'CHF/kWh','Rp/kWh':'Rp/kWh','CHF/MWh':'CHF/MWh'}
for row in rows:
if not row.get('start_timestamp') or not row.get('end_timestamp'):raise ValueError('Explicit delivery start/end required')
integrated=row.get('integrated')
if isinstance(integrated,list):
if len(integrated)!=1:raise ValueError('Ambiguous integrated price components')
integrated=integrated[0]
if not isinstance(integrated,dict) or integrated.get('unit') not in units:raise ValueError('Provider-declared unit required')
value=integrated.get('value')
if value is None or isinstance(value,bool) or not math.isfinite(float(value)):raise ValueError('Finite integrated price required')
item={'tariffId':tariff_id(label),'side':'import','start':timestamp(row['start_timestamp']),
'end':timestamp(row['end_timestamp']),'value':float(value),'unit':units[integrated['unit']],
'observedAt':result['observedAt'],'sourceKind':'published_interval'}
if publication_timestamp:
published=timestamp(publication_timestamp)
if datetime.fromisoformat(published)>datetime.fromisoformat(result['observedAt']):raise ValueError('Future publication')
item['publishedAt']=published
periods.append(item)
result['periods']=periods
return result
def enabled(plant):return bool(os.getenv('NETPLAN_V4_URL') and plant in {p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()})
def send(plant,kind,payload):
if not enabled(plant):return {'status':'disabled'}
token=os.getenv('PROGNOSIS_SERVICE_TOKEN','');base=os.environ['NETPLAN_V4_URL'].rstrip('/');url=urlparse(base)
if not token or url.scheme not in ('http','https') or url.username or url.password:raise ValueError('Private V4 transport not configured')
UUID=__import__('uuid').UUID;UUID(plant)
if kind not in ('forecast','tariffs','prices'):raise ValueError('Unsupported publisher input kind')
req=Request(base+'/internal/v2/prognosis/'+plant+'/planner/inputs/'+kind,
json.dumps(payload,allow_nan=False).encode(),{'Content-Type':'application/json','X-Enelix-Service-Token':token},method='POST')
with urlopen(req,timeout=5) as response:
data=response.read(65537)
if len(data)>65536:raise ValueError('Oversized service response')
return json.loads(data)
def publish_forecasts(config,forecasts):
plant=config['anlagen_id']
if not enabled(plant):return
try:
send(plant,'tariffs',tariff_payload(config));send(plant,'forecast',forecast_payload(forecasts))
except Exception as exc:logging.getLogger(__name__).warning('V4 shadow forecast for %s: %s',plant,type(exc).__name__)
def publish_tariffs(plant,config):
if not enabled(plant):return
try:send(plant,'tariffs',tariff_payload(config))
except Exception as exc:logging.getLogger(__name__).warning('V4 shadow tariffs for %s: %s',plant,type(exc).__name__)
def publish_ckw(label,rows,publication_timestamp=None,tariff_type='integrated'):
plants=[p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()]
if not os.getenv('NETPLAN_V4_URL') or not plants:return
try:
if tariff_type!='integrated':raise ValueError('Full integrated tariff required')
payload=ckw_payload(label,rows,publication_timestamp)
for plant in plants:send(plant,'prices',payload)
except Exception as exc:logging.getLogger(__name__).warning('V4 CKW price provenance: %s',type(exc).__name__)
@@ -0,0 +1,11 @@
schedule
pandas
numpy
influxdb-client
pytz
holidays
pvlib
geopy
scikit-learn
scipy
joblib
@@ -0,0 +1,93 @@
import math
def as_float(value, default=0.0):
try:
if value is None:
return default
return float(value)
except Exception:
return default
def roof_kwp(roof):
return as_float(roof.get("kwp", roof.get("leistung", roof.get("pv_kwp", 0.0))), 0.0)
def roof_azimuth_deg(roof):
return as_float(roof.get("azimut", roof.get("azimuth", roof.get("ausrichtung", 180.0))), 180.0)
def roof_tilt_deg(roof):
return as_float(roof.get("neigung", roof.get("tilt", 30.0)), 30.0)
def roof_features(config):
roofs = config.get("daecher", []) or []
if not roofs:
roofs = [{"kwp": as_float(config.get("ac_leistung", 0.0), 0.0), "azimut": 180.0, "neigung": 30.0}]
total_kwp = sum(roof_kwp(r) for r in roofs)
if total_kwp <= 0:
total_kwp = as_float(config.get("ac_leistung", 0.0), 0.0)
weighted_az = 0.0
weighted_tilt = 0.0
south_factor = 0.0
for roof in roofs:
w = roof_kwp(roof) / total_kwp if total_kwp > 0 else 0.0
az = roof_azimuth_deg(roof)
tilt = roof_tilt_deg(roof)
weighted_az += w * az
weighted_tilt += w * tilt
south_factor += w * max(0.0, math.cos(math.radians(az - 180.0)))
return {
"pv_kwp_total": total_kwp,
"roof_azimuth_sin": math.sin(math.radians(weighted_az)),
"roof_azimuth_cos": math.cos(math.radians(weighted_az)),
"roof_tilt_avg": weighted_tilt,
"roof_south_factor": south_factor,
}
def calc_pure_math_pv(config, dt):
ac_limit = as_float(config.get("ac_leistung", 10.0), 10.0) * 1000.0
roofs = config.get("daecher", []) or []
if not roofs:
roofs = [{"kwp": as_float(config.get("ac_leistung", 0.0), 0.0), "neigung": 30.0, "azimut": 180.0}]
day = dt.timetuple().tm_yday
hour = dt.hour + dt.minute / 60.0
lat = math.radians(as_float(config.get("latitude", 47.0), 47.0))
decl = math.radians(23.45 * math.sin(math.radians(360.0 * (day - 81) / 365.0)))
hour_angle = math.radians(15.0 * (hour - 12.0))
sin_alt = math.sin(lat) * math.sin(decl) + math.cos(lat) * math.cos(decl) * math.cos(hour_angle)
sun_alt = math.asin(max(-1.0, min(1.0, sin_alt)))
if sun_alt <= 0.0:
return 0.0
sun_az = math.atan2(
math.sin(hour_angle),
math.cos(hour_angle) * math.sin(lat) - math.tan(decl) * math.cos(lat),
)
total = 0.0
for roof in roofs:
kwp = roof_kwp(roof)
if kwp <= 0:
continue
tilt = math.radians(roof_tilt_deg(roof))
az = math.radians(roof_azimuth_deg(roof))
cos_inc = math.sin(sun_alt) * math.cos(tilt) + math.cos(sun_alt) * math.sin(tilt) * math.cos(sun_az - az)
if cos_inc > 0:
total += kwp * 1000.0 * cos_inc
return max(0.0, min(total, ac_limit))
def calc_pure_math_load(dt):
hour = dt.hour + dt.minute / 60.0
base = 650.0
morning = 220.0 * math.exp(-((hour - 7.0) ** 2) / 5.0)
evening = 380.0 * math.exp(-((hour - 19.0) ** 2) / 8.0)
weekend = 1.12 if dt.weekday() >= 5 else 1.0
return max(0.0, (base + morning + evening) * weekend)
@@ -0,0 +1,66 @@
from influxdb_client import Point, WritePrecision
DT_H = 5.0 / 60.0
def _f(config, key, default):
try:
return float(config.get(key, default) or default)
except Exception:
return float(default)
def battery_soc_points(data_obj, forecast_var, pv_dict, load_dict, grid_dict):
config = data_obj["config"]
aid = str(config["anlagen_id"])
cap_kwh = _f(config, "batt_capacity_kwh", 0.0)
if cap_kwh <= 0 or not grid_dict:
return []
min_soc = _f(config, "batt_min_soc", _f(config, "batt_min_soc_percent", 0.0))
max_soc = _f(config, "batt_max_soc", _f(config, "batt_max_soc_percent", 100.0))
charge_eff = max(0.01, min(1.0, _f(config, "batt_charge_efficiency", 0.95)))
discharge_eff = max(0.01, min(1.0, _f(config, "batt_discharge_efficiency", 0.95)))
start_soc_value = data_obj.get("current_soc_perc")
if start_soc_value is None or start_soc_value == "":
start_soc_value = config.get("batt_soc_percent", 50.0)
start_soc = float(start_soc_value)
start_soc = max(min_soc, min(max_soc, start_soc))
min_kwh = cap_kwh * min_soc / 100.0
max_kwh = cap_kwh * max_soc / 100.0
soc_kwh = max(min_kwh, min(max_kwh, cap_kwh * start_soc / 100.0))
planned_battery = (
data_obj.get("battery_plans", {})
.get(int(forecast_var), {})
.get("battery", {})
)
points = []
for t in data_obj["df_fut"].index:
if t not in grid_dict:
continue
if t in planned_battery:
battery_target_w = float(planned_battery[t]) # positiv = laden
else:
residual_w = float(load_dict.get(t, 0.0)) - float(pv_dict.get(t, 0.0))
grid_w = float(grid_dict.get(t, 0.0))
battery_target_w = grid_w - residual_w
if battery_target_w > 0:
soc_kwh += battery_target_w * DT_H / 1000.0 * charge_eff
elif battery_target_w < 0:
soc_kwh += battery_target_w * DT_H / 1000.0 / discharge_eff
soc_kwh = max(min_kwh, min(max_kwh, soc_kwh))
soc_percent = max(0.0, min(100.0, soc_kwh / cap_kwh * 100.0))
points.append(
Point("forecast_diagnostics")
.tag("anlagen_id", aid)
.tag("data_type", "battery_soc_simulation")
.tag("forecast_var", f"prog_var_{forecast_var}")
.field("soc_percent", float(soc_percent))
.time(t.to_pydatetime() if hasattr(t, "to_pydatetime") else t, WritePrecision.S)
)
return points
@@ -0,0 +1,106 @@
"""Measured telemetry integrity and repeat-profile helpers.
No IO, no fabricated measurements and no fixed household-load fallback.
Forecast freshness is checked against raw telemetry, never against filled features.
"""
from __future__ import annotations
import datetime
import math
import numpy as np
import pandas as pd
MEASURED_COLUMNS = ('PV', 'Hausverbrauch', 'Netzleistung', 'SOC')
class TelemetryUnavailable(ValueError):
"""No publishable forecast can be derived from the supplied observations."""
def sanitize_measured_frame(frame):
out = frame.copy()
for column in MEASURED_COLUMNS:
if column not in out:
continue
raw = out[column]
numeric = pd.to_numeric(raw, errors='coerce').astype(float)
bad = ~np.isfinite(numeric) | raw.map(lambda x: isinstance(x, (bool, np.bool_)))
if column in ('PV', 'Hausverbrauch', 'SOC'):
bad |= numeric < 0
if column == 'SOC':
bad |= numeric > 100
out[column] = numeric.mask(bad)
return out
def _naive_utc(value):
stamp = pd.Timestamp(value)
if stamp.tzinfo is not None:
stamp = stamp.tz_convert('UTC').tz_localize(None)
return stamp
def require_recent_telemetry(data_obj, fields=('PV','Hausverbrauch'), max_age_minutes=30.0):
"""Refuse missing/stale inputs without changing real zeros or raw samples."""
if isinstance(max_age_minutes, bool) or not math.isfinite(max_age_minutes) or max_age_minutes <= 0:
raise ValueError('Positive telemetry age limit required')
now = _naive_utc(data_obj['now'])
raw = data_obj.get('df_recent_raw')
raw = sanitize_measured_frame(raw) if raw is not None else pd.DataFrame()
if not raw.empty:
raw.index = pd.DatetimeIndex([_naive_utc(t) for t in raw.index])
raw = raw.loc[raw.index < now].sort_index()
report = {}
errors = []
for field in fields:
if field not in MEASURED_COLUMNS:
raise ValueError('Unknown telemetry target')
values = raw[field].dropna() if field in raw else pd.Series(dtype=float)
if values.empty:
errors.append(field + ': keine gemessenen Werte')
continue
stamp = values.index[-1]
age = (now-stamp).total_seconds()/60.0
report[field] = {'lastObservedInterval': stamp.isoformat()+'Z', 'ageMinutes': age,
'observedIntervals': int(len(values)), 'lastValue': float(values.iloc[-1])}
if age > max_age_minutes:
errors.append(field + ': Messdaten veraltet (' + format(age,'.1f') + ' min)')
if errors:
raise TelemetryUnavailable('; '.join(errors) + '. Keine neuen Prognosen/Fahrplaene veroeffentlicht.')
return report
def _finite_nonnegative(value):
if isinstance(value, (bool, np.bool_)):
return None
try:
value = float(value)
except (ValueError, TypeError):
return None
return value if math.isfinite(value) and value >= 0.0 else None
def profile_source_value(history, at, column, predictions=None):
"""Repeat yesterday; prefer same weekday if yesterday is missing, then older days.
Explicitly generated first-day values may be repeated on the second forecast day.
Missing historical values are not zeros. No future measurement is read.
"""
if column not in ('PV','Hausverbrauch'):
raise ValueError('Unsupported repeat-profile target')
predictions = {} if predictions is None else predictions
at = pd.Timestamp(at)
for day in (1, 7, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14):
source = at - datetime.timedelta(days=day)
if source in predictions:
value = _finite_nonnegative(predictions[source])
if value is not None:
return value
if history is not None and column in history and source in history.index:
value = _finite_nonnegative(history.at[source,column])
if value is not None:
return value
raise TelemetryUnavailable(column + ': kein gemessener Tagesprofilwert fuer ' + str(at))
def repeat_daily_profile(history, future_index, column):
result = {}
for at in future_index:
result[at] = profile_source_value(history, at, column, result)
return result
@@ -0,0 +1,133 @@
import unittest
import pandas as pd
from methods.battery_optimizer import DT_H, optimize_battery_plan
class BatteryCostOptimizerTest(unittest.TestCase):
def plan(self, load, pv, import_prices=None, export_prices=None, **overrides):
index = pd.date_range("2026-09-29T00:00:00", periods=len(load), freq="5min")
config = {
"batt_capacity_kwh": 2.0,
"batt_power_kw": 1.0,
"batt_min_soc": 0.0,
"batt_max_soc": 100.0,
"batt_charge_efficiency": 1.0,
"batt_discharge_efficiency": 1.0,
"batt_soc_percent": 0.0,
"batt_grid_charging_enabled": False,
"batt_degradation_chf_kwh": 0.0,
"tarif_bezug": "dynamic",
"tarif_einspeisung": "dynamic",
"tarif_peak_fest": 0.0,
}
config.update(overrides)
data = {
"config": config,
"df_fut": pd.DataFrame({
"import_price": import_prices or [0.30] * len(index),
"export_price": export_prices or [0.10] * len(index),
}, index=index),
"current_soc_perc": config["batt_soc_percent"],
"current_month_peak_kw": overrides.get("current_month_peak_kw", 0.0),
}
return index, optimize_battery_plan(data, dict(zip(index, pv)), dict(zip(index, load)))
def test_grid_charging_is_opt_in(self):
index, plan = self.plan(
[500.0] * 4,
[0.0] * 4,
import_prices=[0.05, 0.05, 0.50, 0.50],
)
self.assertTrue(all(plan["battery"][t] <= 1e-6 for t in index))
def test_cheap_grid_energy_is_shifted_to_expensive_period(self):
index, plan = self.plan(
[500.0] * 4,
[0.0] * 4,
import_prices=[0.05, 0.05, 0.50, 0.50],
batt_grid_charging_enabled=True,
)
self.assertGreater(plan["battery"][index[0]], 0.0)
self.assertLess(plan["battery"][index[-1]], 0.0)
self.assertGreater(plan["grid"][index[0]], 500.0)
self.assertAlmostEqual(plan["grid"][index[-1]], 0.0, places=5)
def test_high_feed_in_value_prefers_export(self):
index, plan = self.plan(
[0.0, 1000.0],
[1000.0, 0.0],
import_prices=[0.20, 0.20],
export_prices=[0.60, 0.60],
)
self.assertAlmostEqual(plan["grid"][index[0]], -1000.0, places=5)
self.assertAlmostEqual(plan["grid"][index[1]], 1000.0, places=5)
def test_peak_tariff_prevents_grid_charge_above_existing_peak(self):
index, plan = self.plan(
[1000.0] * 6,
[0.0] * 6,
import_prices=[0.05] * 3 + [0.50] * 3,
batt_grid_charging_enabled=True,
tarif_peak_fest=20.0,
current_month_peak_kw=1.0,
)
self.assertLessEqual(plan["planned_peak_kw"], 1.0 + 1e-7)
self.assertTrue(all(plan["grid"][t] <= 1000.0 + 1e-5 for t in index))
def test_nearly_empty_battery_is_not_discharged_further_at_low_value(self):
index, plan = self.plan(
[1000.0] * 4,
[0.0] * 4,
import_prices=[0.01] * 4,
export_prices=[0.0] * 4,
batt_soc_percent=5.0,
batt_economic_reserve_soc_percent=10.0,
batt_terminal_value_chf_kwh=0.0,
)
self.assertTrue(all(plan["battery"][timestamp] >= -1e-6 for timestamp in index))
self.assertTrue(all(abs(plan["grid"][timestamp] - 1000.0) <= 1e-5 for timestamp in index))
self.assertAlmostEqual(plan["economic_min_soc_percent"], 5.0)
def test_profitable_export_discharge_remains_allowed_above_reserve(self):
index, plan = self.plan(
[100.0, 100.0],
[1000.0, 1000.0],
import_prices=[0.20, 0.20],
export_prices=[0.80, 0.80],
batt_soc_percent=100.0,
batt_economic_reserve_soc_percent=10.0,
batt_degradation_chf_kwh=0.03,
batt_terminal_value_chf_kwh=0.0,
)
self.assertTrue(any(plan["battery"][timestamp] < -1e-6 for timestamp in index))
self.assertTrue(all(plan["grid"][timestamp] <= -899.0 for timestamp in index))
self.assertAlmostEqual(plan["economic_min_soc_percent"], 10.0)
def test_power_and_soc_limits_hold(self):
load = [0.0] * 12 + [1000.0] * 12
pv = [1000.0] * 12 + [0.0] * 12
index, plan = self.plan(
load,
pv,
batt_capacity_kwh=1.0,
batt_power_kw=0.5,
batt_min_soc=20.0,
batt_max_soc=80.0,
batt_soc_percent=20.0,
)
soc = 0.2
for timestamp in index:
target = plan["battery"][timestamp]
self.assertLessEqual(abs(target), 500.0 + 1e-6)
if target >= 0.0:
soc += target * DT_H / 1000.0
else:
soc += target * DT_H / 1000.0
self.assertGreaterEqual(soc, 0.2 - 1e-8)
self.assertLessEqual(soc, 0.8 + 1e-8)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,66 @@
import unittest
from unittest.mock import patch
import numpy as np
import pandas as pd
from methods.var_2 import predict
from methods.var_11 import predict as predict_repeat
class LoadProfileForecastTest(unittest.TestCase):
def test_profile_keeps_daily_shape_across_48_hours(self):
history_index = pd.date_range("2026-09-21", periods=8 * 288, freq="5min")
phase = 2 * np.pi * (
history_index.hour.to_numpy() + history_index.minute.to_numpy() / 60.0
) / 24.0
history = pd.DataFrame(
{"Hausverbrauch": 1800.0 + 900.0 * np.cos(phase - np.pi)},
index=history_index,
)
future_index = pd.date_range(history_index[-1] + pd.Timedelta(minutes=5), periods=2 * 288, freq="5min")
future_phase = 2 * np.pi * (
future_index.hour.to_numpy() + future_index.minute.to_numpy() / 60.0
) / 24.0
future = pd.DataFrame(
{
"temp_c": 15.0,
"cloud": 20.0,
"hour_sin": np.sin(future_phase),
"hour_cos": np.cos(future_phase),
"weekday": future_index.weekday,
"is_weekday": (future_index.weekday < 5).astype(int),
},
index=future_index,
)
data = {
"config": {"anlagen_id": "test"},
"df_hist": history,
"df_load_training": history,
"df_recent_raw": history.iloc[-2 * 288 :],
"df_fut": future,
}
with patch("methods.var_2.load_model", return_value=None):
values = np.array(list(predict(data).values()))
self.assertGreater(float(values.max() - values.min()), 1200.0)
np.testing.assert_allclose(values[:288], values[288:], rtol=0.0, atol=1e-6)
def test_repeat_profile_remains_available_on_second_day(self):
history_index = pd.date_range("2026-09-29", periods=288, freq="5min")
daily_values = np.arange(288, dtype=float) + 1000.0
history = pd.DataFrame({"Hausverbrauch": daily_values}, index=history_index)
future_index = pd.date_range(history_index[-1] + pd.Timedelta(minutes=5), periods=576, freq="5min")
values = np.array(list(predict_repeat({
"df_hist": history,
"df_fut": pd.DataFrame(index=future_index),
}).values()))
np.testing.assert_allclose(values[:288], daily_values)
np.testing.assert_allclose(values[288:], daily_values)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,71 @@
import ast
import contextlib
import datetime
import io
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
from model_isolation import collect_predictions
class ModelIsolationTest(unittest.TestCase):
def setUp(self):
self.index = [datetime.datetime(2026,10,2,6,0) + datetime.timedelta(minutes=5*i) for i in range(3)]
self.data = {'df_fut': SimpleNamespace(index=self.index)}
self.good = {t: 1000.0 for t in self.index}
def model(self, values=None):
return SimpleNamespace(predict=Mock(return_value=self.good if values is None else values))
def call(self, models, enabled=lambda c,n:True):
return collect_predictions(self.data, {}, models, enabled)
def test_one_failed_model_does_not_remove_other_families(self):
bad=self.model();bad.predict.side_effect=ValueError('private path not logged')
forecasts,errors=self.call({1:self.model(),10:bad,21:self.model()})
self.assertEqual(forecasts[1],self.good);self.assertEqual(forecasts[21],self.good)
self.assertEqual(forecasts[10],{});self.assertEqual(errors[10]['errorType'],'ValueError')
self.assertNotIn('private',str(errors))
def test_missing_timestamp_does_not_get_filled(self):
forecasts,errors=self.call({1:self.model(),10:self.model({self.index[0]:20.0})})
self.assertFalse(forecasts[10]);self.assertIn(10,errors)
def test_nonfinite_negative_and_boolean_rejected(self):
for value in (float('nan'),float('inf'),-1.0,True,'2'):
with self.subTest(value=value):
result,errors=self.call({1:self.model(),10:self.model({t:value for t in self.index})})
self.assertFalse(result[10]);self.assertIn(10,errors)
def test_explicit_zero_forecast_is_not_imputed(self):
result,errors=self.call({10:self.model({t:0.0 for t in self.index})})
self.assertEqual(errors,{});self.assertEqual(sum(result[10].values()),0.)
def test_all_requested_models_fail_closed(self):
with self.assertRaisesRegex(ValueError,'All requested'):
self.call({10:self.model({})})
def test_disabled_models_not_called(self):
model=self.model();result,errors=self.call({10:model},lambda c,n:False)
model.predict.assert_not_called();self.assertEqual(result,{10:{}});self.assertEqual(errors,{})
def test_input_time_order_preserved(self):
result,_=self.call({1:self.model(dict(reversed(list(self.good.items()))))})
self.assertEqual(list(result[1]),self.index)
def test_run_forecast_publishes_valid_pairs_after_repeat_failure(self):
root=Path(__file__).resolve().parents[1]
tree=ast.parse((root/'main.py').read_text())
fn=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='run_forecast')
modules={n:self.model() for n in (1,2,3,10,11,13,21,22,23)}
modules[10].predict.side_effect=ValueError('profile gap')
client=Mock();published=Mock()
data={**self.data,'current_soc_perc':20.,'current_soc_source':'telemetry'}
ns={'datetime':datetime,'LOCAL_TZ':datetime.timezone.utc,'traceback':Mock(),
'get_configs':lambda:[{'anlagen_id':'test','batt_capacity_kwh':10.}],
'InfluxDBClient':Mock(return_value=client),'SYNCHRONOUS':object(),
'INFLUX_URL':'offline','INFLUX_TOKEN':'synthetic','INFLUX_ORG':'offline',
'INFLUX_BUCKET':'offline','INFLUX_TIMEOUT_MS':1,
'build_data_object':lambda *a,**k:data,'active':lambda c,n:True,
'require_recent_telemetry':lambda *a,**k:{},'collect_predictions':collect_predictions,
'_v4_publish_forecasts':published,'battery_soc_points':lambda *a:[],
'_forecast_point':lambda *a:a,'_snapshot_point':lambda *a:a,
'write_quality_metrics':Mock(),**{'v'+str(n):m for n,m in modules.items()}}
exec(compile(ast.Module(body=[fn],type_ignores=[]),'source-run-forecast','exec'),ns)
with contextlib.redirect_stdout(io.StringIO()):r=ns['run_forecast']()
self.assertEqual(r['completed'],['test'])
families=published.call_args.args[1]
self.assertTrue(families[0][1] and families[0][2]);self.assertFalse(families[1][1])
self.assertTrue(families[2][1] and families[2][2]);modules[13].predict.assert_not_called()
client.write_api.return_value.write.assert_called_once()
@@ -0,0 +1,213 @@
"""Offline regressions against the exact reviewed orchestration source.
Only selected function definitions are compiled; main is NOT imported, and there
is no database, network, model-file, prediction-publication or device access.
"""
import ast
import contextlib
import datetime
import io
from pathlib import Path
import unittest
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
from telemetry_quality import (TelemetryUnavailable, require_recent_telemetry,
sanitize_measured_frame, repeat_daily_profile, profile_source_value)
ROOT = Path(__file__).resolve().parents[1]
AT = pd.Timestamp('2026-10-01T20:00:00')
def functions(*names, **extra):
tree = ast.parse((ROOT/'main.py').read_text())
nodes = [n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name in names]
if {n.name for n in nodes} != set(names):
raise AssertionError('Reviewed function missing')
ns = {'pd':pd,'np':np,'datetime':datetime,
'sanitize_measured_frame':sanitize_measured_frame,
'require_recent_telemetry':require_recent_telemetry}
ns.update(extra)
exec(compile(ast.Module(body=nodes,type_ignores=[]),str(ROOT/'main.py'),'exec'),ns)
return ns
def raw_frame(start=None, periods=288):
index=pd.date_range(start if start is not None else AT-pd.Timedelta(days=1),periods=periods,freq='5min')
return pd.DataFrame({'PV':200.,'Hausverbrauch':3200.,'SOC':25.,'Netzleistung':3000.},index=index)
class MeasuredTelemetryTest(unittest.TestCase):
def test_empty_history_is_not_zero_consumption(self):
ns=functions('_fill_defaults','_add_time_features')
frame=ns['_fill_defaults'](pd.DataFrame(index=pd.date_range(AT,periods=3,freq='5min')),True)
self.assertTrue(frame[['PV','Hausverbrauch','SOC','Netzleistung']].isna().all().all())
self.assertEqual(frame['temp_c'].tolist(),[15.]*3)
def test_recorded_zero_is_preserved(self):
frame=raw_frame(periods=3);frame[:]=0.
ns=functions('_fill_defaults','_add_time_features')
out=ns['_fill_defaults'](frame,True)
self.assertEqual(out['Hausverbrauch'].tolist(),[0.]*3)
self.assertEqual(out['PV'].tolist(),[0.]*3)
def test_internal_and_trailing_measurement_gaps_remain_missing(self):
frame=raw_frame(periods=7)
frame.loc[frame.index[[0,2,3,6]],['PV','Hausverbrauch','SOC','Netzleistung']]=np.nan
ns=functions('_fill_defaults','_add_time_features')
out=ns['_fill_defaults'](frame,True)
self.assertEqual(int(out['Hausverbrauch'].isna().sum()),4)
self.assertEqual(int(out['SOC'].isna().sum()),4)
self.assertEqual(int(out['Netzleistung'].isna().sum()),4)
def test_bad_values_not_real_measurements(self):
frame=pd.DataFrame({'Hausverbrauch':[np.inf,-1.,True,0.,250.], 'Netzleistung':[-100.,np.nan,0.,1.,2.], 'SOC':[101.,-1.,np.inf,0.,100.]})
out=sanitize_measured_frame(frame)
self.assertTrue(out['Hausverbrauch'].iloc[:3].isna().all())
self.assertEqual(out['Hausverbrauch'].iloc[3],0.)
self.assertEqual(out['Netzleistung'].iloc[0],-100.)
self.assertTrue(out['SOC'].iloc[:3].isna().all())
def test_recent_recorded_zero_passes(self):
frame=raw_frame();frame[['PV','Hausverbrauch']]=0.
report=require_recent_telemetry({'now':AT,'df_recent_raw':frame})
self.assertEqual(report['Hausverbrauch']['ageMinutes'],5.)
def test_missing_raw_cannot_be_hidden_by_filled_feature_grid(self):
with self.assertRaises(TelemetryUnavailable):
require_recent_telemetry({'now':AT,'df_recent_raw':pd.DataFrame(),'df_hist':raw_frame()})
def test_stale_values_fail(self):
with self.assertRaises(TelemetryUnavailable):
require_recent_telemetry({'now':AT,'df_recent_raw':raw_frame(AT-pd.Timedelta(days=2))})
def test_freshness_is_checked_per_field(self):
frame=raw_frame();frame.loc[frame.index[-12:],'Hausverbrauch']=np.nan
with self.assertRaisesRegex(TelemetryUnavailable,'Hausverbrauch'):
require_recent_telemetry({'now':AT,'df_recent_raw':frame})
def test_future_measurements_do_not_rescue_freshness(self):
frame=raw_frame(AT,periods=3)
with self.assertRaises(TelemetryUnavailable):
require_recent_telemetry({'now':AT,'df_recent_raw':frame})
def test_aware_timestamps_normalized_to_utc(self):
frame=raw_frame();frame.index=frame.index.tz_localize('UTC').tz_convert('Europe/Zurich')
report=require_recent_telemetry({'now':AT.tz_localize('UTC'),'df_recent_raw':frame})
self.assertEqual(report['Hausverbrauch']['ageMinutes'],5.)
def test_soc_staleness_cannot_be_hidden(self):
frame=raw_frame();frame.loc[frame.index[-24:],'SOC']=np.nan
with self.assertRaisesRegex(TelemetryUnavailable,'SOC'):
require_recent_telemetry({'now':AT,'df_recent_raw':frame},['SOC'])
def test_only_exogenous_future_defaults_are_filled(self):
ns=functions('_fill_defaults','_add_time_features')
out=ns['_fill_defaults'](pd.DataFrame(index=pd.date_range(AT,periods=6,freq='5min')),False)
self.assertTrue(out[['PV','Hausverbrauch','SOC','Netzleistung']].isna().all().all())
def test_input_unchanged(self):
frame=raw_frame();before=frame.copy(deep=True)
sanitize_measured_frame(frame);require_recent_telemetry({'now':AT,'df_recent_raw':frame})
pd.testing.assert_frame_equal(frame,before)
class HistoryOrchestrationTest(unittest.TestCase):
def data(self,telemetry):
class FrozenDateTime(datetime.datetime):
@classmethod
def utcnow(cls):return AT.to_pydatetime()
import types
dates=types.SimpleNamespace(datetime=FrozenDateTime,timedelta=datetime.timedelta)
weather=pd.DataFrame({'temp_c':15.,'cloud':20.},index=pd.date_range(AT-pd.Timedelta(days=14),AT+pd.Timedelta(days=2),freq='5min'))
fetch=Mock(return_value=(telemetry,weather,pd.DataFrame()))
ns=functions('build_data_object','_fill_defaults','_add_time_features','_longest_consistent_segment',datetime=dates,fetch_influx_frames=fetch,FORECAST_HORIZON_HOURS=48)
return ns['build_data_object']({'anlagen_id':'offline'},False)
def test_weather_tail_does_not_erase_historical_load(self):
telemetry=raw_frame(AT-pd.Timedelta(days=2),periods=288)
data=self.data(telemetry)
self.assertEqual(data['df_hist']['Hausverbrauch'].count(),len(telemetry))
self.assertEqual(data['df_recent_raw']['Hausverbrauch'].count(),len(telemetry))
self.assertEqual(data['df_recent_raw'].index.max(),telemetry.index.max())
self.assertTrue(data['df_hist']['Hausverbrauch'].iloc[-24:].isna().all())
with self.assertRaises(TelemetryUnavailable):require_recent_telemetry(data)
def test_no_measurements_preserves_all_missing(self):
data=self.data(pd.DataFrame())
self.assertTrue(data['df_hist']['Hausverbrauch'].isna().all())
with self.assertRaises(TelemetryUnavailable):require_recent_telemetry(data)
def test_reconnected_short_tail_does_not_delete_past_profile(self):
old=raw_frame(AT-pd.Timedelta(days=2),periods=288)
new=raw_frame(AT-pd.Timedelta(minutes=10),periods=2)
data=self.data(pd.concat([old,new]))
self.assertEqual(data['df_hist']['Hausverbrauch'].count(),290)
self.assertEqual(require_recent_telemetry(data)['Hausverbrauch']['ageMinutes'],5.)
def test_stale_forecast_never_predicts_or_publishes(self):
import types
models={n:types.SimpleNamespace(predict=Mock()) for n in (1,2,3,10,11,13,21,22,23)}
client=Mock();write=Mock();client.write_api.return_value=write
publish=Mock();trace=Mock()
ns=functions('run_forecast',get_configs=lambda:[{'anlagen_id':'offline','batt_capacity_kwh':10}],
InfluxDBClient=Mock(return_value=client),SYNCHRONOUS=object(),
INFLUX_URL='offline',INFLUX_TOKEN='synthetic',INFLUX_ORG='offline',INFLUX_BUCKET='offline',INFLUX_TIMEOUT_MS=1,
build_data_object=lambda *a,**k:{'now':AT,'df_recent_raw':pd.DataFrame()},active=lambda *args:True,
_v4_publish_forecasts=publish,traceback=trace,**{'v'+str(k):v for k,v in models.items()})
with contextlib.redirect_stdout(io.StringIO()),self.assertRaises(RuntimeError):ns['run_forecast']()
for model in models.values():model.predict.assert_not_called()
publish.assert_not_called();write.write.assert_not_called();client.close.assert_called_once()
def test_stale_training_does_not_overwrite_model(self):
import types
train=Mock();mod=types.SimpleNamespace(train=train)
ns=functions('run_training',get_configs=lambda:[{'anlagen_id':'offline'}],
MODEL_MODULES={2:mod},QUALITY_TARGETS={2:'Hausverbrauch'},active=lambda *a:True,
build_data_object=lambda *a,**k:{'now':AT,'df_recent_raw':pd.DataFrame()},traceback=Mock())
with contextlib.redirect_stdout(io.StringIO()):ns['run_training']()
train.assert_not_called()
def test_queries_exclude_forecasts_and_align_interval_starts(self):
source=(ROOT/'main.py').read_text();ns=functions('fetch_influx_frames',
HISTORY_START='1970-01-01T00:00:00Z',FORECAST_HORIZON_HOURS=48,INFLUX_BUCKET='offline',
_query_df=Mock(return_value=pd.DataFrame()),_pivot_frame=lambda df,fields:df,
_tariff_frame=lambda df,cfg:df,_time_literal=lambda t:t.isoformat())
ns['fetch_influx_frames']({'anlagen_id':'offline'},False)
query=ns['_query_df'].call_args_list[0].args[0]
self.assertIn('timeSrc: "_start"',query)
self.assertIn('!= "forecast_snapshot"',query)
self.assertIn('!= "forecast"',query)
class RepeatProfileIntegrityTest(unittest.TestCase):
def test_pv_repeats_on_second_day(self):
history=raw_frame();history['PV']=np.maximum(0.,np.sin(np.arange(288)*2*np.pi/288))*12000.
idx=pd.date_range(AT,periods=576,freq='5min')
actual=np.array(list(repeat_daily_profile(history,idx,'PV').values()))
np.testing.assert_allclose(actual[:288],history['PV'])
np.testing.assert_allclose(actual[288:],history['PV'])
def test_missing_yesterday_uses_older_finite_day(self):
t=AT
history=pd.DataFrame({'Hausverbrauch':[3500.,np.nan]},index=[t-pd.Timedelta(days=7),t-pd.Timedelta(days=1)])
self.assertEqual(profile_source_value(history,t,'Hausverbrauch'),3500.)
def test_missing_profile_is_not_zero(self):
history=pd.DataFrame({'Hausverbrauch':[np.nan]},index=[AT-pd.Timedelta(days=1)])
with self.assertRaises(TelemetryUnavailable):repeat_daily_profile(history,[AT],'Hausverbrauch')
def test_recorded_profile_zero_is_valid(self):
history=pd.DataFrame({'PV':[0.]},index=[AT-pd.Timedelta(days=1)])
self.assertEqual(profile_source_value(history,AT,'PV'),0.)
def test_negative_profile_cannot_be_silently_clamped(self):
history=pd.DataFrame({'Hausverbrauch':[-100.]},index=[AT-pd.Timedelta(days=1)])
with self.assertRaises(TelemetryUnavailable):profile_source_value(history,AT,'Hausverbrauch')
def test_future_value_is_never_used_as_history(self):
history=pd.DataFrame({'Hausverbrauch':[3300.]},index=[AT+pd.Timedelta(days=1)])
with self.assertRaises(TelemetryUnavailable):profile_source_value(history,AT,'Hausverbrauch')
if __name__=='__main__':unittest.main()
@@ -0,0 +1,9 @@
{
"libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a",
"libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9",
"libs/ManagerNetzfahrplanV4Trait.php": "13d2867d2b4fe7f8846a08d9b4b81269320db6373d44c5f731a09c21c4912ab7",
"tests/fixtures/NativeV4Scenarios.php": "56a5a0df03cb53ea69f6e199c6d2405041a329c7df540ea8bacc08bfaa8766c6",
"tests/fixtures/V4BezugszaehlerScenarios.php": "7820bab98d1249aac3fee9f015f8da500744c12bfb5b198fcc735cadf3167ec2",
"tests/NetzfahrplanV4BezugszaehlerTest.php": "65b2daa769773198859ab40d2b230b1f3c43f1c618df0c4b90a494d31efb786c",
"tests/NetzfahrplanV4BetriebsdatenTest.php": "4a4f5af4cd86797fc40a4a36a7103a26fcf1e59ab381bafa8f67d35b34419dbc"
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
// OFFLINE ONLY: no Symcon kernel, HTTP requests, timers or connected devices.
if (PHP_SAPI !== 'cli' || function_exists('IPS_GetVariable')) {
fwrite(STDERR, "This is an isolated CLI test, not a Symcon runtime script.\n");
exit(2);
}
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
$manifest = json_decode(file_get_contents(__DIR__ . '/SOURCE_MANIFEST.json'), true, 512, JSON_THROW_ON_ERROR);
foreach ($manifest as $relative => $expected) {
$path = __DIR__ . '/' . $relative;
if (!is_file($path) || hash_file('sha256', $path) !== $expected) {
throw new RuntimeException('Source manifest mismatch: ' . $relative);
}
$command = escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($path);
$lines = [];
exec($command, $lines, $code);
if ($code !== 0) {
throw new RuntimeException('PHP syntax failure: ' . $relative);
}
}
require __DIR__ . '/libs/NetzfahrplanV4Bezugszaehler.php';
require __DIR__ . '/libs/NetzfahrplanV4Betriebsdaten.php';
require __DIR__ . '/libs/ManagerNetzfahrplanV4Trait.php';
require __DIR__ . '/tests/fixtures/NativeV4Scenarios.php';
require __DIR__ . '/tests/fixtures/V4BezugszaehlerScenarios.php';
$native = \Belevo\EnelixEMS\Tests\NativeV4Scenarios::run();
$meters = \Belevo\EnelixEMS\Tests\V4BezugszaehlerScenarios::run();
if (count($native) !== 24 || count($meters) !== 20) {
throw new RuntimeException('Expected 24 native and 20 meter scenarios.');
}
foreach (array_merge($native, $meters) as $name) {
echo 'PASS ', $name, "\n";
}
echo 'PHP ', PHP_VERSION, ': 44 offline conversion scenarios passed.', "\n";
echo "NOT a full EMS suite and NOT an IP-Symcon runtime/physical test.\n";
} catch (Throwable $error) {
fwrite(STDERR, 'FAIL: ' . $error->getMessage() . "\n");
exit(1);
}
@@ -0,0 +1,164 @@
<?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->RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]');
$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'] as $key) {
$manager[$key] = $this->ReadPropertyInteger($key);
}
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
$manager[$key] = $this->ReadPropertyFloat($key);
}
$manager['V4ControlPlanSource'] = $this->leseJsonAttribut('Netzfahrplan')['sourceModel'] ?? 'legacy-unspecified';
$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);
$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 = [];
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', 'LadezustandHysterese', '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.');
}
$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);
} 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,219 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
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
{
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, string $meterID, array &$warnings): array
{
$peaks = [];
$past = null;
if ($input === []) {
$warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.';
return [(object) [], null];
}
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);
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 = [];
$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');
$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);
$physicalMin = self::number($c['MindestLadezustand'], 'Technisches Minimum', 0, $min);
if ($socValue < $physicalMin || $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.';
}
$rearm = min($max, $min + self::number($c['LadezustandHysterese'] ?? 0.0, 'Entladehysterese', 0, 100));
$blocked = !empty($c['HystereseAktiv']) || $socValue <= $min;
if ($blocked) {
$warnings[] = $id . ': Entladesperre im Modell aktiv bis zur Wiederfreigabe nach vorheriger Ladung.';
}
if ($socValue < $min) {
$warnings[] = $id . ': unter Betriebsreserve; realen SOC behalten und nur zulassige Wiederaufladung planen.';
}
$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,
'physicalMinSocPercent' => $physicalMin, 'recoveryAllowed' => true,
'dischargeBlocked' => $blocked, 'rearmSocPercent' => $rearm];
}
if ($result === []) {
throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.');
}
[$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];
// A fresh acquisition sample is an operational estimate. It does NOT
// claim a calibrated billing-period boundary or full historical coverage.
$policy = [
'adapterVersion' => 'v4-native-estimated-meter-1',
'sourcePlan' => $manager['V4ControlPlanSource'] ?? 'legacy-unspecified',
'mode' => $mode, 'importW' => $import, 'exportW' => $export,
'months' => $months,
'batteryPolicy' => array_map(static function (array $b): array {
return array_intersect_key($b, array_flip(['id', 'capacityKwh', 'minSocPercent', 'maxSocPercent', 'physicalMinSocPercent', 'rearmSocPercent']));
}, $result),
];
$payload['meterObservation'] = [
'meterId' => $meter['meterId'], 'sampleAt' => gmdate('c', $now),
'powerW' => $gridW, 'totalImportKwh' => $meter['totalKwh'],
'controlPolicyId' => hash('sha256', json_encode($policy, 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, '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];
}
}
@@ -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(24, 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);
}
}
@@ -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);
}
}
@@ -0,0 +1,140 @@
<?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];
$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,
'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, 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 {
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_below_reserve_is_not_fabricated'] = static function () use ($build, $read, $check): void {
$r = static function ($id) use ($read) { $v = $read($id); if ($id === 2) { $v['value'] = 5.; } return $v; };
$b = $build(null, null, null, $r)['operation']['batteries'][0];
$check($b['socPercent'] === 5.0 && $b['minSocPercent'] === 15.0 && $b['physicalMinSocPercent'] === 3.0 && $b['recoveryAllowed'] === true && $b['dischargeBlocked'] === true);
};
$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]['dischargeBlocked'] === true && $x['operation']['batteries'][0]['maxDischargeW'] === 30000.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' => \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 {
$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']);
};
$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;
}
}
@@ -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;
}
}
@@ -0,0 +1,100 @@
"""Read-only probe executed INSIDE the existing forecast-engine container.
No get_configs() (it may migrate SQL), no training/prediction, no forecast writing,
no manual /run_now request, and no user credentials in output. Reads one plant's
stored raw 5-minute forecast values and the input frames used by the engine.
"""
import contextlib
from datetime import datetime, timezone
import hashlib
import io
import json
import math
import os
from pathlib import Path
import sqlite3
from urllib.parse import quote
from uuid import UUID
class Discard(io.TextIOBase):
def write(self,text):return len(text)
def clean_time(value):
if hasattr(value,'to_pydatetime'):value=value.to_pydatetime()
if value.tzinfo is None:value=value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat()
def run():
aid=str(UUID(os.environ['ENELIX_ACCEPTANCE_PLANT']))
os.environ['FORECAST_INFLUX_TIMEOUT_MS']='20000'
# These assignments affect only this diagnostic process, not the service.
with contextlib.redirect_stdout(Discard()),contextlib.redirect_stderr(Discard()):
import main
import numpy as np
import pandas as pd
path=Path(main.SQLITE_DB_PATH)
if not path.is_file():raise RuntimeError('Existing configuration database missing')
con=sqlite3.connect('file:'+quote(str(path))+'?mode=ro',uri=True)
con.row_factory=sqlite3.Row
try:row=con.execute('SELECT * FROM anlagen_meta WHERE anlagen_id=?',(aid,)).fetchone()
finally:con.close()
if row is None:raise RuntimeError('Installation not found')
cfg=dict(row)
cfg['daecher']=json.loads(cfg.get('daecher') or '[]')
for key,default in [('ac_leistung',10.),('batt_capacity_kwh',0.),('batt_power_kw',0.),('tarif_bezug_fest',.3),('tarif_einspeisung_fest',.1),('tarif_peak_fest',5.)]:
value=cfg.get(key);cfg[key]=float(default if value is None or value=='' else value)
data=main.build_data_object(cfg,training=False)
frames={}
for key in ('df_hist','df_recent_raw','df_load_training','df_fut'):
frame=data.get(key)
if frame is None:frames[key]={'available':False};continue
item={'rows':len(frame),'from':clean_time(frame.index.min()) if len(frame) else None,'until':clean_time(frame.index.max()) if len(frame) else None,'columns':{}}
for col in ('Hausverbrauch','PV','Netzleistung','SOC'):
if col not in frame.columns:continue
values=pd.to_numeric(frame[col],errors='coerce');valid=values[np.isfinite(values)]
item['columns'][col]={'finite':len(valid),'zeros':int((valid==0).sum()),'median':float(valid.median()) if len(valid) else None,'maximum':float(valid.max()) if len(valid) else None,'lastFiniteAt':clean_time(valid.index[-1]) if len(valid) else None,'recentValues':[{'time':clean_time(t),'value':float(v)} for t,v in valid.tail(12).items()]}
frames[key]=item
start=datetime.now(timezone.utc).replace(second=0,microsecond=0)
from datetime import timedelta
end=start+timedelta(hours=48)
fields=('prog_var_1','prog_var_2','prog_var_10','prog_var_11','prog_var_21','prog_var_22')
field_filter=' or '.join('r["_field"] == '+json.dumps(f) for f in fields)
query='''from(bucket: %s)
|> range(start: %s, stop: %s)
|> filter(fn: (r) => r["_measurement"] == "api_telemetry")
|> filter(fn: (r) => r["anlagen_id"] == %s)
|> filter(fn: (r) => r["data_type"] == "forecast")
|> filter(fn: (r) => %s)
|> keep(columns: ["_time", "_field", "_value"])
''' % (json.dumps(main.INFLUX_BUCKET),start.isoformat(),end.isoformat(),json.dumps(aid),field_filter)
client=main.InfluxDBClient(url=main.INFLUX_URL,token=main.INFLUX_TOKEN,org=main.INFLUX_ORG,timeout=20000)
series={field:[] for field in fields}
try:
tables=client.query_api().query(org=main.INFLUX_ORG,query=query)
for table in tables:
for record in table.records:
value=record.get_value();field=record.get_field()
if field in series and isinstance(value,(int,float)) and math.isfinite(value):
series[field].append({'time':clean_time(record.get_time()),'value':float(value)})
finally:client.close()
for field in series:series[field].sort(key=lambda p:p['time'])
versions={}
for relative in ('main.py','methods/var_1.py','methods/var_2.py','methods/var_10.py','methods/var_11.py','methods/var_21.py','methods/var_22.py'):
source=Path('/app')/relative
if source.is_file():versions[relative]=hashlib.sha256(source.read_bytes()).hexdigest()
summary={}
for field,points in series.items():
values=[p['value'] for p in points]
summary[field]={'points':len(values),'allZero':bool(values) and max(abs(v) for v in values)==0,'maximumW':max(values) if values else None,'from':points[0]['time'] if points else None,'until':points[-1]['time'] if points else None}
native=True
for points in series.values():
stamps=[datetime.fromisoformat(p['time']).timestamp() for p in points]
if not stamps or any(t%300 for t in stamps) or any(b-a!=300 for a,b in zip(stamps,stamps[1:])):native=False
return {'status':'read_only_acquired','installationId':aid,'observedAt':datetime.now(timezone.utc).isoformat(),'queryResolution':'raw_no_chart_resampling','nativeFiveMinuteForecast':native,'generationTimeVerified':False,'inputFrames':frames,'forecastSummary':summary,'forecastSeries':series,'runningSourceHashes':versions,'modelWrite':False,'liveControlChanged':False}
try:
result=run()
except Exception as error:
# Never echo exception details containing SQL payloads, credentials or URLs.
result={'status':'read_only_probe_failed','errorType':type(error).__name__,'liveControlChanged':False}
print(json.dumps(result,allow_nan=False))
@@ -0,0 +1,5 @@
{
"license/public/netplan-v4.js": [
"e859e91a52dd1d6c96f411d16caed17375f6925bf867d09992ab32ce93bff5d7"
]
}
@@ -0,0 +1,80 @@
# Integrated application data/model path - 2026-10-02
User request: finish application implementation, stop asking for repeated hardware
confirmations and adding standalone diagnostic samplers. No actuator permission is
implied by that development request. Confirmed EV allocation remains 161.44 kWh /
39 kW each direction, with SDL reserve already excluded.
## Implemented in the existing application
- Native ManagerNetzfahrplanV4DatenTrait: 30-second acquisition of configured numeric
sources; durable private app-owned outbox; original timestamps, no new Modbus
polls; acknowledged per-plant batches with retries/backoff. Cursor advances only
after exact dataset/acceptedThrough acknowledgement. Batches bounded below the
existing portal's 1 MiB body limit. No credentials in data payload or reports.
- Existing device proxy now forwards /planner-v4/measurements through the existing
authentication, plant binding, licence check and separate V4 rate limiter.
Device route cannot modify the dataset mapping or issue a trial grant.
- Existing V4 database stores immutable versioned source mappings and observations.
Same-time conflicts reject the whole batch; repeated identical data is idempotent.
No writes to portal/users/legacy measurement databases.
- Existing V4 worker aggregates physical load using original source times. Virtual
EV/SDL filter outputs are not load inputs. Solar terminal variant counts signed
terminal power once. Small unobserved parts remain quantified, never zero-filled.
This configured formula remains an estimate, not independent AC/DC certification.
- Application profile training actually runs daily/weekly. Models are immutable,
bootstrap/holdout status explicit. Incumbent replacement requires a causal
holdout when available. Existing data and model versions are not relabelled.
- Three corrected load variants (daily robust profile, last-day profile,
weekday/weekend profile) are paired with the existing PV families 3/13/23.
They are tagged physical-profile-v1; they are NOT claimed to be the old trained
load algorithms under a new data name. Automatic future family registry work
and economic replay are still distinct from this load-profile implementation.
- Selecting corrected_profile in the existing GUI/setting now actually changes
the load entering the existing optimizer. Missing trained data cannot silently
fall back to the contaminated legacy household series.
- A current SDL request is an explicitly labelled PERSISTENCE SCENARIO for the
shadow economic calculation, not a published future schedule. Missing/stale SDL
is not zero. This is not a robust/full SDL production-dispatch policy.
- GUI now exposes dataset/source selection, records, usable equivalent hours,
model version, validation and real training cadence. Existing normal portal
app.js/index files are not changed.
## Deployment package
Server: commissioning/deploy_application.py --plant <approved UUID> --apply
Rebuild/test V4 with Python 3.11, portal routing tests, consistent SQLite backup,
replace ONLY V4 and portal, configure the new per-plant dataset. No automatic
forecast-source switch and no control grant. Atomic file-bind replacement requires
portal recreation, not a simple restart. Additive DB rollback retains new data.
Default invocation without --apply validates source manifests only.
Test host: /srv/agent/netplan-v4-application-build/install.php inside Symcon.
Installs a DATA-ONLY patch of the currently installed passive manager (not the
unreleased controller-trial candidate), reloads EMS, configures acquisition and
imports up to 48h of the existing 23-source observer as a delivery backlog.
Original observer and old raw journals remain intact. Existing separate observers
are NOT automatically stopped by this initial installer; consolidation should
follow confirmed native batch acceptance. No new root diagnostic category.
An asynchronous module registration may require one repeat, handled explicitly.
Dataset lihrenmoos-physical-v1: minimum coverage 95%, longest unsupported portion
10s, at least 24 equivalent usable hours, 28-day profile history. The 95% rule is
an explicit modelling policy, not a statement that missing energy was measured.
Insufficient data remains collecting; no promised time to good forecasts.
All original acquisition thresholds remain unchanged.
## Tests and honest limits
Host tests use existing isolated QA dependencies; native tests mock IPS/HTTP and
use temporary files. Real container and kernel tests occur on the user-run deploy
commands, not during preparation. No services/modules/settings/devices were modified
by this development session.
Still NOT a complete production commissioning: the corrected profile-to-local
feedback/dispatch integration, full automatic cost replay, independent long-term
outbox/server retention and staged live/failure acceptance remain open. The trial
code from previous commits remains gated and is not included in the data-only
native installation. No accountingEvidenceId/device watchdog proof is fabricated.
This release is an integrated application build, NOT a claim that the full original
multi-plant production scope is done. No main/beta release is authorized by tests alone.
@@ -0,0 +1,256 @@
"""Commission the corrected Lihrenmoos forecast in shadow mode.
The command is a dry run unless --apply is supplied. It never enables live control,
changes Symcon actuator flags, or reads/prints service credentials.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sqlite3
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID
SERVICE_ROOT = Path(__file__).resolve().parents[1]
if str(SERVICE_ROOT) not in sys.path:
sys.path.insert(0, str(SERVICE_ROOT))
RUNTIME_ROOT = Path("/home/agent/services/netplan-v4-shadow")
PLANT = "e3a08f9e-af12-4695-99bd-8b51c0520021"
DATASET = "lihrenmoos-physical-published-v2"
SOURCE_FILES = (
"netplan_v4/store.py",
"netplan_v4/measurement_pipeline.py",
"netplan_v4/service.py",
)
def digest(path: Path) -> str:
if path.is_symlink() or not path.is_file():
raise ValueError("Source file missing or is a symlink")
return hashlib.sha256(path.read_bytes()).hexdigest()
def verify_runtime_source(runtime_root: Path) -> None:
for name in SOURCE_FILES:
if digest(SERVICE_ROOT / name) != digest(runtime_root / name):
raise ValueError("Runtime source differs from reviewed repository: " + name)
def read_state(db: Path, plant: str, dataset: str, now: int) -> dict:
stamp = db.stat()
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
try:
from netplan_v4.store import DEFAULT_SETTINGS
row = con.execute(
"SELECT revision,value FROM planner_settings WHERE plant=?", (plant,)
).fetchone()
settings = {**DEFAULT_SETTINGS, **(json.loads(row["value"]) if row else {})}
settings["revision"] = row["revision"] if row else 0
ds = con.execute(
"SELECT config FROM planner_data_sets WHERE plant=? AND dataset=?",
(plant, dataset),
).fetchone()
if not ds:
raise ValueError("Corrected dataset is not registered")
config = json.loads(ds["config"])
state = con.execute(
"SELECT status,detail FROM planner_pipeline_state WHERE plant=? AND dataset=?",
(plant, dataset),
).fetchone()
model = con.execute(
"""SELECT m.model_id,m.trained_at,m.trained_through,m.value
FROM planner_model_current c JOIN planner_load_models m
ON m.plant=c.plant AND m.dataset=c.dataset AND m.model_id=c.model_id
WHERE c.plant=? AND c.dataset=?""",
(plant, dataset),
).fetchone()
observation_dataset = config.get("sourceDatasetId", dataset)
observation = con.execute(
"""SELECT COUNT(*) records,MAX(captured_at) last_capture,
MAX(received_at) last_received
FROM planner_observations WHERE plant=? AND dataset=?""",
(plant, observation_dataset),
).fetchone()
plan = con.execute(
"""SELECT p.value FROM planner_current c JOIN planner_plans p
ON p.plan_id=c.plan_id WHERE c.plant=? AND c.mode='shadow'""",
(plant,),
).fetchone()
detail = json.loads(state["detail"]) if state else {}
model_value = json.loads(model["value"]) if model else None
return {
"settings": settings,
"dataset": dataset,
"observationDataset": observation_dataset,
"pipelineStatus": state["status"] if state else None,
"pipelineDetail": detail,
"records": observation["records"],
"lastCapture": observation["last_capture"],
"lastReceived": observation["last_received"],
"activeModel": {
"modelId": model["model_id"],
"trainedAt": model["trained_at"],
"trainedThrough": model["trained_through"],
"validation": model_value.get("validation"),
} if model else None,
"currentPlan": json.loads(plan["value"]) if plan else None,
"checkedAt": now,
"databaseMtimeNs": stamp.st_mtime_ns,
}
finally:
con.close()
def require_ready(state: dict, now: int) -> None:
if state["settings"]["runMode"] != "shadow":
raise ValueError("Only shadow mode may be commissioned")
if state["pipelineStatus"] != "model_ready" or not state["activeModel"]:
raise ValueError("Corrected profile model is not ready")
if state["records"] < 1 or not isinstance(state["lastCapture"], int):
raise ValueError("Corrected observations are missing")
if not 0 <= now - state["lastCapture"] <= 600:
raise ValueError("Corrected observations are not current")
if not 0 <= now - state["lastReceived"] <= 600:
raise ValueError("Corrected delivery is not current")
detail = state["pipelineDetail"]
if detail.get("modelId") != state["activeModel"]["modelId"]:
raise ValueError("Pipeline and active model disagree")
if detail.get("usableEquivalentHours", 0) < detail.get("requiredEquivalentHours", 24):
raise ValueError("Minimum usable equivalent hours not reached")
def backup_database(source: Path, destination: Path) -> None:
if destination.exists() or source.is_symlink():
raise ValueError("Backup destination must be new and source must be regular")
with sqlite3.connect(f"file:{source}?mode=ro", uri=True) as src, sqlite3.connect(destination) as dst:
src.backup(dst)
if dst.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("Database backup integrity check failed")
destination.chmod(0o600)
def plan_uses_dataset(db: Path, plant: str, dataset: str, revision: int) -> dict | None:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
try:
row = con.execute(
"""SELECT p.value FROM planner_current c JOIN planner_plans p
ON p.plan_id=c.plan_id WHERE c.plant=? AND c.mode='shadow'""",
(plant,),
).fetchone()
if not row:
return None
plan = json.loads(row[0])
pipeline = plan.get("inputQuality", {}).get("dataPipeline", {})
if plan.get("configRevision") == revision and pipeline.get("datasetId") == dataset:
return plan
return None
finally:
con.close()
def activate(db: Path, plant: str, dataset: str, now: datetime) -> dict:
from netplan_v4.store import PlannerStore
store = PlannerStore(str(db))
try:
before = store.settings(plant)
if before["runMode"] != "shadow":
raise ValueError("Runtime left shadow mode")
if before["forecastSource"] == "corrected_profile" and before["measurementDataset"] == dataset:
store.request(plant, "manual", now)
return {"changed": False, "revision": before["revision"]}
result = store.save_settings(
plant,
{"forecastSource": "corrected_profile", "measurementDataset": dataset},
before["revision"],
now,
)
return {"changed": True, "revision": result["revision"]}
finally:
store.close()
def commission(runtime_root: Path, plant: str, dataset: str, apply: bool, wait_seconds: int = 90) -> dict:
plant = str(UUID(plant))
if runtime_root.resolve() != RUNTIME_ROOT:
raise ValueError("Unexpected runtime root")
verify_runtime_source(runtime_root)
db = runtime_root / "data/netplan-v4.sqlite"
now = datetime.now(timezone.utc).replace(microsecond=0)
before = read_state(db, plant, dataset, int(now.timestamp()))
require_ready(before, int(now.timestamp()))
report = {
"scope": "corrected_forecast_shadow_commissioning",
"startedAt": now.isoformat(),
"installationId": plant,
"datasetId": dataset,
"liveEnabled": False,
"actuatorPermissionGranted": False,
"before": {
"settings": before["settings"],
"pipelineStatus": before["pipelineStatus"],
"records": before["records"],
"lastCapture": before["lastCapture"],
"activeModel": before["activeModel"],
"usableEquivalentHours": before["pipelineDetail"].get("usableEquivalentHours"),
},
}
if not apply:
report["status"] = "ready_no_changes"
return report
folder = runtime_root / "corrected-forecast-releases" / now.strftime("%Y%m%dT%H%M%SZ")
folder.mkdir(parents=True, mode=0o700)
backup_database(db, folder / "before.sqlite")
activated = activate(db, plant, dataset, now)
report["activation"] = activated
deadline = time.monotonic() + wait_seconds
plan = None
while time.monotonic() < deadline:
plan = plan_uses_dataset(db, plant, dataset, activated["revision"])
if plan:
break
time.sleep(2)
after = read_state(db, plant, dataset, int(datetime.now(timezone.utc).timestamp()))
settings = after["settings"]
if settings["runMode"] != "shadow" or settings["forecastSource"] != "corrected_profile" or settings["measurementDataset"] != dataset:
raise ValueError("Corrected shadow settings were not retained")
report["after"] = {
"settings": settings,
"pipelineStatus": after["pipelineStatus"],
"activeModel": after["activeModel"],
"planId": plan.get("planId") if plan else None,
"planStatus": plan.get("status") if plan else None,
"sourceFamily": plan.get("sourceFamily") if plan else None,
"dataPipeline": plan.get("inputQuality", {}).get("dataPipeline") if plan else None,
}
report["status"] = "corrected_forecast_running_shadow" if plan else "corrected_forecast_activated_plan_pending"
report["finishedAt"] = datetime.now(timezone.utc).isoformat()
output = folder / "REPORT.json"
output.write_text(json.dumps(report, indent=2) + "\n")
output.chmod(0o640)
report["reportPath"] = str(output)
return report
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--plant", default=PLANT)
parser.add_argument("--dataset", default=DATASET)
parser.add_argument("--apply", action="store_true")
parser.add_argument("--wait-seconds", type=int, default=90)
args = parser.parse_args()
if not 1 <= args.wait_seconds <= 300:
raise SystemExit("wait-seconds must be 1..300")
try:
result = commission(RUNTIME_ROOT, args.plant, args.dataset, args.apply, args.wait_seconds)
except Exception as exc:
raise SystemExit("Stopped: " + type(exc).__name__ + ". No actuator permission was changed.") from exc
print(json.dumps(result, indent=2))
@@ -0,0 +1,100 @@
{
"scope": "integrated_measurement_application",
"installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021",
"sourceHashes": {
".dockerignore": "fab6861d98f34e54646ae966b237e792fc0a0b4f95f1df3b62a1f062cbb8f790",
"Dockerfile": "655c600a0364e91d47bfc80faaf27e26362bfc2683c8e57b653d913133865713",
"acceptance/Dockerfile.php": "715a2d232d7f901bd6ca1f2e453b7b7f48fc1d7f49bff8944a7d277a8dc30062",
"acceptance/check_forecast.py": "ccee58ec1078767a580f15f895506eceeb15e13b54e8eb9b4905cc03ba14ccd5",
"acceptance/forecast-src/SOURCE_MANIFEST.json": "8103775396c58d82921e2e2a1513ee185ae2431200763717544ff9b1da181fe5",
"acceptance/forecast-src/main.py": "4060564a4a33400ef6b8547633fc4c97caa3f674494d8cfc53a7aae0ed011b6c",
"acceptance/forecast-src/methods/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"acceptance/forecast-src/methods/battery_optimizer.py": "27e7404d3bf4a2511e022232c2f6877adc0db014bdeb132a2a13cd4949d4d5e6",
"acceptance/forecast-src/methods/common.py": "0fe5c9bc3fa8b6d40f0f9db36843623c6e469c150ed25899e3176355d5bb1db8",
"acceptance/forecast-src/methods/var_1.py": "6a7fc3aaf904442aba44bd211d89e4ba54f10485f54441a1239de19e91fa5fe4",
"acceptance/forecast-src/methods/var_10.py": "a3caf21387620687646775e6b0bf85c97af8bc6d0a48e3d779e9684d310333b7",
"acceptance/forecast-src/methods/var_11.py": "1f783e57fee22761e8e5439caef48e275876a7ebf8380467d9b71ad2aa1b8edc",
"acceptance/forecast-src/methods/var_13.py": "f12407cd056a1f28f47ab93b1262c62627c80d4765e75dd495e2c19e8f8e2ad9",
"acceptance/forecast-src/methods/var_2.py": "5bdfd61bb108368890ff1b920f60499eabf6363c6380637100380eb0e28f0c6a",
"acceptance/forecast-src/methods/var_21.py": "e2e3361d57fae8379dcce89cd98595a0d56d23decd1073d94474302b53ff8e15",
"acceptance/forecast-src/methods/var_22.py": "cd51104bf98c686360c037cb74ff0a40bb748f24e52575eb3a3ef9932fac8cd6",
"acceptance/forecast-src/methods/var_23.py": "d356d4078723a59e6cfad5ade631883cf22e7abd9caf38cec046e26b0580c678",
"acceptance/forecast-src/methods/var_3.py": "87662e491ba33e170f3bcfdbd8dd2e54630073fcf4cb6e2e8ab768f9a6d95984",
"acceptance/forecast-src/model_isolation.py": "db33ee8e9583cf006a5224a9f9efeed874ce04144d74f1b1bb25852c614468c5",
"acceptance/forecast-src/netplan_v4_publisher.py": "cb8efe10d2799215b347985c53c96a6420e5461fff4c4e232b6f918d8bff2161",
"acceptance/forecast-src/requirements.txt": "1f4731380246b4b08e5666ce736d9f24063128d2fc0737a061b36ac93952c083",
"acceptance/forecast-src/shared_utils.py": "271489e491305d97706e0a4b5c8745bcb01e19628a0cee71da15512ee2d85e57",
"acceptance/forecast-src/soc_diagnostics.py": "06ce7c55aa69875df94471a9fe47ff3d95da372192b737b9a0ff6493503ac15a",
"acceptance/forecast-src/telemetry_quality.py": "bb959d08d2d50d5597dc10b47a35d510e43ba8bfa755db236652071b57ad1810",
"acceptance/forecast-src/tests/test_battery_optimizer.py": "5b8fa3185672530c072a8cfe506ac8d4878846efbb2ee818b93ee04d57ca7cc8",
"acceptance/forecast-src/tests/test_load_forecast.py": "000903a3691297dd7cfc160b3702825a6f04d53ae9d745dc08f7bfadec06465c",
"acceptance/forecast-src/tests/test_model_isolation.py": "4dadcc7541181a57badc337fa33100c0ba2b9fd20b7dd36b87326eeeb285cf30",
"acceptance/forecast-src/tests/test_telemetry_integrity.py": "8e6a200d6a108d309b8c5ceba15fdb1653644789875648c4284b75170d56b5ce",
"acceptance/php-src/SOURCE_MANIFEST.json": "3f8add37ac99ebbb0b3e77093ee586e5deedce14643190c673e7a3dd942d2a50",
"acceptance/php-src/check.php": "92599dc10d0b8b0bb97cab3c8ae8fbefd084c8cd65992f0e848e8042385cf473",
"acceptance/php-src/libs/ManagerNetzfahrplanV4Trait.php": "13d2867d2b4fe7f8846a08d9b4b81269320db6373d44c5f731a09c21c4912ab7",
"acceptance/php-src/libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9",
"acceptance/php-src/libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a",
"acceptance/php-src/tests/NetzfahrplanV4BetriebsdatenTest.php": "4a4f5af4cd86797fc40a4a36a7103a26fcf1e59ab381bafa8f67d35b34419dbc",
"acceptance/php-src/tests/NetzfahrplanV4BezugszaehlerTest.php": "65b2daa769773198859ab40d2b230b1f3c43f1c618df0c4b90a494d31efb786c",
"acceptance/php-src/tests/fixtures/NativeV4Scenarios.php": "56a5a0df03cb53ea69f6e199c6d2405041a329c7df540ea8bacc08bfaa8766c6",
"acceptance/php-src/tests/fixtures/V4BezugszaehlerScenarios.php": "7820bab98d1249aac3fee9f015f8da500744c12bfb5b198fcc735cadf3167ec2",
"acceptance/read_native_forecasts.py": "1092413b70714c2e12e3af2697a8d3eb7a70efb5965295c20efc92eaf647c77f",
"approved_previous_assets.json": "0fd70ccba10d2970a187dca1be3690461aa143eb29d7e5c244a97951e275cfb0",
"commissioning/APPLICATION_STATUS.md": "efec16d79ccd8cb7531c3c135bca5bb38381123666a45cae30d9631183071abc",
"commissioning/application-source/server-dataset.json": "844c7b76370f451af172c79a876b8d298236e7dd25b613fc41e0b05338565f6d",
"commissioning/deploy_application.py": "8fabbbbf41e00be677a6f109690758035bbe098f9161d9c44865b28ab7f1bd4e",
"compose.portal-bridge.yaml": "4299d9de0e8707777052589e4097744c712698ff254a8bdc886041d2c4925197",
"compose.yaml": "aae681e18be81633589926db93b27043fca983e255f2f62108ad38df42e1d607",
"deploy_integrated_shadow.py": "ec8b324dd5041f89ee84849937f27e9feffb80c6301efe48e9504b326e1f7898",
"forecast_acceptance.py": "cc018773c65b61e37f13dfafe2c53b71eec87311d9f3abb514e78cd5917251f9",
"gui/netplan-v4.css": "216959a2d90f346a167a4ac809e6cf96c00461abd9853f5c5b5052d6e34d7efd",
"gui/netplan-v4.html": "296a247a7a9724dbe8c873372b1d5536ec02ea6eede823c1545e1d8749d64755",
"gui/netplan-v4.js": "25c3f80e7a134efaff170e0fd8e16823e1418466bc6e23b11c6b4d1ffa3c1cc5",
"install_hooks.py": "1c16586f970742c994cd0cf9ffb41e921ad34f64fa6043fef89d6bdd686799f3",
"integrations/NetzfahrplanV4.php": "8ecb7311c6db5998db5fa1b03bd70536bf1fb29f4fea45d257824c033358baf3",
"integrations/netplan-v4-bridge.mjs": "f67c28a148b9233da44be817940425265ca98278dc5e04cd36942432febf8dc7",
"integrations/netplan_v4_publisher.py": "cb8efe10d2799215b347985c53c96a6420e5461fff4c4e232b6f918d8bff2161",
"netplan_v4/__init__.py": "8fe1793927dcdd2d15d0ce1bd3f53b759c0622d6754b5b8efdbb9764e76fde48",
"netplan_v4/battery_model.py": "2142f9173e872da8718ce3f5fa6a0062bd4f2f8ed75d42889bc41e2bd00ef332",
"netplan_v4/controlled_trial.py": "4d4bd8ed97050b3bf5872c839e4bcc2fc9dcb9f2e0a8c66a4611bbd50bf9e1c4",
"netplan_v4/domain.py": "03aadfb6d69f349041c872e105b3ae31e880b507a630f82bdd774948a618b39b",
"netplan_v4/forecast_quality.py": "b656868538cb822dc1dec97c7726bcc47d78744a1db3ce02a11d259ba9c2a840",
"netplan_v4/measurement_pipeline.py": "caab05a69ac28c086f06b10b20460330b4c3721a6467417542d7666ce82ac743",
"netplan_v4/meter_runtime.py": "43275072a212351fa35d34ed2f4932a259112d516d594fad19a7e2b3fd44c547",
"netplan_v4/metering.py": "9ed4d3747de76f646d7be603cbdf37a3fc971109605705017c6644c0baa80987",
"netplan_v4/optimizer.py": "bf1ad3dcadf10c84e76f6758525fc1309b9f665853c660a1107c1347f1ce9063",
"netplan_v4/peak_policy.py": "5ac706655041efb964d4217b5c66a5454aa10348f45d29a98f1a736f3a860576",
"netplan_v4/receiver_contract.py": "a43bec2bc2ad8d621f6e85594013b6da3bee1a6ff8ef523f25dafa04672f401c",
"netplan_v4/selection.py": "8a2fd034b7a74c9d00d12e12cd76da113c29c3458541c89831c25874c98298ad",
"netplan_v4/service.py": "310498c22f235ddaf87e42a2b03e20da4b89d58709964043cc3142417a0f46c4",
"netplan_v4/store.py": "7d8ae265dcc3cc4261289b6e38c4f4877246ef088a78f1150f6764d1c97e2e99",
"release_preflight.py": "85755aea15daa4709b29838fa25b96cf4aef0166114a8b8474ef0d04422927d5",
"requirements.txt": "0b6febdec6a430645b1a17068c19799f9bc451b0b5b174ea119f026063f92464",
"run_tests.py": "17192e693fb8a97be1f0a2f166568d84e86056d4a7ff97f94dcbe1f4391719a4",
"runtime_preflight.py": "69b0fa8925c00cbd2399375301c63acc60f6dd5fe1438ad4458eaa07f9d087a1",
"tests/manager_protocol.php": "043c763f578d176b09224170690c1fb0a204c7e821b3687fbcf33895355e6f93",
"tests/portal.test.mjs": "4e08acda7cbf5eac5b9e3d8d032ca1403250a993ac022e94f440d54f65f90f0a",
"tests/test_application_deployment.py": "9fa667e08f9003dd942c6bbb1e8b77321f88b8d4ec2c042f91450e7d34bdeee5",
"tests/test_battery_recovery.py": "5185416073b4144f00c643acba48dc93be29d03163de10b1f29a8fd84c191ca3",
"tests/test_container_access.py": "181aa47e5c09235ae45a25e3221fc6871bbf89c49ce541599e9b8f7da8570df9",
"tests/test_controlled_trial.py": "8988396ddc709dc8d6bb039f0c4d1ab50efb47e455975f35de5b0a305bdbfe64",
"tests/test_controlled_trial_pipeline.py": "df3d8d6d816c77cdcb2f15d4caaf61fb52e919e56c1c842902b0808f54788e0d",
"tests/test_delivery.py": "ca36fbb6fccc7e89ffbf7147bb8f2b6ed2a5614f270ac87499c6360ccd95af09",
"tests/test_forecast_acceptance.py": "d2305dee8ccd8e633ca5b44b497dde519b715369f5a5572b864919fa2bdddd76",
"tests/test_forecast_quality.py": "461750d0b91d5fe21ec5b0a7d97a83e1bfe76fd6893950281be4753dac3baf82",
"tests/test_integrated_deployment.py": "ac4b1cf56a4222b423fa78d1870ccb8cf02a07a3ad2119a950dd009ea157e883",
"tests/test_measurement_pipeline.py": "761887486922762dd12a1b3beb00f41e0000ec3cbfe3b101484d0fe8f2364eb4",
"tests/test_metering.py": "6c7af6e624c93cde4b6cca00e66fc8fec0b72a047ac77e16778dc1926194a69d",
"tests/test_peak_release.py": "223f4374111cfab99ef352a71a7b91a7f71abc4ed126f2c180b138d1bd390d3d",
"tests/test_receiver_contract.py": "21daccb358fa62a983d2292d5de2b9b3c7300a08e314e850749316a24d3d254e",
"tests/test_release_preflight.py": "64104923bea0890e5010471466de87c289a97bca7dd29d56734d0009e79f24d8",
"tests/test_v4.py": "c35db22a864aa0afc4d0abc357ae854f038e86de8e3001760c0e63ce036a7763"
},
"portalBefore": {
"netplan-v4-bridge.mjs": "f58c652b04cd2ac20f2bec11e1161c44b72c26267b4dfb42096a1fad36a3c9c2",
"public/netplan-v4.js": "6cd1d887bb08c73a815c92eb684a0992e8cb092f748770cc94debcfd9705a628"
},
"createdAt": "2026-10-02T21:00:35.231591+00:00",
"controlEnabled": false
}
@@ -0,0 +1 @@
{"datasetId":"lihrenmoos-physical-v1","mappingSha256":"517d1907631c7f152096e21759bb3452cdd0b4b1b73f91f1c7cd3bd62d8aaa8b","inventorySha256":"0a9dde190d81bc263da222ad8bfb6268295ea03aec239f6c1fcd249b3d34c1bc","sources":[{"key":"grid","variableId":40348,"factorToW":1000,"maxAgeSeconds":60,"role":"grid"},{"key":"pv_goodwe1","variableId":48459,"factorToW":1,"maxAgeSeconds":60,"role":"pv"},{"key":"pv_goodwe2","variableId":53802,"factorToW":1,"maxAgeSeconds":60,"role":"pv"},{"key":"pv_solaredge","variableId":20335,"factorToW":1,"maxAgeSeconds":60,"role":"pv"},{"key":"physical_goodwe1","variableId":47725,"factorToW":-1,"maxAgeSeconds":60,"role":"physical_storage"},{"key":"physical_goodwe2","variableId":35724,"factorToW":-1,"maxAgeSeconds":60,"role":"physical_storage"},{"key":"physical_solaredge","variableId":21447,"factorToW":1,"maxAgeSeconds":60,"role":"physical_storage"},{"key":"ev_account","variableId":52020,"factorToW":1,"maxAgeSeconds":60,"role":"reference"},{"key":"sdl_account","variableId":25085,"factorToW":1,"maxAgeSeconds":60,"role":"reference"},{"key":"grid_display","variableId":49301,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"soc_goodwe1","variableId":27361,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"soc_goodwe2","variableId":23109,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"soc_solaredge","variableId":51938,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"soc_ev","variableId":32871,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"soc_sdl","variableId":23879,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"ev_available_charge","variableId":50230,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"ev_available_discharge","variableId":43899,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"energy_t1","variableId":59607,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"energy_t2","variableId":26620,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"ev_requested","variableId":19651,"role":"reference","factorToW":1,"maxAgeSeconds":60},{"key":"sdl_requested","variableId":38943,"role":"sdl_request","factorToW":1,"maxAgeSeconds":120},{"key":"solar_ac_value","variableId":37975,"role":"solar_raw","factorToW":1,"maxAgeSeconds":60},{"key":"solar_ac_scale","variableId":41853,"role":"solar_scale","factorToW":1,"maxAgeSeconds":60}],"formula":"solar_terminal_v1","solarReference":{"pvKey":"pv_solaredge","batteryKey":"physical_solaredge","rawKey":"solar_ac_value","scaleKey":"solar_ac_scale"},"minimumCoverage":0.95,"maximumGapSeconds":10,"minimumTrainingHours":24,"historyDays":28}

Some files were not shown because too many files have changed in this diff Show More