diff --git a/Manager/module.php b/Manager/module.php
index b0a5863..b509adb 100644
--- a/Manager/module.php
+++ b/Manager/module.php
@@ -276,6 +276,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->initialisiereLizenzInstallationID();
$this->konfiguriereNetzfahrplanV4Daten();
$this->aktualisiereVariablen();
+ foreach (ManagerPrognoseFormular::laufzeitAnzeige() as $ident => $name) {
+ $objektID = $this->GetIDForIdent($ident);
+ IPS_SetName($objektID, $name);
+ IPS_SetHidden($objektID, true);
+ }
try {
$this->pruefeKonfiguration();
diff --git a/docs/FORECAST_SDL_RELEASE_20261006.md b/docs/FORECAST_SDL_RELEASE_20261006.md
new file mode 100644
index 0000000..ee15022
--- /dev/null
+++ b/docs/FORECAST_SDL_RELEASE_20261006.md
@@ -0,0 +1,98 @@
+# Forecast- und SDL-Integration, 6. Oktober 2026
+
+## Auftrag und Umfang
+
+Daniel Haefliger hat die Weiterfuehrung passender unveroeffentlichter Arbeiten,
+die Integration der Prognosebedienung, SDL-Anzeigen und die Veroeffentlichung
+auf develop und beta ausdruecklich beauftragt. main bleibt unveraendert.
+
+- Manager: Prognose / Forecast ist der gemeinsame Bedienort. Die bisherigen
+ separaten Diagnosevariablen sind umbenannt und in der Visualisierung verborgen.
+- Batterie: die bereits integrierte Bereinigung des separaten V4-Formulars bleibt
+ erhalten. Bestehende Idents, APIs und gespeicherte Freigaben werden aus
+ Kompatibilitaetsgruenden nicht umbenannt oder geloescht.
+- Portal: derselbe Planner erscheint im vorhandenen Prognosebereich. Die exakte
+ ausgewaehlte Eingangsprognose wird als forecastPoints eingefroren, unabhaengig
+ vom durch publizierte Preise begrenzten Optimierungshorizont. Netzplan,
+ Batteriestellwerte und Kosten werden nicht kuenstlich verlaengert.
+- Diagramme zeigen PV, Last, SDL-Szenario, Netz, Batterie, SOC, Preise und Kosten.
+ Fehlende Werte bleiben Luecken. Prognosewerte sind keine gemessenen Erfolge.
+- Utils kapselt die generische Darstellung nicht bestimmbarer Energieanteile.
+ EMS entscheidet bei SDL-Bilanzierung, dass die Haus-PV-Anteile nicht eindeutig
+ aus dem SDL-haltigen Netzbezug hergeleitet werden koennen. Zaehler bleiben sichtbar.
+
+## Migration und Erhaltung
+
+Utils vor EMS aktualisieren. Bei alten Utils blendet EMS den eigenen Energy Pie
+bei aktiver SDL aus und protokolliert den Aktualisierungsbedarf, statt falsche
+Prozentwerte anzuzeigen oder die Manager-Regelung zu stoppen.
+
+Bestehende Energy-Pie-Konfiguration, individuelle Flow-Knoten und Nicht-SDL-
+Diagrammreihen bleiben erhalten. Visualisierungsschalter nicht aus-/einschalten:
+das wuerde teilweise eine Neuerzeugung ausloesen. Historien, Energiezaehler,
+Mess-Outbox und Betriebsfreigaben werden nicht zurueckgesetzt.
+
+Die Testanlage wurde mit gesicherten, hashgeprueften Quelldateien aktualisiert.
+Manager 17004 verwendet SDL-Leistung 25085 (W, Faktor +1) und SOC 23879 (%).
+Diese Werte gehoeren zur vorhandenen virtuellen EV/SDL-Aufteilung und sind kein
+unabhaengiger physischer SDL-Messnachweis. Der einzelne Batterie-Flow-Knoten wurde
+von Proxy 19274 auf 42728 umgestellt; alle anderen Felder wurden erhalten.
+Flow 57933 enthaelt zusaetzlich SDL. Leistungsdiagramm 53754 wuchs von vier auf
+sechs Reihen, Energiediagramm 19668 von sechs auf neun. Keine Alt-Reihe entfernt.
+
+Private Rueckfallsicherung auf der Anlage:
+`/var/lib/symcon/backup/enelix-forecast-sdl-20261006T171851Z`.
+Sie enthaelt Konfigurationen und darf nicht in Git, Logs oder Chat kopiert werden.
+Der temporaere Beobachtungshook 12555 wurde wiederhergestellt.
+
+## Pruefungen
+
+- EMS PHPUnit: 360 Tests, 1771 Assertions, erfolgreich.
+- Utils PHPUnit: 58 Tests, 350 Assertions, erfolgreich.
+- Backend: 312 Tests, erfolgreich, isolierter aktueller develop-Stand mit Patch.
+- Formular: 71 Pruefungen; RequestAction: 26; Regeltests: 96; Empfaenger: 32.
+- Chart- und Energy-Pie-JavaScript: 14 Tests, erfolgreich.
+- Portal-Mount: acht Tests, erfolgreich, inklusive Anlagen-/Ansichtswechsel,
+ Abmeldung, verspaeteter Antworten und unabhaengiger Fehlerbehandlung.
+- Echter Chromium: Desktop 1280 px, Mobil 390 und 320 px; Kurven, Auswahl,
+ Maus/Tastatur, Lebenszyklus, CSS-Abgrenzung und Screenshots erfolgreich.
+- Testanlage nach Installation: Manager, Batterie, Energy Pie und Flow Status 102;
+ SDL-Quellen und erhaltene Diagrammreihen kontrolliert.
+
+## Offene Betriebsschritte, keine Produktionsfreigabe
+
+Die Nachkontrolle 2026-10-06 17:26:21 UTC zeigte den bekannten Symcon-Timerfehler
+nach Module Control Reload: alter Batterie-Timer 70 blieb laufend, neue
+Melde-/Rueckmeldetimer hatten noch keinen Lauf. Ein kontrollierter Symcon-Neustart
+und danach erneute Pruefung sind erforderlich. Status 102 allein ist kein Beleg
+fuer funktionierende Timer. Die neue Netzfahrplansteuerung 46716 bleibt false.
+
+Portal-Assets wurden ohne Containerneustart aktualisiert. Das Backend-Image ist
+noch nicht aktualisiert: Docker-Socketzugriff fuer agent ist nicht erlaubt.
+Vorbereitete, erfolgreich vorgepruefte Administratoruebergabe auf enelix-services:
+
+```sh
+sudo python3 /srv/agent/forecast-completion-20261006/deploy_forecast_backend.py --apply
+```
+
+Das Skript prueft zwei exakte Quelldateien, sichert Datenbank und Quellen,
+baut/testet das Image vor dem Austausch und bewahrt die bisherige Image-ID fuer
+Rollback. Keine Socketrechte werden geaendert. Keine Stellfreigabe wird erteilt.
+Nach erfolgreicher Installation Neuberechnung und neue forecastPoints pruefen.
+Bis dahin kennzeichnet die Anzeige alte Plaene und zeigt keine erfundenen Werte.
+
+Unbefristeter Betrieb ist NICHT freigegeben: Nachweis eines geraeteseitigen
+Ausfallschutzes, physischer Soll-/Ist-Nachlauf, sichere Null-/Stop-Reaktion und
+Fortfuehrung ueber mehrere Planerneuerungen fehlen weiterhin. Die bestehende
+befristete Zustimmung bis 7. Oktober 2026, 17:38:07 Europe/Zurich wird nicht
+verlaengert. EV-Konto bleibt 161.44 kWh / 39 kW; physische Gesamtkapazitaet und
+virtuelle SDL-Energie werden nicht als zusaetzliche EV-Energie angesetzt.
+
+## Pull-Request-Text
+
+Forecast-Bedienung im Manager konsolidieren, vollstaendige gespeicherte
+Eingangsprognose im bestehenden Portal einbetten und SDL-Anzeigen vervollstaendigen.
+Individuelle Visualisierungen und kompatible Kennungen bleiben bestehen;
+nicht bestimmbare Energieanteile werden ehrlich als unbekannt dargestellt.
+Automatisierte Tests und responsive Browserpruefung erfolgreich. Zwei explizite
+Betriebsblocker bleiben dokumentiert; kein unbegrenzter Anlagenbetrieb aktiviert.
diff --git a/docs/NETPLAN_V4_AGENT_HANDOFF.md b/docs/NETPLAN_V4_AGENT_HANDOFF.md
index b2209b5..1c31c7b 100644
--- a/docs/NETPLAN_V4_AGENT_HANDOFF.md
+++ b/docs/NETPLAN_V4_AGENT_HANDOFF.md
@@ -1,5 +1,24 @@
# ENELIX Netzfahrplan V4 – verbindlicher Gesprächs- und Arbeitskontext
+## Fortsetzung 06.10.2026, 17:32 UTC: Forecast/SDL installiert, Betriebsblocker
+
+Massgeblicher neuer Bericht: `docs/FORECAST_SDL_RELEASE_20261006.md`.
+Portal-Assets sind auf license.enelix.ch im vorhandenen Prognosebereich aktiv
+und ueber HTTPS hashgleich geprueft. Vollstaendige forecastPoints benoetigen noch
+das vorbereitete Backend-Image-Update durch einen Administrator. agent hat keine
+Docker-Socketrechte; keine Umgehung, kein ausgefuehrtes Backend-Deployment.
+
+Testanlage: Utils und EMS aus gesichertem Kandidat geladen, SDL-Messung und
+Anzeigen eingerichtet, individuelle Reihen/Knoten und Historien erhalten.
+Alte Diagnoseanzeigen und die zwei V4-Diagnosekategorien verborgen, nichts
+geloescht oder deren Beobachter abgeschaltet. Hook 12555 wiederhergestellt.
+Nach Reload haengt erneut alter Batterietimer 70; neue Batterie-Meldetimer
+laufen nicht. Kontrollierter Symcon-Neustart und Nachkontrolle erforderlich.
+Schalter 46716 bleibt false; keine neue Stellprobe, keine Fristverlaengerung.
+Unbefristete Steuerung ist ohne unabhaengigen Ausfallschutz nicht freigegeben.
+Die automatisierten Softwaretests und responsive Chromium-Pruefung sind gruen;
+dies ist ausdruecklich keine erfolgreiche physische Anlagenabnahme.
+
## Integration 06.10.2026: Freigabe fuer develop und beta, kein Anlagenrollout
Daniel hat nach der Bedienintegration Merge, Commit und Push auf **develop und
diff --git a/libs/ManagerEnergieTrait.php b/libs/ManagerEnergieTrait.php
index a19fb43..78a7ae4 100644
--- a/libs/ManagerEnergieTrait.php
+++ b/libs/ManagerEnergieTrait.php
@@ -273,8 +273,9 @@ trait ManagerEnergieTrait
throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.');
}
IPS_SetHidden($instanzID, false);
+ $this->synchronisiereEnergyPieAnteile($instanzID);
- return 'Bestehende Energy-Pie-Konfiguration bleibt unveraendert.';
+ return 'Bestehende Energy-Pie-Messquellen und Darstellung bleiben unveraendert.';
}
IPS_SetName($instanzID, 'Energy Pie');
IPS_SetPosition($instanzID, 500);
@@ -288,11 +289,35 @@ trait ManagerEnergieTrait
] as $property => $wert) {
IPS_SetProperty($instanzID, $property, $wert);
}
+ $this->synchronisiereEnergyPieAnteile($instanzID, false);
IPS_ApplyChanges($instanzID);
return 'Energy Pie wurde erstellt oder aktualisiert.';
}
+ private function synchronisiereEnergyPieAnteile(int $instanzID, bool $anwenden = true): void
+ {
+ // Physischer Netzbezug enthaelt SDL, die Hausenergie dagegen nicht.
+ $berechenbar = !$this->ReadPropertyBoolean('SDLAktiv');
+ $konfiguration = json_decode(IPS_GetConfiguration($instanzID), true, 512, JSON_THROW_ON_ERROR);
+ if (!is_array($konfiguration) || !array_key_exists('EnergieanteileBerechenbar', $konfiguration)) {
+ IPS_SetHidden($instanzID, !$berechenbar);
+ if (!$berechenbar) {
+ IPS_LogMessage('Enelix Manager ' . $this->InstanceID,
+ 'Energy Pie ausgeblendet: Enelix Utils aktualisieren, damit bei aktiver SDL keine unbestimmbaren Energieanteile angezeigt werden.');
+ }
+ return;
+ }
+ IPS_SetHidden($instanzID, false);
+ if (IPS_GetProperty($instanzID, 'EnergieanteileBerechenbar') === $berechenbar) {
+ return;
+ }
+ IPS_SetProperty($instanzID, 'EnergieanteileBerechenbar', $berechenbar);
+ if ($anwenden) {
+ IPS_ApplyChanges($instanzID);
+ }
+ }
+
private function erstelleEnergiefluss(): string
{
if (!in_array(self::ENERGY_FLOW_MODULE_ID, IPS_GetModuleList(), true)) {
diff --git a/libs/ManagerNetzfahrplanV4AktivtestTrait.php b/libs/ManagerNetzfahrplanV4AktivtestTrait.php
index c6a8e9d..4de49a7 100644
--- a/libs/ManagerNetzfahrplanV4AktivtestTrait.php
+++ b/libs/ManagerNetzfahrplanV4AktivtestTrait.php
@@ -23,7 +23,7 @@ trait ManagerNetzfahrplanV4AktivtestTrait
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestEnde', 0);
$this->RegisterVariableBoolean(
'NetzfahrplanV4Aktivtest',
- 'V4 Prognosemanager aktiv (Testbetrieb)',
+ 'Netzfahrplansteuerung (Testbetrieb)',
'~Switch',
952
);
diff --git a/libs/ManagerNetzfahrplanV4DatenTrait.php b/libs/ManagerNetzfahrplanV4DatenTrait.php
index 98a4a14..dc9b32a 100644
--- a/libs/ManagerNetzfahrplanV4DatenTrait.php
+++ b/libs/ManagerNetzfahrplanV4DatenTrait.php
@@ -21,7 +21,7 @@ trait ManagerNetzfahrplanV4DatenTrait
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersion', 0);
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
$this->RegisterAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
- $this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'V4 Messdaten und Modellversorgung', '', 199);
+ $this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'Prognosedaten und Modellversorgung', '', 199);
$this->RegisterTimer('NetzfahrplanV4DatenErfassen', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4DatenErfassen', true);");
}
diff --git a/libs/ManagerNetzfahrplanV4EmpfangTrait.php b/libs/ManagerNetzfahrplanV4EmpfangTrait.php
index d66bc4d..eb215b0 100644
--- a/libs/ManagerNetzfahrplanV4EmpfangTrait.php
+++ b/libs/ManagerNetzfahrplanV4EmpfangTrait.php
@@ -20,8 +20,8 @@ trait ManagerNetzfahrplanV4EmpfangTrait
$this->RegisterAttributeString('NetzfahrplanV4EmpfangStatus', '{}');
$this->RegisterAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
$this->RegisterAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
- $this->RegisterVariableString('NetzfahrplanV4Vorschau', 'V4 Sollwert-Vorschau (KEINE Stellfreigabe)', '~HTMLBox', 950);
- $this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'V4 Vorschau Diagnose (nur lesen)', '', 951);
+ $this->RegisterVariableString('NetzfahrplanV4Vorschau', 'Netzfahrplanvorschau (keine Stellfreigabe)', '~HTMLBox', 950);
+ $this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'Netzfahrplandiagnose', '', 951);
$this->RegisterTimer('NetzfahrplanV4VorschauAktualisieren', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4VorschauAktualisieren', true);");
$this->RegisterTimer('NetzfahrplanV4EmpfangAktualisieren', 0,
@@ -256,7 +256,7 @@ trait ManagerNetzfahrplanV4EmpfangTrait
$value = array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null], $value,
['canDispatch' => false, 'liveEnabled' => false]);
$escape = static fn($v): string => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
- $html = '
V4: NUR VORSCHAU - KEINE STELLFREIGABE ';
+ $html = 'Netzfahrplanvorschau - keine Stellfreigabe ';
foreach (['status' => 'Status', 'planId' => 'Plan-ID', 'family' => 'Modellfamilie', 'step' => 'Zeitschritt (UTC)',
'stepValidUntil' => 'Gueltig bis', 'gridTargetW' => 'Geplantes Netzziel W', 'plannedBatteryW' => 'Geplante Batterie W',
'previewBatteryW' => 'Lokal berechnete Batterie W (nicht ausgegeben)', 'reason' => 'Ablehnungsgrund'] as $key => $label) {
diff --git a/libs/ManagerPrognoseFormular.php b/libs/ManagerPrognoseFormular.php
index fe36a24..ed85c45 100644
--- a/libs/ManagerPrognoseFormular.php
+++ b/libs/ManagerPrognoseFormular.php
@@ -9,6 +9,17 @@ use LogicException;
/** Form composition only. Existing configuration, control authority and backend stay unchanged. */
final class ManagerPrognoseFormular
{
+ /** Stable identifiers remain for archives and integrations; operation belongs in Forecast. */
+ public static function laufzeitAnzeige(): array
+ {
+ return [
+ 'NetzfahrplanV4Aktivtest' => 'Netzfahrplansteuerung (Testbetrieb)',
+ 'NetzfahrplanV4Vorschau' => 'Netzfahrplanvorschau (keine Stellfreigabe)',
+ 'NetzfahrplanV4VorschauJSON' => 'Netzfahrplandiagnose',
+ 'NetzfahrplanV4Datenstatus' => 'Prognosedaten und Modellversorgung',
+ ];
+ }
+
public static function startErlaubt(array $zustand): bool
{
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
diff --git a/services/license/changes/20261006-forecast-integration/README.md b/services/license/changes/20261006-forecast-integration/README.md
new file mode 100644
index 0000000..1da1332
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/README.md
@@ -0,0 +1,17 @@
+# Prognose im bestehenden Portal
+
+Versionierte statische Assets fuer den Prognosebereich von license.enelix.ch.
+Der neue Planner teilt die gewaehlte Anlage und Sitzung des Portals. Die
+bestehende historische Anzeige bleibt separat verfuegbar. Es gibt keine
+Aktivierungsanfrage an Geraete oder Trial-Endpunkte.
+
+`deploy.py` prueft ohne Argumente die Ausgangs- und Zielhashes. `--apply` sichert
+die vorhandenen Assets und tauscht nur die sechs aufgefuehrten Dateien aus.
+Ein abweichender Ausgangsstand fuehrt zum Abbruch, nicht zum Ueberschreiben.
+Nach erfolgter Installation ist erneuter Aufruf absichtlich nicht erforderlich.
+Neuere Branding-Assets und deren Einbindung bleiben erhalten.
+
+Pruefungen: `node --test portal-integration.test.mjs`; weitere Chart- und
+Browserpruefungen liegen unter `services/netplan-v4/gui`.
+Der Releasebericht unter `docs/FORECAST_SDL_RELEASE_20261006.md` benennt den
+noch ausstehenden Backend-Neustand und die Betriebsgrenzen der Testanlage.
diff --git a/services/license/changes/20261006-forecast-integration/deploy.py b/services/license/changes/20261006-forecast-integration/deploy.py
new file mode 100644
index 0000000..f524fef
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/deploy.py
@@ -0,0 +1,62 @@
+"""Deploy static assets only. Abort on drift; keep a private rollback snapshot."""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import time
+
+parser = argparse.ArgumentParser()
+parser.add_argument('--apply', action='store_true')
+args = parser.parse_args()
+package = Path(__file__).resolve().parent
+root = Path('/home/agent/services/license')
+manifest = json.loads((package / 'manifest.json').read_text())
+sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None
+for entry in manifest:
+ relative = Path(entry['path'])
+ if relative.is_absolute() or '..' in relative.parts or relative.parts[0] != 'public':
+ raise SystemExit('Unsafe manifest path')
+ target = root / relative
+ if target.is_symlink() or sha(target) != entry['before']:
+ raise SystemExit('Runtime drift: ' + str(relative))
+ if sha(package / relative) != entry['after']:
+ raise SystemExit('Release checksum mismatch: ' + str(relative))
+if not args.apply:
+ print(json.dumps({'preflight': 'ok', 'files': len(manifest)}))
+ raise SystemExit(0)
+backup = Path('/srv/agent/forecast-completion-20261006') / ('portal-backup-' + str(time.time_ns()))
+backup.mkdir(mode=0o700)
+for entry in manifest:
+ source = root / entry['path']
+ if source.exists():
+ target = backup / entry['path']
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, target)
+shutil.copyfile(package / 'manifest.json', backup / 'manifest.json')
+written = []
+try:
+ # New imported dependencies first; the HTML entrypoint is switched last.
+ order = sorted(manifest, key=lambda e: (e['path'].endswith('index.html'), e['path'].endswith('app.js')))
+ for entry in order:
+ target = root / entry['path']
+ if sha(target) != entry['before']:
+ raise RuntimeError('Concurrent change: ' + entry['path'])
+ temp = target.with_name(target.name + '.forecast-new')
+ with temp.open('xb') as stream:
+ stream.write((package / entry['path']).read_bytes())
+ os.chmod(temp, target.stat().st_mode & 0o777 if target.exists() else 0o644)
+ os.replace(temp, target)
+ written.append(entry)
+ if any(sha(root / entry['path']) != entry['after'] for entry in manifest):
+ raise RuntimeError('Post-deployment checksum mismatch')
+except Exception:
+ for entry in reversed(written):
+ target = root / entry['path']
+ if sha(target) != entry['after']:
+ continue
+ if entry['before'] is not None:
+ shutil.copy2(backup / entry['path'], target)
+ raise
+print(json.dumps({'deployed': True, 'files': len(written), 'backup': str(backup)}))
diff --git a/services/license/changes/20261006-forecast-integration/manifest.json b/services/license/changes/20261006-forecast-integration/manifest.json
new file mode 100644
index 0000000..74d43ac
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/manifest.json
@@ -0,0 +1,32 @@
+[
+ {
+ "path": "public/app.js",
+ "before": "b4828a1bd58a5cc29a3970475c8eebadf89a3fda788c269d6951572603fa5dd9",
+ "after": "95a5a0b0958ad0bd7b444225b90598c2a8c2e92b54631265211bd26b520cdd56"
+ },
+ {
+ "path": "public/index.html",
+ "before": "7010e36746b6739d50c13b91cd2ad29de1ced2bc4ca42b49b3f43cc66f9c1ec2",
+ "after": "26ed3c78ce3deff774f5016357928120d764e434fbe844374656953791d021b0"
+ },
+ {
+ "path": "public/netplan-v4.js",
+ "before": "245af626f3322dcaec5afbbc9772707ee6de00f07fd939e59709e7349f2d12d9",
+ "after": "9c17da0bc6014c1dc09a84bb6015a6af160c59a07c8acda096da9f4e72b3b55d"
+ },
+ {
+ "path": "public/netplan-v4.css",
+ "before": "f7e55386157aa64db5be62319b9f37e27052f0f001fb28f8233d886d3f9b40b5",
+ "after": "1189b8d9b7a092bbdff5ec77a99207d1f0fecab56357a44b856ed161ab80cabe"
+ },
+ {
+ "path": "public/netplan-v4.html",
+ "before": "5ee72a589f12cb30ae14cc484ac406788affe530db4a25dfec3ffb3c79beb924",
+ "after": "df2414f0854743595ebf2eaaf1f49d546e90cb18b71dc86b16b5c1f193cdf960"
+ },
+ {
+ "path": "public/forecast-chart.js",
+ "before": null,
+ "after": "2d51aabd27156c581c31eaffe138a987208b86490f22df2ef98b37c1c018ff4b"
+ }
+]
diff --git a/services/license/changes/20261006-forecast-integration/portal-integration.test.mjs b/services/license/changes/20261006-forecast-integration/portal-integration.test.mjs
new file mode 100644
index 0000000..2cdb6ce
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/portal-integration.test.mjs
@@ -0,0 +1,157 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import vm from 'node:vm';
+
+const source = await readFile(new URL('./public/app.js', import.meta.url), 'utf8');
+const html = await readFile(new URL('./public/index.html', import.meta.url), 'utf8');
+const plannerSource = await readFile(new URL('./public/netplan-v4.js', import.meta.url), 'utf8');
+
+function section(start, end) {
+ const first = source.indexOf(start), last = source.indexOf(end, first + start.length);
+ assert.ok(first >= 0 && last > first, `integration source boundaries: ${start}`);
+ return source.slice(first, last);
+}
+const mountSource = section(' var integratedPlanner = null;', ' function faultDate(');
+const viewSource = section(' function showView(name)', ' async function loadPortalHealth(');
+const authSource = section(' function showAuth()', ' function workspaceOptions(');
+const importPattern = /import\(("\/netplan-v4\.js[^"\n]*")\)/;
+assert.match(mountSource, importPattern);
+const executableSource = mountSource.replace(importPattern, 'importPlanner($1)') + '\n' + viewSource + '\n' + authSource;
+
+function deferred() {
+ let resolve, reject;
+ const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
+ return { promise, resolve, reject };
+}
+const settle = () => new Promise(resolve => setImmediate(resolve));
+function node(id, active = false) {
+ const classes = new Set(active ? ['active'] : []);
+ return {
+ id, textContent: '',
+ replaceChildren() { this.textContent = ''; },
+ classList: {
+ contains(name) { return classes.has(name); },
+ add(name) { classes.add(name); },
+ remove(name) { classes.delete(name); },
+ toggle(name, enabled) { if (enabled) classes.add(name); else classes.delete(name); }
+ }
+ };
+}
+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));
+ return nodes.get(id);
+ };
+ const calls = { imports: [], api: [], mounted: [], disposed: [], rendered: [], legacyStatus: [], configuration: [] };
+ let plant = { id: 'A', installation_id: 'installation-A', manager_variant: 'manager_standard' };
+ const plannerModule = {
+ mountPlanner(host, config) {
+ calls.mounted.push(config.plantId); host.textContent = `mounted:${config.plantId}`;
+ return { ready: options.ready ? options.ready(config.plantId) : Promise.resolve(), dispose() { calls.disposed.push(config.plantId); host.replaceChildren(); } };
+ }
+ };
+ const state = { session: { csrfToken: 'test-only' }, forecastRequest: 0, forecastHistoryDays: 1 };
+ const context = vm.createContext({
+ state, byId, encodeURIComponent,
+ document: { querySelectorAll(selector) { return selector === '.view' ? [byId('view-forecast'), byId('view-overview')] : []; } },
+ history: { replaceState() {} }, location: { pathname: '/', search: '' }, window: { scrollTo() {} },
+ selectedPlant() { return plant; }, managerLabel(value) { return value; },
+ resetForecastChart() {}, renderForecastLicenseWarning() {},
+ setForecastState(message) { calls.legacyStatus.push(message); },
+ renderForecastResult(result) { calls.rendered.push(result); },
+ loadForecastConfiguration(selected, requestId) { calls.configuration.push({ plant: selected.id, requestId }); return Promise.resolve(); },
+ loadInvitations() {}, loadOrders() {}, updateFaults() {},
+ 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);
+ 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.ok(html.indexOf('id="forecastPlanner"') > html.indexOf('id="view-forecast"'));
+ assert.match(plannerSource, /export function mountPlanner\(/);
+ const dependency = plannerSource.match(/from ['"](\.\/forecast-chart\.js)['"]/);
+ assert.ok(dependency, 'chart import remains relative to the deployed planner module');
+ assert.ok((await readFile(new URL('./public/' + dependency[1], import.meta.url), 'utf8')).includes('export function renderForecastChart'));
+});
+
+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);
+});
+
+test('changing plants invalidates stale imports and legacy 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 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']);
+});
+
+test('leaving the forecast view disposes the mounted planner and invalidates pending imports', async () => {
+ const app = harness(); await app.updateForecast(); await settle();
+ const revision = app.state.forecastRequest; app.showView('overview');
+ assert.equal(app.state.forecastRequest, revision + 1); assert.deepEqual(app.calls.disposed, ['A']);
+ assert.equal(app.currentPlanner(), null);
+ const imported = deferred(), pending = harness({ importPlanner: () => imported.promise });
+ await pending.updateForecast(); pending.showView('overview'); imported.resolve(pending.module); await settle();
+ assert.deepEqual(pending.calls.mounted, []);
+});
+
+test('returning to authentication disposes the planner and invalidates pending responses', async () => {
+ const ready = deferred(), app = harness({ ready: () => ready.promise });
+ await app.updateForecast(); await settle();
+ const revision = app.state.forecastRequest; app.showAuth();
+ assert.equal(app.state.forecastRequest, revision + 1);
+ assert.deepEqual(app.calls.disposed, ['A']); assert.equal(app.currentPlanner(), null);
+ assert.equal(app.byId('portalApp').classList.contains('hidden'), true);
+ ready.reject(new Error('late post-logout failure')); await settle();
+ assert.equal(app.byId('forecastPlanner').textContent, '');
+
+ const imported = deferred(), result = deferred();
+ const pending = harness({ 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, []);
+});
+
+test('legacy API failures do not clear or prevent the independent planner', async () => {
+ const app = harness({ api: () => Promise.reject(new Error('legacy 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');
+});
+
+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.match(app.byId('forecastPlanner').textContent, /planner unavailable/);
+ assert.ok(!app.calls.legacyStatus.includes('planner unavailable'));
+});
+
+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();
+ ready.reject(new Error('late old failure')); await settle();
+ assert.deepEqual(app.calls.disposed, ['A']);
+ assert.deepEqual(app.calls.mounted, ['A', 'B']);
+ assert.equal(app.byId('forecastPlanner').textContent, 'mounted:B');
+});
diff --git a/services/license/changes/20261006-forecast-integration/public/app.js b/services/license/changes/20261006-forecast-integration/public/app.js
new file mode 100644
index 0000000..24c18b8
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/public/app.js
@@ -0,0 +1,2593 @@
+(function () {
+ "use strict";
+
+ var state = { session: null, plants: [], orders: [], selectedOrder: [], stripeConfigured: false, vatRate: 8.1, configuredPlantId: null, checkoutConfigurationId: null, replacementOrderId: null, codeDeletionOrderId: null, plantDeletion: null, wizardStep: 0, forecastRequest: 0, forecastResult: null, forecastPlantId: null, forecastVisibleSeries: null, forecastRangeStart: null, forecastRangeEnd: null, forecastHistoryDays: 1, faultRequest: 0, wizardCatalog: null, deviceDrafts: {}, pvSurfaceDrafts: [] };
+ var managerPrices = {
+ manager_standard: { name: "Manager ohne Peak Shaving", price: 308.15, setup: 114.60 },
+ manager_peak: { name: "Manager mit Peak Shaving", price: 329.05, setup: 141.80 },
+ manager_peak_upgrade: { name: "Upgrade auf Peak Shaving", price: 20.90, setup: 0 }
+ };
+ var deviceLabels = { battery: "Batteriespeicher", buffer_storage: "Pufferspeicher", ev_charger: "EV-Ladestation", consumer_single: "Verbraucher einstufig", boiler_multi: "Boiler mehrstufig", heat_pump: "Wärmepumpe" };
+ var roleLabels = { owner: "Eigentümer", admin: "Administrator", operator: "Operator", billing: "Abrechnung", viewer: "Lesend" };
+ var money = new Intl.NumberFormat("de-CH", { style: "currency", currency: "CHF" });
+ var byId = function (id) { return document.getElementById(id); };
+ var toastTimer;
+ var resetToken = "";
+
+ function escapeHtml(value) {
+ return String(value == null ? "" : value).replace(/[&<>"']/g, function (character) {
+ return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character];
+ });
+ }
+
+ function applyCatalog(result) {
+ var items = result.items || {};
+ state.vatRate = Number(result.vatRate || 0);
+ ["manager_standard", "manager_peak", "manager_peak_upgrade"].forEach(function (key) {
+ if (!items[key]) return;
+ managerPrices[key] = {
+ name: items[key].name,
+ price: Number(items[key].unitAmount) / 100,
+ setup: Number(items[key].setupAmount || 0) / 100
+ };
+ });
+ document.querySelectorAll(".module-row").forEach(function (row) {
+ var item = items[row.dataset.key];
+ if (!item) return;
+ row.dataset.name = item.name;
+ row.dataset.price = String(Number(item.unitAmount) / 100);
+ row.dataset.setup = String(Number(item.setupAmount || 0) / 100);
+ row.dataset.termMonths = String(Number(item.termMonths || 0));
+ row.children[1].textContent = money.format(Number(item.unitAmount) / 100) + (item.termMonths ? " / Jahr" : "");
+ row.children[2].textContent = item.termMonths ? "–" : money.format(Number(item.setupAmount || 0) / 100);
+ });
+ byId("plantManagerStandardPrice").textContent = money.format(managerPrices.manager_standard.price) + " netto";
+ byId("plantManagerPeakPrice").textContent = money.format(managerPrices.manager_peak.price) + " netto";
+ byId("billingBasePrice").textContent = money.format(Number(items.cost_report?.unitAmount || 0) / 100) + " netto";
+ byId("billingPowerPrice").textContent = money.format(Number(items.cost_meter_power?.unitAmount || 0) / 100) + " netto pro Zähler";
+ byId("billingAuxPrice").textContent = money.format(Number(items.cost_meter_aux?.unitAmount || 0) / 100) + " netto pro Zähler";
+ byId("vatLabel").textContent = "MwSt. " + state.vatRate.toLocaleString("de-CH") + " %";
+ }
+
+ async function api(path, options) {
+ var request = Object.assign({ headers: {} }, options || {});
+ request.headers = Object.assign({}, request.headers);
+ if (request.body && typeof request.body !== "string") {
+ request.headers["Content-Type"] = "application/json";
+ request.body = JSON.stringify(request.body);
+ }
+ if (state.session && state.session.csrfToken && request.method && request.method !== "GET") request.headers["X-CSRF-Token"] = state.session.csrfToken;
+ var response = await fetch(path, request);
+ var result = await response.json().catch(function () { return {}; });
+ if (!response.ok) throw new Error(result.error || "Die Anfrage konnte nicht verarbeitet werden.");
+ return result;
+ }
+
+ function toast(message) {
+ var node = byId("toast");
+ node.textContent = message;
+ node.classList.add("show");
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(function () { node.classList.remove("show"); }, 3600);
+ }
+
+ function setBusy(form, busy) {
+ var button = form.querySelector("button[type='submit']");
+ if (!button.dataset.label) button.dataset.label = button.textContent;
+ button.disabled = busy;
+ button.textContent = busy ? "Bitte warten …" : button.dataset.label;
+ }
+
+ function switchAuth(mode) {
+ var login = mode === "login";
+ byId("authTabs").classList.remove("hidden");
+ byId("loginTab").classList.toggle("active", login);
+ byId("registerTab").classList.toggle("active", !login);
+ byId("loginForm").classList.toggle("hidden", !login);
+ byId("registerForm").classList.toggle("hidden", login);
+ byId("forgotForm").classList.add("hidden");
+ byId("resetForm").classList.add("hidden");
+ }
+
+ function specialAuth(formId) {
+ byId("authTabs").classList.add("hidden");
+ ["loginForm", "registerForm", "forgotForm", "resetForm"].forEach(function (id) { byId(id).classList.toggle("hidden", id !== formId); });
+ }
+
+ function authNotice(message, error) {
+ var notice = byId("authNotice");
+ notice.textContent = message || "";
+ notice.classList.toggle("hidden", !message);
+ notice.classList.toggle("error", Boolean(error));
+ }
+
+ function initials(name) {
+ return String(name || "EN").split(/\s+/).slice(0, 2).map(function (part) { return part[0]; }).join("").toUpperCase();
+ }
+
+ function showAuth() {
+ ++state.forecastRequest;
+ if (integratedPlanner) integratedPlanner.dispose();
+ integratedPlanner = null;
+ byId("portalApp").classList.add("hidden");
+ byId("authScreen").classList.remove("hidden");
+ }
+
+ function workspaceOptions() {
+ var organizations = state.session.organizations || [];
+ if (!organizations.length) return "Persönlicher Bereich ";
+ return organizations.map(function (organization) { return "" + escapeHtml(organization.name) + " "; }).join("");
+ }
+
+ function renderSession() {
+ var session = state.session;
+ var context = session.organization;
+ var organizations = session.organizations || [];
+ byId("topInitials").textContent = initials(session.user.name);
+ byId("topName").textContent = session.user.name;
+ byId("overviewOrg").textContent = context.name;
+ byId("billingOrg").textContent = context.name;
+ byId("accountName").textContent = session.user.name;
+ byId("accountEmail").textContent = session.user.email;
+ byId("accountOrg").textContent = context.name;
+ byId("workspaceRole").textContent = roleLabels[context.role] || context.role;
+ ["workspaceSelect", "accountWorkspaceSelect"].forEach(function (id) {
+ byId(id).innerHTML = workspaceOptions();
+ byId(id).value = context.id == null ? "" : String(context.id);
+ byId(id).disabled = organizations.length <= 1;
+ });
+ byId("accountWorkspaceLabel").classList.toggle("hidden", organizations.length <= 1);
+ byId("organizationCreateSection").classList.toggle("hidden", organizations.length > 0);
+ var canInvite = context.type === "organization" && ["owner", "admin"].includes(context.role);
+ byId("invitationSection").classList.toggle("hidden", !canInvite);
+ var canManage = context.type === "personal" || ["owner", "admin", "operator"].includes(context.role);
+ document.querySelectorAll("[data-open-plant]").forEach(function (button) { button.disabled = !canManage; });
+ }
+
+ async function acceptStoredInvitation() {
+ var token = sessionStorage.getItem("enelix_invitation_token");
+ if (!token || !state.session) return;
+ try {
+ var result = await api("/api/invitations/accept", { method: "POST", body: { token: token } });
+ sessionStorage.removeItem("enelix_invitation_token");
+ state.session = result.session;
+ renderSession();
+ toast(result.message);
+ } catch (error) {
+ sessionStorage.removeItem("enelix_invitation_token");
+ toast(error.message);
+ }
+ }
+
+ async function showPortal(session) {
+ state.session = session;
+ byId("authScreen").classList.add("hidden");
+ byId("portalApp").classList.remove("hidden");
+ renderSession();
+ await acceptStoredInvitation();
+ await loadPortalHealth();
+ await Promise.all([loadPlants(), loadOrders(), loadInvitations()]);
+ var initialView = location.hash.replace("#", "");
+ var allowedViews = ["overview", "licenses", "forecast", "faults", "account", "configurator"];
+ showView(allowedViews.includes(initialView) ? initialView : "overview");
+ var query = new URLSearchParams(location.search);
+ if (query.get("checkout") === "success") toast("Zahlung abgeschlossen. Stripe bestätigt die Freischaltung in wenigen Sekunden.");
+ if (query.get("checkout") === "free") toast("Kostenlose Jahreslizenz wurde freigeschaltet.");
+ if (query.get("checkout") === "cancelled") toast("Zahlung abgebrochen. Es wurde keine Lizenz aktiviert.");
+ if (query.has("checkout")) history.replaceState(null, "", location.pathname + location.hash);
+ }
+
+ function showView(name) {
+ if (name !== "forecast") {
+ ++state.forecastRequest;
+ if (integratedPlanner) integratedPlanner.dispose();
+ integratedPlanner = null;
+ }
+ document.querySelectorAll(".view").forEach(function (view) { view.classList.toggle("active", view.id === "view-" + name); });
+ document.querySelectorAll(".nav-item").forEach(function (item) { item.classList.toggle("active", item.dataset.view === name); });
+ history.replaceState(null, "", location.pathname + location.search + "#" + name);
+ if (name === "account") {
+ loadInvitations();
+ loadOrders();
+ }
+ if (name === "forecast") updateForecast();
+ if (name === "faults") updateFaults();
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }
+
+ async function loadPortalHealth() {
+ var health = await api("/healthz");
+ state.stripeConfigured = health.stripe === "configured";
+ ["stripeStatus", "billingStripeStatus"].forEach(function (id) {
+ var node = byId(id);
+ if (!node) return;
+ node.textContent = state.stripeConfigured ? "Stripe Sandbox bereit" : "Stripe Sandbox offen";
+ node.className = "status " + (state.stripeConfigured ? "available" : "review");
+ });
+ if (byId("stripeProviderState")) byId("stripeProviderState").textContent = state.stripeConfigured ? "Bereit" : "Nicht konfiguriert";
+ }
+
+ async function loadPlants() {
+ state.plants = (await api("/api/plants")).plants || [];
+ renderPlants();
+ }
+
+ async function loadOrders() {
+ if (!state.session) return;
+ state.orders = (await api("/api/orders")).orders || [];
+ renderOrders();
+ renderPlants();
+ }
+
+ async function loadInvitations() {
+ if (!state.session || byId("invitationSection").classList.contains("hidden")) return void (byId("pendingInvitations").innerHTML = "");
+ var invitations = (await api("/api/invitations")).invitations || [];
+ byId("pendingInvitations").innerHTML = invitations.map(function (invitation) {
+ return "" + escapeHtml(invitation.invited_email) + " " + escapeHtml(roleLabels[invitation.role] || invitation.role) + " offen
";
+ }).join("");
+ }
+
+ function managerLabel(value) {
+ return value === "billing_manager"
+ ? "Abrechnungsmanager"
+ : (managerPrices[value] || managerPrices.manager_standard).name;
+ }
+ function connectionLabel(plant) { return plant.installation_id ? plant.installation_id : "Noch nicht verknüpft"; }
+
+ function activationOrderForPlant(plantId) {
+ var paid = state.orders.filter(function (order) { return order.plant_id === plantId && order.status === "paid"; });
+ var managerOrders = paid.filter(function (order) {
+ return (order.lines || []).some(function (line) {
+ return line.line_type === "license" && ["manager_standard", "manager_peak"].includes(line.catalog_key);
+ });
+ });
+ var candidates = managerOrders.length ? managerOrders : paid;
+ return candidates.find(function (order) { return Boolean(order.activated_installation_id); })
+ || candidates.find(function (order) { return Boolean(order.code_hint); })
+ || candidates[candidates.length - 1]
+ || null;
+ }
+
+ function openReplacementDialog(orderId, plantName, connected) {
+ state.replacementOrderId = orderId;
+ byId("replaceCodeSystem").textContent = plantName;
+ byId("replaceCodeBinding").classList.toggle("hidden", !connected);
+ byId("replaceCodeError").textContent = "";
+ byId("replaceCodeDialog").showModal();
+ }
+
+ function openCodeDeletionDialog(orderId, plantName) {
+ state.codeDeletionOrderId = orderId;
+ byId("deleteCodeSystem").textContent = plantName;
+ byId("deleteCodeError").textContent = "";
+ byId("deleteCodeDialog").showModal();
+ }
+
+ function openPlantDeletionDialog(plantId, plantName, hasPaidLicense) {
+ state.plantDeletion = { id: plantId, name: plantName, hasPaidLicense: hasPaidLicense };
+ byId("deletePlantSystem").textContent = plantName;
+ byId("deletePaidSystem").textContent = plantName;
+ byId("deletePlantError").textContent = "";
+ byId("deletePaidError").textContent = "";
+ byId("deletePlantDialog").showModal();
+ }
+
+ function renderPlants() {
+ var hasPlants = state.plants.length > 0;
+ var managerPlants = state.plants.filter(function (plant) { return plant.manager_variant !== "billing_manager"; });
+ var hasManagerPlants = managerPlants.length > 0;
+ var paidOrders = state.orders.filter(function (order) { return order.status === "paid"; });
+ var paidLicenses = paidOrders.reduce(function (sum, order) {
+ return sum + (order.lines || []).reduce(function (lineSum, line) {
+ return lineSum + (line.line_type === "license" ? Number(line.quantity) : 0);
+ }, 0);
+ }, 0);
+ byId("metricPlants").textContent = state.plants.length;
+ byId("metricManagers").textContent = paidLicenses;
+ byId("metricConnected").textContent = state.plants.filter(function (plant) { return Boolean(plant.installation_id); }).length;
+ byId("moduleNoPlant").classList.toggle("hidden", hasPlants);
+ byId("moduleConfigurator").classList.toggle("hidden", !hasPlants);
+ byId("forecastEmpty").classList.toggle("hidden", hasManagerPlants);
+ byId("forecastContent").classList.toggle("hidden", !hasManagerPlants);
+ byId("faultEmpty").classList.toggle("hidden", hasManagerPlants);
+ byId("faultContent").classList.toggle("hidden", !hasManagerPlants);
+ var canDelete = state.session.organization.type === "personal" || ["owner", "admin"].includes(state.session.organization.role);
+ byId("overviewPlants").innerHTML = hasPlants ? "" + state.plants.map(function (plant) {
+ var orders = state.orders.filter(function (order) { return order.plant_id === plant.id; });
+ var paid = orders.filter(function (order) { return order.status === "paid"; });
+ var licenseCount = paid.reduce(function (sum, order) {
+ return sum + (order.lines || []).reduce(function (lineSum, line) {
+ return lineSum + (line.line_type === "license" ? Number(line.quantity) : 0);
+ }, 0);
+ }, 0);
+ var activated = paid.some(function (order) { return Boolean(order.activated_installation_id); });
+ var licenseState = activated ? "Verwendet" : (paid.length ? "Bezahlt" : "Noch nicht gekauft");
+ var stateClass = activated ? "available" : (paid.length ? "neutral" : "review");
+ var codeOrder = activationOrderForPlant(plant.id);
+ var codeAction = codeOrder
+ ? "
" + (codeOrder.code_hint === "Gelöscht" ? "Neuen Lizenzcode generieren" : "Lizenzcode neu generieren") + " "
+ : "";
+ var deleteCodeAction = canDelete && codeOrder && codeOrder.code_hint && codeOrder.code_hint !== "Gelöscht"
+ ? "
Lizenzcode löschen "
+ : "";
+ var deletePlantAction = canDelete
+ ? "
Anlage löschen "
+ : "";
+ var billingPlant = plant.manager_variant === "billing_manager";
+ var extendAction = billingPlant
+ ? "
Zähler erweitern "
+ : "
Erweitern ";
+ return "
" + escapeHtml(plant.name) + " " + escapeHtml(plant.location) + " " +
+ "" + (billingPlant ? "Lizenztyp" : "Manager") + " " + escapeHtml(managerLabel(plant.manager_variant)) + " " +
+ "Lizenzen " + licenseCount + " " +
+ "Verwendung " + licenseState + " " +
+ "Verbindung " + escapeHtml(plant.installation_id ? (billingPlant ? "Abrechnung verbunden" : "Manager verbunden") : "Nicht verbunden") + " " +
+ "" + extendAction + codeAction + deleteCodeAction + deletePlantAction + "
";
+ }).join("") + "
" : "Noch keine Lizenz vorhanden Lege eine Lizenz direkt an oder starte mit dem Systemkonfigurator.
Neue Lizenz ";
+ var options = managerPlants.map(function (plant) { return "" + escapeHtml(plant.name) + " "; }).join("");
+ var orderValue = byId("orderPlant").value;
+ var forecastValue = byId("forecastPlant").value;
+ var faultValue = byId("faultPlant").value;
+ byId("orderPlant").innerHTML = options;
+ byId("forecastPlant").innerHTML = options;
+ byId("faultPlant").innerHTML = options;
+ if (managerPlants.some(function (plant) { return plant.id === orderValue; })) byId("orderPlant").value = orderValue;
+ if (managerPlants.some(function (plant) { return plant.id === forecastValue; })) byId("forecastPlant").value = forecastValue;
+ if (managerPlants.some(function (plant) { return plant.id === faultValue; })) byId("faultPlant").value = faultValue;
+ if (state.configuredPlantId && managerPlants.some(function (plant) { return plant.id === state.configuredPlantId; })) byId("orderPlant").value = state.configuredPlantId;
+ prepareOrderEditor();
+ updateForecast();
+ updateFaults();
+ }
+
+ function renderOrders() {
+ var labels = { creating_checkout: "Checkout wird erstellt", checkout_open: "Zahlung offen", checkout_failed: "Checkout fehlgeschlagen", payment_processing: "Zahlung wird verarbeitet", payment_failed: "Zahlung fehlgeschlagen", expired: "Abgelaufen", paid: "Bezahlt" };
+ var canDelete = state.session.organization.type === "personal" || ["owner", "admin"].includes(state.session.organization.role);
+ byId("ordersBody").innerHTML = state.orders.length ? state.orders.map(function (order) {
+ var count = (order.lines || []).reduce(function (sum, line) { return sum + (line.line_type === "license" ? line.quantity : 0); }, 0);
+ var hasLicenseLines = (order.lines || []).some(function (line) { return line.line_type === "license"; });
+ var action = "–";
+ if (order.status === "paid" && !hasLicenseLines) {
+ action = "Generator / Ersteinrichtung bezahlt ";
+ } else if (order.status === "paid" && order.code_hint === "Gelöscht") {
+ action = "Neuen Code per E-Mail senden Code gelöscht ";
+ } else if (order.status === "paid" && order.code_hint) {
+ action = "Neuen Code per E-Mail senden " +
+ (canDelete ? "Code löschen " : "") +
+ "" + escapeHtml(order.activated_installation_id ? "Verknüpft · " + order.code_hint : order.code_hint) + " ";
+ } else if (order.status === "paid") action = "Code per E-Mail senden ";
+ var receipt = order.status === "paid" ? "PDF " : "–";
+ return "" + escapeHtml(new Date(order.created_at + "Z").toLocaleDateString("de-CH")) + " " + escapeHtml(order.plant_name) + " " + count + " " + escapeHtml(money.format(order.amount_total / 100)) + " " + escapeHtml(labels[order.status] || order.status) + " " + action + " " + receipt + " ";
+ }).join("") : "Noch keine Bestellungen in dieser Organisation. ";
+ }
+
+ function selectedPlant(selectId) {
+ return state.plants.find(function (plant) { return plant.id === byId(selectId).value && plant.manager_variant !== "billing_manager"; })
+ || state.plants.find(function (plant) { return plant.manager_variant !== "billing_manager"; })
+ || null;
+ }
+
+ function plantRecommendation(plant) {
+ if (!plant || !plant.recommendation_json) return null;
+ try { return JSON.parse(plant.recommendation_json); } catch (_) { return null; }
+ }
+
+ function entitlementTime(value) {
+ return value ? Date.parse(String(value).replace(" ", "T") + "Z") : null;
+ }
+
+ function entitlementIsActive(entitlement) {
+ var now = Date.now();
+ var from = entitlementTime(entitlement.valid_from);
+ var until = entitlementTime(entitlement.valid_until);
+ return entitlement.status === "active" && (!from || from <= now) && (!until || until > now);
+ }
+
+ function paidLicenseSummary(plantId) {
+ var summary = {};
+ state.orders.filter(function (order) {
+ return order.plant_id === plantId && order.status === "paid";
+ }).forEach(function (order) {
+ (order.entitlements || []).filter(entitlementIsActive).forEach(function (entitlement) {
+ summary[entitlement.catalog_key] = Number(summary[entitlement.catalog_key] || 0) + Number(entitlement.quantity || 0);
+ });
+ });
+ return summary;
+ }
+
+ function latestEntitlementUntil(plantId, catalogKey) {
+ var values = [];
+ state.orders.filter(function (order) {
+ return order.plant_id === plantId && order.status === "paid";
+ }).forEach(function (order) {
+ (order.entitlements || []).forEach(function (entitlement) {
+ if (entitlement.catalog_key === catalogKey && entitlement.valid_until) values.push(entitlement.valid_until);
+ });
+ });
+ return values.sort().at(-1) || null;
+ }
+
+ function paidSetupSummary(plantId) {
+ var summary = {};
+ state.orders.filter(function (order) {
+ return order.plant_id === plantId && order.status === "paid";
+ }).forEach(function (order) {
+ (order.lines || []).forEach(function (line) {
+ if (line.line_type !== "setup") return;
+ summary[line.catalog_key] = Number(summary[line.catalog_key] || 0) + Number(line.quantity || 0);
+ });
+ });
+ return summary;
+ }
+
+ function paidManagerVariant(plant) {
+ var managerKeys = [];
+ state.orders.filter(function (order) {
+ return order.plant_id === plant.id && order.status === "paid";
+ }).forEach(function (order) {
+ (order.lines || []).forEach(function (line) {
+ if (line.line_type === "license" && ["manager_standard", "manager_peak"].includes(line.catalog_key)) managerKeys.push(line.catalog_key);
+ });
+ });
+ if (managerKeys.includes("manager_peak")) return "manager_peak";
+ if (managerKeys.includes("manager_standard")) return "manager_standard";
+ return null;
+ }
+
+ function currentManagerVariant(plant) {
+ return paidManagerVariant(plant) || plantRecommendation(plant)?.managerVariant || plant.manager_variant || "manager_standard";
+ }
+
+ function selectedManagerVariant(plant) {
+ var ownedManager = paidManagerVariant(plant);
+ if (!ownedManager) return plantRecommendation(plant)?.managerVariant || plant.manager_variant || "manager_standard";
+ if (ownedManager === "manager_standard" && byId("peakUpgrade").checked) return "manager_peak";
+ return ownedManager;
+ }
+
+ function licenseCoverage(requested, owned) {
+ var coverage = Object.assign({}, owned);
+ var spareConsumers = Math.max(0, Number(owned.consumer_single || 0) - Number(requested.consumer_single || 0));
+ coverage.heat_pump = Number(owned.heat_pump || 0) + Math.min(spareConsumers,
+ Math.max(0, Number(requested.heat_pump || 0) - Number(owned.heat_pump || 0)));
+ return coverage;
+ }
+
+ function prepareOrderEditor(configurationId) {
+ var plant = selectedPlant("orderPlant");
+ if (!plant) return;
+ var configurationPurchase = Boolean(configurationId);
+ state.checkoutConfigurationId = configurationPurchase ? configurationId : null;
+ var recommendation = plantRecommendation(plant);
+ var owned = paidLicenseSummary(plant.id);
+ var setupOwned = paidSetupSummary(plant.id);
+ var coverage = licenseCoverage(recommendation?.modules || {}, owned);
+ var ownedManager = paidManagerVariant(plant);
+ var desiredManager = recommendation?.managerVariant || plant.manager_variant || "manager_standard";
+ byId("managerCurrentName").textContent = ownedManager ? managerLabel(ownedManager) : managerLabel(desiredManager);
+ byId("ownedManager").textContent = ownedManager ? "Bereits gekauft" : "Wird beim Erstkauf einmalig berechnet";
+ byId("peakUpgradeOption").classList.toggle("hidden", ownedManager !== "manager_standard");
+ byId("peakUpgrade").checked = ownedManager === "manager_standard" && desiredManager === "manager_peak";
+ byId("peakUpgrade").disabled = configurationPurchase;
+ document.querySelectorAll(".module-row").forEach(function (row) {
+ var recommendedQuantity = recommendation ? Number(recommendation.modules[row.dataset.key] || 0) : 0;
+ var ownedQuantity = Number(owned[row.dataset.key] || 0);
+ var licenseQuantity = Math.max(0, recommendedQuantity - Number(coverage[row.dataset.key] || 0));
+ var setupQuantity = configurationPurchase
+ ? Math.max(0, recommendedQuantity - Number(setupOwned[row.dataset.key] || 0))
+ : 0;
+ var quantity = Math.max(licenseQuantity, setupQuantity);
+ row.dataset.licenseQuantity = String(licenseQuantity);
+ row.dataset.setupQuantity = String(setupQuantity);
+ var validUntil = latestEntitlementUntil(plant.id, row.dataset.key);
+ var ownedLabel = row.querySelector(".owned-count");
+ ownedLabel.textContent = Number(row.dataset.termMonths || 0) > 0
+ ? (validUntil ? "Gültig bis " + new Date(entitlementTime(validUntil)).toLocaleDateString("de-CH") : "Nicht aktiviert")
+ : "Vorhanden: " + ownedQuantity;
+ row.querySelector(".module-check").checked = configurationPurchase
+ ? quantity > 0
+ : licenseQuantity > 0;
+ row.querySelector(".qty").value = quantity > 0 ? quantity : 1;
+ row.querySelector(".module-check").disabled = configurationPurchase;
+ row.querySelector(".qty").disabled = configurationPurchase;
+ });
+ updateOrder();
+ }
+
+ function updateOrder() {
+ var plant = selectedPlant("orderPlant");
+ if (!plant) return;
+ var configurationPurchase = Boolean(state.checkoutConfigurationId);
+ var ownedManager = paidManagerVariant(plant);
+ var setupOwned = paidSetupSummary(plant.id);
+ var targetManager = selectedManagerVariant(plant);
+ var managerItem = null;
+ if (!ownedManager) managerItem = managerPrices[targetManager] || managerPrices.manager_standard;
+ else if (ownedManager === "manager_standard" && targetManager === "manager_peak") managerItem = managerPrices.manager_peak_upgrade;
+
+ var managerSetupPaid = Number(setupOwned.manager_standard || 0) > 0
+ || Number(setupOwned.manager_peak || 0) > 0;
+ var managerSetupPrice = configurationPurchase && !managerSetupPaid
+ ? (managerPrices[targetManager] || managerPrices.manager_standard).setup
+ : 0;
+ byId("managerPrice").textContent = managerItem ? money.format(managerItem.price) : money.format(0);
+ byId("managerSetup").textContent = configurationPurchase
+ ? (managerSetupPrice > 0
+ ? "Generator / Ersteinrichtung " + money.format(managerSetupPrice)
+ : "Ersteinrichtung bereits bezahlt")
+ : (managerItem ? "Nur Lizenz" : "Kein weiterer Managerkauf");
+
+ state.selectedOrder = [];
+ document.querySelectorAll(".module-row").forEach(function (row) {
+ var quantity = Math.min(999, Math.max(1, Number(row.querySelector(".qty").value) || 1));
+ row.querySelector(".qty").value = quantity;
+ var licenseQuantity = configurationPurchase
+ ? Number(row.dataset.licenseQuantity || 0)
+ : (row.querySelector(".module-check").checked ? quantity : 0);
+ var setupQuantity = configurationPurchase
+ ? Number(row.dataset.setupQuantity || 0)
+ : 0;
+ if (licenseQuantity > 0 || setupQuantity > 0) {
+ state.selectedOrder.push({
+ key: row.dataset.key,
+ name: row.dataset.name,
+ price: Number(row.dataset.price),
+ setup: Number(row.dataset.setup),
+ licenseQuantity: licenseQuantity,
+ setupQuantity: setupQuantity
+ });
+ }
+ });
+
+ var managerLicensePrice = managerItem ? managerItem.price : 0;
+ var licensePrice = managerLicensePrice + state.selectedOrder.reduce(function (sum, item) {
+ return sum + item.price * item.licenseQuantity;
+ }, 0);
+ var setupPrice = managerSetupPrice + state.selectedOrder.reduce(function (sum, item) {
+ return sum + item.setup * item.setupQuantity;
+ }, 0);
+ var items = [];
+ if (managerItem) items.push({ name: managerItem.name, quantity: 1, total: managerItem.price });
+ state.selectedOrder.forEach(function (item) {
+ if (item.licenseQuantity > 0) {
+ items.push({ name: item.name, quantity: item.licenseQuantity, total: item.price * item.licenseQuantity });
+ }
+ });
+ if (managerSetupPrice > 0) {
+ items.push({ name: "Generator / Ersteinrichtung Manager", quantity: 1, total: managerSetupPrice });
+ }
+ state.selectedOrder.forEach(function (item) {
+ if (item.setupQuantity > 0) {
+ items.push({
+ name: "Generator / Ersteinrichtung " + item.name,
+ quantity: item.setupQuantity,
+ total: item.setup * item.setupQuantity
+ });
+ }
+ });
+ byId("orderItems").innerHTML = items.length
+ ? items.map(function (item) { return "" + escapeHtml(item.name) + " × " + item.quantity + " " + money.format(item.total) + "
"; }).join("")
+ : "Keine zusätzliche Lizenz oder Ersteinrichtung erforderlich.
";
+ var netTotal = licensePrice + setupPrice;
+ var vatTotal = Math.round(netTotal * state.vatRate) / 100;
+ byId("moduleTotal").textContent = money.format(licensePrice);
+ byId("setupTotal").textContent = money.format(setupPrice);
+ byId("setupTotalRow").classList.toggle("hidden", !configurationPurchase);
+ byId("netTotal").textContent = money.format(netTotal);
+ byId("vatTotal").textContent = money.format(vatTotal);
+ byId("grandTotal").textContent = money.format(netTotal + vatTotal);
+ var canCheckout = state.session.organization.type === "personal" || ["owner", "admin", "billing"].includes(state.session.organization.role);
+ var checkoutButton = byId("checkoutButton");
+ var freeOrder = netTotal === 0 && items.length > 0;
+ checkoutButton.dataset.free = freeOrder ? "true" : "false";
+ checkoutButton.textContent = freeOrder ? "Kostenlos freischalten" : "Mit Stripe bezahlen";
+ checkoutButton.disabled = (!state.stripeConfigured && !freeOrder) || !canCheckout || items.length === 0;
+ }
+
+ var forecastSeriesLabels = {
+ PV: "PV-Messwert",
+ Hausverbrauch: "Verbrauchsmesswert",
+ Netzleistung: "Netzmesswert",
+ SOC: "Batterie-SOC",
+ prog_var_1: "PV-Prognose · Modell 1",
+ prog_var_2: "Verbrauchsprognose · Modell 2",
+ prog_var_3: "Netzfahrplan · Modell 3 (+ Bezug / − Einspeisung)",
+ prog_var_3_battery: "Batterieplan · Modell 3 (+ Laden / − Entladen)",
+ prog_var_10: "PV-Prognose · Modell 10",
+ prog_var_11: "Verbrauchsprognose · Modell 11",
+ prog_var_13: "Netzfahrplan · Modell 13 (+ Bezug / − Einspeisung)",
+ prog_var_13_battery: "Batterieplan · Modell 13 (+ Laden / − Entladen)",
+ prog_var_21: "PV-Prognose · Modell 21",
+ prog_var_22: "Verbrauchsprognose · Modell 22",
+ prog_var_23: "Netzfahrplan · Modell 23 (+ Bezug / − Einspeisung)",
+ prog_var_23_battery: "Batterieplan · Modell 23 (+ Laden / − Entladen)"
+ };
+ var forecastColors = {
+ PV: "#006b62",
+ prog_var_1: "#2a9d8f",
+ prog_var_10: "#62bbb0",
+ prog_var_21: "#98d7cf",
+ Hausverbrauch: "#b93242",
+ prog_var_2: "#dd5a68",
+ prog_var_11: "#ea8d98",
+ prog_var_22: "#f2bcc2",
+ Netzleistung: "#2f6ecb",
+ prog_var_3: "#5a8ce0",
+ prog_var_13: "#86adeb",
+ prog_var_23: "#b2caf2",
+ prog_var_3_battery: "#8c4b78",
+ prog_var_13_battery: "#a96b91",
+ prog_var_23_battery: "#c38eac",
+ SOC: "#d68a00"
+ };
+ var forecastFamilies = [
+ { pv: "prog_var_1", load: "prog_var_2", schedule: "prog_var_3" },
+ { pv: "prog_var_10", load: "prog_var_11", schedule: "prog_var_13" },
+ { pv: "prog_var_21", load: "prog_var_22", schedule: "prog_var_23" }
+ ];
+
+ function forecastSeriesLabel(key) {
+ return forecastSeriesLabels[key] || key;
+ }
+
+ function forecastSeriesColor(key) {
+ return forecastColors[key] || "#7b8784";
+ }
+
+ function forecastDefaultVisibleSeries(result) {
+ var available = new Set(((result && result.series) || []).map(function (entry) { return entry.key; }));
+ var schedule = (result && result.scheduleSourceModel)
+ || ["prog_var_3", "prog_var_13", "prog_var_23"].find(function (key) { return available.has(key); })
+ || "prog_var_3";
+ var family = forecastFamilies.find(function (entry) { return entry.schedule === schedule; })
+ || forecastFamilies[0];
+ return new Set([
+ "PV",
+ "Hausverbrauch",
+ "Netzleistung",
+ "SOC",
+ family.pv,
+ family.load,
+ schedule,
+ schedule + "_battery"
+ ]);
+ }
+
+ function forecastInputValue(timestamp) {
+ var date = new Date(timestamp);
+ if (Number.isNaN(date.getTime())) return "";
+ var local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
+ return local.toISOString().slice(0, 16);
+ }
+
+ function forecastWindowLabel(start, end) {
+ var hours = Math.max(0, Math.round((end - start) / 3600000));
+ if (hours >= 48 && hours % 24 === 0) return (hours / 24) + " Tage";
+ return hours + " h";
+ }
+
+ function formatForecastHoverDate(value) {
+ var date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "-";
+ return new Intl.DateTimeFormat("de-CH", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);
+ }
+
+ function formatForecastHoverValue(key, value) {
+ var number = Number(value);
+ if (!Number.isFinite(number)) return "-";
+ if (key === "SOC") return number.toLocaleString("de-CH", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + " %";
+ return number.toLocaleString("de-CH", { maximumFractionDigits: 0 }) + " W";
+ }
+
+ function attachForecastHover(series, minTime, maxTime, width, height, padding) {
+ var chart = byId("forecastChart");
+ var tooltip = byId("forecastTooltip");
+ chart.onpointermove = function (event) {
+ var rect = chart.getBoundingClientRect();
+ var sx = (event.clientX - rect.left) / rect.width * width;
+ if (sx < padding.left || sx > width - padding.right) {
+ tooltip.classList.add("hidden");
+ var outsideLine = byId("forecastHoverLine");
+ if (outsideLine) outsideLine.classList.add("hidden");
+ return;
+ }
+ var time = minTime + (sx - padding.left) / (width - padding.left - padding.right) * (maxTime - minTime);
+ var tolerance = Math.max(30, Number(state.forecastResult && state.forecastResult.intervalMinutes) || 15) * 60 * 1000 * 1.6;
+ var rows = series.map(function (entry) {
+ var nearest = null;
+ entry.points.forEach(function (point) {
+ var pt = Date.parse(point.time), value = Number(point.value);
+ if (!Number.isFinite(pt) || !Number.isFinite(value)) return;
+ if (!nearest || Math.abs(pt - time) < Math.abs(nearest.time - time)) nearest = { time: pt, value: value };
+ });
+ return nearest && Math.abs(nearest.time - time) <= tolerance ? { key: entry.key, time: nearest.time, value: nearest.value } : null;
+ }).filter(Boolean);
+ if (!rows.length) return;
+ var referenceTime = rows.reduce(function (best, row) { return Math.abs(row.time - time) < Math.abs(best - time) ? row.time : best; }, rows[0].time);
+ tooltip.innerHTML = "" + escapeHtml(formatForecastHoverDate(referenceTime)) + " " + rows.map(function (row) {
+ return "" + escapeHtml(forecastSeriesLabel(row.key)) + " " + escapeHtml(formatForecastHoverValue(row.key, row.value)) + " ";
+ }).join("");
+ tooltip.classList.remove("hidden");
+ tooltip.style.left = Math.min(Math.max(event.clientX - rect.left + 14, 8), rect.width - tooltip.offsetWidth - 8) + "px";
+ tooltip.style.top = Math.min(Math.max(event.clientY - rect.top + 14, 8), rect.height - tooltip.offsetHeight - 8) + "px";
+ var line = byId("forecastHoverLine");
+ if (line) { line.setAttribute("x1", sx); line.setAttribute("x2", sx); line.classList.remove("hidden"); }
+ };
+ chart.onpointerleave = function () {
+ tooltip.classList.add("hidden");
+ var line = byId("forecastHoverLine");
+ if (line) line.classList.add("hidden");
+ };
+ }
+
+ function formatForecastDate(value) {
+ var date = value ? new Date(value) : null;
+ if (!date || Number.isNaN(date.getTime())) return "–";
+ return new Intl.DateTimeFormat("de-CH", {
+ day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"
+ }).format(date);
+ }
+
+ function setForecastState(message) {
+ byId("forecastState").textContent = message;
+ byId("forecastState").classList.remove("hidden");
+ byId("forecastChart").classList.add("hidden");
+ }
+
+ function resetForecastChart() {
+ byId("forecastChart").innerHTML = "";
+ byId("forecastChart").classList.add("hidden");
+ byId("forecastSeriesControls").innerHTML = "";
+ byId("forecastLegend").innerHTML = "";
+ byId("forecastLegend").classList.add("hidden");
+ byId("forecastTooltip").classList.add("hidden");
+ byId("forecastLastUpdated").textContent = "–";
+ byId("forecastSeriesCount").textContent = "0";
+ byId("forecastQualityStatus").className = "status review";
+ byId("forecastQualityStatus").textContent = "Aufbauphase";
+ byId("forecastQualityState").textContent = "Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie.";
+ byId("forecastQualityState").classList.remove("hidden");
+ byId("forecastQualityTable").classList.add("hidden");
+ byId("forecastQualityBody").innerHTML = "";
+ byId("forecastConfigSection").classList.add("hidden");
+ byId("forecastConfigError").textContent = "";
+ }
+
+ function renderForecastLicenseWarning(plant) {
+ var warning = byId("forecastOfflineWarning");
+ var lastSeen = plant.license_last_seen_at
+ ? new Date(String(plant.license_last_seen_at).replace(" ", "T") + "Z")
+ : null;
+ var now = Date.now();
+ var refreshDue = lastSeen ? lastSeen.getTime() + 24 * 60 * 60 * 1000 : 0;
+ var offlineUntil = lastSeen ? lastSeen.getTime() + 14 * 24 * 60 * 60 * 1000 : 0;
+ var showOffline = Boolean(plant.installation_id && lastSeen && !Number.isNaN(lastSeen.getTime()) && now > refreshDue);
+ warning.classList.toggle("hidden", !showOffline);
+ if (!showOffline) return;
+
+ var remainingDays = Math.max(0, Math.ceil((offlineUntil - now) / (24 * 60 * 60 * 1000)));
+ byId("forecastOfflineTitle").textContent = remainingDays > 0
+ ? "Kein Internet am Manager erkannt"
+ : "Offline-Freigabe abgelaufen";
+ byId("forecastOfflineText").textContent = remainingDays > 0
+ ? "Die lokale Lizenzfreigabe läuft in " + remainingDays + " " + (remainingDays === 1 ? "Tag" : "Tagen") + " ab, falls keine Verbindung zum Lizenzserver hergestellt wird."
+ : "Der Manager benötigt eine Internetverbindung für eine neue Lizenzprüfung.";
+ }
+
+ function formatForecastMetric(value, digits, suffix) {
+ if (value === null || value === undefined || value === "") return "–";
+ var number = Number(value);
+ if (!Number.isFinite(number)) return "–";
+ return number.toLocaleString("de-CH", {
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits
+ }) + (suffix || "");
+ }
+
+ function renderForecastQuality(quality, capabilities) {
+ var available = quality && Array.isArray(quality.series)
+ ? quality.series.filter(function (entry) {
+ return entry && entry.key && Number(entry.samples) > 0;
+ })
+ : [];
+ var byKey = new Map(available.map(function (entry) { return [entry.key, entry]; }));
+ var rows = [];
+ var addPreferred = function (keys) {
+ var candidates = keys.map(function (key) { return byKey.get(key); }).filter(Boolean);
+ candidates.sort(function (left, right) {
+ var a = Number.isFinite(Number(left.r2)) ? Number(left.r2) : -Infinity;
+ var b = Number.isFinite(Number(right.r2)) ? Number(right.r2) : -Infinity;
+ return b - a;
+ });
+ if (candidates.length) rows.push(candidates[0]);
+ };
+ if (capabilities && capabilities.pv) addPreferred(["prog_var_1", "prog_var_10", "prog_var_21"]);
+ if (capabilities && capabilities.load) addPreferred(["prog_var_2", "prog_var_11", "prog_var_22"]);
+ var status = byId("forecastQualityStatus");
+
+ if (!rows.length) {
+ status.className = "status review";
+ status.textContent = "Aufbauphase";
+ byId("forecastQualityState").textContent = "Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie.";
+ byId("forecastQualityState").classList.remove("hidden");
+ byId("forecastQualityTable").classList.add("hidden");
+ byId("forecastQualityBody").innerHTML = "";
+ return;
+ }
+
+ var mature = rows.length > 0
+ && rows.every(function (entry) { return Number(entry.samples) >= 2016; });
+ status.className = mature ? "status available" : "status review";
+ status.textContent = mature ? "Vergleich bereit" : "Aufbauphase";
+ byId("forecastQualityBody").innerHTML = rows.map(function (entry) {
+ var target = entry.target === "PV" ? "PV" : "Verbrauch";
+ return ""
+ + "" + escapeHtml(forecastSeriesLabel(entry.key)) + " "
+ + "" + escapeHtml(target) + " "
+ + "" + formatForecastMetric(entry.r2, 3, "") + " "
+ + "" + formatForecastMetric(entry.mae, 0, " W") + " "
+ + "" + formatForecastMetric(entry.rmse, 0, " W") + " "
+ + "" + Number(entry.samples).toLocaleString("de-CH") + " "
+ + " ";
+ }).join("");
+ byId("forecastQualityState").classList.add("hidden");
+ byId("forecastQualityTable").classList.remove("hidden");
+ }
+
+ function renderForecastResult(result) {
+ state.forecastResult = result;
+ if (!state.forecastVisibleSeries) state.forecastVisibleSeries = forecastDefaultVisibleSeries(result);
+ byId("forecastLastUpdated").textContent = formatForecastDate(result.generatedAt);
+ byId("forecastSeriesCount").textContent = String((result.series || []).length);
+ renderForecastQuality(result.quality, result.forecastCapabilities || {
+ pv: true,
+ load: true,
+ 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.");
+ }
+ byId("forecastApiStatus").textContent = result.configured
+ ? "Prognose aktiv"
+ : "Daten empfangen, Konfiguration offen";
+ renderForecastChart(result.series || []);
+ }
+
+ function renderForecastChart(inputSeries) {
+ var priority = [
+ "PV", "Hausverbrauch", "Netzleistung", "SOC",
+ "prog_var_1", "prog_var_2", "prog_var_3", "prog_var_3_battery",
+ "prog_var_10", "prog_var_11", "prog_var_13", "prog_var_13_battery",
+ "prog_var_21", "prog_var_22", "prog_var_23", "prog_var_23_battery"
+ ];
+ var availableSeries = inputSeries.filter(function (entry) {
+ return entry && Array.isArray(entry.points) && entry.points.length;
+ }).sort(function (left, right) {
+ var a = priority.indexOf(left.key);
+ var b = priority.indexOf(right.key);
+ return (a < 0 ? 99 : a) - (b < 0 ? 99 : b);
+ });
+ var availablePoints = [];
+ availableSeries.forEach(function (entry) {
+ entry.points.forEach(function (point) {
+ var time = Date.parse(point.time);
+ var value = Number(point.value);
+ if (Number.isFinite(time) && Number.isFinite(value)) availablePoints.push({ time: time, value: value });
+ });
+ });
+ if (!availablePoints.length) return setForecastState("Keine darstellbaren Prognosedaten vorhanden.");
+
+ var availableMin = Math.min.apply(null, availablePoints.map(function (point) { return point.time; }));
+ var availableMax = Math.max.apply(null, availablePoints.map(function (point) { return point.time; }));
+ if (!Number.isFinite(state.forecastRangeStart) || state.forecastRangeStart < availableMin || state.forecastRangeStart >= availableMax) {
+ state.forecastRangeStart = availableMin;
+ }
+ if (!Number.isFinite(state.forecastRangeEnd) || state.forecastRangeEnd > availableMax || state.forecastRangeEnd <= availableMin) {
+ state.forecastRangeEnd = availableMax;
+ }
+ if (state.forecastRangeStart >= state.forecastRangeEnd) {
+ state.forecastRangeStart = availableMin;
+ state.forecastRangeEnd = availableMax;
+ }
+
+ var startInput = byId("forecastRangeStart");
+ var endInput = byId("forecastRangeEnd");
+ startInput.min = forecastInputValue(availableMin);
+ startInput.max = forecastInputValue(availableMax);
+ endInput.min = forecastInputValue(availableMin);
+ endInput.max = forecastInputValue(availableMax);
+ startInput.value = forecastInputValue(state.forecastRangeStart);
+ endInput.value = forecastInputValue(state.forecastRangeEnd);
+ byId("forecastWindowValue").textContent = forecastWindowLabel(state.forecastRangeStart, state.forecastRangeEnd);
+
+ if (!state.forecastVisibleSeries) {
+ state.forecastVisibleSeries = forecastDefaultVisibleSeries(state.forecastResult || {});
+ }
+ byId("forecastSeriesControls").innerHTML = availableSeries.map(function (entry) {
+ var checked = state.forecastVisibleSeries.has(entry.key) ? " checked" : "";
+ var prediction = entry.key.indexOf("prog_var_") === 0 ? " prediction" : "";
+ var activeSchedule = state.forecastResult && state.forecastResult.scheduleSourceModel === entry.key ? " · aktiv" : "";
+ return "" + escapeHtml(forecastSeriesLabel(entry.key) + activeSchedule) + " ";
+ }).join("");
+
+ var series = availableSeries.filter(function (entry) {
+ return state.forecastVisibleSeries.has(entry.key);
+ }).map(function (entry) {
+ return {
+ key: entry.key,
+ points: entry.points.filter(function (point) {
+ var time = Date.parse(point.time);
+ return Number.isFinite(time) && time >= state.forecastRangeStart && time <= state.forecastRangeEnd;
+ })
+ };
+ }).filter(function (entry) { return entry.points.length; });
+
+ var legend = byId("forecastLegend");
+ legend.innerHTML = series.map(function (entry) {
+ var prediction = entry.key.indexOf("prog_var_") === 0 ? " prediction" : "";
+ var activeSchedule = state.forecastResult && state.forecastResult.scheduleSourceModel === entry.key ? " · aktiv" : "";
+ return "" + escapeHtml(forecastSeriesLabel(entry.key) + activeSchedule) + " ";
+ }).join("");
+ legend.classList.toggle("hidden", !series.length);
+
+ byId("forecastSeriesCount").textContent = String(series.length);
+ if (!series.length) return setForecastState("Im gewählten Zeitfenster sind für die eingeblendeten Reihen keine Daten vorhanden.");
+
+ var allPoints = [];
+ series.forEach(function (entry) {
+ entry.points.forEach(function (point) {
+ var time = Date.parse(point.time);
+ var value = Number(point.value);
+ if (Number.isFinite(time) && Number.isFinite(value)) allPoints.push({ time: time, value: value });
+ });
+ });
+ if (!allPoints.length) return setForecastState("Im gewählten Zeitfenster sind keine darstellbaren Daten vorhanden.");
+
+ var width = 960;
+ var height = 320;
+ var padding = { left: 62, right: 24, top: 22, bottom: 42 };
+ var minTime = state.forecastRangeStart;
+ var maxTime = state.forecastRangeEnd;
+ var minValue = Math.min(0, Math.min.apply(null, allPoints.map(function (point) { return point.value; })));
+ var maxValue = Math.max(0, Math.max.apply(null, allPoints.map(function (point) { return point.value; })));
+ if (maxTime === minTime) maxTime += 1;
+ if (maxValue === minValue) maxValue += 1;
+ var x = function (time) { return padding.left + (time - minTime) / (maxTime - minTime) * (width - padding.left - padding.right); };
+ var y = function (value) { return padding.top + (maxValue - value) / (maxValue - minValue) * (height - padding.top - padding.bottom); };
+ var svg = [];
+
+ for (var grid = 0; grid <= 4; grid += 1) {
+ var gy = padding.top + grid / 4 * (height - padding.top - padding.bottom);
+ var label = maxValue - grid / 4 * (maxValue - minValue);
+ svg.push(" ");
+ svg.push("" + label.toFixed(1) + " ");
+ }
+
+ var now = Date.now();
+ if (now >= minTime && now <= maxTime) {
+ var nowX = x(now);
+ svg.push(" ");
+ svg.push("Jetzt ");
+ }
+
+ series.forEach(function (entry) {
+ var coordinates = entry.points.map(function (point) {
+ return { time: Date.parse(point.time), value: Number(point.value) };
+ }).filter(function (point) {
+ return Number.isFinite(point.time) && Number.isFinite(point.value);
+ }).sort(function (a, b) { return a.time - b.time; }).map(function (point) {
+ return x(point.time).toFixed(2) + "," + y(point.value).toFixed(2);
+ }).join(" ");
+ var lineClass = entry.key.indexOf("prog_var_") === 0
+ ? "forecast-series-line forecast-series-prediction"
+ : "forecast-series-line";
+ svg.push(" ");
+ });
+
+ svg.push(" ");
+ svg.push("" + formatForecastDate(minTime) + " ");
+ svg.push("" + formatForecastDate(maxTime) + " ");
+ byId("forecastChart").innerHTML = svg.join("");
+ byId("forecastState").classList.add("hidden");
+ byId("forecastChart").classList.remove("hidden");
+ attachForecastHover(series, minTime, maxTime, width, height, padding);
+ }
+
+ var forecastCapabilityVariants = {
+ forecast_pv: [1, 10, 21],
+ forecast_load: [2, 11, 22],
+ grid_schedule: [3, 13, 23]
+ };
+ var customTariff = "Preis selber eingeben";
+ var forecastTariffs = {
+ import: {
+ dynamisch: [
+ { id: "CKW Dynamisch Home", label: "CKW Netz Home dynamic (ab 2027)", fallback: 0.2467 },
+ { id: "CKW Dynamisch Business", label: "CKW Netz Business dynamic (ab 2027)", fallback: 0.2467 },
+ { id: "BKW Sonne scheint", label: "BKW Sonne scheint (ab 2027)", fallback: 0.256 },
+ { id: "EWN MarktStrom Spot", label: "EWN MarktStrom Spot", fallback: 0.1715 },
+ { id: "EWO MarktStrom Spot", label: "EWO MarktStrom Spot", fallback: 0.2524 }
+ ],
+ statisch: [
+ { id: "CKW ClassicStrom 2027", price: 0.2467 },
+ { id: "BKW Energy Blue 2027", price: 0.256 },
+ { id: "EWN StandardStrom 50 Sommer 2027", price: 0.1715 },
+ { id: "EWN StandardStrom 50 Winter 2027", price: 0.1915 },
+ { id: "EWN NaturStrom 50 Sommer 2027", price: 0.1815 },
+ { id: "EWN NaturStrom 50 Winter 2027", price: 0.2015 },
+ { id: "EWO NaturStrom Sommer Normal 2026", price: 0.2524 },
+ { id: "EWO NaturStrom Sommer Spar 2026", price: 0.2162 },
+ { id: "EWO NaturStrom Winter Normal 2026", price: 0.2851 },
+ { id: "EWO NaturStrom Winter Spar 2026", price: 0.2489 },
+ { id: customTariff, custom: true }
+ ]
+ },
+ export: {
+ dynamisch: [
+ { id: "CKW Spotmarkt Einspeisung 2027", fallback: 0.06 },
+ { id: "BKW Dynamische Abnahmevergütung 2027", fallback: 0.06 },
+ { id: "EWN Referenzmarktpreis BFE", fallback: 0.06 },
+ { id: "EWO Referenzmarktpreis BFE", fallback: 0.06 },
+ { id: "Referenzmarktpreis Europa", fallback: 0.06 }
+ ],
+ statisch: [
+ { id: "Mindestvergütung bis 30 kWp 2027", price: 0.06 },
+ { id: customTariff, custom: true }
+ ]
+ }
+ };
+
+ function tariffOptions(direction, mode) {
+ return (forecastTariffs[direction] && forecastTariffs[direction][mode]) || [];
+ }
+
+ function tariffMode(direction, selected, configured) {
+ if (configured === "dynamisch" || configured === "statisch") return configured;
+ return tariffOptions(direction, "dynamisch").some(function (item) { return item.id === selected; })
+ ? "dynamisch" : "statisch";
+ }
+
+ function renderTariffSelector(form, direction, configuredMode, configuredTariff, configuredPrice, preserveConfiguredPrice) {
+ var prefix = direction === "import" ? "bezug" : "einspeisung";
+ var mode = tariffMode(direction, configuredTariff, configuredMode);
+ var modeField = form.elements["tarif_" + prefix + "_modus"];
+ var tariffField = form.elements["tarif_" + prefix];
+ var priceField = form.elements["tarif_" + prefix + "_fest"];
+ var options = tariffOptions(direction, mode);
+ var selected = options.find(function (item) { return item.id === configuredTariff; }) || options[0];
+ modeField.value = mode;
+ tariffField.innerHTML = options.map(function (item) {
+ return "" + escapeHtml(item.label || item.id) + " ";
+ }).join("");
+ tariffField.value = selected.id;
+ var configuredNumber = Number(configuredPrice);
+ var catalogPrice = selected.price === undefined ? selected.fallback : selected.price;
+ var price = preserveConfiguredPrice && Number.isFinite(configuredNumber)
+ ? configuredNumber
+ : (catalogPrice === undefined ? configuredPrice : catalogPrice);
+ priceField.value = forecastNumber(price, direction === "import" ? 0.3 : 0.1);
+ var priceLabel = form.querySelector("[data-tariff-custom='" + direction + "']");
+ var usesFixedPrice = mode === "statisch";
+ priceLabel.classList.toggle("hidden", !usesFixedPrice);
+ priceField.disabled = !usesFixedPrice;
+ priceField.required = usesFixedPrice;
+ }
+
+ function refreshTariffSelector(form, direction) {
+ var prefix = direction === "import" ? "bezug" : "einspeisung";
+ renderTariffSelector(
+ form,
+ direction,
+ form.elements["tarif_" + prefix + "_modus"].value,
+ form.elements["tarif_" + prefix].value,
+ form.elements["tarif_" + prefix + "_fest"].value,
+ false
+ );
+ }
+
+ function forecastNumber(value, fallback) {
+ var number = Number(value);
+ return Number.isFinite(number) ? number : fallback;
+ }
+
+ function topologyNumber(value, unit) {
+ return forecastNumber(value, 0).toLocaleString("de-CH", { maximumFractionDigits: 3 }) + " " + unit;
+ }
+
+ function topologyRows(title, items, describe) {
+ return "" + escapeHtml(title) + " "
+ + (items.length ? items.map(function (item) {
+ return "" + escapeHtml(item.name || item.id) + " "
+ + escapeHtml(describe(item)) + "
";
+ }).join("") : "Keine Komponenten
")
+ + " ";
+ }
+
+ function renderForecastTopology(topology) {
+ var block = byId("forecastTopologyBlock");
+ if (!topology) {
+ block.classList.add("hidden");
+ byId("forecastTopology").innerHTML = "";
+ return;
+ }
+ var summary = topology.summary || {};
+ var inverterTypes = { pv: "PV", battery: "Batterie", hybrid: "Hybrid" };
+ var couplingTypes = { ac: "AC-gekoppelt", dc: "DC-gekoppelt", hybrid: "Hybrid" };
+ byId("forecastTopology").innerHTML =
+ ""
+ + "
PV DC " + topologyNumber(summary.pvDcKwp, "kWp") + "
"
+ + "
PV AC " + topologyNumber(summary.pvAcKw, "kW") + "
"
+ + "
Batterie nutzbar " + topologyNumber(summary.usableBatteryKwh, "kWh") + "
"
+ + "
Laden " + topologyNumber(summary.maxBatteryChargeKw, "kW") + "
"
+ + "
Entladen " + topologyNumber(summary.maxBatteryDischargeKw, "kW") + "
"
+ + "
"
+ + topologyRows("Wechselrichter", topology.inverters || [], function (item) {
+ return (inverterTypes[item.type] || item.type) + " · " + topologyNumber(item.acRatedKw, "kW AC");
+ })
+ + topologyRows("PV-Flächen", topology.pvArrays || [], function (item) {
+ return topologyNumber(item.dcKwp, "kWp") + " · " + topologyNumber(item.tiltDeg, "°") + " · Azimut " + forecastNumber(item.azimuthDeg, 0);
+ })
+ + topologyRows("Batterien", topology.batteries || [], function (item) {
+ return topologyNumber(item.usableCapacityKwh, "kWh") + " · " + (couplingTypes[item.coupling] || item.coupling);
+ })
+ + "
";
+ block.classList.remove("hidden");
+ }
+
+ function renderForecastRoofs(roofs, readOnly) {
+ var disabled = readOnly ? " disabled" : "";
+ byId("forecastRoofs").innerHTML = (roofs || []).map(function (roof, index) {
+ return ""
+ + "Leistung kWp "
+ + "Neigung ° "
+ + "Azimut ° "
+ + "× "
+ + "
";
+ }).join("");
+ }
+
+ function fillForecastConfiguration(plant, result) {
+ var configuration = result.configuration || {};
+ var form = byId("forecastConfigForm");
+ form.dataset.plantId = plant.id;
+ form.elements.standort.value = configuration.standort || plant.location || "";
+ form.elements.kanton.value = configuration.kanton || "LU";
+ form.elements.ac_leistung.value = forecastNumber(configuration.ac_leistung, 10);
+ form.elements.batt_capacity_kwh.value = forecastNumber(configuration.batt_capacity_kwh, 0);
+ form.elements.batt_power_kw.value = forecastNumber(configuration.batt_power_kw, 0);
+ 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]);
+ });
+ });
+ var managerOwned = result.technicalSource === "manager" && Boolean(result.topology);
+ renderForecastRoofs(configuration.daecher || [], managerOwned);
+ renderForecastTopology(result.topology || null);
+
+ var canManage = state.session.organization.type === "personal"
+ || ["owner", "admin", "operator"].includes(state.session.organization.role);
+ form.querySelectorAll("input, select, button").forEach(function (control) {
+ control.disabled = !canManage;
+ });
+ if (managerOwned) {
+ form.querySelectorAll("[data-technical-field]").forEach(function (control) {
+ control.disabled = true;
+ });
+ byId("forecastAddRoof").disabled = true;
+ byId("forecastRoofs").querySelectorAll("input, button").forEach(function (control) {
+ 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;
+ }
+ });
+ byId("forecastConfigStatus").textContent = managerOwned
+ ? "Technik vom Manager"
+ : (result.configured ? "Gespeichert" : "Noch nicht gespeichert");
+ byId("forecastConfigStatus").className = "status " + (result.configured ? "available" : "review");
+ byId("forecastConfigSection").classList.remove("hidden");
+ }
+
+ async function loadForecastConfiguration(plant, requestId) {
+ 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("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.");
+ }
+ var variants = {};
+ Object.entries(forecastCapabilityVariants).forEach(function (entry) {
+ entry[1].forEach(function (variant) {
+ variants["prog_var_" + variant] = selected[entry[0]];
+ });
+ });
+ var roofs = Array.from(byId("forecastRoofs").querySelectorAll(".forecast-roof-row")).map(function (row) {
+ return {
+ kwp: Number(row.querySelector("[data-roof-field='kwp']").value),
+ neigung: Number(row.querySelector("[data-roof-field='neigung']").value),
+ azimut: Number(row.querySelector("[data-roof-field='azimut']").value)
+ };
+ });
+ return Object.assign({
+ standort: form.elements.standort.value,
+ kanton: form.elements.kanton.value,
+ ac_leistung: Number(form.elements.ac_leistung.value),
+ batt_capacity_kwh: Number(form.elements.batt_capacity_kwh.value),
+ batt_power_kw: Number(form.elements.batt_power_kw.value),
+ tarif_bezug_modus: form.elements.tarif_bezug_modus.value,
+ tarif_bezug: form.elements.tarif_bezug.value,
+ tarif_bezug_fest: Number(form.elements.tarif_bezug_fest.value),
+ tarif_einspeisung_modus: form.elements.tarif_einspeisung_modus.value,
+ tarif_einspeisung: form.elements.tarif_einspeisung.value,
+ tarif_einspeisung_fest: Number(form.elements.tarif_einspeisung_fest.value),
+ tarif_peak: "Festwert",
+ tarif_peak_fest: Number(form.elements.tarif_peak_fest.value),
+ daecher: roofs
+ }, variants);
+ }
+
+ var integratedPlanner = 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");
+ if (requestId !== state.forecastRequest || !byId("view-forecast").classList.contains("active")) return;
+ integratedPlanner = plannerModule.mountPlanner(host, { plantId: plant.id, session: state.session });
+ await integratedPlanner.ready;
+ } catch (error) {
+ if (requestId === state.forecastRequest) host.textContent = "Netzfahrplan nicht verfuegbar: " + error.message;
+ }
+ }
+
+ async function updateForecast() {
+ if (integratedPlanner) integratedPlanner.dispose();
+ integratedPlanner = null;
+ if (byId("forecastPlanner")) byId("forecastPlanner").replaceChildren();
+ var plant = selectedPlant("forecastPlant");
+ var requestId = ++state.forecastRequest;
+ if (!plant || state.forecastPlantId !== plant.id) {
+ state.forecastPlantId = plant ? plant.id : null;
+ state.forecastResult = null;
+ state.forecastVisibleSeries = null;
+ state.forecastRangeStart = null;
+ state.forecastRangeEnd = null;
+ }
+ resetForecastChart();
+ if (!plant) return setForecastState("Kein System verfügbar.");
+
+ 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;
+
+ void loadIntegratedPlanner(plant, requestId);
+
+ setForecastState("Prognosedaten werden geladen.");
+ byId("forecastApiStatus").textContent = "Datenverbindung wird geprüft";
+ 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);
+ }
+ } catch (error) {
+ if (requestId !== state.forecastRequest) return;
+ byId("forecastApiStatus").textContent = "Prognosedienst nicht erreichbar";
+ setForecastState(error.message);
+ }
+ }
+
+ function faultDate(value) {
+ if (!value) return "–";
+ var parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? "–" : parsed.toLocaleString("de-CH");
+ }
+
+ function faultSeverityLabel(value) {
+ return { critical: "Kritisch", error: "Fehler", warning: "Warnung", info: "Info" }[value] || value;
+ }
+
+ function resetFaultDisplay(message) {
+ byId("faultActiveCount").textContent = "0";
+ byId("faultUrgentCount").textContent = "0";
+ byId("faultLastUpdated").textContent = "–";
+ byId("faultActiveBody").innerHTML = "";
+ byId("faultHistoryBody").innerHTML = "";
+ byId("faultActiveTable").classList.add("hidden");
+ byId("faultHistoryTable").classList.add("hidden");
+ byId("faultState").classList.remove("hidden");
+ byId("faultState").textContent = message;
+ byId("faultHistoryState").classList.remove("hidden");
+ byId("faultHistoryState").textContent = "Noch keine behobenen Störungen.";
+ }
+
+ function resetFaultRecipients(message) {
+ byId("faultRecipientForm").classList.add("hidden");
+ byId("faultRecipientError").textContent = "";
+ byId("faultRecipientList").innerHTML = "";
+ byId("faultRecipientStatus").textContent = message;
+ }
+
+ function renderFaultRecipients(result) {
+ var recipients = result.recipients || [];
+ byId("faultRecipientForm").classList.toggle("hidden", !result.canManage);
+ byId("faultRecipientError").textContent = "";
+ byId("faultRecipientList").innerHTML = recipients.length
+ ? recipients.map(function (recipient) {
+ var remove = result.canManage
+ ? "× "
+ : "";
+ return "" + escapeHtml(recipient.email) +
+ " Seit " + escapeHtml(faultDate(recipient.createdAt)) + " " + remove + "
";
+ }).join("")
+ : "Noch keine Empfänger eingetragen.
";
+ byId("faultRecipientStatus").textContent = result.mailConfigured
+ ? recipients.length + " von " + Number(result.limit || 10) + " Empfängern eingetragen."
+ : "Der Mailversand ist serverseitig noch nicht konfiguriert.";
+ }
+
+ function faultRows(faults, history) {
+ return faults.map(function (fault) {
+ var source = "" + escapeHtml(fault.sourceName) + " " + escapeHtml(fault.sourceType + " · " + fault.sourceId) + " ";
+ var message = "" + escapeHtml(fault.message) + " " + escapeHtml(fault.code) + " ";
+ var time = history ? fault.resolvedAt : fault.firstSeenAt;
+ return "" +
+ escapeHtml(faultSeverityLabel(fault.severity)) + " " + source +
+ " " + message + " " + escapeHtml(faultDate(time)) + " ";
+ }).join("");
+ }
+
+ function renderFaultResult(result, plant) {
+ var active = result.active || [];
+ var history = result.history || [];
+ var labels = {
+ available: ["Aktuell", "available", "Störungsdaten sind aktuell."],
+ offline: ["Offline", "review", "Seit mehr als 15 Minuten keine Daten empfangen."],
+ no_data: ["Wartet auf Daten", "neutral", "Der Manager hat noch keinen Störungssnapshot gesendet."],
+ not_connected: ["Nicht verbunden", "review", "Das System ist noch nicht mit einem Manager verknüpft."],
+ license_required: ["Managerlizenz erforderlich", "review", "Für dieses System ist noch keine aktive Managerlizenz vorhanden."]
+ };
+ var status = labels[result.status] || ["Unbekannt", "review", "Der Störungsstatus konnte nicht bestimmt werden."];
+ var managerInactive = result.manager && !result.manager.active;
+ byId("faultConnectionStatus").textContent = managerInactive ? "Manager inaktiv" : status[0];
+ byId("faultConnectionStatus").className = "status " + (managerInactive ? "review" : status[1]);
+ byId("faultConnection").textContent = plant.installation_id ? "Verbunden mit " + plant.installation_id : "Noch nicht verknüpft";
+ byId("faultApiStatus").textContent = managerInactive ? "Der Manager meldet sich als inaktiv." : status[2];
+ byId("faultSignal").classList.toggle("offline", result.status !== "available" || managerInactive);
+ byId("faultActiveCount").textContent = String(active.length);
+ byId("faultUrgentCount").textContent = String(active.filter(function (fault) { return ["critical", "error"].includes(fault.severity); }).length);
+ byId("faultLastUpdated").textContent = faultDate(result.lastReceivedAt);
+
+ byId("faultActiveBody").innerHTML = faultRows(active, false);
+ byId("faultActiveTable").classList.toggle("hidden", active.length === 0);
+ byId("faultState").classList.toggle("hidden", active.length > 0);
+ byId("faultState").textContent = result.status === "available" && active.length === 0
+ ? "Keine aktiven Störungen."
+ : status[2];
+
+ byId("faultHistoryBody").innerHTML = faultRows(history, true);
+ byId("faultHistoryTable").classList.toggle("hidden", history.length === 0);
+ byId("faultHistoryState").classList.toggle("hidden", history.length > 0);
+ byId("faultHistoryState").textContent = "Noch keine behobenen Störungen.";
+ }
+
+ async function updateFaults() {
+ var plant = selectedPlant("faultPlant");
+ var requestId = ++state.faultRequest;
+ resetFaultDisplay("Störungsdaten werden geladen.");
+ resetFaultRecipients("Empfänger werden geladen.");
+ if (!plant) {
+ byId("faultConnectionStatus").textContent = "Kein System";
+ byId("faultConnectionStatus").className = "status neutral";
+ resetFaultRecipients("Für die Empfängerverwaltung muss eine Anlage gewählt sein.");
+ return;
+ }
+
+ byId("faultConnection").textContent = plant.installation_id ? "Verbunden mit " + plant.installation_id : "Noch nicht verknüpft";
+ if (!byId("view-faults").classList.contains("active")) return;
+
+ byId("faultApiStatus").textContent = "Datenverbindung wird geprüft";
+ var responses = await Promise.allSettled([
+ api("/api/plants/" + plant.id + "/faults"),
+ api("/api/plants/" + plant.id + "/fault-notification-recipients")
+ ]);
+ if (requestId !== state.faultRequest) return;
+ if (responses[0].status === "fulfilled") {
+ renderFaultResult(responses[0].value, plant);
+ } else {
+ byId("faultConnectionStatus").textContent = "Nicht erreichbar";
+ byId("faultConnectionStatus").className = "status review";
+ byId("faultApiStatus").textContent = "Störungsdienst nicht erreichbar";
+ byId("faultSignal").classList.add("offline");
+ resetFaultDisplay(responses[0].reason.message);
+ }
+ if (responses[1].status === "fulfilled") {
+ renderFaultRecipients(responses[1].value);
+ } else {
+ resetFaultRecipients(responses[1].reason.message);
+ }
+ }
+
+ function configurePlantLegacy(plant) {
+ if (!plant) return;
+ var owned = paidLicenseSummary(plant.id);
+ var configuration = null;
+ try { configuration = plant.configuration_json ? JSON.parse(plant.configuration_json) : null; } catch (_) { configuration = null; }
+ configuration = configuration || {
+ name: plant.name,
+ location: plant.location,
+ siteType: plant.type,
+ peakShaving: currentManagerVariant(plant) === "manager_peak",
+ gridMeasurement: { manufacturer: "", host: "", source: "", notes: "" },
+ pvSystem: { enabled: false, manufacturer: "", host: "" },
+ costReport: { enabled: false, powerMeters: 0, auxiliaryMeters: 0 },
+ devices: {
+ battery: { quantity: owned.battery || 0, name: "" },
+ buffer_storage: { quantity: owned.buffer_storage || 0, name: "" },
+ ev_charger: { quantity: owned.ev_charger || 0, model: "", address: "" },
+ consumer_single: { quantity: owned.consumer_single || 0, name: "", ratedPower: 0 },
+ boiler_multi: { quantity: owned.boiler_multi || 0, name: "", volume: 0 }
+ }
+ };
+ configuration.devices = configuration.devices || {};
+ if (!Object.prototype.hasOwnProperty.call(configuration.devices, "buffer_storage")) {
+ configuration.devices.buffer_storage = configuration.devices.battery || {};
+ configuration.devices.battery = {};
+ }
+ Object.keys(deviceLabels).forEach(function (key) {
+ configuration.devices[key] = configuration.devices[key] || {};
+ configuration.devices[key].quantity = Math.max(Number(configuration.devices[key].quantity || 0), Number(owned[key] || 0));
+ });
+ var form = byId("systemForm");
+ form.dataset.plantId = plant.id;
+ form.elements.name.value = configuration.name || plant.name;
+ form.elements.location.value = configuration.location || plant.location;
+ form.elements.siteType.value = configuration.siteType || plant.type;
+ form.elements.peakShaving.checked = Boolean(configuration.peakShaving);
+ form.elements.gridManufacturer.value = configuration.gridMeasurement?.manufacturer || "";
+ form.elements.gridHost.value = configuration.gridMeasurement?.host || "";
+ form.elements.gridSource.value = configuration.gridMeasurement?.source || "";
+ form.elements.gridNotes.value = configuration.gridMeasurement?.notes || "";
+ form.elements.pvEnabled.checked = Boolean(configuration.pvSystem?.enabled);
+ form.elements.pvManufacturer.value = configuration.pvSystem?.manufacturer || "";
+ form.elements.pvHost.value = configuration.pvSystem?.host || "";
+ form.elements.batteryQuantity.value = configuration.devices?.battery?.quantity || 0;
+ form.elements.batteryName.value = configuration.devices?.battery?.name || "";
+ form.elements.bufferQuantity.value = configuration.devices?.buffer_storage?.quantity || 0;
+ form.elements.bufferName.value = configuration.devices?.buffer_storage?.name || "";
+ form.elements.evQuantity.value = configuration.devices?.ev_charger?.quantity || 0;
+ form.elements.evModel.value = configuration.devices?.ev_charger?.model || "";
+ form.elements.evAddress.value = configuration.devices?.ev_charger?.address || "";
+ form.elements.consumerQuantity.value = configuration.devices?.consumer_single?.quantity || 0;
+ form.elements.consumerName.value = configuration.devices?.consumer_single?.name || "";
+ form.elements.consumerPower.value = configuration.devices?.consumer_single?.ratedPower || 0;
+ form.elements.consumerHardware.value = configuration.devices?.consumer_single?.hardware || "";
+ form.elements.consumerTopic.value = configuration.devices?.consumer_single?.topicPrefix || "";
+ form.elements.boilerQuantity.value = configuration.devices?.boiler_multi?.quantity || 0;
+ form.elements.boilerName.value = configuration.devices?.boiler_multi?.name || "";
+ form.elements.boilerVolume.value = configuration.devices?.boiler_multi?.volume || 0;
+ form.elements.boilerHardware.value = configuration.devices?.boiler_multi?.hardware || "";
+ form.elements.boilerTopic.value = configuration.devices?.boiler_multi?.topicPrefix || "";
+ form.elements.costReportEnabled.checked = Boolean(configuration.costReport?.enabled);
+ form.elements.costPowerMeters.value = configuration.costReport?.powerMeters || 0;
+ form.elements.costAuxMeters.value = configuration.costReport?.auxiliaryMeters || 0;
+ byId("packageResult").classList.add("hidden");
+ showWizardStep(0);
+ updateRecommendation();
+ showView("configurator");
+ }
+
+ function systemPayloadLegacy(form) {
+ var values = Object.fromEntries(new FormData(form).entries());
+ return {
+ plantId: form.dataset.plantId || "",
+ name: values.name, location: values.location, siteType: values.siteType, peakShaving: Boolean(values.peakShaving),
+ gridMeasurement: { manufacturer: values.gridManufacturer, host: values.gridHost, source: values.gridSource, notes: values.gridNotes },
+ pvSystem: { enabled: Boolean(values.pvEnabled), manufacturer: values.pvManufacturer, host: values.pvHost },
+ costReport: { enabled: Boolean(values.costReportEnabled), powerMeters: Number(values.costPowerMeters || 0), auxiliaryMeters: Number(values.costAuxMeters || 0) },
+ devices: {
+ battery: { quantity: Number(values.batteryQuantity), name: values.batteryName },
+ buffer_storage: { quantity: Number(values.bufferQuantity), name: values.bufferName },
+ ev_charger: { quantity: Number(values.evQuantity), name: "Ladestation", model: values.evModel, address: values.evAddress },
+ consumer_single: { quantity: Number(values.consumerQuantity), name: values.consumerName, ratedPower: Number(values.consumerPower || 0), hardware: values.consumerHardware, topicPrefix: values.consumerTopic },
+ boiler_multi: { quantity: Number(values.boilerQuantity), name: values.boilerName, volume: Number(values.boilerVolume || 0), hardware: values.boilerHardware, topicPrefix: values.boilerTopic }
+ }
+ };
+ }
+
+ function schemaDefaults(fields) {
+ return Object.fromEntries((fields || []).map(function (field) { return [field.key, field.default]; }));
+ }
+
+ function renderSchemaField(field, value, attribute) {
+ var data = " " + attribute + "='" + escapeHtml(field.key) + "'";
+ if (field.type === "boolean") {
+ return "" + escapeHtml(field.label) + " ";
+ }
+ if (field.type === "select" || field.type === "choice") {
+ var options = (field.options || []).map(function (option) {
+ return "" + escapeHtml(option.label) + " ";
+ }).join("");
+ return "" + escapeHtml(field.label) + "" + options + " ";
+ }
+ if (field.type === "json") {
+ return "" + escapeHtml(field.label) + " ";
+ }
+ var type = ["integer", "number"].includes(field.type) ? "number" : "text";
+ var step = field.type === "number" ? " step='any'" : "";
+ var min = field.min == null ? "" : " min='" + escapeHtml(field.min) + "'";
+ var max = field.max == null ? "" : " max='" + escapeHtml(field.max) + "'";
+ return "" + escapeHtml(field.label) + " ";
+ }
+
+ function renderSchemaGroups(fields, values, attribute) {
+ var groups = {};
+ (fields || []).forEach(function (field) {
+ groups[field.section] = groups[field.section] || [];
+ groups[field.section].push(field);
+ });
+ return Object.keys(groups).map(function (section) {
+ var controls = groups[section].map(function (field) {
+ return renderSchemaField(field, Object.prototype.hasOwnProperty.call(values || {}, field.key) ? values[field.key] : field.default, attribute);
+ }).join("");
+ return "" + escapeHtml(section) + " " + controls + "
";
+ }).join("");
+ }
+
+ function readSchemaValues(root, fields, attribute) {
+ var result = {};
+ (fields || []).forEach(function (field) {
+ var control = root.querySelector("[" + attribute + "='" + field.key + "']");
+ if (!control) return;
+ if (field.type === "boolean") result[field.key] = control.checked;
+ else if (field.type === "integer" || field.type === "select") result[field.key] = Number(control.value || 0);
+ else if (field.type === "choice") result[field.key] = control.value;
+ else if (field.type === "number") result[field.key] = Number(control.value || 0);
+ else result[field.key] = control.value;
+ });
+ return result;
+ }
+
+ function profileOptions(kind, selected) {
+ return (state.wizardCatalog?.integrations?.[kind] || []).map(function (profile) {
+ var label = profile.manufacturer + " · " + profile.name;
+ return "" + escapeHtml(label) + " ";
+ }).join("");
+ }
+
+ function selectedProfile(kind, id) {
+ var profiles = state.wizardCatalog?.integrations?.[kind] || [];
+ return profiles.find(function (entry) { return entry.id === id; }) || profiles[0] || {};
+ }
+
+ function integrationKind(key) {
+ if (key === "battery") return "storage";
+ if (["buffer_storage", "consumer_single", "boiler_multi"].includes(key)) return "actuator";
+ return "";
+ }
+
+ function renderIntegrationFields(key, integration, compact) {
+ var kind = integrationKind(key);
+ if (!kind) return "";
+ var selected = selectedProfile(kind, integration.profile || "manual");
+ if (kind === "storage") {
+ var manualStorage = selected.id === "manual"
+ ? "Bestehende Leistungsvariable "
+ : "";
+ return "";
+ }
+ var manual = selected.id === "manual"
+ ? "Bestehender Schaltkontakt " +
+ (key === "consumer_single" ? "Bestehende Rückmeldung " : "")
+ : "";
+ return "";
+ }
+
+ function clientLegacyItem(key, source, index) {
+ var properties = schemaDefaults(state.wizardCatalog?.consumerFields?.[key]);
+ var integration = { profile: "manual", host: "", port: 0, unitId: 1, topicPrefix: "", channel: 0, sourceVariableId: 0 };
+ if (key === "consumer_single") {
+ properties.Nennleistung = Number(source.ratedPower || 0);
+ if (source.hardware === "shelly_1pm") integration.profile = "shelly_plus_1pm";
+ integration.topicPrefix = source.topicPrefix || "";
+ }
+ if (key === "boiler_multi") {
+ properties.Boilervolumen = Number(source.volume || 300);
+ if (source.hardware === "shelly_1pm") integration.profile = "shelly_plus_1pm";
+ integration.topicPrefix = source.topicPrefix || "";
+ }
+ if (key === "buffer_storage" && source.hardware === "shelly_1pm") {
+ integration.profile = "shelly_plus_1pm";
+ integration.topicPrefix = source.topicPrefix || "";
+ }
+ if (key === "ev_charger") {
+ properties.Geraetetyp = { goe_legacy: 1, goe_gemini: 2, smartme_pico: 3 }[source.model] || 0;
+ properties.Geraeteadresse = source.address || "";
+ }
+ return {
+ name: source.name || deviceLabels[key] + " " + (index + 1),
+ variant: key === "ev_charger" ? "standalone" : undefined,
+ integration: integration,
+ properties: properties,
+ topology: key === "battery" ? schemaDefaults(state.wizardCatalog?.topologyFields?.battery) : undefined,
+ powerLevels: ["buffer_storage", "boiler_multi"].includes(key) ? [] : undefined,
+ schedule: key === "boiler_multi" ? [] : undefined,
+ sensor: ["buffer_storage", "boiler_multi"].includes(key)
+ ? { profile: "manual", topicPrefix: "", sourceVariableId: 0 }
+ : undefined
+ };
+ }
+
+ function collectIntegration(root) {
+ var integration = {};
+ var scope = root.querySelector("[data-main-integration]") || root;
+ scope.querySelectorAll("[data-integration-field]").forEach(function (control) {
+ var key = control.dataset.integrationField;
+ integration[key] = ["port", "unitId", "channel", "sourceVariableId", "feedbackVariableId"].includes(key) ? Number(control.value || 0) : control.value;
+ });
+ return integration;
+ }
+
+ function renderSensorFields(sensor) {
+ var selected = selectedProfile("sensor", sensor?.profile || "manual");
+ var source = selected.id === "manual"
+ ? "Bestehende Temperaturvariable "
+ : "MQTT-Topic-Präfix ";
+ return "Temperaturfühler " +
+ "Fühlerprofil" + profileOptions("sensor", selected.id) + " " +
+ source + "
Beim Shelly Plus Add-on wird die Temperaturvariable automatisch zugeordnet.
";
+ }
+
+ function renderPowerLevels(key, levels) {
+ var fields = state.wizardCatalog?.deviceListFields?.powerLevel || [];
+ var rows = (levels || []).map(function (level, index) {
+ var values = Object.assign(schemaDefaults(fields), level || {}, { Stufe: index + 1 });
+ return "Stufe " + (index + 1) +
+ " Entfernen
" +
+ renderSchemaGroups(fields, values, "data-power-level-field") + "
" +
+ renderIntegrationFields(key, level.integration || { profile: "manual" }, true) + "
";
+ }).join("");
+ return "Leistungsstufen Leistung und Aktor je Stufe.
" +
+ "
Stufe hinzufügen " + rows + "
";
+ }
+
+ function renderScheduleRows(rows) {
+ var fields = state.wizardCatalog?.deviceListFields?.schedule || [];
+ var content = (rows || []).map(function (row, index) {
+ return "Zeitpunkt " + (index + 1) +
+ " Entfernen
" +
+ renderSchemaGroups(fields, Object.assign(schemaDefaults(fields), row || {}), "data-schedule-field") + "
";
+ }).join("");
+ return "Solltemperatur-Zeitplan Uhrzeiten und Solltemperaturen als Liste.
" +
+ "
Zeitpunkt hinzufügen " + content + "
";
+ }
+
+ function collectPowerLevels(root) {
+ var fields = state.wizardCatalog?.deviceListFields?.powerLevel || [];
+ return Array.from(root.querySelectorAll("[data-power-level]")).map(function (row, index) {
+ return {
+ ...readSchemaValues(row, fields, "data-power-level-field"),
+ Stufe: index + 1,
+ integration: collectIntegration(row)
+ };
+ });
+ }
+
+ function collectScheduleRows(root) {
+ var fields = state.wizardCatalog?.deviceListFields?.schedule || [];
+ return Array.from(root.querySelectorAll("[data-schedule-row]")).map(function (row) {
+ return readSchemaValues(row, fields, "data-schedule-field");
+ });
+ }
+
+ function collectDeviceItems(key) {
+ return Array.from(document.querySelectorAll("[data-device-editor='" + key + "'] [data-device-instance]")).map(function (root) {
+ var variant = root.querySelector("[data-ev-variant]")?.value || "standalone";
+ var fields = key === "ev_charger" && variant === "gateway"
+ ? state.wizardCatalog?.evGatewayFields || []
+ : state.wizardCatalog?.consumerFields?.[key] || [];
+ var result = {
+ name: root.querySelector("[data-device-name]").value,
+ variant: variant,
+ integration: ["buffer_storage", "boiler_multi"].includes(key) ? { profile: "manual" } : collectIntegration(root),
+ properties: readSchemaValues(root, fields, "data-device-property")
+ };
+ if (key === "battery") {
+ result.topology = readSchemaValues(root, state.wizardCatalog?.topologyFields?.battery || [], "data-battery-topology");
+ }
+ if (["buffer_storage", "boiler_multi"].includes(key)) {
+ result.powerLevels = collectPowerLevels(root);
+ result.sensor = collectIntegration(root.querySelector("[data-sensor-integration]"));
+ }
+ if (key === "boiler_multi") result.schedule = collectScheduleRows(root);
+ return result;
+ });
+ }
+
+ function renderDeviceEditor(key, quantity, sourceItems) {
+ var editor = document.querySelector("[data-device-editor='" + key + "']");
+ if (!editor || !state.wizardCatalog) return;
+ var items = sourceItems || (editor.children.length ? collectDeviceItems(key) : state.deviceDrafts[key] || []);
+ while (items.length < quantity) items.push(clientLegacyItem(key, {}, items.length));
+ items = items.slice(0, quantity);
+ state.deviceDrafts[key] = items;
+ editor.innerHTML = items.map(function (item, index) {
+ var variant = key === "ev_charger" && item.variant === "gateway" ? "gateway" : "standalone";
+ var fields = key === "ev_charger" && variant === "gateway"
+ ? state.wizardCatalog.evGatewayFields || []
+ : state.wizardCatalog.consumerFields[key] || [];
+ var properties = Object.assign(schemaDefaults(fields), item.properties || {});
+ var name = item.name || deviceLabels[key] + " " + (index + 1);
+ var identity = "Bezeichnung ";
+ if (key === "ev_charger") {
+ identity += "Anbindung" + (state.wizardCatalog.evVariants || []).map(function (option) {
+ return "" + escapeHtml(option.label) + " ";
+ }).join("") + " ";
+ }
+ identity += "
";
+ var topology = key === "battery"
+ ? "" + renderSchemaGroups(
+ state.wizardCatalog?.topologyFields?.battery || [],
+ Object.assign(schemaDefaults(state.wizardCatalog?.topologyFields?.battery), item.topology || {}),
+ "data-battery-topology"
+ ) + "
"
+ : "";
+ var integration = ["battery", "consumer_single"].includes(key)
+ ? renderIntegrationFields(key, item.integration || {})
+ : "";
+ var structured = ["buffer_storage", "boiler_multi"].includes(key)
+ ? renderSensorFields(item.sensor || {}) + renderPowerLevels(key, item.powerLevels || [])
+ : "";
+ if (key === "boiler_multi") structured += renderScheduleRows(item.schedule || []);
+ return "" +
+ escapeHtml(name) + " " + identity + integration + topology + structured +
+ "
" + renderSchemaGroups(fields, properties, "data-device-property") + "
";
+ }).join("");
+ }
+
+ function collectPvSurfaces() {
+ var fields = state.wizardCatalog?.topologyFields?.pvSurface || [];
+ return Array.from(document.querySelectorAll("[data-topology-editor='pvSurfaces'] [data-topology-instance]")).map(function (root, index) {
+ return {
+ ID: "pv-" + (index + 1),
+ ...readSchemaValues(root, fields, "data-pv-surface")
+ };
+ });
+ }
+
+ function renderPvSurfaceEditor(quantity, sourceItems) {
+ var editor = document.querySelector("[data-topology-editor='pvSurfaces']");
+ if (!editor || !state.wizardCatalog) return;
+ var fields = state.wizardCatalog.topologyFields?.pvSurface || [];
+ var items = sourceItems || (editor.children.length ? collectPvSurfaces() : state.pvSurfaceDrafts || []);
+ while (items.length < quantity) {
+ items.push({ ID: "pv-" + (items.length + 1), ...schemaDefaults(fields), Name: "PV-Fläche " + (items.length + 1) });
+ }
+ items = items.slice(0, quantity);
+ state.pvSurfaceDrafts = items;
+ editor.innerHTML = items.map(function (item, index) {
+ var values = Object.assign(schemaDefaults(fields), item || {});
+ return "" +
+ escapeHtml(values.Name || "PV-Fläche " + (index + 1)) + " " +
+ renderSchemaGroups(fields, values, "data-pv-surface") + "
";
+ }).join("");
+ }
+
+ function renderManagerFields(properties) {
+ if (!state.wizardCatalog || !byId("managerConfigurationFields")) return;
+ byId("managerConfigurationFields").innerHTML = renderSchemaGroups(state.wizardCatalog.managerFields, properties || {}, "data-manager-property");
+ }
+
+ function applyWizardCatalog(catalog) {
+ state.wizardCatalog = catalog;
+ document.querySelectorAll("[data-profile-select='energy']").forEach(function (select) {
+ select.innerHTML = profileOptions("energy", "manual");
+ });
+ renderManagerFields({});
+ Object.keys(deviceLabels).forEach(function (key) { renderDeviceEditor(key, 0, []); });
+ renderPvSurfaceEditor(0, []);
+ updateEnergySourceVisibility(byId("systemForm"), "grid");
+ updateEnergySourceVisibility(byId("systemForm"), "pv");
+ updatePvTopologyVisibility(byId("systemForm"));
+ }
+
+ function updateEnergySourceVisibility(form, prefix) {
+ var manual = form.elements[prefix + "Profile"]?.value === "manual";
+ form.querySelector("[data-manual-source='" + prefix + "']")?.classList.toggle("hidden", !manual);
+ }
+
+ function updatePvTopologyVisibility(form) {
+ form.querySelector("[data-pv-topology]")?.classList.toggle("hidden", !form.elements.pvEnabled.checked);
+ }
+
+ function setEnergyIntegration(form, prefix, integration) {
+ var select = form.elements[prefix + "Profile"];
+ select.value = integration.profile || (integration.manufacturer === "fronius" ? "fronius_solar_api_v1" : "manual");
+ form.elements[prefix + "Host"].value = integration.host || "";
+ form.elements[prefix + "Port"].value = integration.port || selectedProfile("energy", select.value).defaultPort || 0;
+ form.elements[prefix + "UnitId"].value = integration.unitId == null ? 1 : integration.unitId;
+ if (form.elements[prefix + "SourceVariableId"]) {
+ form.elements[prefix + "SourceVariableId"].value = integration.sourceVariableId || 0;
+ }
+ updateEnergySourceVisibility(form, prefix);
+ }
+
+ function configurePlant(plant) {
+ if (!plant || !state.wizardCatalog) return;
+ var owned = paidLicenseSummary(plant.id);
+ var configuration = null;
+ try { configuration = plant.configuration_json ? JSON.parse(plant.configuration_json) : null; } catch (_) { configuration = null; }
+ configuration = configuration || {
+ name: plant.name, location: plant.location, siteType: plant.type,
+ peakShaving: currentManagerVariant(plant) === "manager_peak",
+ manager: { properties: {} },
+ gridMeasurement: { profile: "manual" },
+ pvSystem: { enabled: false, profile: "manual" },
+ costReport: { enabled: false, powerMeters: 0, auxiliaryMeters: 0 },
+ devices: {}
+ };
+ configuration.devices = configuration.devices || {};
+ if (!Object.prototype.hasOwnProperty.call(configuration.devices, "buffer_storage") && configuration.devices.battery) {
+ configuration.devices.buffer_storage = configuration.devices.battery;
+ configuration.devices.battery = {};
+ }
+ var form = byId("systemForm");
+ form.dataset.plantId = plant.id;
+ form.elements.name.value = configuration.name || plant.name;
+ form.elements.location.value = configuration.location || plant.location;
+ form.elements.siteType.value = configuration.siteType || plant.type;
+ form.elements.peakShaving.checked = Boolean(configuration.peakShaving);
+ setEnergyIntegration(form, "grid", configuration.gridMeasurement || {});
+ form.elements.gridSourceVariableId.value = configuration.gridMeasurement?.sourceVariableId || Number(configuration.gridMeasurement?.source || 0);
+ form.elements.gridNotes.value = configuration.gridMeasurement?.notes || "";
+ form.elements.pvEnabled.checked = Boolean(configuration.pvSystem?.enabled);
+ setEnergyIntegration(form, "pv", configuration.pvSystem || {});
+ form.elements.pvName.value = configuration.pvSystem?.name || "PV-Wechselrichter";
+ form.elements.pvAcPowerKw.value = configuration.pvSystem?.acPowerKw || 0;
+ var pvSurfaces = Array.isArray(configuration.topology?.pvSurfaces) ? configuration.topology.pvSurfaces.slice() : [];
+ if (!pvSurfaces.length && configuration.manager?.properties?.AnlagenPVFlaechen) {
+ try {
+ var legacySurfaces = JSON.parse(configuration.manager.properties.AnlagenPVFlaechen);
+ if (Array.isArray(legacySurfaces)) pvSurfaces = legacySurfaces;
+ } catch (_) {}
+ }
+ var pvSurfaceQuantity = configuration.pvSystem?.enabled ? Math.max(1, pvSurfaces.length) : 0;
+ form.elements.pvSurfaceQuantity.value = pvSurfaceQuantity;
+ renderPvSurfaceEditor(pvSurfaceQuantity, pvSurfaces);
+ updatePvTopologyVisibility(form);
+ renderManagerFields(configuration.manager?.properties || {});
+ var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity", heat_pump: "heatPumpQuantity" };
+ Object.keys(deviceLabels).forEach(function (key) {
+ var source = configuration.devices[key] || {};
+ var quantity = Math.max(Number(source.quantity || 0), Number(owned[key] || 0));
+ var items = Array.isArray(source.items) ? source.items.slice() : [];
+ while (items.length < quantity) items.push(clientLegacyItem(key, source, items.length));
+ form.elements[quantityNames[key]].value = quantity;
+ renderDeviceEditor(key, quantity, items);
+ });
+ form.elements.costReportEnabled.checked = Boolean(configuration.costReport?.enabled);
+ form.elements.costPowerMeters.value = configuration.costReport?.powerMeters || 0;
+ form.elements.costAuxMeters.value = configuration.costReport?.auxiliaryMeters || 0;
+ byId("packageResult").classList.add("hidden");
+ showWizardStep(0);
+ updateRecommendation();
+ showView("configurator");
+ }
+
+ function energyIntegration(values, prefix) {
+ return {
+ profile: values[prefix + "Profile"],
+ host: values[prefix + "Host"],
+ port: Number(values[prefix + "Port"] || 0),
+ unitId: Number(values[prefix + "UnitId"] || 1),
+ sourceVariableId: Number(values[prefix + "SourceVariableId"] || 0),
+ notes: values[prefix + "Notes"] || ""
+ };
+ }
+
+ function systemPayload(form) {
+ var values = Object.fromEntries(new FormData(form).entries());
+ var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity", heat_pump: "heatPumpQuantity" };
+ var devices = {};
+ Object.keys(deviceLabels).forEach(function (key) {
+ devices[key] = { quantity: Number(values[quantityNames[key]] || 0), items: collectDeviceItems(key) };
+ });
+ return {
+ plantId: form.dataset.plantId || "",
+ name: values.name,
+ location: values.location,
+ siteType: values.siteType,
+ peakShaving: Boolean(values.peakShaving),
+ manager: { properties: readSchemaValues(byId("managerConfigurationFields"), state.wizardCatalog?.managerFields || [], "data-manager-property") },
+ gridMeasurement: energyIntegration(values, "grid"),
+ pvSystem: Object.assign({
+ enabled: Boolean(values.pvEnabled),
+ name: values.pvName,
+ acPowerKw: Number(values.pvAcPowerKw || 0)
+ }, energyIntegration(values, "pv")),
+ topology: { pvSurfaces: collectPvSurfaces() },
+ costReport: { enabled: Boolean(values.costReportEnabled), powerMeters: Number(values.costPowerMeters || 0), auxiliaryMeters: Number(values.costAuxMeters || 0) },
+ devices: devices
+ };
+ }
+
+ var wizardTitles = ["Anlage", "Manager, Netz und PV", "Batteriespeicher", "Pufferspeicher", "EV-Ladestation", "Einstufige Verbraucher", "Boiler", "Wärmepumpe", "Verbrauchskostenreport", "Angaben prüfen"];
+
+ function updateRecommendation() {
+ var payload = systemPayload(byId("systemForm"));
+ var manager = payload.peakShaving ? "Manager mit Peak Shaving" : "Manager ohne Peak Shaving";
+ byId("recommendManager").textContent = manager;
+ var selectedDevices = Object.keys(deviceLabels).filter(function (key) { return payload.devices[key].quantity > 0; });
+ var configuredPlant = state.plants.find(function (plant) { return plant.id === byId("systemForm").dataset.plantId; });
+ var owned = configuredPlant ? paidLicenseSummary(configuredPlant.id) : {};
+ var requestedModules = Object.fromEntries(Object.entries(payload.devices).map(function (entry) { return [entry[0], entry[1].quantity]; }));
+ var coverage = licenseCoverage(requestedModules, owned);
+ var rows = selectedDevices.map(function (key) {
+ var requested = Number(payload.devices[key].quantity || 0);
+ var licensed = Number(coverage[key] || 0);
+ var missing = Math.max(0, requested - licensed);
+ var licenseText = missing
+ ? licensed + " lizenziert · " + missing + " zusätzliche " + (missing === 1 ? "Lizenz" : "Lizenzen") + " erforderlich"
+ : requested + " vollständig lizenziert";
+ return "" + escapeHtml(deviceLabels[key]) + "" + escapeHtml(licenseText) + " × " + requested + "
";
+ });
+ byId("recommendModules").innerHTML = rows.length ? rows.join("") : "Keine Verbrauchermodule gewählt.
";
+ if (byId("wizardReview")) {
+ var deviceSummary = rows.length ? rows.join("") : "Verbrauchermodule Keine
";
+ byId("wizardReview").innerHTML =
+ "System " + escapeHtml(payload.name || "Noch nicht angegeben") + "
" +
+ "Standort " + escapeHtml(payload.location || "Noch nicht angegeben") + "
" +
+ "Anlagentyp " + escapeHtml(payload.siteType) + "
" +
+ "Manager " + escapeHtml(manager) + "
" +
+ "Netzmessung " + escapeHtml(selectedProfile("energy", payload.gridMeasurement.profile).name + (payload.gridMeasurement.host ? " · " + payload.gridMeasurement.host : "")) + "
" +
+ "PV-Anlage " + escapeHtml(payload.pvSystem.enabled ? selectedProfile("energy", payload.pvSystem.profile).name + (payload.pvSystem.host ? " · " + payload.pvSystem.host : "") : "Nicht gewählt") + "
" +
+ "Verbrauchskostenreport " + escapeHtml(payload.costReport.enabled ? "Ja · separater Lizenzcode" : "Nein") + "
" + deviceSummary;
+ }
+ }
+
+ function showWizardStep(step) {
+ var steps = document.querySelectorAll("[data-wizard-step]");
+ state.wizardStep = Math.max(0, Math.min(steps.length - 1, step));
+ steps.forEach(function (node, index) { node.classList.toggle("active", index === state.wizardStep); });
+ document.querySelectorAll("[data-wizard-marker]").forEach(function (node, index) {
+ node.classList.toggle("active", index === state.wizardStep);
+ node.classList.toggle("done", index < state.wizardStep);
+ });
+ byId("wizardStepCount").textContent = "Schritt " + (state.wizardStep + 1) + " von " + steps.length;
+ byId("wizardStepTitle").textContent = wizardTitles[state.wizardStep];
+ byId("wizardBack").disabled = state.wizardStep === 0;
+ byId("wizardNext").classList.toggle("hidden", state.wizardStep === steps.length - 1);
+ byId("wizardSubmit").classList.toggle("hidden", state.wizardStep !== steps.length - 1);
+ if (state.wizardStep === steps.length - 1) updateRecommendation();
+ }
+
+ function currentWizardStepValid() {
+ var step = document.querySelector("[data-wizard-step='" + state.wizardStep + "']");
+ var form = byId("systemForm");
+ step.querySelectorAll("input, select, textarea").forEach(function (field) { field.setCustomValidity(""); });
+ if (state.wizardStep === 1) {
+ [["grid", true], ["pv", form.elements.pvEnabled.checked]].forEach(function (entry) {
+ if (!entry[1]) return;
+ var profile = selectedProfile("energy", form.elements[entry[0] + "Profile"].value);
+ var host = form.elements[entry[0] + "Host"];
+ if (profile.requiresHost && !host.value.trim()) host.setCustomValidity("Bitte IP-Adresse oder Hostnamen angeben.");
+ });
+ }
+ step.querySelectorAll("[data-integration-field='profile']").forEach(function (select) {
+ var instance = select.closest("[data-device-instance]");
+ var editor = select.closest("[data-device-editor]");
+ var scope = select.closest("[data-main-integration], [data-sensor-integration]") || instance;
+ var kind = select.closest("[data-sensor-integration]") ? "sensor" : integrationKind(editor?.dataset.deviceEditor || "");
+ var profile = selectedProfile(kind, select.value);
+ var requiredField = profile.requiresHost ? "host" : (profile.requiresTopic ? "topicPrefix" : "");
+ var control = requiredField ? scope.querySelector("[data-integration-field='" + requiredField + "']") : null;
+ if (control && !control.value.trim()) control.setCustomValidity(profile.requiresHost
+ ? "Bitte IP-Adresse oder Hostnamen angeben."
+ : "Bitte MQTT-Topic-Präfix angeben.");
+ });
+ var invalid = Array.from(step.querySelectorAll("input, select, textarea")).find(function (field) { return !field.checkValidity(); });
+ if (!invalid) return true;
+ invalid.reportValidity();
+ return false;
+ }
+
+ async function submitAuth(form, endpoint, errorId) {
+ var errorNode = byId(errorId); errorNode.textContent = ""; setBusy(form, true);
+ try {
+ var result = await api(endpoint, { method: "POST", body: Object.fromEntries(new FormData(form).entries()) });
+ form.reset();
+ if (result.pendingVerification) { switchAuth("login"); authNotice(result.message, !result.delivered); return; }
+ await showPortal(result);
+ toast(endpoint.endsWith("register") ? "Konto wurde erstellt." : "Erfolgreich angemeldet.");
+ } catch (error) { errorNode.textContent = error.message; } finally { setBusy(form, false); }
+ }
+
+ byId("loginTab").addEventListener("click", function () { switchAuth("login"); });
+ byId("registerTab").addEventListener("click", function () { switchAuth("register"); });
+ byId("forgotPasswordButton").addEventListener("click", function () { authNotice(""); specialAuth("forgotForm"); });
+ byId("resendVerificationButton").addEventListener("click", async function () {
+ var email = byId("loginForm").querySelector("input[name='email']").value.trim();
+ if (!email) return void (byId("loginError").textContent = "Bitte zuerst die E-Mail-Adresse eingeben.");
+ try { authNotice((await api("/api/auth/resend-verification", { method: "POST", body: { email: email } })).message); } catch (error) { byId("loginError").textContent = error.message; }
+ });
+ document.querySelectorAll("[data-auth-login]").forEach(function (button) { button.addEventListener("click", function () { resetToken = ""; authNotice(""); switchAuth("login"); }); });
+ byId("loginForm").addEventListener("submit", function (event) { event.preventDefault(); submitAuth(event.currentTarget, "/api/auth/login", "loginError"); });
+ byId("registerForm").addEventListener("submit", function (event) { event.preventDefault(); submitAuth(event.currentTarget, "/api/auth/register", "registerError"); });
+ byId("forgotForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; setBusy(form, true); try { var result = await api("/api/auth/forgot-password", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); switchAuth("login"); authNotice(result.message); } catch (error) { byId("forgotError").textContent = error.message; } finally { setBusy(form, false); } });
+ byId("resetForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; var values = Object.fromEntries(new FormData(form).entries()); if (values.password !== values.password_repeat) return void (byId("resetError").textContent = "Die Passwörter stimmen nicht überein."); setBusy(form, true); try { var result = await api("/api/auth/reset-password", { method: "POST", body: { token: resetToken, password: values.password } }); resetToken = ""; form.reset(); switchAuth("login"); authNotice(result.message); } catch (error) { byId("resetError").textContent = error.message; } finally { setBusy(form, false); } });
+
+ document.querySelectorAll(".nav-item").forEach(function (item) { item.addEventListener("click", function () { showView(item.dataset.view); }); });
+ document.addEventListener("click", async function (event) {
+ var goto = event.target.closest("[data-goto]");
+ if (goto) {
+ var gotoDialog = goto.closest("dialog");
+ if (gotoDialog) gotoDialog.close();
+ if (goto.dataset.goto === "configurator" && goto.closest("#newLicenseDialog")) {
+ byId("systemForm").reset();
+ delete byId("systemForm").dataset.plantId;
+ state.deviceDrafts = {};
+ state.pvSurfaceDrafts = [];
+ renderManagerFields({});
+ Object.keys(deviceLabels).forEach(function (key) { renderDeviceEditor(key, 0, []); });
+ renderPvSurfaceEditor(0, []);
+ updateEnergySourceVisibility(byId("systemForm"), "grid");
+ updateEnergySourceVisibility(byId("systemForm"), "pv");
+ updatePvTopologyVisibility(byId("systemForm"));
+ showWizardStep(0);
+ updateRecommendation();
+ }
+ showView(goto.dataset.goto);
+ }
+ var newLicense = event.target.closest("[data-open-new-license]");
+ if (newLicense) byId("newLicenseDialog").showModal();
+ var open = event.target.closest("[data-open-plant]");
+ if (open) {
+ var sourceDialog = open.closest("dialog");
+ if (sourceDialog) sourceDialog.close();
+ byId("plantDialog").showModal();
+ }
+ var openBilling = event.target.closest("[data-open-billing]");
+ if (openBilling) {
+ var billingSourceDialog = openBilling.closest("dialog");
+ if (billingSourceDialog) billingSourceDialog.close();
+ var billingForm = byId("billingForm");
+ billingForm.reset();
+ delete billingForm.dataset.plantId;
+ byId("billingIdentityFields").classList.remove("hidden");
+ byId("billingDialog").showModal();
+ }
+ var billingOrder = event.target.closest("[data-billing-order]");
+ if (billingOrder) {
+ var existingBillingForm = byId("billingForm");
+ existingBillingForm.reset();
+ existingBillingForm.dataset.plantId = billingOrder.dataset.billingOrder;
+ var existingBillingPlant = state.plants.find(function (plant) { return plant.id === billingOrder.dataset.billingOrder; });
+ existingBillingForm.querySelector("[name='name']").value = existingBillingPlant?.name || "Abrechnung";
+ existingBillingForm.querySelector("[name='location']").value = existingBillingPlant?.location || "Schweiz";
+ byId("billingIdentityFields").classList.add("hidden");
+ byId("billingDialog").showModal();
+ }
+ var order = event.target.closest("[data-order]"); if (order) { byId("orderPlant").value = order.dataset.order; showView("licenses"); prepareOrderEditor(); }
+ var configure = event.target.closest("[data-configure-current]");
+ if (configure) configurePlant(selectedPlant("orderPlant"));
+ var replacementButton = event.target.closest("[data-replace-code]");
+ if (replacementButton) {
+ openReplacementDialog(
+ replacementButton.dataset.replaceCode,
+ replacementButton.dataset.replacePlant,
+ replacementButton.dataset.replaceConnected === "true"
+ );
+ }
+ var deleteCodeButton = event.target.closest("[data-delete-code]");
+ if (deleteCodeButton) openCodeDeletionDialog(deleteCodeButton.dataset.deleteCode, deleteCodeButton.dataset.deleteCodePlant);
+ var deletePlantButton = event.target.closest("[data-delete-plant]");
+ if (deletePlantButton) {
+ openPlantDeletionDialog(
+ deletePlantButton.dataset.deletePlant,
+ deletePlantButton.dataset.deletePlantName,
+ deletePlantButton.dataset.deletePlantPaid === "true"
+ );
+ }
+ var codeButton = event.target.closest("[data-code]");
+ if (codeButton) {
+ if (codeButton.dataset.rotate === "true") {
+ var sourceOrder = state.orders.find(function (order) { return order.id === codeButton.dataset.code; });
+ openReplacementDialog(codeButton.dataset.code, sourceOrder?.plant_name || "dieses System", Boolean(sourceOrder?.activated_installation_id));
+ return;
+ }
+ codeButton.disabled = true;
+ try {
+ var result = await api("/api/orders/" + codeButton.dataset.code + "/activation-code", { method: "POST", body: { rotate: false } });
+ toast(result.message);
+ await loadOrders();
+ } catch (error) { toast(error.message); } finally { codeButton.disabled = false; }
+ }
+ });
+ document.querySelectorAll("[data-close-dialog]").forEach(function (button) { button.addEventListener("click", function () { button.closest("dialog").close(); }); });
+ document.querySelectorAll(".module-check, .qty").forEach(function (input) { input.addEventListener("change", updateOrder); });
+ byId("peakUpgrade").addEventListener("change", updateOrder);
+ byId("orderPlant").addEventListener("change", function () { prepareOrderEditor(); });
+ byId("forecastPlant").addEventListener("change", updateForecast);
+ byId("forecastHistoryDays").addEventListener("change", function (event) {
+ state.forecastHistoryDays = Number(event.target.value) || 1;
+ state.forecastRangeStart = null;
+ state.forecastRangeEnd = null;
+ state.forecastVisibleSeries = null;
+ updateForecast();
+ });
+ byId("forecastSeriesControls").addEventListener("change", function (event) {
+ var control = event.target.closest("[data-forecast-series]");
+ if (!control || !state.forecastResult) return;
+ if (!state.forecastVisibleSeries) state.forecastVisibleSeries = new Set();
+ if (control.checked) state.forecastVisibleSeries.add(control.dataset.forecastSeries);
+ else state.forecastVisibleSeries.delete(control.dataset.forecastSeries);
+ renderForecastChart(state.forecastResult.series || []);
+ });
+ ["forecastRangeStart", "forecastRangeEnd"].forEach(function (id) {
+ byId(id).addEventListener("change", function () {
+ if (!state.forecastResult) return;
+ var start = Date.parse(byId("forecastRangeStart").value);
+ var end = Date.parse(byId("forecastRangeEnd").value);
+ if (Number.isFinite(start)) state.forecastRangeStart = start;
+ if (Number.isFinite(end)) state.forecastRangeEnd = end;
+ renderForecastChart(state.forecastResult.series || []);
+ });
+ });
+ byId("forecastRangeReset").addEventListener("click", function () {
+ if (!state.forecastResult) return;
+ state.forecastRangeStart = null;
+ state.forecastRangeEnd = null;
+ renderForecastChart(state.forecastResult.series || []);
+ });
+ byId("faultPlant").addEventListener("change", updateFaults);
+ byId("faultRecipientForm").addEventListener("submit", async function (event) {
+ event.preventDefault();
+ var form = event.currentTarget;
+ var plant = selectedPlant("faultPlant");
+ if (!plant) return;
+ byId("faultRecipientError").textContent = "";
+ setBusy(form, true);
+ try {
+ var result = await api("/api/plants/" + plant.id + "/fault-notification-recipients", {
+ method: "POST",
+ body: { email: byId("faultRecipientEmail").value }
+ });
+ form.reset();
+ toast(result.message);
+ await updateFaults();
+ } catch (error) {
+ byId("faultRecipientError").textContent = error.message;
+ } finally {
+ setBusy(form, false);
+ }
+ });
+ byId("faultRecipientList").addEventListener("click", async function (event) {
+ var button = event.target.closest("[data-remove-fault-recipient]");
+ var plant = selectedPlant("faultPlant");
+ if (!button || !plant) return;
+ button.disabled = true;
+ try {
+ var result = await api("/api/plants/" + plant.id + "/fault-notification-recipients", {
+ method: "DELETE",
+ body: { email: button.dataset.removeFaultRecipient }
+ });
+ toast(result.message);
+ await updateFaults();
+ } catch (error) {
+ byId("faultRecipientError").textContent = error.message;
+ button.disabled = false;
+ }
+ });
+ ["import", "export"].forEach(function (direction) {
+ var form = byId("forecastConfigForm");
+ var prefix = direction === "import" ? "bezug" : "einspeisung";
+ form.elements["tarif_" + prefix + "_modus"].addEventListener("change", function () {
+ renderTariffSelector(form, direction, this.value, "", form.elements["tarif_" + prefix + "_fest"].value);
+ });
+ form.elements["tarif_" + prefix].addEventListener("change", function () {
+ refreshTariffSelector(form, direction);
+ });
+ });
+ byId("forecastAddRoof").addEventListener("click", function () {
+ var roofs = Array.from(byId("forecastRoofs").querySelectorAll(".forecast-roof-row")).map(function (row) {
+ return {
+ kwp: Number(row.querySelector("[data-roof-field='kwp']").value),
+ neigung: Number(row.querySelector("[data-roof-field='neigung']").value),
+ azimut: Number(row.querySelector("[data-roof-field='azimut']").value)
+ };
+ });
+ roofs.push({ kwp: 10, neigung: 30, azimut: 180 });
+ renderForecastRoofs(roofs, false);
+ });
+ byId("forecastRoofs").addEventListener("click", function (event) {
+ var remove = event.target.closest("[data-remove-roof]");
+ if (remove) remove.closest(".forecast-roof-row").remove();
+ });
+ byId("forecastConfigForm").addEventListener("submit", async function (event) {
+ event.preventDefault();
+ var form = event.currentTarget;
+ var plantId = form.dataset.plantId;
+ byId("forecastConfigError").textContent = "";
+ var payload;
+ try {
+ payload = forecastConfigurationPayload(form);
+ } catch (error) {
+ byId("forecastConfigError").textContent = error.message;
+ return;
+ }
+ setBusy(form, true);
+ try {
+ var result = await api("/api/plants/" + plantId + "/prognosis/configuration", {
+ method: "PUT",
+ body: payload
+ });
+ var plant = state.plants.find(function (entry) { return entry.id === plantId; });
+ if (plant) fillForecastConfiguration(plant, result);
+ toast(result.message);
+ await updateForecast();
+ } catch (error) {
+ byId("forecastConfigError").textContent = error.message;
+ } finally {
+ setBusy(form, false);
+ }
+ });
+ byId("accountButton").addEventListener("click", function () { showView("account"); });
+ byId("systemForm").addEventListener("click", function (event) {
+ var instance = event.target.closest("[data-device-instance]");
+ var editor = event.target.closest("[data-device-editor]");
+ if (!instance || !editor) return;
+ var key = editor.dataset.deviceEditor;
+ if (event.target.closest("[data-add-power-level]")) {
+ var items = collectDeviceItems(key);
+ var position = Number(instance.dataset.deviceInstance);
+ items[position].powerLevels = items[position].powerLevels || [];
+ items[position].powerLevels.push({
+ Stufe: items[position].powerLevels.length + 1,
+ Leistung: 0,
+ integration: { profile: "manual", sourceVariableId: 0 }
+ });
+ renderDeviceEditor(key, items.length, items);
+ }
+ if (event.target.closest("[data-remove-power-level]")) {
+ var levelIndex = Array.from(instance.querySelectorAll("[data-power-level]")).indexOf(event.target.closest("[data-power-level]"));
+ var levelItems = collectDeviceItems(key);
+ levelItems[Number(instance.dataset.deviceInstance)].powerLevels.splice(levelIndex, 1);
+ renderDeviceEditor(key, levelItems.length, levelItems);
+ }
+ if (event.target.closest("[data-add-schedule-row]")) {
+ var scheduleItems = collectDeviceItems(key);
+ scheduleItems[Number(instance.dataset.deviceInstance)].schedule.push({ Uhrzeit: "00:00", Solltemperatur: 50 });
+ renderDeviceEditor(key, scheduleItems.length, scheduleItems);
+ }
+ if (event.target.closest("[data-remove-schedule-row]")) {
+ var scheduleIndex = Array.from(instance.querySelectorAll("[data-schedule-row]")).indexOf(event.target.closest("[data-schedule-row]"));
+ var reducedItems = collectDeviceItems(key);
+ reducedItems[Number(instance.dataset.deviceInstance)].schedule.splice(scheduleIndex, 1);
+ renderDeviceEditor(key, reducedItems.length, reducedItems);
+ }
+ updateRecommendation();
+ });
+ byId("systemForm").addEventListener("input", function (event) {
+ var key = event.target.dataset.deviceQuantity;
+ if (key) renderDeviceEditor(key, Math.max(0, Math.min(50, Number(event.target.value || 0))));
+ if (event.target.dataset.topologyQuantity === "pvSurfaces") {
+ renderPvSurfaceEditor(Math.max(0, Math.min(50, Number(event.target.value || 0))));
+ }
+ updateRecommendation();
+ });
+ byId("systemForm").addEventListener("change", function (event) {
+ if (event.target.matches("[data-profile-select='energy']")) {
+ var prefix = event.target.name === "gridProfile" ? "grid" : "pv";
+ var port = event.currentTarget.elements[prefix + "Port"];
+ var selected = selectedProfile("energy", event.target.value);
+ if (port && (!Number(port.value) || Number(port.value) === 80 || Number(port.value) === 502 || Number(port.value) === 1502)) {
+ port.value = selected.defaultPort || 0;
+ }
+ updateEnergySourceVisibility(event.currentTarget, prefix);
+ }
+ if (event.target.name === "pvEnabled") {
+ if (event.target.checked && Number(event.currentTarget.elements.pvSurfaceQuantity.value || 0) === 0) {
+ event.currentTarget.elements.pvSurfaceQuantity.value = 1;
+ renderPvSurfaceEditor(1);
+ }
+ updatePvTopologyVisibility(event.currentTarget);
+ }
+ if (event.target.matches("[data-ev-variant]")) {
+ var evEditor = event.target.closest("[data-device-editor]");
+ var evItems = collectDeviceItems("ev_charger");
+ renderDeviceEditor("ev_charger", evItems.length, evItems);
+ }
+ if (event.target.matches("[data-integration-field='profile']")) {
+ var editor = event.target.closest("[data-device-editor]");
+ var instance = event.target.closest("[data-device-instance]");
+ var key = editor?.dataset.deviceEditor || "";
+ var scope = event.target.closest("[data-main-integration], [data-sensor-integration]");
+ var kind = event.target.closest("[data-sensor-integration]") ? "sensor" : integrationKind(key);
+ var selectedDevice = selectedProfile(kind, event.target.value);
+ var devicePort = scope?.querySelector("[data-integration-field='port']");
+ var channel = scope?.querySelector("[data-integration-field='channel']");
+ if (devicePort) devicePort.value = selectedDevice.defaultPort || 0;
+ if (channel) {
+ channel.max = Math.max(0, Number(selectedDevice.outputs || 1) - 1);
+ channel.value = Math.min(Number(channel.value || 0), Number(channel.max));
+ }
+ var quantity = event.currentTarget.querySelector("[data-device-quantity='" + key + "']");
+ renderDeviceEditor(key, Number(quantity?.value || 0));
+ }
+ updateRecommendation();
+ });
+ byId("wizardBack").addEventListener("click", function () { showWizardStep(state.wizardStep - 1); });
+ byId("wizardNext").addEventListener("click", function () { if (currentWizardStepValid()) showWizardStep(state.wizardStep + 1); });
+ byId("confirmReplaceCode").addEventListener("click", async function () {
+ if (!state.replacementOrderId) return;
+ var button = byId("confirmReplaceCode");
+ var originalLabel = button.textContent;
+ byId("replaceCodeError").textContent = "";
+ button.disabled = true;
+ button.textContent = "Ersatzcode wird versendet …";
+ try {
+ var result = await api("/api/orders/" + state.replacementOrderId + "/activation-code", { method: "POST", body: { rotate: true } });
+ byId("replaceCodeDialog").close();
+ state.replacementOrderId = null;
+ toast(result.message);
+ await loadOrders();
+ } catch (error) {
+ byId("replaceCodeError").textContent = error.message;
+ } finally {
+ button.disabled = false;
+ button.textContent = originalLabel;
+ }
+ });
+
+ byId("confirmDeleteCode").addEventListener("click", async function () {
+ if (!state.codeDeletionOrderId) return;
+ var button = byId("confirmDeleteCode");
+ var originalLabel = button.textContent;
+ byId("deleteCodeError").textContent = "";
+ button.disabled = true;
+ button.textContent = "Code wird gelöscht …";
+ try {
+ var result = await api("/api/orders/" + state.codeDeletionOrderId + "/activation-code", { method: "DELETE", body: { confirm: true } });
+ byId("deleteCodeDialog").close();
+ state.codeDeletionOrderId = null;
+ toast(result.message);
+ await loadOrders();
+ } catch (error) {
+ byId("deleteCodeError").textContent = error.message;
+ } finally {
+ button.disabled = false;
+ button.textContent = originalLabel;
+ }
+ });
+
+ async function deleteSelectedPlant(deletePaidLicenses, errorId, buttonId) {
+ if (!state.plantDeletion) return;
+ var button = byId(buttonId);
+ var originalLabel = button.textContent;
+ byId(errorId).textContent = "";
+ button.disabled = true;
+ button.textContent = "Anlage wird gelöscht …";
+ try {
+ var result = await api("/api/plants/" + state.plantDeletion.id, {
+ method: "DELETE",
+ body: { plantName: state.plantDeletion.name, deletePaidLicenses: deletePaidLicenses }
+ });
+ byId("deletePlantDialog").close();
+ byId("deletePaidDialog").close();
+ state.plantDeletion = null;
+ state.configuredPlantId = null;
+ await Promise.all([loadPlants(), loadOrders()]);
+ showView("overview");
+ toast(result.message);
+ } catch (error) {
+ byId(errorId).textContent = error.message;
+ } finally {
+ button.disabled = false;
+ button.textContent = originalLabel;
+ }
+ }
+
+ byId("confirmDeletePlant").addEventListener("click", async function () {
+ if (!state.plantDeletion) return;
+ if (state.plantDeletion.hasPaidLicense) {
+ byId("deletePlantDialog").close();
+ byId("deletePaidDialog").showModal();
+ return;
+ }
+ await deleteSelectedPlant(false, "deletePlantError", "confirmDeletePlant");
+ });
+ byId("confirmDeletePaid").addEventListener("click", function () {
+ deleteSelectedPlant(true, "deletePaidError", "confirmDeletePaid");
+ });
+
+ async function changeWorkspace(value) {
+ try { state.session = await api("/api/workspace", { method: "POST", body: { organizationId: value || null } }); renderSession(); await Promise.all([loadPlants(), loadOrders(), loadInvitations()]); showView("overview"); toast("Organisation gewechselt."); }
+ catch (error) { toast(error.message); renderSession(); }
+ }
+ byId("workspaceSelect").addEventListener("change", function (event) { changeWorkspace(event.target.value); });
+ byId("accountWorkspaceSelect").addEventListener("change", function (event) { changeWorkspace(event.target.value); });
+
+ byId("plantForm").addEventListener("submit", async function (event) {
+ event.preventDefault(); var form = event.currentTarget; byId("plantError").textContent = ""; setBusy(form, true);
+ try { var result = await api("/api/plants", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); form.querySelector("[value='manager_standard']").checked = true; byId("plantDialog").close(); state.configuredPlantId = result.plant.id; await loadPlants(); showView("licenses"); toast("Lizenzpaket wurde angelegt."); }
+ catch (error) { byId("plantError").textContent = error.message; } finally { setBusy(form, false); }
+ });
+
+ byId("billingForm").addEventListener("submit", async function (event) {
+ event.preventDefault();
+ var form = event.currentTarget;
+ var values = Object.fromEntries(new FormData(form).entries());
+ var plantId = form.dataset.plantId || "";
+ byId("billingError").textContent = "";
+ setBusy(form, true);
+ try {
+ if (!plantId) {
+ var created = await api("/api/plants", {
+ method: "POST",
+ body: { name: values.name, type: "Abrechnung", location: values.location, manager: "billing_manager" }
+ });
+ plantId = created.plant.id;
+ form.dataset.plantId = plantId;
+ }
+ var checkout = await api("/api/checkout", {
+ method: "POST",
+ body: {
+ plantId: plantId,
+ modules: {
+ cost_meter_power: Number(values.powerMeters || 0),
+ cost_meter_aux: Number(values.auxMeters || 0)
+ }
+ }
+ });
+ window.location.assign(checkout.checkoutUrl);
+ } catch (error) {
+ byId("billingError").textContent = error.message;
+ setBusy(form, false);
+ }
+ });
+
+ byId("systemForm").addEventListener("submit", async function (event) {
+ event.preventDefault(); var form = event.currentTarget; byId("systemError").textContent = ""; setBusy(form, true);
+ try {
+ var result = await api("/api/configurations", { method: "POST", body: systemPayload(form) });
+ state.configuredPlantId = result.plant.id;
+ byId("packageResult").classList.toggle("hidden", !result.packageReady);
+ if (result.packageReady) byId("packageDownload").href = result.packageUrl;
+ else byId("packageDownload").removeAttribute("href");
+ await Promise.all([loadPlants(), loadOrders()]);
+ byId("orderPlant").value = result.plant.id;
+ prepareOrderEditor(result.configurationId);
+ showView("licenses");
+ if (result.purchaseProposal?.required) {
+ toast(result.costReportPlant
+ ? "Konfiguration gespeichert. Manager-Lizenzen und der separate Verbrauchskosten-Lizenzsatz sind in der Übersicht vorbereitet."
+ : "Konfiguration gespeichert. Fehlende Lizenzen und die kostenpflichtige Ersteinrichtung durch den Generator sind zur Bestellung vorbereitet.");
+ } else {
+ toast(result.delivered
+ ? "Systemkonfiguration gespeichert und lizenziertes Einrichtungspaket per E-Mail versendet."
+ : "Systemkonfiguration gespeichert. Das lizenzierte Einrichtungspaket konnte nicht per E-Mail versendet werden.");
+ }
+ } catch (error) { byId("systemError").textContent = error.message; } finally { setBusy(form, false); }
+ });
+ byId("orderRecommendation").addEventListener("click", function () {
+ showView("licenses");
+ if (state.configuredPlantId) byId("orderPlant").value = state.configuredPlantId;
+ var plant = selectedPlant("orderPlant");
+ prepareOrderEditor(plant?.configuration_id || null);
+ });
+
+ byId("organizationForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; setBusy(form, true); try { state.session = await api("/api/organizations", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); renderSession(); await Promise.all([loadPlants(), loadOrders(), loadInvitations()]); toast("Organisation wurde erstellt."); } catch (error) { byId("organizationError").textContent = error.message; } finally { setBusy(form, false); } });
+ byId("invitationForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; setBusy(form, true); try { var result = await api("/api/invitations", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); await loadInvitations(); toast(result.message); } catch (error) { byId("invitationError").textContent = error.message; } finally { setBusy(form, false); } });
+ byId("passwordForm").addEventListener("submit", async function (event) {
+ event.preventDefault();
+ var form = event.currentTarget;
+ var values = Object.fromEntries(new FormData(form).entries());
+ byId("passwordError").textContent = "";
+ if (values.newPassword !== values.newPasswordRepeat) return void (byId("passwordError").textContent = "Die neuen Passwörter stimmen nicht überein.");
+ setBusy(form, true);
+ try {
+ var result = await api("/api/account/password", { method: "POST", body: { currentPassword: values.currentPassword, newPassword: values.newPassword } });
+ form.reset();
+ toast(result.message);
+ } catch (error) { byId("passwordError").textContent = error.message; } finally { setBusy(form, false); }
+ });
+
+ byId("checkoutButton").addEventListener("click", async function () {
+ var plant = selectedPlant("orderPlant"); if (!plant) return;
+ var button = byId("checkoutButton"); var modules = {};
+ state.selectedOrder.forEach(function (item) {
+ if (item.licenseQuantity > 0) modules[item.key] = item.licenseQuantity;
+ });
+ byId("checkoutError").textContent = ""; button.disabled = true; button.textContent = button.dataset.free === "true" ? "Wird freigeschaltet …" : "Stripe Checkout wird geöffnet …";
+ try { var result = await api("/api/checkout", { method: "POST", body: { plantId: plant.id, configurationId: state.checkoutConfigurationId, managerVariant: selectedManagerVariant(plant), modules: modules } }); window.location.assign(result.checkoutUrl); }
+ catch (error) { byId("checkoutError").textContent = error.message; toast(error.message); button.disabled = false; button.textContent = button.dataset.free === "true" ? "Kostenlos freischalten" : "Mit Stripe bezahlen"; }
+ });
+
+ byId("logoutButton").addEventListener("click", async function () { try { await api("/api/auth/logout", { method: "POST" }); } catch (_) { /* local logout */ } state.session = null; state.plants = []; state.orders = []; state.faultRequest += 1; history.replaceState(null, "", location.pathname); showAuth(); });
+
+ setInterval(function () {
+ if (state.session && byId("view-faults").classList.contains("active")) updateFaults();
+ }, 60000);
+
+ async function handleInvitationLink(token) {
+ try { var preview = await api("/api/invitations/preview", { method: "POST", body: { token: token } }); sessionStorage.setItem("enelix_invitation_token", token); byId("loginForm").querySelector("input[name='email']").value = preview.email; byId("registerForm").querySelector("input[name='email']").value = preview.email; authNotice("Einladung zu " + preview.organization + " für " + preview.email + "."); }
+ catch (error) { authNotice(error.message, true); }
+ }
+
+ async function bootstrap() {
+ showAuth();
+ var catalogs = await Promise.allSettled([api("/api/catalog"), api("/api/wizard-catalog")]);
+ if (catalogs[0].status === "fulfilled") applyCatalog(catalogs[0].value);
+ if (catalogs[1].status === "fulfilled") applyWizardCatalog(catalogs[1].value);
+ else byId("systemError").textContent = "Der Systemkonfigurator konnte nicht geladen werden.";
+ updateRecommendation();
+ showWizardStep(0);
+ if (location.hash.startsWith("#verify=")) { var verificationToken = decodeURIComponent(location.hash.slice(8)); history.replaceState(null, "", location.pathname); switchAuth("login"); try { authNotice((await api("/api/auth/verify-email", { method: "POST", body: { token: verificationToken } })).message); } catch (error) { authNotice(error.message, true); } return; }
+ if (location.hash.startsWith("#reset=")) { resetToken = decodeURIComponent(location.hash.slice(7)); history.replaceState(null, "", location.pathname); specialAuth("resetForm"); return; }
+ var invitationToken = location.hash.startsWith("#invite=") ? decodeURIComponent(location.hash.slice(8)) : "";
+ if (invitationToken) history.replaceState(null, "", location.pathname);
+ try { var session = await api("/api/session"); if (invitationToken) await handleInvitationLink(invitationToken); if (session.authenticated) return showPortal(session); switchAuth("login"); }
+ catch (_) { switchAuth("login"); byId("loginError").textContent = "Das Portal ist momentan nicht erreichbar."; }
+ }
+
+ bootstrap();
+}());
diff --git a/services/license/changes/20261006-forecast-integration/public/forecast-chart.js b/services/license/changes/20261006-forecast-integration/public/forecast-chart.js
new file mode 100644
index 0000000..73efc87
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/public/forecast-chart.js
@@ -0,0 +1,182 @@
+const SVG_NS = 'http://www.w3.org/2000/svg';
+const COLORS = ['#4e8181', '#757b12', '#ac4f68', '#386b91', '#737c7e', '#9c6335'];
+let sequence = 0;
+
+export const finiteValue = value => typeof value === 'number' && Number.isFinite(value);
+export const formatNumber = (value, digits = 2) => finiteValue(value)
+ ? value.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'nicht vorhanden';
+export function forecastSeries(plan) {
+ const fullHorizon = Array.isArray(plan.forecastPoints) && plan.forecastPoints.length > 0;
+ return { fullHorizon, points: fullHorizon ? plan.forecastPoints : Array.isArray(plan.points) ? plan.points : [] };
+}
+export function formatTime(value) {
+ const stamp = Date.parse(value);
+ return Number.isFinite(stamp) ? new Date(stamp).toLocaleString('de-CH', {
+ timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZoneName: 'short'
+ }) : 'nicht vorhanden';
+}
+
+export function chartDomain(points, series) {
+ let low = 0, high = 0, start = Infinity, end = -Infinity, valid = false;
+ for (const point of points) {
+ const at = Date.parse(point.time), until = Date.parse(point.validUntil || point.time);
+ if (!Number.isFinite(at) || !Number.isFinite(until) || until < at) continue;
+ start = Math.min(start, at); end = Math.max(end, until);
+ for (const item of series) {
+ const value = item.value(point);
+ if (!finiteValue(value)) continue;
+ low = Math.min(low, value); high = Math.max(high, value); valid = true;
+ }
+ }
+ if (!Number.isFinite(start)) return null;
+ return { low, high: high === low ? low + 1 : high, start, end: Math.max(start + 1, end), valid };
+}
+
+// Keep missing values and non-contiguous intervals as visible gaps, never interpolated data.
+export function seriesSegments(points, series, x, y) {
+ const segments = [];
+ let section = [], previousEnd;
+ const flush = () => { if (section.length) segments.push(section); section = []; };
+ for (const point of points) {
+ const value = series.value(point), at = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
+ if (!finiteValue(value) || !Number.isFinite(at) || !Number.isFinite(end) || end < at) {
+ flush(); previousEnd = undefined; continue;
+ }
+ if (previousEnd !== undefined && (at > previousEnd || at < previousEnd)) flush();
+ if (series.position === 'end') section.push([x(end), y(value)]);
+ else {
+ section.push([x(at), y(value)]);
+ if (end > at) section.push([x(end), y(value)]);
+ }
+ previousEnd = end;
+ }
+ flush(); return segments;
+}
+
+export function pointIndexAt(points, stamp) {
+ let selected = -1, distance = Infinity;
+ points.forEach((point, index) => {
+ const start = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return;
+ const next = stamp < start ? start - stamp : stamp >= end ? stamp - end : 0;
+ if (next <= distance) { distance = next; selected = index; }
+ });
+ return selected;
+}
+
+const element = (tag, text, className) => {
+ const node = document.createElement(tag);
+ if (text !== undefined) node.textContent = text;
+ if (className) node.className = className;
+ return node;
+};
+const svgElement = (tag, attributes, text) => {
+ const node = document.createElementNS(SVG_NS, tag);
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
+ if (text !== undefined) node.textContent = text;
+ return node;
+};
+
+export function renderForecastChart(container, { title, points = [], series = [], unit = '', caption = '' }) {
+ const id = 'forecast-chart-' + ++sequence;
+ const root = element('section', undefined, 'forecast-chart');
+ const heading = element('h3', title); heading.id = id;
+ root.setAttribute('aria-labelledby', id); root.append(heading); container.append(root);
+ const records = points.filter(point => Number.isFinite(Date.parse(point.time)) && Number.isFinite(Date.parse(point.validUntil || point.time)) && Date.parse(point.validUntil || point.time) >= Date.parse(point.time));
+ if (!records.length || !chartDomain(records, series)?.valid) {
+ root.append(element('p', 'Keine Prognosewerte vorhanden.', 'forecast-chart-empty'));
+ if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
+ return { element: root, destroy() { root.remove(); } };
+ }
+ const sources = series.map((source, index) => ({ ...source, color: source.color || COLORS[index % COLORS.length], visible: source.visible !== false }));
+ const legend = element('fieldset', undefined, 'forecast-chart-legend');
+ legend.append(element('legend', 'Datenreihen', 'forecast-chart-sr'));
+ const plot = element('div', undefined, 'forecast-chart-plot');
+ const svg = svgElement('svg', { role: 'img', 'aria-label': `${title}, ${unit}. Prognosewerte pro Zeitintervall.` });
+ plot.append(svg);
+ const inspector = element('div', undefined, 'forecast-chart-inspector');
+ const timestamp = element('p', undefined, 'forecast-chart-time');
+ const readout = element('dl'); inspector.append(timestamp, readout);
+ const sliderLabel = element('label', 'Prognosezeitpunkt', 'forecast-chart-slider');
+ const slider = element('input'); slider.type = 'range'; slider.min = '0'; slider.max = String(records.length - 1); slider.step = '1'; slider.value = '0';
+ slider.disabled = records.length === 1; sliderLabel.append(slider);
+ root.append(legend, plot, sliderLabel, inspector);
+ if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
+ let index = 0, domain, width = 900, marker;
+ const left = 68, right = 16, top = 18, bottom = 238, height = 284;
+ const x = value => left + (width - left - right) * (value - domain.start) / (domain.end - domain.start);
+ const y = value => bottom - (bottom - top) * (value - domain.low) / (domain.high - domain.low);
+
+ function inspect(next) {
+ index = Math.max(0, Math.min(records.length - 1, next)); slider.value = String(index);
+ const point = records[index];
+ timestamp.textContent = `${formatTime(point.time)} bis ${formatTime(point.validUntil || point.time)}`;
+ readout.replaceChildren();
+ const descriptions = [];
+ for (const source of sources.filter(item => item.visible)) {
+ const row = element('div'), name = element('dt', source.label), value = source.value(point);
+ name.style.setProperty('--series-color', source.color);
+ const text = finiteValue(value) ? `${formatNumber(value, source.digits ?? 2)} ${unit}` : 'nicht vorhanden';
+ row.append(name, element('dd', text)); readout.append(row); descriptions.push(`${source.label}: ${text}`);
+ }
+ if (!descriptions.length) readout.append(element('div', 'Keine Datenreihe ausgewaehlt.'));
+ slider.setAttribute('aria-valuetext', `${timestamp.textContent}. ${descriptions.join('; ')}`);
+ if (marker) {
+ const active = sources.filter(item => item.visible);
+ const endPosition = active.length && active.every(item => item.position === 'end');
+ const at = x(Date.parse(endPosition ? point.validUntil || point.time : point.time)); marker.setAttribute('x1', at); marker.setAttribute('x2', at);
+ }
+ }
+
+ function draw() {
+ width = Math.max(300, Math.round(plot.getBoundingClientRect().width || 900));
+ const active = sources.filter(source => source.visible);
+ domain = chartDomain(records, active.length ? active : sources);
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.replaceChildren();
+ for (let tick = 0; tick <= 4; tick++) {
+ const value = domain.low + (domain.high - domain.low) * tick / 4, at = y(value);
+ svg.append(svgElement('line', { x1: left, x2: width - right, y1: at, y2: at, class: 'forecast-chart-grid' }));
+ svg.append(svgElement('text', { x: left - 8, y: at + 4, 'text-anchor': 'end', class: 'forecast-chart-axis' }, formatNumber(value, Math.max(1, ...active.map(source => source.digits ?? 1)))));
+ }
+ svg.append(svgElement('text', { x: left, y: 12, class: 'forecast-chart-axis' }, unit));
+ const tickCount = width < 550 ? 2 : 4;
+ for (let tick = 0; tick <= tickCount; tick++) {
+ const stamp = domain.start + (domain.end - domain.start) * tick / tickCount;
+ const anchor = tick === 0 ? 'start' : tick === tickCount ? 'end' : 'middle';
+ const at = new Date(stamp);
+ svg.append(svgElement('text', { x: x(stamp), y: bottom + 20, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleTimeString('de-CH', { timeZone: 'Europe/Zurich', hour: '2-digit', minute: '2-digit' })));
+ svg.append(svgElement('text', { x: x(stamp), y: bottom + 36, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleDateString('de-CH', { timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit' })));
+ }
+ for (const source of active) {
+ for (const section of seriesSegments(records, source, x, y)) {
+ const attributes = { fill: 'none', stroke: source.color, 'stroke-width': 2, 'vector-effect': 'non-scaling-stroke', 'stroke-linecap': 'round' };
+ if (source.dash) attributes['stroke-dasharray'] = source.dash;
+ if (section.length === 1) svg.append(svgElement('circle', { cx: section[0][0], cy: section[0][1], r: 3, fill: source.color }));
+ else svg.append(svgElement('polyline', { ...attributes, points: section.map(pair => pair.join(',')).join(' ') }));
+ }
+ }
+ marker = svgElement('line', { y1: top, y2: bottom, class: 'forecast-chart-marker' }); svg.append(marker);
+ inspect(index);
+ }
+ sources.forEach(source => {
+ const label = element('label'), input = element('input'), swatch = element('span', undefined, 'forecast-chart-swatch');
+ input.type = 'checkbox'; input.checked = source.visible; swatch.style.background = source.color;
+ if (source.dash) swatch.style.background = `repeating-linear-gradient(90deg, ${source.color} 0 5px, transparent 5px 8px)`;
+ label.append(input, swatch, document.createTextNode(source.label)); legend.append(label);
+ input.addEventListener('change', () => { source.visible = input.checked; draw(); });
+ });
+ slider.addEventListener('input', () => inspect(Number(slider.value)));
+ const pointer = event => {
+ const bounds = svg.getBoundingClientRect();
+ if (!bounds.width) return;
+ const position = Math.max(left, Math.min(width - right, (event.clientX - bounds.left) * width / bounds.width));
+ const stamp = domain.start + (position - left) / (width - left - right) * (domain.end - domain.start);
+ inspect(pointIndexAt(records, stamp));
+ };
+ svg.addEventListener('pointermove', event => { if (event.pointerType !== 'touch') pointer(event); });
+ svg.addEventListener('pointerdown', pointer);
+ draw();
+ const resize = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(draw) : null;
+ resize?.observe(plot);
+ return { element: root, destroy() { resize?.disconnect(); root.remove(); } };
+}
diff --git a/services/license/changes/20261006-forecast-integration/public/index.html b/services/license/changes/20261006-forecast-integration/public/index.html
new file mode 100644
index 0000000..2e5818d
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/public/index.html
@@ -0,0 +1,382 @@
+
+
+
+
+
+
+ Enelix EMS Portal
+
+
+
+
+
+
+
+
+
+
+
+
+
+ EMS Portal
+ Energiemanagement
Systeme planen. Lizenzen verbinden. Ein Zugang für Anlagen, Prognosemanager und Lizenzierung.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Arbeitsbereich
Lizenzübersicht Gekaufte Lizenzen, Verwendung und Managerbindung auf einen Blick.
Neue Lizenz
+ 0 Lizenzpakete
0 Bezahlte Lizenzen
0 Verknüpfte Manager
+ Lizenzen und Manager Status, Verwendung und verfügbare Erweiterungen.
+
+
+
+ Kauf · CHF netto
Lizenz erweitern Vorhandene Lizenzen prüfen, zusätzliche Module ergänzen und Peak Shaving bei Bedarf upgraden.
System konfigurieren Stripe Sandbox
+ Noch keine Lizenz vorhanden Lege ein Lizenzpaket direkt an oder ermittle es mit dem Systemkonfigurator.
Neue Lizenz
+
+
+
+
+ Planung
System konfigurieren Geräte erfassen und passende Lizenzen ermitteln.
Enelix EMS
+
+
+
+
+ Betrieb
Prognose Messwerte und berechnete Verläufe des lizenzierten Prognosemanagers.
Serverplanung
+ Kein System verfügbar
+
+
+
+
+
– Aktualisiert
+
0 Datenreihen
+
72 h Zeitfenster
+
+
+
+ Prognosequalität Vergleich der Modellprognosen mit den tatsächlich gemessenen Werten.
Aufbauphase
+ Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie.
+
+
+ Modell Ziel R² MAE RMSE Vergleiche
+
+
+
+
+
+
+
Lizenzierte Variante
Manager Lizenzdauer Unbeschränkt gültig
Keine Verbindung zum Lizenzserver
+
Datenumfang
Live-Prognose
Modus Konfiguration und Anzeige
Simulation Nicht enthalten
Zugriff Lizenzgebunden
+
+
+
+
+
+ Betrieb
Störüberwachung Aktive und behobene Störungen der verbundenen Enelix Manager und Geräte.
Status wird geprüft
+ Kein Manager-System verfügbar
+
+
+
+
0 Aktive Störungen
+
0 Kritisch oder Fehler
+
– Letzter Empfang
+
+
+ Aktive Störungen Aktueller Vollsnapshot des Managers.
+ Störungsdaten werden geladen.
+ Schweregrad Quelle Meldung Seit
+
+
+ Behobene Störungen Die letzten 100 abgeschlossenen Ereignisse.
+ Noch keine behobenen Störungen.
+ Schweregrad Quelle Meldung Behoben
+
+
+ E-Mail-Benachrichtigungen Empfänger für neue und behobene Störungen dieser Anlage.
+
+ E-Mail-Adresse
+ Hinzufügen
+
+
+
+ Empfänger werden geladen.
+
+
+
+
+
+ Einstellungen
Konto und Abrechnung Zugang, Organisation, Personen und Zahlungsverlauf.
Abmelden
+
+
Benutzerkonto
Name –
E-Mail –
Arbeitsbereich – Organisation
+
+
+
+
+ Abrechnung Zahlungen, Lizenzcodes und PDF-Belege der aktuellen Organisation.
Stripe Sandbox – Prüfung
Datum System Lizenzen Gesamt inkl. MwSt. Status Lizenzcode Beleg
+
+
+
+
+
+ Neue Lizenz
Wie möchtest du starten? × Lizenz direkt kaufen Lizenzpaket anlegen und benötigte Module auswählen. Weiter System mit Manager konfigurieren Geräte erfassen und passende Lizenzen ermitteln. Konfigurieren Abrechnungslizenz lösen Abrechnungsmanager und benötigte Zähler lizenzieren. Auswählen
+
+ Direkter Kauf
Neue Lizenz vorbereiten × Bezeichnung TypGewerbe Privat Industrie
Standort Prognosemanager Ohne Peak Shaving CHF 308.15 netto Mit Peak Shaving CHF 329.05 netto
Abbrechen Weiter zur Lizenzwahl
+
+ Abrechnung
Abrechnungslizenz vorbereiten × Zählerlizenzen StromzählerCHF 5.00 netto pro Zähler Wärme- oder WasserzählerCHF 3.00 netto pro Zähler
Grundlizenz: CHF 245.15 netto . Zuzüglich MwSt.
Abbrechen Weiter zu Stripe
+
+ Sicherheitsaktion
Lizenzcode neu generieren × Achtung: Der aktuelle Lizenzcode wird ungültig. Für dieses System wird ein neuer Code erstellt und ausschließlich als PDF an die Konto-E-Mail-Adresse gesendet.
Die bisherige Installationsbindung wird gelöst. Der Ersatzcode kann anschließend einmalig mit einer neuen Symcon-Installation verbunden werden.
Abbrechen Alten Code ersetzen
+
+ Sicherheitsaktion
Lizenzcode löschen? × Sind Sie sicher? Der aktuelle Lizenzcode für dieses System wird sofort und unwiderruflich ungültig.
Die bezahlte Lizenz und eine bestehende Installationsbindung bleiben erhalten. Ein neuer Code kann später wieder per E-Mail erzeugt werden.
Abbrechen Lizenzcode löschen
+
+ Anlage löschen
Sind Sie sicher? × Die Anlage wird dauerhaft entfernt. Möchten Sie diese Anlage wirklich löschen? Konfigurationen und noch nicht bezahlte Bestellungen dieser Anlage werden ebenfalls entfernt.
Abbrechen Anlage löschen
+
+ Zweite Bestätigung
Auch Lizenz löschen? × Achtung: Für diese Anlage besteht eine bezahlte Lizenz. Sollen die Anlage und die zugehörigen Lizenzcodes, Berechtigungen, Bestellungen und Sandbox-Zahlungsdaten wirklich endgültig gelöscht werden?
Diese Aktion kann nicht rückgängig gemacht werden.
Abbrechen Anlage und Lizenz löschen
+
+
+
+
diff --git a/services/license/changes/20261006-forecast-integration/public/netplan-v4.css b/services/license/changes/20261006-forecast-integration/public/netplan-v4.css
new file mode 100644
index 0000000..fea7ff9
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/public/netplan-v4.css
@@ -0,0 +1,49 @@
+.forecast-page { margin: 0; background: #f2f5f4; color: #172124; font-family: Figtree, sans-serif; letter-spacing: 0; }
+.forecast-page main { max-width: 1100px; margin: 2rem auto; padding: 1.5rem; background: #fff; }
+.forecast-page a { color: #305b5b; }
+.forecast-planner { min-width: 0; color: #172124; font-family: Figtree, sans-serif; letter-spacing: 0; }
+.forecast-planner * { box-sizing: border-box; }
+.forecast-planner h3 { margin: 1.6rem 0 .8rem; font-size: 1.05rem; line-height: 1.35; }
+.forecast-planner p { line-height: 1.5; overflow-wrap: anywhere; }
+.forecast-planner form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin: 1.5rem 0; }
+.forecast-planner form label { display: grid; gap: .4rem; align-content: start; font-size: .85rem; font-weight: 600; }
+.forecast-planner select, .forecast-planner input:not([type=checkbox]):not([type=range]), .forecast-planner button { width: 100%; min-height: 42px; margin: 0; padding: .65rem .75rem; border: 1px solid #bacaca; border-radius: 4px; background: #fff; color: #172124; font: inherit; }
+.forecast-planner button { align-self: end; background: #4e8181; color: #fff; cursor: pointer; }
+.forecast-planner button[type=submit] { background: #dce169; color: #172124; border-color: #dce169; }
+.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 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; }
+.forecast-planner-settings { margin-top: 1.5rem; border-top: 1px solid #d8e0de; padding-top: 1rem; }
+.forecast-planner-settings > summary { padding: .5rem 0; cursor: pointer; font-weight: 600; }
+.forecast-planner .forecast-chart { min-width: 0; height: auto; padding: 1.2rem 0; border-top: 1px solid #d8e0de; font-family: Figtree, sans-serif; letter-spacing: 0; }
+.forecast-planner .forecast-chart h3 { margin: 0 0 .75rem; color: #172124; font-size: 1.05rem; line-height: 1.35; }
+.forecast-planner .forecast-chart-legend { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; min-width: 0; margin: 0 0 .75rem; padding: 0; border: 0; }
+.forecast-planner .forecast-chart-legend label { display: inline-flex; align-items: center; gap: .4rem; min-height: 32px; margin: 0; color: #172124; font-size: .8rem; font-weight: 500; cursor: pointer; }
+.forecast-planner .forecast-chart-legend input { width: 16px; height: 16px; margin: 0; accent-color: #4e8181; }
+.forecast-planner .forecast-chart-swatch { display: inline-block; width: 20px; height: 3px; flex: 0 0 20px; }
+.forecast-planner .forecast-chart-plot { width: 100%; min-width: 0; min-height: 284px; }
+.forecast-planner .forecast-chart svg { display: block; width: 100%; height: 284px; overflow: visible; }
+.forecast-planner .forecast-chart-grid { stroke: #e3e9e7; stroke-width: 1; }
+.forecast-planner .forecast-chart-axis { fill: #576467; font-size: 11px; font-family: Figtree, sans-serif; }
+.forecast-planner .forecast-chart-marker { stroke: #172124; stroke-width: 1; stroke-dasharray: 3 3; pointer-events: none; }
+.forecast-planner .forecast-chart-slider { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: .75rem; margin: .2rem 0 .75rem; color: #576467; font-size: .75rem; font-weight: 500; }
+.forecast-planner .forecast-chart-slider input[type=range] { width: 100%; height: 28px; margin: 0; padding: 0; border: 0; box-shadow: none; accent-color: #4e8181; }
+.forecast-planner .forecast-chart input:focus-visible { outline: 2px solid #305b5b; outline-offset: 3px; }
+.forecast-planner .forecast-chart-inspector { padding: .75rem 0; border-top: 1px solid #d8e0de; }
+.forecast-planner .forecast-chart-time { margin: 0 0 .5rem; color: #172124; font-size: .8rem; font-weight: 600; }
+.forecast-planner .forecast-chart-inspector dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .6rem 1.25rem; margin: 0; }
+.forecast-planner .forecast-chart-inspector dl > div { min-width: 0; }
+.forecast-planner .forecast-chart-inspector dt { padding-left: .5rem; border-left: 3px solid var(--series-color); color: #576467; font-size: .75rem; font-weight: 500; overflow-wrap: anywhere; }
+.forecast-planner .forecast-chart-inspector dd { margin: .25rem 0 0 .7rem; color: #172124; font-size: 1rem; font-variant-numeric: tabular-nums; }
+.forecast-planner .forecast-chart-caption, .forecast-planner .forecast-chart-empty { margin: .5rem 0 0; color: #576467; font-size: .8rem; line-height: 1.5; }
+.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 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)); }
+}
diff --git a/services/license/changes/20261006-forecast-integration/public/netplan-v4.html b/services/license/changes/20261006-forecast-integration/public/netplan-v4.html
new file mode 100644
index 0000000..fecb816
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/public/netplan-v4.html
@@ -0,0 +1 @@
+ENELIX Prognose und Netzfahrplan Zurueck zum Portal Prognose und Netzfahrplan Anlage
diff --git a/services/license/changes/20261006-forecast-integration/public/netplan-v4.js b/services/license/changes/20261006-forecast-integration/public/netplan-v4.js
new file mode 100644
index 0000000..f7665db
--- /dev/null
+++ b/services/license/changes/20261006-forecast-integration/public/netplan-v4.js
@@ -0,0 +1,138 @@
+import { forecastSeries, renderForecastChart } from './forecast-chart.js';
+
+export function createPlanner({ message, panel, plants, session }) {
+let generation = 0;
+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 num = (v, digits = 2) => typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'unbekannt';
+
+async function request(url, method = 'GET', body) {
+ const headers = {};
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ if (method !== 'GET') headers['X-CSRF-Token'] = session.csrfToken;
+ const response = await fetch(url, { method, headers, credentials: 'same-origin', body: body === undefined ? undefined : JSON.stringify(body) });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || data.detail || 'Anfrage fehlgeschlagen');
+ return data;
+}
+function select(label, choices, selected) {
+ const wrap = element('label', label + ' '), field = element('select');
+ 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');
+ 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);
+}
+function clearCharts() { 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 }));
+}
+async function render() {
+ const ticket = ++generation, id = plants.value; if (!id) return;
+ const base = `/api/plants/${encodeURIComponent(id)}/prognosis/planner-v4`;
+ message.textContent = 'Lade Planungsstand ...';
+ 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.');
+ 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 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 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);
+ let revision = state.settings.revision;
+ form.addEventListener('submit', async event => {
+ event.preventDefault(); save.disabled = true;
+ try {
+ const r = await request(base + '/settings', 'PUT', { expectedRevision: revision, changes: { family: family.field.value, forecastSource: dataSource.field.value, measurementDataset: dataset.field.value, measurementPolicy: measurement.field.value, peakOutlookPolicy: outlook.field.value, peakOutlookReliance: Number(reliance.value) / 100, trainingCadence: cadence.field.value } });
+ if (ticket !== generation) return;
+ revision = r.revision; message.textContent = `Revision ${revision} gespeichert. Neuberechnung angefordert, noch nicht abgeschlossen.`;
+ } 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']] );
+ 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) + ' %']));
+ }
+ 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.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));
+ 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']]);
+ 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]]);
+ 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('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;
+ message.textContent = '';
+ } catch (error) { if (ticket === generation) { clearCharts(); panel.replaceChildren(); message.textContent = error.message; } }
+}
+return { reload: render, dispose() { ++generation; clearCharts(); panel.replaceChildren(); } };
+}
+
+export function mountPlanner(container, { plantId, session }) {
+ container.classList.add('forecast-planner');
+ const message = document.createElement('p'), panel = document.createElement('div');
+ message.setAttribute('role', 'status'); container.replaceChildren(message, panel);
+ const planner = createPlanner({ message, panel, plants: { value: plantId }, session });
+ return { ...planner, ready: planner.reload() };
+}
+
+const standalone = document.querySelector('[data-standalone-planner]');
+if (standalone) {
+ const message = standalone.querySelector('#message'), panel = standalone.querySelector('#panel'), plants = standalone.querySelector('#plants');
+ try {
+ const response = await fetch('/api/session', { credentials: 'same-origin' });
+ const session = await response.json();
+ if (!response.ok || !session.authenticated) throw new Error('Bitte zuerst im Portal anmelden.');
+ const plantsResponse = await fetch('/api/plants', { credentials: 'same-origin' });
+ const data = await plantsResponse.json();
+ if (!plantsResponse.ok) throw new Error(data.error || 'Anlagen konnten nicht geladen werden.');
+ for (const plant of data.plants || []) {
+ if (!plant.installation_id) continue;
+ const option = document.createElement('option'); option.textContent = plant.name; option.value = plant.id; plants.append(option);
+ }
+ if (!plants.options.length) throw new Error('Keine verknuepfte Anlage vorhanden.');
+ const planner = createPlanner({ message, panel, plants, session });
+ plants.addEventListener('change', planner.reload); await planner.reload();
+ } catch (error) { message.textContent = error.message; }
+}
diff --git a/services/netplan-v4/gui/forecast-chart.browser-test.mjs b/services/netplan-v4/gui/forecast-chart.browser-test.mjs
new file mode 100644
index 0000000..45018bd
--- /dev/null
+++ b/services/netplan-v4/gui/forecast-chart.browser-test.mjs
@@ -0,0 +1,119 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+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 stamp = minute => new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString();
+const state = {
+ checkedAt: stamp(0), controlledTrialAuthorized: true, fresh: true, settings: { revision: 2, family: '3', trainingCadence: 'daily' },
+ families: [{ key: '3', label: 'Testfamilie' }],
+ plan: {
+ planId: 'test-plan', sourceFamily: '3', configRevision: 2, status: 'optimal', runMode: 'shadow', generatedAt: stamp(0), validUntil: stamp(60), forecastUntil: stamp(120), pricesKnownUntil: stamp(60),
+ inputQuality: { loadBasis: 'base_load' },
+ forecastPoints: Array.from({ length: 24 }, (_, index) => ({
+ time: stamp(index * 5), validUntil: stamp((index + 1) * 5), pvW: 1200 + index * 150, loadW: 2400, externalW: index === 4 ? null : 200
+ })),
+ points: Array.from({ length: 12 }, (_, index) => ({
+ time: stamp(index * 5), validUntil: stamp((index + 1) * 5), pvW: 1200 + index * 150, loadW: 2400,
+ externalW: index === 4 ? null : 200, gridTargetW: index * -50 + 400, baselineGridW: 1400 - index * 150,
+ batteryTargetW: index * 100 - 800, socEndPercent: { battery: 50 + index },
+ importPriceChfKwh: .2512, exportPriceChfKwh: .0801,
+ cumulativeCashCostChf: index * .15, cumulativeBaselineCashCostChf: index * .25
+ }))
+ }
+};
+
+test('embedded planner: responsive charts, keyboard/pointer inspection, toggles and lifecycle', async () => {
+ const errors = [], requests = [];
+ const html = 'Legacy
';
+ const server = createServer(async (request, response) => {
+ try {
+ if (request.url === '/') { response.setHeader('Content-Type', 'text/html'); response.end(html); return; }
+ if (!['/netplan-v4.js', '/forecast-chart.js', '/netplan-v4.css'].includes(request.url)) { response.writeHead(404); response.end(); return; }
+ response.setHeader('Content-Type', request.url.endsWith('.css') ? 'text/css' : 'text/javascript');
+ response.end(await readFile(new URL('.' + request.url, import.meta.url)));
+ } catch { response.writeHead(500); response.end(); }
+ });
+ 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 } : {}) });
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
+ page.on('pageerror', error => errors.push(error.message));
+ await page.route('**/api/**', async route => {
+ const request = route.request(); requests.push({ url: request.url(), method: request.method(), csrf: request.headers()['x-csrf-token'] });
+ if (request.url().includes('/slow/')) await new Promise(resolve => setTimeout(resolve, 150));
+ const result = structuredClone(state);
+ if (request.url().includes('/legacy/')) delete result.plan.forecastPoints;
+ if (request.url().includes('/house-total/')) {
+ result.plan.inputQuality.loadBasis = 'house_total';
+ result.plan.forecastPoints.forEach(point => { point.externalW = 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}/`);
+ await page.waitForSelector('.forecast-chart svg');
+ 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');
+ 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/);
+ const chart = page.locator('.forecast-planner .forecast-chart').first();
+ const slider = chart.locator('input[type=range]');
+ assert.equal(await slider.getAttribute('max'), '23');
+ assert.equal(await page.locator('.forecast-planner .forecast-chart').nth(1).locator('input[type=range]').getAttribute('max'), '11');
+ assert.match(await chart.locator('.forecast-chart-caption').innerText(), /Vollstaendige/);
+ await slider.focus(); await slider.press('ArrowRight');
+ assert.equal(await slider.inputValue(), '1');
+ assert.match(await slider.getAttribute('aria-valuetext'), /1.350 W/);
+ await chart.getByLabel('PV-Prognose', { exact: true }).uncheck();
+ assert.equal(await chart.locator('dt').count(), 2);
+ await chart.getByLabel('PV-Prognose', { exact: true }).check();
+ await chart.locator('svg').scrollIntoViewIfNeeded();
+ const bounds = await chart.locator('svg').boundingBox();
+ await page.mouse.move(bounds.x + bounds.width - 20, bounds.y + 70);
+ assert.equal(await slider.inputValue(), '23');
+ for (const width of [1280, 390, 320]) {
+ await page.setViewportSize({ width, height: 900 });
+ await chart.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(60);
+ 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`) });
+ }
+ await page.locator('summary').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);
+ await page.evaluate(async () => {
+ window.planner.dispose();
+ window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'legacy', session: {} });
+ await window.planner.ready;
+ });
+ assert.equal(await slider.getAttribute('max'), '11');
+ assert.match(await chart.locator('.forecast-chart-caption').innerText(), /Aelterer Plan/);
+ await page.evaluate(async () => {
+ window.planner.dispose();
+ window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'house-total', session: {} });
+ await window.planner.ready;
+ });
+ assert.equal(await chart.locator('dd').nth(2).innerText(), 'nicht vorhanden');
+ await page.evaluate(async () => {
+ window.planner.dispose();
+ window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'slow', session: {} });
+ window.planner.dispose();
+ await window.planner.ready;
+ });
+ assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 0);
+ assert.deepEqual(errors, []);
+ } finally {
+ await browser?.close(); await new Promise(resolve => server.close(resolve));
+ }
+});
diff --git a/services/netplan-v4/gui/forecast-chart.js b/services/netplan-v4/gui/forecast-chart.js
new file mode 100644
index 0000000..73efc87
--- /dev/null
+++ b/services/netplan-v4/gui/forecast-chart.js
@@ -0,0 +1,182 @@
+const SVG_NS = 'http://www.w3.org/2000/svg';
+const COLORS = ['#4e8181', '#757b12', '#ac4f68', '#386b91', '#737c7e', '#9c6335'];
+let sequence = 0;
+
+export const finiteValue = value => typeof value === 'number' && Number.isFinite(value);
+export const formatNumber = (value, digits = 2) => finiteValue(value)
+ ? value.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'nicht vorhanden';
+export function forecastSeries(plan) {
+ const fullHorizon = Array.isArray(plan.forecastPoints) && plan.forecastPoints.length > 0;
+ return { fullHorizon, points: fullHorizon ? plan.forecastPoints : Array.isArray(plan.points) ? plan.points : [] };
+}
+export function formatTime(value) {
+ const stamp = Date.parse(value);
+ return Number.isFinite(stamp) ? new Date(stamp).toLocaleString('de-CH', {
+ timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZoneName: 'short'
+ }) : 'nicht vorhanden';
+}
+
+export function chartDomain(points, series) {
+ let low = 0, high = 0, start = Infinity, end = -Infinity, valid = false;
+ for (const point of points) {
+ const at = Date.parse(point.time), until = Date.parse(point.validUntil || point.time);
+ if (!Number.isFinite(at) || !Number.isFinite(until) || until < at) continue;
+ start = Math.min(start, at); end = Math.max(end, until);
+ for (const item of series) {
+ const value = item.value(point);
+ if (!finiteValue(value)) continue;
+ low = Math.min(low, value); high = Math.max(high, value); valid = true;
+ }
+ }
+ if (!Number.isFinite(start)) return null;
+ return { low, high: high === low ? low + 1 : high, start, end: Math.max(start + 1, end), valid };
+}
+
+// Keep missing values and non-contiguous intervals as visible gaps, never interpolated data.
+export function seriesSegments(points, series, x, y) {
+ const segments = [];
+ let section = [], previousEnd;
+ const flush = () => { if (section.length) segments.push(section); section = []; };
+ for (const point of points) {
+ const value = series.value(point), at = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
+ if (!finiteValue(value) || !Number.isFinite(at) || !Number.isFinite(end) || end < at) {
+ flush(); previousEnd = undefined; continue;
+ }
+ if (previousEnd !== undefined && (at > previousEnd || at < previousEnd)) flush();
+ if (series.position === 'end') section.push([x(end), y(value)]);
+ else {
+ section.push([x(at), y(value)]);
+ if (end > at) section.push([x(end), y(value)]);
+ }
+ previousEnd = end;
+ }
+ flush(); return segments;
+}
+
+export function pointIndexAt(points, stamp) {
+ let selected = -1, distance = Infinity;
+ points.forEach((point, index) => {
+ const start = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return;
+ const next = stamp < start ? start - stamp : stamp >= end ? stamp - end : 0;
+ if (next <= distance) { distance = next; selected = index; }
+ });
+ return selected;
+}
+
+const element = (tag, text, className) => {
+ const node = document.createElement(tag);
+ if (text !== undefined) node.textContent = text;
+ if (className) node.className = className;
+ return node;
+};
+const svgElement = (tag, attributes, text) => {
+ const node = document.createElementNS(SVG_NS, tag);
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
+ if (text !== undefined) node.textContent = text;
+ return node;
+};
+
+export function renderForecastChart(container, { title, points = [], series = [], unit = '', caption = '' }) {
+ const id = 'forecast-chart-' + ++sequence;
+ const root = element('section', undefined, 'forecast-chart');
+ const heading = element('h3', title); heading.id = id;
+ root.setAttribute('aria-labelledby', id); root.append(heading); container.append(root);
+ const records = points.filter(point => Number.isFinite(Date.parse(point.time)) && Number.isFinite(Date.parse(point.validUntil || point.time)) && Date.parse(point.validUntil || point.time) >= Date.parse(point.time));
+ if (!records.length || !chartDomain(records, series)?.valid) {
+ root.append(element('p', 'Keine Prognosewerte vorhanden.', 'forecast-chart-empty'));
+ if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
+ return { element: root, destroy() { root.remove(); } };
+ }
+ const sources = series.map((source, index) => ({ ...source, color: source.color || COLORS[index % COLORS.length], visible: source.visible !== false }));
+ const legend = element('fieldset', undefined, 'forecast-chart-legend');
+ legend.append(element('legend', 'Datenreihen', 'forecast-chart-sr'));
+ const plot = element('div', undefined, 'forecast-chart-plot');
+ const svg = svgElement('svg', { role: 'img', 'aria-label': `${title}, ${unit}. Prognosewerte pro Zeitintervall.` });
+ plot.append(svg);
+ const inspector = element('div', undefined, 'forecast-chart-inspector');
+ const timestamp = element('p', undefined, 'forecast-chart-time');
+ const readout = element('dl'); inspector.append(timestamp, readout);
+ const sliderLabel = element('label', 'Prognosezeitpunkt', 'forecast-chart-slider');
+ const slider = element('input'); slider.type = 'range'; slider.min = '0'; slider.max = String(records.length - 1); slider.step = '1'; slider.value = '0';
+ slider.disabled = records.length === 1; sliderLabel.append(slider);
+ root.append(legend, plot, sliderLabel, inspector);
+ if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
+ let index = 0, domain, width = 900, marker;
+ const left = 68, right = 16, top = 18, bottom = 238, height = 284;
+ const x = value => left + (width - left - right) * (value - domain.start) / (domain.end - domain.start);
+ const y = value => bottom - (bottom - top) * (value - domain.low) / (domain.high - domain.low);
+
+ function inspect(next) {
+ index = Math.max(0, Math.min(records.length - 1, next)); slider.value = String(index);
+ const point = records[index];
+ timestamp.textContent = `${formatTime(point.time)} bis ${formatTime(point.validUntil || point.time)}`;
+ readout.replaceChildren();
+ const descriptions = [];
+ for (const source of sources.filter(item => item.visible)) {
+ const row = element('div'), name = element('dt', source.label), value = source.value(point);
+ name.style.setProperty('--series-color', source.color);
+ const text = finiteValue(value) ? `${formatNumber(value, source.digits ?? 2)} ${unit}` : 'nicht vorhanden';
+ row.append(name, element('dd', text)); readout.append(row); descriptions.push(`${source.label}: ${text}`);
+ }
+ if (!descriptions.length) readout.append(element('div', 'Keine Datenreihe ausgewaehlt.'));
+ slider.setAttribute('aria-valuetext', `${timestamp.textContent}. ${descriptions.join('; ')}`);
+ if (marker) {
+ const active = sources.filter(item => item.visible);
+ const endPosition = active.length && active.every(item => item.position === 'end');
+ const at = x(Date.parse(endPosition ? point.validUntil || point.time : point.time)); marker.setAttribute('x1', at); marker.setAttribute('x2', at);
+ }
+ }
+
+ function draw() {
+ width = Math.max(300, Math.round(plot.getBoundingClientRect().width || 900));
+ const active = sources.filter(source => source.visible);
+ domain = chartDomain(records, active.length ? active : sources);
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.replaceChildren();
+ for (let tick = 0; tick <= 4; tick++) {
+ const value = domain.low + (domain.high - domain.low) * tick / 4, at = y(value);
+ svg.append(svgElement('line', { x1: left, x2: width - right, y1: at, y2: at, class: 'forecast-chart-grid' }));
+ svg.append(svgElement('text', { x: left - 8, y: at + 4, 'text-anchor': 'end', class: 'forecast-chart-axis' }, formatNumber(value, Math.max(1, ...active.map(source => source.digits ?? 1)))));
+ }
+ svg.append(svgElement('text', { x: left, y: 12, class: 'forecast-chart-axis' }, unit));
+ const tickCount = width < 550 ? 2 : 4;
+ for (let tick = 0; tick <= tickCount; tick++) {
+ const stamp = domain.start + (domain.end - domain.start) * tick / tickCount;
+ const anchor = tick === 0 ? 'start' : tick === tickCount ? 'end' : 'middle';
+ const at = new Date(stamp);
+ svg.append(svgElement('text', { x: x(stamp), y: bottom + 20, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleTimeString('de-CH', { timeZone: 'Europe/Zurich', hour: '2-digit', minute: '2-digit' })));
+ svg.append(svgElement('text', { x: x(stamp), y: bottom + 36, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleDateString('de-CH', { timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit' })));
+ }
+ for (const source of active) {
+ for (const section of seriesSegments(records, source, x, y)) {
+ const attributes = { fill: 'none', stroke: source.color, 'stroke-width': 2, 'vector-effect': 'non-scaling-stroke', 'stroke-linecap': 'round' };
+ if (source.dash) attributes['stroke-dasharray'] = source.dash;
+ if (section.length === 1) svg.append(svgElement('circle', { cx: section[0][0], cy: section[0][1], r: 3, fill: source.color }));
+ else svg.append(svgElement('polyline', { ...attributes, points: section.map(pair => pair.join(',')).join(' ') }));
+ }
+ }
+ marker = svgElement('line', { y1: top, y2: bottom, class: 'forecast-chart-marker' }); svg.append(marker);
+ inspect(index);
+ }
+ sources.forEach(source => {
+ const label = element('label'), input = element('input'), swatch = element('span', undefined, 'forecast-chart-swatch');
+ input.type = 'checkbox'; input.checked = source.visible; swatch.style.background = source.color;
+ if (source.dash) swatch.style.background = `repeating-linear-gradient(90deg, ${source.color} 0 5px, transparent 5px 8px)`;
+ label.append(input, swatch, document.createTextNode(source.label)); legend.append(label);
+ input.addEventListener('change', () => { source.visible = input.checked; draw(); });
+ });
+ slider.addEventListener('input', () => inspect(Number(slider.value)));
+ const pointer = event => {
+ const bounds = svg.getBoundingClientRect();
+ if (!bounds.width) return;
+ const position = Math.max(left, Math.min(width - right, (event.clientX - bounds.left) * width / bounds.width));
+ const stamp = domain.start + (position - left) / (width - left - right) * (domain.end - domain.start);
+ inspect(pointIndexAt(records, stamp));
+ };
+ svg.addEventListener('pointermove', event => { if (event.pointerType !== 'touch') pointer(event); });
+ svg.addEventListener('pointerdown', pointer);
+ draw();
+ const resize = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(draw) : null;
+ resize?.observe(plot);
+ return { element: root, destroy() { resize?.disconnect(); root.remove(); } };
+}
diff --git a/services/netplan-v4/gui/forecast-chart.test.mjs b/services/netplan-v4/gui/forecast-chart.test.mjs
new file mode 100644
index 0000000..15ed45a
--- /dev/null
+++ b/services/netplan-v4/gui/forecast-chart.test.mjs
@@ -0,0 +1,68 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+
+const source = await readFile(new URL('./forecast-chart.js', import.meta.url), 'utf8');
+const { finiteValue, formatNumber, formatTime, chartDomain, seriesSegments, pointIndexAt, forecastSeries } = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64'));
+const point = (minute, value, duration = 5) => ({ time: new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString(), validUntil: new Date(Date.UTC(2026, 9, 6, 10, minute + duration)).toISOString(), value });
+const series = { value: row => row.value };
+
+test('missing values are not fabricated as zero', () => {
+ for (const value of [null, undefined, '12', NaN, Infinity]) assert.equal(finiteValue(value), false);
+ assert.equal(finiteValue(0), true);
+ assert.equal(formatNumber(null), 'nicht vorhanden');
+ assert.equal(chartDomain([point(0, null)], [series]).valid, false);
+});
+test('domains include negative values and interval ends', () => {
+ const result = chartDomain([point(0, -10), point(5, 20)], [series]);
+ assert.equal(result.low, -10); assert.equal(result.high, 20);
+ assert.equal(result.end - result.start, 600000);
+ assert.equal(chartDomain([], [series]), null);
+ assert.equal(chartDomain([{ time: 'invalid', value: 5 }], [series]), null);
+});
+test('power curves cover entire intervals and split missing values', () => {
+ const rows = [point(0, 5), point(5, null), point(10, 7)];
+ const segments = seriesSegments(rows, series, value => value, value => value);
+ assert.equal(segments.length, 2); assert.equal(segments[0].length, 2);
+ assert.equal(segments[0][1][0], Date.parse(rows[0].validUntil));
+});
+test('time gaps and reversed intervals do not join curves', () => {
+ assert.equal(seriesSegments([point(0, 5), point(10, 7)], series, x => x, y => y).length, 2);
+ assert.equal(seriesSegments([point(0, 5, -1)], series, x => x, y => y).length, 0);
+});
+test('cumulative and SOC samples use the interval end', () => {
+ const row = point(0, 7);
+ assert.deepEqual(seriesSegments([row], { ...series, position: 'end' }, x => x, y => y), [[[Date.parse(row.validUntil), 7]]]);
+});
+test('pointer selection identifies the containing interval and clamps endpoints', () => {
+ const rows = [point(0, 1), point(5, 2), point(10, 3)];
+ assert.equal(pointIndexAt(rows, Date.parse(rows[0].time) - 100), 0);
+ assert.equal(pointIndexAt(rows, Date.parse(rows[1].time)), 1);
+ assert.equal(pointIndexAt(rows, Date.parse(rows[1].time) + 1000), 1);
+ assert.equal(pointIndexAt(rows, Date.parse(rows[2].validUntil) + 100), 2);
+});
+test('time labels use Zurich and reject invalid timestamps', () => {
+ assert.match(formatTime('2026-10-06T10:00:00Z'), /12:00/);
+ assert.equal(formatTime('invalid'), 'nicht vorhanden');
+});
+test('full forecast horizon takes precedence without extending optimized plan points', () => {
+ const optimized = [point(0, 1)], full = [point(0, 1), point(5, 2)];
+ const plan = { points: optimized, forecastPoints: full };
+ assert.deepEqual(forecastSeries(plan), { fullHorizon: true, points: full });
+ assert.equal(plan.points.length, 1);
+});
+test('older or empty forecasts fall back to explicitly limited optimized points', () => {
+ const optimized = [point(0, 1)];
+ for (const forecastPoints of [undefined, null, [], {}, 'invalid']) {
+ assert.deepEqual(forecastSeries({ points: optimized, forecastPoints }), { fullHorizon: false, points: optimized });
+ }
+ assert.deepEqual(forecastSeries({}), { fullHorizon: false, points: [] });
+});
+test('chart styles stay scoped to the planner, including grouped and mobile selectors', async () => {
+ const css = await readFile(new URL('./netplan-v4.css', import.meta.url), 'utf8');
+ const selectors = [...css.matchAll(/([^{}]+)\{/g)].flatMap(match => match[1].split(',').map(selector => selector.trim()));
+ const chartSelectors = selectors.filter(selector => selector.includes('.forecast-chart'));
+ assert.ok(chartSelectors.length > 20, 'chart selectors are present and checked');
+ for (const selector of chartSelectors) assert.match(selector, /^\.forecast-planner\s+/, `unscoped chart selector: ${selector}`);
+ assert.match(css, /\.forecast-planner \.forecast-chart\s*\{[^}]*height:\s*auto\s*;/, 'legacy SVG height must not constrain the new chart sections');
+});
diff --git a/services/netplan-v4/gui/netplan-v4.css b/services/netplan-v4/gui/netplan-v4.css
index 2460732..fea7ff9 100644
--- a/services/netplan-v4/gui/netplan-v4.css
+++ b/services/netplan-v4/gui/netplan-v4.css
@@ -1 +1,49 @@
-body{font:16px system-ui,sans-serif;background:#eef4f4;color:#243c42}main{max-width:1100px;margin:2rem auto;padding:1.5rem;background:white}a{color:#006b83}label{display:block;margin:.8rem 0}select,button{font:inherit;padding:.6rem;margin:.3rem;border:1px solid #bacaca;border-radius:.25rem}button{background:#006b83;color:white;cursor:pointer}.notice{background:#e9f7fa;padding:1rem;border-left:4px solid #006b83}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#eef4f4;padding:1rem}svg{width:100%;height:auto}.curve{fill:none;stroke-width:2}.baseline{stroke:#7d888c}.optimized{stroke:#006b83}.axis{stroke:#b7c8cc}button:disabled{opacity:.5}
+.forecast-page { margin: 0; background: #f2f5f4; color: #172124; font-family: Figtree, sans-serif; letter-spacing: 0; }
+.forecast-page main { max-width: 1100px; margin: 2rem auto; padding: 1.5rem; background: #fff; }
+.forecast-page a { color: #305b5b; }
+.forecast-planner { min-width: 0; color: #172124; font-family: Figtree, sans-serif; letter-spacing: 0; }
+.forecast-planner * { box-sizing: border-box; }
+.forecast-planner h3 { margin: 1.6rem 0 .8rem; font-size: 1.05rem; line-height: 1.35; }
+.forecast-planner p { line-height: 1.5; overflow-wrap: anywhere; }
+.forecast-planner form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin: 1.5rem 0; }
+.forecast-planner form label { display: grid; gap: .4rem; align-content: start; font-size: .85rem; font-weight: 600; }
+.forecast-planner select, .forecast-planner input:not([type=checkbox]):not([type=range]), .forecast-planner button { width: 100%; min-height: 42px; margin: 0; padding: .65rem .75rem; border: 1px solid #bacaca; border-radius: 4px; background: #fff; color: #172124; font: inherit; }
+.forecast-planner button { align-self: end; background: #4e8181; color: #fff; cursor: pointer; }
+.forecast-planner button[type=submit] { background: #dce169; color: #172124; border-color: #dce169; }
+.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 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; }
+.forecast-planner-settings { margin-top: 1.5rem; border-top: 1px solid #d8e0de; padding-top: 1rem; }
+.forecast-planner-settings > summary { padding: .5rem 0; cursor: pointer; font-weight: 600; }
+.forecast-planner .forecast-chart { min-width: 0; height: auto; padding: 1.2rem 0; border-top: 1px solid #d8e0de; font-family: Figtree, sans-serif; letter-spacing: 0; }
+.forecast-planner .forecast-chart h3 { margin: 0 0 .75rem; color: #172124; font-size: 1.05rem; line-height: 1.35; }
+.forecast-planner .forecast-chart-legend { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; min-width: 0; margin: 0 0 .75rem; padding: 0; border: 0; }
+.forecast-planner .forecast-chart-legend label { display: inline-flex; align-items: center; gap: .4rem; min-height: 32px; margin: 0; color: #172124; font-size: .8rem; font-weight: 500; cursor: pointer; }
+.forecast-planner .forecast-chart-legend input { width: 16px; height: 16px; margin: 0; accent-color: #4e8181; }
+.forecast-planner .forecast-chart-swatch { display: inline-block; width: 20px; height: 3px; flex: 0 0 20px; }
+.forecast-planner .forecast-chart-plot { width: 100%; min-width: 0; min-height: 284px; }
+.forecast-planner .forecast-chart svg { display: block; width: 100%; height: 284px; overflow: visible; }
+.forecast-planner .forecast-chart-grid { stroke: #e3e9e7; stroke-width: 1; }
+.forecast-planner .forecast-chart-axis { fill: #576467; font-size: 11px; font-family: Figtree, sans-serif; }
+.forecast-planner .forecast-chart-marker { stroke: #172124; stroke-width: 1; stroke-dasharray: 3 3; pointer-events: none; }
+.forecast-planner .forecast-chart-slider { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: .75rem; margin: .2rem 0 .75rem; color: #576467; font-size: .75rem; font-weight: 500; }
+.forecast-planner .forecast-chart-slider input[type=range] { width: 100%; height: 28px; margin: 0; padding: 0; border: 0; box-shadow: none; accent-color: #4e8181; }
+.forecast-planner .forecast-chart input:focus-visible { outline: 2px solid #305b5b; outline-offset: 3px; }
+.forecast-planner .forecast-chart-inspector { padding: .75rem 0; border-top: 1px solid #d8e0de; }
+.forecast-planner .forecast-chart-time { margin: 0 0 .5rem; color: #172124; font-size: .8rem; font-weight: 600; }
+.forecast-planner .forecast-chart-inspector dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .6rem 1.25rem; margin: 0; }
+.forecast-planner .forecast-chart-inspector dl > div { min-width: 0; }
+.forecast-planner .forecast-chart-inspector dt { padding-left: .5rem; border-left: 3px solid var(--series-color); color: #576467; font-size: .75rem; font-weight: 500; overflow-wrap: anywhere; }
+.forecast-planner .forecast-chart-inspector dd { margin: .25rem 0 0 .7rem; color: #172124; font-size: 1rem; font-variant-numeric: tabular-nums; }
+.forecast-planner .forecast-chart-caption, .forecast-planner .forecast-chart-empty { margin: .5rem 0 0; color: #576467; font-size: .8rem; line-height: 1.5; }
+.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 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)); }
+}
diff --git a/services/netplan-v4/gui/netplan-v4.html b/services/netplan-v4/gui/netplan-v4.html
index 1a96991..fecb816 100644
--- a/services/netplan-v4/gui/netplan-v4.html
+++ b/services/netplan-v4/gui/netplan-v4.html
@@ -1 +1 @@
-ENELIX Netzfahrplan V4 Zurueck zum Portal Netzfahrplan V4 Schattenbetrieb. Diese Ansicht steuert keine Verbraucher. Die bestehende Regelung bleibt unveraendert.
Anlage
+ENELIX Prognose und Netzfahrplan Zurueck zum Portal Prognose und Netzfahrplan Anlage
diff --git a/services/netplan-v4/gui/netplan-v4.js b/services/netplan-v4/gui/netplan-v4.js
index 7e76572..f7665db 100644
--- a/services/netplan-v4/gui/netplan-v4.js
+++ b/services/netplan-v4/gui/netplan-v4.js
@@ -1,7 +1,8 @@
-const message = document.getElementById('message');
-const panel = document.getElementById('panel');
-const plants = document.getElementById('plants');
-let session, generation = 0;
+import { forecastSeries, renderForecastChart } from './forecast-chart.js';
+
+export function createPlanner({ message, panel, plants, session }) {
+let generation = 0;
+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 num = (v, digits = 2) => typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'unbekannt';
@@ -25,35 +26,21 @@ function table(title, rows) {
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);
}
-function curve(title, points, series) {
- panel.append(element('h3', title));
- if (!points.length) { panel.append(element('p', 'Keine Daten.')); return; }
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.setAttribute('viewBox', '0 0 1000 210'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', title);
- const values = points.flatMap(p => series.map(s => s.value(p))).filter(v => typeof v === 'number' && Number.isFinite(v));
- if (!values.length) { panel.append(element('p', 'Keine gueltigen Werte.')); return; }
- const low = Math.min(0, ...values), high = Math.max(1, ...values);
- const start = Date.parse(points[0].time), end = Date.parse(points[points.length - 1].validUntil || points[points.length - 1].time);
- for (const [i, source] of series.entries()) {
- let section = [];
- const flush = () => { if (!section.length) return; const line = document.createElementNS(svg.namespaceURI, 'polyline'); line.setAttribute('class', 'curve ' + (i ? 'optimized' : 'baseline')); line.setAttribute('points', section.join(' ')); svg.append(line); section = []; };
- for (const p of points) {
- const value = source.value(p);
- if (typeof value !== 'number' || !Number.isFinite(value)) { flush(); continue; }
- const x = 10 + 980 * (Date.parse(p.time) - start) / Math.max(1, end - start);
- const y = 190 - (value - low) / (high - low) * 180; section.push(`${x},${y}`);
- }
- flush();
- }
- panel.append(svg, element('p', `${num(low)} bis ${num(high)}. ${series.map((s, i) => (i ? 'Blau: ' : 'Grau: ') + s.label).join('; ')}. ${time(points[0].time)} – ${time(points[points.length - 1].validUntil)}.`));
+function clearCharts() { 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 }));
}
async function render() {
const ticket = ++generation, id = plants.value; if (!id) return;
const base = `/api/plants/${encodeURIComponent(id)}/prognosis/planner-v4`;
message.textContent = 'Lade Planungsstand ...';
+ clearCharts(); panel.replaceChildren();
try {
- const state = await request(base); if (ticket !== generation) return; panel.replaceChildren();
- panel.append(element('p', 'SCHATTENBETRIEB: Die angezeigten Vorgaben steuern keine Geraete.'));
+ 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.');
+ 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 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');
@@ -72,10 +59,11 @@ async function render() {
event.preventDefault(); save.disabled = true;
try {
const r = await request(base + '/settings', 'PUT', { expectedRevision: revision, changes: { family: family.field.value, forecastSource: dataSource.field.value, measurementDataset: dataset.field.value, measurementPolicy: measurement.field.value, peakOutlookPolicy: outlook.field.value, peakOutlookReliance: Number(reliance.value) / 100, trainingCadence: cadence.field.value } });
+ if (ticket !== generation) return;
revision = r.revision; message.textContent = `Revision ${revision} gespeichert. Neuberechnung angefordert, noch nicht abgeschlossen.`;
- } catch (error) { message.textContent = error.message; } finally { save.disabled = false; }
+ } catch (error) { if (ticket === generation) message.textContent = error.message; } finally { save.disabled = false; }
});
- replan.addEventListener('click', async () => { try { await request(base + '/replan', 'POST', {}); message.textContent = 'Neuberechnung angefordert.'; } catch (error) { message.textContent = error.message; } });
+ 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.'));
@@ -89,25 +77,62 @@ async function render() {
panel.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));
const plan = state.plan;
if (plan) {
if (!state.fresh) panel.append(element('p', 'ACHTUNG: Plan veraltet oder Neuberechnung ausstehend.'));
- table('Plan und Kosten', [['Plan-ID', plan.planId], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Prognose bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)], ['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'], ['V4 steuert die Anlage', 'Nein']]);
+ 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']]);
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]]);
- curve('Netzleistung (W)', plan.points, [{ label: 'ohne Optimierung', value: p => p.baselineGridW }, { label: 'optimiert', value: p => p.gridTargetW }]);
- curve('Batterieleistung (W; positiv = Laden)', plan.points, [{ label: 'Batterieplan', value: p => p.batteryTargetW }]);
- for (const asset of Object.keys(plan.points[0]?.socEndPercent || {})) curve(`SOC ${asset} (%)`, plan.points, [{ label: 'geplanter SOC', value: p => p.socEndPercent?.[asset] }]);
- curve('Kumulierte Zahlungen inklusive vollem Peakpreis (CHF)', plan.points, [{ label: 'ohne Optimierung', value: p => p.cumulativeBaselineCashCostChf }, { label: 'optimiert', value: p => p.cumulativeCashCostChf }]);
- panel.append(element('p', 'Kostenverlaeufe sind Prognosen. Restwert der Batterie und Restmonatsbewertung sind keine bereits erzielten Erloese.'));
+ 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('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;
message.textContent = '';
- } catch (error) { if (ticket === generation) { panel.replaceChildren(); message.textContent = error.message; } }
+ } catch (error) { if (ticket === generation) { clearCharts(); panel.replaceChildren(); message.textContent = error.message; } }
+}
+return { reload: render, dispose() { ++generation; clearCharts(); panel.replaceChildren(); } };
+}
+
+export function mountPlanner(container, { plantId, session }) {
+ container.classList.add('forecast-planner');
+ const message = document.createElement('p'), panel = document.createElement('div');
+ message.setAttribute('role', 'status'); container.replaceChildren(message, panel);
+ const planner = createPlanner({ message, panel, plants: { value: plantId }, session });
+ return { ...planner, ready: planner.reload() };
+}
+
+const standalone = document.querySelector('[data-standalone-planner]');
+if (standalone) {
+ const message = standalone.querySelector('#message'), panel = standalone.querySelector('#panel'), plants = standalone.querySelector('#plants');
+ try {
+ const response = await fetch('/api/session', { credentials: 'same-origin' });
+ const session = await response.json();
+ if (!response.ok || !session.authenticated) throw new Error('Bitte zuerst im Portal anmelden.');
+ const plantsResponse = await fetch('/api/plants', { credentials: 'same-origin' });
+ const data = await plantsResponse.json();
+ if (!plantsResponse.ok) throw new Error(data.error || 'Anlagen konnten nicht geladen werden.');
+ for (const plant of data.plants || []) {
+ if (!plant.installation_id) continue;
+ const option = document.createElement('option'); option.textContent = plant.name; option.value = plant.id; plants.append(option);
+ }
+ if (!plants.options.length) throw new Error('Keine verknuepfte Anlage vorhanden.');
+ const planner = createPlanner({ message, panel, plants, session });
+ plants.addEventListener('change', planner.reload); await planner.reload();
+ } catch (error) { message.textContent = error.message; }
}
-try {
- session = await request('/api/session'); if (!session.authenticated) throw new Error('Bitte zuerst im Portal anmelden.');
- const data = await request('/api/plants');
- for (const plant of data.plants || []) { if (!plant.installation_id) continue; const option = element('option', plant.name); option.value = plant.id; plants.append(option); }
- if (!plants.options.length) throw new Error('Keine verknuepfte Anlage vorhanden.');
- plants.addEventListener('change', render); await render();
-} catch (error) { message.textContent = error.message; }
diff --git a/services/netplan-v4/netplan_v4/optimizer.py b/services/netplan-v4/netplan_v4/optimizer.py
index 8dabed5..ff95c49 100644
--- a/services/netplan-v4/netplan_v4/optimizer.py
+++ b/services/netplan-v4/netplan_v4/optimizer.py
@@ -162,6 +162,7 @@ def optimize(steps,batteries,*,at,limits=None,observed_peaks=None,peak_prices=No
elif battery_w< -1:intent='export' if p_export>.001 else 'discharge'
else:intent='hold'
points.append({'time':utc(step.start).isoformat(),'validUntil':step.end.isoformat(),
+ 'pvW':float(step.pv_w),'loadW':float(step.base_load_w),'externalW':float(step.external_w),
'baselineGridW':float(step.residual_w),'gridTargetW':(p_import-p_export)*1000,
'batteryTargetW':battery_w,'assetTargetsW':targets,'socEndPercent':soc_end,
'pvCurtailmentW':float(x[curtail[i]]*1000),'intent':intent,
diff --git a/services/netplan-v4/netplan_v4/service.py b/services/netplan-v4/netplan_v4/service.py
index 0143c79..0689029 100644
--- a/services/netplan-v4/netplan_v4/service.py
+++ b/services/netplan-v4/netplan_v4/service.py
@@ -176,7 +176,7 @@ def price_at(store,plant,side,config,start,end,now):
class AwaitingInput(ValueError):pass
-def assemble(store,plant,family,now):
+def assemble(store,plant,family,now,*,include_forecast_points=False):
values={k:latest(store,plant,k) for k in ('operation','forecast','tariffs')}
for k,v in values.items():
if not v:raise AwaitingInput('Missing '+k+' input')
@@ -206,6 +206,11 @@ def assemble(store,plant,family,now):
external=p.get('externalW',0.) if source['loadBasis']=='base_load' else 0.
steps.append(Step(start,p['loadW'],p['pvW'],price_at(store,plant,'import',tariffs['import'],start,end,decision),price_at(store,plant,'export',tariffs['export'],start,end,decision),external,int((end-start).total_seconds())))
if not steps or steps[0].start!=decision:raise AwaitingInput('Forecast has no current interval')
+ if include_forecast_points:
+ # Preserve the exact selected input horizon separately from price-limited dispatch.
+ input_quality['forecastPoints']=[{'time':s.start.isoformat(),'validUntil':s.end.isoformat(),
+ 'pvW':float(s.pv_w),'loadW':float(s.base_load_w),
+ 'externalW':float(s.external_w) if source['loadBasis']=='base_load' else None} for s in steps]
full_end=steps[-1].end;steps=priced_prefix(steps,decision)
if not steps:raise AwaitingInput('No complete published-price billing quarter')
q=quarter_start(decision);elapsed=int((decision-q).total_seconds());past={}
@@ -291,10 +296,11 @@ def run_once(store,now):
selection=choose_family(settings['family'],current,economic_replay.scores(store,plant,now),registry=store.registry,now=now,
lookback_days=settings['autoLookbackDays'],minimum_days=settings['autoMinimumDays'],
minimum_coverage=settings['autoMinimumCoverage'],margin_chf=settings['autoSwitchMarginChf'])
- data,full_end,quality=assemble(store,plant,selection['family'],now)
+ data,full_end,quality=assemble(store,plant,selection['family'],now,include_forecast_points=True)
data['batteries']=[replace(b,roundtrip_efficiency=settings['roundtripEfficiency']) for b in data['batteries']]
result=optimize(**data,config_revision=settings['revision'],family=selection['family'])
if result['executable']:
+ result['forecastPoints']=quality.pop('forecastPoints')
result.update({'installationId':plant,'inputRefs':quality.pop('inputRefs'),'controlContext':quality.pop('controlContext'),'runMode':'shadow','liveEnabled':False,'sourceSelection':selection,'forecastUntil':full_end.isoformat(),'pricesKnownUntil':result['validUntil'],'inputQuality':quality,'warnings':quality['warnings']+([] if quality['loadBasis']=='base_load' else ['Aggregate house forecast: base-load/SDL separation not verified; shadow only'])})
store.publish_shadow(plant,result,settings['revision'],now,claim['sequence'],claim['lease_token'])
economic_replay.capture(store,plant,now,assemble,optimize)
diff --git a/services/netplan-v4/tests/test_v4.py b/services/netplan-v4/tests/test_v4.py
index ced4fc4..f6e9bb5 100644
--- a/services/netplan-v4/tests/test_v4.py
+++ b/services/netplan-v4/tests/test_v4.py
@@ -36,6 +36,16 @@ def inputs(at=AT):
return op,forecast,tariff
class OptimizerTest(unittest.TestCase):
+ def test_plan_preserves_exact_forecast_components(self):
+ steps = [replace(s, external_w=725.) for s in seq([2100., 2200., 2300.], pv=[3100., 3200., 3300.])]
+ plan = solve(steps)
+ self.assertTrue(plan['executable'])
+ for step, point in zip(steps, plan['points']):
+ self.assertEqual(point['pvW'], step.pv_w)
+ self.assertEqual(point['loadW'], step.base_load_w)
+ self.assertEqual(point['externalW'], step.external_w)
+ self.assertAlmostEqual(point['baselineGridW'], point['loadW'] + point['externalW'] - point['pvW'])
+
def test_grid_price_arbitrage(self):
p=solve(seq([1000.]*6,buy=[.01]*3+[.7]*3),[batt()]);self.assertTrue(p['executable']);self.assertGreater(p['points'][0]['batteryTargetW'],0);self.assertLess(p['points'][-1]['batteryTargetW'],0)
def test_no_future_energy(self):
@@ -157,12 +167,21 @@ class ServiceIntegrationTest(unittest.TestCase):
op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar))
price={'version':1,'eventId':'price1','observedAt':AT.isoformat(),'periods':[{'tariffId':'buy','side':'import','start':AT.isoformat(),'end':(AT+timedelta(minutes=15)).isoformat(),'value':-2.,'unit':'Rp/kWh','observedAt':AT.isoformat(),'sourceKind':'published_interval'}]}
ingest(self.store,AID,'prices',price,AT);p=run_once(self.store,AT);self.assertEqual(len(p['points']),3);self.assertEqual(p['points'][0]['importPriceChfKwh'],-.02)
+ self.assertEqual(len(p['forecastPoints']), 6)
+ self.assertEqual(p['forecastPoints'][-1]['validUntil'], p['forecastUntil'])
+ self.assertGreater(p['forecastUntil'], p['pricesKnownUntil'])
+ self.assertNotIn('gridTargetW', p['forecastPoints'][-1])
+ self.assertNotIn('forecastPoints', p['inputQuality'])
def test_carried_price_does_not_extend_horizon(self):
op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar))
price={'version':1,'eventId':'price1','observedAt':AT.isoformat(),'periods':[{'tariffId':'buy','side':'import','start':AT.isoformat(),'end':(AT+timedelta(hours=48)).isoformat(),'value':.1,'unit':'CHF/kWh','observedAt':AT.isoformat(),'sourceKind':'carried_forward'}]}
ingest(self.store,AID,'prices',price,AT);self.assertEqual(run_once(self.store,AT)['status'],'awaiting_inputs')
def test_aggregate_load_warning(self):
op,fc,tar=inputs();fc['families']['3']['loadBasis']='house_total';self.load((op,fc,tar));self.assertTrue(run_once(self.store,AT)['warnings'])
+ def test_aggregate_forecast_does_not_invent_separate_sdl(self):
+ op,fc,tar=inputs();fc['families']['3']['loadBasis']='house_total'
+ self.load((op,fc,tar));plan=run_once(self.store,AT)
+ self.assertTrue(all(point['externalW'] is None for point in plan['forecastPoints']))
def test_extra_credentials_not_stored(self):
op,fc,tar=inputs();op['password']='do-not-store'
with self.assertRaises(ValueError):ingest(self.store,AID,'operation',op,AT)
diff --git a/tests/ManagerSDLTest.php b/tests/ManagerSDLTest.php
index 6cb27f9..9f096ae 100644
--- a/tests/ManagerSDLTest.php
+++ b/tests/ManagerSDLTest.php
@@ -386,6 +386,68 @@ final class ManagerSDLTest extends TestCase
}
}
+ public function testEnergyPieAttributionTracksSDLWithoutChangingCustomSettings(): void
+ {
+ $custom = [
+ 'ProduktionZaehlerID' => 801,
+ 'NetzbezugZaehlerID' => 802,
+ 'EinspeisungZaehlerID' => 803,
+ 'VerbrauchZaehlerID' => 804,
+ 'FarbeEigenverbrauch' => '#123456',
+ 'FarbeAutarkie' => '#ABCDEF',
+ 'Standardzeitraum' => 'month',
+ 'EnergieanteileBerechenbar' => true,
+ ];
+ ManagerSDLRuntime::$properties[303] = $custom;
+ $this->properties(['SDLAktiv' => true]);
+
+ $this->invoke('synchronisiereEnergyPieAnteile', [303]);
+ self::assertSame(array_replace($custom, ['EnergieanteileBerechenbar' => false]),
+ ManagerSDLRuntime::$properties[303]);
+ $writes = ManagerSDLRuntime::$writes;
+ $this->invoke('synchronisiereEnergyPieAnteile', [303]);
+ self::assertSame($writes, ManagerSDLRuntime::$writes);
+
+ $this->properties(['SDLAktiv' => false]);
+ $this->invoke('synchronisiereEnergyPieAnteile', [303]);
+ self::assertSame($custom, ManagerSDLRuntime::$properties[303]);
+ }
+
+ public function testOlderEnergyPieStaysVisibleWithoutSDLAndKeepsConfiguration(): void
+ {
+ $custom = ['ProduktionZaehlerID' => 801, 'FarbeEigenverbrauch' => '#123456'];
+ ManagerSDLRuntime::$properties[303] = $custom;
+ $this->properties(['SDLAktiv' => false]);
+
+ $this->invoke('synchronisiereEnergyPieAnteile', [303]);
+
+ self::assertSame($custom, ManagerSDLRuntime::$properties[303]);
+ self::assertFalse(ManagerSDLRuntime::$hidden[303]);
+ self::assertSame([], ManagerSDLRuntime::$logs);
+ }
+
+ public function testOlderEnergyPieIsHiddenWithSDLAndRestoredAfterUtilsUpdate(): void
+ {
+ $custom = ['ProduktionZaehlerID' => 801, 'FarbeEigenverbrauch' => '#123456'];
+ ManagerSDLRuntime::$properties[303] = $custom;
+ $this->properties(['SDLAktiv' => true]);
+
+ $this->invoke('synchronisiereEnergyPieAnteile', [303]);
+
+ self::assertSame($custom, ManagerSDLRuntime::$properties[303]);
+ self::assertTrue(ManagerSDLRuntime::$hidden[303]);
+ self::assertCount(1, ManagerSDLRuntime::$logs);
+ self::assertStringContainsString('Enelix Utils aktualisieren', ManagerSDLRuntime::$logs[0][1]);
+
+ ManagerSDLRuntime::$properties[303]['EnergieanteileBerechenbar'] = true;
+ $this->invoke('synchronisiereEnergyPieAnteile', [303]);
+
+ self::assertFalse(ManagerSDLRuntime::$hidden[303]);
+ self::assertSame(array_replace($custom, ['EnergieanteileBerechenbar' => false]),
+ ManagerSDLRuntime::$properties[303]);
+ self::assertCount(1, ManagerSDLRuntime::$logs);
+ }
+
private function advanceCounters(): void
{
foreach ([201 => 102.0, 202 => 11.0, 203 => 5.2, 204 => 10.4, 205 => 8.1] as $id => $value) {
diff --git a/tests/PrognoseFormular/checks.php b/tests/PrognoseFormular/checks.php
index b07dee1..2febbc0 100644
--- a/tests/PrognoseFormular/checks.php
+++ b/tests/PrognoseFormular/checks.php
@@ -36,6 +36,17 @@ function fields(array $form): array
throw new RuntimeException('Forecast panel missing');
}
+$anzeigen = ManagerPrognoseFormular::laufzeitAnzeige();
+check(count($anzeigen) === 4, 'only known forecast runtime objects are consolidated');
+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($anzeigen['NetzfahrplanV4Aktivtest'], 'Testbetrieb'), 'test limitation remains visible');
+$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');
+
$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,
diff --git a/tests/Support/ManagerSDLRuntime.php b/tests/Support/ManagerSDLRuntime.php
index ac5bd61..5dead87 100644
--- a/tests/Support/ManagerSDLRuntime.php
+++ b/tests/Support/ManagerSDLRuntime.php
@@ -11,6 +11,7 @@ final class ManagerSDLRuntime
public static array $media = [];
public static array $writes = [];
public static array $hidden = [];
+ public static array $logs = [];
private static int $nextID = 10000;
public static function reset(): void
@@ -21,6 +22,7 @@ final class ManagerSDLRuntime
self::$media = [];
self::$writes = [];
self::$hidden = [];
+ self::$logs = [];
self::$nextID = 10000;
}
@@ -145,6 +147,16 @@ function IPS_GetProperty(int $instanceID, string $property)
return ManagerSDLRuntime::$properties[$instanceID][$property];
}
+function IPS_GetConfiguration(int $instanceID): string
+{
+ return json_encode(ManagerSDLRuntime::$properties[$instanceID] ?? [], JSON_THROW_ON_ERROR);
+}
+
+function IPS_LogMessage(string $sender, string $message): void
+{
+ ManagerSDLRuntime::$logs[] = [$sender, $message];
+}
+
function IPS_SetProperty(int $instanceID, string $property, $value): void
{
ManagerSDLRuntime::$properties[$instanceID][$property] = $value;