feat(forecast): integrate full forecast charts and complete SDL visualization
Tests / test (push) Failing after 52s
Tests / test (push) Failing after 52s
This commit is contained in:
@@ -276,6 +276,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$this->initialisiereLizenzInstallationID();
|
$this->initialisiereLizenzInstallationID();
|
||||||
$this->konfiguriereNetzfahrplanV4Daten();
|
$this->konfiguriereNetzfahrplanV4Daten();
|
||||||
$this->aktualisiereVariablen();
|
$this->aktualisiereVariablen();
|
||||||
|
foreach (ManagerPrognoseFormular::laufzeitAnzeige() as $ident => $name) {
|
||||||
|
$objektID = $this->GetIDForIdent($ident);
|
||||||
|
IPS_SetName($objektID, $name);
|
||||||
|
IPS_SetHidden($objektID, true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->pruefeKonfiguration();
|
$this->pruefeKonfiguration();
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
# ENELIX Netzfahrplan V4 – verbindlicher Gesprächs- und Arbeitskontext
|
# 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
|
## Integration 06.10.2026: Freigabe fuer develop und beta, kein Anlagenrollout
|
||||||
|
|
||||||
Daniel hat nach der Bedienintegration Merge, Commit und Push auf **develop und
|
Daniel hat nach der Bedienintegration Merge, Commit und Push auf **develop und
|
||||||
|
|||||||
@@ -273,8 +273,9 @@ trait ManagerEnergieTrait
|
|||||||
throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.');
|
throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.');
|
||||||
}
|
}
|
||||||
IPS_SetHidden($instanzID, false);
|
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_SetName($instanzID, 'Energy Pie');
|
||||||
IPS_SetPosition($instanzID, 500);
|
IPS_SetPosition($instanzID, 500);
|
||||||
@@ -288,11 +289,35 @@ trait ManagerEnergieTrait
|
|||||||
] as $property => $wert) {
|
] as $property => $wert) {
|
||||||
IPS_SetProperty($instanzID, $property, $wert);
|
IPS_SetProperty($instanzID, $property, $wert);
|
||||||
}
|
}
|
||||||
|
$this->synchronisiereEnergyPieAnteile($instanzID, false);
|
||||||
IPS_ApplyChanges($instanzID);
|
IPS_ApplyChanges($instanzID);
|
||||||
|
|
||||||
return 'Energy Pie wurde erstellt oder aktualisiert.';
|
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
|
private function erstelleEnergiefluss(): string
|
||||||
{
|
{
|
||||||
if (!in_array(self::ENERGY_FLOW_MODULE_ID, IPS_GetModuleList(), true)) {
|
if (!in_array(self::ENERGY_FLOW_MODULE_ID, IPS_GetModuleList(), true)) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ trait ManagerNetzfahrplanV4AktivtestTrait
|
|||||||
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestEnde', 0);
|
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestEnde', 0);
|
||||||
$this->RegisterVariableBoolean(
|
$this->RegisterVariableBoolean(
|
||||||
'NetzfahrplanV4Aktivtest',
|
'NetzfahrplanV4Aktivtest',
|
||||||
'V4 Prognosemanager aktiv (Testbetrieb)',
|
'Netzfahrplansteuerung (Testbetrieb)',
|
||||||
'~Switch',
|
'~Switch',
|
||||||
952
|
952
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ trait ManagerNetzfahrplanV4DatenTrait
|
|||||||
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersion', 0);
|
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersion', 0);
|
||||||
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
|
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
|
||||||
$this->RegisterAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
|
$this->RegisterAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
|
||||||
$this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'V4 Messdaten und Modellversorgung', '', 199);
|
$this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'Prognosedaten und Modellversorgung', '', 199);
|
||||||
$this->RegisterTimer('NetzfahrplanV4DatenErfassen', 0,
|
$this->RegisterTimer('NetzfahrplanV4DatenErfassen', 0,
|
||||||
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4DatenErfassen', true);");
|
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4DatenErfassen', true);");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
|||||||
$this->RegisterAttributeString('NetzfahrplanV4EmpfangStatus', '{}');
|
$this->RegisterAttributeString('NetzfahrplanV4EmpfangStatus', '{}');
|
||||||
$this->RegisterAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
|
$this->RegisterAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
|
||||||
$this->RegisterAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
|
$this->RegisterAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
|
||||||
$this->RegisterVariableString('NetzfahrplanV4Vorschau', 'V4 Sollwert-Vorschau (KEINE Stellfreigabe)', '~HTMLBox', 950);
|
$this->RegisterVariableString('NetzfahrplanV4Vorschau', 'Netzfahrplanvorschau (keine Stellfreigabe)', '~HTMLBox', 950);
|
||||||
$this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'V4 Vorschau Diagnose (nur lesen)', '', 951);
|
$this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'Netzfahrplandiagnose', '', 951);
|
||||||
$this->RegisterTimer('NetzfahrplanV4VorschauAktualisieren', 0,
|
$this->RegisterTimer('NetzfahrplanV4VorschauAktualisieren', 0,
|
||||||
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4VorschauAktualisieren', true);");
|
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4VorschauAktualisieren', true);");
|
||||||
$this->RegisterTimer('NetzfahrplanV4EmpfangAktualisieren', 0,
|
$this->RegisterTimer('NetzfahrplanV4EmpfangAktualisieren', 0,
|
||||||
@@ -256,7 +256,7 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
|||||||
$value = array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null], $value,
|
$value = array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null], $value,
|
||||||
['canDispatch' => false, 'liveEnabled' => false]);
|
['canDispatch' => false, 'liveEnabled' => false]);
|
||||||
$escape = static fn($v): string => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
$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)',
|
foreach (['status' => 'Status', 'planId' => 'Plan-ID', 'family' => 'Modellfamilie', 'step' => 'Zeitschritt (UTC)',
|
||||||
'stepValidUntil' => 'Gueltig bis', 'gridTargetW' => 'Geplantes Netzziel W', 'plannedBatteryW' => 'Geplante Batterie W',
|
'stepValidUntil' => 'Gueltig bis', 'gridTargetW' => 'Geplantes Netzziel W', 'plannedBatteryW' => 'Geplante Batterie W',
|
||||||
'previewBatteryW' => 'Lokal berechnete Batterie W (nicht ausgegeben)', 'reason' => 'Ablehnungsgrund'] as $key => $label) {
|
'previewBatteryW' => 'Lokal berechnete Batterie W (nicht ausgegeben)', 'reason' => 'Ablehnungsgrund'] as $key => $label) {
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ use LogicException;
|
|||||||
/** Form composition only. Existing configuration, control authority and backend stay unchanged. */
|
/** Form composition only. Existing configuration, control authority and backend stay unchanged. */
|
||||||
final class ManagerPrognoseFormular
|
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
|
public static function startErlaubt(array $zustand): bool
|
||||||
{
|
{
|
||||||
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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 @@
|
|||||||
<!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>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const message = document.getElementById('message');
|
import { forecastSeries, renderForecastChart } from './forecast-chart.js';
|
||||||
const panel = document.getElementById('panel');
|
|
||||||
const plants = document.getElementById('plants');
|
export function createPlanner({ message, panel, plants, session }) {
|
||||||
let session, generation = 0;
|
let generation = 0;
|
||||||
|
const charts = [];
|
||||||
const element = (tag, text) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e; };
|
const element = (tag, text) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e; };
|
||||||
const time = value => value ? new Date(value).toLocaleString('de-CH', { timeZone: 'Europe/Zurich' }) : 'nicht vorhanden';
|
const time = value => 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';
|
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); }
|
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);
|
panel.append(grid);
|
||||||
}
|
}
|
||||||
function curve(title, points, series) {
|
function clearCharts() { for (const chart of charts.splice(0)) chart.destroy(); }
|
||||||
panel.append(element('h3', title));
|
function curve(title, points, series, unit, caption) {
|
||||||
if (!points.length) { panel.append(element('p', 'Keine Daten.')); return; }
|
charts.push(renderForecastChart(panel, { title, points, series, unit, caption }));
|
||||||
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)}.`));
|
|
||||||
}
|
}
|
||||||
async function render() {
|
async function render() {
|
||||||
const ticket = ++generation, id = plants.value; if (!id) return;
|
const ticket = ++generation, id = plants.value; if (!id) return;
|
||||||
const base = `/api/plants/${encodeURIComponent(id)}/prognosis/planner-v4`;
|
const base = `/api/plants/${encodeURIComponent(id)}/prognosis/planner-v4`;
|
||||||
message.textContent = 'Lade Planungsstand ...';
|
message.textContent = 'Lade Planungsstand ...';
|
||||||
|
clearCharts(); panel.replaceChildren();
|
||||||
try {
|
try {
|
||||||
const state = await request(base); if (ticket !== generation) return; panel.replaceChildren();
|
const state = await request(base); if (ticket !== generation) return; clearCharts(); panel.replaceChildren();
|
||||||
panel.append(element('p', 'SCHATTENBETRIEB: Die angezeigten Vorgaben steuern keine Geraete.'));
|
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 form = element('form');
|
||||||
const family = select('Modellfamilie', [['auto', 'Automatisch – historischer Kostenvergleich'], ...state.families.map(f => [f.key, f.label])], state.settings.family);
|
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 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;
|
event.preventDefault(); save.disabled = true;
|
||||||
try {
|
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 } });
|
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.`;
|
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);
|
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', '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.'));
|
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.'));
|
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.'));
|
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']]);
|
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;
|
const plan = state.plan;
|
||||||
if (plan) {
|
if (plan) {
|
||||||
if (!state.fresh) panel.append(element('p', 'ACHTUNG: Plan veraltet oder Neuberechnung ausstehend.'));
|
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 warning of plan.warnings || []) panel.append(element('p', warning));
|
||||||
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]]);
|
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]]);
|
||||||
curve('Netzleistung (W)', plan.points, [{ label: 'ohne Optimierung', value: p => p.baselineGridW }, { label: 'optimiert', value: p => p.gridTargetW }]);
|
const points = plan.points || [];
|
||||||
curve('Batterieleistung (W; positiv = Laden)', plan.points, [{ label: 'Batterieplan', value: p => p.batteryTargetW }]);
|
const forecast = forecastSeries(plan);
|
||||||
for (const asset of Object.keys(plan.points[0]?.socEndPercent || {})) curve(`SOC ${asset} (%)`, plan.points, [{ label: 'geplanter SOC', value: p => p.socEndPercent?.[asset] }]);
|
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('Kumulierte Zahlungen inklusive vollem Peakpreis (CHF)', plan.points, [{ label: 'ohne Optimierung', value: p => p.cumulativeBaselineCashCostChf }, { label: 'optimiert', value: p => p.cumulativeCashCostChf }]);
|
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.');
|
||||||
panel.append(element('p', 'Kostenverlaeufe sind Prognosen. Restwert der Batterie und Restmonatsbewertung sind keine bereits erzielten Erloese.'));
|
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 = '';
|
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 {
|
try {
|
||||||
session = await request('/api/session'); if (!session.authenticated) throw new Error('Bitte zuerst im Portal anmelden.');
|
const response = await fetch('/api/session', { credentials: 'same-origin' });
|
||||||
const data = await request('/api/plants');
|
const session = await response.json();
|
||||||
for (const plant of data.plants || []) { if (!plant.installation_id) continue; const option = element('option', plant.name); option.value = plant.id; plants.append(option); }
|
if (!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.');
|
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; }
|
} 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'
|
elif battery_w< -1:intent='export' if p_export>.001 else 'discharge'
|
||||||
else:intent='hold'
|
else:intent='hold'
|
||||||
points.append({'time':utc(step.start).isoformat(),'validUntil':step.end.isoformat(),
|
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,
|
'baselineGridW':float(step.residual_w),'gridTargetW':(p_import-p_export)*1000,
|
||||||
'batteryTargetW':battery_w,'assetTargetsW':targets,'socEndPercent':soc_end,
|
'batteryTargetW':battery_w,'assetTargetsW':targets,'socEndPercent':soc_end,
|
||||||
'pvCurtailmentW':float(x[curtail[i]]*1000),'intent':intent,
|
'pvCurtailmentW':float(x[curtail[i]]*1000),'intent':intent,
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ def price_at(store,plant,side,config,start,end,now):
|
|||||||
|
|
||||||
class AwaitingInput(ValueError):pass
|
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')}
|
values={k:latest(store,plant,k) for k in ('operation','forecast','tariffs')}
|
||||||
for k,v in values.items():
|
for k,v in values.items():
|
||||||
if not v:raise AwaitingInput('Missing '+k+' input')
|
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.
|
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())))
|
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 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)
|
full_end=steps[-1].end;steps=priced_prefix(steps,decision)
|
||||||
if not steps:raise AwaitingInput('No complete published-price billing quarter')
|
if not steps:raise AwaitingInput('No complete published-price billing quarter')
|
||||||
q=quarter_start(decision);elapsed=int((decision-q).total_seconds());past={}
|
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,
|
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'],
|
lookback_days=settings['autoLookbackDays'],minimum_days=settings['autoMinimumDays'],
|
||||||
minimum_coverage=settings['autoMinimumCoverage'],margin_chf=settings['autoSwitchMarginChf'])
|
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']]
|
data['batteries']=[replace(b,roundtrip_efficiency=settings['roundtripEfficiency']) for b in data['batteries']]
|
||||||
result=optimize(**data,config_revision=settings['revision'],family=selection['family'])
|
result=optimize(**data,config_revision=settings['revision'],family=selection['family'])
|
||||||
if result['executable']:
|
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'])})
|
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'])
|
store.publish_shadow(plant,result,settings['revision'],now,claim['sequence'],claim['lease_token'])
|
||||||
economic_replay.capture(store,plant,now,assemble,optimize)
|
economic_replay.capture(store,plant,now,assemble,optimize)
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ def inputs(at=AT):
|
|||||||
return op,forecast,tariff
|
return op,forecast,tariff
|
||||||
|
|
||||||
class OptimizerTest(unittest.TestCase):
|
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):
|
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)
|
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):
|
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))
|
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'}]}
|
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)
|
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):
|
def test_carried_price_does_not_extend_horizon(self):
|
||||||
op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar))
|
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'}]}
|
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')
|
ingest(self.store,AID,'prices',price,AT);self.assertEqual(run_once(self.store,AT)['status'],'awaiting_inputs')
|
||||||
def test_aggregate_load_warning(self):
|
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'])
|
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):
|
def test_extra_credentials_not_stored(self):
|
||||||
op,fc,tar=inputs();op['password']='do-not-store'
|
op,fc,tar=inputs();op['password']='do-not-store'
|
||||||
with self.assertRaises(ValueError):ingest(self.store,AID,'operation',op,AT)
|
with self.assertRaises(ValueError):ingest(self.store,AID,'operation',op,AT)
|
||||||
|
|||||||
@@ -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
|
private function advanceCounters(): void
|
||||||
{
|
{
|
||||||
foreach ([201 => 102.0, 202 => 11.0, 203 => 5.2, 204 => 10.4, 205 => 8.1] as $id => $value) {
|
foreach ([201 => 102.0, 202 => 11.0, 203 => 5.2, 204 => 10.4, 205 => 8.1] as $id => $value) {
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ function fields(array $form): array
|
|||||||
throw new RuntimeException('Forecast panel missing');
|
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);
|
$base = json_decode(file_get_contents(__DIR__ . '/../../Manager/form.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||||
$state = [
|
$state = [
|
||||||
'prognoseAktiv' => true, 'lizenziert' => true, 'senderAktiv' => true, 'empfangAktiv' => true,
|
'prognoseAktiv' => true, 'lizenziert' => true, 'senderAktiv' => true, 'empfangAktiv' => true,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class ManagerSDLRuntime
|
|||||||
public static array $media = [];
|
public static array $media = [];
|
||||||
public static array $writes = [];
|
public static array $writes = [];
|
||||||
public static array $hidden = [];
|
public static array $hidden = [];
|
||||||
|
public static array $logs = [];
|
||||||
private static int $nextID = 10000;
|
private static int $nextID = 10000;
|
||||||
|
|
||||||
public static function reset(): void
|
public static function reset(): void
|
||||||
@@ -21,6 +22,7 @@ final class ManagerSDLRuntime
|
|||||||
self::$media = [];
|
self::$media = [];
|
||||||
self::$writes = [];
|
self::$writes = [];
|
||||||
self::$hidden = [];
|
self::$hidden = [];
|
||||||
|
self::$logs = [];
|
||||||
self::$nextID = 10000;
|
self::$nextID = 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +147,16 @@ function IPS_GetProperty(int $instanceID, string $property)
|
|||||||
return ManagerSDLRuntime::$properties[$instanceID][$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
|
function IPS_SetProperty(int $instanceID, string $property, $value): void
|
||||||
{
|
{
|
||||||
ManagerSDLRuntime::$properties[$instanceID][$property] = $value;
|
ManagerSDLRuntime::$properties[$instanceID][$property] = $value;
|
||||||
|
|||||||
Reference in New Issue
Block a user