feat(forecast): integrate full forecast charts and complete SDL visualization
Tests / test (push) Failing after 52s

This commit is contained in:
dh
2026-10-06 17:40:45 +00:00
parent 5687596799
commit 372b997876
30 changed files with 4377 additions and 53 deletions
+5
View File
@@ -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();
+98
View File
@@ -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.
+19
View File
@@ -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
+26 -1
View File
@@ -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)) {
+1 -1
View File
@@ -23,7 +23,7 @@ trait ManagerNetzfahrplanV4AktivtestTrait
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestEnde', 0);
$this->RegisterVariableBoolean(
'NetzfahrplanV4Aktivtest',
'V4 Prognosemanager aktiv (Testbetrieb)',
'Netzfahrplansteuerung (Testbetrieb)',
'~Switch',
952
);
+1 -1
View File
@@ -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);");
}
+3 -3
View File
@@ -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 = '<h3>V4: NUR VORSCHAU - KEINE STELLFREIGABE</h3><table>';
$html = '<h3>Netzfahrplanvorschau - keine Stellfreigabe</h3><table>';
foreach (['status' => 'Status', 'planId' => 'Plan-ID', 'family' => 'Modellfamilie', 'step' => 'Zeitschritt (UTC)',
'stepValidUntil' => 'Gueltig bis', 'gridTargetW' => 'Geplantes Netzziel W', 'plannedBatteryW' => 'Geplante Batterie W',
'previewBatteryW' => 'Lokal berechnete Batterie W (nicht ausgegeben)', 'reason' => 'Ablehnungsgrund'] as $key => $label) {
+11
View File
@@ -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',
@@ -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.
@@ -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)}))
@@ -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"
}
]
@@ -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');
});
File diff suppressed because it is too large Load Diff
@@ -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(); } };
}
@@ -0,0 +1,382 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Enelix EMS Portal für Systeme, Lizenzen und Prognosemanager">
<title>Enelix EMS Portal</title>
<meta name="theme-color" content="#4e8181">
<link rel="icon" type="image/svg+xml" href="/branding/enelix-positive.svg">
<link rel="apple-touch-icon" href="/branding/enelix-touch.png">
<link rel="stylesheet" href="/styles.css?v=20261006-brand">
<link rel="stylesheet" href="/docs-portal.css?v=20261006">
<link rel="stylesheet" href="/branding/enelix-20261006.css">
<link rel="stylesheet" href="/netplan-v4.css?v=20261006-integrated">
</head>
<body>
<div id="toast" class="toast" role="status"></div>
<main id="authScreen" class="auth-screen">
<section class="auth-brand">
<div class="brand-lockup"><img class="brand-logo" src="/branding/enelix-negative.svg" alt="enelix" width="252" height="72"><small class="brand-context">EMS Portal</small></div>
<div class="auth-message"><p class="eyebrow">Energiemanagement</p><h1>Systeme planen.<br>Lizenzen verbinden.</h1><p>Ein Zugang für Anlagen, Prognosemanager und Lizenzierung.</p></div>
<div class="energy-lines" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</section>
<section class="auth-workspace">
<div class="auth-box">
<nav class="public-doc-links" aria-label="Öffentliche Informationen"><a href="/docs/index.html">Dokumentation</a><a href="/docs/faq.html">FAQ</a><a href="/docs/preise.html">Preise</a></nav>
<div class="auth-tabs" id="authTabs" role="tablist" aria-label="Anmeldung">
<button class="auth-tab active" id="loginTab" type="button">Anmelden</button>
<button class="auth-tab" id="registerTab" type="button">Konto erstellen</button>
</div>
<p class="auth-notice hidden" id="authNotice" role="status"></p>
<form id="loginForm" class="auth-form">
<div><p class="eyebrow">Willkommen zurück</p><h2>Am Portal anmelden</h2></div>
<label>E-Mail-Adresse<input name="email" type="email" autocomplete="email" required></label>
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
<p class="form-error" id="loginError" role="alert"></p>
<button class="button primary wide" type="submit">Anmelden</button>
<button class="auth-link" id="forgotPasswordButton" type="button">Passwort vergessen?</button>
<button class="auth-link" id="resendVerificationButton" type="button">Bestätigungs-E-Mail erneut senden</button>
</form>
<form id="registerForm" class="auth-form hidden">
<div><p class="eyebrow">Offene Registrierung</p><h2>Neues Konto erstellen</h2></div>
<label>Name<input name="name" autocomplete="name" required minlength="2"></label>
<label>E-Mail-Adresse<input name="email" type="email" autocomplete="email" required></label>
<label>Passwort<input name="password" type="password" autocomplete="new-password" required minlength="10"><small>Mindestens 10 Zeichen.</small></label>
<p class="form-error" id="registerError" role="alert"></p>
<button class="button primary wide" type="submit">Konto erstellen</button>
</form>
<form id="forgotForm" class="auth-form hidden">
<div><p class="eyebrow">Kontozugang</p><h2>Passwort zurücksetzen</h2></div>
<label>E-Mail-Adresse<input name="email" type="email" required></label>
<p class="form-error" id="forgotError" role="alert"></p>
<button class="button primary wide" type="submit">Link anfordern</button>
<button class="auth-link" data-auth-login type="button">Zurück zur Anmeldung</button>
</form>
<form id="resetForm" class="auth-form hidden">
<div><p class="eyebrow">Kontozugang</p><h2>Neues Passwort</h2></div>
<label>Neues Passwort<input name="password" type="password" required minlength="10"></label>
<label>Passwort wiederholen<input name="password_repeat" type="password" required minlength="10"></label>
<p class="form-error" id="resetError" role="alert"></p>
<button class="button primary wide" type="submit">Passwort speichern</button>
<button class="auth-link" data-auth-login type="button">Zurück zur Anmeldung</button>
</form>
</div>
</section>
</main>
<div id="portalApp" class="portal hidden">
<header class="topbar">
<a class="brand" href="#overview" aria-label="Enelix EMS Portal"><img class="brand-logo" src="/branding/enelix-positive.svg" alt="enelix" width="148" height="43"><small class="brand-context">EMS Portal</small></a>
<div class="top-actions"><nav class="header-links" aria-label="Dokumentation und Preise"><a href="/docs/index.html">Dokumentation</a><a href="/docs/preise.html">Preise</a></nav><span class="environment">Entwicklung</span><button class="account-button" id="accountButton" type="button"><span id="topInitials">EN</span><strong id="topName">Konto</strong></button></div>
</header>
<div class="app-shell">
<aside class="sidebar">
<nav>
<button class="nav-item active" data-view="overview">Lizenzen</button>
<button class="nav-item" data-view="forecast">Prognose</button>
<button class="nav-item" data-view="faults">Störüberwachung</button>
<button class="nav-item" data-view="account">Konto</button>
</nav>
<div class="workspace-switcher"><label>Organisation<select id="workspaceSelect"></select></label><small id="workspaceRole">Eigentümer</small></div>
</aside>
<main class="content">
<section class="view active" id="view-overview">
<div class="page-head"><div><p class="eyebrow" id="overviewOrg">Arbeitsbereich</p><h1>Lizenzübersicht</h1><p>Gekaufte Lizenzen, Verwendung und Managerbindung auf einen Blick.</p></div><button class="button primary" type="button" data-open-new-license>Neue Lizenz</button></div>
<div class="metrics"><div><span id="metricPlants">0</span><small>Lizenzpakete</small></div><div><span id="metricManagers">0</span><small>Bezahlte Lizenzen</small></div><div><span id="metricConnected">0</span><small>Verknüpfte Manager</small></div></div>
<section class="work-section"><div class="section-head"><div><h2>Lizenzen und Manager</h2><p>Status, Verwendung und verfügbare Erweiterungen.</p></div></div><div id="overviewPlants"></div></section>
</section>
<section class="view" id="view-licenses">
<div class="page-head"><div><p class="eyebrow">Kauf · CHF netto</p><h1>Lizenz erweitern</h1><p>Vorhandene Lizenzen prüfen, zusätzliche Module ergänzen und Peak Shaving bei Bedarf upgraden.</p></div><div class="page-actions"><button class="button secondary" type="button" data-configure-current>System konfigurieren</button><span class="status" id="stripeStatus">Stripe Sandbox</span></div></div>
<div id="moduleNoPlant" class="empty-state hidden"><strong>Noch keine Lizenz vorhanden</strong><p>Lege ein Lizenzpaket direkt an oder ermittle es mit dem Systemkonfigurator.</p><button class="button primary" data-open-new-license>Neue Lizenz</button></div>
<div id="moduleConfigurator" class="config-layout">
<section class="module-panel">
<label class="select-line">System<select id="orderPlant"></select></label>
<div class="manager-summary"><div><span class="field-label">Manager</span><strong id="managerCurrentName">Noch keine bezahlte Manager-Lizenz</strong><small id="ownedManager">Der Manager wird beim Erstkauf einmalig berechnet.</small></div><label id="peakUpgradeOption" class="upgrade-option hidden"><input id="peakUpgrade" type="checkbox"><span><strong>Auf Peak Shaving upgraden</strong><small>Einmalig nur die Preisdifferenz bezahlen.</small></span></label><span><b id="managerPrice">CHF 0.00</b><small id="managerSetup">Kein weiterer Managerkauf</small></span></div>
<div class="module-list">
<div class="module-header"><span>Verbrauchermodul</span><span>Lizenz</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="battery" data-name="Batteriespeicher" data-price="41.95" data-setup="54.55"><span><input type="checkbox" class="module-check"><strong>Batteriespeicher</strong><small>Batterie</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 41.95</span><span>CHF 54.55</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="buffer_storage" data-name="Pufferspeicher" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Pufferspeicher</strong><small>VerbraucherPufferspeicher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="ev_charger" data-name="EV-Ladestation" data-price="33.65" data-setup="43.75"><span><input type="checkbox" class="module-check"><strong>EV-Ladestation</strong><small>Stand-Alone oder Gateway</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 33.65</span><span>CHF 43.75</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="consumer_single" data-name="Verbraucher einstufig" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Verbraucher einstufig</strong><small>Ein schaltbarer Verbraucher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="heat_pump" data-name="Wärmepumpe" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Wärmepumpe</strong><small>Sperre / Erhöhung oder SG Ready</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="boiler_multi" data-name="Boiler mehrstufig" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Boiler mehrstufig</strong><small>Warmwassererwärmer</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<div class="module-header"><span>Prognoselizenz</span><span>Jahrespreis</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="forecast_pv" data-name="PV-Ertragsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>PV-Ertragsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
<label class="module-row" data-key="forecast_load" data-name="Verbrauchsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>Verbrauchsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
<label class="module-row" data-key="grid_schedule" data-name="Intelligenter Netzfahrplan" data-price="50" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>Intelligenter Netzfahrplan</strong><small>Jahreslizenz · Manager mit Peak Shaving erforderlich</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 50.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
</div>
</section>
<aside class="order-panel"><p class="eyebrow">Bestellung</p><div id="orderItems" class="order-items"></div><dl><div><dt>Lizenzen netto</dt><dd id="moduleTotal">CHF 0.00</dd></div><div id="setupTotalRow" class="hidden"><dt>Generator / Ersteinrichtung</dt><dd id="setupTotal">CHF 0.00</dd></div><div><dt>Gesamt netto</dt><dd id="netTotal">CHF 0.00</dd></div><div><dt id="vatLabel">MwSt. 8.1 %</dt><dd id="vatTotal">CHF 0.00</dd></div><div class="grand-total"><dt>Gesamt inkl. MwSt.</dt><dd id="grandTotal">CHF 0.00</dd></div></dl><button class="button primary wide" id="checkoutButton" type="button">Mit Stripe bezahlen</button><p class="form-error" id="checkoutError"></p></aside>
</div>
</section>
<section class="view" id="view-configurator">
<div class="page-head"><div><p class="eyebrow">Planung</p><h1>System konfigurieren</h1><p>Geräte erfassen und passende Lizenzen ermitteln.</p></div><span class="status available">Enelix EMS</span></div>
<form id="systemForm" class="system-configurator">
<section class="config-form wizard">
<header class="wizard-header">
<div><p class="eyebrow" id="wizardStepCount">Schritt 1 von 10</p><h2 id="wizardStepTitle">Anlage</h2></div>
<ol class="wizard-progress" aria-label="Fortschritt">
<li class="active" data-wizard-marker="0">Anlage</li><li data-wizard-marker="1">Energiequellen</li>
<li data-wizard-marker="2">Batterie</li><li data-wizard-marker="3">Pufferspeicher</li>
<li data-wizard-marker="4">Ladestation</li><li data-wizard-marker="5">Verbraucher</li>
<li data-wizard-marker="6">Boiler</li><li data-wizard-marker="7">Wärmepumpe</li>
<li data-wizard-marker="8">Abrechnung</li><li data-wizard-marker="9">Prüfen</li>
</ol>
</header>
<section class="wizard-step active" data-wizard-step="0">
<p class="wizard-question">Wie heisst die Anlage und wo befindet sie sich?</p>
<div class="field-grid"><label>Systemname<input name="name" required minlength="2" placeholder="z. B. Werk Zürich"></label><label>Standort<input name="location" required minlength="2" placeholder="Ort, Schweiz"></label><label>Anlagentyp<select name="siteType"><option>Gewerbe</option><option>Privat</option><option>Industrie</option></select></label></div>
</section>
<section class="wizard-step" data-wizard-step="1">
<p class="wizard-question">Wie soll der Manager arbeiten und über welche Schnittstellen werden Energiedaten gelesen?</p>
<label class="setup-option"><input name="peakShaving" type="checkbox"><span><strong>Peak Shaving erforderlich</strong><small>Wählt die Manager-Variante mit Lastspitzenregelung.</small></span></label>
<div class="configuration-band">
<h3>Netzmessung</h3>
<div class="field-grid">
<label>Geräteprofil<select name="gridProfile" data-profile-select="energy"></select></label>
<label>IP-Adresse oder Hostname<input name="gridHost" maxlength="120" placeholder="z. B. 192.168.1.40"></label>
<label>Port<input name="gridPort" type="number" min="0" max="65535" value="0"></label>
<label>Modbus Geräte-ID<input name="gridUnitId" type="number" min="0" max="247" value="1"></label>
<label data-manual-source="grid">Bestehende Leistungsvariable<input name="gridSourceVariableId" type="number" min="0" value="0"></label>
<label>Hinweis<textarea name="gridNotes" rows="3" maxlength="300"></textarea></label>
</div>
</div>
<label class="setup-option"><input name="pvEnabled" type="checkbox"><span><strong>PV-Anlage einlesen</strong><small>Herstellerprofil und lokale Schnittstelle für die Erzeugungsmessung verwenden.</small></span></label>
<div class="configuration-band">
<h3>PV-Wechselrichter</h3>
<div class="field-grid">
<label>Bezeichnung<input name="pvName" maxlength="80" value="PV-Wechselrichter"></label>
<label>AC-Nennleistung (kW)<input name="pvAcPowerKw" type="number" min="0" step="0.1" value="0"></label>
<label>Geräteprofil<select name="pvProfile" data-profile-select="energy"></select></label>
<label>IP-Adresse oder Hostname<input name="pvHost" maxlength="120"></label>
<label>Port<input name="pvPort" type="number" min="0" max="65535" value="0"></label>
<label>Modbus Geräte-ID<input name="pvUnitId" type="number" min="0" max="247" value="1"></label>
<label data-manual-source="pv">Bestehende Leistungsvariable<input name="pvSourceVariableId" type="number" min="0" value="0"></label>
</div>
</div>
<div class="configuration-band" data-pv-topology>
<div class="structured-editor-head"><div><h3>PV-Flächen</h3><p>Dachflächen und Ausrichtung ohne JSON erfassen.</p></div></div>
<div class="quantity-line"><label>Anzahl PV-Flächen<input name="pvSurfaceQuantity" data-topology-quantity="pvSurfaces" type="number" min="0" max="50" value="0"></label><small>Bei aktivierter PV wird mindestens eine Fläche angelegt.</small></div>
<div class="device-editor" data-topology-editor="pvSurfaces"></div>
</div>
<details class="config-details"><summary>Optionale Manager-Funktionen</summary><p>Nur betriebliche Optionen. Technische Zuordnungen, Prognoseanschlüsse, Archivierung und Aufbewahrung werden automatisch gesetzt.</p><div id="managerConfigurationFields" class="schema-groups"></div></details>
</section>
<section class="wizard-step" data-wizard-step="2">
<p class="wizard-question">Welche Batteriespeicher soll der Manager steuern?</p>
<div class="quantity-line"><label>Batteriespeicher<input name="batteryQuantity" data-device-quantity="battery" type="number" min="0" max="50" value="0"></label><small>Geräteprofil, Leistungsdaten und Grenzen genügen; Typ und Variablen werden automatisch zugeordnet.</small></div>
<div class="device-editor" data-device-editor="battery"></div>
</section>
<section class="wizard-step" data-wizard-step="3">
<p class="wizard-question">Welche Pufferspeicher sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Pufferspeicher<input name="bufferQuantity" data-device-quantity="buffer_storage" type="number" min="0" max="50" value="0"></label><small>Fühler, Heizkurve und Leistungsstufen werden je Pufferspeicher erfasst.</small></div>
<div class="device-editor" data-device-editor="buffer_storage"></div>
</section>
<section class="wizard-step" data-wizard-step="4">
<p class="wizard-question">Welche EV-Ladestationen sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Anzahl<input name="evQuantity" data-device-quantity="ev_charger" type="number" min="0" max="50" value="0"></label><small>Je Station kann zwischen direkter Anbindung und Easee Gateway gewählt werden.</small></div>
<div class="device-editor" data-device-editor="ev_charger"></div>
</section>
<section class="wizard-step" data-wizard-step="5">
<p class="wizard-question">Welche einstufigen Verbraucher sollen berücksichtigt werden?</p>
<div class="quantity-line"><label>Anzahl<input name="consumerQuantity" data-device-quantity="consumer_single" type="number" min="0" max="50" value="0"></label><small>Bei Shelly-Profilen werden Schaltkontakt und Rückmeldung automatisch verbunden.</small></div>
<div class="device-editor" data-device-editor="consumer_single"></div>
</section>
<section class="wizard-step" data-wizard-step="6">
<p class="wizard-question">Gibt es mehrstufige Boiler oder Warmwassererwärmer?</p>
<div class="quantity-line"><label>Anzahl<input name="boilerQuantity" data-device-quantity="boiler_multi" type="number" min="0" max="50" value="0"></label><small>Leistungsstufen, Zeitplan und Temperaturfühler werden als Listen konfiguriert.</small></div>
<div class="device-editor" data-device-editor="boiler_multi"></div>
</section>
<section class="wizard-step" data-wizard-step="7">
<p class="wizard-question">Welche Wärmepumpen sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Anzahl<input name="heatPumpQuantity" data-device-quantity="heat_pump" type="number" min="0" max="50" value="0"></label></div>
<div class="device-editor" data-device-editor="heat_pump"></div>
</section>
<section class="wizard-step" data-wizard-step="8">
<p class="wizard-question">Soll im selben Symcon-System ein Verbrauchskostenreport eingerichtet werden?</p>
<label class="setup-option"><input name="costReportEnabled" type="checkbox"><span><strong>Verbrauchskostenreport hinzufügen</strong><small>Wird als eigene Instanz eingerichtet und verwendet einen separaten Lizenzcode.</small></span></label>
<div class="field-grid"><label>Stromzähler<input name="costPowerMeters" type="number" min="0" max="999" value="0"></label><label>Wärme- oder Wasserzähler<input name="costAuxMeters" type="number" min="0" max="999" value="0"></label></div>
</section>
<section class="wizard-step" data-wizard-step="9">
<p class="wizard-question">Bitte prüfe die Angaben vor dem Speichern.</p>
<div id="wizardReview" class="wizard-review"></div>
</section>
<p class="form-error" id="systemError"></p>
<footer class="wizard-actions"><button class="button secondary" id="wizardBack" type="button">Zurück</button><button class="button primary" id="wizardNext" type="button">Weiter</button><button class="button primary hidden" id="wizardSubmit" type="submit">Konfiguration abschliessen</button></footer>
</section>
<aside class="recommendation-panel"><p class="eyebrow">Aktueller Vorschlag</p><h2 id="recommendManager">Manager ohne Peak Shaving</h2><div id="recommendModules" class="recommend-list"></div><p>Der Vorschlag enthält den Manager und alle gewählten Verbrauchermodule.</p><div id="packageResult" class="package-result hidden"><strong>Einrichtungspaket bereit</strong><a class="button secondary wide" id="packageDownload" href="#">Symcon-Paket herunterladen</a><button class="button primary wide" id="orderRecommendation" type="button">Lizenzen bestellen</button></div></aside>
</form>
</section>
<section class="view" id="view-forecast">
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Prognose</h1><p>Messwerte und berechnete Verläufe des lizenzierten Prognosemanagers.</p></div><span class="status available">Serverplanung</span></div>
<div id="forecastEmpty" class="empty-state hidden"><strong>Kein System verfügbar</strong></div>
<div id="forecastContent">
<section class="forecast-toolbar">
<label>System<select id="forecastPlant"></select></label>
<div class="connection-state"><span class="signal"></span><div><strong id="forecastConnection">Noch nicht verknüpft</strong><p id="forecastApiStatus">Status wird geprüft</p></div></div>
</section>
<section id="forecastPlanner" class="forecast-planner-section" aria-label="Aktueller Netzfahrplan"></section>
<div class="forecast-metrics">
<div><span id="forecastLastUpdated">–</span><small>Aktualisiert</small></div>
<div><span id="forecastSeriesCount">0</span><small>Datenreihen</small></div>
<div><span id="forecastWindowValue">72 h</span><small>Zeitfenster</small></div>
</div>
<section class="forecast-chart-section">
<div class="section-head"><div><h2>Leistung und Prognose</h2><p>Messwerte und Prognosen gemeinsam vergleichen. Reihen können einzeln ein- und ausgeblendet werden.</p></div></div>
<div class="forecast-series-panel">
<strong>Kurven auswählen</strong>
<div id="forecastSeriesControls" class="forecast-series-controls"></div>
</div>
<div class="forecast-chart-controls">
<label>Historie
<select id="forecastHistoryDays">
<option value="1" selected>1 Tag</option>
<option value="7">7 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
<option value="90">90 Tage</option>
<option value="180">180 Tage</option>
</select>
</label>
<label>Von<input id="forecastRangeStart" type="datetime-local"></label>
<label>Bis<input id="forecastRangeEnd" type="datetime-local"></label>
<button id="forecastRangeReset" class="button secondary compact" type="button">Gesamtes Zeitfenster</button>
</div>
<div id="forecastState" class="forecast-state" role="status">Prognosedaten werden geladen.</div>
<div id="forecastLegend" class="forecast-chart-legend hidden" aria-label="Legende der angezeigten Kurven"></div>
<div class="forecast-chart-wrap">
<svg id="forecastChart" class="forecast-chart hidden" viewBox="0 0 960 320" preserveAspectRatio="none" role="img" aria-label="Zeitverlauf der Prognosedaten"></svg>
<div id="forecastTooltip" class="forecast-tooltip hidden"></div>
</div>
</section>
<section class="forecast-quality-section">
<div class="section-head"><div><h2>Prognosequalität</h2><p>Vergleich der Modellprognosen mit den tatsächlich gemessenen Werten.</p></div><span id="forecastQualityStatus" class="status review">Aufbauphase</span></div>
<div id="forecastQualityState" class="forecast-quality-state" role="status">Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie.</div>
<div id="forecastQualityTable" class="table-wrap hidden">
<table>
<thead><tr><th>Modell</th><th>Ziel</th><th>R²</th><th>MAE</th><th>RMSE</th><th>Vergleiche</th></tr></thead>
<tbody id="forecastQualityBody"></tbody>
</table>
</div>
</section>
<section id="forecastConfigSection" class="forecast-config-section hidden">
<div class="section-head">
<div><h2>Prognosekonfiguration</h2><p>Anlagenparameter für Berechnung und Tarife.</p></div>
<span id="forecastConfigStatus" class="status review">Nicht gespeichert</span>
</div>
<form id="forecastConfigForm">
<div class="forecast-config-grid">
<label>Standort<input name="standort" maxlength="120" required></label>
<label>Kanton<select name="kanton"><option>AG</option><option>AI</option><option>AR</option><option>BE</option><option>BL</option><option>BS</option><option>FR</option><option>GE</option><option>GL</option><option>GR</option><option>JU</option><option>LU</option><option>NE</option><option>NW</option><option>OW</option><option>SG</option><option>SH</option><option>SO</option><option>SZ</option><option>TG</option><option>TI</option><option>UR</option><option>VD</option><option>VS</option><option>ZG</option><option>ZH</option></select></label>
<label>PV AC-Leistung kW<input name="ac_leistung" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Batteriekapazität kWh<input name="batt_capacity_kwh" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Batterieleistung kW<input name="batt_power_kw" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Peakpreis CHF/kW<input name="tarif_peak_fest" type="number" min="0" max="100000" step="0.01" required></label>
<label>Bezug Tarifart<select name="tarif_bezug_modus"><option value="dynamisch">Dynamisch</option><option value="statisch">Statisch</option></select></label>
<label>Bezugstarif<select name="tarif_bezug"></select></label>
<label data-tariff-custom="import">Bezugspreis CHF/kWh<input name="tarif_bezug_fest" type="number" min="0" max="100" step="0.001" required></label>
<label>Einspeisung Tarifart<select name="tarif_einspeisung_modus"><option value="dynamisch">Dynamisch</option><option value="statisch">Statisch</option></select></label>
<label>Einspeisetarif<select name="tarif_einspeisung"></select></label>
<label data-tariff-custom="export">Einspeisevergütung CHF/kWh<input name="tarif_einspeisung_fest" type="number" min="0" max="100" step="0.001" required></label>
</div>
<div id="forecastTopologyBlock" class="forecast-config-block hidden">
<div class="config-block-head"><div><h3>Technische Anlagendaten</h3><p>Schreibgeschützt aus dem verbundenen Enelix Manager.</p></div><span class="status available">Manager</span></div>
<div id="forecastTopology" class="forecast-topology"></div>
</div>
<div class="forecast-config-block">
<div class="config-block-head"><div><h3>PV-Dächer</h3><p>Leistung, Neigung und Azimut je Dachfläche.</p></div><button id="forecastAddRoof" class="button secondary compact" type="button">Dach hinzufügen</button></div>
<div id="forecastRoofs" class="forecast-roofs"></div>
</div>
<div class="forecast-config-block">
<div class="config-block-head"><div><h3>Prognoseumfang</h3><p>Funktionen für diese Anlage. Der Netzfahrplan schliesst PV- und Verbrauchsprognose ein.</p></div></div>
<div class="forecast-variants">
<label><input type="checkbox" name="forecast_pv">PV-Ertragsprognose</label>
<label><input type="checkbox" name="forecast_load">Verbrauchsprognose</label>
<label><input type="checkbox" name="grid_schedule">Intelligenter Netzfahrplan</label>
</div>
</div>
<p id="forecastConfigError" class="form-error" role="alert"></p>
<div class="forecast-config-actions"><button class="button primary" type="submit">Konfiguration speichern</button></div>
</form>
</section>
<div class="forecast-layout">
<section class="manager-detail"><p class="eyebrow">Lizenzierte Variante</p><h2 id="forecastVariant">Manager</h2><div class="license-validity"><span>Lizenzdauer</span><strong>Unbeschränkt gültig</strong></div><div id="forecastOfflineWarning" class="offline-license-warning hidden" role="status"><strong id="forecastOfflineTitle">Keine Verbindung zum Lizenzserver</strong><p id="forecastOfflineText"></p></div></section>
<section class="forecast-scope"><p class="eyebrow">Datenumfang</p><h2>Live-Prognose</h2><dl class="detail-list"><div><dt>Modus</dt><dd>Konfiguration und Anzeige</dd></div><div><dt>Simulation</dt><dd>Nicht enthalten</dd></div><div><dt>Zugriff</dt><dd>Lizenzgebunden</dd></div></dl></section>
</div>
</div>
</section>
<section class="view" id="view-faults">
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Störüberwachung</h1><p>Aktive und behobene Störungen der verbundenen Enelix Manager und Geräte.</p></div><span class="status neutral" id="faultConnectionStatus">Status wird geprüft</span></div>
<div id="faultEmpty" class="empty-state hidden"><strong>Kein Manager-System verfügbar</strong></div>
<div id="faultContent">
<section class="fault-toolbar">
<label>System<select id="faultPlant"></select></label>
<div class="connection-state"><span class="signal" id="faultSignal"></span><div><strong id="faultConnection">Noch nicht verknüpft</strong><p id="faultApiStatus">Status wird geprüft</p></div></div>
</section>
<div class="fault-metrics">
<div><span id="faultActiveCount">0</span><small>Aktive Störungen</small></div>
<div><span id="faultUrgentCount">0</span><small>Kritisch oder Fehler</small></div>
<div><span id="faultLastUpdated">–</span><small>Letzter Empfang</small></div>
</div>
<section class="fault-section">
<div class="section-head"><div><h2>Aktive Störungen</h2><p>Aktueller Vollsnapshot des Managers.</p></div></div>
<div id="faultState" class="fault-state" role="status">Störungsdaten werden geladen.</div>
<div id="faultActiveTable" class="table-wrap hidden"><table><thead><tr><th>Schweregrad</th><th>Quelle</th><th>Meldung</th><th>Seit</th></tr></thead><tbody id="faultActiveBody"></tbody></table></div>
</section>
<section class="fault-section">
<div class="section-head"><div><h2>Behobene Störungen</h2><p>Die letzten 100 abgeschlossenen Ereignisse.</p></div></div>
<div id="faultHistoryState" class="fault-state">Noch keine behobenen Störungen.</div>
<div id="faultHistoryTable" class="table-wrap hidden"><table><thead><tr><th>Schweregrad</th><th>Quelle</th><th>Meldung</th><th>Behoben</th></tr></thead><tbody id="faultHistoryBody"></tbody></table></div>
</section>
<section class="fault-section fault-notification-section">
<div class="section-head"><div><h2>E-Mail-Benachrichtigungen</h2><p>Empfänger für neue und behobene Störungen dieser Anlage.</p></div></div>
<form id="faultRecipientForm" class="fault-recipient-form">
<label>E-Mail-Adresse<input id="faultRecipientEmail" name="email" type="email" maxlength="254" autocomplete="email" placeholder="technik@beispiel.ch" required></label>
<button class="button secondary" type="submit">Hinzufügen</button>
</form>
<p id="faultRecipientError" class="form-error" role="alert"></p>
<div id="faultRecipientList" class="fault-recipient-list"></div>
<p id="faultRecipientStatus" class="fault-recipient-status">Empfänger werden geladen.</p>
</section>
</div>
</section>
<section class="view" id="view-account">
<div class="page-head"><div><p class="eyebrow">Einstellungen</p><h1>Konto und Abrechnung</h1><p>Zugang, Organisation, Personen und Zahlungsverlauf.</p></div><button class="button secondary" id="logoutButton" type="button">Abmelden</button></div>
<div class="account-layout">
<section><h2>Benutzerkonto</h2><dl class="detail-list"><div><dt>Name</dt><dd id="accountName">–</dd></div><div><dt>E-Mail</dt><dd id="accountEmail">–</dd></div><div><dt>Arbeitsbereich</dt><dd id="accountOrg">–</dd></div></dl><label id="accountWorkspaceLabel">Organisation<select id="accountWorkspaceSelect"></select></label></section>
<section id="organizationCreateSection"><h2>Organisation anlegen</h2><form id="organizationForm" class="compact-form"><label>Name<input name="name" required minlength="2"></label><p class="form-error" id="organizationError"></p><button class="button secondary" type="submit">Organisation erstellen</button></form></section>
<section id="invitationSection" class="hidden"><h2>Person einladen</h2><form id="invitationForm" class="compact-form"><label>E-Mail-Adresse<input name="email" type="email" required></label><label>Rolle<select name="role"><option value="operator">Operator</option><option value="admin">Administrator</option><option value="billing">Abrechnung</option><option value="viewer">Lesend</option></select></label><p class="form-error" id="invitationError"></p><button class="button secondary" type="submit">Einladung senden</button></form><div id="pendingInvitations" class="pending-list"></div></section>
<section><h2>Passwort ändern</h2><form id="passwordForm" class="compact-form"><label>Aktuelles Passwort<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>Neues Passwort<input name="newPassword" type="password" autocomplete="new-password" required minlength="10"><small>Mindestens 10 Zeichen.</small></label><label>Neues Passwort wiederholen<input name="newPasswordRepeat" type="password" autocomplete="new-password" required minlength="10"></label><p class="form-error" id="passwordError"></p><button class="button secondary" type="submit">Passwort speichern</button></form></section>
</div>
<section class="work-section billing-orders"><div class="section-head"><div><h2>Abrechnung</h2><p>Zahlungen, Lizenzcodes und PDF-Belege der aktuellen Organisation.</p></div><div class="billing-summary"><span class="status review" id="billingStripeStatus">Stripe Sandbox</span><span id="billingOrg">–</span><span id="stripeProviderState">Prüfung</span></div></div><div class="table-wrap"><table><thead><tr><th>Datum</th><th>System</th><th>Lizenzen</th><th>Gesamt inkl. MwSt.</th><th>Status</th><th>Lizenzcode</th><th>Beleg</th></tr></thead><tbody id="ordersBody"></tbody></table></div></section>
</section>
</main>
</div>
</div>
<dialog id="newLicenseDialog" class="decision-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Neue Lizenz</p><h2>Wie möchtest du starten?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="purchase-paths"><button type="button" data-open-plant><span><strong>Lizenz direkt kaufen</strong><small>Lizenzpaket anlegen und benötigte Module auswählen.</small></span><b>Weiter</b></button><button type="button" data-goto="configurator"><span><strong>System mit Manager konfigurieren</strong><small>Geräte erfassen und passende Lizenzen ermitteln.</small></span><b>Konfigurieren</b></button><button type="button" data-open-billing><span><strong>Abrechnungslizenz lösen</strong><small>Abrechnungsmanager und benötigte Zähler lizenzieren.</small></span><b>Auswählen</b></button></div></div></dialog>
<dialog id="plantDialog"><form id="plantForm"><div class="dialog-head"><div><p class="eyebrow">Direkter Kauf</p><h2>Neue Lizenz vorbereiten</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="field-pair"><label>Bezeichnung<input name="name" required placeholder="z. B. Werk Zürich"></label><label>Typ<select name="type"><option>Gewerbe</option><option>Privat</option><option>Industrie</option></select></label></div><label>Standort<input name="location" required placeholder="Ort, Schweiz"></label><fieldset><legend>Prognosemanager</legend><label class="choice-row"><input type="radio" name="manager" value="manager_standard" checked><span><strong>Ohne Peak Shaving</strong><small id="plantManagerStandardPrice">CHF 308.15 netto</small></span></label><label class="choice-row"><input type="radio" name="manager" value="manager_peak"><span><strong>Mit Peak Shaving</strong><small id="plantManagerPeakPrice">CHF 329.05 netto</small></span></label></fieldset><p class="form-error" id="plantError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button primary" type="submit">Weiter zur Lizenzwahl</button></div></form></dialog>
<dialog id="billingDialog"><form id="billingForm"><div class="dialog-head"><div><p class="eyebrow">Abrechnung</p><h2>Abrechnungslizenz vorbereiten</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div id="billingIdentityFields"><div class="field-pair"><label>Bezeichnung<input name="name" required placeholder="z. B. Abrechnung Werk Zürich"></label><label>Typ<input name="type" value="Abrechnung" readonly></label></div><label>Standort<input name="location" required placeholder="Ort, Schweiz"></label></div><fieldset><legend>Zählerlizenzen</legend><div class="field-pair"><label>Stromzähler<input name="powerMeters" type="number" min="0" max="999" value="0" required><small id="billingPowerPrice">CHF 5.00 netto pro Zähler</small></label><label>Wärme- oder Wasserzähler<input name="auxMeters" type="number" min="0" max="999" value="0" required><small id="billingAuxPrice">CHF 3.00 netto pro Zähler</small></label></div></fieldset><p class="billing-base-price">Grundlizenz: <strong id="billingBasePrice">CHF 245.15 netto</strong>. Zuzüglich MwSt.</p><p class="form-error" id="billingError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button primary" type="submit">Weiter zu Stripe</button></div></form></dialog>
<dialog id="replaceCodeDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Sicherheitsaktion</p><h2>Lizenzcode neu generieren</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Achtung: Der aktuelle Lizenzcode wird ungültig.</strong><p>Für <b id="replaceCodeSystem">dieses System</b> wird ein neuer Code erstellt und ausschließlich als PDF an die Konto-E-Mail-Adresse gesendet.</p><p id="replaceCodeBinding" class="hidden">Die bisherige Installationsbindung wird gelöst. Der Ersatzcode kann anschließend einmalig mit einer neuen Symcon-Installation verbunden werden.</p></div><p class="form-error" id="replaceCodeError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmReplaceCode" type="button">Alten Code ersetzen</button></div></div></dialog>
<dialog id="deleteCodeDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Sicherheitsaktion</p><h2>Lizenzcode löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Sind Sie sicher?</strong><p>Der aktuelle Lizenzcode für <b id="deleteCodeSystem">dieses System</b> wird sofort und unwiderruflich ungültig.</p><p>Die bezahlte Lizenz und eine bestehende Installationsbindung bleiben erhalten. Ein neuer Code kann später wieder per E-Mail erzeugt werden.</p></div><p class="form-error" id="deleteCodeError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeleteCode" type="button">Lizenzcode löschen</button></div></div></dialog>
<dialog id="deletePlantDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Anlage löschen</p><h2>Sind Sie sicher?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Die Anlage wird dauerhaft entfernt.</strong><p>Möchten Sie <b id="deletePlantSystem">diese Anlage</b> wirklich löschen? Konfigurationen und noch nicht bezahlte Bestellungen dieser Anlage werden ebenfalls entfernt.</p></div><p class="form-error" id="deletePlantError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePlant" type="button">Anlage löschen</button></div></div></dialog>
<dialog id="deletePaidDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Zweite Bestätigung</p><h2>Auch Lizenz löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning critical-warning"><strong>Achtung: Für diese Anlage besteht eine bezahlte Lizenz.</strong><p>Sollen <b id="deletePaidSystem">die Anlage</b> und die zugehörigen Lizenzcodes, Berechtigungen, Bestellungen und Sandbox-Zahlungsdaten wirklich endgültig gelöscht werden?</p><p>Diese Aktion kann nicht rückgängig gemacht werden.</p></div><p class="form-error" id="deletePaidError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePaid" type="button">Anlage und Lizenz löschen</button></div></div></dialog>
<script src="/app.js?v=20261006-forecast"></script>
</body>
</html>
@@ -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)); }
}
@@ -0,0 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ENELIX Prognose und Netzfahrplan</title><link rel="stylesheet" href="/netplan-v4.css"></head><body class="forecast-page"><main class="forecast-planner" data-standalone-planner><a href="/">Zurueck zum Portal</a><h1>Prognose und Netzfahrplan</h1><label>Anlage <select id="plants"></select></label><p id="message" role="status"></p><section id="panel"></section></main><script type="module" src="/netplan-v4.js"></script></body></html>
@@ -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; }
}
@@ -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 = '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>.forecast-chart{display:block;width:100%;height:320px}.forecast-chart-legend{border-bottom:1px solid black}</style><link rel="stylesheet" href="/netplan-v4.css"></head><body style="margin:16px"><svg id="legacy-chart" class="forecast-chart"></svg><div id="legacy-legend" class="forecast-chart-legend">Legacy</div><div id="host"></div><script type="module">import {mountPlanner} from "/netplan-v4.js"; window.mountPlanner=mountPlanner; window.planner=mountPlanner(document.querySelector("#host"),{plantId:"test",session:{csrfToken:"test-only"}});</script></body></html>';
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));
}
});
+182
View File
@@ -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(); } };
}
@@ -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');
});
+49 -1
View File
@@ -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)); }
}
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ENELIX Netzfahrplan V4</title><link rel="stylesheet" href="/netplan-v4.css"></head><body><main><a href="/">Zurueck zum Portal</a><h1>Netzfahrplan V4</h1><p class="notice">Schattenbetrieb. Diese Ansicht steuert keine Verbraucher. Die bestehende Regelung bleibt unveraendert.</p><label>Anlage <select id="plants"></select></label><p id="message" role="status"></p><section id="panel"></section></main><script type="module" src="/netplan-v4.js"></script></body></html>
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ENELIX Prognose und Netzfahrplan</title><link rel="stylesheet" href="/netplan-v4.css"></head><body class="forecast-page"><main class="forecast-planner" data-standalone-planner><a href="/">Zurueck zum Portal</a><h1>Prognose und Netzfahrplan</h1><label>Anlage <select id="plants"></select></label><p id="message" role="status"></p><section id="panel"></section></main><script type="module" src="/netplan-v4.js"></script></body></html>
+65 -40
View File
@@ -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 {
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); }
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.');
plants.addEventListener('change', render); await render();
const planner = createPlanner({ message, panel, plants, session });
plants.addEventListener('change', planner.reload); await planner.reload();
} catch (error) { message.textContent = error.message; }
}
@@ -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,
+8 -2
View File
@@ -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)
+19
View File
@@ -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)
+62
View File
@@ -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) {
+11
View File
@@ -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,
+12
View File
@@ -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;