feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s

Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
This commit is contained in:
dh
2026-10-08 10:02:58 +00:00
parent af69151165
commit 74a2d6c685
82 changed files with 5054 additions and 617 deletions
+10
View File
@@ -1,5 +1,15 @@
# Agenten-Anweisungen für Enelix-EMS
## V4-Zwei-Schalter-Workflow: Entwicklungsstand 08.10.2026
Neue Bedienung und Migrationsgrenzen stehen in `docs/prognose-v4-workflow.md`.
Der Umbau ist auf dem develop-Basisstand 86834bb8aab51beba34d93574327d778d2c44521
vorbereitet, nicht als Anlagenrollout oder neue Stellfreigabe zu verstehen.
Aktuellen Git-, Test- und Installationsstand vor Fortsetzung erneut pruefen.
Die parallel bearbeitete Batterietimerkorrektur bleibt erhalten; Lihrenmoos
wurde fuer diesen Umbau nicht veraendert oder neu gestartet. Alte befristete
Testfreigaben werden weder uebernommen noch verlaengert.
## Aktueller V4-Fortsetzungseinstieg: 06.10.2026
Neuester Quellstand: Bedienintegration und Zusammenfuehrung sind in
+13 -3
View File
@@ -19,9 +19,12 @@ IP-Symcon-Registervariablen.
- Sollwert-Timeout, zeitbasierte Aenderungssperre und periodische Vollmeldung
- optionale Diagnosevariablen und Debug-Logging
Es gibt keinen zyklischen Regel- oder Berechnungstimer. Timer werden nur fuer
Vollmeldungen, den Ablauf einer Manager-Vorgabe und das Ende der
Aenderungssperre verwendet.
Die normale Verbraucherregelung ist ereignisbasiert. Ihre Timer dienen
Vollmeldungen, dem Ablauf einer Manager-Vorgabe und dem Ende der
Aenderungssperre. Der zusaetzliche, separat freizugebende V4-Testpfad besitzt
ausserdem Rueckmeldesampling und einen Software-Watchdog. Deren automatischer
Lauf ist vor einem Test nachzuweisen; ein aktiver Instanzstatus allein reicht
nicht. Details und bekannte Timerblockade stehen in der Modulreferenz.
## Voraussetzungen
@@ -179,3 +182,10 @@ Wechselrichtersteuerung.
- [Ausfuehrliche Modulbeschreibung](../docs/module/Batterie/README.md)
- [Manager-Verbraucher-Schnittstelle](../docs/Schnittstelle.md)
- [Migration von Enelix 1](../docs/migration/Batterie.md)
## Vollständige Referenz und Kanalstand
Alle Properties, Laufzeitvariablen, Einheiten, Beispiele und bekannten Grenzen
stehen in der [Modulreferenz](../docs/module/Batterie/README.md). Der beschriebene
Funktionsstand liegt in Testing und Beta; Stable enthält noch keine
installierbaren Module. [Kanalvergleich und Fehlerstatus](../README.md).
+180 -1
View File
@@ -2,14 +2,81 @@
Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
## Unreleased
## Zusammengefuehrter Entwicklungsstand 2026-10-08
- V4-Zwei-Schalter-Workflow: Prognose lernen und Netzfahrplan regeln getrennt;
vorhandene Freigaben, Datenfrische und lokale Schutzgrenzen bleiben massgeblich.
Keine neue Stellfreigabe und kein Anlagenrollout durch diese Veroeffentlichung.
- Batterietimer-Korrektur d42cfb3, Ladestationskorrektur 5885681 und
PHP-8.0-Kompatibilitaet 86834bb sind in der Quellbasis enthalten.
- Manager: Energy Pie verwendet die bilanzielle Gesamtanlage inklusive SDL
unabhaengig von SDLAktiv. Kein Mischen des SDL-bereinigten Hauszaehlers mit
physischen Netzzaehlern, keine doppelte Addition von SDL-Energie. Benoetigt
zuerst Enelix Utils mit Gesamtbilanz-Unterstuetzung; Quellen, Farben, Archive,
Hauslast fuer Regelung/Prognose und Stellpfade bleiben unveraendert.
Die folgenden datierten Dokumentationsnotizen beschreiben ihren damaligen Stand;
die aktuelle Bedienung steht in docs/prognose-v4-workflow.md. Die vollstaendige
Anlagenabnahme bleibt ein eigener Auftrag.
## Dokumentationsabgleich 2026-10-08
Die Funktionsbasis `5885681` ist auf Testing und Beta veröffentlicht.
Stable enthält weiterhin nur die Grundlage `b6253f9` mit Vertrag 3.0.
Die nachfolgenden Neuerungen gelten nicht für Stable; es wurde kein neuer
Modulrelease und kein Anlagenrollout durch diesen Dokumentationsabgleich erzeugt.
### Ergänzte Neuerungen und Korrekturen
- Stand-Alone-Ladestationen: Ladepausen und Nullwerte nicht als dauerhaftes
Ladeende verriegeln; go-e-Fehler und Pico-Datenfrische prüfen. Phasenprobe
benötigt Managerbudget, 0 W ergibt keine Einphasigkeit. Frische
Dreiphasenmessung korrigiert unsichere Altwerte. `FahrzeugMaximalstrom`
und `Geraetehinweis` mit Zugriff, Einheit und Diagnosebeispielen dokumentiert.
- Forecast und SDL: Vollständige ausgewählte Eingangsprognose als `forecastPoints`;
ausführbare Netz-/Batteriepläne bleiben durch gültige Preise begrenzt.
PV, Last, SDL-Szenario, Netz, Batterie, SOC, Preise und Kosten getrennt anzeigen.
- Vorhandene Energy-Pie-Konfiguration, individuelle Flow-Knoten und Nicht-SDL-
Diagrammreihen erhalten. Bei nicht bestimmbaren Energieanteilen keine Zahlen
erfinden; ältere Utils erfordern ein Update vor der SDL-Anzeige.
- Wärmepumpen-Lizenz-/Bestelloberfläche und Kopfnavigation sind vorbereitet;
deren Auslieferung erfordert den passenden Portal-Backend-Stand.
- Go-e-Regelübergänge stabilisiert. Easee-Initialstatus, Wiederverbindung,
Wartezustände und veraltete Ladeleistung nach Stopp korrigiert.
- Festlasten nach Istleistung bilanzieren; Batterie neutral beginnen und danach
unter Berücksichtigung ihrer Priorität verteilen. Feste Leistungsangebote
zuteilen; veraltete Verbraucher blockieren andere frische Angebote nicht.
- Fahrplanvorgaben zwischen Stützstellen linear interpolieren; Netzfahrplan-
Batteriesollwert und den lokalen Rückfall getrennt behandeln.
- V4: numerische Mappingkennung stabilisiert, historische Publikationslücken
ohne Lockerung der Live-Altersgrenzen behandelt, Klassenkollision im Installer
vermieden, bestätigte Geräteabrufe und Retry verbessert, Empfängerdrossel
bei Neuinitialisierung zurückgesetzt.
- Integrierte V4-Korrekturen prüfen abgewiesene Aktionen, trennen Geräte-I/O vom
Stell-Lock, verwenden den bestätigten Cache und erneuern Pläne ohne
blockierenden HTTP-Abruf im Regeltakt.
### Bekannte offene Grenzen
Am 06.10.2026 wurden erneut blockierte Batterietimer nach Modul-Reload gemeldet;
am 07.10.2026, 20:34 Uhr Europe/Zurich, liefen die periodischen Timer laut
späterer Nachkontrolle. Ursache und Wiederholbarkeit des Reload-Problems sind
nicht geklärt. Die frühere Testfreigabe ist seit dem 07.10. abgelaufen.
Physischer Soll-/Ist-Nachlauf,
mehrere Planerneuerungen, sicherer Stopp und unabhängiger Geräte-Watchdog sind
nicht vollständig abgenommen. V4 bleibt Testbetrieb, keine Dauerfreigabe.
Der Bericht vom 06.10. meldet das Forecast-Backend mit vollständigen
Eingangsprognosen noch nicht als ausgerollt; ein späterer erfolgreicher Rollout
ist hier nicht belegt. Portal-Assets allein beheben das nicht.
Diese Angaben sind datierte Nachweise, keine Live-Prüfung dieser Sitzung.
Die [öffentliche Statusübersicht](docs/public/versionen.md) erläutert Auswirkungen
und sichere Nachkontrolle. Alte Hinweise auf 2-kWh-Gruppen weiter unten gelten
historisch; bei positivem Restbudget ersetzt sie heute die schrittweise
Leistungsverteilung. Negative Defizitregelung bleibt separat unverändert.
## Änderungen vor dem nächsten nummerierten Release
- Ladestation Stand-Alone: Ladepausen und einzelne Nullwerte verriegeln das
Fahrzeug nicht mehr als geladen. Ladeende nur mit aktuellem eindeutigen
Geraetesignal; Pico hat kein solches Voll-Signal. go-e-Fehler, Pico-Status
@@ -79,3 +146,115 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
- Diese Version ist fuer kontrollierte Anlagen- und Feldtests bestimmt.
- Vor produktivem Einsatz sind Sicherung und anlagenspezifische Abnahme erforderlich.
- Korrekturen werden vorwaerts ueber `develop` und anschliessend `beta` verteilt.
## Quellnachweise der Funktionen und Korrekturen
Historische Commit-Titel, bei Anlagenbezug neutral zusammengefasst, enthalten
in der oben genannten Testing-/Beta-Quellbasis seit dem Stable-Grundstand. Ein späterer Commit kann früheres
Verhalten ersetzen; für die aktuelle Bedienung gelten die Modul-READMEs.
Die Liste dokumentiert Codeveröffentlichung, nicht Installation oder Feldabnahme.
| Commit | Änderung |
| --- | --- |
| [`5885681`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/5885681) | fix(charging): validate status and budget phase detection |
| [`372b997`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/372b997) | feat(forecast): integrate full forecast charts and complete SDL visualization |
| [`5687596`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/5687596) | feat(portal): prepare heat pump licensing and header navigation |
| [`9fbc52e`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/9fbc52e) | feat(manager): integrate forecast controls and merge V4 development |
| [`5437f3f`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/5437f3f) | fix(verbraucher): clear discarded power setpoints |
| [`ebec355`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/ebec355) | feat(manager): account for SDL power and SOC separately |
| [`58f91e4`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/58f91e4) | feat(manager): distribute equal-priority power stepwise |
| [`ec7182b`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/ec7182b) | fix(charger): stabilize Go-E setpoint control |
| [`37addf4`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/37addf4) | feat(manager): Energievisualisierung und Synchronisierung erweitern |
| [`ce525a1`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/ce525a1) | fix(v4): reset receiver throttle on reinit |
| [`e6aa50b`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/e6aa50b) | fix(v4): retry transient confirmed device reads |
| [`13e49ff`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/13e49ff) | feat(v4): confirm feedback with device reads |
| [`30f7652`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/30f7652) | Korrigiertes Forecast-Anlagenprofil in Betrieb nehmen (neutral zusammengefasst). |
| [`9878197`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/9878197) | feat(control): connect physical feedback to bounded V4 command and fallback paths |
| [`8eb9688`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/8eb9688) | feat(v4): consolidate audited data recovery economic replay and optional archival |
| [`6c91d6b`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/6c91d6b) | fix(accounting): stabilize numeric mapping identity across property storage |
| [`5890093`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/5890093) | fix(history): model sparse publication without relaxing live freshness |
| [`6d9aba7`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/6d9aba7) | fix(installer): avoid staged and installed capture class collision |
| [`fe3f46b`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/fe3f46b) | fix(release): include forecast test dependencies in versioned application |
| [`fc024fc`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/fc024fc) | feat(application): integrate measured-load ingestion training and planner source |
| [`44362e4`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/44362e4) | feat(observation): separate physical load and unfiltered EV-SDL model without dispatch |
| [`76f8b06`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/76f8b06) | feat(data): add passive versioned raw capture independent of control |
| [`e0e52d2`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/e0e52d2) | feat(accounting): separate physical storage and SDL with source-age validation |
| [`9394fe3`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/9394fe3) | feat(manager): add gated short-lease V4 control trial and local fallback |
| [`b5c1fa6`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/b5c1fa6) | feat(manager): receive and preview V4 shadow plans without dispatch |
| [`fc4c463`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/fc4c463) | feat(manager): transmit estimated meter samples and SOC recovery state |
| [`12174a5`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/12174a5) | fix(manager): isolate V4 active import sources from legacy counter |
| [`640fe53`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/640fe53) | feat(manager): add opt-in native V4 shadow operation telemetry |
| [`8d83b08`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/8d83b08) | feat(batterie): Nachladeleistung begrenzen |
| [`3ce9af4`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/3ce9af4) | fix(ladestation): Regeluebergaenge stabilisieren |
| [`38353ae`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/38353ae) | feat(manager): Fahrplanvorgabe linear interpolieren |
| [`cf92e76`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/cf92e76) | fix(manager): Fahrplanvorgabe linear interpolieren |
| [`add1077`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/add1077) | fix(manager): Batterie nach Prioritaet als Restverbraucher regeln |
| [`bfdcf45`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/bfdcf45) | fix(ladestation): Steuerbefehle in Diagnose erhalten |
| [`faa5dfb`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/faa5dfb) | feat(ladestation): Phasenerkennung und Schaltsperren erweitern |
| [`53fca5e`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/53fca5e) | feat(manager): Batteriesollwert aus Netzfahrplan anwenden |
| [`f611da9`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/f611da9) | fix(easee): Ereignisverbindung konsistent neu aufbauen |
| [`3939695`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/3939695) | fix(manager): Regelung und Energiefluss korrigieren |
| [`cae14cf`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/cae14cf) | fix(manager): Festlasten nach Istleistung bilanzieren |
| [`3aaa744`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/3aaa744) | fix(manager): Batterie als neutralen Restregler behandeln |
| [`fdc2007`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/fdc2007) | fix(manager): Energieausgleich aus Enelix 1 wiederherstellen |
| [`3740087`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/3740087) | fix(easee): veraltete Ladeleistung nach Ladestopp nullen |
| [`ace1821`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/ace1821) | fix(ladestation): Fahrzeugstrom nach Lernzeit begrenzen |
| [`b3b868c`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/b3b868c) | fix(ladestation): Ladeanlauf und Easee-Verbindung stabilisieren |
| [`a74f14a`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/a74f14a) | feat(warmwasser): Temperaturgrenzen konfigurierbar machen |
| [`b3bbae1`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/b3bbae1) | fix(manager): Anlagentopologie immer synchronisieren |
| [`995b704`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/995b704) | fix(manager): Prognoseberechtigungen verständlich diagnostizieren |
| [`5ff0d40`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/5ff0d40) | fix(manager): feste Leistungsangebote immer zuteilen |
| [`ccecc43`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/ccecc43) | fix(easee): wartende Ladevorgaenge starten |
| [`156591a`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/156591a) | fix(ladestation): Ladeende nach Update neu prüfen |
| [`e0814a0`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/e0814a0) | fix(ladestation): Ladeende nach Update neu prüfen |
| [`3cabf59`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/3cabf59) | fix(ladestation): Solarpause nicht als Ladeende erkennen |
| [`b86810d`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/b86810d) | fix(ladestation): Solarpause nicht als Ladeende erkennen |
| [`d1fc62d`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/d1fc62d) | fix(manager): Regelung bei veralteten Verbrauchern fortsetzen |
| [`21577c0`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/21577c0) | fix(manager): Prognoseberechtigungen verständlich diagnostizieren |
| [`aefe463`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/aefe463) | fix(easee): Wartezustand ohne Ausgangsphase zulassen |
| [`6f76e6c`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/6f76e6c) | fix(easee): Initialstatus der Ladestation laden |
| [`b111a5c`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/b111a5c) | Ladefreigabe für Ladestationen standardmäßig aktivieren |
| [`ef70856`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/ef70856) | feat: Wärmepumpenlizenz separat freischalten |
| [`cf2ce3a`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/cf2ce3a) | feat(manager): Lizenzierung und Energietopologie verfeinern |
| [`708a8ec`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/708a8ec) | feat(manager): Energieaufzeichnung und Visualisierungen integrieren |
| [`40f41a2`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/40f41a2) | fix(manager): Störungszugang zuverlässig aktivieren |
| [`2d24ce0`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/2d24ce0) | feat(manager): Störungsüberwachung ans Portal anbinden |
| [`f47c908`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/f47c908) | feat(batterie): stufenlose Leistungsbereiche unterstützen |
| [`72d96ae`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/72d96ae) | feat: Einspeisebegrenzung in Demo simulieren |
| [`dcf51bd`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/dcf51bd) | feat: anlagenweite Einspeisebegrenzung und Netzfahrplan |
| [`efa8fe6`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/efa8fe6) | feat: Prognosetelemetrie aus Demoanlage senden |
| [`f250dea`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/f250dea) | feat: Demoanlage an Prognosetopologie anbinden |
| [`1e64813`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/1e64813) | feat: Anlagentopologie mit Prognoseportal synchronisieren |
| [`dbd728d`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/dbd728d) | feat: Anlagentopologie im Manager abbilden |
| [`a67018e`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/a67018e) | fix: Waermepumpe ohne Vorgabe normal betreiben |
| [`0f1e0c9`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/0f1e0c9) | Installations-ID bei Manager-Updates erhalten |
| [`98ee569`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/98ee569) | feat: Demoanlage um Batterie und Tagesprofile erweitern |
| [`2392a9c`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/2392a9c) | feat: Lizenzkontingente im Manager durchsetzen |
| [`7845c66`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/7845c66) | feat: Wärmepumpenmodul integrieren |
| [`d730866`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/d730866) | feat: Batteriemodul ereignisbasiert integrieren |
| [`d03a7fd`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/d03a7fd) | Easee-Gateway und Ladestationsmodul implementieren |
| [`d804ba4`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/d804ba4) | Manager-Lizenzierung dokumentieren |
| [`3c71626`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/3c71626) | Verbrauchermodule und betriebsartabhängige Angebote ergänzen |
| [`7b6d1b2`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/7b6d1b2) | Manager-Lizenzierung integrieren |
| [`cf1ae5e`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/cf1ae5e) | Fix native demo tiles |
| [`3edf724`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/3edf724) | Use explicit demo timer action |
| [`abd8c7e`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/abd8c7e) | Enhance demo visualization and charts |
| [`312b76a`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/312b76a) | Add interactive Enelix demo system |
| [`5c5ef73`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/5c5ef73) | Verbraucher 1-Stufig um Mindestzeiten erweitern |
| [`49fe18c`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/49fe18c) | Wassererwaermer umfassend dokumentieren |
| [`0526854`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/0526854) | Automatische Verbrauchererkennung absichern |
| [`ecbd0a3`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/ecbd0a3) | Doppelte Verbraucher-Modul-ID entfernen |
| [`9598a28`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/9598a28) | Modulstatus und Anleitung vervollstaendigen |
| [`6c2d6dd`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/6c2d6dd) | Lastwechselsperre nur ueber Vertrag melden |
| [`d4828f1`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/d4828f1) | Verbraucher 1-Stufig eventbasiert implementieren |
| [`29cfd8c`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/29cfd8c) | Manager-Diagnose und Verbrauchersuche verbessern |
| [`92e8ec5`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/92e8ec5) | Leistungsangebot nach Lastwechsel sperren |
| [`355e716`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/355e716) | Lastwechsel ereignisbasiert sperren |
| [`1b4a788`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/1b4a788) | Manager-Konfiguration neu strukturieren |
| [`9e6a675`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/9e6a675) | Diagnosevariablen bedarfsgesteuert anlegen |
| [`4514ccb`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/4514ccb) | Wassererwaermer-Konfiguration vereinfachen |
| [`9869d99`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/9869d99) | Modulalias für Wassererwärmer bereinigen |
| [`98fb926`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/98fb926) | Modulhersteller und Anzeigenamen vereinheitlichen |
| [`7a3c436`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/7a3c436) | Verbraucher Warmwassererwaermer implementieren |
| [`0a3ece3`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/0a3ece3) | Enelix-2-Manager implementieren |
| [`578285b`](https://git.belevo.ch/ENELIX/Enelix-EMS/commit/578285b) | Obere Manager-Anschlüsse dokumentieren |
+7
View File
@@ -9,3 +9,10 @@ Konfiguration, Betrieb und Fehlerbehandlung:
Kind-Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
## Vollständige Referenz und Kanalstand
Alle Properties, Laufzeitvariablen, Einheiten, Beispiele und bekannten Grenzen
stehen in der [Modulreferenz](../docs/module/Easee-Gateway/README.md). Der beschriebene
Funktionsstand liegt in Testing und Beta; Stable enthält noch keine
installierbaren Module. [Kanalvergleich und Fehlerstatus](../README.md).
+7
View File
@@ -9,3 +9,10 @@ Konfiguration, Betriebsarten und Tests:
Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
## Vollständige Referenz und Kanalstand
Alle Properties, Laufzeitvariablen, Einheiten, Beispiele und bekannten Grenzen
stehen in der [Modulreferenz](../docs/module/Ladestation-Gateway/README.md). Der beschriebene
Funktionsstand liegt in Testing und Beta; Stable enthält noch keine
installierbaren Module. [Kanalvergleich und Fehlerstatus](../README.md).
+10
View File
@@ -0,0 +1,10 @@
# LadestationStandAlone
IP-Symcon-Modul für Enelix EMS ab Kernelversion 8.0.
## Vollständige Referenz und Kanalstand
Alle Properties, Laufzeitvariablen, Einheiten, Beispiele und bekannten Grenzen
stehen in der [Modulreferenz](../docs/module/Ladestation-Stand-Alone/README.md). Der beschriebene
Funktionsstand liegt in Testing und Beta; Stable enthält noch keine
installierbaren Module. [Kanalvergleich und Fehlerstatus](../README.md).
+10
View File
@@ -0,0 +1,10 @@
# Manager
IP-Symcon-Modul für Enelix EMS ab Kernelversion 8.0.
## Vollständige Referenz und Kanalstand
Alle Properties, Laufzeitvariablen, Einheiten, Beispiele und bekannten Grenzen
stehen in der [Modulreferenz](../docs/module/Manager/README.md). Der beschriebene
Funktionsstand liegt in Testing und Beta; Stable enthält noch keine
installierbaren Module. [Kanalvergleich und Fehlerstatus](../README.md).
+60 -145
View File
@@ -17,6 +17,7 @@ require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4TestTrait.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4AktivtestTrait.php';
require_once __DIR__ . '/../libs/ManagerPrognoseFormular.php';
require_once __DIR__ . '/../libs/ManagerPrognoseWorkflowTrait.php';
use Belevo\EnelixEMS\Anlagentopologie;
use Belevo\EnelixEMS\EinspeiseRegler;
@@ -36,6 +37,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4Trait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4TestTrait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4AktivtestTrait;
use \Belevo\EnelixEMS\ManagerPrognoseWorkflowTrait;
private const STATUS_AKTIV = 102;
private const STATUS_MESSWERT_UNGUELTIG = 201;
@@ -44,7 +46,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
private const PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology';
private const PROGNOSE_TELEMETRIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry';
private const PROGNOSE_FAHRPLAN_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule';
private const STOERUNGS_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/faults';
private const LIZENZ_RETRY_SEKUNDEN = 3600;
private const STOERUNGS_RETRY_MAX_SEKUNDEN = 900;
@@ -130,6 +131,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->registriereNetzfahrplanV4();
$this->registriereV4ManagerTest();
$this->registriereV4ManagerAktivtest();
$this->registrierePrognoseWorkflow();
$this->RegisterPropertyInteger('Rolle', 0);
$this->RegisterPropertyInteger('NetzleistungVariableID', 0);
@@ -148,7 +150,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyFloat('Umschaltdifferenz', 5.0);
$this->RegisterPropertyBoolean('EinspeisebegrenzungAktiv', false);
$this->RegisterPropertyFloat('Einspeisegrenze', 0.0);
$this->RegisterPropertyBoolean('PrognoseAktiv', false);
$this->RegisterPropertyBoolean('PrognoseAktiv', true);
$this->RegisterPropertyBoolean('NetzfahrplanAktiv', false);
$this->RegisterPropertyString('AnlagenWechselrichter', '[]');
$this->RegisterPropertyString('AnlagenPVFlaechen', '[]');
@@ -274,7 +276,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->stoppeNetzfahrplanV4Vorschau();
$this->initialisiereLizenzInstallationID();
$this->konfiguriereNetzfahrplanV4Daten();
$this->vorbereitePrognoseWorkflow();
$this->aktualisiereVariablen();
foreach (ManagerPrognoseFormular::laufzeitAnzeige() as $ident => $name) {
$objektID = $this->GetIDForIdent($ident);
@@ -336,11 +338,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return;
}
$this->SetStatus(self::STATUS_AKTIV);
if ($this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
$this->konfiguriereNetzfahrplanV4Daten();
if ($this->prognoseLernenAktiv()) {
$this->SetTimerInterval('NetzfahrplanV4Senden', 90000);
}
$this->konfiguriereNetzfahrplanV4Vorschau();
$this->synchronisiereAnlagentopologie();
$this->aktualisierePrognoseWorkflow();
if ($this->prognoseTelemetrieKonfiguriert()) {
$this->SetTimerInterval(
'PrognoseSenden',
@@ -539,14 +543,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if (!is_bool($wert)) {
throw new InvalidArgumentException('Netzfahrplan-Schaltung muss ein boolescher Wert sein.');
}
// The saved property is the only normal control request; old test buttons
// cannot start a session through this action any longer.
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();
throw new RuntimeException('Netzfahrplan noch nicht bereit. Status im Prognosebereich pruefen.');
}
$this->UpdateFormField('NetzfahrplanRegelungAktiv', 'value', $wert);
return;
case 'PrognoseWorkflow':
$this->aktualisierePrognoseWorkflow();
return;
case 'NetzfahrplanV4DatenErfassen':
@@ -581,12 +587,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->prognoseLizenzStatustext($aktiv, $lizenziert)
);
$this->UpdateFormField(
'NetzfahrplanAktiv',
'NetzfahrplanRegelungAktiv',
'enabled',
$aktiv && $netzfahrplanLizenziert && $this->peakShavingLizenziert()
$aktiv && $netzfahrplanLizenziert && $this->prognoseWorkflowZustand()['ready']
);
if (!$aktiv) {
$this->UpdateFormField('NetzfahrplanAktiv', 'value', false);
$this->UpdateFormField('NetzfahrplanRegelungAktiv', 'value', false);
}
$this->aktualisierePrognoseBedienung($aktiv);
return;
@@ -896,10 +902,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
$this->regleEinspeisung($netzleistung, $stoerungen, $erneuern);
$fahrplanVorgabe = $this->aktuelleNetzfahrplanVorgabe();
$zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK
? (float) $lastspitzengrenze
: ($fahrplanVorgabe ?? $this->ReadPropertyFloat('SollwertSolarladen'));
: $this->ReadPropertyFloat('SollwertSolarladen');
$ergebnis = ManagerRegler::verteile(
$netzleistung,
$zielnetzleistung,
@@ -923,20 +928,46 @@ class Manager extends IPSModule implements ManagerSchnittstelle
);
$this->v4ManagerTestUebergabe($ergebnis, $verbraucher, $synchronisiert);
$nativeSollwerte = $ergebnis['Sollwerte'];
$v4Report = $this->korrigierePrognoseBatterie($ergebnis, $verbraucher, $synchronisiert);
if ($v4Report !== null) {
// The correction anticipates other targets. Send those first; a failed
// handoff must not be compensated as though the other load had changed.
$batteryID = $v4Report['batteryInstanceId'];
$corrected = $ergebnis['Sollwerte'][$batteryID];
unset($ergebnis['Sollwerte'][$batteryID]);
$ergebnis['Sollwerte'][$batteryID] = $corrected;
}
$v4Sent = false;
$sendFailed = false;
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
$v4Battery = $v4Report !== null && (int) $instanzID === $v4Report['batteryInstanceId'];
if ($v4Battery && $sendFailed) {
$sollleistung = $nativeSollwerte[$instanzID];
$ergebnis['Sollwerte'][$instanzID] = $sollleistung;
$v4Report['fallbackTargetW'] = $sollleistung;
}
if (!$erneuern && !$v4Battery && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
continue;
}
try {
$this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung);
$letzteSollwerte[(string) $instanzID] = $sollleistung;
if ($v4Battery && !$sendFailed) $v4Sent = true;
} catch (Throwable $fehler) {
$sendFailed = true;
$this->v4ManagerTestAbbrechen('Andere Verbraucher-Stellwertuebergabe fehlgeschlagen');
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
}
}
$this->WriteAttributeString('LetzteSollwerte', json_encode($letzteSollwerte, JSON_THROW_ON_ERROR));
if ($v4Report !== null) {
$this->WriteAttributeString('PrognoseV4Regelstatus', json_encode($v4Report + [
'at' => time(), 'controlActive' => $v4Sent && !$sendFailed,
'reason' => $sendFailed ? 'Stellwertuebergabe fehlgeschlagen.' : 'Batterievorgabe an den lokalen Regler uebergeben.',
], JSON_THROW_ON_ERROR));
}
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
$this->SetStatus(self::STATUS_AKTIV);
@@ -1567,7 +1598,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'Sollleistung_W' => $sollleistung,
];
Nachrichtenvertrag::pruefeManagerdaten($paket);
IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR));
if (IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR)) === false) {
throw new RuntimeException('Verbraucher hat die Managerdaten nicht angenommen.');
}
$this->protokolliere('Managerdaten', [
'InstanzID' => $instanzID,
'Betriebsart' => $betriebsart,
@@ -2595,19 +2628,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
) {
return false;
}
if ($this->ReadPropertyBoolean('PrognoseAktiv')
&& !$this->prognoseBerechtigungInLease($lease)
) {
return false;
}
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')
&& Lizenzpruefung::berechtigungsMenge(
$lease,
Lizenzpruefung::NETZFAHRPLAN
) < 1
) {
return false;
}
// Forecast entitlement gates only the forecast workflow. A missing or
// expired forecast option must not disable licensed local protection.
try {
[$mengen] = $this->verbraucherLizenzbelegung(
@@ -2688,17 +2710,17 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->prognoseLizenzStatustext($aktiv, $lizenziert)
);
$this->UpdateFormField(
'NetzfahrplanAktiv',
'NetzfahrplanRegelungAktiv',
'enabled',
$aktiv && $netzfahrplanLizenziert
&& $this->peakShavingLizenziert($lizenzcode)
$this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv')
|| ($aktiv && $netzfahrplanLizenziert && $this->prognoseWorkflowZustand()['ready'])
);
$this->aktualisierePrognoseBedienung(null, $lizenzcode);
}
private function prognoseBedienzustand(?bool $aktiv = null, ?string $lizenzcode = null): array
{
return [
return $this->prognoseWorkflowZustand() + [
'prognoseAktiv' => ($aktiv ?? true) && $this->ReadPropertyBoolean('PrognoseAktiv'),
'lizenziert' => $this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN, $lizenzcode),
'senderAktiv' => $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv'),
@@ -2719,9 +2741,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private function aktualisierePrognoseBedienung(?bool $aktiv = null, ?string $lizenzcode = null): void
{
$zustand = $this->prognoseBedienzustand($aktiv, $lizenzcode);
$this->UpdateFormField('PrognoseNetzfahrplanStart', 'enabled', ManagerPrognoseFormular::startErlaubt($zustand));
$this->UpdateFormField('NetzfahrplanRegelungAktiv', 'enabled', $zustand['controlRequested'] || 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
@@ -2926,22 +2947,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if ($this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) {
$this->regelbareWechselrichter();
}
if ($this->ReadPropertyBoolean('PrognoseAktiv')
&& !$this->prognoseTelemetrieKonfiguriert()
) {
throw new InvalidArgumentException(
'Aktive Prognosen benoetigen Netzleistung, PV-Leistung und Batterie-Ladezustand.'
);
}
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')
&& (!$this->ReadPropertyBoolean('PrognoseAktiv')
|| !$this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')
|| !$this->prognoseTelemetrieKonfiguriert())
) {
throw new InvalidArgumentException(
'Der intelligente Netzfahrplan benoetigt Einspeisebegrenzung und Prognosetelemetrie.'
);
}
// Forecast onboarding errors are reported separately and never disable
// the existing local manager or turn on a legacy schedule.
$this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
@@ -2990,34 +2997,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private function prognoseTelemetrieKonfiguriert(): bool
{
return $this->ReadPropertyBoolean('PrognoseAktiv')
return $this->prognoseLernenAktiv()
&& $this->messquellenKonfiguriert()
&& $this->aktuelleBatterieSoc() !== null;
}
private function aktuelleNetzfahrplanVorgabe(): ?float
{
if (!$this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
return null;
}
$fahrplan = $this->leseJsonAttribut('Netzfahrplan');
if (($fahrplan['status'] ?? null) !== 'available'
|| !is_array($fahrplan['points'] ?? null)
|| !is_string($fahrplan['validUntil'] ?? null)
) {
return null;
}
$gueltigBis = strtotime($fahrplan['validUntil']);
if ($gueltigBis === false || $gueltigBis <= time()) {
return null;
}
return ManagerRegler::interpolierteFahrplanVorgabe(
$fahrplan['points'],
time()
);
}
private function synchronisiereAnlagentopologie(): void
{
try {
@@ -3157,9 +3141,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'values' => $werte,
];
$this->uebertragePrognoseTelemetrie($installationID, $nutzlast, $token);
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
$this->aktualisiereNetzfahrplan($installationID, $token);
}
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
} catch (Throwable $fehler) {
$meldung = substr($fehler->getMessage(), 0, 500);
@@ -3168,72 +3149,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
}
private function aktualisiereNetzfahrplan(string $installationID, string $token): void
{
$url = sprintf(
self::PROGNOSE_FAHRPLAN_ENDPOINT,
rawurlencode($installationID)
) . '?' . http_build_query([
'exportLimitW' => $this->ReadPropertyFloat('Einspeisegrenze'),
]);
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Der Netzfahrplan konnte nicht vorbereitet werden.');
}
try {
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: Bearer ' . $token,
],
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
]);
$antwort = curl_exec($curl);
if ($antwort === false) {
throw new RuntimeException('Netzfahrplan nicht erreichbar: ' . curl_error($curl));
}
if (strlen($antwort) > 524288) {
throw new RuntimeException('Die Antwort des Netzfahrplans ist zu gross.');
}
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
} finally {
curl_close($curl);
}
if ($httpStatus === 401) {
$this->WriteAttributeString('PrognoseInstallationsToken', '');
}
if ($httpStatus !== 200) {
$meldung = 'Unbekannter Fehler';
try {
$fehlerdaten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (is_array($fehlerdaten) && is_string($fehlerdaten['error'] ?? null)) {
$meldung = $fehlerdaten['error'];
}
} catch (Throwable $fehler) {
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
}
throw new RuntimeException(
'Netzfahrplan HTTP ' . $httpStatus . ': ' . $meldung
);
}
$fahrplan = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($fahrplan)
|| ($fahrplan['schemaVersion'] ?? null) !== 1
|| !is_array($fahrplan['points'] ?? null)
) {
throw new RuntimeException('Der Netzfahrplan hat ein ungueltiges Format.');
}
$this->WriteAttributeString(
'Netzfahrplan',
json_encode($fahrplan, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
}
/** @param array<string, mixed> $nutzlast */
private function uebertragePrognoseTelemetrie(
+10
View File
@@ -0,0 +1,10 @@
# Pufferspeicher
IP-Symcon-Modul für Enelix EMS ab Kernelversion 8.0.
## Vollständige Referenz und Kanalstand
Alle Properties, Laufzeitvariablen, Einheiten, Beispiele und bekannten Grenzen
stehen in der [Modulreferenz](../docs/module/Pufferspeicher/README.md). Der beschriebene
Funktionsstand liegt in Testing und Beta; Stable enthält noch keine
installierbaren Module. [Kanalvergleich und Fehlerstatus](../README.md).
+50
View File
@@ -4,6 +4,20 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status
**Dokumentationsabgleich: 8. Oktober 2026.** Funktionsstände vor dieser reinen
Dokumentationsänderung:
| Kanal | Quellbasis | Tatsächlicher Umfang |
| --- | --- | --- |
| Testing / `develop` | `5885681` | Alle unten genannten Module, neue Ladestatus-/Phasenprüfung, Forecast-/SDL-Integration und begrenzter V4-Testpfad. |
| Beta / `beta` | `5885681` | Gleicher veröffentlichter Quellstand wie Testing; keine zusätzliche Anlagenabnahme daraus ableiten. |
| Stable / `main` | `b6253f9` | Nur Bibliotheksgrundlage und Nachrichtenvertrag 3.0, noch keine installierbaren EMS-Module. |
**Stable enthält die nachfolgend beschriebenen Module noch nicht.** Diese
README beschreibt den Testing-/Beta-Stand. Ein Dokumentationscommit nach der
genannten Quellbasis ändert weder Modulverhalten noch Freigabestatus.
Die kanalgenaue Referenz liegt jeweils im gleichnamigen Git-Branch.
Version 0.1 Build 1 ist die erste Beta fuer kontrollierte Anlagen- und
Feldtests. Der gemeinsame Nachrichtenvertrag, die Verbraucherbasis, der Manager
und alle unten aufgefuehrten Module sind implementiert. Produktiver Einsatz
@@ -78,6 +92,7 @@ Alternativen und Folgen beschreibt
```bash
composer install
composer check
python3 tests/check_documentation.py
```
Der Gesamttest schliesst alle Modulsuiten ein. Der einstufige Verbraucher kann
@@ -101,6 +116,10 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
## Dokumentation
- [Versionen, Neuerungen und bekannte Grenzen](docs/public/versionen.md)
- [Öffentliche Dokumentation auf license.enelix.ch](https://license.enelix.ch/docs/index.html)
- [Vollständige Feld- und Modulreferenzen](docs/module/README.md)
- [Changelog](CHANGELOG.md)
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
@@ -121,3 +140,34 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
- [Migration Batterie](docs/migration/Batterie.md)
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
- [Modulübersicht](docs/module/README.md)
## Neuerungen und Fehlerstatus
Die aktuellen Erweiterungen umfassen schrittweise Leistungsverteilung bei
gleicher Priorität, separate SDL-Bilanzierung mit SOC, erweiterte Energiefluss-
und Diagrammdarstellung sowie eine zusammengeführte Prognosebedienung. Die
vollständige ausgewählte Eingangsprognose wird von dem durch veröffentlichte
Preise begrenzten ausführbaren Plan unterschieden. Eine Prognose ist kein
Nachweis einer realen Einsparung.
Behoben wurden unter anderem veraltete Sollwertanzeigen nach dem Verwerfen einer
Vorgabe, Go-e-Regelübergänge, Easee-Wiederverbindung und stehenbleibende Ladeleistung,
falsch erkannte Ladeenden während Solarpausen, veraltete Pico-Statusdaten,
unbudgetierte Phasenproben, Festlastbilanzierung und mehrere
V4-Empfangs-/Messdatenprobleme. Die belegten Korrekturen und ihre Git-Nachweise
stehen im [Changelog](CHANGELOG.md).
**Weiter offen:** V4-Dauerbetriebsfreigabe und physische Abnahme, Ursache der
wiederholt beobachteten Batterietimerblockade nach Modul-Reload sowie der separat
zu bestätigende Forecast-Backend-Rollout. Am 7. Oktober wurden laufende
periodische Timer dokumentiert; der historische Stillstand ist damit kein
behaupteter aktueller Zustand. Diese Dokumentationsarbeit ist keine Live-Prüfung.
Die frühere V4-Testfreigabe ist seit 7. Oktober abgelaufen und wird nicht erneuert.
Bei alten Plänen können vollständige Forecast-
Kurven fehlen; fehlende Werte bleiben Lücken. Signierte produktive Lizenz-
Leases und die Prüfung der Untermanager-Messabgrenzung bleiben offene Grenzen.
Vor SDL-Visualisierungsupdates zuerst Enelix Utils aktualisieren. Bestehende
Zähler, Quellen, individuelle Diagramme und Schutzfreigaben erhalten.
`EnergieanteileBerechenbar=false` bedeutet nicht bestimmbare Anteile, nicht 0 %.
Git-Veröffentlichung und Portal-Dokumentation aktualisieren keine Symcon-Anlage.
+7
View File
@@ -170,3 +170,10 @@ gleichzeitig Bestandteil des allgemeinen Tests.
- [Manager-Verbraucher-Schnittstelle](../docs/Schnittstelle.md)
- [Migration von Enelix 1](../docs/migration/Verbraucher-1-Stufig.md)
- [Aufbau der separaten Testsuite](../tests/VerbraucherEinStufig/README.md)
## Vollständige Referenz und Kanalstand
Alle Properties, Laufzeitvariablen, Einheiten, Beispiele und bekannten Grenzen
stehen in der [Modulreferenz](../docs/module/Verbraucher-1-Stufig/README.md). Der beschriebene
Funktionsstand liegt in Testing und Beta; Stable enthält noch keine
installierbaren Module. [Kanalvergleich und Fehlerstatus](../README.md).
+10
View File
@@ -0,0 +1,10 @@
# Waermepumpe
IP-Symcon-Modul für Enelix EMS ab Kernelversion 8.0.
## Vollständige Referenz und Kanalstand
Alle Properties, Laufzeitvariablen, Einheiten, Beispiele und bekannten Grenzen
stehen in der [Modulreferenz](../docs/module/Waermepumpe/README.md). Der beschriebene
Funktionsstand liegt in Testing und Beta; Stable enthält noch keine
installierbaren Module. [Kanalvergleich und Fehlerstatus](../README.md).
+10
View File
@@ -0,0 +1,10 @@
# Warmwassererwaermer
IP-Symcon-Modul für Enelix EMS ab Kernelversion 8.0.
## Vollständige Referenz und Kanalstand
Alle Properties, Laufzeitvariablen, Einheiten, Beispiele und bekannten Grenzen
stehen in der [Modulreferenz](../docs/module/Wassererwaermer/README.md). Der beschriebene
Funktionsstand liegt in Testing und Beta; Stable enthält noch keine
installierbaren Module. [Kanalvergleich und Fehlerstatus](../README.md).
+155
View File
@@ -1,5 +1,15 @@
# ENELIX Netzfahrplan V4 – verbindlicher Gesprächs- und Arbeitskontext
## Veroeffentlichungsauftrag 08.10.2026: gemeinsamer Stand
Daniel Haefliger hat den aktuellen Stand inklusive Batterie, Ladestationen,
Energy Pie und weiterer unveroeffentlichter Projektarbeit fuer develop und beta
freigegeben: Autor/Committer dh_Agent <dh@belevo.ch>, Git-Zugang dh.
Dies ist weder eine Anlageninstallation noch eine neue Stell-/Testfreigabe.
Aktuellen Veroeffentlichungsnachweis in docs/RELEASE_20261008.md und Remote-Refs pruefen.
Die folgenden Betriebsbeobachtungen sind datierte Historie, keine Live-Nachkontrolle
dieses Releaseauftrags. Neuere Timer-/Ladestationskorrekturen bleiben enthalten.
## Fortsetzung 06.10.2026, 17:32 UTC: Forecast/SDL installiert, Betriebsblocker
Massgeblicher neuer Bericht: `docs/FORECAST_SDL_RELEASE_20261006.md`.
@@ -404,3 +414,148 @@ EMS `examples/V4CorrectedFeedback/`, `examples/V4UnifiedRelease/`, `services/net
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.“
## Ergaenzte historische Server-Uebergaben
## Neuer Stand 06.10.2026, 17:41 UTC: Forecast/SDL, erneute Timerblockade
Der neuere Bericht `docs/FORECAST_SDL_RELEASE_20261006.md` ist massgeblich.
EMS 372b997876a2faa1ff563b6e004f9c864ed2504b und Utils
5b6125d0862a39fe93e1479d4cc5424df0168491 sind auf develop UND beta bestaetigt.
Autor dh_Agent, technisches Git-Konto dh, ausdruecklicher Auftrag Daniel.
Dieser alte divergente Arbeitsordner wurde NICHT ueberschrieben oder bereinigt.
Portal-Assets live; Backend-Image noch nicht ausgerollt (Dockerrechte fehlen).
Testanlage aktualisiert, SDL-Anzeigen eingerichtet, Diagnosen verborgen.
WICHTIG: Nach dem Modul-Reload haengt alter Batterie-Timer 70 erneut; neue
Sampler-/Meldetimer laufen nicht. Die untenstehende Entwarnung um 14:35 UTC
gilt nicht mehr. Neuer kontrollierter Symcon-Neustart wurde bei Daniel angefragt.
Netzfahrplan-Schalter 46716 bleibt false. Keine unbefristete Freigabe oder
Umgehung von Timern/Hardware-Schutz. Temporaere Hooks wieder entfernt.
## Nachkontrolle 06.10.2026, 14:35 UTC: Batterietimer laufen nach Neustart
Dieser neuere Laufzeitnachweis ersetzt die unten dokumentierte weiterhin offene
Timerblockade und den damaligen Nullauftrag, nicht die verbleibenden V4-Abnahmeanforderungen.
- Daniel hat den kontrollierten Symcon-Neustart nach Sicherung ausdruecklich freigegeben. Der Agentversuch wurde mit `Interactive authentication required` abgewiesen. Nach manueller Uebergabe meldete Daniel die Anlage wieder als laufend; `ActiveEnterTimestamp` bestaetigt 06.10.2026, 16:32:09 Europe/Zurich. Keine Berechtigungsumgehung.
- Gepruefte Sicherung vor dem Versuch: Settings und 147 EMS-Quelldateien unter `/srv/agent/symcon-restart-20261006/backup` auf iot-symcon01, Verzeichnis 0700 und Dateien 0600. Keine Zugangsdaten ausgegeben oder in das Repository uebernommen.
- Rein lesende Kernel-Nachkontrolle 14:34:11 bis 14:35:11 UTC, 13 Stichproben: `Meldezyklus`, `NetzfahrplanV4RueckmeldungAktualisieren` und `RueckmeldungVerzoegert` zeigen fortlaufend steigende `LastRun`-Werte. Managerstoertext leer; Verbrauchermeldung `Alle aktiven Verbraucher werden geregelt.` Kernel weiterhin 8.0.
- Der separate V4-Abschnitt im Batterieformular ist entfernt und im laufenden Kernel als abwesend bestaetigt. Die zugehoerige Konfiguration und Backend-Methoden bleiben erhalten; zuvor 12 isolierte Formularvarianten erfolgreich geprueft.
- Konfigurationshashes von Manager 17004, Batterie 44234 und Gateway 58448 sind vor/nach dem Neustart gleich. V4-Schalter bleibt AUS, V4-Batteriestatus `stopped`. Der bereits vor dem Neustart eingeschaltete ALTE Netzfahrplan bleibt EIN. Die normale Regelung gibt wieder EV-Leistung vor; letzter Snapshot 21055 W EV / 0 W SDL. Das ist KEIN V4-Stellversuch und KEINE physische Tracking-Abnahme.
- Nachweis: `/srv/agent/symcon-restart-20261006/verification-result.json`, `passed=true`. Die aeltere `result.json` dokumentiert bewusst den zuvor abgewiesenen Agent-Neustart. `after.jsonl` enthaelt beide Beobachtungsphasen; `verification-start.json` grenzt die echte Neustart-Nachkontrolle ab.
- Temporaere rein lesende Beobachtung wieder entfernt; 12555.ips.php hat erneut SHA-256 `c702a4947ae200b23c3eecccabeb843ad6a55e97509292584ca2de779d5e4940`. Kein externer Timerersatz, kein Aktivstart und keine neue Testfreigabe hinterlassen.
- Der akute Timerstillstand ist behoben; seine urspruengliche Ursache ist damit nicht nachgewiesen. Der V4-Testwatchdog war mangels aktiver V4-Sitzung deaktiviert und wurde NICHT erprobt. Physisches V4-Tracking, >90-s-Planwechsel und Watchdog-/Nullstopp-Abnahme bleiben offen. Bestehendes Testende 07.10.2026, 17:38:07 Europe/Zurich unveraendert.
- Keine Modulquelle in dieser Nachkontrolle geaendert. Server-89-develop nach Fetch weiterhin divergent und mit bestehenden lokalen Aenderungen; kein Fast-Forward, Commit, Merge oder Push. Nur diese datierte Uebergabe ergaenzt.
## 16. Nachtrag 07.10.2026: Ladeunterbrechungen und Ladebedienung
- Auftrag Daniel: Morgenbeschwerde zu Pico und go-e untersuchen, bekannte Bedienung inklusive Boost fuer alle fuenf Ladepunkte wiederherstellen. Keine V4-/SDL-Freigabe und kein Auftrag fuer einen pauschalen Ladealgorithmus-Rollout.
- Laufzeit iot-symcon01 geprueft: Symcon 8.0, seit 06.10. 16:32 CEST gestartet; aktuelle Instanzen und Timer aktiv. Neue stufenweise Manager-Verteilung ist installiert (Git-Blob libs/ManagerRegler.php: 36cf412b8b0d5ee6652c141095261dd4fb6bb3f7). Keine alten Timerblockaden als heutigen Zustand darstellen.
- Archiv 07.10. bis ca. 19:05 CEST: Pico 1 ohne Energiezuwachs; Pico 2 ca. 0.231 kWh, go-e Ladesaeule ca. 0.295 kWh, rechte go-e ca. 6.417 kWh, Easee ca. 31.611 kWh. Pico 2 und go-e zeigen kurze Ladephasen und Stopps. Exakte Nutzer-/Fahrzeugzuordnung der Beschwerde fehlt. Keine behauptete gemeinsame Hardwareursache.
- Zentrale Portal-Auswertung: 325 Vorkommen, davon 315 Peak/PV-Angebotskonflikte (35 Gruppen x 9 Verbraucher), daneben zehn Kommunikations-/Statusvorkommen. Vollstaendige lokale Tageslogs fehlen: Dateilog abgeschaltet, Modul-Logging aus, Journal mit Agentkonto nicht lesbar. Keine vollstaendige native Zustands-/Bedienhistorie. Direkter Pico-1-Status ca. 19:07 CEST: frische Daten, 32 A erlaubt/dynamisch, 0 W/0 A. Ursache am Fahrzeug/Autorisierung/Ladeweg noch offen.
- Aktueller Modulcode: fuenf isolierte Reproduktionen bestaetigen latchende Voll-Erkennung nach kurzem Niedrigstromwert, nicht ausgewertete go-e-Fehler, nicht gepruefte Pico-Datenfrische/Fehler, 0-W-Phasentest ergibt eine Phase, gespeicherte eine Phase ueberstimmt neue Dreiphasen-Rueckmeldung. Das sind aktuelle Codebefunde, keine bewiesene Rekonstruktion des Morgenablaufs. Keine dieser Modulschwaechen hier behoben.
- Installation 19:17:31 CEST: /srv/agent/lihrenmoos-charger-ui. Root 16447, Aktionsskript 46103. Zuordnung Instanz/Gruppe/Boost: 22078/31363/16854; 31986/29211/47015; 27189/42489/28906; 43878/22180/50715; 46052/22895/27030. Vorhandene fuenf Links unter 14536 umgestellt; native Variablen nur verlinkt. Keine alten Module reaktiviert.
- Boost bildet die fruehere Prioritaetslogik ab: PrioritaetPeak=0, vorherigen Wert sichern und bei Aus/erkanntem Abstecken wiederherstellen. Solarladen bleibt separat. Spaetere Boost-Bedienung verwendet gezieltes ApplyChanges der Station. Installer selbst hat keine Instanz angewendet, keine Ladebefehle gesendet und keine Stromgrenzen/V4/SDL-Einstellungen geaendert. Urspruenglicher Beobachterskript-Inhalt 12555 bytegleich wiederhergestellt (SHA256 c702a4947ae200b23c3eecccabeb843ad6a55e97509292584ca2de779d5e4940).
- Nachweis: install-result.json status=installed, configurationUnchanged=true; 38 isolierte Bedienungstests und 66 gespeicherte Zustandspruefungen erfolgreich. settings.json schreibt zeitverzoegert; erste Nachkontrolle war deshalb noch unvollstaendig, spaetere Pruefung vollstaendig erfolgreich. README.md und before.json dokumentieren Wiederanlauf/Rueckbau. rollback.php nur syntaktisch geprueft, nicht ausgefuehrt. Kein echter WebFront-Bildschirmtest und kein Fahrzeug-Ladeversuch.
- Git: kein neuer Codecommit, kein Commit/Push. Bestehende lokale Aenderungen erhalten. origin/develop am Abschluss erfolgreich gefetcht; wegen zahlreicher sich mit Remote-Aenderungen ueberschneidender lokaler Arbeiten nicht automatisch integriert. Dieser Nachtrag ist nur angehaengt.
- Naechster konkreter Schritt: Pico 1 mit bekanntem Fahrzeug und zeitgleichem Zustands-/Sollstrom-/Fehlertrace pruefen; danach falsche Voll-Erkennung sowie Phasen-/Fehlerauswertung gezielt korrigieren, regressionspruefen und kontrolliert ausrollen. Keine Behauptung, die Ladeunterbrechungen seien durch die UI-Wiederherstellung behoben.
## Ladestationskorrekturen veroeffentlicht: 07.10.2026, 18:26 UTC
- Auftraggeber Daniel Haefliger hat Fehlerkorrekturen sowie Commit/Push auf develop und beta mit Autor dh_Agent und Konto dh ausdruecklich freigegeben. Kein Anlagenrollout daraus abgeleitet.
- Repository ENELIX/Enelix-EMS, Commit `58856816697e4720a57cac046ec1793c1325576b`, `fix(charging): validate status and budget phase detection`. Autor und Committer `dh_Agent <dh@belevo.ch>`. Konfigurierter Login dh und authentifizierte Repository-Pushberechtigung technisch geprueft; Profil-Endpunkt wegen Token-Scope nicht verfuegbar. Atomarer normaler Fast-Forward-Push erfolgreich; anschliessendes ls-remote bestaetigt develop UND beta auf diesem Commit. main blieb `b6253f9e55c659913ebb43783fe2b6a72cfd180f`.
- Die schmutzigen kanonischen Arbeitsverzeichnisse wurden erhalten. Entwicklung im sauberen develop-Checkout `/srv/agent/charger-fixes-20261007` auf ubuntu. Zehn eigene Dateien committed; nur die dort beim Test erzeugte composer.lock bleibt untracked und wurde nicht veroeffentlicht. Der vorhandene Pre-Push-Schutz wird ueber core.hooksPath weiterverwendet; einmalige beta-Freigabe ist verbraucht und nicht mehr gesetzt.
- Stand-alone-Korrekturen: kein verriegeltes Ladeende aus einzelnen Null-/Niedrigwerten; go-e err/car validiert; Pico-Zustaende und echte Kontakt-/Messzeitstempel geprueft. Pico LastWarningOrError ist nur historische Diagnose und Pico hat kein eindeutiges Voll-Signal. Leerlauf-ValueDate 0001 ist nur mit frischem LastSeen und ohne Ladeleistung zulaessig. Managerdaten koennen defekten/abgelaufenen Geraetecache nicht erneuern oder Fehler wegdruecken.
- Die 6-A-Phasenprobe nutzt bei zugeordnetem Manager das normale Angebot [0,4104] und braucht Budget; kein direkter Probebefehl aus dem Lesepfad. Freigaben und Solar/Peak gelten auch waehrend der Probe. 0 W ergibt keine Einphasigkeit. Frische Dreiphasenmessung korrigiert alte Einphasigkeit; bestaetigte drei Phasen werden bis zum Ausstecken nicht aus einer einzelnen belasteten Phase heruntergestuft. Migration der Erkennungsversion 2 verwirft alte unsichere Phasenwerte einmalig, ohne Bedienfreigaben zu aendern.
- Verifiziert: composer check mit PHP-Syntaxpruefung und 402 Unit-/Struktur-/isolierten Laufzeittests, 1875 Assertions; zusaetzlich 92 reine Adapter-/Reglerpruefungen in eigenem Namespace im Symcon-8.0-Entwicklungskernel. git diff --check erfolgreich. Native vollstaendige Modul-Funktionstests angepasst, aber nicht ausgefuehrt. Kein Hardwaretest, keine Stellbefehle an die Anlage, keine Feldabnahme. Testhelfer `/srv/agent/charger_kernel_regression_20261007.py` auf ubuntu.
- VEROEFFENTLICHT IST NICHT INSTALLIERT: Lihrenmoos wurde in diesem Korrekturauftrag nicht aktualisiert. Die zuvor eingerichtete Bedienung der fuenf Stationen bleibt unveraendert. V4, Batterie, SDL und die zeitlich befristeten Freigaben wurden nicht angefasst oder verlaengert.
- Naechster operativer Schritt nur separat beauftragt: laufende Quelldateien/Konfiguration sichern, Konflikte zu fremden Laufzeitaenderungen vergleichen, passenden Modulstand kontrolliert installieren und frische Daten, PV/Peak, Ladepause/Wiederanlauf, Phasen und physischen Stop an realen Fahrzeugen abnehmen. Nicht pauschal alle fremden Moduldateien ueberschreiben. Die urspruengliche morgendliche Beschwerde bleibt mangels vollstaendiger Stell-/Zustandshistorie nicht zweifelsfrei einem einzigen Fehler zugeordnet.
## Ladestationsinstallation vorbereitet: 07.10.2026, 18:41 UTC
Daniel hat die Installation von 58856816697e4720a57cac046ec1793c1325576b in Lihrenmoos beauftragt. Noch NICHT installiert: Vor Aktivierung wurde eine ausdrueckliche Rueckfrage zum erforderlichen bibliotheksweiten MC_ReloadModule gestellt, weil dieser auch Manager/Batterie neu initialisieren und bekannte Timerprobleme ausloesen kann. Kein Dienstneustart freigegeben. Die Primaerverbindung iot-symcon01 ist erreichbar. Quellenvergleich: Regler/Adapter entsprechen dem Commit-Elternstand; die Stand-alone-Moduldatei hat lediglich die noch fehlende veroeffentlichte Sollwertbereinigung. Abhaengigkeiten VerbraucherBasisTrait und Nachrichtenvertrag entsprechen dem Release. Andere Laufzeitanpassungen bleiben unangetastet.
Vorbereitung auf iot-symcon01: /srv/agent/lihrenmoos-charger-fix-5885681. release.json, prepared.json und source/ enthalten die drei exakt mit Git-Blob und SHA256 geprueften Dateien. PHP-8.3-CLI-Syntaxpruefung bestanden. backup/ ist 0700, Dateien 0600; drei alte Quelldateien und settings.json (3474385 Bytes) gesichert. 110 unbeteiligte PHP-/JSON-Dateien per Hash erfasst. Kein Live-Modul ausgetauscht. Vor spaeterer Ausfuehrung erneut alle before-Hashes und aktuelle Konfiguration pruefen; prepare.py nicht erneut ausfuehren/Backup nicht ueberschreiben.
probe-v2.json liefert echten Kernelstand 20:34:51 CEST: Symcon 8.0; Manager 17004, Batterie 44234 und fuenf Ladepunkte Status 102; periodische Timer laufen. Pico 1 verbunden mit 0 W, Easee ca. 3485 W; andere drei Ladepunkte ohne Fahrzeug. Keine neuen Hardwaretests oder Bedienvorgaben. MC-Runtime-Metadaten: MC_ReloadModule(integer InstanceID,string Module), Module Control 44866, Modulordner Enelix-EMS. Ein isolierter Versuch gezielter direkter Create/Apply-Initialisierung im ENTWICKLUNGSKERNEL wurde vom Framework als ausserhalb des Create-Kontexts abgewiesen, Testinstanz entfernt; dieser Weg ist ungeeignet und darf NICHT auf der Anlage verwendet oder umgangen werden. Die eigentlichen 402 Release-Tests und 92 reinen Kernelpruefungen bleiben davon getrennt. Die temporaeren READ-ONLY-Probehooks wurden entfernt: 12555.ips.php wieder SHA256 c702a4947ae200b23c3eecccabeb843ad6a55e97509292584ca2de779d5e4940.
Naechster Schritt nach Antwort: Bei Freigabe kontrollierten, gesicherten Drei-Dateien-Austausch mit regulaerem Bibliotheksreload vorbereiten/ausfuehren, keine MC_UpdateModule/RevertModule-Aktion und kein pauschales Git-Pull im Live-Checkout. Danach Ladepunkte, Manager/Batterie, periodische Timer, Bedienwerte/Boost und unveraenderte Fremdquellen ueber mehrere Zyklen kontrollieren. Bei Timerblockade keinen eigenmaechtigen Dienstneustart und keinen Ersatz-Tick-Hook. V4-Testfreigaben sind nicht verlaengert.
## Ladestationskorrekturen installiert: 08.10.2026, 04:47 UTC; Nachkontrolle mit Blockaden
Dieser Abschnitt ersetzt die vorherige Angabe "vorbereitet, nicht installiert". Daniel hat mit "du darfst" den angefragten bibliotheksweiten Reload freigegeben, NICHT den Neustart von symcon.service.
- Host erneut mit Primaerconnector als iot-symcon01 bestaetigt. Drei Live-Quelldateien passten exakt zum vorbereiteten Stand; alle 110 unbeteiligten PHP-/JSON-Dateien unveraendert. Vor dem Eingriff alle elf EMS-Instanzen Status 102, keine offenen Konfigurationsaenderungen; Manager- und Batterietimer liefen. Aktuelle Konfiguration verwendet, keine Ruecksetzung auf den Vortag. Live-Git bleibt develop auf 3ce9af42440c240c03b37ac475c366b7c7c2693b mit bestehenden lokalen Anpassungen; kein Git-Update, Commit oder Push.
- Um 06:47:21-06:47:23 CEST wurden ausschliesslich LadestationStandAlone/module.php, libs/LadestationAdapter.php und libs/LadestationRegler.php aus Release 58856816697e4720a57cac046ec1793c1325576b installiert. MC_ReloadModule(44866, 'Enelix-EMS') gab true zurueck. Kein MC_UpdateModule/RevertModule, kein Dienstneustart, keine V4-/SDL-/Boost-Bedienaktion und kein zusaetzlicher Hardwaretest.
- Frische native Sicherung: /srv/agent/lihrenmoos-charger-fix-5885681/backup-native-20261008-044721. Ordner 0700, Dateien 0600; drei alte Quellen, aktuelle native Instanzkonfigurationen und settings.json, 3475706 Bytes, SHA256 bf55d4290e892c678e191131f91adb10a97d030d240ee73ad4e241a9d6497b54. Fruehere Sicherung erhalten. Die drei neuen Dateien sind SHA256-geprueft und Besitzer/Gruppe/Dateimodus nativ erhalten. Installer, begrenzte Nachkontrolle und vorbereiteter Ruecksetzweg syntaxgeprueft; Ruecksetzung nicht ausgefuehrt.
- Echte Kernel-Nachkontrolle 06:48:06-06:50:36 CEST: 31 Stichproben ueber 150 Sekunden. Alle elf Konfigurationshashes, Bedienwerte inklusive Boost und 67 UI-Objekte unveraendert; V4-Schalter 46716 weiter false. Alle 110 Fremdquellen unveraendert. Symcon8.0, Dienst laut systemctl seit 06.10.2026 16:32:09 CEST aktiv, hier nicht neu gestartet.
- Pico 1/2 und beide go-e Status 102; alle acht Status-/Meldetimer laufen. Alte go-e-Sollwerte 3680/10944 W wurden zu 0 bereinigt. Pico 1 weiterhin verbunden, 0 W Istleistung; unsichere alte Einphasigkeit jetzt korrekt unbekannt (0). Kein erfolgreicher physischer Ladestart nachgewiesen. Pico 2 und beide go-e ohne Fahrzeug. Diese Installation ist keine Feldabnahme aller Ladepunkte.
- WICHTIG: Bekannte Batterietimerblockade ist NACH DIESEM Reload erneut aufgetreten. Alter Timer 243 bleibt Running=true mit unveraendertem LastRun 1791434840. Neue Batterie-Timer 304 (1-s-Rueckmeldung) und 306 (Meldezyklus) haben LastRun=0; auch neue verzoegerte Rueckmeldung/Timeout-/Freigabetimer laufen nicht. Instanzstatus 102 und sich weiter aendernde Werte sind keine Entwarnung. Alle sechs geprueften periodischen Managertimer laufen. Die nach Namen zusammengefassten Zwischenzaehler in verification.json koennen beim doppelten Timer verfalscht sein; finished.json wertet deshalb jeden Timer per TimerID aus und bestaetigt die Blockade.
- Easee-Meldezyklus laeuft, aber Gateway 55829 bleibt Status203/Connected=false, Meldung "Easee-Ereignisverbindung wird aufgebaut."; WS-Parent45241 Status200, Ladepunkt46052 Status202 mit "Easee Gateway ist nicht verbunden." Das ist gegenueber dem Vorzustand nicht wiederhergestellt. Gateway-Wartungstimer laeuft; keine Zugangsdaten ausgegeben oder veraendert. Ursache der fehlenden Wiederverbindung nicht abschliessend bestimmt.
- Verbindlicher Ergebnisbericht /srv/agent/lihrenmoos-charger-fix-5885681/finished.json: status=installed_runtime_issues_restart_permission_requested, runtimeHealthy=false. install-result.json belegt die Installation, verification.json den zeitlichen Verlauf. Keine erfolgreiche Gesamtabnahme behaupten.
- Alle temporaeren Hooks entfernt; Beobachterskript12555 bytegleich SHA256 c702a4947ae200b23c3eecccabeb843ad6a55e97509292584ca2de779d5e4940. Kein externer Tick-Ersatz, kein Wiederanlauf-Hook. Install-/Preflight-Claims NICHT loeschen oder Installer blind wiederholen. rollback.php ist vorbereitet, nicht ausgefuehrt; Quell-Rollback behebt nicht nachgewiesen die Kernel-Timerblockade und wuerde einen weiteren Reload erfordern.
- Daniel wurde ausdruecklich um einen zusaetzlichen kontrollierten Neustart des gesamten Symcon-Dienstes gebeten, weil weitere Anlagenfunktionen kurz betroffen sind. Diese Freigabe steht bei Abschluss noch aus. Erst nach klarer Antwort frische Sicherung/Statuspruefung, dann erlaubten Neustart versuchen; fruehere systemctl-Neustartversuche verlangten interaktive Authentifizierung. Keine Rechte umgehen. Danach Batterietimer und Easee samt Ladestationen/Manager erneut ueber mehrere Zyklen pruefen. V4-Testfreigabe vom 07.10. nicht erneuern oder reaktivieren.
## Batterie-Timer: Veroeffentlichung 08.10.2026, 05:18 UTC
- Daniel Haefliger beauftragte Commit/Push unserer Timerkorrektur nach develop
und beta, Autor dh_Agent, Zugang dh, danach Lihrenmoos-Update.
- ENELIX/Enelix-EMS: Commit d42cfb334284a574ef160bde6d28b45a622129c7,
fix(battery): keep native timer callbacks reload-safe. Autor und Committer
dh_Agent <dh@belevo.ch>. Konfigurierter Login dh und authentifizierte
Repository-Pushberechtigung geprueft; Profilabfrage mangels Token-Scope nicht
verfuegbar. Normaler Push nach develop erfolgreich und per ls-remote bestaetigt.
- beta NOCH NICHT aktualisiert: weiterhin 58856816697e4720a57cac046ec1793c1325576b.
main unveraendert b6253f9e55c659913ebb43783fe2b6a72cfd180f.
- Drei Produktionsdateien: alle sechs nativen Batterietimer gehen ueber den
generierten ENELIX_BatterieTimerAktion-Wrapper; neue strikt cache-only
Diagnose ENELIX_GetV4BatterieRueckmeldungCache. Keine neuen Tick-Hooks,
keine erneuerten Zeitstempel, keine gelockerten Frische-/Schutzpruefungen.
Bestehende APIs, Konfiguration, Energiezaehler und EV-/SDL-Aufteilung erhalten.
- Tests und Migrationsgrenzen: tests/BatterieTimer/README.md. Vorher bestanden
134 isolierte CLI-Pruefungen und insgesamt acht native Reloadtests, davon
sechs am 08.10.2026. git diff --check erfolgreich. Zusaetzliche PHPUnit-
Einbindung der 28 Vertragspruefungen ist committed, aber der zusammengefuehrte
aktuelle Gesamtstand hat noch keinen neuen vollstaendigen PHPUnit-Nachweis.
- CI-Lauf 185 fuer d42cfb3 ist queued (auch die alten Laeufe 183/184 queued).
https://git.belevo.ch/ENELIX/Enelix-EMS/actions/runs/185
CLI-PHP fehlt auf ubuntu/enelix-services; Dockerzugriff denied. Eine separate
grosse Source-/Testkopie nach iot-symcon01 wurde von der Sicherheitspruefung
vor Uebertragung blockiert. Nicht als ausgefuehrten Test darstellen und diese
Sperre nicht durch andere Uebertragungswege umgehen.
- Sauberer develop-Releasecheckout /srv/agent/charger-fixes-20261007 wiederverwendet.
Nur zehn eigene Dateien committed. Die vorher vorhandene composer.lock bleibt
untracked und erhalten; kanonische schmutzige Repos nicht zurueckgesetzt.
Sicherung /srv/agent/battery-timer-release-backup-20261008 auf ubuntu.
- NICHT INSTALLIERT: kein weiterer Anlagenreload, kein Symcon-Neustart, keine
Stellversuche. Die bekannte Reload-Blockade vom 08.10. 04:47 UTC und Easee-
Stoerung sind historische Momentaufnahmen, nicht erneut live abgenommen.
Systemctl zeigte weiterhin active/running seit 06.10.2026 16:32:09 CEST.
- Fuer den robusten Erstwechsel wurde Daniel explizit um kontrollierten
Stop/Installation/Start mit kurzer Unterbrechung ALLER Symcon-Ablaufe gebeten.
Eine Antwort darauf liegt in diesem Stand noch nicht vor. Beim Rueckfall nur
gesicherte betroffene Quellen, keine pauschalen Konfigurations-/Energie-Resets.
Nach Freigabe aktuellen Zustand und Quellen erneut sichern/pruefen, bestehende
Ladepunktkorrektur erhalten, dann automatische Timerfortschritte und frische
bestaetigte Cachewerte messen. Status 102 reicht nicht. V4 bleibt unangetastet;
die abgelaufene Stelltestfreigabe wird durch diesen Auftrag nicht erneuert.
## Symcon-Neustart am 08.10.2026 freigegeben, technisch abgewiesen
Daniel bestaetigte den angefragten kontrollierten Neustart ausdruecklich mit "top ja gerne, kannst du". Der normale Dienstneustart wurde ueber den primaeren Connector auf dem verifizierten Host iot-symcon01 versucht, vom Betriebssystem aber mit "Interactive authentication required" abgewiesen (Exit 1). Keine Rechteausweitung, kein Umweg ueber andere Connectoren oder einen privilegierten Kernelaufruf. Es fand KEIN Neustart statt: ActiveEnterTimestamp bleibt 06.10.2026 16:32:09 CEST und InvocationID 91626b4f92de4d9d8831691c75060685. Dienst weiter active/running.
Frische Sicherung 08.10.2026 07:39:21 UTC: /srv/agent/lihrenmoos-charger-fix-5885681/backup-restart-20261008-073921. 150 Dateien (EMS-Quellen, settings.json und urspruenglicher Beobachter), Ordner 0700/Dateien 0600, inhaltlich rueckgelesen/gehasht. Settings 3478937 Bytes, SHA256 a2c2c16a6380e523f5022a9fd61a81e1d537492471cd7c3a82f1c085b3b7c072. Zusaetzlich aktuelle native Konfigurationen aller elf EMS-Instanzen geschuetzt gesichert. Die drei Ladestationskorrekturen weiterhin exakt Release 5885681, alle 110 unbeteiligten Quellen unveraendert. Kein Modul-/Git-Update oder Push.
WICHTIGE NEUE BEOBACHTUNG: Schon die native Vorpruefung um 09:39:41 CEST zeigte wieder laufende Batterie-Timer 304/306, keinen alten haengenden Timer 243 mehr und Easee55829 Connected=true/Status102. Die bisherige Blockade und fehlende Easee-Verbindung haben sich bis dahin ohne einen von uns ausgefuehrten Neustart aufgeloest. Zeitpunkt und Ursache der Erholung sind nicht bestimmt. Die historische Blockade um 06:50 CEST bleibt belegt; nicht als aktuellen Dauerzustand wiederholen und die Erholung nicht dem abgewiesenen Neustart zuschreiben.
Der vorliegende Auftrag betrifft den Dienstneustart, nicht die in der getrennten Uebergabe dokumentierte neue Batterietimer-Veroeffentlichung d42cfb3. Deren Installation, Beta-Freigabe und weitere Tests wurden hier NICHT vorgenommen. V4-Schalter bleibt aus; keine abgelaufene Stelltestfreigabe erneuert.
Abgeschlossene reine Kernel-Nachkontrolle 09:42:36-09:45:11 CEST: 32 Stichproben ueber 154.998 Sekunden. Alle 18 explizit geprueften periodischen Timer bestanden, darunter Batterie-Sampler304 und Meldezyklus306, acht Pico/go-e-Status-/Meldetimer, Easee-Meldetimer, Gateway-Wartung und sechs Manager-Timer. Batterie-Meldetimer28 und Sampler31 beobachtete Fortschritte. Kein doppelter haengender Batterietimer mehr vorhanden. Alle elf EMS-Instanzen Status102 ohne offene Konfigurationsaenderungen; Easee verbunden mit einem Abonnement und leerem Fehlertext. Alle elf Konfigurationshashes, Bedienwerte einschliesslich Boost und 67 UI-Objekte unveraendert, ebenso die drei Releasequellen und 110 Fremdquellen. V4 weiter false. Dies belegt den aktuellen stabilen Beobachtungszeitraum, keine dauerhafte Behebung der Reload-Ursache.
Belege im Ordner /srv/agent/lihrenmoos-charger-fix-5885681: restart-prepared.json, restart-before.json, restart-attempt.json und restart-runtime-check.json (done=true, passed=true, mode=verification_without_restart). Fuer den Dienstneustart bleibt die technische Voraussetzung ein berechtigtes Administratorkonto; Benutzerfreigabe war vorhanden. Keine erneute Rueckfrage nur zur selben Freigabe noetig, aber vor einem spaeteren Versuch Zustand und Sicherung aktualisieren.
Temporare restartcheck-/restartverify-Beobachtung entfernt, Skript12555 wieder SHA256 c702a4947ae200b23c3eecccabeb843ad6a55e97509292584ca2de779d5e4940. Kein Tick-Ersatz oder aktiver Wiederanlauf-Hook. Letzte gemessene Leistung aller fuenf Ladepunkte 0 W; ein echter Ladestart oder die urspruengliche Pico-1-Ursache ist dadurch NICHT abgenommen. Pico1 und Easee melden ein verbundenes Fahrzeug, die anderen drei keines. Naechste fachliche Abnahme waere ein kontrollierter Ladeversuch mit bekanntem Fahrzeug und zeitgleichem Status-/Sollstromtrace; nicht aus dieser Neustartfreigabe eigenstaendig weitere Hardwaretests ableiten.
+45
View File
@@ -0,0 +1,45 @@
# Gemeinsame Veroeffentlichung 08.10.2026
## Freigabe und Umfang
Daniel Haefliger hat den aktuellen Projektstand einschliesslich weiterer
unveroeffentlichter Aenderungen fuer develop und beta freigegeben.
Autor und Committer: dh_Agent <dh@belevo.ch>; authentifizierter Git-Zugang: dh.
main bleibt unveraendert. Keine Anlageninstallation, kein Reload/Neustart
und keine erneuerte Stell- oder Testfreigabe.
Die Quellbasis enthaelt Batterie-Timer d42cfb3, Ladestationen 5885681,
PHP-8.0-Kompatibilitaet 86834bb und die parallel veroeffentlichte Energy-Pie-
Gesamtbilanz: EMS af69151 und Utils 6f44e76. Dazu kommen der gepruefte
V4-Zwei-Schalter-Workflow in EMS und die bisherigen Dokumentationsaenderungen
beider Bibliotheken. Historische Betriebsnotizen bleiben datiert erhalten.
## Abgrenzung
Alte Arbeitskopien bleiben unangetastet. Bereits enthaltene oder ersetzte
Zwischenstaende werden nicht erneut angewendet. Ausgeschlossen sind
Testhilfsdateien, lokal erzeugte composer.lock sowie zwei unvollstaendige
alte Portal-Snapshots ausserhalb der versionierten Integrationspakete.
Der frueher verworfene measurement_pipeline-Entwurf wird nicht integriert:
Ein accountingEvidenceId darf keine ungepruefte Messgrenze bestaetigen.
## Pruefung und Migration
Vor Veroeffentlichung: Quellvergleich, Geheimnissuche, git diff --check,
PHP-Syntax/PHPUnit, Dokumentationsvertrag sowie die betroffenen Node-,
Python- und Browserpruefungen. Exakte Ergebnisse und Commit-/Remote-Nachweise
liegen im Releaseprotokoll /srv/agent/release-all-20261008 auf ubuntu.
Die Gitea-Tests des veroeffentlichten develop-Commits pruefen PHP 8.0;
beta wird erst nach erfolgreichem Abschluss weitergefuehrt.
Ein spaeterer Anlagenrollout benoetigt eine eigene Sicherung, Driftpruefung
und Freigabe. Fuer die Energy-Pie-Gesamtbilanz zuerst Utils, dann EMS
aktualisieren. Farbwerte, Quellen und Archivdaten nicht zuruecksetzen.
V4-Migration und Grenzen: EMS docs/prognose-v4-workflow.md.
## PR-Kurztext
Fuehrt Workflow und Modul-/Bedienungsdokumentation auf dem aktuellen
Batterie-, Ladepunkt- und Energy-Pie-Stand zusammen. Bewahrt parallele
Arbeitskopien und lokale Schutzpruefungen. Veroeffentlichung ist keine
Betriebsfreigabe; Hardware- und Symcon-GUI-Abnahme bleiben separat.
+42
View File
@@ -1,5 +1,8 @@
# Batterie
Die Referenz gilt für `develop` und `beta`; `main` enthält noch kein
installierbares Batteriemodul. [Kanalstand und bekannte Grenzen](../../../README.md).
> Status: implementiert. Zielplattform ist IP-Symcon ab Version 8.0,
> Nachrichtenvertrag 4.0.
@@ -128,3 +131,42 @@ Transport und gemeinsame Felder folgen
- BatterieModulstrukturTest: Metadaten, Formular, Ereignismodell und Manager-ID
- Symcon/modules/Batterie.php: reale Modulinstanz und Registeraktionen unter
IP-Symcon 8.0
## Weitere Diagnosevariablen und V4-Kompatibilität
| Ident | Typ / Zugriff | Bedeutung |
| --- | --- | --- |
| `LadezustandGueltig` | Boolean / Diagnose | Ladezustandsquelle ist vorhanden, aktuell und im zulässigen Bereich. |
| `HystereseAktiv` | Boolean / Diagnose | Die Ladezustandshysterese beschränkt das aktuelle Leistungsangebot. |
Der frühere separate V4-Formularbereich ist entfernt; vorhandene Properties,
Methoden und Kennungen bleiben kompatibel. Dies entfernt weder Schutzprüfungen
noch historische Daten. Die Bedienung liegt im
[Prognosebereich des Managers](../Manager/README.md).
| Property | Typ / Standard | Bedeutung |
| --- | --- | --- |
| `NetzfahrplanV4RegeltestErlaubt` | Boolean / `false` | Lokale Batteriefreigabe für den begrenzten Regeltest. |
| `NetzfahrplanV4AktivtestErlaubt` | Boolean / `false` | Zusätzliche Freigabe für den ausdrücklich befristeten Aktivtest. |
| `NetzfahrplanV4WatchdogVerzichtErlaubt` | Boolean / `false` | Nur dokumentierte Testanlagen-Ausnahme; keine allgemeine Empfehlung oder Dauerfreigabe. |
| `NetzfahrplanV4GeraeteWatchdogNachweis` | String / leer | Nachweiskennung des unabhängigen Geräte-Ausfallschutzes. Ein Symcon-Timer ersetzt diesen Nachweis nicht. |
| `NetzfahrplanV4RueckmeldungKonfiguration` | String/JSON / `{}` | Geprüfte physische Messquellen, Vorzeichen und Rückmeldezuordnung. Anlagenspezifisch, keine universelle Musterkonfiguration. |
Bestätigter Geräteabruf, Variablenpublikation und Sammlerzeit sind verschieden.
Die Rückmeldung verwendet erfolgreiche Geräte-Lesevorgänge und einen kurzlebigen,
quellengebundenen Cache. Ungültige/veraltete Bestätigungen verhindern den Test;
ein unveränderter Messwert darf nicht künstlich frisch geschrieben werden.
Bekannte Betriebsgrenze: Am 6. Oktober 2026 wurden nach Modul-Reload erneut
blockierte Batterie-Timer beobachtet. Die spätere Nachkontrolle vom 7. Oktober
meldet laufende periodische Timer, aber keine geklärte Ursache oder physische
V4-Abnahme. Status `102` allein beweist keine laufende Rückmeldung oder
Watchdog-Ausführung. Automatischen Timerlauf, physisches Tracking, mehrere
Planerneuerungen und bestätigten Null-Stopp gesondert nachweisen. Die frühere
Testfreigabe endete am 7. Oktober und wird nicht erneuert. Keine externen
Timer-Ersatzaufrufe und kein automatischer Aktivstart aus dieser Anleitung.
Beim Verwerfen einer gültigen Manager-Vorgabe wird seit `5437f3f` auch
`Sollleistung` auf 0 W gesetzt. Das ist die Bereinigung der Vorgabe, kein
Versprechen, dass lokale Schutz-/Nachladefunktionen und Istleistung sofort null
werden. Der bestehende Batteriemanagement- und Gerätepfad bleibt massgeblich.
@@ -192,6 +192,7 @@ Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
| `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. |
| `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. |
| `Geraetehinweis` | Pico-Autorisierung und historisches letztes Ereignis, getrennt vom aktuellen Fehler. |
| `FahrzeugMaximalstrom` | Integer in A / Anzeige. Erkannte Fahrzeuggrenze nach `FahrzeugstromErkennungszeit`; kein manuell zu setzender Ladestrom. |
| `LetzterGeraetebefehl` | Letzter Steuerbefehl mit HTTP-Methode und URL ohne Zugangsdaten; Statusabfragen ueberschreiben ihn nicht. |
| `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. |
@@ -199,6 +200,43 @@ Die Regellogik speichert ihren Zustand unabhaengig von der Sichtbarkeit der
Diagnosevariablen. Das Ein- oder Ausblenden veraendert daher nicht das
Regelverhalten.
## Beispiele zur Diagnose
`FahrzeugMaximalstrom` ist eine Integer-Diagnosevariable in A. Beispiel:
Ein erkanntes Fahrzeuglimit von 10 A bleibt auch bei höherem Anlagenbudget
wirksam; Angebot und Zuteilung dürfen es nicht umgehen. Die Konfiguration
`FahrzeugstromErkennungszeit` bestimmt die Beobachtungszeit.
Bei unbekannten Phasen bietet die Station im freigegebenen PV-Solarbetrieb
`[0,4104] W` an. Sind nur 3000 W zuteilbar, bleibt sie aus; die 6-A-Probe darf
nicht am Manager vorbei starten. Bei 4104 W passender Vorgabe und frischen
Geraetedaten kann die Probe beginnen. Eine anschliessende Messung von 0 W
beweist keine Einphasigkeit. Im Peak-Solarbetrieb bleibt das Angebot `[0]`.
Pico mit `State=6` wartet auf Autorisierung und bietet `[0]`. Ein altes
`LastWarningOrError` allein ist kein aktueller Ladefehler. Frisches `LastSeen`
bei altem `ValueDate` reicht bei positiver Ladeleistung dagegen nicht aus:
das Modul meldet einen Fehler und wartet auf vollstaendige frische Daten.
Sicheres Lesebeispiel fuer die Symcon-Schnellausfuehrung. Eine bestehende
Stand-Alone-Instanz einsetzen; optionale, nicht angelegte Diagnosen werden
uebersprungen. Es werden weder Zugangsdaten gelesen noch Stellbefehle gesendet.
```php
<?php
$stationId = 0; // Vorhandene Ladestation-Stand-Alone-Instanz einsetzen.
if ($stationId <= 0 || !IPS_InstanceExists($stationId)) {
throw new RuntimeException('Vorhandene Ladestations-ID erforderlich.');
}
foreach (['FahrzeugVerbunden', 'FahrzeugGeladen', 'Phasenzahl',
'Ladestrom', 'FahrzeugMaximalstrom', 'LeistungsangebotDiagnose'] as $ident) {
$id = @IPS_GetObjectIDByIdent($ident, $stationId);
if ($id !== false && IPS_VariableExists($id)) {
echo $ident . ': ' . json_encode(GetValue($id), JSON_THROW_ON_ERROR) . PHP_EOL;
}
}
```
## Managerkommunikation
Das Modul implementiert
@@ -271,6 +309,11 @@ abgeglichen am 07.10.2026.
## Migration der Korrektur vom 07.10.2026
Seit `5437f3f` wird beim Verwerfen einer Manager-Vorgabe auch die gespeicherte
und sichtbare `Sollleistung` bereinigt. Lokale Freigaben, Mindestzeiten und
physische Fahrzeugreaktion bleiben davon getrennt. Gesendete Steuerbefehle
bleiben in der Diagnose erhalten. [Kanalstand und Fehlerkorrekturen](../../../README.md).
Vor Installation laufende Moduldateien und Konfiguration sichern und fremde
Anpassungen vergleichen. Beim ersten `ApplyChanges` mit Erkennungsversion 2
werden alte, moeglicherweise aus 0 W abgeleitete Phasen verworfen. Ein alter
+156
View File
@@ -464,3 +464,159 @@ oder Stellbefehl ist Teil dieser Quellcode-Aenderung.
- Anbieterformate für Prognose und Störüberwachung festlegen.
- Produktive signierte Lizenz-Leases nach Abschluss des Entwicklungsvertrags integrieren.
- Verhalten und Messabgrenzung bei Untermanagern im Anlagentest bestätigen.
## Ergänzungen zum Kanalstand vom 8. Oktober 2026
Diese Referenz gilt für Testing (`develop`) und Beta mit Quellbasis `5885681`.
Stable (`main`, Quellbasis `b6253f9`) enthält noch keinen installierbaren Manager.
Die Kanalnamen sind keine Anlagenabnahme. Die [Kanalübersicht](../../../README.md)
trennt Funktionen, Fehlerkorrekturen und noch offene Betriebsnachweise.
### Prognosebedienung und Netzfahrplan V4
Im Bereich **Prognose / Forecast** sind Topologie, Empfang, Vorschau und bewusste
Start-/Stoppbedienung zusammengeführt. `PrognoseAktiv` schaltet Telemetrie und
Topologiesynchronisation ein, startet aber keine V4-Regelung. `NetzfahrplanAktiv`
gehört weiterhin zum bisherigen Netzfahrplanregler. Bei eingerichteter V4-
Konfiguration wird dessen ausgeschalteter Altschalter verborgen; ein aktiver
Altregler bleibt zum Ausschalten sichtbar. Beide Regler nicht parallel starten.
Die neue Bedienung `FormNetzfahrplanSchalten` prüft gespeicherte Konfiguration,
Lizenz und lokale Testfreigaben erneut. Ausschalten bleibt bei Lizenzverlust
möglich. Der Stopp widerruft die Testsitzung und kann danach eine frisch
berechnete normale EMS-Zuteilung auslösen. Er ist kein anlagenweiter Not-Aus
und löscht keinen unabhängigen SDL-Auftrag.
V4 ist ein begrenzter Testpfad, kein freigegebener Dauerregler. Ein vorhandener
Plan, `optimal`, `shadow_seen` oder eine erfolgreiche Befehlsquittung belegt
weder physische Leistungsnachführung noch Einsparungen. Nach dem Modul-Reload
vom 6. Oktober wurde eine Batterietimerblockade dokumentiert. Die spätere
Nachkontrolle vom 7. Oktober meldet laufende periodische Timer, keine physische
V4-Abnahme. Ursache und Wiederholbarkeit des Reload-Problems bleiben offen;
nach Updates Timer erneut prüfen. Watchdog, Planwechsel und sicherer Stopp
bleiben separat abzunehmen. Die frühere befristete Testfreigabe endete am
7. Oktober 2026. Diese Dokumentation erneuert sie nicht; Schutzprüfungen dürfen
nicht umgangen werden.
| Property | Typ / Standard | Zweck und Grenze |
| --- | --- | --- |
| `NetzfahrplanV4SchattenAktiv` | Boolean / `false` | Versand nativer Betriebsdaten für die Schattenplanung; allein keine Stellbefehle. |
| `NetzfahrplanV4EmpfangAktiv` | Boolean / `false` | Planempfang und lokale Vorschau; Empfang ist keine Ausführung. |
| `NetzfahrplanV4NetzladenErlaubt` | Boolean / `false` | Netzladen in den Planungsrandbedingungen erlauben; ersetzt keine lokale Freigabe. |
| `NetzfahrplanV4BatterieOptionen` | String/JSON / `{}` | Batteriebezogene Planungsoptionen, gebunden an die Asset-ID der Topologie. |
| `NetzfahrplanV4MessnachweisVariableID` | Integer / `0` | Stringvariable mit geprüftem JSON-Messnachweis, keine Leistungsvariable. |
| `NetzfahrplanV4BezugszaehlerQuellen` | String/JSON / `[]` | Separate Bezugszählerquellen für Viertelstunden-/Monatspeaknachweise. Keine Übernahme eines ungeprüften Altzählers. |
| `NetzfahrplanV4MessdatenAktiv` | Boolean / `false` | Rohmessaufnahme alle 30 s mit Originalzeitstempeln, lokaler Outbox und bestätigtem Versand. |
| `NetzfahrplanV4ArchivAktiv` | Boolean / `false` | Optionale lokale Archivierung bestätigter Messdaten; kein Ersatz für externe Sicherungen. |
| `NetzfahrplanV4Messkonfiguration` | String/JSON / `{}` | Validierte Quellen-/Messgrenzenzuordnung, an Installation und Manager gebunden. |
| `NetzfahrplanV4Datensatz` | String / leer | Datensatzkennung, 1 bis 80 Buchstaben, Ziffern, `_` oder `-`. |
| `NetzfahrplanV4RegeltestErlaubt` | Boolean / `false` | Lokale Freigabe des begrenzten Regeltests; weitere Server-/Batteriegates bleiben erforderlich. |
| `NetzfahrplanV4AktivtestErlaubt` | Boolean / `false` | Zusätzliche Freigabe des ausdrücklich begrenzten Aktivtests, keine Produktionsfreigabe. |
| `NetzfahrplanV4WatchdogVerzichtErlaubt` | Boolean / `false` | Nur dokumentierte Testanlagen-Ausnahme, niemals Ersatz für einen Geräte-Watchdog im Dauerbetrieb. |
Komplexe Messkonfigurationen müssen aus den tatsächlichen Quellen abgeleitet
werden. Leere Standardwerte sind nicht ausführbare Musterkonfigurationen.
Vorhandene Datenkennungen, Outbox-Cursor und Bestätigungen nicht manuell ändern.
Normaler Rohdatenversand erfolgt im 60-s-Abstand; ein bestätigter Rückstand kann
schneller abgearbeitet werden, Fehler warten mindestens 60 s. `scheduled`
bezeichnet daher nicht automatisch einen Fehler. Die letzte Quittung ist
entscheidend. Ein HTTP-429 beim Planempfang behält seinen Backoff.
| Variable | Typ / Zugriff | Inhalt |
| --- | --- | --- |
| `NetzfahrplanV4Vorschau` | String/HTML / Anzeige | Geprüfte Planvorschau ohne Stellfreigabe. |
| `NetzfahrplanV4VorschauJSON` | String/JSON / Diagnose | Plan-, Eingangs- und Ablehnungsdiagnose. |
| `NetzfahrplanV4Datenstatus` | String/JSON / Diagnose | Aufnahme, Versand, Datensatz und letzte Bestätigung; `controlEnabled=false` bezieht sich auf diesen Datenweg. |
| `NetzfahrplanV4Aktivtest` | Boolean / bedienbar | Bestehender Schalter für den begrenzten Testbetrieb; kein automatischer Start beim Update. |
Die Idents bleiben für bestehende Verknüpfungen erhalten. Die separaten
V4-Diagnosevariablen sind im integrierten Stand verborgen; die Bedienung erfolgt
im Prognosebereich. Verborgene Diagnosen sind nicht gelöscht.
### Weitere Properties und Diagnosewerte
| Property | Typ / Standard | Beschreibung |
| --- | --- | --- |
| `StoerueberwachungAktiv` | Boolean / `false` | Optionale Übertragung des Störungszustands ans Portal. |
| `StoerungsSendeintervall` | Integer / `300` | Sendeintervall der Störungsüberwachung in Sekunden. |
| `DiagrammPVModus` | Integer / `0` | `0` gesamt, `1` einzeln, `2` gesamt und einzeln. |
| `DiagrammBatterieModus` | Integer / `0` | `0` gesamt, `1` einzeln, `2` gesamt und einzeln. |
| `LadestationenSeparatAnzeigen` | Boolean / `true` | Ladestationen in neu aufgebauten Darstellungen separat führen. |
| `VerbraucherSeparatAnzeigen` | Boolean / `true` | Weitere Verbraucher separat führen. Hausdarstellung wird entsprechend abgegrenzt. |
| `EnergieflussLeistungseinheit` | Integer / `0` | `0` W, `1` kW; interne Regelungswerte bleiben W. |
| `PrognosePVVariableID`, `PrognoseHausverbrauchVariableID`, `PrognoseSOCVariableID` | Integer / `0` | Verdeckte Altproperties; neue Messquellen über die Anlagentopologie pflegen. |
| `MessungPVLeistungVariableID`, `MessungHausverbrauchLeistungVariableID`, `MessungBatterieleistungVariableID` | Integer / `0` | Verdeckte frühere Messquellen, nicht als zweite aktive Topologie konfigurieren. |
| `MessungPVLeistungsfaktor`, `MessungHausverbrauchLeistungsfaktor`, `MessungBatterieleistungsfaktor` | Float / `1.0` | Zugehörige frühere Normierungsfaktoren. |
| `LizenzAnschluss` | String/JSON / `{}` | Verdeckte Altproperty; aktuelle Aktivierung über `Lizenzcode`. Keine Zugangsdaten in Beispielen. |
| Variable | Typ / Einheit | Beschreibung |
| --- | --- | --- |
| `NichtGeregelteGeraete` | String/JSON | Diagnose nicht geregelter Geräte. |
| `Monatsgrenzen` | String/JSON | Diagnose der Monatsgrenzen; gleichnamige Property ist die Konfiguration. |
| `EnergieFunFacts` | String/HTML | Optionale Energy-Facts-Anzeige. |
| `SDLLeistungArchiv` | Float / W | Separate signierte SDL-Leistung. |
| `SDLSOCArchiv` | Float / % | Separater SDL-Ladezustand. |
| `SDLLadenEnergie`, `SDLEntladenEnergie` | Float / kWh | Getrennte integrierte SDL-Richtungen. |
| `EnergieflussPV`, `EnergieflussNetz`, `EnergieflussHaus`, `EnergieflussBatterie` | Float / W oder kW | Verborgene Anzeigequellen des Energieflusses, keine Stellregister. |
| `EnergieflussSDL` | Float / W oder kW | Optionaler separater SDL-Knoten; verwendet die Einheit des Energieflusses. |
| `EnergieflussIst_<InstanzID>`, `EnergieflussSoll_<InstanzID>` | Float / W oder kW | Darstellungswerte je separat geführtem Verbraucher. |
| `DiagrammHausLeistung`, `DiagrammHausEnergie` | Float / W bzw. kWh | Haus ohne separat dargestellte Verbraucher. |
| `DiagrammPVLeistung_<Hash>`, `DiagrammPVEnergie_<Hash>` | Float / W bzw. kWh | Einzel-PV-Quellen aus der Topologie. |
| `DiagrammBatterieLeistung_<Hash>`, `DiagrammBatterieLaden_<Hash>`, `DiagrammBatterieEntladen_<Hash>` | Float / W bzw. kWh | Einzelbatterie und richtungsgetrennte Energie. |
| `DiagrammVerbraucherLeistung_<InstanzID>`, `DiagrammVerbraucherEnergie_<InstanzID>` | Float / W bzw. kWh | Einzelverbraucher und integrierte Energie. |
Die dynamischen Variablen entstehen nur bei passenden Quellen und aktivierter
Darstellung. `<Hash>` ist eine vom Modul berechnete stabile Kennung, keine
einzutragende Objekt-ID. Bestehende individuelle Energieflussknoten, Diagramm-
reihen und Energy-Pie-Messquellen bleiben erhalten. Anzeigeoptionen bei einer
Migration nicht zum Erzwingen eines Neuaufbaus aus- und wieder einschalten.
### SDL und Energieanteile: Beispiel
Bei 10 kW PV, 2 kW Netzbezug, 3 kW Batterieladung und 4 kW separat abgegrenzter
SDL-Ladung ergibt sich `max(0, 10 + 2 - 3 - 4) = 5 kW` Hauslast. Ein Faktor
`1000` wandelt eine kW-Quelle in W um. Eine bereits in der Batteriemessung
enthaltene SDL-Leistung darf nicht ein zweites Mal abgezogen werden.
Bei aktiver SDL lässt sich aus dem gemeinsamen Netzbezug nicht eindeutig
bestimmen, welcher Hausverbrauch aus PV stammt. Der Manager setzt deshalb
`EnergieanteileBerechenbar=false` am eigenen Energy Pie. Zähler bleiben sichtbar;
Autarkie und Eigenverbrauch werden als nicht bestimmbar statt als null angezeigt.
Zuerst Utils aktualisieren: Erkennt ein altes Utils diese Property nicht, blendet
EMS den eigenen Energy Pie aus und protokolliert den Aktualisierungsbedarf.
Die Regelung wird dadurch nicht gestoppt.
### Sicheres Lesebeispiel
In der Symcon-Schnellausführung zuerst eine vorhandene Manager-ID einsetzen.
Das Beispiel liest nur ausgewählte Werte, keine Lizenzdaten, und gibt keine
Stellbefehle aus. Fehlende optionale Diagnosevariablen werden übersprungen.
```php
<?php
$managerId = 0; // Vorhandene Manager-Instanz eintragen.
if ($managerId <= 0 || !IPS_InstanceExists($managerId)) {
throw new RuntimeException('Vorhandene Manager-ID erforderlich.');
}
foreach (['Aktiv', 'Betriebsart', 'Netzleistung', 'SDLStatus'] as $ident) {
$id = @IPS_GetObjectIDByIdent($ident, $managerId);
if ($id !== false && IPS_VariableExists($id)) {
echo $ident . ': ' . json_encode(GetValue($id), JSON_THROW_ON_ERROR) . PHP_EOL;
}
}
```
## V4-Zwei-Schalter-Workflow (08.10.2026)
Diese Bedienung ersetzt die historische separate V4-Testbedienung; Altangaben
weiter unten erteilen keine Freigabe fuer den neuen Workflow.
| Property | Typ / Standard | Wirkung |
| --- | --- | --- |
| `PrognoseAktiv` | Boolean / `true` | Prognose lernen; Uebertragung erfordert Lizenz und gueltige Messzuordnung. |
| `NetzfahrplanRegelungAktiv` | Boolean / `false` | Explizite lokale Regelanforderung. Kein Aktivstatus ohne frischen Plan, gueltige Rueckmeldung und lokale Schutzpruefung. |
Vorhandene Spezialzuordnungen bleiben erhalten. Alte Testschalter werden nicht
in eine Regelanforderung umgedeutet. Bei ungueltigen Daten bleibt der normale
lokale Regelpfad massgeblich. Eine Portalbestaetigung ist kein physischer
Soll-/Ist-Nachweis. [Einrichtung, Migration und Grenzen](../../prognose-v4-workflow.md).
+12 -6
View File
@@ -1,9 +1,7 @@
# EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer, Pufferspeicher, Batterie, Verbraucher 1-Stufig,
> Waermepumpe, Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway
> sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner
> enthalten Besprechungsgrundlagen.
> Alle neun unten aufgeführten Module sind in Testing und Beta implementiert.
> Stable enthält sie noch nicht. [Kanalstand und Fehlerstatus](../../README.md).
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
@@ -24,5 +22,13 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
## Review-Regel
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Offene Punkte
werden nicht durch Annahmen ersetzt. Erst nach Freigabe wird aus einem Entwurf
ein Ordner mit `module.json`, `form.json`, `module.php` und Unit-Tests.
werden nicht durch Annahmen ersetzt. Properties sind gespeicherte Einstellungen;
Variablen sind Laufzeitwerte. Nur ausdrücklich bedienbare Variablen dürfen als
Aktion verwendet werden. Diagnosen und dynamische Darstellungen entstehen teils
erst bei aktivierter Option und passenden Quellen. Interne Attribute sind
keine frei zu bearbeitenden Benutzer-Variablen.
Der Abgleich vom 8. Oktober 2026 erfasst die festen Property-/Variablenkennungen
der Module und eingebundenen Traits; dynamische Manager-Anzeigen stehen in der
Manager-Referenz. Beispiele verwenden Platzhalter und schalten ohne ausdrückliche
Beschreibung keine Geräte. Eine nachgewiesene Anlagenabnahme bleibt getrennt.
+19
View File
@@ -87,3 +87,22 @@ Die Rückmeldung ist verpflichtend: entweder eine numerische Leistungsmessung
oder ein Boolean-Betriebsstatus. Bei ungültiger Konfiguration, fehlgeschlagenem
Schalten oder widersprüchlicher Rücklesung setzt das Modul seinen Status auf
Fehler und meldet sich dem Manager als nicht verfügbar.
## Vollständige Sperr- und Laufzeitdiagnose
`DiagnosevariablenAnzeigen` ist eine Boolean-Property mit Standard `false`.
Sie ergänzt bei Bedarf folgende reine Anzeigevariablen:
| Ident | Typ / Einheit | Bedeutung |
| --- | --- | --- |
| `AnlaufAusstehend` | Boolean | Ein angeforderter Anlauf ist noch nicht abgeschlossen. |
| `WiederholsperreAktiv` | Boolean | Erneuter Anlauf ist wegen der Wiederholsperre gesperrt. |
| `SperrerholungAktiv` | Boolean | Sperrerholung beeinflusst den aktuellen Betriebszustand. |
| `RestMindestlaufzeit` | Integer / s | Noch verbleibende Mindestlaufzeit. |
| `RestMindestsperrzeit` | Integer / s | Noch verbleibende Mindestsperrzeit. |
Beispiel: Eine ausstehende Schaltung bei `RestMindestsperrzeit=120` ist zunächst
eine einzuhaltende Schutzzeit, kein Anlass zum Umgehen des Sperrkontakts.
Die Wärmepumpe besitzt mit `heat_pump` ein eigenes Lizenzkontingent; eine
Boilerlizenz ersetzt es nicht. Eine Portal-Bestelloberfläche ist erst nach
passendem Backend-Rollout nutzbar, nicht allein durch Veröffentlichung im Git.
+12
View File
@@ -404,3 +404,15 @@ Wesentliche Unterschiede:
Manager.
- Die neue Modul-ID erfordert eine neue Instanz; eine automatische Umwandlung
des Enelix-1-Objekts findet nicht statt.
## Plausibilitätsgrenzen der Temperatur
| Property | Typ / Standard | Bedeutung |
| --- | --- | --- |
| `TemperaturUntergrenze` | Float / `0.0` | Untere zulässige Messwertgrenze in °C. |
| `TemperaturObergrenze` | Float / `100.0` | Obere zulässige Messwertgrenze in °C. |
Diese Grenzen prüfen die Temperaturquelle; sie ersetzen nicht die Soll- oder
Mindesttemperatur. Beispiel: Eine Quelle mit 45 °C liegt innerhalb der
Standardgrenzen. Ein Sensorfehler von -127 °C ist kein gültiger Kaltwasserwert.
Grenzen nur passend zum realen Sensor und zur Anlage konfigurieren.
+97
View File
@@ -0,0 +1,97 @@
# V4-Prognose und lokale Batterieregelung
## Ziel und Bedienung
Der Prognosebereich des Managers hat zwei Schalter:
1. **Netzdaten senden und Prognosen lernen**: bei neuen Instanzen vorausgewaehlt;
Datenerfassung und Versand beginnen erst mit gueltiger Netzfahrplanlizenz und
vollstaendiger Messzuordnung. Empfang und Planungsdatenversorgung sind integriert.
2. **Batterie nach Netzfahrplan regeln**: standardmaessig aus. Erst bei gueltigem
Modell, Plan, frischen lokalen Messungen und erfuellten Schutzbedingungen bereit.
Ein angeforderter Schalter bleibt auch bei Fehlern abschaltbar.
Die normalen Symcon-Konfigurationseinstellungen werden mit Uebernehmen gespeichert.
Die Zustaende sind Aus, Daten sammeln, Lernen, Bereit, Aktiv und Unterbrochen.
Der Grund einer Sperre wird angezeigt. Die Servermeldung Aktiv setzt eine frische
lokale Bestaetigung voraus; sie ist kein Nachweis physisch ausgefuehrter Leistung.
## Verantwortlichkeiten
- Der V4-Dienst lernt die bereinigte Lastprognose und berechnet den Netzfahrplan.
PV-Prognose, Wetter- und Tarifversorgung bleiben bestehende Eingangsdienste.
- Der Manager misst die Abweichung zum V4-Netzziel und korrigiert ausschliesslich
eine eindeutig zugeordnete Batterie. Andere Verbraucher behalten ihre normale
Zuteilung; deren gemessene Leistungsveraenderung wird genau einmal beruecksichtigt.
- Das Batteriemodul bleibt der einzige Geraetetreiber. BMS-/SOC-Grenzen,
Leistungsangebote, lokale Zeitueberwachung und Geraeteschutz bleiben wirksam.
- Lizenzportal und Manager verwenden denselben Workflow. PV/Haus, Bezugs- und
Einspeisepreise, Netzziel, Batterie, SOC und Kosten stammen aus dem jeweiligen
eingefrorenen Plan. Historische Daten bleiben getrennt erhalten.
## Neue Installation
Manager, Netzleistungsmessung, Wirkenergie-Bezugszaehler, PV-/Batterietopologie und
zugeordneten Batterieregler konfigurieren. Leistungseinheiten und Vorzeichen muessen
zum bestehenden Batterieregler passen. Die normale Lizenz an genau diese Installation
binden; es werden keine festen fremden Anlagen- oder Objektkennungen uebernommen.
Die automatische Ersteinrichtung unterstuetzt eindeutig getrennte AC-PV- und
Batteriemessungen aus den bereits unterstuetzten M-Bus-/ModBus-Adaptern. Die
physische Batterie wird einmal abgezogen, kein virtueller Speicher oder SDL-Kanal
erfunden. Die bestaetigte physische Rueckmeldung wird fuer diese Topologie ohne
zusatzlichen V4-Konfigurationsschalter zugeordnet. Individuelle bestehende
Rueckmeldungs- und Messkonfigurationen haben Vorrang.
Fuer das erste Modell gelten mindestens 24 nutzbare aequivalente Messstunden,
95 Prozent Fensterabdeckung und maximal 5 Sekunden unbedeckte Zeit pro Fenster.
Das ist keine Garantie fuer Bereitschaft nach genau 24 Stunden. Fehlende Daten,
Preise, unklare Topologie oder eine fehlende Geraeteschutzpruefung bleiben Sperrgruende.
Neue Anlagen duerfen gekennzeichnete Peak-/Viertelstundenschaetzungen aus echten,
lueckenlos passenden Zaehlerdaten verwenden. Das erzeugt keinen Abrechnungsnachweis.
Vor normaler aktiver Regelung ist insbesondere der separat dokumentierte
unabhaengige Geraete-Watchdog erforderlich. Ein alter Testverzicht, eine modellierte
virtuelle Batterie oder eine abgelaufene Testfreigabe werden nicht in eine
Produktionsfreigabe umgewandelt. Spezialanlagen mit SDL, geteiltem Speicher oder
Hybrid-/DC-Bilanz benoetigen weiterhin ihre explizite fachliche Zuordnung.
## Migration und Rueckfall
- Alte Netzfahrplan-Einstellungen und Archive bleiben gespeichert, werden aber
nicht mehr zum Abruf oder zur Regelung des alten Fahrplans verwendet.
- Eine alte Netzfahrplan- oder Testfreigabe aktiviert den neuen Schalter niemals.
- Datensatzkennungen sind an Installation und Messzuordnung gebunden. Eine
Aenderung der Zuordnung stoppt die automatische Einrichtung zur Pruefung;
vorhandene Rohdaten, Cursor und individuelle Servereinstellungen bleiben erhalten.
- Lernen ausschalten stoppt Versand und serverseitiges Lernen. Der aktive
Netzfahrplan benoetigt weiterhin eingeschaltetes Lernen und frische Daten.
- Bei fehlendem oder ungueltigem Plan erfolgt keine neue V4-Korrektur. Die normale
lokale Managerregelung bleibt erhalten. Ein normaler Batteriebefehl kann daher
weiterhin ungleich null sein; Fahrplan aus bedeutet nicht Batterie gesperrt.
- Ein abgelehnter Verbraucher-Stellbefehl wird nicht bestaetigt. Die korrigierte
Batterie wird zuletzt uebergeben; bei vorherigem Fehler gilt ihre normale Zuteilung.
- Empfangsquittungen sind keine Ausfuehrungsquittungen. Die urspruengliche
Empfangszeit einer behaltenen Planhuelle wird niemals erneuert.
## Auslieferung und Abnahme
Backend, authentifizierte Portal-Bridge, Prognose-Publisher, Portal-Assets sowie
Manager und neue Hilfsklassen gemeinsam ausliefern. Neue Datentabellen sind additiv;
vor einer produktiven Umstellung konsistente Datenbanksicherung mit geprueftem
Ruecksetzweg erstellen. Alte Installationsskripte nicht blind wiederverwenden.
Dieses Aenderungspaket ist Entwicklungsarbeit, kein ausgefuehrter Anlagenrollout.
Die parallel laufenden Batterie-/Neustartarbeiten in Lihrenmoos bleiben unberuehrt.
Vor einem spaeteren Rollout gegen den dann aktuellen Batteriestand abgleichen,
Quellen und Konfiguration sichern und zuerst passiv pruefen. Ein echter Stelltest
braucht eine aktuelle ausdrueckliche Freigabe und eine separate Abnahme von
Tracking, Planwechsel, Kommunikationsausfall und sicherem Rueckfall.
## Pull-Request-Kurztext
Vereinheitlicht V4-Prognose, Ersteinrichtung und lokale Batteriekorrektur zu einem
Zwei-Schalter-Workflow. Erhaelt bestehende Daten und Schutzgrenzen, entfernt den
alten Fahrplan aus der aktiven Managerregelung und zeigt im Portal dieselben
eingefrorenen Planungsgrundlagen. Keine Aktivierung, Installation oder Erneuerung
einer Testfreigabe durch das Aenderungspaket.
+53
View File
@@ -68,6 +68,59 @@ Nein. Dokumentationsabgleich und Modulupdate sind getrennt. IP-Symcon-Module wer
Prüfe die drei Pflichtzähler, deren Archivierung, die Einheit beziehungsweise den Faktor und den gewählten Zeitraum. Das Modul benötigt Energiezähler, nicht nur momentane Leistungsmessungen.
## Wo stehen Neuerungen und bekannte Fehler?
In der [Kanal- und Fehlerübersicht](versionen.md), den Changelogs und den
Modulreferenzen. Dort werden behobene Codefehler, noch offene Betriebsfehler
und fehlende Abnahmen getrennt. Ein dokumentierter Fix ist kein Beleg dafür,
dass die eigene Anlage bereits aktualisiert wurde.
## Warum fehlen in Stable die beschriebenen Module?
Beim Abgleich vom 8. Oktober 2026 enthält `main` in beiden Repositories noch
die Bibliotheksgrundlage. Die implementierten Module liegen in `beta` und
`develop`. Kanalwechsel nur nach der Freigabe für deine Anlage durchführen;
die Website nennt ihren tatsächlichen Quellkanal.
## Bedeutet unbekannte Autarkie einen Messwert von null?
Nein. Bei einer nicht eindeutig bestimmbaren Energieaufteilung, etwa mit einem
separaten SDL-Zweig, ist eine Prozentzahl nicht belastbar. Das Energiediagramm
zeigt dann die vorhandenen Zähler weiter, aber keine erfundenen Energieanteile.
## Warum zeigt ein aktiver Modulstatus trotzdem keine frischen V4-Werte?
Status `102` bestätigt nicht, dass alle Timer laufen. Am 6. Oktober wurde nach
Modul-Reload ein Batterietimerproblem dokumentiert; am 7. Oktober liefen die
periodischen Timer bei der späteren Nachkontrolle wieder. Die Ursache und
Wiederholbarkeit nach Reload bleiben offen. Vor jedem separat freigegebenen
V4-Test müssen automatischer Timerlauf, Rückmeldung und Stopp nachgewiesen
sein. Keine Schutzgates oder Timer durch zusätzliche Aufrufskripte umgehen.
## Warum startet die Phasenprobe trotz Solarüberschuss nicht?
Bei unbekannten Phasen bietet eine zugeordnete Stand-Alone-Station zunächst
`[0,4104] W` an. Die Probe benötigt eine entsprechende Managerzuteilung sowie
gültige Freigaben und frische Gerätedaten. 3000 W Restbudget reichen nicht.
Im Peakbetrieb mit Solarladen bleibt `[0]` maßgeblich. Eine 0-W-Messung beweist
keine Einphasigkeit. [Details und Beispiele](../module/Ladestation-Stand-Alone/README.md#beispiele-zur-diagnose).
## Bedeutet 0 W, dass ein Fahrzeug vollständig geladen ist?
Nein. Solarpausen, fehlendes Budget oder Autorisierungswartezeit können ebenfalls
0 W ergeben. Seit `5885681` genügt ein niedriger Strom nicht mehr für
`FahrzeugGeladen`. go-e benötigt ein aktuelles explizites Ladeende ohne relevante
Last; Pico besitzt kein eindeutiges Voll-Signal. Historische Pico-Ereignisse
erscheinen getrennt als `Geraetehinweis`.
## Warum hat das Energiediagramm nach dem Speichern andere Farben?
Ältere Symcon-8-Farbfelder waren an Stringwerte gebunden und konnten diese
überschreiben. Seit `2ce95e7` verwendet das Formular native Integer-Farbfelder
und synchronisiert vorhandene Stringwerte. Bereits falsch gespeicherte Farben
müssen gezielt neu gewählt werden. Gleiche Grund- und Akzentfarben ergeben
einen einfarbigen Hintergrund; die Zähler bleiben unverändert.
## Welche Daten sollte ich bei einem Fehler bereithalten?
Notiere Modulname, Version beziehungsweise Kanal, Instanzstatus, genaue Fehlermeldung, Zeitpunkt und die letzten Änderungen. Ergänze relevante Messwerte mit Einheiten. Entferne Lizenzcodes, Kontodaten, Passwörter und Tokens aus Screenshots und Protokollen.
+24
View File
@@ -46,6 +46,30 @@ Die Optionen für Energy Pie, Diagramme, Energy Facts und Energiefluss verwalten
Prüfe Lizenzstatus, Netzleistung, Verbraucherstatus und Störtext bei ausgeschalteter Regelung. Nimm zunächst einen Verbraucher unter Aufsicht in Betrieb und vergleiche Sollwert mit physischem Verhalten. Prüfe außerdem Abschaltung und Verhalten bei fehlenden Messwerten, bevor weitere Geräte hinzukommen.
## Prognose, SDL und neue Anzeigen
Im bestehenden Bereich **Prognose / Forecast** findest du jetzt Planempfang,
Vorschau und die bewusste Testbedienung. Das Aktivieren der Prognosetelemetrie
startet keine Netzfahrplanregelung. Der alte Schalter `NetzfahrplanAktiv` und
der neue begrenzte V4-Test sind unterschiedliche Funktionen. Vor einer
Aktivierung gelten die [bekannten Betriebsgrenzen](versionen.md).
Die Portal-Prognose zeigt die gespeicherte Eingangsprognose getrennt vom
ausführbaren Netz-/Batterieplan. Noch nicht veröffentlichte Preise verkürzen
den ausführbaren Horizont. Alte Pläne ohne vollständige `forecastPoints`
zeigen Hinweise und Lücken, keine künstlich verlängerten Stellwerte.
SDL kann mit eigener Istleistung und optionalem SOC berücksichtigt werden.
Die Hauslast wird unabhängig von den Anzeigeschaltern bereinigt. Bei aktiver SDL
können PV-Eigenverbrauch und Autarkie nicht eindeutig bestimmbar sein; dies
erscheint im Energy Pie ausdrücklich als unbekannt. Zuerst Utils, danach EMS
aktualisieren. Bestehende Visualisierungsschalter nicht zum Erzwingen einer
Migration aus- und wieder einschalten.
Die [Manager-Referenz](../module/Manager/README.md) enthält alle zusätzlichen
Properties, Diagnosevariablen, dynamischen Anzeige-Idents sowie ein rein
lesendes Schnellausführungsbeispiel.
## Im Alltag
`Aktiv` schaltet die Manager-Regelung ein oder aus. `Betriebsart` zeigt **Inaktiv**, **PV** oder **Peak**. Verbraucher können eigene Mindestzeiten, Temperaturanforderungen und lokale Schutzfunktionen haben. Ein deaktivierter Manager ist deshalb kein universeller elektrischer Not-Aus. Verwende für Arbeiten an der Anlage die vorgesehenen technischen Sicherheitsmaßnahmen.
+13
View File
@@ -8,6 +8,19 @@ Wähle die archivierten, fortlaufenden Zähler für Produktion, Einspeisung und
Wähle einen Zeitraum mit vorhandenen Archivdaten und prüfe Energiebilanz, Eigenverbrauch und Autarkie. Fehlende Archivdaten werden als Diagnose angezeigt. [Energiediagramm-Referenz](../../../Enelix-Utils/docs/module/Energiediagramm/README.md).
Bei nicht eindeutig abgrenzbarer Energieaufteilung setzt eine passende
Integration `EnergieanteileBerechenbar=false`. Zähler bleiben sichtbar,
Autarkie und Eigenverbrauch erscheinen unbekannt statt als erfundene Prozentwerte.
Beispiel: Zusätzliche Speicherflüsse können einen Netzbezug von 20 kWh bei nur
10 kWh gemessenem Hausverbrauch erklären; daraus folgt keine sichere Hausquote.
Die beiden Diagrammfarben und drei Hintergrundfarben sind in der Konfiguration
wählbar. Gleiche Werte bei allen drei Hintergrundfarben ergeben eine einfarbige
Fläche. Native Integer-Farbfelder mit Suffix `RGB` synchronisieren ältere
Stringwerte; gleichzeitig geänderte Integerwerte haben Vorrang. Bereits
fehlerhaft gespeicherte Altfarben werden nicht erraten. Die normale Kachel ist
unter Symcon 8 zu verwenden, falls der angebotene Vergrösserungsdialog leer bleibt.
## Shelly Modul
Richte zuerst den nativen MQTT-Datenfluss in IP-Symcon und MQTT am Shelly-Gerät ein. Das Modul verarbeitet Shelly-NG-Meldungen der Generationen 2, 3 und 4; alte Gen1-Topics sind nicht Teil dieser Anbindung.
+129
View File
@@ -0,0 +1,129 @@
# Versionen, Neuerungen und Fehlerstatus
Stand des Dokumentationsabgleichs: **8. Oktober 2026**. Die Quellbasis bezeichnet
den geprüften Funktionsstand vor den anschliessenden Dokumentationscommits.
Die Kopfzeile dieser Website nennt die tatsächlich veröffentlichte Dokumentation.
## Die drei Kanäle
| Kanal | Enelix EMS | Enelix Utils | Bedeutung |
| --- | --- | --- | --- |
| Testing / `develop` | `5885681` | `2ce95e7` | Aktuelle Entwicklung; Funktionen kontrolliert prüfen. |
| Beta / `beta` | `5885681` | `2ce95e7` | Derzeit gleicher Code wie Testing; anlagenspezifische Abnahme bleibt erforderlich. |
| Stable / `main` | `b6253f9` | `51bcf9d` | Noch Bibliotheksgrundlagen: EMS-Vertrag 3.0, keine installierbaren EMS-/Utils-Module. |
**Stable ist derzeit kein vollständiges EMS-Paket.** Die aktuellen Modul-
anleitungen dieser Website beziehen sich auf den oben angezeigten Quellkanal,
nicht automatisch auf alle drei Branches. Ein Dokumentationsupdate übernimmt
keinen Testing-Code nach Stable und aktualisiert keine laufende Anlage.
Die Original-READMEs sind kanalgenau erreichbar:
[EMS Testing](https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/README.md),
[EMS Beta](https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/beta/README.md),
[EMS Stable](https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/main/README.md),
[Utils Testing](https://git.belevo.ch/ENELIX/Enelix-Utils/src/branch/develop/README.md),
[Utils Beta](https://git.belevo.ch/ENELIX/Enelix-Utils/src/branch/beta/README.md),
[Utils Stable](https://git.belevo.ch/ENELIX/Enelix-Utils/src/branch/main/README.md).
## Neuerungen in Testing und Beta
| Bereich | Veröffentlichtes Verhalten | Referenz |
| --- | --- | --- |
| Manager | PV-/Peak-Regelung, Lizenzkontingente, Topologie, Einspeisebegrenzung, Telemetrie und Störungsüberwachung. | [Manager](../module/Manager/README.md) |
| Leistungsverteilung | Bei gleicher Priorität kleinste nächste absolute Leistungsstufe; Energiebezug entscheidet erst bei Gleichstand. | [Verteilbeispiel](../module/Manager/README.md#verteilalgorithmus) |
| SDL | Separate Leistung und optionaler SOC, bereinigte Hauslast sowie eigene Energiefluss-/Diagrammreihen. | [SDL und Einheiten](../module/Manager/README.md#sdl--regelenergie) |
| Prognosebedienung | Empfang, Vorschau und bewusste Testbedienung im bestehenden Prognose-/Forecast-Bereich. Kein automatischer Start. | [Manager-Anleitung](manager.md) |
| Prognosekurven | Vollständige gespeicherte Eingangsprognose getrennt vom ausführbaren Preis-/Planungshorizont; PV, Last, SDL-Szenario, Netz, Batterie, SOC, Preise und Kosten. | Hinweise unten |
| Energieanteile | Nicht bestimmbare Autarkie-/Eigenverbrauchsanteile ausdrücklich unbekannt; Zähler bleiben sichtbar. | [Energiediagramm](https://git.belevo.ch/ENELIX/Enelix-Utils/src/branch/develop/docs/module/Energiediagramm/README.md) |
| Energy-Pie-Farben | Fünf native Farbauswahlen, drei konfigurierbare Hintergrundfarben, Symcon-8-Migration und bidirektionale String-/Integer-Synchronisierung. | [Farben und Migration](https://git.belevo.ch/ENELIX/Enelix-Utils/src/branch/develop/docs/module/Energiediagramm/README.md#darstellung) |
| Ladestatus und Phasen | Explizites Ladeende statt Nullwert-Erkennung, geprüfte Gerätezeitstempel und Phasenprobe nur innerhalb des Managerbudgets. | [Ladestation Stand-Alone](../module/Ladestation-Stand-Alone/README.md) |
| Verbraucher | Batterie, Warmwasser, Pufferspeicher, einstufiger Verbraucher, Wärmepumpe und beide Ladestationsvarianten mit lokalen Schutzbedingungen. | [EMS-Module](../module/README.md) |
| Utils | Verbrauchskostenreport, virtuelle Batterie, CC100-I/O, Energiediagramm, VGT und generisches Shelly-NG-MQTT. | [Utils-Anleitung](utils.md) |
Der bisherige Forecast-Datenweg und Netzfahrplan V4 sind nicht identisch.
Bei V4 umfasst die Modellfamilie `3` PV 1 und Last 2, `13` PV 10 und Last 11,
`23` PV 21 und Last 22. Die wirtschaftliche Vergleichsauswertung hat einen
begrenzten Replay-Umfang; sie ist kein nachgewiesener rollender Produktionsbetrieb.
Bis zu 48 Stunden Eingangsprognose bedeuten nicht, dass für 48 Stunden gültige
Preise oder ausführbare Stellwerte vorliegen. Ein gekennzeichnetes SDL-Szenario
ist kein bestätigter zukünftiger SDL-Fahrplan. Fehlende Werte bleiben Lücken.
## Belegte Fehlerkorrekturen
| Fehlerbild | Korrektur im Quellstand | Was weiter zu prüfen ist |
| --- | --- | --- |
| Veraltete Sollleistung nach verworfener Vorgabe | Gespeicherte und sichtbare Sollleistung wird bereinigt. | Lokale Ersatz-/Schutzfunktionen und reale Istleistung separat prüfen. |
| Gleichrangige Verbraucher werden ungleich versorgt | Schrittweise Leistungsstufenvergabe statt vollständiger Vergabe nach Energiegruppe. | Mindestzeiten, Sperren und individuelle Leistungsraster bleiben wirksam. |
| Festlasten verfälschen das Restbudget | Bilanzierung anhand gemessener Istleistung. | Messquellen und Alter kontrollieren. |
| Solarpause wird als Ladeende behandelt | Ladeende, Solarpausen, Phasen-/Anlaufzustände und Übergänge korrigiert. | Ladefreigabe, Fahrzeuglimit und Gerätekommunikation prüfen. |
| go-e-Fehler oder alte Pico-Daten werden weiterverwendet | go-e `err`/`car`, Pico-Zustand, Kontakt- und Messzeit werden validiert. Managerdaten erneuern den Gerätecache nicht. | Pico-Autorisierungswartezeit und historisches Ereignis nicht mit aktuellem Gerätefehler verwechseln. |
| Nullleistung erzeugt eine falsche Phasenanzahl | 0 W bleibt unbekannt; frische Dreiphasenströme korrigieren alte Einphasigkeit. Die 6-A-Probe braucht bei Managerzuordnung 4104 W Budget. | Migration verwirft alte unsichere Erkennung einmalig. Freigaben und Solar-/Peak-Grenzen bleiben erhalten. |
| Easee-Werte bleiben nach Stopp stehen | Veraltete Ladeleistung wird genullt; Ereignisverbindung konsistent neu aufgebaut. | Cloud-/Parent-Verbindung bleibt Voraussetzung. |
| V4-Datenkennung unterscheidet `100` und `100.0` | Numerische Normalisierung und eng begrenzte Kompatibilitätsbehandlung. | Keine Originalkennungen oder Cursor manuell ersetzen. |
| Veraltete oder blockierende V4-Rückmeldung | Bestätigter Geräteabruf, getrenntes Sampling und quellengebundener Cache. | Ein implementierter Fix beweist keinen laufenden Timer oder Hardware-Watchdog. |
| V4-Plan wird bei Zwischenzustand verpasst | Empfangs-Retry und Initialisierungsdrossel korrigiert. | Abgelaufene Pläne und HTTP-429 bleiben gesperrt bzw. gedrosselt. |
| Nicht bestimmbarer PV-Anteil erscheint als Zahl | Explizite Darstellung unbekannter Energieanteile. | Geeignete Zähler und passende Messgrenzen bleiben erforderlich. |
| Symcon-8-Farbfelder überschreiben Stringfarben | Native Integer-Farbproperties mit Synchronisierung vorhandener Stringwerte. | Bereits falsch gespeicherte schwarze Werte werden nicht automatisch rekonstruiert. |
Die vollständigen Quellverweise stehen im [EMS-Changelog](../../CHANGELOG.md)
und im [Utils-Changelog](https://git.belevo.ch/ENELIX/Enelix-Utils/src/branch/develop/CHANGELOG.md).
## Bekannte Grenzen und offene Betriebsfehler
**V4-Batterietimer:** Am 6. Oktober 2026 wurde nach Modul-Reload erneut eine
Timerblockade beobachtet. Die spätere dokumentierte Nachkontrolle vom 7. Oktober
2026, 20:34 Uhr Europe/Zurich, zeigt laufende periodische Timer bei aktiven
Instanzen. Der frühere Stillstand wird deshalb nicht als heutiger Live-Zustand
behauptet. Ursache und Wiederholbarkeit nach Reload sind weiter offen; Status
`102` allein ist kein Funktionsnachweis. Kein Timerersatz und keine automatische
Aktivierung aus dieser Anleitung. Es fand hier keine neue Anlagenprüfung statt.
**V4-Abnahme:** Physische Soll-/Ist-Nachführung, sicherer Null-/Stopp-Pfad,
Weiterbetrieb über mehrere Planerneuerungen und unabhängiger Geräte-Ausfallschutz
sind nicht vollständig abgenommen. Die frühere befristete Testfreigabe endete
am 7. Oktober 2026 um 17:38 Uhr Europe/Zurich. Sie ist zum Dokumentationsstand
abgelaufen. Diese Dokumentation verlängert sie nicht und erklärt V4 nicht
produktionsreif.
**Ladestationen:** `5885681` ist auf Testing/Beta veröffentlicht. Der jüngste
hier verwendete Installationsbericht vom 7. Oktober beschreibt nur eine
gesicherte Vorbereitung, noch keine Installation dieses Fixes. Nach einem
gesondert freigegebenen Update bleiben Ladepause/Wiederanlauf, frische Statusdaten,
Phasenerkennung und physischer Stopp an echten Fahrzeugen abzunehmen. Die
ursprünglichen Ladeunterbrechungen sind nicht zweifelsfrei auf eine einzige
Ursache zurückgeführt. Behobene Codefehler sind kein nachträglicher Ablaufbeweis.
**Forecast-Backend:** Der verwendete Rolloutbericht vom 6. Oktober meldet Portal-
Assets aktualisiert, das neue Backend-Image mit vollständigen `forecastPoints`
aber noch ausstehend. Alte Pläne können deshalb gekennzeichnete fehlende Kurven
haben. Dies ist eine datierte Rolloutgrenze, kein in dieser Dokumentationsarbeit
neu gemessener Live-Zustand. Ein späterer erfolgreicher Backend-Rollout ist
für diesen Abgleich nicht belegt. Kein stilles Auffüllen fehlender Werte.
**Weitere Grenzen:** Produktiv signierte Lizenz-Leases, Untermanager-
Messabgrenzung sowie gerätespezifische Feldabnahmen bleiben separat zu prüfen.
Die vorbereitete Wärmepumpen-Bestelloberfläche erfordert den passenden Portal-
Backend-Rollout. Die Einbindung einer VGT-Anwendung ins Portal ist noch keine
durch das vorhandene MQTT-Modul belegte Funktion.
**Energiediagramm:** Der von Symcon 8 angebotene Vergrösserungsdialog kann leer
bleiben; dann die normale Kachel verwenden. Der zusätzliche HTML-SDK-Vollbildmodus
ist erst ab Kernelversion 9.0 aktiviert. Fehlende Archivwerte und unbekannte
Energieanteile sind getrennte Zustände und dürfen nicht als null ersetzt werden.
Der Abgleich basiert auf veröffentlichtem Code, Repository-Dokumentation und
datierten Test-/Installationsberichten. Die separate Gitea-Issue-Liste konnte
in diesem Lauf nicht authentifiziert gelesen werden; eine vollständige
Erfassung aller dort gemeldeten offenen Fehler wird deshalb nicht behauptet.
## Sicher aktualisieren und Fehler melden
1. Installierten Kanal, Version und vorhandene lokale Änderungen prüfen.
2. Konfiguration, Zähler und individuelle Visualisierungen sichern.
3. Für die neuen SDL-Anzeigen zuerst Utils, danach EMS im freigegebenen Kanal aktualisieren.
4. Quellen, Vorzeichen, Timer und Rückmeldungen prüfen, bevor Steuerung freigegeben wird.
5. Bei einem Fehler Modul, Kanal, Zeitpunkt, Status und bereinigte Diagnose notieren; keine Zugangsdaten teilen.
Ein Git-Push ist kein Anlagenupdate. Ein grüner Softwaretest ist keine
Hardwareabnahme. Die bestehenden Beispiel-IDs und Screenshots sind niemals
ungeprüft als Anlagenkonfiguration zu übernehmen.
@@ -9,6 +9,28 @@ require_once __DIR__.'/NetzfahrplanV4Geraeteabruf.php';
/** Shared confirmed physical reading used by receiver and battery before a trial command. */
trait BatterieNetzfahrplanV4RueckmeldungTrait
{
/** Existing specialist mappings always win; automatic lookup is passive. */
private function effektiveV4RueckmeldungKonfiguration(?string $property = null): string
{
$json = $property ?? $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
if (!in_array(trim($json), ['', '{}'], true)) return $json;
$managers = $this->zugeordneteManagerIDs();
if (count($managers) !== 1
|| (!function_exists('ENELIX_GetV4RueckmeldungKonfiguration')
&& !function_exists(__NAMESPACE__ . '\\ENELIX_GetV4RueckmeldungKonfiguration'))) return '';
$json = ENELIX_GetV4RueckmeldungKonfiguration($managers[0], $this->InstanceID);
if (!is_string($json) || strlen($json) > 65536) {
throw new \RuntimeException('feedback_auto_configuration_invalid');
}
if (in_array(trim($json), ['', '{}'], true)) return '';
$config = NetzfahrplanV4Rueckmeldung::configuration(json_decode($json, true, 32, JSON_THROW_ON_ERROR));
if ($config['managerId'] !== $managers[0] || $config['batteryInstanceId'] !== $this->InstanceID
|| $config['mode'] !== 'physical_sum' || $config['allowEstimatedForTrial']) {
throw new \RuntimeException('feedback_auto_configuration_binding_invalid');
}
return json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
}
private function registriereV4Rueckmeldung(): void
{
$this->RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}');
@@ -21,28 +43,41 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait
$this->SetBuffer('V4ConfirmedFeedback', '');
$this->SetBuffer('V4ConfirmedFeedbackConfig', '');
$this->SetBuffer('V4ConfirmedFeedbackMono', '');
$configured = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration')), ['', '{}'], true);
$this->SetTimerInterval('NetzfahrplanV4RueckmeldungAktualisieren', $configured ? 1000 : 0);
$this->SetBuffer('V4ConfirmedFeedbackContext', '');
// Assignment can happen after battery ApplyChanges. Empty automatic
// configuration returns before device I/O; the existing timer discovers it.
$this->SetTimerInterval('NetzfahrplanV4RueckmeldungAktualisieren', 1000);
}
/** No transport I/O under the battery control lock. Original sample times never move. */
/** Strictly local: never call the Manager from a Manager -> battery cache read. */
private function v4BatterieRueckmeldung(): array
{
$json = $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
$property = $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
$automatic = in_array(trim($property), ['', '{}'], true);
$context = json_decode($this->GetBuffer('V4ConfirmedFeedbackContext'), true);
$json = is_array($context) && is_string($context['configuration'] ?? null) ? $context['configuration'] : '';
if ($json === '') throw new \RuntimeException('feedback_configuration_unavailable');
$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
|| ($context['propertyHash'] ?? null) !== hash('sha256', $property)
|| ($context['autoMode'] ?? null) !== $automatic
|| (!$automatic && $json !== $property)
|| $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)) {
$config = NetzfahrplanV4Rueckmeldung::configuration(json_decode($json, true, 32, JSON_THROW_ON_ERROR));
if ($config['batteryInstanceId'] !== $this->InstanceID
|| ($automatic && ($config['mode'] !== 'physical_sum' || $config['allowEstimatedForTrial']))
|| ($sample['configHash'] ?? null) !== NetzfahrplanV4Rueckmeldung::fingerprint($config)) {
throw new \RuntimeException('feedback_cache_binding_invalid');
}
foreach (['installationId', 'assetId', 'managerId', 'batteryInstanceId'] as $key) {
if (($sample[$key] ?? null) !== $config[$key]) 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']);
@@ -56,11 +91,25 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait
/** Independent acquisition; never waits behind another read or takes the actuator lock. */
private function aktualisiereV4Rueckmeldung(): void
{
$json=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
$property=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
$automatic=in_array(trim($property), ['', '{}'], true);
try {
$json=$this->effektiveV4RueckmeldungKonfiguration($property);
if ($json==='') {
$this->SetBuffer('V4ConfirmedFeedback', '');
$this->SetBuffer('V4ConfirmedFeedbackConfig', '');
$this->SetBuffer('V4ConfirmedFeedbackMono', '');
$this->SetBuffer('V4ConfirmedFeedbackContext', '');
return;
}
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');
} catch (\Throwable $error) {
$this->SetBuffer('V4ConfirmedFeedback', '');
throw $error;
}
$lock='ENELIX.V4.Feedback.'.$this->InstanceID;
if (!IPS_SemaphoreEnter($lock,0)) return;
@@ -102,9 +151,21 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait
}
}
if (!is_array($result)) throw new \RuntimeException('feedback_snapshot_unavailable');
$this->SetBuffer('V4ConfirmedFeedback',json_encode($result,JSON_THROW_ON_ERROR));
$managers=$this->zugeordneteManagerIDs();
if ($this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration') !== $property
|| !in_array($c['managerId'], $managers, true)
|| ($automatic && count($managers) !== 1)) {
throw new \RuntimeException('feedback_configuration_changed_during_read');
}
// Publish the sample last so a concurrent cache read cannot mix contexts.
$this->SetBuffer('V4ConfirmedFeedback', '');
$this->SetBuffer('V4ConfirmedFeedbackContext', json_encode([
'configuration' => $json, 'propertyHash' => hash('sha256', $property),
'autoMode' => $automatic,
], JSON_THROW_ON_ERROR));
$this->SetBuffer('V4ConfirmedFeedbackConfig', hash('sha256', $json));
$this->SetBuffer('V4ConfirmedFeedbackMono', (string) (hrtime(true) / 1e9));
$this->SetBuffer('V4ConfirmedFeedback',json_encode($result,JSON_THROW_ON_ERROR));
} catch (\Throwable $error) {
$this->SetBuffer('V4ConfirmedFeedback', '');
throw $error;
+12 -10
View File
@@ -29,16 +29,18 @@ trait ManagerNetzfahrplanV4DatenTrait
private function konfiguriereNetzfahrplanV4Daten(): void
{
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 0);
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
if (!$this->prognoseLernenAktiv() || $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') !== '') return;
try {
$this->v4DatenKonfiguration();
$config = $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);
$ages = array_column($config['accounting']['sources'], 'maxAgeSeconds');
$seconds = max(1, min(30, (int) floor(min($ages) / 2)));
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', $seconds * 1000);
} catch (\Throwable $e) {
$this->v4DatenStatus(['status'=>'configuration_error','errorType'=>get_class($e)]);
}
@@ -47,10 +49,10 @@ trait ManagerNetzfahrplanV4DatenTrait
private function v4DatenKonfiguration(): array
{
$id=$this->ReadAttributeString('LizenzInstallationID');
$dataset=$this->ReadPropertyString('NetzfahrplanV4Datensatz');
$dataset=$this->prognoseV4Datensatz();
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=json_decode($this->prognoseV4Messkonfiguration(),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;
@@ -74,12 +76,12 @@ trait ManagerNetzfahrplanV4DatenTrait
$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));
'datasetId'=>$this->prognoseV4Datensatz(),'controlEnabled'=>false],JSON_THROW_ON_ERROR));
}
public function ErfasseNetzfahrplanV4Daten(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
if (!$this->prognoseLernenAktiv() || $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') !== '') return;
$lock='ENELIX.V4.ApplicationData.'.$this->InstanceID;
if (!IPS_SemaphoreEnter($lock,0)) return;
try {
@@ -185,7 +187,7 @@ trait ManagerNetzfahrplanV4DatenTrait
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);
$payload=json_encode(['version'=>1,'datasetId'=>$this->prognoseV4Datensatz(),'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 {
@@ -197,11 +199,11 @@ trait ManagerNetzfahrplanV4DatenTrait
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['datasetId']??null)!==$this->prognoseV4Datensatz()
||($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'),
'datasetId'=>$this->prognoseV4Datensatz(),
'journalDuplicateTimestampsSkipped'=>$skippedDuplicateTimestamps,
'journalAcknowledgedTimestampsSkipped'=>$skippedAcknowledgedTimestamps];
} finally { curl_close($h); }
+22 -15
View File
@@ -41,9 +41,7 @@ trait ManagerNetzfahrplanV4EmpfangTrait
private function konfiguriereNetzfahrplanV4Vorschau(): void
{
$enabled = $this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
&& $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
&& !$this->ReadPropertyBoolean('NetzfahrplanAktiv');
$enabled = $this->prognoseLernenAktiv();
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', $enabled ? 10000 : 0);
$this->SetTimerInterval('NetzfahrplanV4EmpfangAktualisieren', $enabled ? 10000 : 0);
$this->aktualisiereNetzfahrplanV4Vorschau();
@@ -52,26 +50,31 @@ trait ManagerNetzfahrplanV4EmpfangTrait
/** Called before the next operation upload, so the preceding completed plan can be seen. */
private function empfangeNetzfahrplanV4(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')) return;
if (!$this->prognoseLernenAktiv()) 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;
$interval = $this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv')
|| ($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')) {
if (!$this->prognoseLernenAktiv()) {
throw new RuntimeException('Freigabe waehrend des Abrufs geaendert.');
}
if (($response['installationId'] ?? null) !== $this->ReadAttributeString('LizenzInstallationID')) {
throw new RuntimeException('Serverantwort gehoert zu einer anderen Installation.');
}
if (is_array($response['workflow'] ?? null)) {
$this->WriteAttributeString('PrognoseV4Serverstatus', json_encode(
['receivedAt' => $receipt, 'workflow' => $response['workflow']], JSON_THROW_ON_ERROR));
}
// 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.
@@ -81,7 +84,7 @@ trait ManagerNetzfahrplanV4EmpfangTrait
$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'];
'pending', 'settings', 'plan', 'families', 'lastRun', 'controlledTrial', 'workflow'];
$safe = array_intersect_key($response, array_flip($keys));
$this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode(
['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR));
@@ -107,7 +110,8 @@ trait ManagerNetzfahrplanV4EmpfangTrait
$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')
$keepPrevious = ($this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv')
|| ($session['kind'] ?? null) === 'local_active_test_consent')
&& $previous['receivedAt'] <= time()
&& time() - $previous['receivedAt'] <= NetzfahrplanV4Planpruefung::MAX_RECEIPT_AGE;
}
@@ -181,11 +185,15 @@ trait ManagerNetzfahrplanV4EmpfangTrait
&& 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')) {
if (count($controllers)===1 && function_exists('ENELIX_GetV4BatterieRueckmeldungCache')) {
$controller=$controllers[0];$configured=IPS_GetProperty($controller,'NetzfahrplanV4RueckmeldungKonfiguration');
if (in_array(trim((string)$configured),['','{}'],true)
&& method_exists($this,'GetV4RueckmeldungKonfiguration')) {
$configured=$this->GetV4RueckmeldungKonfiguration($controller);
}
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);
$f=json_decode(ENELIX_GetV4BatterieRueckmeldungCache($controller),true,32,JSON_THROW_ON_ERROR);
$feedbackNow=time();
if (($f['status']??null)==='unavailable') {
$reason=is_string($f['reason']??null)?$f['reason']:'unknown';
@@ -222,10 +230,9 @@ trait ManagerNetzfahrplanV4EmpfangTrait
public function GetNetzfahrplanV4Vorschau(): string
{
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
if (!$this->prognoseLernenAktiv()) {
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)) {
+22 -4
View File
@@ -40,7 +40,7 @@ trait ManagerNetzfahrplanV4Trait
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
$manager[$key] = $this->ReadPropertyFloat($key);
}
$manager['V4ControlPlanSource'] = $this->leseJsonAttribut('Netzfahrplan')['sourceModel'] ?? 'legacy-unspecified';
$manager['V4ControlPlanSource'] = 'netplan-v4';
$manager['EinspeisebegrenzungAktiv'] = $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv');
$manager['NetzladenErlaubt'] = $this->ReadPropertyBoolean('NetzfahrplanV4NetzladenErlaubt');
$manager['Monatsgrenzen'] = json_decode($this->ReadPropertyString('Monatsgrenzen'), true, 512, JSON_THROW_ON_ERROR);
@@ -48,6 +48,20 @@ trait ManagerNetzfahrplanV4Trait
$manager['V4BezugszaehlerQuellen'] = json_decode(
$this->ReadPropertyString('NetzfahrplanV4BezugszaehlerQuellen'), true, 512, JSON_THROW_ON_ERROR
);
if ($manager['V4BezugszaehlerQuellen'] === [] && $this->ReadPropertyInteger('NetzbezugEnergieVariableID') > 0) {
$meterID = $this->ReadPropertyInteger('NetzbezugEnergieVariableID');
if (!IPS_VariableExists($meterID)) throw new RuntimeException('Wirkenergie-Bezugszaehler fehlt.');
$meterObject = IPS_GetObject($meterID);
if (!IPS_InstanceExists($meterObject['ParentID'])
|| !in_array(IPS_GetInstance($meterObject['ParentID'])['ModuleInfo']['ModuleID'],
[NetzfahrplanV4Geraeteabruf::MBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE,
NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS], true)) {
throw new RuntimeException('Wirkenergie-Bezugszaehler benoetigt eine direkte physische Messquelle.');
}
$manager['V4BezugszaehlerQuellen'] = [['VariableID' => $meterID,
'ElternID' => $meterObject['ParentID'], 'Ident' => $meterObject['ObjectIdent'],
'FaktorZuKWh' => $this->ReadPropertyFloat('NetzbezugEnergiefaktor'), 'Messgroesse' => 'WirkenergieBezug']];
}
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 512, JSON_THROW_ON_ERROR);
$cache = $this->leseJsonAttribut('VerbraucherCache');
$controllers = [];
@@ -64,11 +78,15 @@ trait ManagerNetzfahrplanV4Trait
$entry = $cache[(string) $id] ?? [];
$c['EmpfangenAm'] = $entry['EmpfangenAm'] ?? 0;
$c['Verfuegbar'] = $entry['Daten']['Verfuegbar'] ?? false;
if (function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
if (function_exists('ENELIX_GetV4BatterieRueckmeldungCache')) {
$configured = IPS_GetProperty($id, 'NetzfahrplanV4RueckmeldungKonfiguration');
if (in_array(trim((string) $configured), ['', '{}'], true)
&& method_exists($this, 'GetV4RueckmeldungKonfiguration')) {
$configured = $this->GetV4RueckmeldungKonfiguration($id);
}
if (is_string($configured) && !in_array(trim($configured), ['', '{}'], true)) {
try {
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldung($id), true, 32, JSON_THROW_ON_ERROR);
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldungCache($id), true, 32, JSON_THROW_ON_ERROR);
$checkedAt = $feedback['checkedAt'] ?? null;
if (($feedback['usableForTrial'] ?? false) === true
&& ($feedback['deviceReadConfirmed'] ?? false) === true
@@ -128,7 +146,7 @@ trait ManagerNetzfahrplanV4Trait
private function sendeNetzfahrplanV4(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
if (!$this->prognoseLernenAktiv()) {
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
return;
}
+18 -61
View File
@@ -6,7 +6,7 @@ namespace Belevo\EnelixEMS;
use LogicException;
/** Form composition only. Existing configuration, control authority and backend stay unchanged. */
/** Exactly two operator switches; legacy configuration is retained but not exposed. */
final class ManagerPrognoseFormular
{
/** Stable identifiers remain for archives and integrations; operation belongs in Forecast. */
@@ -22,45 +22,25 @@ final class ManagerPrognoseFormular
public static function startErlaubt(array $zustand): bool
{
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
'testErlaubt', 'aktivtestErlaubt', 'watchdogVerzichtErlaubt'] as $feld) {
foreach (['prognoseAktiv', 'lizenziert', 'ready'] 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']);
return true;
}
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.';
$states = ['disabled' => 'Aus', 'collecting' => 'Daten sammeln', 'learning' => 'Lernen',
'ready' => 'Bereit', 'active' => 'Aktiv', 'interrupted' => 'Unterbrochen'];
return ($states[$zustand['state'] ?? 'disabled'] ?? 'Status unbekannt') . ': '
. ($zustand['reason'] ?? 'Aktuellen Status abwarten.');
}
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;
@@ -69,45 +49,22 @@ final class ManagerPrognoseFormular
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' => 'CheckBox', 'name' => 'PrognoseAktiv',
'caption' => '1. Netzdaten senden und Prognosen lernen',
'onChange' => "IPS_RequestAction(\$id, 'FormPrognoseAktiv', \$PrognoseAktiv);"],
['type' => 'CheckBox', 'name' => 'NetzfahrplanRegelungAktiv',
'caption' => '2. Batterie nach Netzfahrplan regeln',
'enabled' => ($zustand['controlRequested'] ?? false) || self::startErlaubt($zustand)],
['type' => 'Label', 'name' => 'PrognoseNetzfahrplanStatus', 'caption' => self::statustext($zustand)],
['type' => 'Label', 'name' => 'PrognoseLizenzInformation',
'caption' => 'V4-Netzziel und Batteriekorrektur. Andere Verbraucher bleiben in der bisherigen Managerregelung.'],
];
$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) {
+294
View File
@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Einrichtung.php';
require_once __DIR__ . '/NetzfahrplanV4Regelung.php';
require_once __DIR__ . '/NetzfahrplanV4Geraeteabruf.php';
require_once __DIR__ . '/NetzfahrplanV4RueckmeldungEinrichtung.php';
/** One learning consent and one local control request. No server-side actuator authority. */
trait ManagerPrognoseWorkflowTrait
{
private function registrierePrognoseWorkflow(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanRegelungAktiv', false);
$this->RegisterAttributeString('PrognoseV4Einrichtung', '{}');
$this->RegisterAttributeString('PrognoseV4Serverstatus', '{}');
$this->RegisterAttributeString('PrognoseV4Regelstatus', '{}');
$this->RegisterAttributeString('PrognoseV4Einrichtungsfehler', '');
$this->RegisterAttributeString('PrognoseV4Verbindungsfehler', '');
$this->RegisterAttributeInteger('PrognoseV4EingerichtetAm', 0);
$this->RegisterTimer('PrognoseWorkflow', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseWorkflow', true);");
}
private function prognoseLernenAktiv(): bool
{
return $this->ReadPropertyBoolean('PrognoseAktiv')
&& $this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN);
}
private function prognoseV4Datensatz(): string
{
$custom = $this->ReadPropertyString('NetzfahrplanV4Datensatz');
if ($custom !== '') return $custom;
return $this->leseJsonAttribut('PrognoseV4Einrichtung')['datasetId'] ?? '';
}
private function prognoseV4Messkonfiguration(): string
{
$custom = $this->ReadPropertyString('NetzfahrplanV4Messkonfiguration');
if (!in_array(trim($custom), ['', '{}'], true)) return $custom;
return json_encode($this->leseJsonAttribut('PrognoseV4Einrichtung')['capture'] ?? new \stdClass(),
JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
}
/** Passive configuration lookup for the battery sampler; no device reads or writes. */
public function GetV4RueckmeldungKonfiguration(int $batteryID): string
{
try {
if (!$this->prognoseLernenAktiv() || $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') !== ''
|| !in_array(trim($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration')), ['', '{}'], true)) return '{}';
$setup = $this->leseJsonAttribut('PrognoseV4Einrichtung');
$capture = $setup['capture'] ?? [];
foreach (($capture['accounting']['sources'] ?? []) as $source) {
if (!IPS_VariableExists($source['variableId'])) return '{}';
$o = IPS_GetObject($source['variableId']);
if ($o['ParentID'] !== $source['parentId'] || $o['ObjectIdent'] !== $source['ident']
|| !IPS_InstanceExists($o['ParentID'])) return '{}';
$module = IPS_GetInstance($o['ParentID'])['ModuleInfo']['ModuleID'];
if (!in_array($module, [NetzfahrplanV4Geraeteabruf::MBUS_DEVICE,
NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS], true)) return '{}';
if ($source['role'] === 'grid' && ($source['variableId'] !== $this->ReadPropertyInteger('NetzleistungVariableID')
|| $source['factorToW'] != $this->ReadPropertyFloat('Netzleistungsfaktor'))) return '{}';
}
$controllers = [];
foreach ($this->aktiveVerbraucherIDs() as $id) {
if (!IPS_InstanceExists($id)
|| IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') continue;
$controller = ['InstanzID' => $id];
foreach (['IstleistungVariableID', 'LadezustandVariableID', 'NetzleistungVariableID', 'MesswertMaxAlter'] as $key) {
$controller[$key] = IPS_GetProperty($id, $key);
}
$controllers[] = $controller;
}
$config = NetzfahrplanV4RueckmeldungEinrichtung::erstellen($capture,
$this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $batteryID,
json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR), $controllers);
return json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
} catch (Throwable) {
return '{}';
}
}
private function vorbereitePrognoseWorkflow(): void
{
$this->WriteAttributeString('PrognoseV4Regelstatus', '{}');
$this->WriteAttributeString('PrognoseV4Serverstatus', '{}');
$this->WriteAttributeInteger('PrognoseV4EingerichtetAm', 0);
$this->WriteAttributeString('PrognoseV4Einrichtungsfehler', '');
$this->WriteAttributeString('PrognoseV4Verbindungsfehler', '');
$this->SetTimerInterval('PrognoseWorkflow', 30000);
if (!$this->ReadPropertyBoolean('PrognoseAktiv')) return;
try {
$custom = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration')), ['', '{}'], true);
if ($custom) {
// Specialist mappings and their server-side dataset are already commissioned.
if ($this->ReadPropertyString('NetzfahrplanV4Datensatz') === '') {
throw new RuntimeException('Individuelle Messzuordnung benoetigt einen eigenen Datensatz.');
}
$this->v4DatenKonfiguration();
return;
}
if ($this->ReadPropertyString('NetzfahrplanV4Datensatz') !== '') {
throw new RuntimeException('Bestehender Datensatz ohne Messzuordnung; Einrichtung vervollstaendigen.');
}
$manager = [
'NetzleistungVariableID' => $this->ReadPropertyInteger('NetzleistungVariableID'),
'Netzleistungsfaktor' => $this->ReadPropertyFloat('Netzleistungsfaktor'),
'MesswertMaxAlter' => $this->ReadPropertyInteger('MesswertMaxAlter'),
'SDLAktiv' => $this->ReadPropertyBoolean('SDLAktiv'),
'SDLLeistungVariableID' => $this->ReadPropertyInteger('SDLLeistungVariableID'),
];
$reader = function (int $id): array {
if (!IPS_VariableExists($id)) throw new RuntimeException('Messquelle fehlt.');
$o = IPS_GetObject($id);$v = IPS_GetVariable($id);
if (($o['ObjectType'] ?? null) !== 2 || !IPS_InstanceExists($o['ParentID'])) {
throw new RuntimeException('Direkte Geraetemessung erforderlich.');
}
$module = IPS_GetInstance($o['ParentID'])['ModuleInfo']['ModuleID'];
// A different library alone does not prove physical metering: gateways
// can publish virtual allocations from a completely separate repository.
$physical = in_array($module, [NetzfahrplanV4Geraeteabruf::MBUS_DEVICE,
NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS], true);
return ['parentID' => $o['ParentID'], 'ident' => $o['ObjectIdent'],
'type' => $v['VariableType'], 'virtual' => !$physical];
};
$configuration = NetzfahrplanV4Einrichtung::erstellen($this->ReadAttributeString('LizenzInstallationID'),
$this->InstanceID, $manager,
json_decode($this->ReadPropertyString('AnlagenWechselrichter'), true, 64, JSON_THROW_ON_ERROR),
json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR), $reader);
$previous = $this->leseJsonAttribut('PrognoseV4Einrichtung');
if ($previous !== [] && ($previous['mappingSha256'] ?? '') !== $configuration['mappingSha256']) {
throw new RuntimeException('Messzuordnung geaendert; bestehende Lerndaten bleiben erhalten. Datensatzmigration erforderlich.');
}
$this->WriteAttributeString('PrognoseV4Einrichtung', json_encode($configuration,
JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
} catch (Throwable $e) {
$this->WriteAttributeString('PrognoseV4Einrichtungsfehler', substr($e->getMessage(), 0, 300));
}
}
/** Network traffic belongs to this slow timer, never to the actuator loop. */
private function aktualisierePrognoseWorkflow(): void
{
$lock = 'ENELIX.V4.Workflow.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 0)) return;
try {
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) return;
$setup = $this->leseJsonAttribut('PrognoseV4Einrichtung');
$custom = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration')), ['', '{}'], true);
if (!$custom && $this->prognoseLernenAktiv() && $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') === '') {
if (isset($setup['dataset']) && time() - $this->ReadAttributeInteger('PrognoseV4EingerichtetAm') > 300) {
$answer = $this->prognoseWorkflowHttp('/setup', ['version' => 1,
'installationId' => $this->ReadAttributeString('LizenzInstallationID'),
'dataset' => $setup['dataset'], 'learningEnabled' => true]);
if (($answer['datasetId'] ?? null) !== $setup['datasetId']
|| ($answer['selectedDatasetId'] ?? null) !== $setup['datasetId']) {
throw new RuntimeException('Server verwendet einen anderen Datensatz; vorhandene Auswahl bleibt erhalten.');
}
$this->WriteAttributeInteger('PrognoseV4EingerichtetAm', time());
}
}
$s = $this->prognoseWorkflowZustand();
$this->prognoseWorkflowHttp('/workflow', ['version' => 1,
'installationId' => $this->ReadAttributeString('LizenzInstallationID'), 'observedAt' => gmdate('Y-m-d\TH:i:s\Z'),
'learningEnabled' => $this->prognoseLernenAktiv(),
'controlRequested' => $this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv'),
'controlActive' => $s['controlActive'], 'reason' => substr($s['reason'], 0, 300)]);
$this->WriteAttributeString('PrognoseV4Verbindungsfehler', '');
} catch (Throwable $e) {
$this->WriteAttributeString('PrognoseV4Verbindungsfehler', substr($e->getMessage(), 0, 300));
} finally {
IPS_SemaphoreLeave($lock);
$this->aktualisierePrognoseBedienung();
}
}
private function prognoseWorkflowHttp(string $suffix, array $body): array
{
if (!in_array($suffix, ['/setup', '/workflow'], true)) throw new RuntimeException('Unzulaessige Workflowaktion.');
$id = $this->ReadAttributeString('LizenzInstallationID');
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
if (!preg_match('/^[0-9a-f-]{36}$/D', $id) || !preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) {
throw new RuntimeException('Lizenz mit dieser Installation verbinden.');
}
$h = curl_init('https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4' . $suffix);
if ($h === false) throw new RuntimeException('Prognoseverbindung nicht verfuegbar.');
$response = '';
try {
curl_setopt_array($h, [CURLOPT_POST => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION),
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int {
if (strlen($response) + strlen($chunk) > 65536) return 0;
$response .= $chunk;return strlen($chunk);
}]);
$ok = curl_exec($h);$status = (int) curl_getinfo($h, CURLINFO_HTTP_CODE);
if ($ok === false || $status !== 200) throw new RuntimeException('Prognoseeinrichtung HTTP ' . $status . '.');
$answer = json_decode($response, true, 32, JSON_THROW_ON_ERROR);
if (!is_array($answer)) throw new RuntimeException('Ungueltige Workflowantwort.');
return $answer;
} finally { curl_close($h); }
}
private function prognoseWorkflowZustand(): array
{
$requested = $this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv');
$base = ['state' => 'disabled', 'reason' => 'Datenerfassung ausgeschaltet.', 'ready' => false,
'controlRequested' => $requested, 'controlActive' => false];
if (!$this->ReadPropertyBoolean('PrognoseAktiv')) return $base;
$base['state'] = 'collecting';$base['reason'] = 'Messdaten werden gesammelt.';
if (!$this->prognoseLernenAktiv()) {
return array_replace($base, ['state' => 'interrupted', 'reason' => 'Netzfahrplanlizenz mit der Installation verbinden.']);
}
$error = $this->ReadAttributeString('PrognoseV4Einrichtungsfehler');
if ($error === '') $error = $this->ReadAttributeString('PrognoseV4Verbindungsfehler');
if ($error !== '') return array_replace($base, ['state' => 'interrupted', 'reason' => $error]);
$server = $this->leseJsonAttribut('PrognoseV4Serverstatus');
$at = $server['receivedAt'] ?? 0;
if (!is_int($at) || $at > time() || time() - $at > 90) {
return array_replace($base, ['state' => 'interrupted', 'reason' => 'Aktuellen Serverstatus abwarten.']);
}
$workflow = $server['workflow'] ?? [];
if (($workflow['planningReady'] ?? false) !== true) {
return array_replace($base, ['state' => $requested ? 'interrupted' : (($workflow['state'] ?? '') === 'learning' ? 'learning' : 'collecting'),
'reason' => (string) ($workflow['reason'] ?? 'Modell und gueltiger Plan werden vorbereitet.')]);
}
try {
$this->prognoseV4Regelbereitschaft();
$base = array_replace($base, ['ready' => true, 'state' => 'ready', 'reason' => 'Bereit. Netzfahrplan kann eingeschaltet werden.']);
} catch (Throwable $e) {
return array_replace($base, ['state' => 'interrupted', 'reason' => substr($e->getMessage(), 0, 300)]);
}
$local = $this->leseJsonAttribut('PrognoseV4Regelstatus');
$active = $requested && ($local['controlActive'] ?? false) === true
&& ($local['at'] ?? 0) <= time() && time() - ($local['at'] ?? 0) <= 10;
if ($requested) {
$base['state'] = $active ? 'active' : 'interrupted';
$base['reason'] = $active ? 'Manager korrigiert die Batterie auf das V4-Netzziel.' : ($local['reason'] ?? 'Naechsten gueltigen Regelzyklus abwarten.');
$base['controlActive'] = $active;
}
return $base;
}
private function prognoseV4Regelbereitschaft(): array
{
if (!$this->prognoseLernenAktiv() || !$this->GetValue('Aktiv')
|| IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== 102) throw new RuntimeException('Manager nicht regelbereit.');
if ($this->v4ManagerTestSession() !== [] || $this->GetValue('NetzfahrplanV4Aktivtest')) {
throw new RuntimeException('Separater Inbetriebnahmetest aktiv; keine zweite Batteriesteuerung.');
}
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
if (count($operation['batteries']) !== 1) throw new RuntimeException('V4 regelt derzeit genau eine Batterie.');
$asset = $operation['batteries'][0]['id'];
$feedback = $operation['localFeedback'][$asset] ?? [];
$battery = $feedback['batteryInstanceId'] ?? 0;
if (!is_int($battery) || !IPS_InstanceExists($battery)
|| !in_array($battery, $this->aktiveVerbraucherIDs(), true)) throw new RuntimeException('Bestaetigte Batterierueckmeldung fehlt.');
$binding = ['batteryInstanceId' => $battery, 'assetId' => $asset,
'watchdogEvidence' => IPS_GetProperty($battery, 'NetzfahrplanV4GeraeteWatchdogNachweis')];
NetzfahrplanV4Regelung::pruefen($this->leseJsonAttribut('NetzfahrplanV4Empfang'),
$this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $operation, $actual, $binding, time());
return [$operation, $actual, $binding];
}
/** Changes only a bound battery allocation. The ordinary battery module keeps device ownership. */
private function korrigierePrognoseBatterie(array &$allocation, array $consumers, bool $synchronized): ?array
{
$this->WriteAttributeString('PrognoseV4Regelstatus', '{}');
if (!$this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv')) return null;
try {
$s = $this->prognoseWorkflowZustand();
if (!$s['ready']) throw new RuntimeException($s['reason']);
[$operation, $actual, $binding] = $this->prognoseV4Regelbereitschaft();
$result = NetzfahrplanV4Regelung::korrigieren($this->leseJsonAttribut('NetzfahrplanV4Empfang'),
$this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $operation, $actual,
$allocation, $consumers, $synchronized, $binding, time());
$allocation = $result['allocation'];
return $result['report'];
} catch (Throwable $e) {
$this->WriteAttributeString('PrognoseV4Regelstatus', json_encode(['at' => time(), 'controlActive' => false,
'reason' => substr($e->getMessage(), 0, 300)], JSON_THROW_ON_ERROR));
return null;
}
}
}
+17 -4
View File
@@ -29,6 +29,13 @@ final class NetzfahrplanV4Bilanzierung
public static function konfigurieren(array $c): array
{
$fields = ['version','sources','maxSkewSeconds','splitToleranceW'];
$physicalOnly = ($c['storageMode'] ?? null) === 'physical_only';
if (array_key_exists('storageMode', $c)) {
if (!$physicalOnly) {
throw new InvalidArgumentException('Unbekannte Speicherbilanzierung.');
}
$fields[] = 'storageMode';
}
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) {
@@ -57,9 +64,13 @@ final class NetzfahrplanV4Bilanzierung
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'])) {
if (($roles['grid']??0)!==1 || !isset($roles['pv'],$roles['physical_storage'])
|| (!$physicalOnly && !isset($roles['controlled_virtual']))) {
throw new InvalidArgumentException('Netz, PV, physische und steuerbare virtuelle Batterie muessen explizit zugeordnet sein.');
}
if ($physicalOnly && (isset($roles['controlled_virtual']) || isset($roles['external_virtual']))) {
throw new InvalidArgumentException('Physische Speicherbilanz darf keine virtuellen Anteile enthalten.');
}
$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.');
@@ -101,11 +112,13 @@ final class NetzfahrplanV4Bilanzierung
$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;
$physicalOnly = ($c['storageMode'] ?? null) === 'physical_only';
$residual=$physicalOnly ? 0.0 : $totals['physical_storage']-$totals['controlled_virtual']-$totals['external_virtual'];
$external=$physicalOnly ? 0.0 : $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'];
$storage=$physicalOnly ? $totals['physical_storage'] : $totals['controlled_virtual']+$external;
$reconstructed=$base+$totals['flexible_load']+$storage-$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)),
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
/** Pure defaults for ordinary, separately metered AC plants. No activation or I/O.
* Existing specialist mappings must be retained by the caller, not passed here.
*/
final class NetzfahrplanV4Einrichtung
{
private static function identifier($value, string $label): string
{
if (!is_string($value) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/D', trim($value))) {
throw new InvalidArgumentException($label . ': eindeutige Anlagenkennung fehlt.');
}
return trim($value);
}
private static function objectId($value, string $label): int
{
if (!is_int($value) || $value < 1 || $value > 99999) {
throw new InvalidArgumentException($label . ': gueltige Objektzuordnung fehlt.');
}
return $value;
}
private static function factor($value): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value)
|| $value == 0 || abs($value) > 1.0e6) {
throw new InvalidArgumentException('Expliziter Leistungsfaktor fehlt oder ist ungueltig.');
}
return (float) $value;
}
private static function source(string $key, string $role, $id, $factor, int $age, callable $read): array
{
$id = self::objectId($id, $role);
try {
$object = $read($id);
} catch (Throwable $e) {
// Reader exceptions may contain connection settings; never propagate them.
throw new InvalidArgumentException('Messquelle ' . $id . ' ist nicht verfuegbar.');
}
if (!is_array($object) || !in_array($object['type'] ?? null, [1, 2], true)
|| !is_int($object['parentID'] ?? null) || $object['parentID'] < 1 || $object['parentID'] > 99999
|| !is_string($object['ident'] ?? null) || strlen($object['ident']) > 100) {
throw new InvalidArgumentException('Messquelle ' . $id . ' ist keine eindeutig zugeordnete numerische Variable.');
}
if (($object['virtual'] ?? false) !== false || ($object['sharedStorage'] ?? false) !== false) {
throw new InvalidArgumentException('Virtuelle oder geteilte Speicher benoetigen eine explizite Messzuordnung.');
}
return ['key' => $key, 'role' => $role, 'variableId' => $id,
'parentId' => $object['parentID'], 'ident' => $object['ident'],
'factorToW' => self::factor($factor), 'maxAgeSeconds' => $age, 'dependsOn' => []];
}
/** objectReader(int): {parentID:int, ident:string, type:int, virtual?:bool, sharedStorage?:bool}.
* type is IPS VariableType (1=int, 2=float), not ObjectType. Readers verify
* the object really is a variable and reject derived/virtual source parents.
* No values, timestamps, full instance settings or credentials are needed.
*
* @return array{capture:array,dataset:array,datasetId:string,mappingSha256:string,inventorySha256:string}
*/
public static function erstellen(
string $installationId,
int $managerId,
array $manager,
array $inverters,
array $batteries,
callable $objectReader
): array {
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $installationId)) {
throw new InvalidArgumentException('Gueltige Installationskennung erforderlich.');
}
self::objectId($managerId, 'Manager');
$age = $manager['MesswertMaxAlter'] ?? null;
if (!is_int($age) || $age < 1) {
throw new InvalidArgumentException('Maximales Messwertalter fehlt.');
}
$age = min(60, $age);
if (($manager['SDLAktiv'] ?? false) !== false || ($manager['SDLLeistungVariableID'] ?? 0) !== 0) {
throw new InvalidArgumentException('SDL-Anlage benoetigt eine explizite physische Messzuordnung.');
}
if (array_keys($inverters) !== array_keys(array_values($inverters))
|| array_keys($batteries) !== array_keys(array_values($batteries))
|| $inverters === [] || $batteries === [] || count($inverters) + count($batteries) > 79) {
throw new InvalidArgumentException('Vollstaendige Wechselrichter- und Batterietopologie erforderlich.');
}
$sources = [self::source('grid', 'grid', $manager['NetzleistungVariableID'] ?? null,
$manager['Netzleistungsfaktor'] ?? null, $age, $objectReader)];
$inverterMap = [];
foreach ($inverters as $inverter) {
if (!is_array($inverter)) {
throw new InvalidArgumentException('Ungueltiger Wechselrichtereintrag.');
}
$id = self::identifier($inverter['ID'] ?? null, 'Wechselrichter');
if (isset($inverterMap[$id]) || !in_array($inverter['Typ'] ?? null, ['pv', 'battery'], true)) {
throw new InvalidArgumentException('Doppelte oder hybride Wechselrichter benoetigen eine explizite Messzuordnung.');
}
$inverterMap[$id] = $inverter['Typ'];
if ($inverter['Typ'] === 'pv') {
$sources[] = self::source('pv_' . substr(hash('sha256', $id), 0, 24), 'pv',
$inverter['IstleistungVariableID'] ?? null, $inverter['Istleistungsfaktor'] ?? null, $age, $objectReader);
}
}
$batteryMap = [];
$usedInverters = [];
foreach ($batteries as $battery) {
if (!is_array($battery)) {
throw new InvalidArgumentException('Ungueltiger Batterieeintrag.');
}
$id = self::identifier($battery['ID'] ?? null, 'Batterie');
$inverterId = self::identifier($battery['WechselrichterID'] ?? null, 'Batteriewechselrichter');
if (isset($batteryMap[$id]) || ($battery['Kopplung'] ?? null) !== 'ac'
|| ($inverterMap[$inverterId] ?? null) !== 'battery' || isset($usedInverters[$inverterId])) {
throw new InvalidArgumentException('Batterie benoetigt einen eigenen AC-Wandler und eine eindeutige Leistungsmessung.');
}
if (($battery['Virtuell'] ?? false) !== false || ($battery['SDLAktiv'] ?? false) !== false) {
throw new InvalidArgumentException('Virtuelle oder SDL-Batterie benoetigt eine explizite Messzuordnung.');
}
$batteryMap[$id] = $inverterId;
$usedInverters[$inverterId] = true;
$sources[] = self::source('battery_' . substr(hash('sha256', $id), 0, 24), 'physical_storage',
$battery['LeistungVariableID'] ?? null, $battery['Leistungsfaktor'] ?? null, $age, $objectReader);
}
foreach ($inverterMap as $id => $type) {
if ($type === 'battery' && !isset($usedInverters[$id])) {
throw new InvalidArgumentException('Nicht zugeordneter Batteriewechselrichter in der Anlagenbilanz.');
}
}
$accounting = NetzfahrplanV4Bilanzierung::konfigurieren([
'version' => 1, 'sources' => $sources, 'maxSkewSeconds' => min(30, $age),
'splitToleranceW' => 0.0, 'storageMode' => 'physical_only',
]);
ksort($inverterMap);
ksort($batteryMap);
$inventory = ['version' => 1, 'installationId' => $installationId, 'managerId' => $managerId,
'inverters' => $inverterMap, 'batteries' => $batteryMap, 'sources' => $accounting['sources']];
$inventoryHash = hash('sha256', json_encode($inventory, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
$capture = NetzfahrplanV4Messaufnahme::configuration([
'version' => 1, 'installationId' => $installationId, 'managerId' => $managerId,
'reportedInventorySha256' => $inventoryHash, 'accounting' => $accounting, 'extraSources' => [],
]);
$mappingHash = hash('sha256', json_encode($capture, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
$datasetId = 'v4-' . substr($mappingHash, 0, 32);
$dataset = ['datasetId' => $datasetId, 'mappingSha256' => $mappingHash, 'inventorySha256' => $inventoryHash,
'sources' => array_map(static fn(array $s): array => [
'key' => $s['key'], 'variableId' => $s['variableId'], 'role' => $s['role'],
'factorToW' => $s['factorToW'], 'maxAgeSeconds' => $s['maxAgeSeconds'],
], $capture['accounting']['sources']),
'formula' => 'physical_sum_v1', 'solarReference' => null, 'minimumCoverage' => 0.95,
'maximumGapSeconds' => 5, 'minimumTrainingHours' => 24, 'historyDays' => 30];
return ['capture' => $capture, 'dataset' => $dataset, 'datasetId' => $datasetId,
'mappingSha256' => $mappingHash, 'inventorySha256' => $inventoryHash];
}
}
+1
View File
@@ -304,6 +304,7 @@ final class NetzfahrplanV4Planpruefung
'stepValidUntil' => $point['validUntil'], 'gridTargetW' => (float) $point['gridTargetW'],
'plannedBatteryW' => (float) $point['batteryTargetW'], 'previewBatteryW' => $clipped,
'actualGridW' => $grid, 'actualBatteryW' => $battery, 'expectedGridW' => $expectedGrid,
'maxChargeW' => $charge, 'maxDischargeW' => $discharge,
'unmetGridLimitW' => $violation, 'reasons' => $reasons, 'inputWarnings' => $p['warnings'] ?? [],
'validityNote' => 'Nur Momentvorschau; keine Stellfreigabe und keine nachgewiesene Netzreaktion.'];
}
+229
View File
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
require_once __DIR__ . '/Nachrichtenvertrag.php';
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
/** Pure correction of an ordinary Manager allocation. No commands or permissions.
* The caller separately enforces licence, user consent and healthy model readiness.
*/
final class NetzfahrplanV4Regelung
{
private static function number($value, string $label): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || abs($value) > 1.0e9) {
throw new InvalidArgumentException($label . ': endlicher Zahlenwert erforderlich.');
}
return (float) $value;
}
private static function objectId($value): int
{
if (!is_int($value) || $value < 1 || $value > 99999) {
throw new InvalidArgumentException('Eindeutige Instanzzuordnung erforderlich.');
}
return $value;
}
/** A legal interval or singleton is selected, never a gap between offers. */
private static function offeredTarget(array $offers, float $desired, float $lower, float $upper): int
{
$best = null;
foreach ($offers as $offer) {
$from = is_int($offer) ? $offer : $offer['Von_W'];
$until = is_int($offer) ? $offer : $offer['Bis_W'];
$low = (int) ceil(max($lower, $from));
$high = (int) floor(min($upper, $until));
if ($low > $high) {
continue;
}
$candidate = max($low, min($high, (int) $desired));
if ($best === null || [abs($candidate - $desired), abs($candidate), $candidate]
< [abs($best - $desired), abs($best), $best]) {
$best = $candidate;
}
}
if ($best === null || !Nachrichtenvertrag::enthaeltLeistungswert($offers, $best)) {
throw new InvalidArgumentException('Kein angebotener Batteriesollwert innerhalb aller Schutzgrenzen.');
}
return $best;
}
/** Read-only readiness checks. Consumer synchronization and offers are checked at allocation time.
* @return array{checked:array,feedback:array,preview:array}
*/
public static function pruefen(
array $envelope,
string $installationId,
int $managerId,
array $operation,
array $actual,
array $binding,
int $now
): array {
self::objectId($managerId);
$batteryId = self::objectId($binding['batteryInstanceId'] ?? null);
$assetId = $binding['assetId'] ?? null;
$watchdog = $binding['watchdogEvidence'] ?? null;
if ($now < 1 || $batteryId === $managerId
|| !is_string($assetId) || $assetId === '' || strlen($assetId) > 100
|| !is_string($watchdog) || strlen(trim($watchdog)) < 8 || strlen($watchdog) > 256
|| strcasecmp(trim($watchdog), 'explicit_test_plant_watchdog_waiver') === 0) {
throw new InvalidArgumentException('Batteriezuordnung oder Geraete-Watchdog fehlen.');
}
foreach (['installationId' => $installationId, 'managerId' => $managerId] as $key => $value) {
if (isset($binding[$key]) && $binding[$key] !== $value) {
throw new InvalidArgumentException('Widerspruechliche lokale Anlagenzuordnung.');
}
$binding[$key] = $value;
}
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)
|| !is_array($operation['batteries'] ?? null)
|| array_keys($operation['batteries']) !== array_keys(array_values($operation['batteries']))
|| count($operation['batteries']) !== 1 || ($operation['batteries'][0]['id'] ?? null) !== $assetId
|| count($actual) !== 1 || !array_key_exists($assetId, $actual)
|| array_key_exists('localPredictedOtherDeltaW', $operation)) {
throw new InvalidArgumentException('Vollstaendiger unveraenderter Einzelbatterie-Kontext erforderlich.');
}
$feedback = $operation['localFeedback'][$assetId] ?? [];
if (!is_array($feedback)) {
throw new InvalidArgumentException('Bestaetigte physische Batterierueckmeldung fehlt.');
}
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $binding, $now);
if (($feedback['estimated'] ?? null) !== false || ($feedback['separatelyMeasured'] ?? null) !== true
|| abs(self::number($actual[$assetId], 'Batterie-Istleistung') - $feedback['batteryW']) > 0.000001
|| abs(self::number($operation['gridW'] ?? null, 'Netz-Istleistung') - $feedback['gridW']) > 0.001) {
throw new InvalidArgumentException('Direkt gemessene Batterie- und Netzleistung aus derselben Aufnahme erforderlich.');
}
$checked = NetzfahrplanV4Planpruefung::pruefen(
$envelope['response'], $installationId, $operation, $now, $envelope['receivedAt']
);
$quality = $checked['plan']['inputQuality'] ?? [];
if (($quality['loadBasis'] ?? null) !== 'base_load'
|| !is_string($quality['accountingEvidenceId'] ?? null) || strlen($quality['accountingEvidenceId']) < 8) {
throw new InvalidArgumentException('Nachvollziehbare physische Verbrauchsbilanz fehlt.');
}
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
if ($preview['unmetGridLimitW'] > 1) {
throw new InvalidArgumentException('Netzgrenze durch Batterie allein nicht erreichbar.');
}
return ['checked' => $checked, 'feedback' => $feedback, 'preview' => $preview];
}
/** @return array{allocation:array,report:array} */
public static function korrigieren(
array $envelope,
string $installationId,
int $managerId,
array $operation,
array $actual,
array $allocation,
array $consumers,
bool $synchronized,
array $binding,
int $now
): array {
$verified = self::pruefen($envelope, $installationId, $managerId, $operation, $actual, $binding, $now);
$checked = $verified['checked'];
$feedback = $verified['feedback'];
$batteryId = $binding['batteryInstanceId'];
$assetId = $binding['assetId'];
if (!$synchronized || !is_array($allocation['Sollwerte'] ?? null)
|| !array_key_exists($batteryId, $allocation['Sollwerte'])) {
throw new InvalidArgumentException('Verbraucher nicht synchronisiert oder Batterie nicht in Zuteilung.');
}
if (array_keys($consumers) !== array_keys(array_values($consumers)) || count($consumers) > 500) {
throw new InvalidArgumentException('Ungueltige Verbraucherliste.');
}
$byId = [];
foreach ($consumers as $consumer) {
if (!is_array($consumer) || !is_array($consumer['Daten'] ?? null)) {
throw new InvalidArgumentException('Verbraucherdaten fehlen.');
}
$id = self::objectId($consumer['InstanzID'] ?? null);
if (isset($byId[$id])) {
throw new InvalidArgumentException('Doppelte Verbraucherzuordnung.');
}
Nachrichtenvertrag::pruefeVerbraucherdaten($consumer['Daten']);
$d = $consumer['Daten'];
if ($d['Kopf']['AbsenderID'] !== $id || $d['Kopf']['EmpfaengerID'] !== $managerId
|| $d['Kopf']['Zeitpunkt'] > $now || $now - $d['Kopf']['Zeitpunkt'] > 60) {
throw new InvalidArgumentException('Verbrauchermeldung falsch zugeordnet oder veraltet.');
}
$byId[$id] = $d;
}
$battery = $byId[$batteryId] ?? null;
if ($battery === null || !$battery['Verfuegbar'] || !$battery['AenderungMoeglich']) {
throw new InvalidArgumentException('Batterie fehlt oder bietet keine Aenderung an.');
}
$delta = 0.0;
foreach ($allocation['Sollwerte'] as $id => $target) {
if (!is_int($id) || !isset($byId[$id]) || !is_int($target)) {
throw new InvalidArgumentException('Sollwert ohne eindeutigen Verbraucher.');
}
if ($id === $batteryId) {
continue;
}
if ($byId[$id]['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) {
throw new InvalidArgumentException('Istleistung anderer zugeteilter Verbraucher muss gemessen sein.');
}
$delta += self::number($target, 'Anderer Sollwert') - self::number($byId[$id]['Istleistung_W'], 'Andere Istleistung');
}
$delta = self::number($delta, 'Andere Verbraucherabweichung');
$operation['gridW'] = self::number($feedback['gridW'] + $delta, 'Prognostizierte Netzleistung');
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
if ($preview['unmetGridLimitW'] > 1) {
throw new InvalidArgumentException('Netzgrenze durch Batterie allein nicht erreichbar.');
}
$lower = -self::number($preview['maxDischargeW'] ?? null, 'Vorschau-Entladegrenze');
$upper = self::number($preview['maxChargeW'] ?? null, 'Vorschau-Ladegrenze');
$desired = self::number($preview['previewBatteryW'], 'Vorschau-Sollwert');
$residual = $operation['gridW'] - $actual[$assetId];
$import = $operation['limits']['importW'];
$export = $operation['limits']['exportW'];
$month = (int) (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('n');
$monthly = $operation['limits']['managerMonthLimitsW'][$month] ?? null;
foreach ([$monthly, $checked['point']['importLimitW']] as $limit) {
if ($limit !== null) {
$import = $import === null ? $limit : min($import, $limit);
}
}
if ($checked['point']['exportLimitW'] !== null) {
$export = $export === null ? $checked['point']['exportLimitW'] : min($export, $checked['point']['exportLimitW']);
}
if ($import !== null) {
$upper = min($upper, $import - $residual);
}
if ($export !== null) {
$lower = max($lower, -$export - $residual);
}
// An offer gap must not turn hold into charging or reverse the computed intent.
if ($desired >= 0) {
$lower = max($lower, 0.0);
}
if ($desired <= 0) {
$upper = min($upper, 0.0);
}
$target = self::offeredTarget($battery['Leistungswerte_W'], $desired, $lower, $upper);
$old = self::number($allocation['Sollwerte'][$batteryId], 'Bisheriger Batteriesollwert');
$allocation['Sollwerte'][$batteryId] = $target;
if (array_key_exists('Restabweichung_W', $allocation)) {
$allocation['Restabweichung_W'] = (int) (self::number($allocation['Restabweichung_W'], 'Restabweichung') - ($target - $old));
}
return ['allocation' => $allocation, 'report' => [
'status' => 'allocation_corrected', 'installationId' => $installationId, 'managerId' => $managerId,
'batteryInstanceId' => $batteryId, 'assetId' => $assetId, 'planId' => $checked['plan']['planId'],
'checkedAt' => gmdate('c', $now), 'stepValidUntil' => $checked['point']['validUntil'],
'targetW' => $target, 'gridTargetW' => $preview['gridTargetW'], 'expectedGridW' => $residual + $target,
'otherConsumerPredictedDeltaW' => $delta, 'physicalResponseVerified' => false,
]];
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
/** Pure feedback binding for an already verified automatic physical capture. */
final class NetzfahrplanV4RueckmeldungEinrichtung
{
/**
* The caller verifies the current capture against the automatic plant setup,
* including supported physical source parents. Custom mappings stay separate.
* $assets is AnlagenBatterien; $controllers contains every assigned active
* battery controller with its existing public measurement properties only.
* No device read, configuration write, watchdog proof or permission is created.
*/
public static function erstellen(
array $capture,
string $installationId,
int $managerId,
int $batteryId,
array $assets,
array $controllers
): array {
$capture = NetzfahrplanV4Messaufnahme::configuration($capture);
if (($capture['installationId'] ?? null) !== $installationId
|| ($capture['managerId'] ?? null) !== $managerId
|| ($capture['accounting']['storageMode'] ?? null) !== 'physical_only'
|| $capture['extraSources'] !== []) {
throw new InvalidArgumentException('feedback_auto_capture_binding_invalid');
}
if (array_keys($assets) !== array_keys(array_values($assets)) || count($assets) !== 1 || !is_array($assets[0])
|| array_keys($controllers) !== array_keys(array_values($controllers)) || count($controllers) !== 1 || !is_array($controllers[0])) {
throw new InvalidArgumentException('feedback_auto_battery_assignment_ambiguous');
}
$asset = $assets[0];
$controller = $controllers[0];
$assetId = $asset['ID'] ?? null;
if (!is_string($assetId) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/D', trim($assetId))
|| ($asset['Kopplung'] ?? null) !== 'ac' || ($asset['Virtuell'] ?? false) !== false
|| ($asset['SDLAktiv'] ?? false) !== false || ($controller['InstanzID'] ?? null) !== $batteryId
|| ($controller['Aktiv'] ?? true) !== true) {
throw new InvalidArgumentException('feedback_auto_battery_binding_invalid');
}
foreach (['LeistungVariableID', 'SOCVariableID'] as $key) {
if (!is_int($asset[$key] ?? null) || $asset[$key] < 1 || $asset[$key] > 99999) {
throw new InvalidArgumentException('feedback_auto_battery_measurements_missing');
}
}
if (($controller['IstleistungVariableID'] ?? null) !== $asset['LeistungVariableID']
|| ($controller['LadezustandVariableID'] ?? null) !== $asset['SOCVariableID']
|| $asset['SOCVariableID'] === $asset['LeistungVariableID']) {
throw new InvalidArgumentException('feedback_auto_controller_measurements_differ');
}
if ((!is_int($asset['Leistungsfaktor'] ?? null) && !is_float($asset['Leistungsfaktor'] ?? null))
|| (float) $asset['Leistungsfaktor'] !== 1.0) {
throw new InvalidArgumentException('feedback_auto_controller_units_differ');
}
$maxAge = $controller['MesswertMaxAlter'] ?? null;
if (!is_int($maxAge) || $maxAge < 1) {
throw new InvalidArgumentException('feedback_auto_controller_freshness_invalid');
}
$maxAge = min(60, $maxAge);
$sources = [];
$roles = [];
foreach ($capture['accounting']['sources'] as $source) {
if ($source['dependsOn'] !== [] || !in_array($source['role'], ['grid', 'pv', 'physical_storage'], true)) {
throw new InvalidArgumentException('feedback_auto_derived_source_refused');
}
if (!in_array($source['role'], ['grid', 'physical_storage'], true)) {
continue;
}
$roles[$source['role']] = ($roles[$source['role']] ?? 0) + 1;
// The ordinary battery module consumes these same variables as W.
if ($source['factorToW'] !== 1.0) {
throw new InvalidArgumentException('feedback_auto_controller_units_differ');
}
if (($source['role'] === 'grid' && $source['variableId'] !== ($controller['NetzleistungVariableID'] ?? null))
|| ($source['role'] === 'physical_storage' && $source['variableId'] !== $asset['LeistungVariableID'])
|| $source['variableId'] === $asset['SOCVariableID']) {
throw new InvalidArgumentException('feedback_auto_source_binding_invalid');
}
$expectedKey = $source['role'] === 'grid' ? 'grid' : 'battery_' . substr(hash('sha256', trim($assetId)), 0, 24);
if ($source['key'] !== $expectedKey) {
throw new InvalidArgumentException('feedback_auto_source_identity_changed');
}
$maxAge = min($maxAge, $source['maxAgeSeconds']);
$sources[] = [
'key' => $source['key'], 'role' => $source['role'] === 'grid' ? 'grid' : 'physical',
'variableId' => $source['variableId'], 'parentId' => $source['parentId'],
'ident' => $source['ident'], 'factorToW' => $source['factorToW'],
];
}
if (($roles['grid'] ?? 0) !== 1 || ($roles['physical_storage'] ?? 0) !== 1) {
throw new InvalidArgumentException('feedback_auto_physical_sources_ambiguous');
}
return NetzfahrplanV4Rueckmeldung::configuration([
'version' => 1, 'installationId' => $installationId, 'assetId' => trim($assetId),
'managerId' => $managerId, 'batteryInstanceId' => $batteryId,
'mode' => 'physical_sum', 'allowEstimatedForTrial' => false,
'maxAgeSeconds' => $maxAge,
'maxSkewSeconds' => min(30, $maxAge, $capture['accounting']['maxSkewSeconds']),
'idleToleranceW' => 0.0, 'trackingToleranceW' => 0.0, 'sources' => $sources,
]);
}
}
@@ -1,23 +1,23 @@
[
{
"path": "public/app.js",
"before": "b4828a1bd58a5cc29a3970475c8eebadf89a3fda788c269d6951572603fa5dd9",
"after": "95a5a0b0958ad0bd7b444225b90598c2a8c2e92b54631265211bd26b520cdd56"
"before": "95a5a0b0958ad0bd7b444225b90598c2a8c2e92b54631265211bd26b520cdd56",
"after": "6d0e4f9eb3fa8acb73bdc2a07c1a08796ef8a2bba7f0cf4b44965174a3f4aeb8"
},
{
"path": "public/index.html",
"before": "7010e36746b6739d50c13b91cd2ad29de1ced2bc4ca42b49b3f43cc66f9c1ec2",
"after": "26ed3c78ce3deff774f5016357928120d764e434fbe844374656953791d021b0"
"before": "26ed3c78ce3deff774f5016357928120d764e434fbe844374656953791d021b0",
"after": "db5568f87895d4c3a74481d2f0e9e126a7e951fd36056e6337a2635f6019b3e1"
},
{
"path": "public/netplan-v4.js",
"before": "245af626f3322dcaec5afbbc9772707ee6de00f07fd939e59709e7349f2d12d9",
"after": "9c17da0bc6014c1dc09a84bb6015a6af160c59a07c8acda096da9f4e72b3b55d"
"before": "9c17da0bc6014c1dc09a84bb6015a6af160c59a07c8acda096da9f4e72b3b55d",
"after": "b06eea3ae7564cb38b6b11cd4bbcc52274db0a0832faf6df5204c289c33c353d"
},
{
"path": "public/netplan-v4.css",
"before": "f7e55386157aa64db5be62319b9f37e27052f0f001fb28f8233d886d3f9b40b5",
"after": "1189b8d9b7a092bbdff5ec77a99207d1f0fecab56357a44b856ed161ab80cabe"
"before": "1189b8d9b7a092bbdff5ec77a99207d1f0fecab56357a44b856ed161ab80cabe",
"after": "ca3f95d558554ec992deb5123d5bd4a598f5e41a6416cfc2b133d984ddd4e5e0"
},
{
"path": "public/netplan-v4.html",
@@ -28,7 +28,7 @@ const settle = () => new Promise(resolve => setImmediate(resolve));
function node(id, active = false) {
const classes = new Set(active ? ['active'] : []);
return {
id, textContent: '',
id, textContent: '', open: false, dataset: {},
replaceChildren() { this.textContent = ''; },
classList: {
contains(name) { return classes.has(name); },
@@ -41,7 +41,11 @@ function node(id, active = false) {
function harness(options = {}) {
const nodes = new Map();
const byId = id => {
if (!nodes.has(id)) nodes.set(id, node(id, id === 'view-forecast' && options.active !== false));
if (!nodes.has(id)) {
const item = node(id, id === 'view-forecast' && options.active !== false);
if (id === 'forecastHistory') item.open = options.historyOpen === true;
nodes.set(id, item);
}
return nodes.get(id);
};
const calls = { imports: [], api: [], mounted: [], disposed: [], rendered: [], legacyStatus: [], configuration: [] };
@@ -66,14 +70,18 @@ function harness(options = {}) {
api(url) { calls.api.push(url); return options.api ? options.api(url) : Promise.resolve({ status: 'available', source: url }); },
importPlanner(url) { calls.imports.push(url); return options.importPlanner ? options.importPlanner(url, plannerModule) : Promise.resolve(plannerModule); }
});
vm.runInContext(executableSource + '\nthis.integration = {updateForecast, showView, showAuth, currentPlanner: () => integratedPlanner};', context);
vm.runInContext(executableSource + '\nthis.integration = {updateForecast, loadForecastHistory, historicalForecastResult, showView, showAuth, currentPlanner: () => integratedPlanner};', context);
return { state, calls, byId, module: plannerModule, ...context.integration, select(id, linked = true) { plant = id ? { id, installation_id: linked ? `installation-${id}` : null, manager_variant: 'manager_standard' } : null; } };
}
test('release assets expose the host, stylesheet and relative chart dependency', async () => {
assert.equal((html.match(/id="forecastPlanner"/g) || []).length, 1);
assert.match(html, /href="\/netplan-v4\.css\?[^"\s]+"/);
assert.equal(html.match(/src="\/app\.js[^"\s]*"/)?.[0], 'src="/app.js?v=20261006-forecast"', 'entrypoint version must invalidate the pre-integration cached app');
assert.equal(html.match(/src="\/app\.js[^"\s]*"/)?.[0], 'src="/app.js?v=20261008-workflow"', 'entrypoint version must invalidate the previous mixed forecast app');
assert.match(mountSource, /netplan-v4\.js\?v=20261008-workflow/);
assert.match(html, /<details id="forecastHistory"[^>]*>/);
assert.doesNotMatch(html, /<details id="forecastHistory"[^>]*\bopen\b/);
assert.doesNotMatch(html, /<input[^>]+name="(?:forecast_pv|forecast_load|grid_schedule)"/);
assert.ok(html.indexOf('id="forecastPlanner"') > html.indexOf('id="view-forecast"'));
assert.match(plannerSource, /export function mountPlanner\(/);
const dependency = plannerSource.match(/from ['"](\.\/forecast-chart\.js)['"]/);
@@ -85,20 +93,22 @@ test('inactive views and unlinked plants do not mount the planner', async () =>
const hidden = harness({ active: false }); await hidden.updateForecast(); await settle();
assert.equal(hidden.calls.imports.length, 0); assert.equal(hidden.calls.api.length, 0);
const unlinked = harness(); unlinked.select('A', false); await unlinked.updateForecast(); await settle();
assert.equal(unlinked.calls.mounted.length, 0); assert.equal(unlinked.calls.api.length, 1);
assert.equal(unlinked.calls.mounted.length, 0); assert.equal(unlinked.calls.api.length, 0);
assert.match(unlinked.byId('forecastPlanner').textContent, /Lizenzcode/);
assert.equal(unlinked.calls.configuration.length, 1, 'tariff and PV configuration remains available');
});
test('changing plants invalidates stale imports and legacy responses', async () => {
test('changing plants invalidates stale imports and explicitly opened history responses', async () => {
const imports = [deferred(), deferred()], results = [deferred(), deferred()];
let importIndex = 0, resultIndex = 0;
const app = harness({ importPlanner: () => imports[importIndex++].promise, api: () => results[resultIndex++].promise });
const app = harness({ historyOpen: true, importPlanner: () => imports[importIndex++].promise, api: () => results[resultIndex++].promise });
const first = app.updateForecast(); app.select('B'); const second = app.updateForecast();
imports[0].resolve(app.module); await settle(); assert.deepEqual(app.calls.mounted, []);
imports[1].resolve(app.module); results[1].resolve({ status: 'available', plant: 'B' });
results[0].resolve({ status: 'available', plant: 'A' }); await Promise.all([first, second]); await settle();
assert.deepEqual(app.calls.mounted, ['B']);
assert.deepEqual(app.calls.rendered.map(result => result.plant), ['B']);
assert.deepEqual(app.calls.configuration.map(entry => entry.plant), ['B']);
assert.deepEqual(app.calls.configuration.map(entry => entry.plant), ['A', 'B']);
});
test('leaving the forecast view disposes the mounted planner and invalidates pending imports', async () => {
@@ -122,31 +132,95 @@ test('returning to authentication disposes the planner and invalidates pending r
assert.equal(app.byId('forecastPlanner').textContent, '');
const imported = deferred(), result = deferred();
const pending = harness({ importPlanner: () => imported.promise, api: () => result.promise });
const pending = harness({ historyOpen: true, importPlanner: () => imported.promise, api: () => result.promise });
const update = pending.updateForecast(); pending.showAuth();
imported.resolve(pending.module); result.resolve({ status: 'available', plant: 'A' });
await update; await settle();
assert.deepEqual(pending.calls.mounted, []);
assert.deepEqual(pending.calls.rendered, []);
assert.deepEqual(pending.calls.configuration, []);
assert.equal(pending.calls.configuration.length, 1, 'configuration request starts independently and has its own response guard');
});
test('legacy API failures do not clear or prevent the independent planner', async () => {
const app = harness({ api: () => Promise.reject(new Error('legacy unavailable')) });
test('default view loads V4 and configuration without calling the old forecast endpoint', async () => {
const app = harness(); await app.updateForecast(); await settle();
assert.deepEqual(app.calls.mounted, ['A']);
assert.equal(app.calls.api.length, 0); assert.equal(app.calls.rendered.length, 0);
assert.equal(app.calls.configuration.length, 1);
});
test('historical API failures do not clear or prevent the independent planner', async () => {
const app = harness({ historyOpen: true, api: () => Promise.reject(new Error('history unavailable')) });
await app.updateForecast(); await settle();
assert.deepEqual(app.calls.mounted, ['A']);
assert.equal(app.byId('forecastPlanner').textContent, 'mounted:A');
assert.equal(app.calls.legacyStatus.at(-1), 'legacy unavailable');
assert.equal(app.calls.legacyStatus.at(-1), 'history unavailable');
});
test('planner import failures remain inside the planner host', async () => {
const app = harness({ importPlanner: () => Promise.reject(new Error('planner unavailable')) });
await app.updateForecast(); await settle();
assert.equal(app.calls.rendered.length, 1);
assert.equal(app.calls.rendered.length, 0);
assert.equal(app.calls.configuration.length, 1, 'configuration is independent of the planner import');
assert.match(app.byId('forecastPlanner').textContent, /planner unavailable/);
assert.ok(!app.calls.legacyStatus.includes('planner unavailable'));
});
test('history is lazy, cached per plant and range, and never remounts the active planner', async () => {
const app = harness(); await app.updateForecast(); await settle();
assert.equal(app.calls.api.length, 0);
app.byId('forecastHistory').open = true; await app.loadForecastHistory();
assert.equal(app.calls.api.length, 1); assert.equal(app.calls.rendered.length, 1);
await app.loadForecastHistory(); assert.equal(app.calls.api.length, 1);
app.state.forecastHistoryDays = 7; await app.loadForecastHistory();
assert.equal(app.calls.api.length, 2); assert.match(app.calls.api.at(-1), /historyDays=7/);
assert.deepEqual(app.calls.mounted, ['A']); assert.equal(app.calls.disposed.length, 0);
});
test('historical rendering excludes future and legacy schedule curves without mutating stored results', () => {
const now = Date.parse('2026-10-08T12:00:00Z');
const points = [{ time: '2026-10-08T11:00:00Z', value: 1 }, { time: '2026-10-08T13:00:00Z', value: 2 }, { time: 'invalid', value: 3 }];
const result = { scheduleSourceModel: 'prog_var_3', series: [
{ key: 'PV', points }, { key: 'prog_var_1', points }, { key: 'prog_var_3', points }, { key: 'prog_var_3_battery', points }
] };
const output = harness().historicalForecastResult(result, now);
assert.equal(output.scheduleSourceModel, null);
assert.deepEqual(Array.from(output.series, row => row.key), ['PV', 'prog_var_1']);
for (const row of output.series) assert.equal(row.points.length, 1);
assert.equal(result.series.length, 4); assert.equal(points.length, 3); assert.equal(result.scheduleSourceModel, 'prog_var_3');
});
test('tariff changes preserve existing generator flags without recreating legacy operator switches', () => {
const code = section(' var forecastCapabilityVariants =', ' var forecastTariffs =')
+ section(' function forecastGeneratorFlags(', ' var integratedPlanner =');
const context = vm.createContext({ byId() { return { querySelectorAll() { return []; } }; } });
vm.runInContext(code + '\nthis.payload = forecastConfigurationPayload; this.flags = forecastGeneratorFlags;', context);
const flags = { prog_var_1: true, prog_var_2: false, prog_var_3: true, prog_var_10: false, unknown: true };
const form = { dataset: { forecastVariants: JSON.stringify(flags) }, elements: new Proxy({}, { get() { return { value: '1' }; } }) };
const payload = context.payload(form);
assert.equal(payload.prog_var_1, true); assert.equal(payload.prog_var_2, false);
assert.equal(payload.prog_var_3, true); assert.equal(payload.prog_var_10, false);
assert.equal(Object.hasOwn(payload, 'prog_var_13'), false, 'absent flags are not manufactured');
assert.equal(Object.hasOwn(payload, 'unknown'), false);
assert.equal(payload.tarif_bezug_fest, 1); assert.equal(payload.tarif_einspeisung_fest, 1);
});
test('configuration failures cannot expose the previous plant as an editable form', async () => {
const code = section(' async function loadForecastConfiguration(', ' function forecastGeneratorFlags(');
const nodes = new Map();
const byId = id => { if (!nodes.has(id)) nodes.set(id, node(id)); return nodes.get(id); };
const pending = deferred(), state = { forecastRequest: 1 }, filled = [];
const context = vm.createContext({ state, byId, api() { return pending.promise; }, fillForecastConfiguration(plant) { filled.push(plant.id); } });
vm.runInContext(code + '\nthis.load = loadForecastConfiguration;', context);
byId('forecastConfigForm').dataset.plantId = 'old-plant';
const load = context.load({ id: 'new-plant' }, 1);
assert.equal(byId('forecastConfigForm').classList.contains('hidden'), true);
assert.equal(byId('forecastConfigForm').dataset.plantId, '');
pending.reject(new Error('configuration unavailable')); await load;
assert.equal(byId('forecastConfigError').textContent, 'configuration unavailable');
assert.equal(byId('forecastConfigForm').classList.contains('hidden'), true);
assert.equal(filled.length, 0);
});
test('late planner errors cannot overwrite the next plant after disposal', async () => {
const ready = deferred(), app = harness({ ready: id => id === 'A' ? ready.promise : Promise.resolve() });
await app.updateForecast(); await settle(); app.select('B'); await app.updateForecast(); await settle();
@@ -845,20 +845,14 @@
schedule: true
});
if (result.status === "license_required") {
byId("forecastApiStatus").textContent = "Keine aktive Prognoselizenz";
return setForecastState("Für dieses System ist eine aktive Prognoselizenz erforderlich.");
}
if (result.status === "not_connected") {
byId("forecastApiStatus").textContent = "Manager noch nicht verbunden";
return setForecastState("Verbinde den Manager zuerst mit dem Lizenzcode dieses Systems.");
}
if (result.status === "no_data") {
byId("forecastApiStatus").textContent = "Verbindung bereit, noch keine Daten";
return setForecastState("Für dieses System liegen im gewählten Zeitfenster noch keine Prognosedaten vor.");
return setForecastState("Für dieses System liegen im gewählten Zeitfenster noch keine historischen Daten vor.");
}
byId("forecastApiStatus").textContent = result.configured
? "Prognose aktiv"
: "Daten empfangen, Konfiguration offen";
renderForecastChart(result.series || []);
}
@@ -1167,13 +1161,7 @@
form.elements.tarif_peak_fest.value = forecastNumber(configuration.tarif_peak_fest, 5);
renderTariffSelector(form, "import", configuration.tarif_bezug_modus, configuration.tarif_bezug, configuration.tarif_bezug_fest, true);
renderTariffSelector(form, "export", configuration.tarif_einspeisung_modus, configuration.tarif_einspeisung, configuration.tarif_einspeisung_fest, true);
Object.entries(forecastCapabilityVariants).forEach(function (entry) {
var capability = entry[0];
var variants = entry[1];
form.elements[capability].checked = variants.some(function (variant) {
return Boolean(configuration["prog_var_" + variant]);
});
});
form.dataset.forecastVariants = JSON.stringify(forecastGeneratorFlags(configuration));
var managerOwned = result.technicalSource === "manager" && Boolean(result.topology);
renderForecastRoofs(configuration.daecher || [], managerOwned);
renderForecastTopology(result.topology || null);
@@ -1192,59 +1180,47 @@
control.disabled = true;
});
}
var licensed = result.forecastCapabilities || {
pv: true,
load: true,
schedule: true
};
[
["forecast_pv", "pv"],
["forecast_load", "load"],
["grid_schedule", "schedule"]
].forEach(function (entry) {
if (!licensed[entry[1]]) {
form.elements[entry[0]].checked = false;
form.elements[entry[0]].disabled = true;
}
});
var licensed = result.forecastCapabilities;
byId("forecastLicensedScope").textContent = licensed ? ([
licensed.pv ? "PV-Ertragsprognose" : "", licensed.load ? "Hausprognose" : "", licensed.schedule ? "Netzfahrplan" : ""
].filter(Boolean).join(" · ") || "Keine Prognosefunktion lizenziert") : "Lizenzumfang nicht bestätigt";
byId("forecastConfigStatus").textContent = managerOwned
? "Technik vom Manager"
: (result.configured ? "Gespeichert" : "Noch nicht gespeichert");
byId("forecastConfigStatus").className = "status " + (result.configured ? "available" : "review");
form.classList.remove("hidden");
byId("forecastConfigSection").classList.remove("hidden");
}
async function loadForecastConfiguration(plant, requestId) {
var form = byId("forecastConfigForm");
form.classList.add("hidden");
form.dataset.plantId = "";
try {
var result = await api("/api/plants/" + plant.id + "/prognosis/configuration");
if (requestId !== state.forecastRequest) return;
fillForecastConfiguration(plant, result);
} catch (error) {
if (requestId !== state.forecastRequest) return;
byId("forecastConfigStatus").textContent = "Nicht verfügbar";
byId("forecastConfigError").textContent = error.message;
byId("forecastConfigSection").classList.remove("hidden");
}
}
function forecastConfigurationPayload(form) {
var selected = {
forecast_pv: form.elements.forecast_pv.checked,
forecast_load: form.elements.forecast_load.checked,
grid_schedule: form.elements.grid_schedule.checked
};
if (selected.grid_schedule) {
selected.forecast_pv = true;
selected.forecast_load = true;
}
if (!Object.values(selected).some(Boolean)) {
throw new Error("Bitte mindestens eine Prognosefunktion aktivieren.");
}
function forecastGeneratorFlags(configuration) {
var variants = {};
Object.entries(forecastCapabilityVariants).forEach(function (entry) {
entry[1].forEach(function (variant) {
variants["prog_var_" + variant] = selected[entry[0]];
var key = "prog_var_" + variant;
if (Object.prototype.hasOwnProperty.call(configuration, key)) variants[key] = configuration[key];
});
});
return variants;
}
function forecastConfigurationPayload(form) {
var variants = forecastGeneratorFlags(JSON.parse(form.dataset.forecastVariants || "{}"));
var roofs = Array.from(byId("forecastRoofs").querySelectorAll(".forecast-roof-row")).map(function (row) {
return {
kwp: Number(row.querySelector("[data-roof-field='kwp']").value),
@@ -1271,13 +1247,15 @@
}
var integratedPlanner = null;
var forecastHistoryRequest = 0;
var forecastHistoryKey = null;
async function loadIntegratedPlanner(plant, requestId) {
var host = byId("forecastPlanner");
if (!host || !plant.installation_id) return;
host.textContent = "Aktueller Netzfahrplan wird geladen.";
try {
var plannerModule = await import("/netplan-v4.js?v=20261006-integrated");
var plannerModule = await import("/netplan-v4.js?v=20261008-workflow");
if (requestId !== state.forecastRequest || !byId("view-forecast").classList.contains("active")) return;
integratedPlanner = plannerModule.mountPlanner(host, { plantId: plant.id, session: state.session });
await integratedPlanner.ready;
@@ -1292,6 +1270,8 @@
if (byId("forecastPlanner")) byId("forecastPlanner").replaceChildren();
var plant = selectedPlant("forecastPlant");
var requestId = ++state.forecastRequest;
++forecastHistoryRequest;
forecastHistoryKey = null;
if (!plant || state.forecastPlantId !== plant.id) {
state.forecastPlantId = plant ? plant.id : null;
state.forecastResult = null;
@@ -1300,27 +1280,51 @@
state.forecastRangeEnd = null;
}
resetForecastChart();
if (!plant) return setForecastState("Kein System verfügbar.");
if (!plant) {
byId("forecastPlanner").textContent = "Kein System verfügbar.";
return;
}
byId("forecastVariant").textContent = managerLabel(plant.manager_variant);
byId("forecastConnection").textContent = plant.installation_id ? "Verbunden mit " + plant.installation_id : "Noch nicht verknüpft";
renderForecastLicenseWarning(plant);
if (!byId("view-forecast").classList.contains("active")) return;
byId("forecastApiStatus").textContent = plant.installation_id
? "Betriebsstatus aus der Manager-Rückmeldung"
: "Manager-Verknüpfung erforderlich";
if (plant.installation_id) void loadIntegratedPlanner(plant, requestId);
else byId("forecastPlanner").textContent = "Verbinde den Manager zuerst mit dem Lizenzcode dieses Systems.";
void loadForecastConfiguration(plant, requestId);
void loadForecastHistory();
}
void loadIntegratedPlanner(plant, requestId);
function historicalForecastResult(result, now) {
var keys = ["PV", "Hausverbrauch", "Netzleistung", "SOC", "prog_var_1", "prog_var_2", "prog_var_10", "prog_var_11", "prog_var_21", "prog_var_22"];
return Object.assign({}, result, {
scheduleSourceModel: null,
series: (result.series || []).filter(function (series) { return keys.includes(series.key); }).map(function (series) {
return Object.assign({}, series, { points: (series.points || []).filter(function (point) {
var stamp = Date.parse(point.time);
return Number.isFinite(stamp) && stamp <= now;
}) });
})
});
}
setForecastState("Prognosedaten werden geladen.");
byId("forecastApiStatus").textContent = "Datenverbindung wird geprüft";
async function loadForecastHistory() {
var plant = selectedPlant("forecastPlant"), requestId = state.forecastRequest;
if (!plant || !byId("view-forecast").classList.contains("active") || !byId("forecastHistory").open) return;
var key = plant.id + ":" + state.forecastHistoryDays;
if (forecastHistoryKey === key) return;
var historyId = ++forecastHistoryRequest;
setForecastState("Historische Messwerte und Modellvergleiche werden geladen.");
try {
var result = await api("/api/plants/" + plant.id + "/prognosis?historyDays=" + encodeURIComponent(state.forecastHistoryDays));
if (requestId !== state.forecastRequest) return;
renderForecastResult(result);
if (["available", "no_data"].includes(result.status)) {
await loadForecastConfiguration(plant, requestId);
}
if (requestId !== state.forecastRequest || historyId !== forecastHistoryRequest) return;
forecastHistoryKey = key;
renderForecastResult(historicalForecastResult(result, Date.now()));
} catch (error) {
if (requestId !== state.forecastRequest) return;
byId("forecastApiStatus").textContent = "Prognosedienst nicht erreichbar";
if (requestId !== state.forecastRequest || historyId !== forecastHistoryRequest) return;
setForecastState(error.message);
}
}
@@ -2167,12 +2171,13 @@
byId("peakUpgrade").addEventListener("change", updateOrder);
byId("orderPlant").addEventListener("change", function () { prepareOrderEditor(); });
byId("forecastPlant").addEventListener("change", updateForecast);
byId("forecastHistory").addEventListener("toggle", loadForecastHistory);
byId("forecastHistoryDays").addEventListener("change", function (event) {
state.forecastHistoryDays = Number(event.target.value) || 1;
state.forecastRangeStart = null;
state.forecastRangeEnd = null;
state.forecastVisibleSeries = null;
updateForecast();
loadForecastHistory();
});
byId("forecastSeriesControls").addEventListener("change", function (event) {
var control = event.target.closest("[data-forecast-series]");
@@ -11,7 +11,7 @@
<link rel="stylesheet" href="/styles.css?v=20261006-brand">
<link rel="stylesheet" href="/docs-portal.css?v=20261006">
<link rel="stylesheet" href="/branding/enelix-20261006.css">
<link rel="stylesheet" href="/netplan-v4.css?v=20261006-integrated">
<link rel="stylesheet" href="/netplan-v4.css?v=20261008-workflow">
</head>
<body>
<div id="toast" class="toast" role="status"></div>
@@ -215,7 +215,7 @@
</section>
<section class="view" id="view-forecast">
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Prognose</h1><p>Messwerte und berechnete Verläufe des lizenzierten Prognosemanagers.</p></div><span class="status available">Serverplanung</span></div>
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Prognose und Netzfahrplan</h1><p>Berechnungsgrundlagen und vom Manager gemeldeter Betriebsstatus.</p></div></div>
<div id="forecastEmpty" class="empty-state hidden"><strong>Kein System verfügbar</strong></div>
<div id="forecastContent">
<section class="forecast-toolbar">
@@ -223,13 +223,16 @@
<div class="connection-state"><span class="signal"></span><div><strong id="forecastConnection">Noch nicht verknüpft</strong><p id="forecastApiStatus">Status wird geprüft</p></div></div>
</section>
<section id="forecastPlanner" class="forecast-planner-section" aria-label="Aktueller Netzfahrplan"></section>
<details id="forecastHistory" class="config-details">
<summary>Messhistorie und frühere Modellvergleiche</summary>
<p>Vergangene Messwerte und Modellprognosen. Nicht die Grundlage des aktuellen Netzfahrplans.</p>
<div class="forecast-metrics">
<div><span id="forecastLastUpdated">–</span><small>Aktualisiert</small></div>
<div><span id="forecastSeriesCount">0</span><small>Datenreihen</small></div>
<div><span id="forecastWindowValue">72 h</span><small>Zeitfenster</small></div>
<div><span id="forecastWindowValue">–</span><small>Zeitfenster</small></div>
</div>
<section class="forecast-chart-section">
<div class="section-head"><div><h2>Leistung und Prognose</h2><p>Messwerte und Prognosen gemeinsam vergleichen. Reihen können einzeln ein- und ausgeblendet werden.</p></div></div>
<div class="section-head"><div><h2>Historischer Vergleich</h2><p>Nur vergangene PV-, Haus-, Netz- und SOC-Werte; frühere Netz- und Batteriepläne sind hier ausgeblendet.</p></div></div>
<div class="forecast-series-panel">
<strong>Kurven auswählen</strong>
<div id="forecastSeriesControls" class="forecast-series-controls"></div>
@@ -266,9 +269,11 @@
</table>
</div>
</section>
<section id="forecastConfigSection" class="forecast-config-section hidden">
</details>
<details id="forecastConfigSection" class="forecast-config-section config-details hidden">
<summary>Anlagendaten und Tarife</summary>
<div class="section-head">
<div><h2>Prognosekonfiguration</h2><p>Anlagenparameter für Berechnung und Tarife.</p></div>
<div><h2>Standort und Tarife</h2><p>Gemeinsame Eingangsdaten für Prognosen und Netzfahrplan.</p></div>
<span id="forecastConfigStatus" class="status review">Nicht gespeichert</span>
</div>
<form id="forecastConfigForm">
@@ -295,20 +300,15 @@
<div id="forecastRoofs" class="forecast-roofs"></div>
</div>
<div class="forecast-config-block">
<div class="config-block-head"><div><h3>Prognoseumfang</h3><p>Funktionen für diese Anlage. Der Netzfahrplan schliesst PV- und Verbrauchsprognose ein.</p></div></div>
<div class="forecast-variants">
<label><input type="checkbox" name="forecast_pv">PV-Ertragsprognose</label>
<label><input type="checkbox" name="forecast_load">Verbrauchsprognose</label>
<label><input type="checkbox" name="grid_schedule">Intelligenter Netzfahrplan</label>
<div class="config-block-head"><div><h3>Lizenzierter Prognoseumfang</h3><p id="forecastLicensedScope">Wird geprüft.</p></div></div>
</div>
</div>
<p id="forecastConfigError" class="form-error" role="alert"></p>
<div class="forecast-config-actions"><button class="button primary" type="submit">Konfiguration speichern</button></div>
</form>
</section>
<p id="forecastConfigError" class="form-error" role="alert"></p>
</details>
<div class="forecast-layout">
<section class="manager-detail"><p class="eyebrow">Lizenzierte Variante</p><h2 id="forecastVariant">Manager</h2><div class="license-validity"><span>Lizenzdauer</span><strong>Unbeschränkt gültig</strong></div><div id="forecastOfflineWarning" class="offline-license-warning hidden" role="status"><strong id="forecastOfflineTitle">Keine Verbindung zum Lizenzserver</strong><p id="forecastOfflineText"></p></div></section>
<section class="forecast-scope"><p class="eyebrow">Datenumfang</p><h2>Live-Prognose</h2><dl class="detail-list"><div><dt>Modus</dt><dd>Konfiguration und Anzeige</dd></div><div><dt>Simulation</dt><dd>Nicht enthalten</dd></div><div><dt>Zugriff</dt><dd>Lizenzgebunden</dd></div></dl></section>
<section class="forecast-scope"><p class="eyebrow">Regelung</p><h2>Netzfahrplan</h2><dl class="detail-list"><div><dt>Planung</dt><dd>Server</dd></div><div><dt>Ausführung</dt><dd>Manager und Batteriemodul</dd></div><div><dt>Aktivierung</dt><dd>Im Manager</dd></div></dl></section>
</div>
</div>
</section>
@@ -377,6 +377,6 @@
<dialog id="deletePaidDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Zweite Bestätigung</p><h2>Auch Lizenz löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning critical-warning"><strong>Achtung: Für diese Anlage besteht eine bezahlte Lizenz.</strong><p>Sollen <b id="deletePaidSystem">die Anlage</b> und die zugehörigen Lizenzcodes, Berechtigungen, Bestellungen und Sandbox-Zahlungsdaten wirklich endgültig gelöscht werden?</p><p>Diese Aktion kann nicht rückgängig gemacht werden.</p></div><p class="form-error" id="deletePaidError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePaid" type="button">Anlage und Lizenz löschen</button></div></div></dialog>
<script src="/app.js?v=20261006-forecast"></script>
<script src="/app.js?v=20261008-workflow"></script>
</body>
</html>
@@ -13,6 +13,18 @@
.forecast-planner button:disabled { opacity: .5; cursor: wait; }
.forecast-planner button:focus-visible, .forecast-planner input:focus-visible, .forecast-planner select:focus-visible { outline: 2px solid #305b5b; outline-offset: 3px; }
.forecast-planner-notice { padding: .85rem 1rem; border-left: 4px solid #dce169; background: #f2f6e7; }
.forecast-planner .forecast-workflow { padding: 0 0 1rem; border-bottom: 1px solid #d8e0de; }
.forecast-planner .forecast-workflow-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: 1rem; }
.forecast-planner .forecast-workflow-heading h3 { margin: 0; }
.forecast-planner .forecast-workflow-heading button { width: auto; background: #fff; color: #305b5b; }
.forecast-planner .forecast-workflow-state { display: block; font-size: 1.2rem; line-height: 1.35; font-weight: 600; overflow-wrap: anywhere; color: #305b5b; }
.forecast-planner .forecast-workflow[data-state=interrupted] .forecast-workflow-state { color: #933e3e; }
.forecast-planner .forecast-workflow[data-state=unknown] .forecast-workflow-state { color: #576467; }
.forecast-planner .forecast-workflow p { margin: .65rem 0 0; font-size: .85rem; }
.forecast-planner .forecast-workflow-facts, .forecast-planner .forecast-plan-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin: 1.25rem 0; }
.forecast-planner .forecast-workflow-facts > div, .forecast-planner .forecast-plan-facts > div { min-width: 0; }
.forecast-planner .forecast-workflow-facts dt, .forecast-planner .forecast-plan-facts dt { color: #576467; font-size: .8rem; }
.forecast-planner .forecast-workflow-facts dd, .forecast-planner .forecast-plan-facts dd { margin: .4rem 0 0; font-size: .9rem; line-height: 1.4; font-weight: 600; overflow-wrap: anywhere; }
.forecast-planner table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: .85rem; }
.forecast-planner th, .forecast-planner td { padding: .65rem .5rem; border-bottom: 1px solid #d8e0de; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
.forecast-planner th { width: 40%; font-weight: 600; }
@@ -42,7 +54,7 @@
.forecast-planner .forecast-chart-sr { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 600px) {
.forecast-page main { margin: 0; padding: 1rem; }
.forecast-planner form { grid-template-columns: minmax(0, 1fr); }
.forecast-planner form, .forecast-planner .forecast-workflow-facts, .forecast-planner .forecast-plan-facts { grid-template-columns: minmax(0, 1fr); }
.forecast-planner th { width: 43%; }
.forecast-planner .forecast-chart-slider { grid-template-columns: minmax(0, 1fr); gap: 0; }
.forecast-planner .forecast-chart-inspector dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -1,10 +1,44 @@
import { forecastSeries, renderForecastChart } from './forecast-chart.js';
const WORKFLOW_LABELS = {
disabled: 'Ausgeschaltet', collecting: 'Sammelt Daten', learning: 'Lernt',
ready: 'Bereit', active: 'Aktiv', interrupted: 'Unterbrochen'
};
const WORKFLOW_REASONS = {
manager_control_active: 'Der Manager bestaetigt die aktive Batterieregelung nach Netzfahrplan.',
manager_report_stale: 'Die Manager-Rueckmeldung ist abgelaufen. Aktiver Betrieb ist nicht mehr bestaetigt.',
planning_not_ready: 'Die Planung ist noch nicht bereit. Der Netzfahrplan ist nicht aktiv bestaetigt.',
manager_control_not_active: 'Der Netzfahrplan ist angefordert; der Manager bestaetigt noch keine aktive Regelung.',
learning_disabled: 'Die Datenuebertragung fuer die Prognose ist ausgeschaltet.',
plan_and_model_ready: 'Modell und Plan liegen vor. Die Aktivierung erfolgt im Manager nach lokaler Schutzpruefung.',
awaiting_fresh_plan: 'Das Modell liegt vor. Ein aktueller Netzfahrplan wird noch benoetigt.',
model_training: 'Aus den vorhandenen Messdaten wird das Prognosemodell aufgebaut.',
collecting_measurements: 'Messdaten werden gesammelt. Fuer das erste Modell fehlt noch eine ausreichende Datenbasis.',
setup_required: 'Die Einrichtung von Messquellen und Prognose ist noch nicht vollstaendig.'
};
export function workflowView(workflow, now = Date.now()) {
const confirmed = workflow && Object.hasOwn(WORKFLOW_LABELS, workflow.state)
&& typeof workflow.checkedAt === 'string' && Number.isFinite(Date.parse(workflow.checkedAt))
&& ['learningEnabled', 'controlRequested', 'controlActive', 'planningReady'].every(key => typeof workflow[key] === 'boolean');
const consistent = confirmed && (workflow.state !== 'active' || (workflow.controlActive && workflow.controlRequested && workflow.planningReady))
&& (workflow.state === 'active' || !workflow.controlActive)
&& (workflow.state !== 'ready' || workflow.planningReady);
if (!consistent) return { state: 'unknown', label: 'Betriebsstatus nicht bestaetigt', reason: 'Keine vollstaendige, bestaetigte Manager-Rueckmeldung vorhanden.' };
if (workflow.state === 'active' && (workflow.reportFresh !== true || !workflow.learningEnabled
|| !Number.isFinite(Date.parse(workflow.sourceReportAt)) || !Number.isFinite(Date.parse(workflow.sourceFreshUntil))
|| Date.parse(workflow.sourceReportAt) > now || Date.parse(workflow.sourceFreshUntil) <= now)) {
return { ...workflow, state: 'interrupted', label: WORKFLOW_LABELS.interrupted, controlActive: false, reason: WORKFLOW_REASONS.manager_report_stale };
}
return { ...workflow, label: WORKFLOW_LABELS[workflow.state], reason: Object.hasOwn(WORKFLOW_REASONS, workflow.reason) ? WORKFLOW_REASONS[workflow.reason] : (typeof workflow.reason === 'string' ? workflow.reason : '') };
}
export function createPlanner({ message, panel, plants, session }) {
let generation = 0;
let workflowTimer;
const charts = [];
const element = (tag, text) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e; };
const time = value => value ? new Date(value).toLocaleString('de-CH', { timeZone: 'Europe/Zurich' }) : 'nicht vorhanden';
const time = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString('de-CH', { timeZone: 'Europe/Zurich' }) : 'nicht vorhanden';
const num = (v, digits = 2) => typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'unbekannt';
async function request(url, method = 'GET', body) {
@@ -21,12 +55,20 @@ function select(label, choices, selected) {
for (const [value, caption] of choices) { const option = element('option', caption); option.value = value; field.append(option); }
field.value = selected; wrap.append(field); return { wrap, field };
}
function table(title, rows) {
panel.append(element('h3', title)); const grid = element('table');
function table(title, rows, target = panel) {
target.append(element('h3', title)); const grid = element('table');
for (const [name, value] of rows) { const row = element('tr'); row.append(element('th', name), element('td', String(value))); grid.append(row); }
panel.append(grid);
target.append(grid);
}
function clearCharts() { for (const chart of charts.splice(0)) chart.destroy(); }
function facts(rows, className) {
const list = element('dl'); list.className = className;
for (const [name, value] of rows) { const row = element('div'); row.append(element('dt', name), element('dd', value)); list.append(row); }
return list;
}
function details(caption) {
const node = element('details'); node.className = 'forecast-planner-settings'; node.append(element('summary', caption)); return node;
}
function clearCharts() { clearTimeout(workflowTimer); for (const chart of charts.splice(0)) chart.destroy(); }
function curve(title, points, series, unit, caption) {
charts.push(renderForecastChart(panel, { title, points, series, unit, caption }));
}
@@ -37,23 +79,44 @@ async function render() {
clearCharts(); panel.replaceChildren();
try {
const state = await request(base); if (ticket !== generation) return; clearCharts(); panel.replaceChildren();
const safety = element('p', 'SCHATTENBETRIEB der Serverplanung: Die Serverprognose allein schaltet keine Geraete. Eine lokale Nutzung durch den Forecast Switch braucht eine gesonderte Freigabe. Der tatsaechliche Anlagenbetrieb wird hier nicht bestaetigt.');
let workflow = workflowView(state.workflow);
const status = element('section'); status.className = 'forecast-workflow';
const heading = element('div'); heading.className = 'forecast-workflow-heading';
const title = element('h3', 'Prognose und Netzfahrplan');
const refresh = element('button', 'Status aktualisieren'); refresh.type = 'button'; refresh.addEventListener('click', render);
heading.append(title, refresh);
const stateBody = element('div'); status.append(heading, stateBody);
const showWorkflow = () => {
workflow = workflowView(state.workflow); status.dataset.state = workflow.state;
const badge = element('strong', workflow.label); badge.className = 'forecast-workflow-state';
const reason = element('p', workflow.reason); reason.className = 'forecast-workflow-reason';
stateBody.replaceChildren(badge, reason, facts([
['Prognose lernen', workflow.learningEnabled === undefined ? 'Nicht bestaetigt' : workflow.learningEnabled ? 'Eingeschaltet' : 'Ausgeschaltet'],
['Netzfahrplan aktivieren', workflow.controlRequested === undefined ? 'Nicht bestaetigt' : workflow.controlRequested ? 'Eingeschaltet' : 'Ausgeschaltet'],
['Regelung durch Manager', workflow.controlActive === undefined ? 'Nicht bestaetigt' : workflow.controlActive ? 'Aktiv gemeldet' : 'Kein aktiver Betrieb bestaetigt']
], 'forecast-workflow-facts'), element('p', 'Manager-Rueckmeldung: ' + time(workflow.sourceReportAt)));
};
showWorkflow();
if (workflow.state === 'active') workflowTimer = setTimeout(showWorkflow, Math.min(2147483647, Date.parse(workflow.sourceFreshUntil) - Date.now() + 1));
panel.append(status);
const safety = element('p', 'Der Manager regelt die Batterie auf das Netzziel und korrigiert Abweichungen mit lokalen Messwerten. Geraete- und Netzgrenzen behalten Vorrang. Die Kurven zeigen berechnete Plaene, keine nachgewiesene Ausfuehrung.');
safety.className = 'forecast-planner-notice'; panel.append(safety);
table('Freigabe und Datenstand', [['Serverseitige Probebetriebsfreigabe', state.controlledTrialAuthorized === true ? 'Vorhanden; lokale Schutzpruefung bleibt erforderlich' : state.controlledTrialAuthorized === false ? 'Nicht vorhanden' : 'Nicht gemeldet'], ['Lokale Ausfuehrung', 'In dieser Ansicht nicht nachgewiesen'], ['Status geprueft', time(state.checkedAt)], ['Letzte Planquittierung', state.acknowledgement?.status || 'Nicht vorhanden']]);
const settingsStart = panel.childNodes.length;
const settingsDetails = details('Erweiterte Planungseinstellungen');
const diagnostics = details('Technische Details, Daten und Training');
table('Betriebsnachweis', [['Planungsbereitschaft', workflow.planningReady === undefined ? 'Nicht bestaetigt' : workflow.planningReady ? 'Bereit gemeldet; lokale Schutzpruefung bleibt erforderlich' : 'Noch nicht bereit'], ['Serverstatus geprueft', time(state.checkedAt)], ['Letzte Planquittierung', state.acknowledgement?.status || 'Nicht vorhanden'], ['Bedeutung der Quittierung', 'Planempfang ist kein Ausfuehrungsnachweis']], diagnostics);
const form = element('form');
const family = select('Modellfamilie', [['auto', 'Automatisch – historischer Kostenvergleich'], ...state.families.map(f => [f.key, f.label])], state.settings.family);
const measurement = select('Messdatenbasis', [['verified_only', 'Nur verifizierte Peak-/Viertelstundenwerte'], ['allow_estimates', 'Gekennzeichnete Schaetzungen fuer die Planung zulassen']], state.settings.measurementPolicy || 'verified_only');
const outlook = select('Peakbewertung', [['full_incremental', 'Voller zusaetzlicher Monatstarif'], ['empirical_if_available', 'Restmonats-Szenarien bei ausreichender Historie']], state.settings.peakOutlookPolicy || 'full_incremental');
const dataSource = select('Verbrauchsprognose', [['legacy', 'Bisherige Datenquelle'], ['corrected_profile', 'Bereinigte physische Lastprofile']], state.settings.forecastSource || 'legacy');
const dataSource = select('Verbrauchsprognose', [['legacy', 'Importierte Eingangsprognose'], ['corrected_profile', 'Bereinigte physische Lastprofile']], state.settings.forecastSource || 'legacy');
const datasets = state.dataPipeline?.datasets || [];
const dataset = select('Messdatensatz', [['', 'Datensatz auswaehlen'], ...datasets.map(d => [d.datasetId, d.datasetId + ' (' + d.status + ')'])], state.settings.measurementDataset || '');
const cadence = select('Nachtraining', [['daily', 'Taeglich'], ['weekly', 'Woechentlich']], state.settings.trainingCadence);
const relianceWrap = element('label', 'Gewicht der Restmonatsprognose (0 bis 100 %) '), reliance = element('input');
reliance.type = 'number'; reliance.min = '0'; reliance.max = '100'; reliance.step = '1'; reliance.value = String(100 * (state.settings.peakOutlookReliance ?? .5)); relianceWrap.append(reliance);
const save = element('button', 'Speichern und neu berechnen'), replan = element('button', 'Neu berechnen'), refresh = element('button', 'Status aktualisieren');
save.type = 'submit'; replan.type = refresh.type = 'button';
form.append(family.wrap, dataSource.wrap, dataset.wrap, measurement.wrap, outlook.wrap, relianceWrap, cadence.wrap, save, replan, refresh); panel.append(form);
const save = element('button', 'Speichern und neu berechnen'), replan = element('button', 'Neu berechnen');
save.type = 'submit'; replan.type = 'button';
form.append(family.wrap, dataSource.wrap, dataset.wrap, measurement.wrap, outlook.wrap, relianceWrap, cadence.wrap, save, replan); settingsDetails.append(form);
let revision = state.settings.revision;
form.addEventListener('submit', async event => {
event.preventDefault(); save.disabled = true;
@@ -64,45 +127,42 @@ async function render() {
} catch (error) { if (ticket === generation) message.textContent = error.message; } finally { save.disabled = false; }
});
replan.addEventListener('click', async () => { replan.disabled = true; try { await request(base + '/replan', 'POST', {}); if (ticket === generation) message.textContent = 'Neuberechnung angefordert.'; } catch (error) { if (ticket === generation) message.textContent = error.message; } finally { replan.disabled = false; } });
refresh.addEventListener('click', render);
panel.append(element('p', 'Die Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe. Ohne ausreichende vergleichbare Historie gilt der volle zusaetzliche Monatstarif. Technische und konfigurierte Managergrenzen bleiben verbindlich.'));
panel.append(element('p', 'Fuer bereinigte Lastprofile steuert die Trainingsauswahl den automatischen Modellaufbau. Historische Prognosen bleiben unveraendert. Die wirtschaftliche Modellautomatik erstellt vergleichbare Kosten-Replays aus eingefrorenen Tagesplaenen und spaeter eingegangenen Messwerten. Ohne ausreichende Vergleichstage bleibt die bisherige Familie aktiv.'));
for (const d of datasets) table('Daten und Training: ' + d.datasetId, [['Zustand', d.status], ['Angenommene Aufnahmen', d.records], ['Letzte Aufnahme', time(d.lastCapture)], ['Letzter Serverempfang', time(d.lastReceived)], ['Nutzbare 5-Minutenwerte', d.detail?.usableWindows ?? 0], ['Datenbasis in Stunden', num(d.detail?.usableEquivalentHours)], ['Modellstand', d.detail?.modelId || 'noch in Datensammlung'], ['Letztes Training', time(d.detail?.trainedAt)], ['Validierung', d.detail?.validation?.status || 'noch ausstehend'], ['Messgrundlage', 'Konfigurierte physische Schaetzung; kein unabhaengiger Messnachweis']] );
settingsDetails.append(element('p', 'Die Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe. Ohne ausreichende vergleichbare Historie gilt der volle zusaetzliche Monatstarif. Technische und konfigurierte Managergrenzen bleiben verbindlich.'));
settingsDetails.append(element('p', 'Fuer bereinigte Lastprofile steuert die Trainingsauswahl den automatischen Modellaufbau. Historische Prognosen bleiben unveraendert. Die wirtschaftliche Modellautomatik erstellt vergleichbare Kosten-Replays aus eingefrorenen Tagesplaenen und spaeter eingegangenen Messwerten. Ohne ausreichende Vergleichstage bleibt die bisherige Familie aktiv.'));
for (const d of datasets) table('Daten und Training: ' + d.datasetId, [['Zustand', d.status], ['Angenommene Aufnahmen', d.records], ['Letzte Aufnahme', time(d.lastCapture)], ['Letzter Serverempfang', time(d.lastReceived)], ['Nutzbare 5-Minutenwerte', d.detail?.usableWindows ?? 0], ['Datenbasis in Stunden', num(d.detail?.usableEquivalentHours)], ['Modellstand', d.detail?.modelId || 'noch in Datensammlung'], ['Letztes Training', time(d.detail?.trainedAt)], ['Validierung', d.detail?.validation?.status || 'noch ausstehend'], ['Messgrundlage', 'Konfigurierte physische Schaetzung; kein unabhaengiger Messnachweis']], diagnostics);
if (state.economicComparison?.connected) {
table('Wirtschaftlicher Modellvergleich', [['Gespeicherte Tagesvergleiche', state.economicComparison.cohorts], ['Abgeschlossene Vergleiche', state.economicComparison.completedComparisons.length], ['Methode', 'Eingefrorener Tagesplan mit simulierter Netzregelung'], ['Grenze dieses Vergleichs', 'Eine netzladefaehige Batterie; keine vollstaendige Nachbildung laufender Neuoptimierungen'], ['Bewertung', 'Energiekosten + zusaetzlicher Peak + Verluste + gemeinsame Restenergiebewertung']]);
panel.append(element('p', 'Der SDL-Anteil der historischen Bilanz ist eine gekennzeichnete Schaetzung aus dem damaligen Auftrag. Die Vergleichskosten sind keine nachgewiesenen Einsparungen auf einer Stromrechnung.'));
for (const item of state.economicComparison.completedComparisons) table('Vergleich ' + item.day, Object.entries(item.results).map(([family, result]) => ['Familie ' + family, num(result.costChf) + ' CHF; Datenabdeckung ' + num(100 * result.coverage, 1) + ' %']));
table('Wirtschaftlicher Modellvergleich', [['Gespeicherte Tagesvergleiche', state.economicComparison.cohorts], ['Abgeschlossene Vergleiche', state.economicComparison.completedComparisons.length], ['Methode', 'Eingefrorener Tagesplan mit simulierter Netzregelung'], ['Grenze dieses Vergleichs', 'Eine netzladefaehige Batterie; keine vollstaendige Nachbildung laufender Neuoptimierungen'], ['Bewertung', 'Energiekosten + zusaetzlicher Peak + Verluste + gemeinsame Restenergiebewertung']], diagnostics);
diagnostics.append(element('p', 'Der SDL-Anteil der historischen Bilanz ist eine gekennzeichnete Schaetzung aus dem damaligen Auftrag. Die Vergleichskosten sind keine nachgewiesenen Einsparungen auf einer Stromrechnung.'));
for (const item of state.economicComparison.completedComparisons) table('Vergleich ' + item.day, Object.entries(item.results).map(([family, result]) => ['Familie ' + family, num(result.costChf) + ' CHF; Datenabdeckung ' + num(100 * result.coverage, 1) + ' %']), diagnostics);
}
if (state.maintenance) table('Datenaufbewahrung', [['Archivierung', state.maintenance.status], ['Originale', 'Nach verifizierter Kompression weiterhin wiederherstellbar; externe Sicherung separat betreiben']]);
panel.append(element('p', state.lastRun ? `Rechenstatus: ${state.lastRun.status} ${state.lastRun.detail?.reason || ''}` : 'Noch kein Rechenlauf.'));
if (state.maintenance) table('Datenaufbewahrung', [['Archivierung', state.maintenance.status], ['Originale', 'Nach verifizierter Kompression weiterhin wiederherstellbar; externe Sicherung separat betreiben']], diagnostics);
diagnostics.append(element('p', state.lastRun ? `Rechenstatus: ${state.lastRun.status} ${state.lastRun.detail?.reason || ''}` : 'Noch kein Rechenlauf.'));
if (state.pending) panel.append(element('p', 'Neuberechnung vorgemerkt oder aktiv.'));
for (const [month, basis] of Object.entries(state.peakPlanningBases || {})) table(`Vorlaeufige Peakbasis ${month}`, [['Wert', num(basis.kw) + ' kW'], ['Qualitaet', basis.quality], ['Quelle', basis.source], ['Beobachtet', time(basis.observedAt)], ['Hinweis', basis.notes || 'Kein Abrechnungsnachweis']]);
const settingsDetails = element('details');
settingsDetails.className = 'forecast-planner-settings';
settingsDetails.append(element('summary', 'Planungseinstellungen, Daten und Training'), ...Array.from(panel.childNodes).slice(settingsStart));
for (const [month, basis] of Object.entries(state.peakPlanningBases || {})) table(`Vorlaeufige Peakbasis ${month}`, [['Wert', num(basis.kw) + ' kW'], ['Qualitaet', basis.quality], ['Quelle', basis.source], ['Beobachtet', time(basis.observedAt)], ['Hinweis', basis.notes || 'Kein Abrechnungsnachweis']], diagnostics);
const plan = state.plan;
if (plan) {
if (!state.fresh) panel.append(element('p', 'ACHTUNG: Plan veraltet oder Neuberechnung ausstehend.'));
const quality = plan.inputQuality || {};
const loadBasis = quality.loadBasis === 'base_load' ? 'Bereinigte Basislast' : quality.loadBasis === 'house_total' ? 'Aggregierte Hauslast; SDL-Trennung nicht nachgewiesen' : quality.loadBasis || 'Nicht angegeben';
table('Plan und Quellen', [['Plan-ID', plan.planId], ['Berechnet', time(plan.generatedAt)], ['Rechenstatus', plan.status || 'Nicht vorhanden'], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Lastgrundlage', loadBasis], ['Datenquelle der Lastprognose', quality.dataPipeline ? 'Bereinigte physische Lastprofile' : 'Eingegangene Prognose der angegebenen Modellfamilie'], ['Planungsmodus des Servers', plan.runMode || 'Nicht angegeben'], ['Optimierter Horizont bis', time(plan.validUntil)], ['Prognoseeingang bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]]);
table('Kostenprognose', [['Energiekosten', num(plan.energyCostChf) + ' CHF'], ['Zusaetzliche Peakkosten zum vollen Tarif', num(plan.additionalPeakCostChf) + ' CHF'], ['Peakbewertung unter Restmonats-Szenarien', num(plan.planningPeakCostChf) + ' CHF'], ['Planung mit geschaetzter Peakbasis', plan.peakCostIsEstimate ? 'Ja – kein Abrechnungsnachweis' : 'Nein']]);
panel.append(facts([['Plan berechnet', time(plan.generatedAt)], ['Netzfahrplan bis', time(plan.validUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]], 'forecast-plan-facts'));
table('Plan und Quellen', [['Plan-ID', plan.planId], ['Berechnet', time(plan.generatedAt)], ['Rechenstatus', plan.status || 'Nicht vorhanden'], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Lastgrundlage', loadBasis], ['Datenquelle der Lastprognose', quality.dataPipeline ? 'Bereinigte physische Lastprofile' : 'Eingegangene Prognose der angegebenen Modellfamilie'], ['Planungsmodus des Servers', plan.runMode || 'Nicht angegeben'], ['Optimierter Horizont bis', time(plan.validUntil)], ['Prognoseeingang bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]], diagnostics);
table('Kostenprognose', [['Energiekosten', num(plan.energyCostChf) + ' CHF'], ['Zusaetzliche Peakkosten zum vollen Tarif', num(plan.additionalPeakCostChf) + ' CHF'], ['Peakbewertung unter Restmonats-Szenarien', num(plan.planningPeakCostChf) + ' CHF'], ['Planung mit geschaetzter Peakbasis', plan.peakCostIsEstimate ? 'Ja - kein Abrechnungsnachweis' : 'Nein']], diagnostics);
for (const warning of plan.warnings || []) panel.append(element('p', warning));
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]]);
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]], diagnostics);
const points = plan.points || [];
const forecast = forecastSeries(plan);
const forecastScope = forecast.fullHorizon ? 'Vollstaendige, fuer diesen Plan gespeicherte Eingangsprognose, auch ueber das Ende der bekannten Preise hinaus. Netz- und Batterieplan sowie Kosten bleiben auf den optimierten Zeitraum begrenzt.' : 'Aelterer Plan ohne separat gespeicherte Gesamtprognose: nur Eingangswerte im optimierten Zeitraum verfuegbar.';
curve('PV, Last und externe Leistung', forecast.points, [{ label: 'PV-Prognose', value: p => p.pvW, color: '#757b12' }, { label: quality.loadBasis === 'base_load' ? 'Basislast-Prognose' : 'Last-Prognose', value: p => p.loadW, color: '#4e8181' }, { label: 'Externe Leistung / SDL-Szenario', value: p => quality.loadBasis === 'base_load' ? p.externalW : undefined, color: '#ac4f68', dash: '6 3' }], 'W', forecastScope + ' Externe Leistung ist nur bei nachgewiesener Basislast separat bilanziert; ein fortgeschriebener SDL-Auftrag ist kein veroeffentlichter SDL-Fahrplan.');
curve('Netzleistung', points, [{ label: 'Ohne Optimierung', value: p => p.baselineGridW, color: '#737c7e', dash: '6 3' }, { label: 'Optimiert', value: p => p.gridTargetW, color: '#4e8181' }], 'W', 'Positiv: Netzbezug. Negativ: Einspeisung.');
curve('PV- und Hausprognose', forecast.points, [{ label: 'PV-Prognose', value: p => p.pvW, color: '#757b12' }, { label: quality.loadBasis === 'base_load' ? 'Hausprognose (bereinigte Basislast)' : 'Hausprognose (aggregierte Last)', value: p => p.loadW, color: '#4e8181' }, { label: 'Externe Leistung / SDL-Szenario', value: p => quality.loadBasis === 'base_load' ? p.externalW : undefined, color: '#ac4f68', dash: '6 3' }], 'W', forecastScope + ' Externe Leistung ist nur bei nachgewiesener Basislast separat bilanziert; ein fortgeschriebener SDL-Auftrag ist kein veroeffentlichter SDL-Fahrplan.');
curve('Strompreis Bezug und Einspeisung', points, [{ label: 'Bezugspreis', value: p => p.importPriceChfKwh, digits: 4 }, { label: 'Einspeisepreis', value: p => p.exportPriceChfKwh, digits: 4, dash: '6 3' }], 'CHF/kWh', 'Fuer diesen Plan gespeicherte Preise. Keine Fortschreibung ueber den bekannten Preiszeitraum hinaus.');
curve('Netzfahrplan', points, [{ label: 'Ohne Optimierung', value: p => p.baselineGridW, color: '#737c7e', dash: '6 3' }, { label: 'Netzziel fuer den Manager', value: p => p.gridTargetW, color: '#4e8181' }], 'W', 'Positiv: Netzbezug. Negativ: Einspeisung. Berechnetes Netzziel; keine gemessene Netzleistung.');
curve('Batterieleistung', points, [{ label: 'Batterieplan', value: p => p.batteryTargetW }], 'W', 'Positiv: Laden. Negativ: Entladen.');
const assets = new Set(points.flatMap(point => Object.keys(point.socEndPercent || {})));
for (const asset of assets) curve(`Ladezustand ${asset}`, points, [{ label: 'Geplanter SOC am Intervallende', value: p => p.socEndPercent?.[asset], position: 'end' }], '%');
curve('Energiepreise', points, [{ label: 'Bezugspreis', value: p => p.importPriceChfKwh, digits: 4 }, { label: 'Einspeisepreis', value: p => p.exportPriceChfKwh, digits: 4, dash: '6 3' }], 'CHF/kWh', 'Preise im optimierten Horizont, keine Fortschreibung ueber den bekannten Preiszeitraum hinaus.');
curve('Kumulierte Kosten inklusive vollem Peakpreis', points, [{ label: 'Ohne Optimierung', value: p => p.cumulativeBaselineCashCostChf, position: 'end', color: '#737c7e', dash: '6 3' }, { label: 'Optimiert', value: p => p.cumulativeCashCostChf, position: 'end', color: '#4e8181' }], 'CHF', 'Jeweils am Intervallende. Prognose von Energie- und zusaetzlichen vollen Monatspeakkosten, keine Rechnung und kein Nachweis realisierter Einsparungen.');
panel.append(element('p', 'Restwert der Batterie und Restmonatsbewertung sind keine bereits erzielten Erloese. Der angezeigte Kostenverlauf enthaelt diese nicht; auch Durchsatzkosten sind darin nicht enthalten.'));
}
panel.append(settingsDetails);
if (!plan) settingsDetails.open = true;
else panel.append(element('p', 'Noch kein Netzfahrplan vorhanden. Sobald die erforderlichen Daten und Preise vorliegen, erscheint hier die Berechnungsgrundlage.'));
panel.append(settingsDetails, diagnostics);
message.textContent = '';
} catch (error) { if (ticket === generation) { clearCharts(); panel.replaceChildren(); message.textContent = error.message; } }
}
@@ -117,7 +177,7 @@ export function mountPlanner(container, { plantId, session }) {
return { ...planner, ready: planner.reload() };
}
const standalone = document.querySelector('[data-standalone-planner]');
const standalone = globalThis.document?.querySelector('[data-standalone-planner]');
if (standalone) {
const message = standalone.querySelector('#message'), panel = standalone.querySelector('#panel'), plants = standalone.querySelector('#plants');
try {
+16 -10
View File
@@ -48,13 +48,15 @@
"compose.yaml": "aae681e18be81633589926db93b27043fca983e255f2f62108ad38df42e1d607",
"deploy_integrated_shadow.py": "ec8b324dd5041f89ee84849937f27e9feffb80c6301efe48e9504b326e1f7898",
"forecast_acceptance.py": "cc018773c65b61e37f13dfafe2c53b71eec87311d9f3abb514e78cd5917251f9",
"gui/netplan-v4.css": "216959a2d90f346a167a4ac809e6cf96c00461abd9853f5c5b5052d6e34d7efd",
"gui/forecast-chart.browser-test.mjs": "a6789acb4fd76a3272ee6e91f3193082e7011cd0939f18c9612e88aef42e469a",
"gui/netplan-v4.css": "ca3f95d558554ec992deb5123d5bd4a598f5e41a6416cfc2b133d984ddd4e5e0",
"gui/netplan-v4.html": "296a247a7a9724dbe8c873372b1d5536ec02ea6eede823c1545e1d8749d64755",
"gui/netplan-v4.js": "245af626f3322dcaec5afbbc9772707ee6de00f07fd939e59709e7349f2d12d9",
"gui/netplan-v4.js": "b06eea3ae7564cb38b6b11cd4bbcc52274db0a0832faf6df5204c289c33c353d",
"gui/netplan-v4.test.mjs": "e29f811baf78c62de08c1da69b61cda491bcc7d26e8efae660f5b60677a3d0fc",
"install_hooks.py": "1c16586f970742c994cd0cf9ffb41e921ad34f64fa6043fef89d6bdd686799f3",
"integrations/NetzfahrplanV4.php": "8ecb7311c6db5998db5fa1b03bd70536bf1fb29f4fea45d257824c033358baf3",
"integrations/netplan-v4-bridge.mjs": "f67c28a148b9233da44be817940425265ca98278dc5e04cd36942432febf8dc7",
"integrations/netplan_v4_publisher.py": "cb8efe10d2799215b347985c53c96a6420e5461fff4c4e232b6f918d8bff2161",
"integrations/netplan-v4-bridge.mjs": "b2fdb30bf2567a8d3cf39445535325ae4c5009de326bd8306531545270272e93",
"integrations/netplan_v4_publisher.py": "e1ad0ad9e11ddf55a18e51d4333e5599a38975e51dd833357ce388f0412cb61a",
"netplan_v4/__init__.py": "8fe1793927dcdd2d15d0ce1bd3f53b759c0622d6754b5b8efdbb9764e76fde48",
"netplan_v4/battery_model.py": "2142f9173e872da8718ce3f5fa6a0062bd4f2f8ed75d42889bc41e2bd00ef332",
"netplan_v4/controlled_trial.py": "4d4bd8ed97050b3bf5872c839e4bcc2fc9dcb9f2e0a8c66a4611bbd50bf9e1c4",
@@ -63,7 +65,7 @@
"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/measurement_pipeline.py": "12ca01a39815fc5bd52447576be575d32670925d75f3e0bc34eb0b1d14e98b8f",
"netplan_v4/meter_runtime.py": "43275072a212351fa35d34ed2f4932a259112d516d594fad19a7e2b3fd44c547",
"netplan_v4/metering.py": "9ed4d3747de76f646d7be603cbdf37a3fc971109605705017c6644c0baa80987",
"netplan_v4/optimizer.py": "bf1ad3dcadf10c84e76f6758525fc1309b9f665853c660a1107c1347f1ce9063",
@@ -71,14 +73,15 @@
"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",
"netplan_v4/service.py": "62ed950b4b00c009e40e1cae48aeb1323137bf5a3eceaad68119c0cb9cdad167",
"netplan_v4/store.py": "e9a25e5b9cbddd1bf3feb94c335f6218941f5a0a599561df45df11d73b23570b",
"netplan_v4/workflow.py": "668109935feb6e64eb4a44e545952849b72dda69a6a721a93b861d6013251e4e",
"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/portal.test.mjs": "b9cf560a96e3d12ab136f5c4d2cbbcf985a384fed1347c3e87a2110a37c6145f",
"tests/test_application_deployment.py": "9fa667e08f9003dd942c6bbb1e8b77321f88b8d4ec2c042f91450e7d34bdeee5",
"tests/test_battery_recovery.py": "5185416073b4144f00c643acba48dc93be29d03163de10b1f29a8fd84c191ca3",
"tests/test_container_access.py": "181aa47e5c09235ae45a25e3221fc6871bbf89c49ce541599e9b8f7da8570df9",
@@ -94,12 +97,15 @@
"tests/test_mapping_recovery.py": "5c85eedf7ae780c8efa11ced856a7816ce24df0347d01e1a08e19f6dc101468e",
"tests/test_measurement_pipeline.py": "761887486922762dd12a1b3beb00f41e0000ec3cbfe3b101484d0fe8f2364eb4",
"tests/test_metering.py": "6c7af6e624c93cde4b6cca00e66fc8fec0b72a047ac77e16778dc1926194a69d",
"tests/test_native_measurement_setup.py": "88c7254d7a4e389d5bd6a82af5c536ca095af39ce9422922ae972c36e2320867",
"tests/test_peak_release.py": "223f4374111cfab99ef352a71a7b91a7f71abc4ed126f2c180b138d1bd390d3d",
"tests/test_receiver_contract.py": "21daccb358fa62a983d2292d5de2b9b3c7300a08e314e850749316a24d3d254e",
"tests/test_release_preflight.py": "e9f0dfc3dd752c2cc25908e47781554bf71e2e1af4f4d2514621fab37205ca68",
"tests/test_release_preflight.py": "f5ab9623adc7925f744f36980d81c6da67cea89b4f8fd1187bdcb31f30470a96",
"tests/test_retention.py": "dfe62da159be4b103643d61c10307395e22612c65017aaadd469d50a65398b51",
"tests/test_unified_release.py": "3665b3ffd0872e1956312ac16ab9527802ebc396e52ae0d2954bad53890d5236",
"tests/test_v4.py": "c35db22a864aa0afc4d0abc357ae854f038e86de8e3001760c0e63ce036a7763"
"tests/test_v4.py": "c35db22a864aa0afc4d0abc357ae854f038e86de8e3001760c0e63ce036a7763",
"tests/test_workflow.py": "6dc2d29555774a8c403779853d292f20653d991dae911a0f4f894c6acf213307",
"tests/test_workflow_core.py": "a667c7c9b333bd28d3e0c7e8b0a41c45d687a58749f678c82e66fd3bf8eabd29"
},
"scope": "application_source_only",
"productionReady": false
@@ -4,7 +4,20 @@ import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { join } from 'node:path';
const { chromium } = createRequire(import.meta.url)('playwright');
const require = createRequire(process.env.BROWSER_PACKAGE || import.meta.url);
const { chromium } = require('playwright');
const browserOptions = { headless: true };
if (process.env.BROWSER_EXECUTABLE) {
browserOptions.executablePath = process.env.BROWSER_EXECUTABLE;
const loaded = require('@sparticuz/chromium');
const standalone = loaded.default || loaded;
browserOptions.args = standalone.args.filter(arg => ![
'--single-process', '--disable-web-security', '--allow-running-insecure-content',
'--disable-site-isolation-trials', '--no-sandbox', '--disable-setuid-sandbox'
].includes(arg) && !arg.startsWith('--disable-features='));
} else if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
browserOptions.executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
}
const stamp = minute => new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString();
const state = {
@@ -40,7 +53,7 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let browser;
try {
browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } : {}) });
browser = await chromium.launch(browserOptions);
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/**', async route => {
@@ -52,6 +65,19 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
result.plan.inputQuality.loadBasis = 'house_total';
result.plan.forecastPoints.forEach(point => { point.externalW = null; });
}
const scenario = new URL(request.url()).pathname.split('/')[3];
if (['disabled', 'collecting', 'learning', 'ready', 'active', 'interrupted', 'expired', 'contradictory', 'no-plan', 'hostile-reason'].includes(scenario)) {
const workflowState = scenario === 'expired' || scenario === 'contradictory' ? 'active' : scenario === 'no-plan' ? 'collecting' : scenario === 'hostile-reason' ? 'interrupted' : scenario;
const now = Date.now();
result.workflow = {
state: workflowState, learningEnabled: scenario !== 'disabled', controlRequested: workflowState === 'active',
controlActive: workflowState === 'active' && scenario !== 'contradictory', planningReady: ['ready', 'active'].includes(workflowState),
reportFresh: true, checkedAt: new Date(now).toISOString(), sourceReportAt: new Date(now - 1000).toISOString(),
sourceFreshUntil: new Date(now + (scenario === 'expired' ? -1 : 119000)).toISOString(),
reason: scenario === 'hostile-reason' ? '<img src=x onerror="window.injected=true">' : scenario === 'collecting' ? 'collecting_measurements' : ''
};
if (scenario === 'no-plan') result.plan = null;
}
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(request.method() === 'PUT' ? { revision: 3 } : result) });
});
await page.goto(`http://127.0.0.1:${server.address().port}/`);
@@ -59,10 +85,15 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 6);
assert.equal(await page.locator('#legacy-chart').evaluate(node => getComputedStyle(node).height), '320px');
assert.equal(await page.locator('#legacy-legend').evaluate(node => getComputedStyle(node).borderBottomWidth), '1px');
await page.locator('#legacy-chart, #legacy-legend').evaluateAll(nodes => {
for (const node of nodes) node.style.display = 'none';
});
assert.equal(await page.locator('select#plants').count(), 0);
assert.equal(requests.length, 1);
assert.match(await page.locator('#host').innerText(), /tatsaechliche Anlagenbetrieb wird hier nicht bestaetigt/);
assert.match(await page.locator('#host').innerText(), /Vorhanden; lokale Schutzpruefung/);
assert.equal(await page.locator('.forecast-workflow-state').innerText(), 'Betriebsstatus nicht bestaetigt');
assert.match(await page.locator('#host').innerText(), /berechnete Plaene, keine nachgewiesene Ausfuehrung/);
assert.equal(await page.locator('.forecast-planner details[open]').count(), 0);
assert.deepEqual(await page.locator('.forecast-planner .forecast-chart h3').allTextContents(), ['PV- und Hausprognose', 'Strompreis Bezug und Einspeisung', 'Netzfahrplan', 'Batterieleistung', 'Ladezustand battery', 'Kumulierte Kosten inklusive vollem Peakpreis']);
const chart = page.locator('.forecast-planner .forecast-chart').first();
const slider = chart.locator('input[type=range]');
assert.equal(await slider.getAttribute('max'), '23');
@@ -74,6 +105,7 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
await chart.getByLabel('PV-Prognose', { exact: true }).uncheck();
assert.equal(await chart.locator('dt').count(), 2);
await chart.getByLabel('PV-Prognose', { exact: true }).check();
assert.equal(requests.length, 1, 'chart inspection and visibility never change planner settings or controls');
await chart.locator('svg').scrollIntoViewIfNeeded();
const bounds = await chart.locator('svg').boundingBox();
await page.mouse.move(bounds.x + bounds.width - 20, bounds.y + 70);
@@ -85,13 +117,41 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `no horizontal overflow at ${width}`);
assert.ok((await chart.boundingBox()).height > 320, 'legacy SVG height must not constrain new chart sections');
assert.ok(await chart.locator('polyline').count() >= 3);
if (process.env.FORECAST_SCREENSHOT_DIR) await chart.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-chart-${width}.png`) });
if (process.env.FORECAST_SCREENSHOT_DIR) {
await chart.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-chart-${width}.png`) });
await page.locator('.forecast-workflow').screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-workflow-${width}.png`) });
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-viewport-${width}.png`) });
await page.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-full-${width}.png`), fullPage: true });
}
await page.locator('summary').click();
}
await page.getByText('Erweiterte Planungseinstellungen', { exact: true }).click();
await page.getByRole('button', { name: 'Speichern und neu berechnen', exact: true }).click();
await page.getByRole('status').filter({ hasText: 'Revision 3 gespeichert' }).waitFor();
assert.equal(requests.at(-1).csrf, 'test-only');
assert.equal(requests.some(request => /trial|arm|live/.test(request.url)), false);
const showPlant = async plantId => page.evaluate(async id => {
window.planner.dispose();
window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: id, session: {} });
await window.planner.ready;
}, plantId);
for (const [scenario, label] of Object.entries({ disabled: 'Ausgeschaltet', collecting: 'Sammelt Daten', learning: 'Lernt', ready: 'Bereit', active: 'Aktiv', interrupted: 'Unterbrochen', expired: 'Unterbrochen', contradictory: 'Betriebsstatus nicht bestaetigt', 'no-plan': 'Sammelt Daten' })) {
await showPlant(scenario);
assert.equal(await page.locator('.forecast-workflow-state').innerText(), label, scenario);
assert.equal(await page.locator('.forecast-planner details[open]').count(), 0, 'technical details remain collapsed');
if (scenario === 'no-plan') assert.match(await page.locator('#host').innerText(), /Noch kein Netzfahrplan/);
}
await showPlant('hostile-reason');
assert.equal(await page.locator('.forecast-workflow-reason img').count(), 0);
assert.match(await page.locator('.forecast-workflow-reason').innerText(), /<img/);
assert.equal(await page.evaluate(() => window.injected), undefined);
await page.clock.install();
await showPlant('active');
assert.equal(await page.locator('.forecast-workflow-state').innerText(), 'Aktiv');
await page.clock.fastForward(120000);
assert.equal(await page.locator('.forecast-workflow-state').innerText(), 'Unterbrochen', 'open pages expire the local report without hiding charts');
assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 6);
await page.clock.resume();
await page.evaluate(async () => {
window.planner.dispose();
window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'legacy', session: {} });
+13 -1
View File
@@ -13,6 +13,18 @@
.forecast-planner button:disabled { opacity: .5; cursor: wait; }
.forecast-planner button:focus-visible, .forecast-planner input:focus-visible, .forecast-planner select:focus-visible { outline: 2px solid #305b5b; outline-offset: 3px; }
.forecast-planner-notice { padding: .85rem 1rem; border-left: 4px solid #dce169; background: #f2f6e7; }
.forecast-planner .forecast-workflow { padding: 0 0 1rem; border-bottom: 1px solid #d8e0de; }
.forecast-planner .forecast-workflow-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: 1rem; }
.forecast-planner .forecast-workflow-heading h3 { margin: 0; }
.forecast-planner .forecast-workflow-heading button { width: auto; background: #fff; color: #305b5b; }
.forecast-planner .forecast-workflow-state { display: block; font-size: 1.2rem; line-height: 1.35; font-weight: 600; overflow-wrap: anywhere; color: #305b5b; }
.forecast-planner .forecast-workflow[data-state=interrupted] .forecast-workflow-state { color: #933e3e; }
.forecast-planner .forecast-workflow[data-state=unknown] .forecast-workflow-state { color: #576467; }
.forecast-planner .forecast-workflow p { margin: .65rem 0 0; font-size: .85rem; }
.forecast-planner .forecast-workflow-facts, .forecast-planner .forecast-plan-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin: 1.25rem 0; }
.forecast-planner .forecast-workflow-facts > div, .forecast-planner .forecast-plan-facts > div { min-width: 0; }
.forecast-planner .forecast-workflow-facts dt, .forecast-planner .forecast-plan-facts dt { color: #576467; font-size: .8rem; }
.forecast-planner .forecast-workflow-facts dd, .forecast-planner .forecast-plan-facts dd { margin: .4rem 0 0; font-size: .9rem; line-height: 1.4; font-weight: 600; overflow-wrap: anywhere; }
.forecast-planner table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: .85rem; }
.forecast-planner th, .forecast-planner td { padding: .65rem .5rem; border-bottom: 1px solid #d8e0de; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
.forecast-planner th { width: 40%; font-weight: 600; }
@@ -42,7 +54,7 @@
.forecast-planner .forecast-chart-sr { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 600px) {
.forecast-page main { margin: 0; padding: 1rem; }
.forecast-planner form { grid-template-columns: minmax(0, 1fr); }
.forecast-planner form, .forecast-planner .forecast-workflow-facts, .forecast-planner .forecast-plan-facts { grid-template-columns: minmax(0, 1fr); }
.forecast-planner th { width: 43%; }
.forecast-planner .forecast-chart-slider { grid-template-columns: minmax(0, 1fr); gap: 0; }
.forecast-planner .forecast-chart-inspector dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+94 -34
View File
@@ -1,10 +1,44 @@
import { forecastSeries, renderForecastChart } from './forecast-chart.js';
const WORKFLOW_LABELS = {
disabled: 'Ausgeschaltet', collecting: 'Sammelt Daten', learning: 'Lernt',
ready: 'Bereit', active: 'Aktiv', interrupted: 'Unterbrochen'
};
const WORKFLOW_REASONS = {
manager_control_active: 'Der Manager bestaetigt die aktive Batterieregelung nach Netzfahrplan.',
manager_report_stale: 'Die Manager-Rueckmeldung ist abgelaufen. Aktiver Betrieb ist nicht mehr bestaetigt.',
planning_not_ready: 'Die Planung ist noch nicht bereit. Der Netzfahrplan ist nicht aktiv bestaetigt.',
manager_control_not_active: 'Der Netzfahrplan ist angefordert; der Manager bestaetigt noch keine aktive Regelung.',
learning_disabled: 'Die Datenuebertragung fuer die Prognose ist ausgeschaltet.',
plan_and_model_ready: 'Modell und Plan liegen vor. Die Aktivierung erfolgt im Manager nach lokaler Schutzpruefung.',
awaiting_fresh_plan: 'Das Modell liegt vor. Ein aktueller Netzfahrplan wird noch benoetigt.',
model_training: 'Aus den vorhandenen Messdaten wird das Prognosemodell aufgebaut.',
collecting_measurements: 'Messdaten werden gesammelt. Fuer das erste Modell fehlt noch eine ausreichende Datenbasis.',
setup_required: 'Die Einrichtung von Messquellen und Prognose ist noch nicht vollstaendig.'
};
export function workflowView(workflow, now = Date.now()) {
const confirmed = workflow && Object.hasOwn(WORKFLOW_LABELS, workflow.state)
&& typeof workflow.checkedAt === 'string' && Number.isFinite(Date.parse(workflow.checkedAt))
&& ['learningEnabled', 'controlRequested', 'controlActive', 'planningReady'].every(key => typeof workflow[key] === 'boolean');
const consistent = confirmed && (workflow.state !== 'active' || (workflow.controlActive && workflow.controlRequested && workflow.planningReady))
&& (workflow.state === 'active' || !workflow.controlActive)
&& (workflow.state !== 'ready' || workflow.planningReady);
if (!consistent) return { state: 'unknown', label: 'Betriebsstatus nicht bestaetigt', reason: 'Keine vollstaendige, bestaetigte Manager-Rueckmeldung vorhanden.' };
if (workflow.state === 'active' && (workflow.reportFresh !== true || !workflow.learningEnabled
|| !Number.isFinite(Date.parse(workflow.sourceReportAt)) || !Number.isFinite(Date.parse(workflow.sourceFreshUntil))
|| Date.parse(workflow.sourceReportAt) > now || Date.parse(workflow.sourceFreshUntil) <= now)) {
return { ...workflow, state: 'interrupted', label: WORKFLOW_LABELS.interrupted, controlActive: false, reason: WORKFLOW_REASONS.manager_report_stale };
}
return { ...workflow, label: WORKFLOW_LABELS[workflow.state], reason: Object.hasOwn(WORKFLOW_REASONS, workflow.reason) ? WORKFLOW_REASONS[workflow.reason] : (typeof workflow.reason === 'string' ? workflow.reason : '') };
}
export function createPlanner({ message, panel, plants, session }) {
let generation = 0;
let workflowTimer;
const charts = [];
const element = (tag, text) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e; };
const time = value => value ? new Date(value).toLocaleString('de-CH', { timeZone: 'Europe/Zurich' }) : 'nicht vorhanden';
const time = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString('de-CH', { timeZone: 'Europe/Zurich' }) : 'nicht vorhanden';
const num = (v, digits = 2) => typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'unbekannt';
async function request(url, method = 'GET', body) {
@@ -21,12 +55,20 @@ function select(label, choices, selected) {
for (const [value, caption] of choices) { const option = element('option', caption); option.value = value; field.append(option); }
field.value = selected; wrap.append(field); return { wrap, field };
}
function table(title, rows) {
panel.append(element('h3', title)); const grid = element('table');
function table(title, rows, target = panel) {
target.append(element('h3', title)); const grid = element('table');
for (const [name, value] of rows) { const row = element('tr'); row.append(element('th', name), element('td', String(value))); grid.append(row); }
panel.append(grid);
target.append(grid);
}
function clearCharts() { for (const chart of charts.splice(0)) chart.destroy(); }
function facts(rows, className) {
const list = element('dl'); list.className = className;
for (const [name, value] of rows) { const row = element('div'); row.append(element('dt', name), element('dd', value)); list.append(row); }
return list;
}
function details(caption) {
const node = element('details'); node.className = 'forecast-planner-settings'; node.append(element('summary', caption)); return node;
}
function clearCharts() { clearTimeout(workflowTimer); for (const chart of charts.splice(0)) chart.destroy(); }
function curve(title, points, series, unit, caption) {
charts.push(renderForecastChart(panel, { title, points, series, unit, caption }));
}
@@ -37,23 +79,44 @@ async function render() {
clearCharts(); panel.replaceChildren();
try {
const state = await request(base); if (ticket !== generation) return; clearCharts(); panel.replaceChildren();
const safety = element('p', 'SCHATTENBETRIEB der Serverplanung: Die Serverprognose allein schaltet keine Geraete. Eine lokale Nutzung durch den Forecast Switch braucht eine gesonderte Freigabe. Der tatsaechliche Anlagenbetrieb wird hier nicht bestaetigt.');
let workflow = workflowView(state.workflow);
const status = element('section'); status.className = 'forecast-workflow';
const heading = element('div'); heading.className = 'forecast-workflow-heading';
const title = element('h3', 'Prognose und Netzfahrplan');
const refresh = element('button', 'Status aktualisieren'); refresh.type = 'button'; refresh.addEventListener('click', render);
heading.append(title, refresh);
const stateBody = element('div'); status.append(heading, stateBody);
const showWorkflow = () => {
workflow = workflowView(state.workflow); status.dataset.state = workflow.state;
const badge = element('strong', workflow.label); badge.className = 'forecast-workflow-state';
const reason = element('p', workflow.reason); reason.className = 'forecast-workflow-reason';
stateBody.replaceChildren(badge, reason, facts([
['Prognose lernen', workflow.learningEnabled === undefined ? 'Nicht bestaetigt' : workflow.learningEnabled ? 'Eingeschaltet' : 'Ausgeschaltet'],
['Netzfahrplan aktivieren', workflow.controlRequested === undefined ? 'Nicht bestaetigt' : workflow.controlRequested ? 'Eingeschaltet' : 'Ausgeschaltet'],
['Regelung durch Manager', workflow.controlActive === undefined ? 'Nicht bestaetigt' : workflow.controlActive ? 'Aktiv gemeldet' : 'Kein aktiver Betrieb bestaetigt']
], 'forecast-workflow-facts'), element('p', 'Manager-Rueckmeldung: ' + time(workflow.sourceReportAt)));
};
showWorkflow();
if (workflow.state === 'active') workflowTimer = setTimeout(showWorkflow, Math.min(2147483647, Date.parse(workflow.sourceFreshUntil) - Date.now() + 1));
panel.append(status);
const safety = element('p', 'Der Manager regelt die Batterie auf das Netzziel und korrigiert Abweichungen mit lokalen Messwerten. Geraete- und Netzgrenzen behalten Vorrang. Die Kurven zeigen berechnete Plaene, keine nachgewiesene Ausfuehrung.');
safety.className = 'forecast-planner-notice'; panel.append(safety);
table('Freigabe und Datenstand', [['Serverseitige Probebetriebsfreigabe', state.controlledTrialAuthorized === true ? 'Vorhanden; lokale Schutzpruefung bleibt erforderlich' : state.controlledTrialAuthorized === false ? 'Nicht vorhanden' : 'Nicht gemeldet'], ['Lokale Ausfuehrung', 'In dieser Ansicht nicht nachgewiesen'], ['Status geprueft', time(state.checkedAt)], ['Letzte Planquittierung', state.acknowledgement?.status || 'Nicht vorhanden']]);
const settingsStart = panel.childNodes.length;
const settingsDetails = details('Erweiterte Planungseinstellungen');
const diagnostics = details('Technische Details, Daten und Training');
table('Betriebsnachweis', [['Planungsbereitschaft', workflow.planningReady === undefined ? 'Nicht bestaetigt' : workflow.planningReady ? 'Bereit gemeldet; lokale Schutzpruefung bleibt erforderlich' : 'Noch nicht bereit'], ['Serverstatus geprueft', time(state.checkedAt)], ['Letzte Planquittierung', state.acknowledgement?.status || 'Nicht vorhanden'], ['Bedeutung der Quittierung', 'Planempfang ist kein Ausfuehrungsnachweis']], diagnostics);
const form = element('form');
const family = select('Modellfamilie', [['auto', 'Automatisch – historischer Kostenvergleich'], ...state.families.map(f => [f.key, f.label])], state.settings.family);
const measurement = select('Messdatenbasis', [['verified_only', 'Nur verifizierte Peak-/Viertelstundenwerte'], ['allow_estimates', 'Gekennzeichnete Schaetzungen fuer die Planung zulassen']], state.settings.measurementPolicy || 'verified_only');
const outlook = select('Peakbewertung', [['full_incremental', 'Voller zusaetzlicher Monatstarif'], ['empirical_if_available', 'Restmonats-Szenarien bei ausreichender Historie']], state.settings.peakOutlookPolicy || 'full_incremental');
const dataSource = select('Verbrauchsprognose', [['legacy', 'Bisherige Datenquelle'], ['corrected_profile', 'Bereinigte physische Lastprofile']], state.settings.forecastSource || 'legacy');
const dataSource = select('Verbrauchsprognose', [['legacy', 'Importierte Eingangsprognose'], ['corrected_profile', 'Bereinigte physische Lastprofile']], state.settings.forecastSource || 'legacy');
const datasets = state.dataPipeline?.datasets || [];
const dataset = select('Messdatensatz', [['', 'Datensatz auswaehlen'], ...datasets.map(d => [d.datasetId, d.datasetId + ' (' + d.status + ')'])], state.settings.measurementDataset || '');
const cadence = select('Nachtraining', [['daily', 'Taeglich'], ['weekly', 'Woechentlich']], state.settings.trainingCadence);
const relianceWrap = element('label', 'Gewicht der Restmonatsprognose (0 bis 100 %) '), reliance = element('input');
reliance.type = 'number'; reliance.min = '0'; reliance.max = '100'; reliance.step = '1'; reliance.value = String(100 * (state.settings.peakOutlookReliance ?? .5)); relianceWrap.append(reliance);
const save = element('button', 'Speichern und neu berechnen'), replan = element('button', 'Neu berechnen'), refresh = element('button', 'Status aktualisieren');
save.type = 'submit'; replan.type = refresh.type = 'button';
form.append(family.wrap, dataSource.wrap, dataset.wrap, measurement.wrap, outlook.wrap, relianceWrap, cadence.wrap, save, replan, refresh); panel.append(form);
const save = element('button', 'Speichern und neu berechnen'), replan = element('button', 'Neu berechnen');
save.type = 'submit'; replan.type = 'button';
form.append(family.wrap, dataSource.wrap, dataset.wrap, measurement.wrap, outlook.wrap, relianceWrap, cadence.wrap, save, replan); settingsDetails.append(form);
let revision = state.settings.revision;
form.addEventListener('submit', async event => {
event.preventDefault(); save.disabled = true;
@@ -64,45 +127,42 @@ async function render() {
} catch (error) { if (ticket === generation) message.textContent = error.message; } finally { save.disabled = false; }
});
replan.addEventListener('click', async () => { replan.disabled = true; try { await request(base + '/replan', 'POST', {}); if (ticket === generation) message.textContent = 'Neuberechnung angefordert.'; } catch (error) { if (ticket === generation) message.textContent = error.message; } finally { replan.disabled = false; } });
refresh.addEventListener('click', render);
panel.append(element('p', 'Die Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe. Ohne ausreichende vergleichbare Historie gilt der volle zusaetzliche Monatstarif. Technische und konfigurierte Managergrenzen bleiben verbindlich.'));
panel.append(element('p', 'Fuer bereinigte Lastprofile steuert die Trainingsauswahl den automatischen Modellaufbau. Historische Prognosen bleiben unveraendert. Die wirtschaftliche Modellautomatik erstellt vergleichbare Kosten-Replays aus eingefrorenen Tagesplaenen und spaeter eingegangenen Messwerten. Ohne ausreichende Vergleichstage bleibt die bisherige Familie aktiv.'));
for (const d of datasets) table('Daten und Training: ' + d.datasetId, [['Zustand', d.status], ['Angenommene Aufnahmen', d.records], ['Letzte Aufnahme', time(d.lastCapture)], ['Letzter Serverempfang', time(d.lastReceived)], ['Nutzbare 5-Minutenwerte', d.detail?.usableWindows ?? 0], ['Datenbasis in Stunden', num(d.detail?.usableEquivalentHours)], ['Modellstand', d.detail?.modelId || 'noch in Datensammlung'], ['Letztes Training', time(d.detail?.trainedAt)], ['Validierung', d.detail?.validation?.status || 'noch ausstehend'], ['Messgrundlage', 'Konfigurierte physische Schaetzung; kein unabhaengiger Messnachweis']] );
settingsDetails.append(element('p', 'Die Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe. Ohne ausreichende vergleichbare Historie gilt der volle zusaetzliche Monatstarif. Technische und konfigurierte Managergrenzen bleiben verbindlich.'));
settingsDetails.append(element('p', 'Fuer bereinigte Lastprofile steuert die Trainingsauswahl den automatischen Modellaufbau. Historische Prognosen bleiben unveraendert. Die wirtschaftliche Modellautomatik erstellt vergleichbare Kosten-Replays aus eingefrorenen Tagesplaenen und spaeter eingegangenen Messwerten. Ohne ausreichende Vergleichstage bleibt die bisherige Familie aktiv.'));
for (const d of datasets) table('Daten und Training: ' + d.datasetId, [['Zustand', d.status], ['Angenommene Aufnahmen', d.records], ['Letzte Aufnahme', time(d.lastCapture)], ['Letzter Serverempfang', time(d.lastReceived)], ['Nutzbare 5-Minutenwerte', d.detail?.usableWindows ?? 0], ['Datenbasis in Stunden', num(d.detail?.usableEquivalentHours)], ['Modellstand', d.detail?.modelId || 'noch in Datensammlung'], ['Letztes Training', time(d.detail?.trainedAt)], ['Validierung', d.detail?.validation?.status || 'noch ausstehend'], ['Messgrundlage', 'Konfigurierte physische Schaetzung; kein unabhaengiger Messnachweis']], diagnostics);
if (state.economicComparison?.connected) {
table('Wirtschaftlicher Modellvergleich', [['Gespeicherte Tagesvergleiche', state.economicComparison.cohorts], ['Abgeschlossene Vergleiche', state.economicComparison.completedComparisons.length], ['Methode', 'Eingefrorener Tagesplan mit simulierter Netzregelung'], ['Grenze dieses Vergleichs', 'Eine netzladefaehige Batterie; keine vollstaendige Nachbildung laufender Neuoptimierungen'], ['Bewertung', 'Energiekosten + zusaetzlicher Peak + Verluste + gemeinsame Restenergiebewertung']]);
panel.append(element('p', 'Der SDL-Anteil der historischen Bilanz ist eine gekennzeichnete Schaetzung aus dem damaligen Auftrag. Die Vergleichskosten sind keine nachgewiesenen Einsparungen auf einer Stromrechnung.'));
for (const item of state.economicComparison.completedComparisons) table('Vergleich ' + item.day, Object.entries(item.results).map(([family, result]) => ['Familie ' + family, num(result.costChf) + ' CHF; Datenabdeckung ' + num(100 * result.coverage, 1) + ' %']));
table('Wirtschaftlicher Modellvergleich', [['Gespeicherte Tagesvergleiche', state.economicComparison.cohorts], ['Abgeschlossene Vergleiche', state.economicComparison.completedComparisons.length], ['Methode', 'Eingefrorener Tagesplan mit simulierter Netzregelung'], ['Grenze dieses Vergleichs', 'Eine netzladefaehige Batterie; keine vollstaendige Nachbildung laufender Neuoptimierungen'], ['Bewertung', 'Energiekosten + zusaetzlicher Peak + Verluste + gemeinsame Restenergiebewertung']], diagnostics);
diagnostics.append(element('p', 'Der SDL-Anteil der historischen Bilanz ist eine gekennzeichnete Schaetzung aus dem damaligen Auftrag. Die Vergleichskosten sind keine nachgewiesenen Einsparungen auf einer Stromrechnung.'));
for (const item of state.economicComparison.completedComparisons) table('Vergleich ' + item.day, Object.entries(item.results).map(([family, result]) => ['Familie ' + family, num(result.costChf) + ' CHF; Datenabdeckung ' + num(100 * result.coverage, 1) + ' %']), diagnostics);
}
if (state.maintenance) table('Datenaufbewahrung', [['Archivierung', state.maintenance.status], ['Originale', 'Nach verifizierter Kompression weiterhin wiederherstellbar; externe Sicherung separat betreiben']]);
panel.append(element('p', state.lastRun ? `Rechenstatus: ${state.lastRun.status} ${state.lastRun.detail?.reason || ''}` : 'Noch kein Rechenlauf.'));
if (state.maintenance) table('Datenaufbewahrung', [['Archivierung', state.maintenance.status], ['Originale', 'Nach verifizierter Kompression weiterhin wiederherstellbar; externe Sicherung separat betreiben']], diagnostics);
diagnostics.append(element('p', state.lastRun ? `Rechenstatus: ${state.lastRun.status} ${state.lastRun.detail?.reason || ''}` : 'Noch kein Rechenlauf.'));
if (state.pending) panel.append(element('p', 'Neuberechnung vorgemerkt oder aktiv.'));
for (const [month, basis] of Object.entries(state.peakPlanningBases || {})) table(`Vorlaeufige Peakbasis ${month}`, [['Wert', num(basis.kw) + ' kW'], ['Qualitaet', basis.quality], ['Quelle', basis.source], ['Beobachtet', time(basis.observedAt)], ['Hinweis', basis.notes || 'Kein Abrechnungsnachweis']]);
const settingsDetails = element('details');
settingsDetails.className = 'forecast-planner-settings';
settingsDetails.append(element('summary', 'Planungseinstellungen, Daten und Training'), ...Array.from(panel.childNodes).slice(settingsStart));
for (const [month, basis] of Object.entries(state.peakPlanningBases || {})) table(`Vorlaeufige Peakbasis ${month}`, [['Wert', num(basis.kw) + ' kW'], ['Qualitaet', basis.quality], ['Quelle', basis.source], ['Beobachtet', time(basis.observedAt)], ['Hinweis', basis.notes || 'Kein Abrechnungsnachweis']], diagnostics);
const plan = state.plan;
if (plan) {
if (!state.fresh) panel.append(element('p', 'ACHTUNG: Plan veraltet oder Neuberechnung ausstehend.'));
const quality = plan.inputQuality || {};
const loadBasis = quality.loadBasis === 'base_load' ? 'Bereinigte Basislast' : quality.loadBasis === 'house_total' ? 'Aggregierte Hauslast; SDL-Trennung nicht nachgewiesen' : quality.loadBasis || 'Nicht angegeben';
table('Plan und Quellen', [['Plan-ID', plan.planId], ['Berechnet', time(plan.generatedAt)], ['Rechenstatus', plan.status || 'Nicht vorhanden'], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Lastgrundlage', loadBasis], ['Datenquelle der Lastprognose', quality.dataPipeline ? 'Bereinigte physische Lastprofile' : 'Eingegangene Prognose der angegebenen Modellfamilie'], ['Planungsmodus des Servers', plan.runMode || 'Nicht angegeben'], ['Optimierter Horizont bis', time(plan.validUntil)], ['Prognoseeingang bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]]);
table('Kostenprognose', [['Energiekosten', num(plan.energyCostChf) + ' CHF'], ['Zusaetzliche Peakkosten zum vollen Tarif', num(plan.additionalPeakCostChf) + ' CHF'], ['Peakbewertung unter Restmonats-Szenarien', num(plan.planningPeakCostChf) + ' CHF'], ['Planung mit geschaetzter Peakbasis', plan.peakCostIsEstimate ? 'Ja – kein Abrechnungsnachweis' : 'Nein']]);
panel.append(facts([['Plan berechnet', time(plan.generatedAt)], ['Netzfahrplan bis', time(plan.validUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]], 'forecast-plan-facts'));
table('Plan und Quellen', [['Plan-ID', plan.planId], ['Berechnet', time(plan.generatedAt)], ['Rechenstatus', plan.status || 'Nicht vorhanden'], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Lastgrundlage', loadBasis], ['Datenquelle der Lastprognose', quality.dataPipeline ? 'Bereinigte physische Lastprofile' : 'Eingegangene Prognose der angegebenen Modellfamilie'], ['Planungsmodus des Servers', plan.runMode || 'Nicht angegeben'], ['Optimierter Horizont bis', time(plan.validUntil)], ['Prognoseeingang bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]], diagnostics);
table('Kostenprognose', [['Energiekosten', num(plan.energyCostChf) + ' CHF'], ['Zusaetzliche Peakkosten zum vollen Tarif', num(plan.additionalPeakCostChf) + ' CHF'], ['Peakbewertung unter Restmonats-Szenarien', num(plan.planningPeakCostChf) + ' CHF'], ['Planung mit geschaetzter Peakbasis', plan.peakCostIsEstimate ? 'Ja - kein Abrechnungsnachweis' : 'Nein']], diagnostics);
for (const warning of plan.warnings || []) panel.append(element('p', warning));
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]]);
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]], diagnostics);
const points = plan.points || [];
const forecast = forecastSeries(plan);
const forecastScope = forecast.fullHorizon ? 'Vollstaendige, fuer diesen Plan gespeicherte Eingangsprognose, auch ueber das Ende der bekannten Preise hinaus. Netz- und Batterieplan sowie Kosten bleiben auf den optimierten Zeitraum begrenzt.' : 'Aelterer Plan ohne separat gespeicherte Gesamtprognose: nur Eingangswerte im optimierten Zeitraum verfuegbar.';
curve('PV, Last und externe Leistung', forecast.points, [{ label: 'PV-Prognose', value: p => p.pvW, color: '#757b12' }, { label: quality.loadBasis === 'base_load' ? 'Basislast-Prognose' : 'Last-Prognose', value: p => p.loadW, color: '#4e8181' }, { label: 'Externe Leistung / SDL-Szenario', value: p => quality.loadBasis === 'base_load' ? p.externalW : undefined, color: '#ac4f68', dash: '6 3' }], 'W', forecastScope + ' Externe Leistung ist nur bei nachgewiesener Basislast separat bilanziert; ein fortgeschriebener SDL-Auftrag ist kein veroeffentlichter SDL-Fahrplan.');
curve('Netzleistung', points, [{ label: 'Ohne Optimierung', value: p => p.baselineGridW, color: '#737c7e', dash: '6 3' }, { label: 'Optimiert', value: p => p.gridTargetW, color: '#4e8181' }], 'W', 'Positiv: Netzbezug. Negativ: Einspeisung.');
curve('PV- und Hausprognose', forecast.points, [{ label: 'PV-Prognose', value: p => p.pvW, color: '#757b12' }, { label: quality.loadBasis === 'base_load' ? 'Hausprognose (bereinigte Basislast)' : 'Hausprognose (aggregierte Last)', value: p => p.loadW, color: '#4e8181' }, { label: 'Externe Leistung / SDL-Szenario', value: p => quality.loadBasis === 'base_load' ? p.externalW : undefined, color: '#ac4f68', dash: '6 3' }], 'W', forecastScope + ' Externe Leistung ist nur bei nachgewiesener Basislast separat bilanziert; ein fortgeschriebener SDL-Auftrag ist kein veroeffentlichter SDL-Fahrplan.');
curve('Strompreis Bezug und Einspeisung', points, [{ label: 'Bezugspreis', value: p => p.importPriceChfKwh, digits: 4 }, { label: 'Einspeisepreis', value: p => p.exportPriceChfKwh, digits: 4, dash: '6 3' }], 'CHF/kWh', 'Fuer diesen Plan gespeicherte Preise. Keine Fortschreibung ueber den bekannten Preiszeitraum hinaus.');
curve('Netzfahrplan', points, [{ label: 'Ohne Optimierung', value: p => p.baselineGridW, color: '#737c7e', dash: '6 3' }, { label: 'Netzziel fuer den Manager', value: p => p.gridTargetW, color: '#4e8181' }], 'W', 'Positiv: Netzbezug. Negativ: Einspeisung. Berechnetes Netzziel; keine gemessene Netzleistung.');
curve('Batterieleistung', points, [{ label: 'Batterieplan', value: p => p.batteryTargetW }], 'W', 'Positiv: Laden. Negativ: Entladen.');
const assets = new Set(points.flatMap(point => Object.keys(point.socEndPercent || {})));
for (const asset of assets) curve(`Ladezustand ${asset}`, points, [{ label: 'Geplanter SOC am Intervallende', value: p => p.socEndPercent?.[asset], position: 'end' }], '%');
curve('Energiepreise', points, [{ label: 'Bezugspreis', value: p => p.importPriceChfKwh, digits: 4 }, { label: 'Einspeisepreis', value: p => p.exportPriceChfKwh, digits: 4, dash: '6 3' }], 'CHF/kWh', 'Preise im optimierten Horizont, keine Fortschreibung ueber den bekannten Preiszeitraum hinaus.');
curve('Kumulierte Kosten inklusive vollem Peakpreis', points, [{ label: 'Ohne Optimierung', value: p => p.cumulativeBaselineCashCostChf, position: 'end', color: '#737c7e', dash: '6 3' }, { label: 'Optimiert', value: p => p.cumulativeCashCostChf, position: 'end', color: '#4e8181' }], 'CHF', 'Jeweils am Intervallende. Prognose von Energie- und zusaetzlichen vollen Monatspeakkosten, keine Rechnung und kein Nachweis realisierter Einsparungen.');
panel.append(element('p', 'Restwert der Batterie und Restmonatsbewertung sind keine bereits erzielten Erloese. Der angezeigte Kostenverlauf enthaelt diese nicht; auch Durchsatzkosten sind darin nicht enthalten.'));
}
panel.append(settingsDetails);
if (!plan) settingsDetails.open = true;
else panel.append(element('p', 'Noch kein Netzfahrplan vorhanden. Sobald die erforderlichen Daten und Preise vorliegen, erscheint hier die Berechnungsgrundlage.'));
panel.append(settingsDetails, diagnostics);
message.textContent = '';
} catch (error) { if (ticket === generation) { clearCharts(); panel.replaceChildren(); message.textContent = error.message; } }
}
@@ -117,7 +177,7 @@ export function mountPlanner(container, { plantId, session }) {
return { ...planner, ready: planner.reload() };
}
const standalone = document.querySelector('[data-standalone-planner]');
const standalone = globalThis.document?.querySelector('[data-standalone-planner]');
if (standalone) {
const message = standalone.querySelector('#message'), panel = standalone.querySelector('#panel'), plants = standalone.querySelector('#plants');
try {
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { workflowView } from './netplan-v4.js';
const now = Date.parse('2026-10-08T12:00:00Z');
const report = (state, overrides = {}) => ({
state, learningEnabled: state !== 'disabled', controlRequested: state === 'active',
controlActive: state === 'active', planningReady: ['ready', 'active'].includes(state),
reportFresh: true, checkedAt: new Date(now).toISOString(),
sourceReportAt: new Date(now - 10000).toISOString(), sourceFreshUntil: new Date(now + 110000).toISOString(),
reason: '', ...overrides
});
test('workflow reports all six states without deriving control from a plan', () => {
for (const [state, label] of Object.entries({ disabled: 'Ausgeschaltet', collecting: 'Sammelt Daten', learning: 'Lernt', ready: 'Bereit', active: 'Aktiv', interrupted: 'Unterbrochen' })) {
assert.equal(workflowView(report(state), now).label, label);
}
assert.equal(workflowView(undefined, now).label, 'Betriebsstatus nicht bestaetigt');
assert.equal(workflowView({ plan: { status: 'optimal', runMode: 'shadow' }, acknowledgement: { status: 'shadow_seen' } }, now).state, 'unknown');
});
test('missing, malformed or contradictory reports never claim active control', () => {
const malformed = [null, {}, report('other'), report('active', { checkedAt: 'invalid' }),
report('active', { checkedAt: 123 }), report('active', { controlActive: false }),
report('active', { controlRequested: false }), report('active', { planningReady: false }),
report('ready', { controlActive: true }), report('ready', { planningReady: false }),
report('active', { learningEnabled: 'true' })];
for (const input of malformed) assert.equal(workflowView(input, now).state, 'unknown');
});
test('active requires a fresh manager report and expires at its exact boundary', () => {
for (const overrides of [{ reportFresh: false }, { reportFresh: undefined }, { learningEnabled: false },
{ sourceReportAt: null }, { sourceReportAt: new Date(now + 1).toISOString() },
{ sourceFreshUntil: null }, { sourceFreshUntil: new Date(now).toISOString() },
{ sourceFreshUntil: new Date(now - 1).toISOString() }]) {
const view = workflowView(report('active', overrides), now);
assert.equal(view.state, 'interrupted'); assert.equal(view.controlActive, false);
}
assert.equal(workflowView(report('active'), now + 109999).state, 'active');
assert.equal(workflowView(report('active'), now + 110000).state, 'interrupted');
});
test('known reasons are understandable and unknown reasons remain visible as text', () => {
assert.match(workflowView(report('collecting', { reason: 'collecting_measurements' }), now).reason, /Messdaten werden gesammelt/);
assert.match(workflowView(report('active', { reason: 'manager_control_active' }), now).reason, /Manager bestaetigt/);
const reason = '<script>unexpected_reason</script>';
assert.equal(workflowView(report('interrupted', { reason }), now).reason, reason);
});
test('portal release assets match the canonical planner sources', async () => {
for (const file of ['netplan-v4.js', 'netplan-v4.css', 'netplan-v4.html']) {
const canonical = await readFile(new URL(file, import.meta.url), 'utf8');
const release = await readFile(new URL('../../license/changes/20261006-forecast-integration/public/' + file, import.meta.url), 'utf8');
assert.equal(release, canonical, file);
}
});
@@ -26,13 +26,13 @@ export function createPlannerV4Bridge({configuredPrognosisPlant,roleAllowed,body
if(method!=='GET'&&!roleAllowed(access.session,['owner','admin','operator'])){json(res,403,{error:'Keine Aenderungsberechtigung.'});return true;}
await forward(req,res,access.plant.installation_id,action?`/${action}`:'',method);return true;
}
const d=url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/planner-v4(?:\/(operation|ack|measurements))?$/i);
const d=url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/planner-v4(?:\/(operation|ack|measurements|setup|workflow))?$/i);
if(d){
const action=d[2]||'';if(req.method!==(action?'POST':'GET')){json(res,405,{error:'Methode nicht erlaubt.'});return true;}
if(!checkDeviceRate(req,d[1],'planner-v4')){json(res,429,{error:'Zu viele Anfragen.'});return true;}
const activation=deviceActivation(req,res,d[1]);if(!activation)return true;
if(Number(ownedLicenseState(activation.plant_id).quantities.grid_schedule||0)<1){json(res,403,{error:'Netzfahrplan nicht lizenziert.'});return true;}
await forward(req,res,d[1],action==='operation'?'/inputs/operation':action==='measurements'?'/measurements':action?'/ack':'',req.method);return true;
await forward(req,res,d[1],action==='operation'?'/inputs/operation':action?`/${action}`:'',req.method);return true;
}
return false;
};
@@ -4,10 +4,12 @@ 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 uuid import UUID,uuid4
from urllib.parse import urlparse
from urllib.request import Request,urlopen
import json,logging,math,os
import json,logging,math,os,time
_enrollment_cache = {'key': None, 'checked': 0., 'plants': set()}
def timestamp(value):
if hasattr(value,'to_pydatetime'):value=value.to_pydatetime()
@@ -71,13 +73,42 @@ def ckw_payload(label,rows,publication_timestamp=None,at=None):
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 planning_plants():
"""Discover enrolled planning tenants; this registry has no actuator authority."""
base=os.getenv('NETPLAN_V4_URL','').rstrip('/');token=os.getenv('PROGNOSIS_SERVICE_TOKEN','')
if not base:return set()
explicit={p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()}
if not token:return explicit
url=urlparse(base)
if url.scheme not in ('http','https') or url.username or url.password:return explicit
key=(base,token);now=time.monotonic()
if _enrollment_cache['key']==key and now-_enrollment_cache['checked']<60:
return explicit|_enrollment_cache['plants']
plants=set()
try:
req=Request(base+'/internal/v2/planner/installations',headers={'X-Enelix-Service-Token':token})
with urlopen(req,timeout=5) as response:
raw=response.read(500001)
if len(raw)>500000:raise ValueError('Oversized enrollment response')
data=json.loads(raw)
values=data['installationIds']
if not isinstance(values,list) or len(values)>10000 or data.get('controlEnabled') is not False:
raise ValueError('Invalid planning enrollment response')
if any(not isinstance(p,str) or str(UUID(p))!=p for p in values):
raise ValueError('Invalid planning installation ID')
plants=set(values)
except Exception as exc:
logging.getLogger(__name__).warning('V4 planning enrollment unavailable: %s',type(exc).__name__)
_enrollment_cache.update(key=key,checked=now,plants=plants)
return explicit|plants
def enabled(plant):return plant in planning_plants()
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 str(UUID(plant))!=plant:raise ValueError('Canonical installation UUID required')
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')
@@ -99,7 +130,7 @@ def publish_tariffs(plant,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()]
plants=sorted(planning_plants())
if not os.getenv('NETPLAN_V4_URL') or not plants:return
try:
if tariff_type!='integrated':raise ValueError('Full integrated tariff required')
@@ -144,7 +144,7 @@ def validate_config(c):
return c
def register_dataset(con, plant, c, now):
def register_dataset(con, plant, c, now, *, own_transaction=True):
"""Operator endpoint only; device append endpoint cannot change units or limits."""
validate_config(c)
if c.get('sourceDatasetId'):
@@ -155,15 +155,16 @@ def register_dataset(con, plant, c, now):
if canonical(comparable(origin)) != canonical(comparable(c)):
raise ValueError('Referenced observations must retain identical measurement meaning')
value = canonical(c)
con.execute('BEGIN IMMEDIATE')
if own_transaction: con.execute('BEGIN IMMEDIATE')
try:
old = con.execute('SELECT config FROM planner_data_sets WHERE plant=? AND dataset=?', (plant,c['datasetId'])).fetchone()
if old and old[0] != value:
raise ValueError('Dataset is immutable; use a new datasetId for changed measurement meaning')
con.execute('INSERT OR IGNORE INTO planner_data_sets VALUES(?,?,?,?)', (plant,c['datasetId'],value,now))
con.commit()
if own_transaction: con.commit()
except Exception:
con.rollback(); raise
if own_transaction: con.rollback()
raise
return {'status':'configured', 'datasetId':c['datasetId'], 'mappingSha256':c['mappingSha256'], 'controlEnabled':False}
@@ -483,11 +484,14 @@ def apply_load_forecast(con,plant,dataset,forecast,decision):
last=json.loads(last[0])
if decision-last['capturedAt']>120: raise ValueError('Corrected measurements older than 120 seconds')
sdl_sources=[s for s in c['sources'] if s['role']=='sdl_request']
if len(sdl_sources)!=1: raise ValueError('Explicit SDL request channel needed for the labelled persistence scenario')
if len(sdl_sources)>1: raise ValueError('At most one explicit SDL request channel is supported')
sdl=0.; external_observed=None; external_policy='no_external_sdl_channel'
if sdl_sources:
s=sdl_sources[0];r=last['raw'][s['key']]
if not r['valid'] or decision-r['sourceUpdatedAt']>s['maxAgeSeconds']:
raise ValueError('No current external SDL request for the persistence scenario')
sdl=r['value']*s['factorToW']
sdl=r['value']*s['factorToW'];external_observed=iso(r['sourceUpdatedAt'])
external_policy='last_sdl_request_persistence_estimate'
result=json.loads(canonical(forecast)); result['families']={}
result['observedAt']=iso(max(availability_epoch(forecast['observedAt']),model['trainedAt'],last['capturedAt']))
for family,old in forecast['families'].items():
@@ -501,7 +505,7 @@ def apply_load_forecast(con,plant,dataset,forecast,decision):
'loadMethodVersion':model['methodVersion'],'loadVariant':family,
'loadModelTrainedAt':iso(model['trainedAt']),'pvForecastEventId':forecast.get('eventId'),
'measurementBasis':'configured_physical_estimate','measurementBoundaryVerified':False,
'externalPolicy':'last_sdl_request_persistence_estimate','externalObservedAt':iso(r['sourceUpdatedAt']),
'externalPolicy':external_policy,'externalObservedAt':external_observed,
'externalPowerW':sdl,'futureSdlPublished':False,'validation':model['validation'],
'historyTimingMethod':model.get('historyTimingMethod','strict_expiry'),
'observationDatasetId':model.get('observationDatasetId',dataset)}}
+42 -9
View File
@@ -16,7 +16,7 @@ from .domain import Battery,Limits,Price,QuarterPast,Step,month_key,quarter_star
from .store import PlannerStore,canonical
from .selection import choose_family
from .optimizer import optimize
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline, workflow
from .forecast_quality import assess_family
from .receiver_contract import provenance
from .peak_policy import basis_record, RestMonthOutlook, empirical_rest_month
@@ -198,7 +198,9 @@ def assemble(store,plant,family,now,*,include_forecast_points=False):
input_quality['forecastAssessment']=assessment
if source.get('dataPipeline'):
input_quality['dataPipeline']=source['dataPipeline']
input_quality['warnings'].append('Corrected physical-load profile uses configured measurement mapping; external SDL is an explicitly labelled last-request persistence scenario, not a published future SDL schedule')
input_quality['warnings'].append('Corrected physical-load profile uses configured measurement mapping')
if source['dataPipeline'].get('externalPolicy')=='last_sdl_request_persistence_estimate':
input_quality['warnings'].append('External SDL is an explicitly labelled last-request persistence scenario, not a published future SDL schedule')
for p in source['points']:
start=utc(p['time']);end=start+timedelta(minutes=5)
if end<=decision:continue
@@ -276,6 +278,7 @@ def run_once(store,now):
stamp=int(now.timestamp())//300
plants=[r[0] for r in store.con.execute('SELECT plant FROM planner_settings UNION SELECT DISTINCT plant FROM planner_input_current UNION SELECT plant FROM planner_data_sets')]
for plant in plants:
if not workflow.learning_enabled(store.con,plant):continue
config=store.settings(plant)
economic_replay.advance(store,plant,now)
datasets=[r[0] for r in store.con.execute('SELECT dataset FROM planner_data_sets WHERE plant=?',(plant,))]
@@ -291,6 +294,9 @@ def run_once(store,now):
if not claim:return {'status':'idle'}
plant=claim['plant'];result={'status':'internal_error','executable':False,'points':[]}
try:
if not workflow.learning_enabled(store.con,plant):
result={'status':'learning_disabled','executable':False,'points':[]}
return result
settings=store.settings(plant);previous=store.current(plant)
current=previous['sourceFamily'] if previous else store.registry.entries()[0].key
selection=choose_family(settings['family'],current,economic_replay.scores(store,plant,now),registry=store.registry,now=now,
@@ -316,16 +322,18 @@ def run_once(store,now):
def status(store,plant,now):
plan=store.current(plant);settings=store.settings(plant)
row=store.con.execute('SELECT * FROM planner_run_status WHERE plant=?',(plant,)).fetchone()
pending=store.con.execute('SELECT reasons,requested_at FROM planner_work WHERE plant=?',(plant,)).fetchone()
pending=store.con.execute('SELECT revision,reasons,requested_at FROM planner_work WHERE plant=?',(plant,)).fetchone()
ack=store.con.execute('SELECT * FROM planner_ack WHERE plant=?',(plant,)).fetchone()
fresh=bool(plan and plan['configRevision']==settings['revision'] and utc(plan['validUntil'])>now and 0<=(now-utc(plan['generatedAt'])).total_seconds()<=900 and not pending and row and row['status'] in ('optimal','feasible_time_limit'))
return {'receiverProtocolVersion':1,'installationId':plant,'checkedAt':utc(now).isoformat(),'settings':settings,'peakPlanningBases':meter_runtime.assumptions(store.con,plant),'families':[asdict(f) for f in store.registry.entries()],'plan':plan,'fresh':fresh,'pending':dict(pending) if pending else None,'lastRun':{**dict(row),'detail':json.loads(row['detail'])} if row else None,'acknowledgement':dict(ack) if ack else None,'liveEnabled':False,'dataPipeline':measurement_pipeline.pipeline_status(store.con,plant),'economicComparison':economic_replay.status(store,plant),'maintenance':retention.status(store.con)}
state={'receiverProtocolVersion':1,'installationId':plant,'checkedAt':utc(now).isoformat(),'settings':settings,'peakPlanningBases':meter_runtime.assumptions(store.con,plant),'families':[asdict(f) for f in store.registry.entries()],'plan':plan,'fresh':fresh,'pending':dict(pending) if pending else None,'lastRun':{**dict(row),'detail':json.loads(row['detail'])} if row else None,'acknowledgement':dict(ack) if ack else None,'liveEnabled':False,'dataPipeline':measurement_pipeline.pipeline_status(store.con,plant),'economicComparison':economic_replay.status(store,plant),'maintenance':retention.status(store.con)}
state['workflow']=workflow.view(store,plant,state,now)
return state
def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial_plants=()):
allowed={str(UUID(p)) for p in plants}
trial_allowed={str(UUID(p)) for p in controlled_trial_plants}
if not trial_allowed <= allowed:raise ValueError('Trial allowlist must be a subset of plant allowlist')
if not allowed or not service_token or len(service_token)<24:raise ValueError('Private service token and explicit plant allowlist required')
if not service_token or len(service_token)<24:raise ValueError('Private service token required')
path=Path(db_path).resolve()
if path.name in ('users.db','portal.sqlite','settings.json'):raise ValueError('Dedicated planner database required')
path.parent.mkdir(parents=True,exist_ok=True);stop=Event()
@@ -346,14 +354,38 @@ def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial
if start_worker:thread.join(35)
app=FastAPI(title='ENELIX V4 - Schattenbetrieb',lifespan=lifespan)
app.state.store_factory=factory
def authorize(plant,token):
def authorize(plant,token,*,onboarding=False):
if not secrets.compare_digest(token or '',service_token):raise HTTPException(401,'Unauthorized')
try:plant=str(UUID(plant))
try:
if str(UUID(plant))!=plant:raise ValueError('Canonical UUID required')
except ValueError:raise HTTPException(400,'Invalid installation ID')
if plant not in allowed:raise HTTPException(403,'Installation not enabled for shadow trial')
return factory()
s=factory()
if not onboarding and plant not in allowed and not workflow.enrolled(s.con,plant):
s.close();raise HTTPException(403,'Installation not enabled for planning')
return s
@app.post('/internal/v2/prognosis/{plant}/planner/setup')
def setup(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token,onboarding=True)
try:return workflow.setup(s,plant,payload,datetime.now(timezone.utc))
except (ValueError,KeyError,TypeError,AttributeError) as exc:raise HTTPException(409 if 'immutable' in str(exc) else 400,str(exc)[:200])
finally:s.close()
@app.post('/internal/v2/prognosis/{plant}/planner/workflow')
def workflow_report(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token)
try:return workflow.report(s,plant,payload,datetime.now(timezone.utc))
except (ValueError,KeyError,TypeError,AttributeError) as exc:raise HTTPException(400,str(exc)[:200])
finally:s.close()
@app.get('/health')
def health():return {'status':'ok','mode':'shadow','liveEnabled':False,'receiverProtocolVersion':1,'applicationRelease':'unified-rc1','mappingCompatibilityVersion':1,'economicReplayVersion':1,'archiveVersion':1}
@app.get('/internal/v2/planner/installations')
def planning_installations(token:str=Header(default='',alias='X-Enelix-Service-Token')):
if not secrets.compare_digest(token or '',service_token):raise HTTPException(401,'Unauthorized')
s=factory()
try:
registered={r[0] for r in s.con.execute('SELECT plant FROM planner_enrollment WHERE learning_enabled=1')}
paused={r[0] for r in s.con.execute('SELECT plant FROM planner_enrollment WHERE learning_enabled=0')}
return {'installationIds':sorted((allowed|registered)-paused),'controlEnabled':False}
finally:s.close()
@app.get('/internal/v2/prognosis/{plant}/planner')
def read(plant:str,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token)
@@ -419,6 +451,7 @@ def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial
def measurement_batch(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token)
try:
if not workflow.learning_enabled(s.con,plant):raise ValueError('Learning is disabled for this installation')
now=datetime.now(timezone.utc)
result=measurement_pipeline.ingest_batch(s.con,plant,payload,int(now.timestamp()))
# Existing five-minute worker handles rollup/training; no per-record optimizer flood.
+2 -1
View File
@@ -4,7 +4,7 @@ import sqlite3
from datetime import datetime,timedelta
from uuid import uuid4
from .domain import default_registry,month_key,number,quarter_start,utc
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline, workflow
def canonical(value):
return json.dumps(value,sort_keys=True,separators=(',',':'),allow_nan=False)
@@ -36,6 +36,7 @@ class PlannerStore:
economic_replay.schema(self.con)
retention.schema(self.con)
measurement_pipeline.schema(self.con)
workflow.schema(self.con)
def close(self):self.con.close()
def settings(self,plant):
row=self.con.execute('SELECT revision,value FROM planner_settings WHERE plant=?',(plant,)).fetchone()
+214
View File
@@ -0,0 +1,214 @@
"""Authenticated data onboarding and observed workflow, never actuator authority."""
from datetime import timedelta
from uuid import UUID
import json
from . import measurement_pipeline
from .domain import utc
from .receiver_contract import control_context
REPORT_MAX_AGE_SECONDS = 120
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)
def schema(con):
con.executescript('''
CREATE TABLE IF NOT EXISTS planner_enrollment(
plant TEXT PRIMARY KEY, dataset TEXT NOT NULL, learning_enabled INTEGER NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS planner_workflow_report(
plant TEXT PRIMARY KEY, observed_at TEXT NOT NULL, received_at TEXT NOT NULL,
value TEXT NOT NULL);
''')
def identity(plant, value):
if not isinstance(plant, str) or str(UUID(plant)) != plant:
raise ValueError('Canonical installation UUID required')
if type(value.get('version')) is not int or value['version'] != 1 or value.get('installationId') != plant:
raise ValueError('Installation path/payload mismatch or unsupported version')
def enrolled(con, plant):
return con.execute('SELECT * FROM planner_enrollment WHERE plant=?', (plant,)).fetchone()
def learning_enabled(con, plant):
row = enrolled(con, plant)
if row is not None:
return bool(row['learning_enabled'])
report_row = con.execute('SELECT value FROM planner_workflow_report WHERE plant=?', (plant,)).fetchone()
return report_row is None or json.loads(report_row['value'])['learningEnabled']
def setup(store, plant, value, now):
identity(plant, value)
if set(value) != {'version', 'installationId', 'dataset', 'learningEnabled'} or type(value['learningEnabled']) is not bool:
raise ValueError('Explicit dataset and boolean learningEnabled required')
config = measurement_pipeline.validate_config(value['dataset'])
# Compatibility proofs and relaxed history-timing policies remain operator-only.
existing = store.con.execute('SELECT config FROM planner_data_sets WHERE plant=? AND dataset=?', (plant, config['datasetId'])).fetchone()
if not existing and (config.get('sourceDatasetId') or config.get('historyTimingPolicy')):
raise ValueError('Device setup cannot grant history compatibility or timing exceptions')
if not existing and (config['minimumCoverage'] < .95 or config['maximumGapSeconds'] > 5 or config['minimumTrainingHours'] < 24):
raise ValueError('New datasets require coverage >= .95, gap <= 5s and training >= 24h')
con = store.con
con.execute('BEGIN IMMEDIATE')
try:
measurement_pipeline.register_dataset(con, plant, config, int(now.timestamp()), own_transaction=False)
current = store.settings(plant)
initialized = con.execute('SELECT 1 FROM planner_settings WHERE plant=?', (plant,)).fetchone() is None
if initialized:
# Native interval estimates retain explicit provenance and gap checks;
# this is a planning policy, never billing or actuator evidence.
current.update(forecastSource='corrected_profile', measurementDataset=config['datasetId'], trainingCadence='daily', measurementPolicy='allow_estimates')
current.pop('revision')
store._validate_settings(current)
con.execute('INSERT INTO planner_settings VALUES(?,?,?)', (plant, 1, canonical(current)))
store._request(plant, 1, 'onboarding', now)
con.execute('INSERT INTO planner_audit(plant,at,kind,detail) VALUES(?,?,?,?)',
(plant, utc(now).isoformat(), 'onboarding', canonical({'datasetId': config['datasetId'], 'controlEnabled': False})))
stamp = utc(now).isoformat()
con.execute('''INSERT INTO planner_enrollment VALUES(?,?,?,?,?)
ON CONFLICT(plant) DO UPDATE SET dataset=excluded.dataset,
learning_enabled=excluded.learning_enabled,updated_at=excluded.updated_at''',
(plant, config['datasetId'], int(value['learningEnabled']), stamp, stamp))
con.commit()
except Exception:
con.rollback()
raise
settings = store.settings(plant)
return {'status': 'configured', 'installationId': plant, 'datasetId': config['datasetId'],
'selectedDatasetId': settings['measurementDataset'], 'settingsInitialized': initialized,
'settingsRevision': settings['revision'], 'learningEnabled': value['learningEnabled'],
'controlEnabled': False, 'controlledTrialAuthorized': False}
def report(store, plant, value, now):
identity(plant, value)
if set(value) != {'version', 'installationId', 'observedAt', 'learningEnabled', 'controlRequested', 'controlActive', 'reason'}:
raise ValueError('Explicit workflow report required')
for key in ('learningEnabled', 'controlRequested', 'controlActive'):
if type(value[key]) is not bool:
raise ValueError('Workflow flags must be boolean')
reason = value['reason']
if not isinstance(reason, str) or len(reason) > 300 or any(ord(c) < 32 for c in reason):
raise ValueError('Workflow reason must be plain text up to 300 characters')
observed = measurement_pipeline.epoch(value['observedAt'])
if not -30 <= now.timestamp() - observed <= REPORT_MAX_AGE_SECONDS:
raise ValueError('Fresh whole-second UTC workflow report required')
if value['controlActive'] and not (value['learningEnabled'] and value['controlRequested']):
raise ValueError('Active control requires learning and requested control')
normalized = {**value, 'observedAt': measurement_pipeline.iso(observed)}
data = canonical(normalized)
con = store.con
con.execute('BEGIN IMMEDIATE')
try:
old = con.execute('SELECT observed_at,value FROM planner_workflow_report WHERE plant=?', (plant,)).fetchone()
if old and utc(old['observed_at']).timestamp() > observed:
raise ValueError('Workflow report moved backwards')
if old and utc(old['observed_at']).timestamp() == observed:
if old['value'] != data:
raise ValueError('Conflicting workflow report at the same time')
con.commit()
return {'status': 'duplicate', 'controlEnabled': False}
con.execute('''INSERT INTO planner_workflow_report VALUES(?,?,?,?)
ON CONFLICT(plant) DO UPDATE SET observed_at=excluded.observed_at,
received_at=excluded.received_at,value=excluded.value''',
(plant, normalized['observedAt'], utc(now).isoformat(), data))
# The authenticated first switch also pauses autonomous training. A stop
# remains effective after report expiry; stale telemetry cannot re-enable it.
registration = enrolled(con, plant)
if registration and observed >= int(utc(registration['updated_at']).timestamp()):
con.execute('UPDATE planner_enrollment SET learning_enabled=?,updated_at=? WHERE plant=?',
(int(value['learningEnabled']), utc(now).isoformat(), plant))
con.commit()
except Exception:
con.rollback()
raise
return {'status': 'recorded', 'controlEnabled': False}
def planning_continuity(store, plant, state, now):
"""Keep readiness during routine telemetry replans, never renew a receipt.
This does not change receiver ``fresh``. The Manager must still validate its
originally accepted envelope and stop when that local envelope expires.
"""
if state['fresh']:
return True
try:
pending = state['pending']
plan = state['plan']
settings = state['settings']
run = state['lastRun']
if not pending or not plan or not run:
return False
reasons = json.loads(pending['reasons'])
if not isinstance(reasons, list) or not reasons or not set(reasons) <= {'operation_changed', 'telemetry_changed', 'five_minute_tick'}:
return False
revision = settings['revision']
if pending['revision'] != revision or plan['configRevision'] != revision:
return False
if run['status'] not in ('optimal', 'feasible_time_limit') or run['detail'].get('planId') != plan['planId']:
return False
if run['detail'].get('configRevision') != revision:
return False
if not utc(plan['validFrom']) <= now < utc(plan['validUntil']) or not 0 <= (now-utc(plan['generatedAt'])).total_seconds() <= 900:
return False
rows = store.con.execute('''SELECT i.kind,i.value FROM planner_inputs i
JOIN planner_input_current c ON i.plant=c.plant AND i.kind=c.kind AND i.event_id=c.event_id
WHERE i.plant=? AND i.kind IN ('operation','forecast','tariffs')''', (plant,))
inputs = {row['kind']: json.loads(row['value']) for row in rows}
if any(plan['inputRefs'][kind] != inputs[kind]['eventId'] for kind in ('forecast', 'tariffs')):
return False
operation = inputs['operation']
if not 0 <= (now-utc(operation['observedAt'])).total_seconds() <= 120:
return False
return control_context(operation) == plan['controlContext']
except (KeyError, TypeError, ValueError, AttributeError):
return False
def view(store, plant, state, now):
settings = state['settings']
registration = enrolled(store.con, plant)
row = store.con.execute('SELECT value FROM planner_workflow_report WHERE plant=?', (plant,)).fetchone()
report_value = json.loads(row['value']) if row else None
source = utc(report_value['observedAt']) if report_value else None
report_fresh = bool(source and 0 <= (now-source).total_seconds() <= REPORT_MAX_AGE_SECONDS)
learning = bool(registration['learning_enabled']) if registration else bool(report_fresh and report_value['learningEnabled'])
requested = bool(report_value and report_value['controlRequested'])
dataset = settings['measurementDataset']
model = measurement_pipeline.current_model(store.con, plant, dataset, int(now.timestamp())) if dataset else None
pipeline = next((d for d in state['dataPipeline']['datasets'] if d['datasetId'] == dataset), {})
plan = state.get('plan') or {}
selected = plan.get('inputQuality', {}).get('dataPipeline', {})
ready = bool(settings['forecastSource'] == 'corrected_profile' and model and planning_continuity(store, plant, state, now)
and plan.get('executable') is True and selected.get('datasetId') == dataset
and selected.get('modelId') == model['modelId'])
active = bool(learning and requested and report_fresh and report_value['controlActive'] and ready)
if active:
name, reason = 'active', report_value['reason'] or 'manager_control_active'
elif requested:
name = 'interrupted'
reason = 'manager_report_stale' if not report_fresh else report_value['reason'] or ('planning_not_ready' if not ready else 'manager_control_not_active')
elif not learning:
name, reason = 'disabled', 'learning_disabled'
elif ready:
name, reason = 'ready', 'plan_and_model_ready'
elif model:
name = 'learning'
reason = (state.get('lastRun') or {}).get('detail', {}).get('reason') or 'awaiting_fresh_plan'
elif pipeline.get('status') in ('training', 'candidate_pending'):
name, reason = 'learning', 'model_training'
else:
name, reason = 'collecting', 'collecting_measurements' if registration else 'setup_required'
return {'state': name, 'reason': reason, 'learningEnabled': learning, 'controlRequested': requested,
'controlActive': active, 'planningReady': ready, 'checkedAt': utc(now).isoformat(),
'sourceReportAt': source.isoformat() if source else None,
'sourceFreshUntil': (source+timedelta(seconds=REPORT_MAX_AGE_SECONDS)).isoformat() if source else None,
'reportFresh': report_fresh, 'usingPreviousPlan': bool(ready and not state['fresh'])}
+14 -1
View File
@@ -8,7 +8,7 @@ function fixture(options={}){
configuredPrognosisPlant:(req,res,id,csrf)=>{auth.push({id,csrf});return options.denied?null:{plant:{installation_id:INSTALL},license:{quantities:{grid_schedule:options.unlicensed?0:1}},session:{}};},
roleAllowed:()=>!options.viewer,bodyJson:async()=>({expectedRevision:0,changes:{family:'23'}}),
json:(res,status,payload)=>sent.push({status,payload}),deviceActivation:()=>options.deviceDenied?null:{plant_id:PLANT},
checkDeviceRate:()=>!options.ratelimited,ownedLicenseState:()=>({quantities:{grid_schedule:1}}),serviceToken:'synthetic-test-only',
checkDeviceRate:()=>!options.ratelimited,ownedLicenseState:()=>({quantities:{grid_schedule:options.unlicensed?0:1}}),serviceToken:'synthetic-test-only',
fetchImpl:async(url,args)=>{upstream.push({url:String(url),args});if(options.down)throw new Error('secret-host-detail');return {ok:!options.conflict,status:options.conflict?409:200,json:async()=>options.conflict?{detail:'internal-field'}:{liveEnabled:false,plan:{planId:'p1'}}};}
});return {bridge,sent,auth,upstream};
}
@@ -30,3 +30,16 @@ test('measurement batches use authenticated dedicated data ingress',async()=>{co
test('unauthenticated measurement upload cannot reach storage',async()=>{const f=fixture({deviceDenied:true});await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/measurements`));assert.equal(f.upstream.length,0);});
test('device cannot change dataset mapping through public proxy',async()=>{const f=fixture();assert.equal(await f.bridge({method:'PUT'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/datasets/physical-v1`)),false);assert.equal(f.upstream.length,0);});
test('measurement upload keeps device rate protection',async()=>{const f=fixture({ratelimited:true});await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/measurements`));assert.equal(f.sent[0].status,429);assert.equal(f.upstream.length,0);});
for(const action of ['setup','workflow']){
const endpoint=id=>new URL(`https://portal.test/api/v1/installations/${id}/prognosis/planner-v4/${action}`);
test(`${action} uses authenticated device license and installation binding`,async()=>{
const f=fixture();assert.equal(await f.bridge({method:'POST'},{},endpoint(INSTALL)),true);
assert.match(f.upstream[0].url,new RegExp(`/prognosis/${INSTALL}/planner/${action}$`));
assert.equal(f.upstream[0].args.method,'POST');
});
for(const option of ['deviceDenied','unlicensed','ratelimited'])test(`${action} rejects ${option} before forwarding`,async()=>{
const f=fixture({[option]:true});await f.bridge({method:'POST'},{},endpoint(INSTALL));assert.equal(f.upstream.length,0);
});
test(`${action} cannot use a read request to mutate`,async()=>{const f=fixture();await f.bridge({method:'GET'},{},endpoint(INSTALL));assert.equal(f.sent[0].status,405);assert.equal(f.upstream.length,0);});
}
@@ -0,0 +1,61 @@
"""Cross-language contract for the ordinary Manager's generated measurement map."""
from hashlib import sha256
import json
from pathlib import Path
import shutil
import sqlite3
import subprocess
import unittest
from netplan_v4 import measurement_pipeline as pipeline
@unittest.skipUnless(shutil.which('php'), 'Native PHP CLI required for capture contract')
class NativeMeasurementSetupTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
fixture = Path(__file__).resolve().parents[3] / 'tests' / 'V4Setup' / 'checks.php'
result = subprocess.run(['php', str(fixture), '--fixture'], check=True, capture_output=True, text=True)
if result.stderr:
raise AssertionError(result.stderr)
cls.fixture = json.loads(result.stdout)
def test_dataset_matches_backend_and_exact_php_capture_hash(self):
setup = self.fixture['setup']
config = pipeline.validate_config(setup['dataset'])
self.assertEqual(config['mappingSha256'], sha256(self.fixture['captureJson'].encode()).hexdigest())
self.assertEqual(config['mappingSha256'], self.fixture['record']['mappingSha256'])
self.assertEqual(config['inventorySha256'], self.fixture['record']['reportedInventorySha256'])
self.assertEqual(config['datasetId'], setup['datasetId'])
self.assertEqual(len(config['sources']), 3)
def test_capture_ingests_without_virtual_double_count_or_permissions(self):
setup = self.fixture['setup']
record = self.fixture['record']
config = setup['dataset']
plant = record['installationId']
now = pipeline.epoch(record['capturedAt'])
con = sqlite3.connect(':memory:')
try:
pipeline.schema(con)
registered = pipeline.register_dataset(con, plant, config, now)
self.assertFalse(registered['controlEnabled'])
receipt = pipeline.ingest_batch(con, plant, {'version': 1, 'datasetId': setup['datasetId'], 'records': [record]}, now)
self.assertEqual(receipt['stored'], 1)
self.assertFalse(receipt['controlEnabled'])
values = {key: source['value'] for key, source in record['raw'].items()}
self.assertEqual(pipeline.physical_value(values, config), 4000)
self.assertFalse(record['trainingEligible'])
self.assertFalse(record['measurementBoundaryVerified'])
changed = json.loads(json.dumps(config))
changed['sources'][0]['factorToW'] *= -1
with self.assertRaises(ValueError):
pipeline.register_dataset(con, plant, changed, now)
with self.assertRaises(ValueError):
pipeline.ingest_batch(con, 'other-installation', {'version': 1, 'datasetId': setup['datasetId'], 'records': [record]}, now)
finally:
con.close()
if __name__ == '__main__':
unittest.main()
@@ -40,7 +40,12 @@ class ReleasePreflightTest(unittest.TestCase):
self.assertIn('?mode=ro',text);self.assertNotIn('ALTER TABLE',text)
def test_gui_labels_estimates_and_incomplete_features(self):
text=(ROOT/'gui/netplan-v4.js').read_text()
self.assertIn('SCHATTENBETRIEB',text)
self.assertIn('Die Kurven zeigen berechnete Plaene, keine nachgewiesene Ausfuehrung',text)
self.assertIn('Geraete- und Netzgrenzen behalten Vorrang',text)
self.assertIn('Planempfang ist kein Ausfuehrungsnachweis',text)
self.assertIn('kein Nachweis realisierter Einsparungen',text)
self.assertIn('Planung mit geschaetzter Peakbasis',text)
self.assertIn('kein Abrechnungsnachweis',text)
self.assertIn('measurementPolicy',text)
self.assertIn('erstellt vergleichbare Kosten-Replays',text)
self.assertIn('keine vollstaendige Nachbildung laufender Neuoptimierungen',text)
@@ -48,4 +53,15 @@ class ReleasePreflightTest(unittest.TestCase):
self.assertIn('trainingCadence',text)
self.assertIn('Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe',text)
def test_gui_separates_planning_from_confirmed_manager_control(self):
text=(ROOT/'gui/netplan-v4.js').read_text()
self.assertIn('Betriebsstatus nicht bestaetigt',text)
self.assertIn('workflow.controlActive && workflow.controlRequested && workflow.planningReady',text)
self.assertIn('workflow.reportFresh !== true',text)
self.assertIn('Date.parse(workflow.sourceFreshUntil) <= now',text)
self.assertIn("state: 'interrupted'",text)
self.assertIn('controlActive: false',text)
self.assertIn('lokale Schutzpruefung bleibt erforderlich',text)
self.assertIn("setTimeout(showWorkflow",text)
if __name__=='__main__':unittest.main()
+238
View File
@@ -0,0 +1,238 @@
import copy
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
import tempfile
import unittest
from fastapi.testclient import TestClient
from netplan_v4 import measurement_pipeline as m, workflow, meter_runtime
from netplan_v4.service import create_app, run_once, status, ingest
from netplan_v4.store import PlannerStore, canonical
from test_measurement_pipeline import config, record
from test_v4 import AID, TOKEN, inputs
OTHER = '00000000-0000-4000-8000-000000000002'
NOW = datetime(2026, 10, 2, 12, tzinfo=timezone.utc)
def setup_payload(plant=AID):
c = config(maximumGapSeconds=5, minimumTrainingHours=24, historyDays=30)
c['sources'] = [s for s in c['sources'] if s['role'] != 'sdl_request']
return {'version': 1, 'installationId': plant, 'dataset': c, 'learningEnabled': True}
def report_payload(plant=AID, at=NOW, **changes):
return {'version': 1, 'installationId': plant, 'observedAt': at.isoformat(),
'learningEnabled': True, 'controlRequested': False, 'controlActive': False,
'reason': '', **changes}
class WorkflowTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.path = str(Path(self.temp.name)/'workflow.sqlite')
self.store = PlannerStore(self.path)
def tearDown(self):
self.store.close()
self.temp.cleanup()
def setup(self, payload=None, plant=AID):
return workflow.setup(self.store, plant, payload or setup_payload(plant), NOW)
def test_setup_initializes_conservative_planning_only(self):
answer = self.setup()
self.assertTrue(answer['settingsInitialized'])
self.assertFalse(answer['controlEnabled'])
settings = self.store.settings(AID)
self.assertEqual(settings['forecastSource'], 'corrected_profile')
self.assertEqual(settings['trainingCadence'], 'daily')
self.assertEqual(settings['measurementPolicy'], 'allow_estimates')
self.assertEqual(settings['runMode'], 'shadow')
self.assertEqual(status(self.store, AID, NOW)['workflow']['state'], 'collecting')
def test_repeat_setup_keeps_user_settings_revision_and_history(self):
self.setup()
self.store.save_settings(AID, {'family': '23', 'trainingCadence': 'weekly'}, 1, NOW)
c = setup_payload()['dataset']
m.ingest_batch(self.store.con, AID, {'version': 1, 'datasetId': c['datasetId'],
'records': [record(int(NOW.timestamp()), c)]}, int(NOW.timestamp()))
before = self.store.settings(AID)
answer = self.setup()
self.assertFalse(answer['settingsInitialized'])
self.assertEqual(before, self.store.settings(AID))
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_observations').fetchone()[0], 1)
def test_existing_custom_dataset_selection_is_not_overwritten(self):
m.register_dataset(self.store.con, AID, config(datasetId='custom-derived'), int(NOW.timestamp()))
self.store.save_settings(AID, {'forecastSource': 'corrected_profile', 'measurementDataset': 'custom-derived'}, 0, NOW)
result = self.setup()
self.assertEqual(result['selectedDatasetId'], 'custom-derived')
self.assertEqual(result['settingsRevision'], 1)
self.assertEqual(self.store.settings(AID)['measurementPolicy'], 'verified_only')
def test_drift_requires_new_dataset_and_keeps_old_selection(self):
self.setup()
payload = setup_payload()
payload['dataset']['mappingSha256'] = 'c'*64
with self.assertRaisesRegex(ValueError, 'immutable'):
self.setup(payload)
payload['dataset']['datasetId'] = 'physical-v2'
result = self.setup(payload)
self.assertEqual(result['selectedDatasetId'], 'physical-v1')
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_data_sets').fetchone()[0], 2)
def test_two_plants_remain_isolated(self):
self.setup()
self.setup(plant=OTHER)
self.store.save_settings(AID, {'family': '23'}, 1, NOW)
self.assertEqual(self.store.settings(OTHER)['family'], '3')
workflow.report(self.store, AID, report_payload(controlRequested=True), NOW)
self.assertFalse(status(self.store, OTHER, NOW)['workflow']['controlRequested'])
bad = setup_payload(OTHER)
with self.assertRaises(ValueError):
self.setup(bad)
def test_setup_cannot_supply_safety_or_relaxed_policies(self):
for mutate in (lambda v: v.update(controlEnabled=True),
lambda v: v['dataset'].update(minimumTrainingHours=1),
lambda v: v['dataset'].update(maximumGapSeconds=10),
lambda v: v.update(version=True),
lambda v: v.update(learningEnabled=1)):
value = setup_payload()
mutate(value)
with self.assertRaises(ValueError):
self.setup(value)
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_enrollment').fetchone()[0], 0)
def test_reports_are_monotonic_idempotent_and_tenant_bound(self):
self.setup()
value = report_payload()
self.assertEqual(workflow.report(self.store, AID, value, NOW)['status'], 'recorded')
self.assertEqual(workflow.report(self.store, AID, value, NOW)['status'], 'duplicate')
for bad in ({**value, 'reason': 'different'}, report_payload(at=NOW-timedelta(seconds=1)),
report_payload(plant=OTHER)):
with self.assertRaises(ValueError):
workflow.report(self.store, AID, bad, NOW)
def test_reports_reject_invalid_types_time_and_false_active_claim(self):
self.setup()
for change in ({'controlActive': True}, {'controlRequested': 1}, {'reason': 'x'*301},
{'reason': 'line\nbreak'}, {'version': True}, {'observedAt': '2026-10-02T12:00:00'},
{'observedAt': (NOW-timedelta(seconds=121)).isoformat()},
{'observedAt': (NOW+timedelta(seconds=31)).isoformat()}):
with self.assertRaises(ValueError):
workflow.report(self.store, AID, report_payload(**change), NOW)
def ready(self, native_meter=False):
self.setup()
c = setup_payload()['dataset']
t = int(NOW.timestamp())
rows = [record(ts, c) for ts in range(t-25*3600, t+1, 30)]
for i in range(0, len(rows), 120):
m.ingest_batch(self.store.con, AID, {'version': 1, 'datasetId': c['datasetId'], 'records': rows[i:i+120]}, t)
m.advance(self.store.con, AID, c['datasetId'], self.store.settings(AID), t)
values = inputs(NOW)
if native_meter:
with self.store.con:
for seconds in range(-900, 1, 30):
at = NOW+timedelta(seconds=seconds)
observation = {'meterId': 'symcon-active-import:'+'c'*64, 'sampleAt': at.isoformat(),
'powerW': 3000., 'totalImportKwh': 100.+(seconds+900)/1200,
'controlPolicyId': 'native-test'}
meter_runtime.observe(self.store.con, AID, observation, at)
values[0]['measuredPeaks'] = {}
values[0]['meterObservation'] = observation
for kind, value in zip(('operation', 'forecast', 'tariffs'), values):
ingest(self.store, AID, kind, value, NOW)
self.assertTrue(run_once(self.store, NOW)['executable'])
def test_physical_only_ready_plan_retains_pv_and_prices(self):
self.ready()
view = status(self.store, AID, NOW)
self.assertEqual(view['workflow']['state'], 'ready')
self.assertFalse(view['workflow']['controlActive'])
plan = view['plan']
self.assertEqual(plan['points'][0]['pvW'], 5000.)
self.assertEqual(plan['points'][0]['externalW'], 0.)
self.assertEqual(plan['inputQuality']['dataPipeline']['externalPolicy'], 'no_external_sdl_channel')
def test_native_onboarding_is_ready_without_manual_peak_evidence(self):
self.ready(native_meter=True)
state = status(self.store, AID, NOW)
self.assertEqual(state['workflow']['state'], 'ready')
self.assertEqual(state['plan']['peakBasis']['2026-10']['quality'], 'estimated')
self.assertEqual(state['plan']['measuredPeaksKw'], {})
self.assertFalse(state['liveEnabled'])
def test_report_false_pauses_autonomous_worker_not_only_display(self):
self.setup()
workflow.report(self.store, AID, report_payload(learningEnabled=False), NOW)
self.assertEqual(run_once(self.store, NOW)['status'], 'learning_disabled')
def test_ordinary_replan_retains_readiness_but_never_receiver_freshness(self):
self.ready()
original = self.store.current(AID)
self.store.request(AID, 'telemetry_changed', NOW)
state = status(self.store, AID, NOW)
self.assertFalse(state['fresh'])
self.assertTrue(state['workflow']['planningReady'])
self.assertTrue(state['workflow']['usingPreviousPlan'])
self.assertEqual(state['plan'], original)
self.store.save_settings(AID, {'family': '23'}, 1, NOW)
self.assertFalse(status(self.store, AID, NOW)['workflow']['planningReady'])
def test_optimal_or_ack_alone_never_means_active(self):
self.ready()
plan = self.store.current(AID)
self.store.acknowledge(AID, plan['planId'], 1, NOW, status='received')
self.assertEqual(status(self.store, AID, NOW)['workflow']['state'], 'ready')
def test_active_requires_fresh_report_and_matching_fresh_plan(self):
self.ready()
workflow.report(self.store, AID, report_payload(controlRequested=True, controlActive=True), NOW)
view = status(self.store, AID, NOW)['workflow']
self.assertEqual(view['state'], 'active')
self.assertTrue(view['controlActive'])
stale = status(self.store, AID, NOW+timedelta(seconds=121))['workflow']
self.assertEqual(stale['state'], 'interrupted')
self.assertEqual(stale['reason'], 'manager_report_stale')
self.assertFalse(stale['controlActive'])
self.store.request(AID, 'manual', NOW)
self.assertFalse(status(self.store, AID, NOW)['workflow']['controlActive'])
def test_active_report_without_usable_model_is_interrupted(self):
self.setup()
workflow.report(self.store, AID, report_payload(controlRequested=True, controlActive=True), NOW)
view = status(self.store, AID, NOW)['workflow']
self.assertEqual(view['state'], 'interrupted')
self.assertFalse(view['controlActive'])
def test_learning_disabled_pauses_worker_but_retains_data(self):
self.setup({**setup_payload(), 'learningEnabled': False})
self.assertEqual(run_once(self.store, NOW)['status'], 'learning_disabled')
self.assertEqual(status(self.store, AID, NOW)['workflow']['state'], 'disabled')
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_data_sets').fetchone()[0], 1)
def test_private_setup_enrolls_without_expanding_actuator_allowlist(self):
with TestClient(create_app(self.path, TOKEN, [], start_worker=False)) as client:
path = '/internal/v2/prognosis/'+OTHER+'/planner'
headers = {'X-Enelix-Service-Token': TOKEN}
self.assertEqual(client.post(path+'/setup', json=setup_payload(OTHER)).status_code, 401)
self.assertEqual(client.get(path, headers=headers).status_code, 403)
result = client.post(path+'/setup', json=setup_payload(OTHER), headers=headers)
self.assertEqual(result.status_code, 200, result.text)
state = client.get(path, headers=headers).json()
self.assertFalse(state['controlledTrialAuthorized'])
self.assertIsNone(state['controlledTrial'])
self.assertFalse(state['liveEnabled'])
registry = client.get('/internal/v2/planner/installations', headers=headers)
self.assertEqual(registry.json()['installationIds'], [OTHER])
self.assertEqual(client.get('/internal/v2/planner/installations').status_code, 401)
self.assertEqual(client.post(path+'/trial/arm', json={}, headers=headers).status_code, 409)
self.assertEqual(client.post(path+'/setup', json=setup_payload(AID), headers=headers).status_code, 400)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,278 @@
"""Stdlib-only enrollment/status tests, independent of the HTTP/solver runtime."""
from datetime import datetime, timedelta, timezone
from copy import deepcopy
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from netplan_v4 import workflow, measurement_pipeline as m, meter_runtime
from netplan_v4.store import PlannerStore, canonical
from netplan_v4.receiver_contract import control_context
PLANT = '00000000-0000-4000-8000-000000000001'
OTHER = '00000000-0000-4000-8000-000000000002'
NOW = datetime(2026, 10, 8, 12, tzinfo=timezone.utc)
def payload(plant=PLANT):
config = {'datasetId': 'physical-v1', 'mappingSha256': 'a'*64, 'inventorySha256': 'b'*64,
'formula': 'physical_sum_v1', 'solarReference': None, 'minimumCoverage': .95,
'maximumGapSeconds': 5, 'minimumTrainingHours': 24, 'historyDays': 30,
'sources': [{'key': role, 'role': role, 'variableId': i+101, 'factorToW': 1,
'maxAgeSeconds': 60} for i, role in enumerate(('grid', 'pv', 'physical_storage'))]}
return {'version': 1, 'installationId': plant, 'dataset': config, 'learningEnabled': True}
def report(**changes):
return {'version': 1, 'installationId': PLANT, 'observedAt': NOW.isoformat(),
'learningEnabled': True, 'controlRequested': False, 'controlActive': False,
'reason': '', **changes}
class WorkflowCoreTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.s = PlannerStore(str(Path(self.temp.name)/'planner.sqlite'))
def tearDown(self):
self.s.close()
self.temp.cleanup()
def state(self, at=NOW, **changes):
state = {'settings': self.s.settings(PLANT), 'fresh': False, 'plan': None,
'dataPipeline': {'datasets': []}, 'lastRun': None, **changes}
return workflow.view(self.s, PLANT, state, at)
def test_new_installation_enrolls_and_duplicate_retains_user_revision(self):
result = workflow.setup(self.s, PLANT, payload(), NOW)
self.assertTrue(result['settingsInitialized'])
self.assertFalse(result['controlEnabled'])
self.assertFalse(result['controlledTrialAuthorized'])
self.s.save_settings(PLANT, {'family': '23', 'trainingCadence': 'weekly'}, 1, NOW)
old = self.s.settings(PLANT)
workflow.setup(self.s, PLANT, payload(), NOW)
self.assertEqual(old, self.s.settings(PLANT))
self.assertEqual(old['measurementPolicy'], 'allow_estimates')
self.assertEqual(self.state()['state'], 'collecting')
def test_two_installations_and_dataset_drift_are_isolated(self):
workflow.setup(self.s, PLANT, payload(), NOW)
workflow.setup(self.s, OTHER, payload(OTHER), NOW)
drift = payload()
drift['dataset']['mappingSha256'] = 'c'*64
with self.assertRaisesRegex(ValueError, 'immutable'):
workflow.setup(self.s, PLANT, drift, NOW)
self.assertEqual(self.s.con.execute('SELECT COUNT(*) FROM planner_data_sets').fetchone()[0], 2)
workflow.report(self.s, PLANT, report(), NOW)
self.assertIsNone(self.s.con.execute('SELECT value FROM planner_workflow_report WHERE plant=?', (OTHER,)).fetchone())
def test_device_cannot_weaken_thresholds_or_claim_authority(self):
for mutate in (lambda x: x.update(controlEnabled=True), lambda x: x.update(learningEnabled=1),
lambda x: x.update(installationId=OTHER), lambda x: x['dataset'].update(minimumTrainingHours=1),
lambda x: x['dataset'].update(maximumGapSeconds=10)):
value = payload()
mutate(value)
with self.assertRaises(ValueError):
workflow.setup(self.s, PLANT, value, NOW)
self.assertIsNone(workflow.enrolled(self.s.con, PLANT))
def test_report_duplicate_monotonicity_and_exact_types(self):
workflow.setup(self.s, PLANT, payload(), NOW)
self.assertEqual(workflow.report(self.s, PLANT, report(), NOW)['status'], 'recorded')
self.assertEqual(workflow.report(self.s, PLANT, report(), NOW)['status'], 'duplicate')
for changes in ({'reason': 'conflict'}, {'controlRequested': 1}, {'version': True},
{'controlActive': True}, {'reason': 'x'*301}, {'reason': 'line\nbreak'},
{'observedAt': (NOW-timedelta(seconds=1)).isoformat()},
{'observedAt': '2026-10-08T12:00:00'}, {'installationId': OTHER}):
with self.assertRaises(ValueError):
workflow.report(self.s, PLANT, report(**changes), NOW)
def test_fresh_plan_and_model_do_not_imply_active_manager(self):
workflow.setup(self.s, PLANT, payload(), NOW)
with self.s.con:
self.s.con.execute('INSERT INTO planner_load_models VALUES(?,?,?,?,?,?)',
(PLANT, 'physical-v1', 'model-1', int(NOW.timestamp()), int(NOW.timestamp()), '{}'))
self.s.con.execute('INSERT INTO planner_model_current VALUES(?,?,?)', (PLANT, 'physical-v1', 'model-1'))
plan = {'executable': True, 'inputQuality': {'dataPipeline': {'datasetId': 'physical-v1', 'modelId': 'model-1'}}}
self.assertEqual(self.state(fresh=True, plan=plan)['state'], 'ready')
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
self.assertEqual(self.state(fresh=True, plan=plan)['state'], 'active')
self.assertFalse(self.state(fresh=False, plan=plan)['controlActive'])
stale = self.state(at=NOW+timedelta(seconds=121), fresh=True, plan=plan)
self.assertEqual(stale['state'], 'interrupted')
self.assertEqual(stale['reason'], 'manager_report_stale')
self.assertFalse(stale['controlActive'])
def test_model_mismatch_and_unready_plan_block_active(self):
workflow.setup(self.s, PLANT, payload(), NOW)
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
self.assertEqual(self.state()['state'], 'interrupted')
self.assertFalse(self.state()['planningReady'])
def test_learning_toggle_preserves_registration_and_plan_settings(self):
workflow.setup(self.s, PLANT, payload(), NOW)
old = self.s.settings(PLANT)
workflow.setup(self.s, PLANT, {**payload(), 'learningEnabled': False}, NOW)
self.assertEqual(self.state()['state'], 'disabled')
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
self.assertEqual(old, self.s.settings(PLANT))
def test_explicit_learning_stop_overrides_earlier_fresh_active_report(self):
workflow.setup(self.s, PLANT, payload(), NOW)
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
workflow.setup(self.s, PLANT, {**payload(), 'learningEnabled': False}, NOW+timedelta(seconds=1))
self.assertFalse(self.state()['learningEnabled'])
self.assertFalse(self.state()['controlActive'])
def test_workflow_learning_stop_pauses_worker_until_new_enable_report(self):
workflow.setup(self.s, PLANT, payload(), NOW)
original = self.s.settings(PLANT)
workflow.report(self.s, PLANT, report(learningEnabled=False), NOW)
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
self.assertFalse(self.state(at=NOW+timedelta(seconds=121))['learningEnabled'])
later = NOW+timedelta(seconds=122)
workflow.report(self.s, PLANT, report(observedAt=later.isoformat()), later)
self.assertTrue(workflow.learning_enabled(self.s.con, PLANT))
self.assertEqual(original, self.s.settings(PLANT))
def test_pre_enrollment_legacy_plant_can_pause_learning_via_report(self):
workflow.report(self.s, PLANT, report(learningEnabled=False), NOW)
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
def test_report_captured_before_setup_stop_cannot_reenable_learning(self):
workflow.setup(self.s, PLANT, payload(), NOW)
workflow.setup(self.s, PLANT, {**payload(), 'learningEnabled': False}, NOW+timedelta(seconds=10))
delayed = report(observedAt=(NOW+timedelta(seconds=5)).isoformat())
workflow.report(self.s, PLANT, delayed, NOW+timedelta(seconds=11))
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
self.assertFalse(self.state(at=NOW+timedelta(seconds=11))['learningEnabled'])
def test_native_meter_bootstrap_preserves_estimated_quality_and_gap_checks(self):
workflow.setup(self.s, PLANT, payload(), NOW)
meter = 'symcon-active-import:'+'c'*64
with self.s.con:
for seconds in range(-900, 61, 30):
at = NOW+timedelta(seconds=seconds)
observation = {'meterId': meter, 'sampleAt': at.isoformat(), 'powerW': 3000.,
'totalImportKwh': 100.+(seconds+900)/1200, 'controlPolicyId': 'native-test'}
meter_runtime.observe(self.s.con, PLANT, observation, at)
peaks = meter_runtime.assumptions(self.s.con, PLANT)
self.assertEqual(peaks['2026-10']['quality'], 'estimated')
self.assertAlmostEqual(peaks['2026-10']['kw'], 3.)
self.assertEqual(self.s.peaks(PLANT), {})
quarter = meter_runtime.current_quarter(self.s.con, PLANT, observation, NOW+timedelta(seconds=60))
self.assertEqual(quarter['quality'], 'estimated')
self.assertEqual(quarter['coverage'], 1.)
self.assertIsNone(meter_runtime.current_quarter(self.s.con, PLANT, observation, NOW+timedelta(seconds=181)))
def continuity_state(self):
workflow.setup(self.s, PLANT, payload(), NOW)
operation = {'eventId': 'new-telemetry', 'observedAt': NOW.isoformat(),
'batteries': [{'id': 'battery', 'capacityKwh': 10., 'minSocPercent': 10.,
'maxSocPercent': 90., 'gridCharging': True}],
'limits': {'importW': 10000., 'exportW': 5000., 'managerMonthLimitsW': {}}}
inputs = {'operation': operation, 'forecast': {'eventId': 'forecast-1'}, 'tariffs': {'eventId': 'tariffs-1'}}
with self.s.con:
for kind, value in inputs.items():
self.s.con.execute('INSERT INTO planner_inputs VALUES(?,?,?,?,?)',
(PLANT, kind, value['eventId'], NOW.isoformat(), canonical(value)))
self.s.con.execute('INSERT INTO planner_input_current VALUES(?,?,?)', (PLANT, kind, value['eventId']))
self.s.con.execute('INSERT INTO planner_load_models VALUES(?,?,?,?,?,?)',
(PLANT, 'physical-v1', 'model-1', int(NOW.timestamp()), int(NOW.timestamp()), '{}'))
self.s.con.execute('INSERT INTO planner_model_current VALUES(?,?,?)', (PLANT, 'physical-v1', 'model-1'))
plan = {'planId': 'plan-1', 'configRevision': 1, 'executable': True,
'generatedAt': NOW.isoformat(), 'validFrom': NOW.isoformat(), 'validUntil': (NOW+timedelta(minutes=15)).isoformat(),
'controlContext': control_context(operation), 'inputRefs': {'operation': 'old-telemetry', 'forecast': 'forecast-1', 'tariffs': 'tariffs-1'},
'inputQuality': {'dataPipeline': {'datasetId': 'physical-v1', 'modelId': 'model-1'}}}
return {'settings': self.s.settings(PLANT), 'fresh': False, 'plan': plan,
'pending': {'revision': 1, 'reasons': canonical(['operation_changed', 'five_minute_tick'])},
'dataPipeline': {'datasets': []},
'lastRun': {'status': 'optimal', 'detail': {'planId': 'plan-1', 'configRevision': 1}}}
def test_routine_same_policy_replan_keeps_readiness_without_renewing_envelope(self):
state = self.continuity_state()
original = deepcopy(state)
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
view = workflow.view(self.s, PLANT, state, NOW)
self.assertTrue(view['planningReady'])
self.assertTrue(view['usingPreviousPlan'])
self.assertTrue(view['controlActive'])
self.assertEqual(original, state)
self.assertFalse(state['fresh'])
def test_replan_continuity_rejects_revision_prices_model_and_failed_run(self):
state = self.continuity_state()
for change in (lambda s: s['pending'].update(reasons='["prices_changed"]'),
lambda s: s['pending'].update(reasons='["forecast_changed"]'),
lambda s: s['pending'].update(revision=2),
lambda s: s['settings'].update(revision=2),
lambda s: s['plan'].update(validUntil=NOW.isoformat()),
lambda s: s['plan'].update(generatedAt=(NOW-timedelta(seconds=901)).isoformat()),
lambda s: s['lastRun'].update(status='awaiting_inputs'),
lambda s: s['plan']['inputRefs'].update(forecast='changed'),
lambda s: s['plan']['inputRefs'].update(tariffs='changed'),
lambda s: s['plan']['controlContext']['limits'].update(importW=500.),
lambda s: s['plan']['inputQuality']['dataPipeline'].update(modelId='old-model')):
altered = deepcopy(state)
change(altered)
self.assertFalse(workflow.view(self.s, PLANT, altered, NOW)['planningReady'])
self.assertFalse(workflow.view(self.s, PLANT, state, NOW+timedelta(seconds=121))['planningReady'])
def test_physical_only_training_keeps_pv_and_needs_no_fabricated_sdl(self):
workflow.setup(self.s, PLANT, payload(), NOW)
c = payload()['dataset']
end = int(NOW.timestamp())
rows = []
for stamp in range(end-25*3600, end+1, 30):
rows.append({'schemaVersion': 1, 'kind': 'raw_accounting_capture', 'installationId': PLANT,
'capturedAt': m.iso(stamp), 'captureStartedAt': m.iso(stamp),
'mappingSha256': c['mappingSha256'], 'reportedInventorySha256': c['inventorySha256'],
'raw': {s['key']: {'variableId': s['variableId'], 'value': {'grid': 3000, 'pv': 5000, 'physical_storage': 2000}[s['key']],
'sourceUpdatedAt': stamp, 'issues': []} for s in c['sources']}})
for start in range(0, len(rows), 120):
m.ingest_batch(self.s.con, PLANT, {'version': 1, 'datasetId': c['datasetId'], 'records': rows[start:start+120]}, end)
m.advance(self.s.con, PLANT, c['datasetId'], self.s.settings(PLANT), end)
model = m.current_model(self.s.con, PLANT, c['datasetId'], end)
self.assertIsNotNone(model)
forecast = {'observedAt': NOW.isoformat(), 'families': {'3': {'points': [{'time': NOW.isoformat(), 'pvW': 5000, 'loadW': 9999}]}}}
result = m.apply_load_forecast(self.s.con, PLANT, c['datasetId'], forecast, end)['families']['3']
self.assertEqual(result['points'][0]['loadW'], 6000)
self.assertEqual(result['points'][0]['pvW'], 5000)
self.assertEqual(result['points'][0]['externalW'], 0)
self.assertEqual(result['dataPipeline']['externalPolicy'], 'no_external_sdl_channel')
class PublisherEnrollmentTest(unittest.TestCase):
def setUp(self):
path = Path(__file__).resolve().parents[1]/'integrations/netplan_v4_publisher.py'
spec = importlib.util.spec_from_file_location('workflow_test_publisher', path)
self.publisher = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.publisher)
def response(self, plants):
class Response:
def __enter__(self): return self
def __exit__(self, *args): pass
def read(self, limit): return json.dumps({'installationIds': plants, 'controlEnabled': False}).encode()
return Response()
def test_registry_adds_new_installations_without_static_manual_configuration(self):
env = {'NETPLAN_V4_URL': 'http://planner.test', 'PROGNOSIS_SERVICE_TOKEN': 'synthetic-test-token', 'NETPLAN_V4_PLANTS': PLANT}
with patch.dict(os.environ, env), patch.object(self.publisher, 'urlopen', return_value=self.response([OTHER])) as request:
self.assertTrue(self.publisher.enabled(PLANT))
self.assertTrue(self.publisher.enabled(OTHER))
self.assertEqual(request.call_count, 1)
self.assertEqual(request.call_args.args[0].full_url, 'http://planner.test/internal/v2/planner/installations')
def test_registry_failure_preserves_explicit_plants_not_cached_dynamic_grants(self):
env = {'NETPLAN_V4_URL': 'http://planner.test', 'PROGNOSIS_SERVICE_TOKEN': 'synthetic-test-token', 'NETPLAN_V4_PLANTS': PLANT}
with patch.dict(os.environ, env), patch.object(self.publisher, 'urlopen', side_effect=TimeoutError):
self.assertEqual(self.publisher.planning_plants(), {PLANT})
if __name__ == '__main__':
unittest.main()
+17 -6
View File
@@ -10,8 +10,8 @@ $GLOBALS['deviceCalls'] = 0;
$GLOBALS['testParent'] = 20;
function MBUS_UpdateValues(int $id): bool { ++$GLOBALS['deviceCalls']; throw new RuntimeException('Unexpected device I/O'); }
function ModBus_RequestRead(int $id): bool { ++$GLOBALS['deviceCalls']; throw new RuntimeException('Unexpected device I/O'); }
function IPS_VariableExists(int $id): bool { return $id === 10; }
function IPS_GetObject(int $id): array { return ['ParentID' => $GLOBALS['testParent'], 'ObjectIdent' => 'grid']; }
function IPS_VariableExists(int $id): bool { return in_array($id, [10, 11], true); }
function IPS_GetObject(int $id): array { return ['ParentID' => $GLOBALS['testParent'], 'ObjectIdent' => $id === 10 ? 'grid' : 'battery']; }
function IPS_GetInstanceListByModuleID(string $id): array { return [17004]; }
function IPS_GetProperty(int $id, string $key): mixed
{
@@ -93,15 +93,26 @@ foreach ($expected as $name => $call) {
check([$battery->properties, $battery->attributes, $battery->buffers] === $before,
'timer entrypoint does not change configuration, energy or buffers');
$config = ['batteryInstanceId' => 44234, 'managerId' => 17004,
'sources' => [['variableId' => 10, 'parentId' => 20, 'ident' => 'grid']]];
$config = ['version' => 1, 'installationId' => '11111111-2222-4333-8444-555555555555', 'assetId' => 'battery',
'batteryInstanceId' => 44234, 'managerId' => 17004, 'mode' => 'physical_sum', 'allowEstimatedForTrial' => false,
'maxSkewSeconds' => 30, 'maxAgeSeconds' => 60, 'idleToleranceW' => 0.0, 'trackingToleranceW' => 0.0,
'sources' => [
['key' => 'grid', 'role' => 'grid', 'variableId' => 10, 'parentId' => 20, 'ident' => 'grid', 'factorToW' => 1.0],
['key' => 'battery', 'role' => 'physical', 'variableId' => 11, 'parentId' => 20, 'ident' => 'battery', 'factorToW' => 1.0],
]];
$json = json_encode($config, JSON_THROW_ON_ERROR);
$battery->properties['NetzfahrplanV4RueckmeldungKonfiguration'] = $json;
$sample = ['kind' => 'local_physical_feedback', 'checkedAt' => time(),
'sourceOldestAt' => time() - 1, 'deviceReadConfirmed' => true, 'batteryW' => -123.0];
'sourceOldestAt' => time() - 1, 'deviceReadConfirmed' => true, 'batteryW' => -123.0,
'installationId' => $config['installationId'], 'assetId' => $config['assetId'],
'batteryInstanceId' => 44234, 'managerId' => 17004,
'configHash' => \Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung::fingerprint($config)];
$validBuffers = ['V4ConfirmedFeedback' => json_encode($sample, JSON_THROW_ON_ERROR),
'V4ConfirmedFeedbackConfig' => hash('sha256', $json),
'V4ConfirmedFeedbackMono' => (string) (hrtime(true) / 1e9)];
'V4ConfirmedFeedbackMono' => (string) (hrtime(true) / 1e9),
'V4ConfirmedFeedbackContext' => json_encode([
'configuration' => $json, 'propertyHash' => hash('sha256', $json), 'autoMode' => false,
], JSON_THROW_ON_ERROR)];
$battery->buffers = $validBuffers;
check($battery->GetV4BatterieRueckmeldungCache() === $validBuffers['V4ConfirmedFeedback'],
'cache-only API returns original confirmed sample and timestamps');
+3 -2
View File
@@ -347,7 +347,7 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringContainsString('EinspeiseRegler::stellwert(', $inhalt);
self::assertStringContainsString("RegisterAttributeString('LetztePvGrenzen'", $inhalt);
self::assertStringContainsString("RegisterAttributeString('Netzfahrplan'", $inhalt);
self::assertStringContainsString(
self::assertStringNotContainsString(
'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule',
$inhalt
);
@@ -359,7 +359,8 @@ final class ManagerModulstrukturTest extends TestCase
"foreach (['PeakShavingTitel', 'EinspeisebegrenzungAktiv', 'Einspeisegrenze']",
$inhalt
);
self::assertStringContainsString('aktuelleNetzfahrplanVorgabe()', $inhalt);
self::assertStringNotContainsString('aktuelleNetzfahrplanVorgabe()', $inhalt);
self::assertStringContainsString('korrigierePrognoseBatterie(', $inhalt);
self::assertStringContainsString('gibEinspeisebegrenzungFrei()', $inhalt);
self::assertStringNotContainsString("RegisterPropertyFloat('Einspeisetoleranz'", $inhalt);
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class ManagerPrognoseFormularTest extends TestCase
{
/** @dataProvider scripts */
public function testTwoSwitchWorkflow(string $script): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/PrognoseFormular/' . $script],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/Total: [0-9]+ checks\./', $output);
}
public function scripts(): array
{
return [['checks.php'], ['manager_checks.php']];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class ManagerPrognoseWorkflowTest extends TestCase
{
public function testIsolatedWorkflowAndDispatchContracts(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/PrognoseWorkflow/checks.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/TOTAL [0-9]+ workflow checks passed/', $output);
}
}
+5 -1
View File
@@ -23,7 +23,11 @@ final class NetzfahrplanV4BezugszaehlerTest extends TestCase
self::assertStringNotContainsString($forbidden, $source);
}
$trait = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
self::assertStringNotContainsString("'NetzbezugEnergieVariableID'", $trait);
// The explicitly configured physical import-energy source is now shared;
// legacy calculated energy archives and power-to-energy guesses are not.
self::assertStringContainsString("'NetzbezugEnergieVariableID'", $trait);
self::assertStringContainsString("'FaktorZuKWh' => \$this->ReadPropertyFloat('NetzbezugEnergiefaktor')", $trait);
self::assertStringNotContainsString("GetValue('NetzbezugEnergie')", $trait);
self::assertStringContainsString("RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]')", $trait);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4EinrichtungTest extends TestCase
{
public function testIsolatedOnboardingChecks(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4Setup/checks.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/TOTAL [0-9]+ setup checks passed/', $output);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4RegelungTest extends TestCase
{
public function testIsolatedProductionAllocationChecks(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4Control/checks.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/TOTAL [0-9]+ control checks passed/', $output);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4RueckmeldungEinrichtungTest extends TestCase
{
public function testIsolatedAutomaticFeedbackChecks(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4FeedbackSetup/checks.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/TOTAL [0-9]+ feedback setup checks passed/', $output);
}
}
+69 -51
View File
@@ -20,7 +20,9 @@ function check(bool $ok, string $name): void
function fields(array $form): array
{
foreach ($form['elements'] as $element) {
if (($element['name'] ?? '') === 'PrognoseForecast') {
if (($element['name'] ?? '') !== 'PrognoseForecast') {
continue;
}
$fields = [];
foreach ($element['items'] as $field) {
if (isset($field['name'])) {
@@ -32,79 +34,94 @@ function fields(array $form): array
}
return $fields;
}
}
throw new RuntimeException('Forecast panel missing');
}
$anzeigen = ManagerPrognoseFormular::laufzeitAnzeige();
check(count($anzeigen) === 4, 'only known forecast runtime objects are consolidated');
check(count($anzeigen) === 4, 'known historical runtime identifiers remain available');
foreach ($anzeigen as $ident => $name) {
check(str_starts_with($ident, 'NetzfahrplanV4'), 'stable existing identifier retained');
check(!str_contains($name, 'V4'), 'technical version removed from display name');
check(!str_contains($name, 'V4'), 'technical version removed from historical display name');
}
check(str_contains($anzeigen['NetzfahrplanV4Aktivtest'], 'Testbetrieb'), 'test limitation remains visible');
check(str_contains($anzeigen['NetzfahrplanV4Aktivtest'], 'Testbetrieb'), 'historical test object is not relabelled as production control');
$module = file_get_contents(__DIR__ . '/../../Manager/module.php');
check(str_contains($module, 'ManagerPrognoseFormular::laufzeitAnzeige()'), 'runtime display migration is applied');
check(str_contains($module, 'IPS_SetHidden($objektID, true)'), 'legacy diagnostics are hidden, not deleted');
check(str_contains($module, "RegisterPropertyBoolean('NetzfahrplanAktiv', false)"), 'legacy controller property remains stored');
check(str_contains($module, "RegisterPropertyInteger('PrognoseSendeintervall'"), 'internal sending interval remains stored');
$propertySources = $module;
foreach (glob(__DIR__ . '/../../libs/ManagerNetzfahrplanV4*Trait.php') ?: [] as $path) {
$propertySources .= file_get_contents($path);
}
foreach (['NetzfahrplanV4EmpfangAktiv', 'NetzfahrplanV4SchattenAktiv', 'NetzfahrplanV4MessdatenAktiv',
'NetzfahrplanV4RegeltestErlaubt', 'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'] as $property) {
check(preg_match("/RegisterPropertyBoolean\\(\\s*'" . $property . "'/", $propertySources) === 1,
'hidden technical property remains registered: ' . $property);
}
$base = json_decode(file_get_contents(__DIR__ . '/../../Manager/form.json'), true, 512, JSON_THROW_ON_ERROR);
$state = [
'prognoseAktiv' => true, 'lizenziert' => true, 'senderAktiv' => true, 'empfangAktiv' => true,
'messdatenAktiv' => true, 'legacyAktiv' => false, 'managerAktiv' => true, 'testErlaubt' => true,
'aktivtestErlaubt' => true, 'watchdogVerzichtErlaubt' => true, 'aktivtestAktiv' => false,
'ende' => 1791387487, 'jetzt' => 1791300000,
];
$original = $base;
$state = ['prognoseAktiv' => true, 'lizenziert' => true, 'ready' => true,
'controlRequested' => false, 'controlActive' => false, 'state' => 'ready', 'reason' => 'Lokale Pruefungen bestanden.'];
$form = ManagerPrognoseFormular::integrieren($base, $state);
$f = fields($form);
check(count($form['elements']) === count($base['elements']), 'no additional panel');
check(!str_contains(json_encode($form), 'V4 Planempfang'), 'separate V4 tab removed');
foreach (['PrognoseAktiv', 'NetzfahrplanAktiv', 'PrognoseSendeintervall', 'NetzfahrplanV4EmpfangAktiv',
'PrognoseNetzfahrplanStart', 'PrognoseNetzfahrplanStop', 'PrognoseNetzfahrplanPruefen',
'PrognoseLizenzVerwalten', 'PrognoseNetzfahrplanStatus'] as $name) {
check(isset($f[$name]), 'one forecast panel contains ' . $name);
$switches = array_keys(array_filter($f, static fn(array $field): bool => $field['type'] === 'CheckBox'));
check($switches === ['PrognoseAktiv', 'NetzfahrplanRegelungAktiv'], 'exactly learning and control switches are exposed');
check(str_contains($f['PrognoseAktiv']['onChange'], "'FormPrognoseAktiv'"), 'learning switch refreshes existing form guard');
check($f['NetzfahrplanRegelungAktiv']['enabled'] === true, 'ready licensed learning permits control request');
check(!isset($f['NetzfahrplanRegelungAktiv']['onChange']), 'control is a saved property, not a direct actuator action');
foreach (['NetzfahrplanAktiv', 'PrognoseSendeintervall', 'NetzfahrplanV4EmpfangAktiv',
'NetzfahrplanV4SchattenAktiv', 'NetzfahrplanV4MessdatenAktiv', 'NetzfahrplanV4RegeltestErlaubt',
'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt',
'PrognoseNetzfahrplanStart', 'PrognoseNetzfahrplanStop', 'PrognoseNetzfahrplanPruefen'] as $name) {
check(!isset($f[$name]), 'legacy or technical operator field removed: ' . $name);
}
$buttons = array_filter($f, static fn(array $field): bool => $field['type'] === 'Button');
check(array_keys($buttons) === ['PrognoseLizenzVerwalten'], 'only the portal link remains as a button');
check($buttons['PrognoseLizenzVerwalten']['link'] === true, 'portal button is a link');
check(str_contains($buttons['PrognoseLizenzVerwalten']['onClick'], 'https://license.enelix.ch'), 'existing license portal retained');
check(!str_contains($buttons['PrognoseLizenzVerwalten']['onClick'], 'IPS_RequestAction'), 'portal link cannot dispatch a command');
check($f['PrognoseNetzfahrplanStatus']['caption'] === 'Bereit: Lokale Pruefungen bestanden.', 'status displays current readiness and reason');
foreach ($base['elements'] as $index => $element) {
if (($element['name'] ?? '') !== 'PrognoseForecast') {
check($form['elements'][$index] === $element, 'unrelated panel unchanged: ' . $element['caption']);
check($form['elements'][$index] === $element, 'unrelated panel unchanged: ' . ($element['caption'] ?? $index));
}
}
foreach (['PrognoseAktiv', 'PrognoseSendeintervall', 'PrognoseLizenzInformation'] as $name) {
check($f[$name] === fields($base)[$name], 'existing forecast field unchanged: ' . $name);
foreach ([false, true] as $learning) {
foreach ([false, true] as $licensed) {
foreach ([false, true] as $ready) {
$changed = array_replace($state, ['prognoseAktiv' => $learning, 'lizenziert' => $licensed, 'ready' => $ready]);
$expected = $learning && $licensed && $ready;
check(ManagerPrognoseFormular::startErlaubt($changed) === $expected, 'all three start guards are required');
check(fields(ManagerPrognoseFormular::integrieren($base, $changed))['NetzfahrplanRegelungAktiv']['enabled'] === $expected,
'inactive control request follows start guard');
$changed['controlRequested'] = true;
check(fields(ManagerPrognoseFormular::integrieren($base, $changed))['NetzfahrplanRegelungAktiv']['enabled'] === true,
'requested control can be switched off despite any failed guard');
}
}
}
check($f['NetzfahrplanAktiv']['visible'] === false, 'inactive legacy control hidden for configured V4');
check($f['PrognoseNetzfahrplanStart']['enabled'], 'licensed existing consent enables explicit start');
check(str_contains($f['PrognoseNetzfahrplanStart']['onClick'], "'FormNetzfahrplanSchalten', true"), 'start uses guarded manager action');
check(str_contains($f['PrognoseNetzfahrplanStop']['onClick'], "'FormNetzfahrplanSchalten', false"), 'stop uses existing stop path');
check(str_contains($f['PrognoseNetzfahrplanPruefen']['onClick'], "'NetzfahrplanV4Empfangen'"), 'plan check uses existing throttled receiver');
check(str_contains($f['PrognoseLizenzVerwalten']['onClick'], 'https://license.enelix.ch'), 'existing license portal retained');
check(str_contains($f['PrognoseNetzfahrplanStatus']['caption'], '07.10.2026 15:38:07 UTC'), 'original expiry displayed without extension');
check(str_contains($f['PrognoseNetzfahrplanStatus']['caption'], 'Kein freigegebener Dauerbetrieb'), 'test limitation remains explicit');
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
'testErlaubt', 'aktivtestErlaubt', 'watchdogVerzichtErlaubt'] as $key) {
$blocked = array_replace($state, [$key => false]);
$b = fields(ManagerPrognoseFormular::integrieren($base, $blocked));
check(!$b['PrognoseNetzfahrplanStart']['enabled'], 'start blocked without ' . $key);
check($b['PrognoseNetzfahrplanStop']['enabled'], 'stop remains possible without ' . $key);
}
foreach ([['legacyAktiv' => true], ['aktivtestAktiv' => true], ['ende' => 1791300000], ['ende' => -1]] as $change) {
check(!ManagerPrognoseFormular::startErlaubt(array_replace($state, $change)), 'conflict, replay or expiry blocks start: ' . json_encode($change));
foreach (['prognoseAktiv', 'lizenziert', 'ready'] as $key) {
foreach ([null, 0, 1, 'true', 'false', []] as $invalid) {
check(!ManagerPrognoseFormular::startErlaubt(array_replace($state, [$key => $invalid])), 'nonboolean guard fails closed: ' . $key);
}
$missing = $state;
unset($missing[$key]);
check(!ManagerPrognoseFormular::startErlaubt($missing), 'missing guard fails closed: ' . $key);
}
check(!ManagerPrognoseFormular::startErlaubt([]), 'missing state fails closed');
check(ManagerPrognoseFormular::startErlaubt(array_replace($state, ['ende' => 0])), 'first start still uses existing backend consent procedure');
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, ['legacyAktiv' => true, 'lizenziert' => false])));
check($b['NetzfahrplanAktiv']['visible'] && $b['NetzfahrplanAktiv']['enabled'], 'active legacy controller can be disabled despite lost license');
check(str_contains($b['PrognoseNetzfahrplanStatus']['caption'], 'konkurrier') === false
&& str_contains($b['PrognoseNetzfahrplanStatus']['caption'], 'gesperrt'), 'controller conflict is visible');
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, ['lizenziert' => false])));
check($b['NetzfahrplanV4EmpfangAktiv']['enabled'], 'active receiver remains switchable off without license');
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, ['lizenziert' => false, 'empfangAktiv' => false])));
check(!$b['NetzfahrplanV4EmpfangAktiv']['enabled'], 'unlicensed receiver cannot be enabled through form');
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, [
'senderAktiv' => false, 'empfangAktiv' => false, 'messdatenAktiv' => false,
])));
check($b['NetzfahrplanAktiv'] === fields($base)['NetzfahrplanAktiv'], 'non-migrated installation retains legacy control');
foreach (['disabled' => 'Aus', 'collecting' => 'Daten sammeln', 'learning' => 'Lernen',
'ready' => 'Bereit', 'active' => 'Aktiv', 'interrupted' => 'Unterbrochen'] as $key => $label) {
check(ManagerPrognoseFormular::statustext(['state' => $key, 'reason' => 'Pruefgrund']) === $label . ': Pruefgrund',
'workflow state and reason visible: ' . $key);
}
check(str_starts_with(ManagerPrognoseFormular::statustext(['state' => 'future-state']), 'Status unbekannt:'), 'unknown workflow status is not reported as ready');
check(str_contains(ManagerPrognoseFormular::statustext([]), 'Aktuellen Status abwarten.'), 'missing reason has a waiting state');
$missing = $base;
$missing['elements'] = array_values(array_filter($missing['elements'], fn($e) => ($e['name'] ?? '') !== 'PrognoseForecast'));
$missing['elements'] = array_values(array_filter($missing['elements'], static fn(array $e): bool => ($e['name'] ?? '') !== 'PrognoseForecast'));
try {
ManagerPrognoseFormular::integrieren($missing, $state);
check(false, 'missing forecast anchor rejected');
@@ -123,5 +140,6 @@ try {
} catch (LogicException $e) {
check(true, 'duplicate forecast anchor rejected');
}
check($form === ManagerPrognoseFormular::integrieren($base, $state), 'repeated rendering is deterministic and does not modify the base form');
check($form === ManagerPrognoseFormular::integrieren($base, $state), 'repeated rendering is deterministic');
check($base === $original, 'base form and historical configuration are not mutated');
echo "Total: $count checks. Form generation only; no IPS or device calls.\n";
+48 -44
View File
@@ -26,14 +26,10 @@ require_once __DIR__ . '/../../Manager/module.php';
final class ManagerFormHarness extends Manager
{
public array $calls = [];
public bool $reject = false;
public function V4ManagerAktivtestSchalten(bool $aktiv): void
{
$this->calls[] = $aktiv;
if ($this->reject) {
throw new RuntimeException('Existing backend refused start');
}
$this->values['NetzfahrplanV4Aktivtest'] = $aktiv;
throw new RuntimeException('The normal form must never invoke the legacy trial controller.');
}
}
@@ -53,6 +49,7 @@ function fixture(): ManagerFormHarness
'NetzfahrplanV4EmpfangAktiv', 'NetzfahrplanV4MessdatenAktiv', 'NetzfahrplanV4RegeltestErlaubt',
'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'], true);
$h->props['NetzfahrplanAktiv'] = false;
$h->props['NetzfahrplanRegelungAktiv'] = false;
// Synthetic fixture, not a license or credential from an installation.
$h->props['Lizenzcode'] = 'ENX' . str_repeat('A', 16);
$h->attrs['LizenzcodeHash'] = hash('sha256', $h->props['Lizenzcode']);
@@ -67,6 +64,8 @@ function fixture(): ManagerFormHarness
],
], JSON_THROW_ON_ERROR);
$h->attrs['NetzfahrplanV4AktivtestEnde'] = time() + 600;
$h->attrs['PrognoseV4Serverstatus'] = json_encode(['receivedAt' => time(),
'workflow' => ['planningReady' => false, 'state' => 'collecting', 'reason' => 'Messdaten werden gesammelt.']], JSON_THROW_ON_ERROR);
$h->values = ['Aktiv' => true, 'NetzfahrplanV4Aktivtest' => false];
$GLOBALS['instanceStatus'] = 102;
return $h;
@@ -74,58 +73,63 @@ function fixture(): ManagerFormHarness
function rejected(ManagerFormHarness $h, $value, string $name): void
{
$before = [$h->props, $h->attrs, $h->values];
try {
$h->RequestAction('FormNetzfahrplanSchalten', $value);
} catch (RuntimeException | InvalidArgumentException $e) {
check($h->calls === [], $name);
check($h->calls === [] && [$h->props, $h->attrs, $h->values] === $before, $name);
return;
}
check(false, $name);
}
rejected(fixture(), true, 'old test permissions cannot bypass missing workflow readiness');
foreach ([1, 0, 'true', 'false', null, []] as $value) {
rejected(fixture(), $value, 'nonboolean compatibility action rejected: ' . gettype($value));
}
$h = fixture();
$before = [$h->props, $h->attrs];
$h->RequestAction('FormNetzfahrplanSchalten', true);
check($h->calls === [true], 'real manager action delegates exactly one explicit start');
check(!$h->fields['PrognoseNetzfahrplanStart']['enabled'], 'started session cannot be restarted from form');
check([$h->props, $h->attrs] === $before, 'form action changes no configuration, expiry or license');
$h->props['Lizenzcode'] = '';
$before = [$h->props, $h->attrs, $h->values];
$h->RequestAction('FormNetzfahrplanSchalten', false);
check($h->calls === [true, false], 'stop still delegates after license loss');
check(!$h->values['NetzfahrplanV4Aktivtest'], 'stop status is refreshed');
foreach ([1, 'true', 'false', null, []] as $value) {
rejected(fixture(), $value, 'nonboolean input rejected: ' . gettype($value));
}
foreach (['PrognoseAktiv', 'NetzfahrplanV4SchattenAktiv', 'NetzfahrplanV4EmpfangAktiv',
'NetzfahrplanV4RegeltestErlaubt', 'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'] as $prop) {
$h = fixture(); $h->props[$prop] = false;
rejected($h, true, 'server-side start guard checks ' . $prop);
}
check($h->fields['NetzfahrplanRegelungAktiv']['value'] === false, 'compatibility disable updates the saved-property form field');
check($h->calls === [], 'compatibility form action invokes no legacy trial controller');
check([$h->props, $h->attrs, $h->values] === $before, 'form preview changes no properties, authority or device values');
$h = fixture(); $h->props['Lizenzcode'] = '';
rejected($h, true, 'missing license cannot be bypassed by calling the action directly');
rejected($h, true, 'missing license cannot be bypassed through the old action');
$h->RequestAction('FormNetzfahrplanSchalten', false);
check($h->fields['NetzfahrplanRegelungAktiv']['value'] === false && $h->calls === [], 'disable preview remains possible after license loss');
$h = fixture(); $lease = json_decode($h->attrs['LizenzLease'], true);
$lease['entitlements'][1]['quantity'] = 0; $h->attrs['LizenzLease'] = json_encode($lease);
rejected($h, true, 'manager license alone does not grant grid schedule');
$h = fixture(); $h->props['NetzfahrplanAktiv'] = true;
rejected($h, true, 'competing legacy controller blocks action');
$h = fixture(); $h->attrs['NetzfahrplanV4AktivtestEnde'] = time() - 1;
rejected($h, true, 'expired consent cannot be renewed by the form');
$h = fixture(); $GLOBALS['instanceStatus'] = 203;
rejected($h, true, 'inactive or faulty manager cannot start');
$h = fixture(); $h->values['Aktiv'] = false;
rejected($h, true, 'disabled manager cannot start');
$h = fixture(); $h->values['NetzfahrplanV4Aktivtest'] = true;
rejected($h, true, 'existing session cannot be restarted');
$h = fixture(); $h->reject = true;
try {
$h->RequestAction('FormNetzfahrplanSchalten', true);
check(false, 'backend rejection preserved');
} catch (RuntimeException $e) {
check($e->getMessage() === 'Existing backend refused start' && $h->calls === [true], 'backend rejection preserved without retry or bypass');
}
$h = fixture(); $h->RequestAction('FormPrognoseAktiv', false);
check(!$h->fields['PrognoseNetzfahrplanStart']['enabled'] && $h->calls === [], 'unsaved forecast disable prevents start without a control call');
$h = fixture(); $h->props['PrognoseAktiv'] = false;
rejected($h, true, 'stored learning consent is required');
$h = fixture(); $h->attrs['PrognoseV4Serverstatus'] = '{}';
rejected($h, true, 'missing server workflow is not ready');
$h = fixture(); $h->attrs['PrognoseV4Serverstatus'] = json_encode(['receivedAt' => time() - 100,
'workflow' => ['planningReady' => true]], JSON_THROW_ON_ERROR);
rejected($h, true, 'stale server readiness cannot start control');
$h = fixture(); $h->attrs['PrognoseV4Einrichtungsfehler'] = 'Batteriezuordnung unvollstaendig.';
rejected($h, true, 'setup errors block the compatibility start action');
$h = fixture();
$h->RequestAction('FormPrognoseAktiv', false);
check(!$h->fields['NetzfahrplanRegelungAktiv']['enabled'] && $h->calls === [], 'unsaved learning disable prevents a new control request without dispatch');
check($h->fields['NetzfahrplanRegelungAktiv']['value'] === false, 'learning disable clears the form control request');
$h = fixture(); $h->props['PrognoseAktiv'] = false;
$h->RequestAction('FormPrognoseAktiv', true);
check(!$h->fields['PrognoseNetzfahrplanStart']['enabled'] && $h->calls === [], 'unsaved forecast enable does not grant stored permission');
echo "Total: $count checks. Real RequestAction and license checks; mocked IPS and control delegate.\n";
check(!$h->fields['NetzfahrplanRegelungAktiv']['enabled'] && $h->calls === [], 'unsaved learning enable does not grant stored readiness');
foreach (['license', 'setup', 'learning'] as $failure) {
$h = fixture(); $h->props['NetzfahrplanRegelungAktiv'] = true;
if ($failure === 'license') $h->props['Lizenzcode'] = '';
if ($failure === 'setup') $h->attrs['PrognoseV4Einrichtungsfehler'] = 'Zuordnung fehlt.';
if ($failure === 'learning') $h->props['PrognoseAktiv'] = false;
$h->RequestAction('FormPrognoseAktiv', false);
check($h->fields['NetzfahrplanRegelungAktiv']['enabled'] === true && $h->calls === [],
'already requested control remains switchable off after ' . $failure . ' failure');
}
$h = fixture(); $h->RequestAction('FormPrognoseAktiv', true);
check(str_contains($h->fields['PrognoseNetzfahrplanStatus']['caption'], 'Messdaten werden gesammelt.'), 'manager form displays the current workflow reason');
foreach (['PrognoseNetzfahrplanStart', 'PrognoseNetzfahrplanStop', 'PrognoseNetzfahrplanPruefen', 'NetzfahrplanV4EmpfangAktiv'] as $name) {
check(!isset($h->fields[$name]), 'form refresh no longer updates removed field: ' . $name);
}
echo "Total: $count checks. Real RequestAction and license checks; mocked IPS, no device calls.\n";
+282
View File
@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/../../libs/ManagerPrognoseWorkflowTrait.php';
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4DatenTrait.php';
require_once __DIR__ . '/../../libs/Lizenzpruefung.php';
require_once __DIR__ . '/../V4Receiver/fixture.php';
function time(): int { return $GLOBALS['workflowTime']; }
function gmdate(string $format, ?int $at = null): string { return \gmdate($format, $at ?? time()); }
function IPS_SemaphoreEnter(string $name, int $wait): bool { return true; }
function IPS_SemaphoreLeave(string $name): void { $GLOBALS['workflowUnlocks'][] = $name; }
function IPS_VariableExists(int $id): bool { return isset($GLOBALS['workflowObjects'][$id]); }
function IPS_GetObject(int $id): array { return $GLOBALS['workflowObjects'][$id]; }
function IPS_GetVariable(int $id): array { return ['VariableType' => 2]; }
function IPS_InstanceExists(int $id): bool { return isset($GLOBALS['workflowInstances'][$id]); }
function IPS_GetInstance(int $id): array { return $GLOBALS['workflowInstances'][$id]; }
function IPS_GetProperty(int $id, string $key) { return $GLOBALS['workflowProperties'][$id][$key] ?? null; }
function IPS_RequestAction(int $id, string $action, $value): bool
{
$GLOBALS['workflowActions'][] = ['id' => $id, 'action' => $action, 'value' => json_decode($value, true, 64, JSON_THROW_ON_ERROR)];
return !in_array($id, $GLOBALS['workflowReject'] ?? [], true);
}
final class WorkflowHarness
{
use ManagerPrognoseWorkflowTrait;
use ManagerNetzfahrplanV4DatenTrait;
public int $InstanceID = 900;
public array $properties = [];
public array $attributes = [];
public array $values = ['Aktiv' => true, 'NetzfahrplanV4Aktivtest' => false];
public array $timers = [];
public array $requests = [];
public array $session = [];
public array $operation = [];
public array $actual = [];
public bool $licensed = true;
public ?string $httpError = null;
public ?string $selectedDataset = null;
public int $uiUpdates = 0;
public function __construct()
{
$this->registrierePrognoseWorkflow();
$this->registriereNetzfahrplanV4Daten();
[$response, $id, $operation, $now] = \receiverFixture();
$GLOBALS['workflowTime'] = $now;
$id = '11111111-2222-4333-8444-555555555555';
$response['installationId'] = $response['plan']['installationId'] = $id;
$response['plan']['inputQuality'] = ['loadBasis' => 'base_load', 'accountingEvidenceId' => 'physical-map-v1'];
$this->properties += ['PrognoseAktiv' => true, 'NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0,
'MesswertMaxAlter' => 60, 'SDLAktiv' => false, 'SDLLeistungVariableID' => 0,
'AnlagenWechselrichter' => json_encode([
['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1.0],
['ID' => 'battery-wr', 'Typ' => 'battery'],
], JSON_THROW_ON_ERROR),
'AnlagenBatterien' => json_encode([['ID' => 'battery', 'WechselrichterID' => 'battery-wr',
'Kopplung' => 'ac', 'LeistungVariableID' => 103, 'Leistungsfaktor' => 1.0]], JSON_THROW_ON_ERROR)];
$this->attributes += ['LizenzInstallationID' => $id, 'PrognoseInstallationsToken' => ''];
$this->attributes['NetzfahrplanV4Empfang'] = json_encode(['response' => $response, 'receivedAt' => $now], JSON_THROW_ON_ERROR);
$operation['localFeedback'] = ['b' => [
'version' => 1, 'kind' => 'local_physical_feedback', 'installationId' => $id, 'managerId' => 900,
'assetId' => 'b', 'batteryInstanceId' => 901, 'checkedAt' => $now, 'sourceOldestAt' => $now,
'maxAgeSeconds' => 60, 'configHash' => str_repeat('a', 64), 'gridW' => $operation['gridW'],
'batteryW' => 0.0, 'estimated' => false, 'separatelyMeasured' => true,
'deviceReadConfirmed' => true, 'usableForTrial' => true,
]];
$this->operation = $operation;
$this->actual = ['b' => 0.0];
$GLOBALS['workflowObjects'] = [];
$GLOBALS['workflowInstances'] = [
900 => ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => 'manager-module']],
901 => ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => 'battery-module']],
902 => ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => 'charger-module']],
];
foreach ([101 => 'Grid', 102 => 'PV', 103 => 'Battery'] as $source => $ident) {
$GLOBALS['workflowObjects'][$source] = ['ParentID' => $source + 100, 'ObjectIdent' => $ident, 'ObjectType' => 2];
$GLOBALS['workflowInstances'][$source + 100] = ['InstanceStatus' => 102,
'ModuleInfo' => ['ModuleID' => NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE]];
}
$GLOBALS['workflowProperties'] = [901 => ['NetzfahrplanV4GeraeteWatchdogNachweis' => 'device-watchdog-reviewed-v1']];
$GLOBALS['workflowActions'] = [];
$GLOBALS['workflowReject'] = [];
}
public function RegisterPropertyBoolean(string $name, bool $value): void { $this->properties[$name] ??= $value; }
public function RegisterPropertyString(string $name, string $value): void { $this->properties[$name] ??= $value; }
public function RegisterAttributeString(string $name, string $value): void { $this->attributes[$name] ??= $value; }
public function RegisterAttributeInteger(string $name, int $value): void { $this->attributes[$name] ??= $value; }
public function RegisterTimer(string $name, int $value, string $script): void { $this->timers[$name] = $value; }
public function RegisterVariableString(string $name, string $caption, string $profile, int $position): void {}
public function ReadPropertyBoolean(string $name): bool { return $this->properties[$name] ?? false; }
public function ReadPropertyString(string $name): string { return $this->properties[$name] ?? ''; }
public function ReadPropertyInteger(string $name): int { return $this->properties[$name] ?? 0; }
public function ReadPropertyFloat(string $name): float { return $this->properties[$name] ?? 0.0; }
public function ReadAttributeString(string $name): string { return $this->attributes[$name] ?? ''; }
public function ReadAttributeInteger(string $name): int { return $this->attributes[$name] ?? 0; }
public function WriteAttributeString(string $name, string $value): void { $this->attributes[$name] = $value; }
public function WriteAttributeInteger(string $name, int $value): void { $this->attributes[$name] = $value; }
public function SetTimerInterval(string $name, int $value): void { $this->timers[$name] = $value; }
public function GetValue(string $name) { return $this->values[$name] ?? false; }
public function SetValue(string $name, $value): void { $this->values[$name] = $value; }
private function leseJsonAttribut(string $name): array { return json_decode($this->ReadAttributeString($name) ?: '{}', true, 64, JSON_THROW_ON_ERROR); }
private function berechtigungLizenziert(string $capability): bool { return $this->licensed; }
private function aktiveVerbraucherIDs(): array { return [901, 902]; }
private function v4ManagerTestSession(): array { return $this->session; }
private function netzfahrplanV4LokaleVorschauwerte(): array { return [$this->operation, $this->actual]; }
private function aktualisierePrognoseBedienung(): void { ++$this->uiUpdates; }
private function prognoseWorkflowHttp(string $suffix, array $body): array
{
$this->requests[] = ['suffix' => $suffix, 'body' => $body];
if ($this->httpError !== null) throw new RuntimeException($this->httpError);
if ($suffix === '/setup') {
return ['datasetId' => $body['dataset']['datasetId'], 'selectedDatasetId' => $this->selectedDataset ?? $body['dataset']['datasetId']];
}
return ['status' => 'recorded'];
}
public function server(bool $ready, string $state = 'ready', ?int $at = null): void
{
$this->attributes['PrognoseV4Serverstatus'] = json_encode(['receivedAt' => $at ?? time(),
'workflow' => ['planningReady' => $ready, 'state' => $state, 'reason' => 'server-' . $state]], JSON_THROW_ON_ERROR);
}
public function state(): array { return $this->prognoseWorkflowZustand(); }
public function prepare(): void { $this->vorbereitePrognoseWorkflow(); }
public function tick(): void { $this->aktualisierePrognoseWorkflow(); }
public function dataset(): string { return $this->prognoseV4Datensatz(); }
public function mapping(): string { return $this->prognoseV4Messkonfiguration(); }
public function correct(array &$allocation): ?array { return $this->korrigierePrognoseBatterie($allocation, [], true); }
}
$count = 0;
function workflowCheck(bool $ok, string $label): void
{
global $count;
if (!$ok) throw new RuntimeException('FAIL ' . $label);
++$count;
echo 'PASS ' . $label . "\n";
}
$h = new WorkflowHarness();
$h->server(false, 'collecting');
workflowCheck($h->state()['state'] === 'collecting' && !$h->state()['ready'], 'model not ready does not enable control');
$h->properties['NetzfahrplanRegelungAktiv'] = true;
$a = ['Sollwerte' => [901 => 1200, 902 => 5000]]; $old = $a;
workflowCheck($h->correct($a) === null && $a === $old, 'persisted control request cannot bypass missing model');
$h->properties['NetzfahrplanAktiv'] = true;
$h->properties['NetzfahrplanV4RegeltestErlaubt'] = true;
$h->properties['NetzfahrplanV4AktivtestErlaubt'] = true;
workflowCheck(!$h->state()['ready'] && !$h->state()['controlActive'], 'old flags do not promote an unready new workflow');
$h = new WorkflowHarness(); $h->server(false, 'learning');
workflowCheck($h->state()['state'] === 'learning', 'learning state remains visible before readiness');
$h = new WorkflowHarness(); $h->server(true);
workflowCheck($h->state()['state'] === 'ready' && $h->state()['ready'] && !$h->state()['controlActive'], 'ready model and physical guards still do not activate control');
$h->properties['NetzfahrplanRegelungAktiv'] = true;
$h->attributes['PrognoseV4Regelstatus'] = json_encode(['controlActive' => true, 'at' => time()], JSON_THROW_ON_ERROR);
workflowCheck($h->state()['state'] === 'active' && $h->state()['controlActive'], 'fresh local delivery status required for active state');
$h->attributes['PrognoseV4Regelstatus'] = json_encode(['controlActive' => true, 'at' => time() - 11], JSON_THROW_ON_ERROR);
workflowCheck($h->state()['state'] === 'interrupted' && !$h->state()['controlActive'], 'stale active status expires');
$h->server(true, 'ready', time() - 91);
workflowCheck(!$h->state()['ready'] && !$h->state()['controlActive'], 'stale server status blocks activation');
$h->server(true, 'ready', time() + 1);
workflowCheck(!$h->state()['ready'], 'future server status blocked');
$h = new WorkflowHarness(); $h->server(true); $h->licensed = false;
workflowCheck($h->state()['state'] === 'interrupted' && !$h->state()['ready'], 'missing licence blocks readiness');
$h->tick();
workflowCheck($h->requests === [] && $h->uiUpdates === 1, 'no unlicensed server traffic and visible UI refresh');
$h = new WorkflowHarness(); $h->server(true); $h->properties['PrognoseAktiv'] = false;
$h->properties['NetzfahrplanRegelungAktiv'] = true;
workflowCheck($h->state()['state'] === 'disabled' && !$h->state()['ready'], 'learning switch off prevents control');
$h->tick();
workflowCheck(count($h->requests) === 1 && $h->requests[0]['suffix'] === '/workflow'
&& !$h->requests[0]['body']['learningEnabled'] && !$h->requests[0]['body']['controlActive'], 'learning off is reported without setup or activation');
$h = new WorkflowHarness(); $h->server(true); $h->values['Aktiv'] = false;
workflowCheck(!$h->state()['ready'], 'inactive manager blocks readiness');
$h = new WorkflowHarness(); $h->server(true); $h->session = ['sessionId' => 'existing'];
workflowCheck(!$h->state()['ready'], 'separate commissioning session cannot run alongside ordinary control');
$h = new WorkflowHarness(); $h->server(true); $h->values['NetzfahrplanV4Aktivtest'] = true;
workflowCheck(!$h->state()['ready'], 'old active test is not ordinary control permission');
$h = new WorkflowHarness(); $h->server(true);
$GLOBALS['workflowProperties'][901]['NetzfahrplanV4GeraeteWatchdogNachweis'] = 'explicit_test_plant_watchdog_waiver';
workflowCheck(!$h->state()['ready'], 'historical watchdog waiver cannot activate ordinary control');
$h = new WorkflowHarness(); $h->server(true);
$h->operation['localFeedback']['b']['checkedAt'] -= 3;
workflowCheck(!$h->state()['ready'], 'stale physical feedback blocks ready despite healthy server');
$h = new WorkflowHarness(); $h->server(true);
$h->attributes['PrognoseV4Einrichtungsfehler'] = 'mapping failed';
workflowCheck($h->state()['reason'] === 'mapping failed' && !$h->state()['ready'], 'setup error cannot preserve readiness');
$h = new WorkflowHarness(); $h->server(true); $h->httpError = 'synthetic HTTP failure';
$h->tick();
workflowCheck($h->state()['state'] === 'interrupted' && !$h->state()['ready'], 'connection failure prevents active state');
$h->httpError = null; $h->tick();
workflowCheck($h->attributes['PrognoseV4Verbindungsfehler'] === '' && $h->state()['ready'], 'successful report recovers connection status');
$h = new WorkflowHarness(); $h->prepare();
workflowCheck($h->attributes['PrognoseV4Einrichtungsfehler'] === '' && str_starts_with($h->dataset(), 'v4-'), 'ordinary metered plant automatically configured');
$automatic = $h->attributes['PrognoseV4Einrichtung'];
$h->tick();
workflowCheck(array_column($h->requests, 'suffix') === ['/setup', '/workflow'], 'automatic dataset registered before workflow report');
$h->requests = []; $h->tick();
workflowCheck(array_column($h->requests, 'suffix') === ['/workflow'], 'unchanged setup throttled');
$h->properties['Netzleistungsfaktor'] = 1000.0; $h->prepare();
workflowCheck($h->attributes['PrognoseV4Einrichtung'] === $automatic && $h->attributes['PrognoseV4Einrichtungsfehler'] !== '', 'changed units hold while original setup remains preserved');
$h = new WorkflowHarness();
$GLOBALS['workflowInstances'][203]['ModuleInfo']['ModuleID'] = 'foreign-virtual-storage-module';
$h->prepare();
workflowCheck($h->attributes['PrognoseV4Einrichtungsfehler'] !== '' && $h->attributes['PrognoseV4Einrichtung'] === '{}', 'foreign library is not physical measurement evidence');
$h = new WorkflowHarness(); $h->prepare();
$automatic = $h->attributes['PrognoseV4Einrichtung'];
$mapping = $h->mapping();
$h->properties['NetzfahrplanV4Messkonfiguration'] = $mapping;
$h->properties['NetzfahrplanV4Datensatz'] = 'specialist-existing-v2';
$h->prepare(); $h->tick();
workflowCheck($h->mapping() === $mapping && $h->dataset() === 'specialist-existing-v2', 'custom mapping and dataset retained byte for byte');
workflowCheck($h->attributes['PrognoseV4Einrichtung'] === $automatic, 'historical automatic setup preserved for diagnosis');
workflowCheck(array_column($h->requests, 'suffix') === ['/workflow'], 'custom mapping never re-registers old automatic dataset');
$h->properties['NetzfahrplanV4Datensatz'] = ''; $h->prepare();
workflowCheck($h->attributes['PrognoseV4Einrichtungsfehler'] !== '', 'custom mapping without explicit dataset cannot reuse old automatic identity');
$h = new WorkflowHarness(); $h->prepare(); $h->selectedDataset = 'existing-server-choice'; $h->tick();
workflowCheck($h->attributes['PrognoseV4Verbindungsfehler'] !== '' && count($h->requests) === 1, 'server dataset choice is never silently overwritten or reported ready');
// Execute the actual Manager send block and sender method in a fake-only harness.
$module = file_get_contents(__DIR__ . '/../../Manager/module.php');
$start = strpos($module, ' $this->v4ManagerTestUebergabe($ergebnis, $verbraucher, $synchronisiert);');
$end = strpos($module, ' $this->aktualisiereStoerungen($verbraucher, $stoerungen);', $start);
$methodStart = strpos($module, ' private function sendeManagerdaten(');
$methodEnd = strpos($module, "\n /**", $methodStart);
if ($start === false || $end === false || $methodStart === false || $methodEnd === false) throw new RuntimeException('Manager source boundary changed');
$block = substr($module, $start, $end - $start);
$method = substr($module, $methodStart, $methodEnd - $methodStart);
class WorkflowSendBase
{
public int $InstanceID = 900;
public array $attributes = ['LetzteSollwerte' => '{}'];
public array $logs = [];
protected function v4ManagerTestUebergabe(array &$allocation, array $consumers, bool $sync): void {}
protected function korrigierePrognoseBatterie(array &$allocation, array $consumers, bool $sync): ?array
{
$allocation['Sollwerte'][901] = 2000;
return ['batteryInstanceId' => 901, 'targetW' => 2000, 'physicalResponseVerified' => false];
}
protected function leseJsonAttribut(string $name): array { return json_decode($this->attributes[$name], true, 64, JSON_THROW_ON_ERROR); }
protected function WriteAttributeString(string $name, string $value): void { $this->attributes[$name] = $value; }
protected function v4ManagerTestAbbrechen(string $reason): void {}
protected function protokolliere(string $kind, array $data): void { $this->logs[] = $data; }
}
eval('namespace Belevo\\EnelixEMS; use RuntimeException; use Throwable; class WorkflowSendHarness extends WorkflowSendBase {'
. 'public function run(array $ergebnis): array { $verbraucher=[]; $synchronisiert=true; $erneuern=false;'
. '$betriebsart="PV"; $stoerungen=[];' . $block . 'return $ergebnis; }' . $method . '}');
$setup = new WorkflowHarness();
$sender = new WorkflowSendHarness();
$sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]);
workflowCheck(array_column($GLOBALS['workflowActions'], 'id') === [902, 901], 'other commands sent before corrected battery');
workflowCheck($GLOBALS['workflowActions'][1]['value']['Sollleistung_W'] === 2000, 'successful handoff uses corrected battery target');
workflowCheck(json_decode($sender->attributes['PrognoseV4Regelstatus'], true)['controlActive'] === true, 'active only after successful sends');
$GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = [902];
$sender = new WorkflowSendHarness();
$sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]);
$report = json_decode($sender->attributes['PrognoseV4Regelstatus'], true);
workflowCheck(array_column($GLOBALS['workflowActions'], 'id') === [902, 901]
&& $GLOBALS['workflowActions'][1]['value']['Sollleistung_W'] === 1500, 'failed other command falls back to original ordinary battery target');
workflowCheck(!$report['controlActive'] && $report['fallbackTargetW'] === 1500, 'fallback is not reported as active V4');
workflowCheck(!array_key_exists(902, json_decode($sender->attributes['LetzteSollwerte'], true)), 'false device return does not advance accepted target');
workflowCheck(count($sender->logs) === 1 && $sender->logs[0]['InstanzID'] === 901, 'rejected handoff is not logged as successful');
$GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = [901];
$sender = new WorkflowSendHarness();
$sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]);
workflowCheck(!json_decode($sender->attributes['PrognoseV4Regelstatus'], true)['controlActive'], 'false battery return cannot claim active control');
workflowCheck(!array_key_exists(901, json_decode($sender->attributes['LetzteSollwerte'], true)), 'false battery return retains previous accepted target');
$GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = [];
$sender = new WorkflowSendHarness();
$sender->attributes['LetzteSollwerte'] = json_encode([901 => 2000, 902 => 5000], JSON_THROW_ON_ERROR);
$sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]);
workflowCheck(array_column($GLOBALS['workflowActions'], 'id') === [901], 'active battery target renewed even if value unchanged');
echo "TOTAL $count workflow checks passed; fake clocks, transport and Symcon calls only.\n";
+7 -4
View File
@@ -26,7 +26,7 @@ function curl_close($h):void {}
class Lizenzpruefung {const NETZFAHRPLAN='test-only';}
class FakeManager {
use ManagerNetzfahrplanV4DatenTrait;
public int $InstanceID=17004;public array $p=[],$a=[],$vars=[],$timers=[];public bool $licensed=true;
public int $InstanceID=17004;public array $p=['PrognoseAktiv'=>false],$a=['PrognoseV4Einrichtungsfehler'=>''],$vars=[],$timers=[];public bool $licensed=true;
public function boot():void {$this->registriereNetzfahrplanV4Daten();}
public function configure():void {$this->konfiguriereNetzfahrplanV4Daten();}
public function folder():string {return $this->v4DatenVerzeichnis();}
@@ -46,6 +46,9 @@ class FakeManager {
public function WriteAttributeInteger($k,$v){$this->a[$k]=$v;}
public function SetValue($k,$v){if($k!=='NetzfahrplanV4Datenstatus')throw new \RuntimeException('Unexpected variable write');$this->vars[$k]=$v;}
private function berechtigungLizenziert($k){return $this->licensed;}
private function prognoseLernenAktiv(){return $this->p['PrognoseAktiv'];}
private function prognoseV4Datensatz(){return $this->p['NetzfahrplanV4Datensatz'];}
private function prognoseV4Messkonfiguration(){return $this->p['NetzfahrplanV4Messkonfiguration'];}
}
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,
'reportedInventorySha256'=>str_repeat('a',64),'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]],'extraSources'=>[]];
@@ -54,8 +57,8 @@ foreach(['grid','pv','physical_storage','controlled_virtual','external_virtual']
$sensor[$id]=['value'=>[3000,5000,2000,2000,0][$i],'updated'=>$clock,'changed'=>$clock];}
$m=new FakeManager();$m->boot();$m->a['LizenzInstallationID']=$c['installationId'];$m->a['PrognoseInstallationsToken']='synthetic-not-a-real-token';
try {
$m->ErfasseNetzfahrplanV4Daten();check($sent===[],'disabled by default makes no network request');
$m->p['NetzfahrplanV4MessdatenAktiv']=true;$m->p['NetzfahrplanV4Datensatz']='physical-v1';$m->p['NetzfahrplanV4Messkonfiguration']=json_encode($c);
$m->ErfasseNetzfahrplanV4Daten();check($sent===[],'learning disabled makes no network request');
$m->p['PrognoseAktiv']=true;$m->p['NetzfahrplanV4Datensatz']='physical-v1';$m->p['NetzfahrplanV4Messkonfiguration']=json_encode($c);
$m->configure();check($m->timers['NetzfahrplanV4DatenErfassen']===30000,'only application acquisition timer enabled');
$m->ErfasseNetzfahrplanV4Daten();check(count($sent)===1,'native capture delivered to existing device API');
$cursor=$m->a['NetzfahrplanV4DatenCursor'];check(json_decode($cursor,true)['offset']>0,'matching ack commits durable byte cursor');
@@ -86,7 +89,7 @@ try {
$err=json_decode($m->vars['NetzfahrplanV4Datenstatus'],true);
check($err['status']==='capture_error'&&$err['delivery']['status']==='acknowledged','capture failure does not deadlock pending delivery');
check(count($sent)===$beforeCount+1&&str_ends_with(file_get_contents($file),'crash-tail'),'complete prefix sent while original incomplete tail retained');
$m->p['NetzfahrplanV4MessdatenAktiv']=false;$m->configure();check($m->timers['NetzfahrplanV4DatenErfassen']===0,'disable affects acquisition only');
$m->p['PrognoseAktiv']=false;$m->configure();check($m->timers['NetzfahrplanV4DatenErfassen']===0,'learning switch disables acquisition without discarding history');
check(count($m->vars)===1,'only own manager data-status variable written');
$text=file_get_contents(__DIR__.'/../../libs/ManagerNetzfahrplanV4DatenTrait.php');
check(!preg_match('/\b(?:RequestAction\(|IPS_ApplyChanges\(|MC_Reload\(|schreibeRegister\()/',$text),'data path contains no actuator/module reload calls');
+206
View File
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../libs/NetzfahrplanV4Regelung.php';
require_once __DIR__ . '/../V4Receiver/fixture.php';
use Belevo\EnelixEMS\Nachrichtenvertrag as Message;
use Belevo\EnelixEMS\NetzfahrplanV4Regelung as Control;
use Belevo\EnelixEMS\NetzfahrplanV4Planpruefung as Plan;
function controlConsumer(int $id, int $manager, int $now, int $actual, array $offers): array
{
return ['InstanzID' => $id, 'Daten' => [
'Kopf' => ['Version' => Message::VERSION, 'AbsenderID' => $id, 'EmpfaengerID' => $manager, 'Zeitpunkt' => $now],
'Betriebsart' => Message::BETRIEBSART_PV, 'PrioritaetPV' => 1, 'PrioritaetPeak' => 1,
'Leistungswerte_W' => $offers, 'AenderungMoeglich' => true, 'Verfuegbar' => true,
'Istleistung_W' => $actual, 'Leistungsquelle' => Message::LEISTUNGSQUELLE_GEMESSEN,
'Zustand' => [['Kennung' => 'Sollleistung_W', 'Art' => 'Sollwert', 'Wert' => $actual, 'Einheit' => 'W']],
]];
}
function controlFixture(): array
{
[$response, $installation, $op, $now] = receiverFixture();
$manager = 900;
$battery = 901;
$binding = ['batteryInstanceId' => $battery, 'assetId' => 'b', 'watchdogEvidence' => 'device-watchdog-reviewed-v1',
'feedbackConfigHash' => str_repeat('a', 64)];
$response['plan']['inputQuality'] = ['loadBasis' => 'base_load', 'accountingEvidenceId' => 'physical-meter-map-v1'];
$feedback = ['version' => 1, 'kind' => 'local_physical_feedback', 'installationId' => $installation,
'assetId' => 'b', 'managerId' => $manager, 'batteryInstanceId' => $battery, 'checkedAt' => $now,
'sourceOldestAt' => $now, 'sourceNewestAt' => $now, 'physicalOldestAt' => $now,
'deviceReadConfirmed' => true, 'maxAgeSeconds' => 60, 'configHash' => str_repeat('a', 64),
'gridW' => $op['gridW'], 'physicalStorageW' => 0.0, 'batteryW' => 0.0,
'estimated' => false, 'separatelyMeasured' => true, 'usableForTrial' => true, 'canDispatch' => false];
$op['localFeedback'] = ['b' => $feedback];
return ['envelope' => ['response' => $response, 'receivedAt' => $now], 'installation' => $installation,
'manager' => $manager, 'operation' => $op, 'actual' => ['b' => 0.0],
'allocation' => ['Verteilbudget_W' => 6500, 'Restabweichung_W' => 0, 'Sollwerte' => [901 => 1500, 902 => 5000]],
'consumers' => [controlConsumer($battery, $manager, $now, 0, [['Von_W' => -39000, 'Bis_W' => 39000]]),
controlConsumer(902, $manager, $now, 4000, [['Von_W' => 0, 'Bis_W' => 11000]])],
'synchronized' => true, 'binding' => $binding, 'now' => $now];
}
function controlRun(array $f): array
{
return Control::korrigieren($f['envelope'], $f['installation'], $f['manager'], $f['operation'], $f['actual'],
$f['allocation'], $f['consumers'], $f['synchronized'], $f['binding'], $f['now']);
}
function controlReady(array $f): array
{
return Control::pruefen($f['envelope'], $f['installation'], $f['manager'], $f['operation'], $f['actual'], $f['binding'], $f['now']);
}
function controlContext(array &$f): void
{
$f['envelope']['response']['plan']['controlContext'] = Plan::kontext($f['operation']);
}
$count = 0;
function controlCheck(bool $ok, string $label): void
{
global $count;
if (!$ok) {
throw new RuntimeException('FAIL ' . $label);
}
++$count;
echo 'PASS ' . $label . "\n";
}
function controlRefuses(callable $call, string $label): void
{
try {
$call();
} catch (InvalidArgumentException $e) {
controlCheck(true, $label);
return;
}
throw new RuntimeException('Expected refusal: ' . $label);
}
$f = controlFixture();
$original = $f;
$r = controlRun($f);
controlCheck($r['allocation']['Sollwerte'][901] === 2000, 'battery follows grid target after other consumer delta');
controlCheck($r['allocation']['Sollwerte'][902] === 5000, 'other consumer target remains exact');
controlCheck($r['allocation']['Restabweichung_W'] === -500, 'allocation arithmetic reflects battery correction');
controlCheck($r['report']['expectedGridW'] === 0.0 && $r['report']['otherConsumerPredictedDeltaW'] === 1000.0, 'other consumer measured delta counted once');
controlCheck($r['report']['batteryInstanceId'] === 901 && $r['report']['assetId'] === 'b', 'report bound to selected battery');
controlCheck(!$r['report']['physicalResponseVerified'], 'calculation never claims physical response');
controlCheck($f === $original, 'inputs and source timestamps never changed');
$ready = controlReady($f);
controlCheck($ready['preview']['previewBatteryW'] === 3000.0 && !$ready['preview']['canDispatch'], 'readiness checks no actuator or other allocation');
controlCheck($ready['preview']['maxChargeW'] === 39000.0 && $ready['preview']['maxDischargeW'] === 39000.0, 'preview reports bounded charge/discharge envelope');
$x = $f; $x['synchronized'] = false;
controlRefuses(static fn() => controlRun($x), 'unsynchronized consumers blocked');
$x = $f; unset($x['operation']['localFeedback']);
controlRefuses(static fn() => controlReady($x), 'missing verified feedback blocks readiness');
foreach (['estimated' => true, 'separatelyMeasured' => false, 'deviceReadConfirmed' => false, 'usableForTrial' => false] as $key => $value) {
$x = $f; $x['operation']['localFeedback']['b'][$key] = $value;
controlRefuses(static fn() => controlRun($x), 'feedback gate ' . $key);
}
foreach (['checkedAt' => $f['now'] - 3, 'sourceOldestAt' => $f['now'] - 61, 'maxAgeSeconds' => 61] as $key => $value) {
$x = $f; $x['operation']['localFeedback']['b'][$key] = $value;
controlRefuses(static fn() => controlReady($x), 'stale or relaxed feedback gate ' . $key);
}
$x = $f; $x['operation']['localFeedback']['b']['checkedAt']++;
controlRefuses(static fn() => controlRun($x), 'future feedback rejected');
foreach (['managerId' => 800, 'batteryInstanceId' => 903, 'assetId' => 'wrong', 'installationId' => 'wrong'] as $key => $value) {
$x = $f; $x['operation']['localFeedback']['b'][$key] = $value;
controlRefuses(static fn() => controlRun($x), 'wrong feedback binding ' . $key);
}
$x = $f; $x['binding']['feedbackConfigHash'] = str_repeat('b', 64);
controlRefuses(static fn() => controlRun($x), 'changed source mapping blocked');
foreach (['', 'short', 'explicit_test_plant_watchdog_waiver', ' explicit_test_plant_watchdog_waiver '] as $value) {
$x = $f; $x['binding']['watchdogEvidence'] = $value;
controlRefuses(static fn() => controlReady($x), 'missing watchdog or test-only waiver is no production evidence');
}
$x = $f; unset($x['envelope']['response']['plan']['inputQuality']);
controlRefuses(static fn() => controlReady($x), 'missing physical accounting evidence rejected');
$x = $f; $x['envelope']['response']['plan']['inputQuality']['loadBasis'] = 'net_load';
controlRefuses(static fn() => controlRun($x), 'contaminated net-load forecast rejected');
$x = $f; $x['envelope']['receivedAt'] -= 91;
controlRefuses(static fn() => controlReady($x), 'expired plan receipt rejected');
$x = $f; $x['envelope']['response']['pending'] = 'new-plan';
controlRefuses(static fn() => controlRun($x), 'pending revision blocks control');
$x = $f; $x['operation']['batteries'][0]['measuredAt'] = gmdate('c', $f['now'] - 61);
controlRefuses(static fn() => controlReady($x), 'stale battery limits block readiness');
$x = $f; $x['operation']['batteries'][] = $x['operation']['batteries'][0];
controlRefuses(static fn() => controlRun($x), 'multiple battery topology blocked');
$x = $f; unset($x['allocation']['Sollwerte'][901]);
controlRefuses(static fn() => controlRun($x), 'battery absent from ordinary allocation blocked');
$x = $f; $x['consumers'][] = $x['consumers'][0];
controlRefuses(static fn() => controlRun($x), 'duplicate active battery entry blocked');
$x = $f; $x['actual']['b'] = 1000.0;
controlRefuses(static fn() => controlRun($x), 'battery actual must come from verified snapshot');
$x = $f; $x['operation']['gridW'] += 1000.0;
controlRefuses(static fn() => controlRun($x), 'pre-adjusted grid cannot double count predicted other load');
$x = $f; $x['operation']['localPredictedOtherDeltaW'] = 0.0;
controlRefuses(static fn() => controlRun($x), 'previous trial delta cannot leak into ordinary correction');
$x = $f; $x['consumers'][1]['Daten']['Leistungsquelle'] = Message::LEISTUNGSQUELLE_BERECHNET;
controlRefuses(static fn() => controlRun($x), 'other allocated consumers need measured actual');
$x = $f; $x['consumers'][1]['Daten']['Kopf']['Zeitpunkt'] -= 61;
controlRefuses(static fn() => controlRun($x), 'stale consumer message blocked despite supplied synchronization');
$x = $f; $x['allocation']['Sollwerte'][999] = 1000;
controlRefuses(static fn() => controlRun($x), 'unknown allocation target rejected');
$x = $f; $x['consumers'][0]['Daten']['AenderungMoeglich'] = false;
controlRefuses(static fn() => controlRun($x), 'battery lock respected');
$x = $f; $x['consumers'][0]['Daten']['Verfuegbar'] = false;
controlRefuses(static fn() => controlRun($x), 'unavailable battery blocked');
$x = $f; $x['consumers'][0]['Daten']['Leistungswerte_W'] = [];
controlRefuses(static fn() => controlRun($x), 'empty battery offer blocked');
$x = $f; $x['consumers'][0]['Daten']['Leistungswerte_W'] = [['Von_W' => -1000, 'Bis_W' => 1000]];
controlCheck(controlRun($x)['report']['targetW'] === 1000, 'clipping respects advertised battery range');
$x = $f; $x['consumers'][0]['Daten']['Leistungswerte_W'] = [0, 3000, ['Von_W' => 4000, 'Bis_W' => 5000]];
controlCheck(controlRun($x)['report']['targetW'] === 3000, 'discrete offer chosen instead of unavailable gap value');
$x = $f; $x['operation']['batteries'][0]['maxChargeW'] = 500.0;
controlCheck(controlRun($x)['report']['targetW'] === 500, 'current BMS limit restricts offer');
$x = $f; $x['operation']['batteries'][0]['socPercent'] = 90.0;
controlCheck(controlRun($x)['report']['targetW'] === 0, 'full battery cannot charge from target');
$x['consumers'][0]['Daten']['Leistungswerte_W'] = [['Von_W' => 1000, 'Bis_W' => 5000]];
controlRefuses(static fn() => controlRun($x), 'no safe offer cannot become unoffered zero');
$x = $f;
$x['actual']['b'] = 1000.0;
$x['operation']['localFeedback']['b']['batteryW'] = 1000.0;
$x['consumers'][0]['Daten']['Istleistung_W'] = 1000;
controlCheck(controlRun($x)['report']['targetW'] === 3000, 'already flowing battery power counted once');
$x = $f;
$x['operation']['gridW'] = 3000.0;
$x['operation']['localFeedback']['b']['gridW'] = 3000.0;
$x['operation']['batteries'][0]['socPercent'] = 15.0;
controlCheck(controlRun($x)['report']['targetW'] === 0, 'reserve blocks discharge although plan requests grid compensation');
$x = $f;
$x['operation']['gridW'] = 3000.0;
$x['operation']['localFeedback']['b']['gridW'] = 3000.0;
$x['operation']['batteries'][0]['dischargeBlocked'] = true;
controlCheck(controlRun($x)['report']['targetW'] === 0, 'local discharge hysteresis remains authoritative');
$x = $f;
$x['consumers'][] = controlConsumer(903, $x['manager'], $x['now'], 2000, [2000]);
$x['consumers'][2]['Daten']['Leistungsquelle'] = Message::LEISTUNGSQUELLE_BERECHNET;
controlCheck(controlRun($x)['report']['targetW'] === 2000, 'unallocated other consumers remain already counted in grid');
$x = $f;
$x['operation']['gridW'] = 6000.0;
$x['operation']['localFeedback']['b']['gridW'] = 6000.0;
$x['operation']['limits']['importW'] = 4000.0;
$x['operation']['batteries'][0]['maxDischargeW'] = 2000.0;
controlContext($x);
controlRefuses(static fn() => controlRun($x), 'unreachable hard grid limit refuses correction');
$x = $f;
$x['operation']['gridW'] = 3000.0;
$x['operation']['localFeedback']['b']['gridW'] = 3000.0;
$x['operation']['limits']['importW'] = 2000.0;
$x['consumers'][0]['Daten']['Leistungswerte_W'] = [0, ['Von_W' => -1000, 'Bis_W' => -500]];
controlContext($x);
controlRefuses(static fn() => controlRun($x), 'unordered invalid offer rejected by message contract');
$x['consumers'][0]['Daten']['Leistungswerte_W'] = [['Von_W' => -1000, 'Bis_W' => -500], 0];
controlRefuses(static fn() => controlRun($x), 'offered discharge insufficient for grid boundary refused');
$x = $f;
$x['operation']['batteries'][0]['gridCharging'] = false;
controlContext($x);
controlCheck(controlRun($x)['report']['targetW'] === 2000, 'PV-only charging respects residual after other consumers');
$source = file_get_contents(__DIR__ . '/../../libs/NetzfahrplanV4Regelung.php');
controlCheck(!preg_match('/\b(?:IPS_|SetValue|GetValue|RequestAction|curl_exec|shell_exec)/', $source), 'pure allocation helper has no I/O or commands');
echo "TOTAL $count control checks passed; synthetic plans and measurements only.\n";
+9 -4
View File
@@ -13,21 +13,23 @@ namespace Belevo\EnelixEMS {
class FeedbackReceiver {
use ManagerNetzfahrplanV4EmpfangTrait;
public int $InstanceID=17004;public array $props,$attrs,$op;
public function __construct($r,$op){$this->op=$op;$this->props=['NetzfahrplanV4EmpfangAktiv'=>true,'NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>1002,'SOCVariableID'=>1001,'Leistungsfaktor'=>1]])];$this->attrs=['LizenzInstallationID'=>$r['installationId'],'NetzfahrplanV4Empfang'=>json_encode(['response'=>$r,'receivedAt'=>time()]),'NetzfahrplanV4EmpfangStatus'=>'{}'];}
public function __construct($r,$op){$this->op=$op;$this->props=['PrognoseAktiv'=>true,'NetzfahrplanRegelungAktiv'=>false,'NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>1002,'SOCVariableID'=>1001,'Leistungsfaktor'=>1]])];$this->attrs=['LizenzInstallationID'=>$r['installationId'],'NetzfahrplanV4Empfang'=>json_encode(['response'=>$r,'receivedAt'=>time()]),'NetzfahrplanV4EmpfangStatus'=>'{}','PrognoseV4Serverstatus'=>'{}'];}
private function ReadPropertyBoolean($k){return $this->props[$k];}
private function ReadPropertyString($k){return $this->props[$k];}
private function ReadPropertyInteger($k){return $this->props[$k];}
private function ReadAttributeString($k){return $this->attrs[$k];}
private function aktiveVerbraucherIDs(){return [44234];}
private function prognoseLernenAktiv(){return $this->props['PrognoseAktiv'];}
private function berechtigungLizenziert($k){return true;}
public function GetNetzfahrplanV4Diagnose(){return json_encode(['operation'=>$this->op]);}
}
}
namespace {
function ENELIX_GetV4BatterieRueckmeldung($id){$GLOBALS['now']+=($GLOBALS['feedbackDelay']??0);$GLOBALS['feedback']['checkedAt']=$GLOBALS['now'];return json_encode($GLOBALS['feedback']);}
function ENELIX_GetV4BatterieRueckmeldungCache($id){$GLOBALS['cacheReads']++;$GLOBALS['now']+=($GLOBALS['feedbackDelay']??0);return json_encode($GLOBALS['feedback']);}
function ENELIX_GetV4BatterieRueckmeldung($id){throw new RuntimeException('Synchronous device read forbidden in preview');}
require_once __DIR__.'/../V4Receiver/fixture.php';
use Belevo\EnelixEMS\FeedbackReceiver;
[$response,$id,$op,$now]=receiverFixture();$configured=true;$count=0;$feedbackDelay=0;
[$response,$id,$op,$now]=receiverFixture();$configured=true;$count=0;$feedbackDelay=0;$cacheReads=0;
$feedback=['kind'=>'local_physical_feedback','version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234,'batteryW'=>0.,'gridW'=>$op['gridW'],'checkedAt'=>$now,'estimated'=>true,'usableForTrial'=>false,'canDispatch'=>false,'configHash'=>str_repeat('a',64)];
function ck($v,$t){global$count;if(!$v)throw new RuntimeException('FAIL '.$t);$count++;echo "PASS $t\n";}
$m=new FeedbackReceiver($response,$op);$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
@@ -35,7 +37,10 @@ namespace {
ck($p['previewBatteryW']==3000,'net target translated once to total battery demand');
ck($p['canDispatch']===false&&$p['localFeedback']['b']['estimated']===true,'model feedback remains marked and preview grants no dispatch');
$feedbackDelay=1;$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);$feedbackDelay=0;
ck($p['status']==='preview_only','feedback timestamp produced after device read is compared with post-read clock');
ck($p['status']==='preview_only'&&$feedback['checkedAt']===$now-1,'cached feedback timestamp is retained across delayed preview read');
ck($cacheReads===2,'preview only reads confirmed feedback cache, never synchronous device getter');
$now+=2;$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);$now-=2;
ck($p['status']==='unavailable'&&$p['previewBatteryW']===null,'stale confirmed cache is rejected without a synchronous refresh or legacy fallback');
$feedback=['status'=>'unavailable','reason'=>'feedback_device_read_failed:grid','batteryW'=>null,'gridW'=>null];$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
ck($p['status']==='unavailable'&&$p['previewBatteryW']===null,'configured feedback failure never falls back to held virtual display');
ck(str_contains($p['reason'],'feedback_unavailable:feedback_device_read_failed:grid'),'configured feedback exposes bounded root cause without credentials');
+327
View File
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
if (PHP_SAPI !== 'cli' || function_exists('IPS_GetKernelVersion')) {
throw new \RuntimeException('Standalone PHP CLI only; never load mocks into Symcon.');
}
require_once __DIR__ . '/../../libs/NetzfahrplanV4Einrichtung.php';
require_once __DIR__ . '/../../libs/NetzfahrplanV4RueckmeldungEinrichtung.php';
require_once __DIR__ . '/../../libs/BatterieNetzfahrplanV4RueckmeldungTrait.php';
$count = 0;
function feedbackSetupCheck(bool $ok, string $label): void
{
global $count;
if (!$ok) throw new \RuntimeException('FAIL ' . $label);
++$count;
echo 'PASS ' . $label . PHP_EOL;
}
function feedbackSetupRefuses(callable $call, string $label): void
{
try { $call(); }
catch (\Throwable $error) { feedbackSetupCheck(true, $label); return; }
throw new \RuntimeException('Expected refusal: ' . $label);
}
function feedbackSetupFixture(): array
{
$assets = [['ID' => 'battery', 'WechselrichterID' => 'battery-wr', 'Kopplung' => 'ac',
'LeistungVariableID' => 103, 'SOCVariableID' => 104, 'Leistungsfaktor' => 1.0]];
$objects = [
101 => ['parentID' => 201, 'ident' => 'Grid', 'type' => 2],
102 => ['parentID' => 202, 'ident' => 'PV', 'type' => 2],
103 => ['parentID' => 203, 'ident' => 'Battery', 'type' => 2],
];
$installationId = '11111111-2222-4333-8444-555555555555';
$setup = NetzfahrplanV4Einrichtung::erstellen($installationId, 900,
['NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60],
[['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1000],
['ID' => 'battery-wr', 'Typ' => 'battery']],
$assets, static fn(int $id): array => $objects[$id]);
return ['capture' => $setup['capture'], 'installationId' => $installationId,
'managerId' => 900, 'batteryId' => 901, 'assets' => $assets,
'controllers' => [['InstanzID' => 901, 'IstleistungVariableID' => 103,
'LadezustandVariableID' => 104, 'NetzleistungVariableID' => 101, 'MesswertMaxAlter' => 60]]];
}
function feedbackSetupCreate(array $f): array
{
return NetzfahrplanV4RueckmeldungEinrichtung::erstellen($f['capture'], $f['installationId'],
$f['managerId'], $f['batteryId'], $f['assets'], $f['controllers']);
}
$f = feedbackSetupFixture();
$config = feedbackSetupCreate($f);
feedbackSetupCheck($config === feedbackSetupCreate($f), 'automatic feedback deterministic');
feedbackSetupCheck($config['installationId'] === $f['installationId'] && $config['managerId'] === 900
&& $config['batteryInstanceId'] === 901 && $config['assetId'] === 'battery', 'single exact plant and controller binding');
feedbackSetupCheck($config['mode'] === 'physical_sum' && !$config['allowEstimatedForTrial'], 'only separately metered physical feedback');
feedbackSetupCheck(count($config['sources']) === 2 && array_column($config['sources'], 'variableId') === [103, 101],
'battery and grid sampled exactly once without PV double count');
feedbackSetupCheck(array_column($config['sources'], 'role') === ['physical', 'grid'], 'no invented request or gateway sources');
feedbackSetupCheck($config['maxAgeSeconds'] === 60 && $config['maxSkewSeconds'] === 30, 'existing strict freshness defaults');
feedbackSetupCheck($config['idleToleranceW'] === 0.0 && $config['trackingToleranceW'] === 0.0, 'no relaxed tracking tolerance');
feedbackSetupCheck(!str_contains(json_encode($config), 'watchdog') && !str_contains(json_encode($config), 'canDispatch')
&& !str_contains(json_encode($config), 'confirmedAt'), 'setup creates neither confirmation nor control permission');
$roundtrip = json_decode(json_encode($f), true);
feedbackSetupCheck(feedbackSetupCreate($roundtrip) === $config, 'JSON float normalization stable');
$changed = $f;
$changed['controllers'][0]['MesswertMaxAlter'] = 8;
feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 8
&& feedbackSetupCreate($changed)['maxSkewSeconds'] === 8, 'controller stricter freshness preserved');
$changed = $f;
$changed['controllers'][0]['MesswertMaxAlter'] = 600;
feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 60, 'large local setting cannot relax feedback freshness');
$changed = $f;
foreach ($changed['capture']['accounting']['sources'] as &$source) {
if ($source['role'] === 'physical_storage') $source['maxAgeSeconds'] = 7;
}
unset($source);
feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 7, 'capture source stricter freshness preserved');
$changed = $f;
$changed['assets'][0]['Name'] = 'private name';
$changed['controllers'][0]['Password'] = 'do-not-export';
feedbackSetupCheck(feedbackSetupCreate($changed) === $config, 'only allowlisted nonsecret fields leave setup');
$changed = $f;
$changed['installationId'] = $changed['capture']['installationId'] = '99999999-2222-4333-8444-555555555555';
feedbackSetupCheck(NetzfahrplanV4Rueckmeldung::fingerprint(feedbackSetupCreate($changed))
!== NetzfahrplanV4Rueckmeldung::fingerprint($config), 'distinct plant cannot share feedback identity');
$changed = $f;
foreach ($changed['capture']['accounting']['sources'] as &$source) {
if ($source['role'] === 'physical_storage') $source['parentId'] = 205;
}
unset($source);
feedbackSetupCheck(NetzfahrplanV4Rueckmeldung::fingerprint(feedbackSetupCreate($changed))
!== NetzfahrplanV4Rueckmeldung::fingerprint($config), 'changed physical identity changes fingerprint');
foreach ([
static function (array &$x): void { $x['managerId']++; },
static function (array &$x): void { $x['batteryId']++; },
static function (array &$x): void { $x['assets'] = []; },
static function (array &$x): void { $x['assets'][] = $x['assets'][0]; },
static function (array &$x): void { $x['controllers'] = []; },
static function (array &$x): void { $x['controllers'][] = $x['controllers'][0]; },
static function (array &$x): void { $x['controllers'][0]['Aktiv'] = false; },
static function (array &$x): void { $x['controllers'][0]['IstleistungVariableID'] = 105; },
static function (array &$x): void { $x['controllers'][0]['LadezustandVariableID'] = 105; },
static function (array &$x): void { $x['controllers'][0]['NetzleistungVariableID'] = 105; },
static function (array &$x): void { $x['controllers'][0]['MesswertMaxAlter'] = 0; },
static function (array &$x): void { unset($x['assets'][0]['SOCVariableID']); },
static function (array &$x): void { $x['assets'][0]['SOCVariableID'] = $x['controllers'][0]['LadezustandVariableID'] = 103; },
static function (array &$x): void { $x['assets'][0]['Virtuell'] = true; },
static function (array &$x): void { $x['assets'][0]['SDLAktiv'] = true; },
static function (array &$x): void { $x['assets'][0]['Kopplung'] = 'dc'; },
static function (array &$x): void { $x['assets'][0]['ID'] = 'different'; },
static function (array &$x): void { $x['assets'][0]['Leistungsfaktor'] = -1; },
static function (array &$x): void { unset($x['capture']['accounting']['storageMode']); },
] as $index => $change) {
$changed = $f;
$change($changed);
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'ambiguous or incompatible binding ' . $index);
}
foreach (['grid', 'physical_storage'] as $role) {
foreach ([-1, 1000] as $factor) {
$changed = $f;
foreach ($changed['capture']['accounting']['sources'] as &$source) {
if ($source['role'] === $role) $source['factorToW'] = $factor;
}
unset($source);
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'controller raw W units ' . $role . ' ' . $factor);
}
}
$changed = $f;
$changed['capture']['accounting']['sources'][] = [
'key' => 'other', 'role' => 'reference', 'variableId' => 105, 'parentId' => 205,
'ident' => 'Other', 'factorToW' => 1.0, 'maxAgeSeconds' => 60, 'dependsOn' => [],
];
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'specialist accounting roles require explicit mapping');
$changed = $f;
$changed['capture']['accounting']['sources'][0]['dependsOn'] = ['grid'];
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'derived physical source cannot become automatic feedback');
$changed = $f;
$changed['capture']['extraSources'][] = [
'key' => 'soc', 'variableId' => 104, 'parentId' => 203, 'ident' => 'SOC', 'unit' => 'percent',
];
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'specialist extra sources require explicit mapping');
$GLOBALS['feedbackTestNow'] = 1791450000;
$GLOBALS['feedbackTestDeviceCalls'] = 0;
$GLOBALS['feedbackTestGetterCalls'] = 0;
$GLOBALS['feedbackTestManagerRunning'] = false;
$GLOBALS['feedbackTestGetterConfig'] = '';
$GLOBALS['feedbackTestReadSucceeds'] = true;
$GLOBALS['feedbackTestModules'] = [201 => NetzfahrplanV4Geraeteabruf::MBUS_DEVICE, 203 => NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE];
$GLOBALS['feedbackTestObjects'] = [101 => ['ParentID' => 201, 'ObjectIdent' => 'Grid'], 103 => ['ParentID' => 203, 'ObjectIdent' => 'Battery']];
$GLOBALS['feedbackTestValues'] = [101 => 1000, 103 => 250];
function time(): int { return $GLOBALS['feedbackTestNow']; }
function usleep(int $duration): void {}
function ENELIX_GetV4RueckmeldungKonfiguration(int $manager, int $battery): string
{
++$GLOBALS['feedbackTestGetterCalls'];
if ($GLOBALS['feedbackTestManagerRunning']) throw new \RuntimeException('forbidden Manager -> battery cache -> Manager cycle');
if ($manager !== 900 || $battery !== 901) throw new \RuntimeException('wrong getter invocation');
return $GLOBALS['feedbackTestGetterConfig'];
}
function IPS_SemaphoreEnter(string $key, int $timeout): bool { return true; }
function IPS_SemaphoreLeave(string $key): void {}
function IPS_VariableExists(int $id): bool { return isset($GLOBALS['feedbackTestObjects'][$id]); }
function IPS_GetVariable(int $id): array
{
return ['VariableUpdated' => time() - 120, 'VariableChanged' => time() - 180];
}
function IPS_GetObject(int $id): array { return $GLOBALS['feedbackTestObjects'][$id]; }
function GetValue(int $id): int { return $GLOBALS['feedbackTestValues'][$id]; }
function IPS_InstanceExists(int $id): bool { return isset($GLOBALS['feedbackTestModules'][$id]); }
function IPS_GetInstance(int $id): array { return ['ModuleInfo' => ['ModuleID' => $GLOBALS['feedbackTestModules'][$id]]]; }
function MBUS_UpdateValues(int $id): bool
{
++$GLOBALS['feedbackTestDeviceCalls'];
return $GLOBALS['feedbackTestReadSucceeds'];
}
function ModBus_RequestRead(int $id): bool { return MBUS_UpdateValues($id); }
final class FeedbackSetupBattery
{
use BatterieNetzfahrplanV4RueckmeldungTrait;
public int $InstanceID = 901;
public array $managers = [], $buffers = [], $timers = [];
public string $property = '{}';
public function register(): void { $this->registriereV4Rueckmeldung(); }
public function configure(): void { $this->konfiguriereV4Rueckmeldung(); }
public function tick(): void { $this->aktualisiereV4Rueckmeldung(); }
public function effective(): string { return $this->effektiveV4RueckmeldungKonfiguration(); }
private function zugeordneteManagerIDs(): array
{
if ($GLOBALS['feedbackTestManagerRunning']) throw new \RuntimeException('cache must not inspect Manager assignment');
return $this->managers;
}
protected function ReadPropertyString(string $key): string { return $this->property; }
protected function RegisterPropertyString(string $key, string $value): void {}
protected function RegisterTimer(string $key, int $interval, string $script): void { $this->timers[$key] = compact('interval', 'script'); }
protected function SetTimerInterval(string $key, int $interval): void { $this->timers[$key]['interval'] = $interval; }
protected function SetBuffer(string $key, string $value): void { $this->buffers[$key] = $value; }
protected function GetBuffer(string $key): string { return $this->buffers[$key] ?? ''; }
}
$battery = new FeedbackSetupBattery();
$battery->register();
$timerScript = $battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['script'];
feedbackSetupCheck($timerScript === "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', false);",
'native timer wrapper unchanged');
$battery->configure();
$battery->tick();
feedbackSetupCheck($battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['interval'] === 1000,
'existing sampler discovers assignment after battery setup');
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 0 && $GLOBALS['feedbackTestGetterCalls'] === 0,
'unassigned battery neither queries manager nor reads devices');
$battery->managers = [900];
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 0, 'empty manager config when learning off performs no device I/O');
$GLOBALS['feedbackTestGetterConfig'] = json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'assignment alone never produces fake fresh sample');
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 2, 'late manager assignment uses existing sampler without ApplyChanges');
$sample = json_decode($battery->GetV4BatterieRueckmeldungCache(), true);
feedbackSetupCheck($sample['batteryW'] === 250 && $sample['gridW'] === 1000, 'confirmed physical snapshot values');
feedbackSetupCheck($sample['deviceReadConfirmed'] && $sample['separatelyMeasured'] && !$sample['estimated'], 'real adapter response required');
feedbackSetupCheck($sample['sourceVariableOldestAt'] === time() - 120 && $sample['sourceOldestAt'] === time(),
'original unchanged variable timestamps retained separately from device confirmation');
feedbackSetupCheck(!$sample['canDispatch'] && !$sample['gatewayWatchdogVerified'], 'physical sampler never invents permission or watchdog');
$buffers = $battery->buffers;
$calls = $GLOBALS['feedbackTestDeviceCalls'];
$getters = $GLOBALS['feedbackTestGetterCalls'];
$GLOBALS['feedbackTestManagerRunning'] = true;
$battery->GetV4BatterieRueckmeldungCache();
feedbackSetupCheck($battery->buffers === $buffers && $GLOBALS['feedbackTestDeviceCalls'] === $calls,
'cache getter remains device I/O free and never renews timestamps');
feedbackSetupCheck($GLOBALS['feedbackTestGetterCalls'] === $getters,
'Manager -> battery cache never calls back into Manager or assignment lookup');
$GLOBALS['feedbackTestManagerRunning'] = false;
$context = json_decode($buffers['V4ConfirmedFeedbackContext'], true);
feedbackSetupCheck($context['autoMode'] && $context['propertyHash'] === hash('sha256', '{}')
&& json_decode($context['configuration'], true) === $config, 'sampler stores exact automatic probe context');
foreach ([
['autoMode', false], ['propertyHash', str_repeat('0', 64)], ['configuration', '{}'],
] as [$key, $value]) {
$brokenContext = $context;
$brokenContext[$key] = $value;
$battery->buffers['V4ConfirmedFeedbackContext'] = json_encode($brokenContext);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'cached context drift refused: ' . $key);
$battery->buffers = $buffers;
}
foreach (['installationId', 'assetId', 'managerId', 'batteryInstanceId', 'configHash'] as $key) {
$brokenSample = $sample;
$brokenSample[$key] = is_int($brokenSample[$key]) ? $brokenSample[$key] + 1 : 'wrong';
$battery->buffers['V4ConfirmedFeedback'] = json_encode($brokenSample);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'cached probe binding drift refused: ' . $key);
$battery->buffers = $buffers;
}
$battery->property = json_encode($config);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic to explicit property change invalidates old sample');
$battery->property = '{}';
$GLOBALS['feedbackTestNow'] += 3;
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic cache expires after two seconds without contacting Manager');
$GLOBALS['feedbackTestNow'] -= 3;
$battery->buffers['V4ConfirmedFeedbackMono'] = (string) (hrtime(true) / 1e9 - 3);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic monotonic cache TTL remains two seconds');
$battery->buffers = $buffers;
$changedConfig = $config;
$changedConfig['maxAgeSeconds'] = 10;
$GLOBALS['feedbackTestGetterConfig'] = json_encode($changedConfig);
$getters = $GLOBALS['feedbackTestGetterCalls'];
feedbackSetupCheck(json_decode($battery->GetV4BatterieRueckmeldungCache(), true)['configHash'] === $sample['configHash']
&& $GLOBALS['feedbackTestGetterCalls'] === $getters,
'still-fresh probe stays bound to sampled configuration; Manager drift is observed only by sampler');
$battery->tick();
feedbackSetupCheck(json_decode($battery->GetV4BatterieRueckmeldungCache(), true)['configHash']
=== NetzfahrplanV4Rueckmeldung::fingerprint($changedConfig), 'next confirmed sample adopts changed automatic config');
$GLOBALS['feedbackTestGetterConfig'] = '';
$calls = $GLOBALS['feedbackTestDeviceCalls'];
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls && $battery->buffers['V4ConfirmedFeedback'] === '',
'learning off clears automatic cache without device I/O');
$GLOBALS['feedbackTestGetterConfig'] = json_encode($config);
$battery->managers = [900, 902];
$getters = $GLOBALS['feedbackTestGetterCalls'];
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls && $GLOBALS['feedbackTestGetterCalls'] === $getters,
'ambiguous manager assignment does not choose or read a device');
$battery->managers = [900];
$battery->property = " \n" . json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) . " \n";
$explicit = $battery->property;
$getters = $GLOBALS['feedbackTestGetterCalls'];
feedbackSetupCheck($battery->effective() === $explicit, 'explicit specialist configuration retained byte for byte');
$battery->tick();
feedbackSetupCheck($battery->property === $explicit && $GLOBALS['feedbackTestGetterCalls'] === $getters,
'explicit mapping never calls manager or rewrites properties');
feedbackSetupCheck($battery->buffers['V4ConfirmedFeedbackConfig'] === hash('sha256', $explicit),
'existing explicit configuration cache hashing unchanged');
$GLOBALS['feedbackTestManagerRunning'] = true;
$battery->GetV4BatterieRueckmeldungCache();
$GLOBALS['feedbackTestManagerRunning'] = false;
feedbackSetupCheck($GLOBALS['feedbackTestGetterCalls'] === $getters, 'explicit cache also performs no Manager callbacks');
$battery->property .= ' ';
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'explicit property byte drift invalidates probe');
$battery->property = '{}';
$badBinding = $config;
$badBinding['managerId'] = 902;
$GLOBALS['feedbackTestGetterConfig'] = json_encode($badBinding);
$calls = $GLOBALS['feedbackTestDeviceCalls'];
feedbackSetupRefuses(static fn() => $battery->tick(), 'manager returned wrong binding refused');
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls, 'wrong automatic binding refused before device I/O');
$GLOBALS['feedbackTestGetterConfig'] = json_encode($config);
$GLOBALS['feedbackTestReadSucceeds'] = false;
feedbackSetupRefuses(static fn() => $battery->tick(), 'failed physical device response is never confirmed');
feedbackSetupCheck($battery->buffers['V4ConfirmedFeedback'] === '', 'failed response clears old confirmed sample');
$GLOBALS['feedbackTestReadSucceeds'] = true;
$GLOBALS['feedbackTestModules'] = [201 => 'unsupported', 203 => 'unsupported'];
$calls = $GLOBALS['feedbackTestDeviceCalls'];
feedbackSetupRefuses(static fn() => $battery->tick(), 'virtual or unsupported source parent refused');
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls, 'unsupported parents not refreshed');
feedbackSetupCheck($battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['script'] === $timerScript,
'configuration and sampling never replace native timer callback');
echo 'TOTAL ' . $count . ' feedback setup checks passed' . PHP_EOL;
+27 -18
View File
@@ -35,10 +35,11 @@ namespace Belevo\EnelixEMS {
public array $op=[];
public function __construct($id,$op) {
$this->op=$op;
$this->props=['NetzfahrplanV4EmpfangAktiv'=>false,'NetzfahrplanV4SchattenAktiv'=>true,
$this->props=['PrognoseAktiv'=>false,'NetzfahrplanRegelungAktiv'=>false,'NetzfahrplanV4SchattenAktiv'=>true,
'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,
'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>100,'Leistungsfaktor'=>1]])];
$this->attrs=['LizenzInstallationID'=>$id,'PrognoseInstallationsToken'=>str_repeat('x',43)];
$this->attrs=['LizenzInstallationID'=>$id,'PrognoseInstallationsToken'=>str_repeat('x',43),
'PrognoseV4Serverstatus'=>'{}'];
$this->registriereNetzfahrplanV4Empfang();
}
public function receive() {$this->empfangeNetzfahrplanV4();}
@@ -47,6 +48,7 @@ namespace Belevo\EnelixEMS {
public function configure() {$this->konfiguriereNetzfahrplanV4Vorschau();}
public function GetNetzfahrplanV4Diagnose() { return json_encode(['operation'=>$this->op]); }
private function aktiveVerbraucherIDs() {return [];}
private function prognoseLernenAktiv() {return $this->props['PrognoseAktiv'];}
private function v4ManagerTestSession() {return $this->session;}
private function berechtigungLizenziert($key) {return $this->licensed;}
private function ReadPropertyBoolean($key) {return (bool)$this->props[$key];}
@@ -55,7 +57,7 @@ namespace Belevo\EnelixEMS {
private function ReadAttributeString($key) {return $this->attrs[$key]??'';}
private function ReadAttributeInteger($key) {return (int)($this->attrs[$key]??0);}
private function WriteAttributeString($key,$val) {
if (!str_starts_with($key,'NetzfahrplanV4')) throw new \RuntimeException('Unexpected external attribute');
if (!str_starts_with($key,'NetzfahrplanV4') && $key!=='PrognoseV4Serverstatus') throw new \RuntimeException('Unexpected external attribute');
$this->attrs[$key]=$val;
}
private function WriteAttributeInteger($key,$val) { $this->attrs[$key]=$val; }
@@ -86,8 +88,9 @@ namespace {
$GLOBALS['clock']+=$seconds;$GLOBALS['response']['checkedAt']=gmdate('c',$GLOBALS['clock']);
$h->op['observedAt']=gmdate('c',$GLOBALS['clock']);$h->op['batteries'][0]['measuredAt']=$h->op['observedAt'];
}
$h=fixture();$h->receive();check(count($requests)===0,'default disabled no network');
$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->configure();check($h->timers['NetzfahrplanV4VorschauAktualisieren']===10000,'watchdog without network');
$h=fixture();$h->receive();check(count($requests)===0,'learning disabled makes no network request');
$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();check(count($requests)===0,'retired receiver flag cannot enable learning or network');
$h->props['PrognoseAktiv']=true;$h->configure();check($h->timers['NetzfahrplanV4VorschauAktualisieren']===10000,'watchdog without network');
$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
check($v['status']==='preview_only' && $v['previewBatteryW']===3000 && $v['canDispatch']===false,'read and show only');
check(count($requests)===2 && str_ends_with($requests[1]['url'],'/ack'),'one read and one acknowledgement');
@@ -97,25 +100,25 @@ namespace {
advance($h,91);$h->update();$v=json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true);
check($v['status']==='unavailable' && $v['previewBatteryW']===null,'expired cache clears numeric targets, not fake zero');
check(!str_contains($h->vars['NetzfahrplanV4Vorschau'],'3000'),'expired HTML clears previous setpoint');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();advance($h,61);$GLOBALS['getCode']=429;$h->receive();
$h=fixture();$h->props['PrognoseAktiv']=true;$h->receive();advance($h,61);$GLOBALS['getCode']=429;$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']==='{}' && json_decode($h->GetNetzfahrplanV4Vorschau(),true)['previewBatteryW']===null,'429 invalidates cache');
check(!str_contains(json_encode($h->vars),str_repeat('x',43)),'no credentials in diagnosis');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->props['NetzfahrplanAktiv']=true;$h->receive();check(count($requests)===0,'legacy execution on blocks preview acquisition');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['response']['installationId']='00000000-0000-4000-8000-000000000001';$h->receive();
$h=fixture();$h->props['PrognoseAktiv']=true;$h->props['NetzfahrplanAktiv']=true;$h->receive();check(count($requests)===2 && $h->props['NetzfahrplanRegelungAktiv']===false,'obsolete legacy flag neither blocks preview nor enables V4 control');
$h=fixture();$h->props['PrognoseAktiv']=true;$GLOBALS['response']['installationId']='00000000-0000-4000-8000-000000000001';$h->receive();
check(count($requests)===1 && $h->attrs['NetzfahrplanV4Empfang']==='{}','foreign response not acknowledged');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['ackCode']=503;$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
$h=fixture();$h->props['PrognoseAktiv']=true;$GLOBALS['ackCode']=503;$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
check($v['status']==='preview_only' && !$v['reception']['acknowledged'] && isset($v['reception']['acknowledgementError']),'ack failure is visible, no false execution confirmation');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->op['batteries'][0]['minSocPercent']=30.;$h->update();
$h=fixture();$h->props['PrognoseAktiv']=true;$h->receive();$h->op['batteries'][0]['minSocPercent']=30.;$h->update();
check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'local policy change invalidates cache');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->stop();check($h->attrs['NetzfahrplanV4Empfang']==='{}' && $h->timers['NetzfahrplanV4VorschauAktualisieren']===0,'apply/restart invalidates cached plan');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->licensed=false;$h->receive();check(count($requests)===0,'no license no read');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['lock']=true;$h->receive();check(count($requests)===0,'concurrent receiver does not send');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->props['NetzfahrplanV4EmpfangAktiv']=false;$h->update();check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'disable clears displayed target');
$h=fixture();$h->props['PrognoseAktiv']=true;$h->receive();$h->stop();check($h->attrs['NetzfahrplanV4Empfang']==='{}' && $h->timers['NetzfahrplanV4VorschauAktualisieren']===0,'apply/restart invalidates cached plan');
$h=fixture();$h->props['PrognoseAktiv']=true;$h->licensed=false;$h->receive();check(count($requests)===0,'no license no read');
$h=fixture();$h->props['PrognoseAktiv']=true;$GLOBALS['lock']=true;$h->receive();check(count($requests)===0,'concurrent receiver does not send');
$h=fixture();$h->props['PrognoseAktiv']=true;$h->receive();$h->props['PrognoseAktiv']=false;$h->update();check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'disable clears displayed target');
$source=file_get_contents(__DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php');
check(!str_contains($source, 'netzfahrplanV4EmpfangFormular'), 'receiver no longer owns a separate form panel');
check(!preg_match('/\b(?:SendDataToChildren|SendDataToParent|sendeManagerdaten|regeln|SetStatus|EnableAction|IPS_SetProperty|IPS_ApplyChanges)\s*\(/',$source),'no control or external configuration call in receiver');
check(!str_contains($source,"'status' => 'applied'"),'never acknowledges applied');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->configure();
$h=fixture();$h->props['PrognoseAktiv']=true;$h->configure();
check($h->timers['NetzfahrplanV4EmpfangAktualisieren']===10000,'refresh has an independent timer');
$h->session=['kind'=>'local_active_test_consent'];$h->receive();$saved=$h->attrs['NetzfahrplanV4Empfang'];
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+45,'active refresh due after 45 seconds');
@@ -125,16 +128,22 @@ namespace {
advance($h,46);$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']==='{}','active envelope older than 90 seconds is discarded');
$h->stop();check($h->timers['NetzfahrplanV4EmpfangAktualisieren']===0,'stop disables independent refresh timer');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->session=['kind'=>'local_active_test_consent'];$h->receive();
$h=fixture();$h->props['PrognoseAktiv']=true;$h->session=['kind'=>'local_active_test_consent'];$h->receive();
$future=json_decode($h->attrs['NetzfahrplanV4Empfang'],true);$future['receivedAt']=$clock+120;
$h->attrs['NetzfahrplanV4Empfang']=json_encode($future);advance($h,45);$GLOBALS['getCode']=503;$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']==='{}','future receipt cannot survive failed refresh');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['response']['pending']=['sequence'=>1];$GLOBALS['response']['fresh']=false;$h->receive();
$h=fixture();$h->props['PrognoseAktiv']=true;$GLOBALS['response']['pending']=['sequence'=>1];$GLOBALS['response']['fresh']=false;$h->receive();
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+15 && $h->attrs['NetzfahrplanV4Empfang']==='{}',
'pending startup retries after 15 seconds without creating a usable envelope');
$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
check(($v['reception']['status']??null)==='rejected_or_unavailable','unavailable preview preserves receiver rejection detail');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['getCode']=429;$h->receive();
$h=fixture();$h->props['PrognoseAktiv']=true;$GLOBALS['getCode']=429;$h->receive();
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+60,'startup rate-limit response retains ordinary backoff');
$h=fixture();$h->props['PrognoseAktiv']=true;$h->props['NetzfahrplanRegelungAktiv']=true;$h->receive();
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+45,'normal control switch uses active refresh without a test session');
$h=fixture();$h->props['PrognoseAktiv']=true;
$GLOBALS['response']['workflow']=['state'=>'ready','planningReady'=>true,'controlActive'=>false];$h->receive();
$workflow=json_decode($h->attrs['PrognoseV4Serverstatus'],true);
check($workflow['receivedAt']===$clock && $workflow['workflow']===$GLOBALS['response']['workflow'],'server workflow cached separately without inferring control activity');
echo "Total: $count checks. Mocked IPS and HTTP only.\n";
}
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../libs/NetzfahrplanV4Einrichtung.php';
use Belevo\EnelixEMS\NetzfahrplanV4Einrichtung as Setup;
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme as Capture;
use Belevo\EnelixEMS\NetzfahrplanV4Bilanzierung as Accounting;
function setupFixture(): array
{
return [
'installationId' => '11111111-2222-4333-8444-555555555555',
'managerId' => 900,
'manager' => ['NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60],
'inverters' => [
['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1.0],
['ID' => 'battery-wr', 'Typ' => 'battery'],
],
'batteries' => [['ID' => 'battery', 'WechselrichterID' => 'battery-wr', 'Kopplung' => 'ac',
'LeistungVariableID' => 103, 'Leistungsfaktor' => 1.0]],
'objects' => [
101 => ['parentID' => 201, 'ident' => 'Grid', 'type' => 2],
102 => ['parentID' => 202, 'ident' => 'PV', 'type' => 2],
103 => ['parentID' => 203, 'ident' => 'Battery', 'type' => 2],
],
];
}
function setupCreate(array $f): array
{
return Setup::erstellen($f['installationId'], $f['managerId'], $f['manager'], $f['inverters'], $f['batteries'],
static fn(int $id): array => $f['objects'][$id] ?? []);
}
function setupRecord(array $setup, array $f, int $now, array $changes = []): array
{
$samples = [];
$values = [101 => 1000, 102 => 5000, 103 => 2000];
foreach ($f['objects'] as $id => $o) {
$samples[$id] = ['value' => $values[$id], 'updated' => $now, 'changed' => $now - 30,
'parentID' => $o['parentID'], 'ident' => $o['ident']];
$samples[$id] = array_replace($samples[$id], $changes[$id] ?? []);
}
return Capture::capture($setup['capture'], static fn(int $id): array => $samples[$id], static fn(): int => $now);
}
if (($argv[1] ?? '') === '--fixture') {
$f = setupFixture();
$setup = setupCreate($f);
echo json_encode(['setup' => $setup, 'captureJson' => json_encode($setup['capture'], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION),
'record' => setupRecord($setup, $f, 1791450000)], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
exit(0);
}
$count = 0;
function setupCheck(bool $ok, string $label): void
{
global $count;
if (!$ok) {
throw new RuntimeException('FAIL ' . $label);
}
++$count;
echo 'PASS ' . $label . "\n";
}
function setupRefuses(callable $call, string $label): void
{
try {
$call();
} catch (InvalidArgumentException $e) {
setupCheck(true, $label);
return;
}
throw new RuntimeException('Expected refusal: ' . $label);
}
$f = setupFixture();
$setup = setupCreate($f);
setupCheck($setup === setupCreate($f), 'idempotent deterministic setup');
setupCheck($setup['datasetId'] === $setup['dataset']['datasetId'], 'single dataset identity');
setupCheck($setup['mappingSha256'] === hash('sha256', json_encode($setup['capture'], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)), 'exact PHP capture hash');
setupCheck($setup['inventorySha256'] === $setup['capture']['reportedInventorySha256'], 'inventory identity bound to capture');
setupCheck($setup['dataset']['inventorySha256'] === $setup['inventorySha256'], 'inventory identity bound to dataset');
setupCheck($setup['dataset']['mappingSha256'] === $setup['mappingSha256'], 'mapping identity bound to dataset');
setupCheck($setup['dataset']['minimumTrainingHours'] === 24 && $setup['dataset']['minimumCoverage'] === 0.95, 'training requires sufficient measured basis');
setupCheck($setup['dataset']['historyDays'] === 30 && $setup['dataset']['maximumGapSeconds'] === 5, 'bounded history and gap defaults');
setupCheck($setup['dataset']['formula'] === 'physical_sum_v1' && $setup['dataset']['solarReference'] === null, 'ordinary physical formula');
setupCheck(count($setup['dataset']['sources']) === 3 && $setup['capture']['extraSources'] === [], 'one physical source per measured flow');
$roundtrip = json_decode(json_encode($f, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
setupCheck(setupCreate($roundtrip) === $setup, 'integer versus float JSON normalization stable');
$roundtripCapture = json_decode(json_encode($setup['capture'], JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
setupCheck(Capture::configuration($roundtripCapture) === $setup['capture'], 'stored capture float representation normalized');
$changed = $f;
$changed['inverters'] = array_reverse($changed['inverters']);
setupCheck(setupCreate($changed) === $setup, 'topology row order irrelevant');
$changed = $f;
$changed['installationId'] = '99999999-2222-4333-8444-555555555555';
setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'other installation never shares identity');
$changed = $f;
$changed['managerId']++;
setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'manager identity bound');
$changed = $f;
$changed['manager']['Netzleistungsfaktor'] = 1000;
setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'unit changes require new dataset');
$changed = $f;
$changed['objects'][103]['ident'] = 'OtherBattery';
setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'changed source ident requires new dataset');
$changed = $f;
$changed['objects'][103]['parentID']++;
setupCheck(setupCreate($changed)['inventorySha256'] !== $setup['inventorySha256'], 'changed parent requires new inventory');
$changed = $f;
$changed['manager']['MesswertMaxAlter'] = 10;
setupCheck(array_unique(array_column(setupCreate($changed)['dataset']['sources'], 'maxAgeSeconds')) === [10], 'stricter age retained');
$changed['manager']['MesswertMaxAlter'] = 300;
setupCheck(array_unique(array_column(setupCreate($changed)['dataset']['sources'], 'maxAgeSeconds')) === [60], 'larger manager age does not relax default freshness');
$changed = $f;
$changed['manager']['Secret'] = 'do-not-export';
$changed['inverters'][0]['Unused'] = 'do-not-export';
$changed['batteries'][0]['Name'] = 'do-not-export';
$changed['objects'][101]['credentials'] = 'do-not-export';
setupCheck(setupCreate($changed) === $setup, 'unknown fields and credentials not copied or hashed');
setupCheck(!str_contains(json_encode($setup), 'controlEnabled') && !str_contains(json_encode($setup), 'confirmedAt'), 'configuration grants no dispatch or device confirmation');
$now = 1791450000;
$record = setupRecord($setup, $f, $now);
setupCheck($record['mappingSha256'] === $setup['mappingSha256'], 'real capture hashes exactly match dataset');
setupCheck($record['assessment']['baseLoadCandidateW'] === 4000.0, 'physical battery removed exactly once');
setupCheck($record['assessment']['reconstructedGridW'] === 1000.0 && $record['assessment']['arithmeticBalanceErrorW'] === 0.0, 'physical-only balance reconstructs grid');
setupCheck($record['assessment']['controlledVirtualW'] === 0.0 && $record['assessment']['allocationResidualW'] === 0.0
&& $record['assessment']['externalEffectW'] === 0.0, 'no invented virtual battery or SDL residual');
setupCheck($record['assessment']['status'] === 'consistent_candidate', 'physical-only observation does not require a virtual source');
setupCheck(!$record['trainingEligible'] && !$record['controlEligible'] && !$record['measurementBoundaryVerified'], 'capture grants no readiness or verified boundary');
$old = setupRecord($setup, $f, $now, [103 => ['updated' => $now - 61]]);
setupCheck($old['assessment']['status'] === 'quality_hold', 'stale physical source remains blocked');
$key = array_column($setup['capture']['accounting']['sources'], 'key', 'variableId')[103];
setupCheck($old['raw'][$key]['sourceUpdatedAt'] === $now - 61, 'original stale source timestamp retained');
$wrong = setupRecord($setup, $f, $now, [103 => ['ident' => 'reused']]);
setupCheck($wrong['assessment'] === null && $wrong['raw'][$key]['value'] === null, 'changed live identity cannot reuse old mapping');
$future = setupRecord($setup, $f, $now, [103 => ['updated' => $now + 1]]);
setupCheck($future['assessment'] === null, 'future source not freshened');
$discharge = setupRecord($setup, $f, $now, [101 => ['value' => -3000], 103 => ['value' => -2000]]);
setupCheck($discharge['assessment']['baseLoadCandidateW'] === 4000.0, 'discharge and export signs preserved');
foreach ([
['installationId', '../not-a-plant'], ['managerId', 0],
] as [$key, $bad]) {
$changed = $f; $changed[$key] = $bad;
setupRefuses(static fn() => setupCreate($changed), 'invalid ' . $key);
}
foreach ([0, '60', true] as $bad) {
$changed = $f; $changed['manager']['MesswertMaxAlter'] = $bad;
setupRefuses(static fn() => setupCreate($changed), 'invalid age ' . gettype($bad));
}
foreach ([0, true, '1', INF, NAN] as $bad) {
$changed = $f; $changed['manager']['Netzleistungsfaktor'] = $bad;
setupRefuses(static fn() => setupCreate($changed), 'invalid factor ' . gettype($bad));
}
foreach ([0, 3, '2', null] as $bad) {
$changed = $f; $changed['objects'][103]['type'] = $bad;
setupRefuses(static fn() => setupCreate($changed), 'non-numeric source type ' . gettype($bad));
}
foreach (['virtual', 'sharedStorage'] as $field) {
$changed = $f; $changed['objects'][103][$field] = true;
setupRefuses(static fn() => setupCreate($changed), 'explicit ' . $field . ' source rejected');
}
foreach (['SDLAktiv' => true, 'SDLLeistungVariableID' => 105] as $field => $value) {
$changed = $f; $changed['manager'][$field] = $value;
setupRefuses(static fn() => setupCreate($changed), 'SDL requires existing explicit mapping ' . $field);
}
$changed = $f; unset($changed['objects'][103]);
setupRefuses(static fn() => setupCreate($changed), 'missing object rejected');
$changed = $f; $changed['inverters'][0]['Typ'] = 'hybrid';
setupRefuses(static fn() => setupCreate($changed), 'ambiguous hybrid PV output rejected');
$changed = $f; $changed['batteries'][0]['Kopplung'] = 'dc';
setupRefuses(static fn() => setupCreate($changed), 'ambiguous DC power boundary rejected');
$changed = $f; $changed['batteries'][0]['LeistungVariableID'] = 101;
setupRefuses(static fn() => setupCreate($changed), 'grid source cannot also be physical battery');
$changed = $f; $changed['batteries'][] = $changed['batteries'][0]; $changed['batteries'][1]['ID'] = 'second';
setupRefuses(static fn() => setupCreate($changed), 'shared inverter cannot double count physical storage');
$changed = $f; $changed['batteries'][0]['WechselrichterID'] = 'absent';
setupRefuses(static fn() => setupCreate($changed), 'missing battery inverter rejected');
$changed = $f; $changed['inverters'][] = ['ID' => 'unassigned', 'Typ' => 'battery'];
setupRefuses(static fn() => setupCreate($changed), 'unassigned physical battery inverter rejected');
$changed = $f; $changed['inverters'][] = $changed['inverters'][0];
setupRefuses(static fn() => setupCreate($changed), 'duplicate inverter identifier rejected');
$changed = $f; $changed['inverters'] = [$changed['inverters'][1]];
setupRefuses(static fn() => setupCreate($changed), 'missing PV source is not invented as zero');
$c = $setup['capture']['accounting']; unset($c['storageMode']);
setupRefuses(static fn() => Accounting::konfigurieren($c), 'legacy mode retains explicit virtual source requirement');
$c = $setup['capture']['accounting']; $c['sources'][0]['role'] = 'controlled_virtual';
setupRefuses(static fn() => Accounting::konfigurieren($c), 'physical-only mode cannot hide virtual allocation');
try {
Setup::erstellen($f['installationId'], $f['managerId'], $f['manager'], $f['inverters'], $f['batteries'],
static function (int $id): array { throw new RuntimeException('do-not-export'); });
throw new RuntimeException('Expected reader failure');
} catch (InvalidArgumentException $e) {
setupCheck(!str_contains($e->getMessage(), 'do-not-export'), 'reader errors do not expose secrets');
}
echo "TOTAL $count setup checks passed; synthetic metadata and measurements only.\n";
+88
View File
@@ -0,0 +1,88 @@
"""Check literal Symcon registrations and reviewed dynamic families against READMEs.
This is a documentation-presence check, not a PHP evaluator or hardware test.
Only repository sources are read; no credentials, runtime state or device APIs.
"""
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULES = {
'Manager': 'Manager', 'Batterie': 'Batterie',
'Warmwassererwaermer': 'Wassererwaermer', 'Pufferspeicher': 'Pufferspeicher',
'VerbraucherEinStufig': 'Verbraucher-1-Stufig', 'Waermepumpe': 'Waermepumpe',
'LadestationStandAlone': 'Ladestation-Stand-Alone',
'LadestationGateway': 'Ladestation-Gateway', 'EaseeGateway': 'Easee-Gateway',
'Energiediagramm': 'Energiediagramm', 'CC100Hardware': 'CC100-Hardware',
'ShellyModul': 'Shelly-Modul', 'Verbrauchskostenreport': 'Verbrauchskostenreport',
'VirtuelleBatterie': 'Virtuelle-Batterie', 'VGTSchnittstelle': 'VGT-Schnittstelle',
}
DYNAMIC = {
'Energiediagramm': ['FarbeEigenverbrauchRGB', 'FarbeAutarkieRGB',
'FarbeHintergrundRGB', 'FarbeHintergrundAkzent1RGB', 'FarbeHintergrundAkzent2RGB'],
'Manager': ['PVLeistungArchiv', 'HausverbrauchLeistungArchiv', 'NetzleistungArchiv',
'BatterieleistungArchiv', 'SDLLeistungArchiv', 'PVEnergie', 'Hausenergie',
'NetzbezugEnergie', 'EinspeisungEnergie', 'BatterieLadenEnergie',
'BatterieEntladenEnergie', 'SDLLadenEnergie', 'SDLEntladenEnergie',
'EnergieflussPV', 'EnergieflussNetz', 'EnergieflussHaus', 'EnergieflussBatterie',
'EnergieflussSDL', 'EnergieflussIst_', 'EnergieflussSoll_', 'DiagrammHausLeistung',
'DiagrammHausEnergie', 'DiagrammPVLeistung_', 'DiagrammPVEnergie_',
'DiagrammBatterieLeistung_', 'DiagrammBatterieLaden_', 'DiagrammBatterieEntladen_',
'DiagrammVerbraucherLeistung_', 'DiagrammVerbraucherEnergie_'],
'CC100Hardware': ['DI1', 'DI8', 'DO1', 'DO4', 'AI1', 'AI2', 'AO1', 'AO2', 'PT1', 'PT2'],
'ShellyModul': ['Typabbildung', 'Hash', 'switch:0.aenergy.total', 'Online', 'Switch.Set'],
}
def sources(path, seen=None):
seen = set() if seen is None else seen
path = path.resolve()
if path in seen:
return
seen.add(path)
text = path.read_text(encoding='utf-8')
yield text
for match in re.finditer(r"require_once\s+__DIR__\s*\.\s*['\"]([^'\"]+)['\"]", text):
target = (path.parent / match[1].lstrip('/')).resolve()
if ROOT in target.parents and target.is_file() and 'vendor' not in target.parts:
yield from sources(target, seen)
def main():
modules = sorted(ROOT.glob('*/module.php'))
errors, checked, dynamic = [], 0, 0
for source in modules:
name = source.parent.name
if name not in MODULES:
errors.append(name + ': missing documentation mapping')
continue
document = ROOT / 'docs/module' / MODULES[name] / 'README.md'
if not document.is_file() or not (source.parent / 'README.md').is_file():
errors.append(name + ': README missing')
continue
text = document.read_text(encoding='utf-8')
shared = ROOT / 'docs/Schnittstelle.md'
if name not in ['Manager', 'EaseeGateway'] and shared.exists():
text += shared.read_text(encoding='utf-8')
fields = set()
for code in sources(source):
fields.update(re.findall(r"->Register(Property|Variable)(?:Boolean|Integer|Float|String)\s*\(\s*['\"]([^'\"]+)['\"]", code))
for kind, ident in sorted(fields):
checked += 1
if ident not in text:
errors.append(name + ': undocumented ' + kind + ' ' + ident)
for ident in DYNAMIC.get(name, []):
dynamic += 1
if ident not in text:
errors.append(name + ': undocumented dynamic family ' + ident)
if not modules:
errors.append('No installable module sources found')
for error in errors:
print(error)
print(f'{len(modules)} modules; {checked} literal registrations; {dynamic} dynamic markers; {len(errors)} gaps')
return bool(errors)
if __name__ == '__main__':
sys.exit(main())
+6
View File
@@ -37,6 +37,8 @@ would drift from checkout. The browser refreshes the same catalog endpoint every
- `/docs/utils.html`
- `/docs/faq.html`
- `/docs/preise.html`
- `/docs/versionen.html`: channel availability, new features and known limitations.
- `/docs/ems/changelog.html` and `/docs/utils/changelog.html`: reviewed Git change history.
- `/docs/ems/*.html` and `/docs/utils/*.html`
- `/docs/manifest.json`: published source commits and file mappings.
- `/docs-status.json`: last attempted/successful synchronization, no internal errors.
@@ -50,6 +52,10 @@ The current page's group opens on the server, also without JavaScript. Search
expands matching groups and restores their previous state when cleared. All
page URLs remain unchanged; new references without a group stay accessible.
Explicit links to a different Git branch keep their external Git target.
Only references to the rendered channel are mapped to local documentation;
Stable and Beta comparisons must not silently open the Testing page.
`assets/portal-links.css` contains only the added navigation styling.
`portal-navigation.patch` records the exact additive portal HTML change; check
it against the current portal before applying it and copy the stylesheet to
+4 -3
View File
@@ -55,8 +55,8 @@ article pre code { padding:0; }
.heading-anchor { padding-left:8px; opacity:0; font-weight:400; text-decoration:none; }
h1:hover .heading-anchor,h2:hover .heading-anchor,h3:hover .heading-anchor,.heading-anchor:focus { opacity:1; }
.table-scroll { max-width:100%; overflow-x:auto; margin:20px 0; }
table { border-collapse:collapse; width:100%; font-size:13px; line-height:1.55; text-align:left; }
th { color:#284a45; background:#eef4f1; font-weight:700; }
table { border-collapse:collapse; width:100%; min-width:640px; font-size:13px; line-height:1.55; text-align:left; }
th { color:#284a45; background:#eef4f1; font-weight:700; overflow-wrap:normal; }
th,td { padding:12px; border-bottom:1px solid var(--line); vertical-align:top; }
article table code { overflow-wrap:normal; }
.unpublished { color:inherit; }
@@ -84,5 +84,6 @@ footer p { margin:4px 0; }
[hidden] { display:none!important; }
@media (max-width:1150px) { .docs-layout { grid-template-columns:228px minmax(0,1fr); } .contents { display:none; } main { padding:28px 36px; } }
@media (max-width:720px) { .topbar { position:relative; padding:16px; flex-wrap:wrap; } .topbar nav { gap:20px; font-size:13px; width:100%; justify-content:space-between; } .portal-link { border:0; padding:0; } .docs-layout { display:block; } .sidebar { position:relative; top:0; height:auto; border:0; border-bottom:1px solid var(--line); padding:0 20px; } #docs-menu > summary { display:list-item; padding:15px 0; font-weight:650; cursor:pointer; font-size:14px; } #docs-menu[open] { padding-bottom:20px; } .sidebar nav { display:block; } .sidebar section { margin:16px 0 0; } .sidebar a,.nav-folder > summary { min-height:40px; } main { padding:24px 20px 40px; } article h1 { font-size:29px; } article h2 { font-size:21px; } article { font-size:15px; } .sync-status { margin-bottom:24px; } .price-table { min-width:570px; } article h1,article h2,article h3 { scroll-margin-top:20px; } }
@media print { .topbar,.sidebar,.contents,.price-toolbar,.heading-anchor { display:none; } .docs-layout { display:block; } main { padding:0; } .table-scroll { overflow:visible; } article { font-size:11px; } a { color:inherit; } }
@media (max-width:380px) { article h1 { font-size:26px; } article h1 .heading-anchor { display:none; } }
@media print { .topbar,.sidebar,.contents,.price-toolbar,.heading-anchor { display:none; } .docs-layout { display:block; } main { padding:0; } .table-scroll { overflow:visible; } table { min-width:0; } article { font-size:11px; } a { color:inherit; } }
@media (prefers-reduced-motion:reduce) { * { transition:none!important; scroll-behavior:auto!important; } }
+3 -1
View File
@@ -1,6 +1,7 @@
export const repositories = [
{ id: 'ems', name: 'Enelix-EMS', url: 'https://git.belevo.ch/ENELIX/Enelix-EMS.git', pages: [
['README.md', 'Enelix EMS', 'ems/index.html'],
['CHANGELOG.md', 'EMS-Änderungen', 'ems/changelog.html'],
['docs/module/README.md', 'EMS-Modulübersicht', 'ems/module.html'],
...['Manager', 'Batterie', 'Wassererwaermer', 'Pufferspeicher', 'Verbraucher-1-Stufig', 'Waermepumpe', 'Ladestation-Stand-Alone', 'Ladestation-Gateway', 'Easee-Gateway'].map(name => [`docs/module/${name}/README.md`, name, `ems/${name}.html`]),
['docs/Schnittstelle.md', 'EMS-Schnittstelle', 'ems/schnittstelle.html'],
@@ -9,6 +10,7 @@ export const repositories = [
] },
{ id: 'utils', name: 'Enelix-Utils', url: 'https://git.belevo.ch/ENELIX/Enelix-Utils.git', pages: [
['README.md', 'Enelix Utils', 'utils/index.html'],
['CHANGELOG.md', 'Utils-Änderungen', 'utils/changelog.html'],
['docs/module/README.md', 'Utils-Modulübersicht', 'utils/module.html'],
...['Energiediagramm', 'Shelly-Modul', 'Verbrauchskostenreport', 'CC100-Hardware', 'Virtuelle-Batterie', 'VGT-Schnittstelle'].map(name => [`docs/module/${name}/README.md`, name, `utils/${name}.html`])
] }
@@ -17,5 +19,5 @@ export const repositories = [
export const guides = [
['index', 'Enelix verstehen'], ['erste-schritte', 'Erste Schritte'], ['installation', 'Installation'], ['manager', 'Manager bedienen'],
['verbraucher', 'Verbraucher einrichten'], ['utils', 'Utils nutzen'],
['faq', 'Häufige Fragen'], ['preise', 'Preise']
['versionen', 'Versionen und Fehlerstatus'], ['faq', 'Häufige Fragen'], ['preise', 'Preise']
];
+5 -3
View File
@@ -12,8 +12,10 @@ export function resolveLink(href, page, pages) {
const [path, anchor] = href.split('#', 2);
let key;
if (/^https:\/\/git\.belevo\.ch\/ENELIX\/(Enelix-EMS|Enelix-Utils)\/src\/branch\/[^/]+\//.test(path)) {
const match = path.match(/^https:\/\/git\.belevo\.ch\/ENELIX\/(Enelix-EMS|Enelix-Utils)\/src\/branch\/[^/]+\/(.+)$/);
key = `${match[1]}/${match[2]}`;
const match = path.match(/^https:\/\/git\.belevo\.ch\/ENELIX\/(Enelix-EMS|Enelix-Utils)\/src\/branch\/([^/]+)\/(.+)$/);
// A deliberate channel comparison must not silently link to the current channel.
if (match[2] !== (page.branch || 'develop')) return href;
key = `${match[1]}/${match[3]}`;
} else if (/^https:\/\//.test(href)) {
try { const url = new URL(href); return url.username || url.password ? null : url.href; } catch { return null; }
} else if (/^[\w+.-]+:|^\/\//.test(href) || path.startsWith('/')) return null;
@@ -91,7 +93,7 @@ export function renderNavigation(current, pages) {
}
export function renderPage(page, pages, manifest, assetVersion) {
const { html, toc } = renderMarkdown(page, pages, manifest.images || []);
const { html, toc } = renderMarkdown({ ...page, branch:manifest.branch }, pages, manifest.images || []);
const isPrices = page.output === 'preise.html';
const nav = renderNavigation(page, pages);
const source = page.commit ? `${page.repo} · ${page.commit.slice(0, 12)}` : 'Redaktioneller Entwurf · Git-Veröffentlichung ausstehend';
+12
View File
@@ -8,6 +8,18 @@ import { repositories } from '../config.mjs';
const page = { repo:'Enelix-EMS', source:'docs/public/manager.md', output:'manager.html', title:'Manager', group:'Anleitungen', markdown:'# Manager\n\n## Einrichten\n\nInhalt.', commit:'a'.repeat(40) };
const targets = [page, { repo:'Enelix-EMS', source:'docs/module/Manager/README.md', output:'ems/Manager.html' }, { repo:'Enelix-Utils', source:'docs/module/Energiediagramm/README.md', output:'utils/Energiediagramm.html' }];
test('relative README links remain public', () => assert.equal(resolveLink('../module/Manager/README.md#variablen', page, targets), '/docs/ems/Manager.html#variablen'));
test('explicit other-channel links retain their Git target', () => {
for (const branch of ['main', 'beta']) {
const href = `https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/${branch}/docs/module/Manager/README.md#variablen`;
assert.equal(resolveLink(href, { ...page, branch:'develop' }, targets), href);
}
});
test('same-channel links map locally and respect the manifest branch', () => {
const href = 'https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/beta/docs/module/Manager/README.md';
assert.equal(resolveLink(href, { ...page, branch:'beta' }, targets), '/docs/ems/Manager.html');
const html = renderPage({ ...page, markdown:`# Channels\n\n[Beta](${href})` }, targets, { branch:'beta', checkedAt:'2026-10-07T00:00:00Z' }, '123456');
assert.match(html, /href="\/docs\/ems\/Manager.html"/);
});
test('cross-repository links remain public', () => assert.equal(resolveLink('../../../Enelix-Utils/docs/module/Energiediagramm/README.md', page, targets), '/docs/utils/Energiediagramm.html'));
test('private and unsafe targets are not published', () => {
for (const href of ['../NETPLAN_V4_AGENT_HANDOFF.md', '../Offene-Punkte.md', 'javascript:alert(1)', '//evil.example', 'data:text/html,hello', 'https://user:pass@example.com', '/srv/agent/.netrc']) assert.equal(resolveLink(href, page, targets), null);