Files
Enelix-EMS/services/license/changes/20261006-forecast-integration/public/netplan-v4.js
T
dh 74a2d6c685
Tests / test (push) Successful in 1m1s
feat(workflow): publish verified forecast controls and consolidated docs
Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
2026-10-08 10:02:58 +00:00

199 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { forecastSeries, renderForecastChart } from './forecast-chart.js';
const WORKFLOW_LABELS = {
disabled: 'Ausgeschaltet', collecting: 'Sammelt Daten', learning: 'Lernt',
ready: 'Bereit', active: 'Aktiv', interrupted: 'Unterbrochen'
};
const WORKFLOW_REASONS = {
manager_control_active: 'Der Manager bestaetigt die aktive Batterieregelung nach Netzfahrplan.',
manager_report_stale: 'Die Manager-Rueckmeldung ist abgelaufen. Aktiver Betrieb ist nicht mehr bestaetigt.',
planning_not_ready: 'Die Planung ist noch nicht bereit. Der Netzfahrplan ist nicht aktiv bestaetigt.',
manager_control_not_active: 'Der Netzfahrplan ist angefordert; der Manager bestaetigt noch keine aktive Regelung.',
learning_disabled: 'Die Datenuebertragung fuer die Prognose ist ausgeschaltet.',
plan_and_model_ready: 'Modell und Plan liegen vor. Die Aktivierung erfolgt im Manager nach lokaler Schutzpruefung.',
awaiting_fresh_plan: 'Das Modell liegt vor. Ein aktueller Netzfahrplan wird noch benoetigt.',
model_training: 'Aus den vorhandenen Messdaten wird das Prognosemodell aufgebaut.',
collecting_measurements: 'Messdaten werden gesammelt. Fuer das erste Modell fehlt noch eine ausreichende Datenbasis.',
setup_required: 'Die Einrichtung von Messquellen und Prognose ist noch nicht vollstaendig.'
};
export function workflowView(workflow, now = Date.now()) {
const confirmed = workflow && Object.hasOwn(WORKFLOW_LABELS, workflow.state)
&& typeof workflow.checkedAt === 'string' && Number.isFinite(Date.parse(workflow.checkedAt))
&& ['learningEnabled', 'controlRequested', 'controlActive', 'planningReady'].every(key => typeof workflow[key] === 'boolean');
const consistent = confirmed && (workflow.state !== 'active' || (workflow.controlActive && workflow.controlRequested && workflow.planningReady))
&& (workflow.state === 'active' || !workflow.controlActive)
&& (workflow.state !== 'ready' || workflow.planningReady);
if (!consistent) return { state: 'unknown', label: 'Betriebsstatus nicht bestaetigt', reason: 'Keine vollstaendige, bestaetigte Manager-Rueckmeldung vorhanden.' };
if (workflow.state === 'active' && (workflow.reportFresh !== true || !workflow.learningEnabled
|| !Number.isFinite(Date.parse(workflow.sourceReportAt)) || !Number.isFinite(Date.parse(workflow.sourceFreshUntil))
|| Date.parse(workflow.sourceReportAt) > now || Date.parse(workflow.sourceFreshUntil) <= now)) {
return { ...workflow, state: 'interrupted', label: WORKFLOW_LABELS.interrupted, controlActive: false, reason: WORKFLOW_REASONS.manager_report_stale };
}
return { ...workflow, label: WORKFLOW_LABELS[workflow.state], reason: Object.hasOwn(WORKFLOW_REASONS, workflow.reason) ? WORKFLOW_REASONS[workflow.reason] : (typeof workflow.reason === 'string' ? workflow.reason : '') };
}
export function createPlanner({ message, panel, plants, session }) {
let generation = 0;
let workflowTimer;
const charts = [];
const element = (tag, text) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e; };
const time = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString('de-CH', { timeZone: 'Europe/Zurich' }) : 'nicht vorhanden';
const num = (v, digits = 2) => typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'unbekannt';
async function request(url, method = 'GET', body) {
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, target = panel) {
target.append(element('h3', title)); const grid = element('table');
for (const [name, value] of rows) { const row = element('tr'); row.append(element('th', name), element('td', String(value))); grid.append(row); }
target.append(grid);
}
function facts(rows, className) {
const list = element('dl'); list.className = className;
for (const [name, value] of rows) { const row = element('div'); row.append(element('dt', name), element('dd', value)); list.append(row); }
return list;
}
function details(caption) {
const node = element('details'); node.className = 'forecast-planner-settings'; node.append(element('summary', caption)); return node;
}
function clearCharts() { clearTimeout(workflowTimer); for (const chart of charts.splice(0)) chart.destroy(); }
function curve(title, points, series, unit, caption) {
charts.push(renderForecastChart(panel, { title, points, series, unit, caption }));
}
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();
let workflow = workflowView(state.workflow);
const status = element('section'); status.className = 'forecast-workflow';
const heading = element('div'); heading.className = 'forecast-workflow-heading';
const title = element('h3', 'Prognose und Netzfahrplan');
const refresh = element('button', 'Status aktualisieren'); refresh.type = 'button'; refresh.addEventListener('click', render);
heading.append(title, refresh);
const stateBody = element('div'); status.append(heading, stateBody);
const showWorkflow = () => {
workflow = workflowView(state.workflow); status.dataset.state = workflow.state;
const badge = element('strong', workflow.label); badge.className = 'forecast-workflow-state';
const reason = element('p', workflow.reason); reason.className = 'forecast-workflow-reason';
stateBody.replaceChildren(badge, reason, facts([
['Prognose lernen', workflow.learningEnabled === undefined ? 'Nicht bestaetigt' : workflow.learningEnabled ? 'Eingeschaltet' : 'Ausgeschaltet'],
['Netzfahrplan aktivieren', workflow.controlRequested === undefined ? 'Nicht bestaetigt' : workflow.controlRequested ? 'Eingeschaltet' : 'Ausgeschaltet'],
['Regelung durch Manager', workflow.controlActive === undefined ? 'Nicht bestaetigt' : workflow.controlActive ? 'Aktiv gemeldet' : 'Kein aktiver Betrieb bestaetigt']
], 'forecast-workflow-facts'), element('p', 'Manager-Rueckmeldung: ' + time(workflow.sourceReportAt)));
};
showWorkflow();
if (workflow.state === 'active') workflowTimer = setTimeout(showWorkflow, Math.min(2147483647, Date.parse(workflow.sourceFreshUntil) - Date.now() + 1));
panel.append(status);
const safety = element('p', 'Der Manager regelt die Batterie auf das Netzziel und korrigiert Abweichungen mit lokalen Messwerten. Geraete- und Netzgrenzen behalten Vorrang. Die Kurven zeigen berechnete Plaene, keine nachgewiesene Ausfuehrung.');
safety.className = 'forecast-planner-notice'; panel.append(safety);
const settingsDetails = details('Erweiterte Planungseinstellungen');
const diagnostics = details('Technische Details, Daten und Training');
table('Betriebsnachweis', [['Planungsbereitschaft', workflow.planningReady === undefined ? 'Nicht bestaetigt' : workflow.planningReady ? 'Bereit gemeldet; lokale Schutzpruefung bleibt erforderlich' : 'Noch nicht bereit'], ['Serverstatus geprueft', time(state.checkedAt)], ['Letzte Planquittierung', state.acknowledgement?.status || 'Nicht vorhanden'], ['Bedeutung der Quittierung', 'Planempfang ist kein Ausfuehrungsnachweis']], diagnostics);
const form = element('form');
const family = select('Modellfamilie', [['auto', 'Automatisch – historischer Kostenvergleich'], ...state.families.map(f => [f.key, f.label])], state.settings.family);
const measurement = select('Messdatenbasis', [['verified_only', 'Nur verifizierte Peak-/Viertelstundenwerte'], ['allow_estimates', 'Gekennzeichnete Schaetzungen fuer die Planung zulassen']], state.settings.measurementPolicy || 'verified_only');
const outlook = select('Peakbewertung', [['full_incremental', 'Voller zusaetzlicher Monatstarif'], ['empirical_if_available', 'Restmonats-Szenarien bei ausreichender Historie']], state.settings.peakOutlookPolicy || 'full_incremental');
const dataSource = select('Verbrauchsprognose', [['legacy', 'Importierte Eingangsprognose'], ['corrected_profile', 'Bereinigte physische Lastprofile']], state.settings.forecastSource || 'legacy');
const datasets = state.dataPipeline?.datasets || [];
const dataset = select('Messdatensatz', [['', 'Datensatz auswaehlen'], ...datasets.map(d => [d.datasetId, d.datasetId + ' (' + d.status + ')'])], state.settings.measurementDataset || '');
const cadence = select('Nachtraining', [['daily', 'Taeglich'], ['weekly', 'Woechentlich']], state.settings.trainingCadence);
const relianceWrap = element('label', 'Gewicht der Restmonatsprognose (0 bis 100 %) '), reliance = element('input');
reliance.type = 'number'; reliance.min = '0'; reliance.max = '100'; reliance.step = '1'; reliance.value = String(100 * (state.settings.peakOutlookReliance ?? .5)); relianceWrap.append(reliance);
const save = element('button', 'Speichern und neu berechnen'), replan = element('button', 'Neu berechnen');
save.type = 'submit'; replan.type = 'button';
form.append(family.wrap, dataSource.wrap, dataset.wrap, measurement.wrap, outlook.wrap, relianceWrap, cadence.wrap, save, replan); settingsDetails.append(form);
let revision = state.settings.revision;
form.addEventListener('submit', async event => {
event.preventDefault(); save.disabled = true;
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; } });
settingsDetails.append(element('p', 'Die Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe. Ohne ausreichende vergleichbare Historie gilt der volle zusaetzliche Monatstarif. Technische und konfigurierte Managergrenzen bleiben verbindlich.'));
settingsDetails.append(element('p', 'Fuer bereinigte Lastprofile steuert die Trainingsauswahl den automatischen Modellaufbau. Historische Prognosen bleiben unveraendert. Die wirtschaftliche Modellautomatik erstellt vergleichbare Kosten-Replays aus eingefrorenen Tagesplaenen und spaeter eingegangenen Messwerten. Ohne ausreichende Vergleichstage bleibt die bisherige Familie aktiv.'));
for (const d of datasets) table('Daten und Training: ' + d.datasetId, [['Zustand', d.status], ['Angenommene Aufnahmen', d.records], ['Letzte Aufnahme', time(d.lastCapture)], ['Letzter Serverempfang', time(d.lastReceived)], ['Nutzbare 5-Minutenwerte', d.detail?.usableWindows ?? 0], ['Datenbasis in Stunden', num(d.detail?.usableEquivalentHours)], ['Modellstand', d.detail?.modelId || 'noch in Datensammlung'], ['Letztes Training', time(d.detail?.trainedAt)], ['Validierung', d.detail?.validation?.status || 'noch ausstehend'], ['Messgrundlage', 'Konfigurierte physische Schaetzung; kein unabhaengiger Messnachweis']], diagnostics);
if (state.economicComparison?.connected) {
table('Wirtschaftlicher Modellvergleich', [['Gespeicherte Tagesvergleiche', state.economicComparison.cohorts], ['Abgeschlossene Vergleiche', state.economicComparison.completedComparisons.length], ['Methode', 'Eingefrorener Tagesplan mit simulierter Netzregelung'], ['Grenze dieses Vergleichs', 'Eine netzladefaehige Batterie; keine vollstaendige Nachbildung laufender Neuoptimierungen'], ['Bewertung', 'Energiekosten + zusaetzlicher Peak + Verluste + gemeinsame Restenergiebewertung']], diagnostics);
diagnostics.append(element('p', 'Der SDL-Anteil der historischen Bilanz ist eine gekennzeichnete Schaetzung aus dem damaligen Auftrag. Die Vergleichskosten sind keine nachgewiesenen Einsparungen auf einer Stromrechnung.'));
for (const item of state.economicComparison.completedComparisons) table('Vergleich ' + item.day, Object.entries(item.results).map(([family, result]) => ['Familie ' + family, num(result.costChf) + ' CHF; Datenabdeckung ' + num(100 * result.coverage, 1) + ' %']), diagnostics);
}
if (state.maintenance) table('Datenaufbewahrung', [['Archivierung', state.maintenance.status], ['Originale', 'Nach verifizierter Kompression weiterhin wiederherstellbar; externe Sicherung separat betreiben']], diagnostics);
diagnostics.append(element('p', state.lastRun ? `Rechenstatus: ${state.lastRun.status} ${state.lastRun.detail?.reason || ''}` : 'Noch kein Rechenlauf.'));
if (state.pending) panel.append(element('p', 'Neuberechnung vorgemerkt oder aktiv.'));
for (const [month, basis] of Object.entries(state.peakPlanningBases || {})) table(`Vorlaeufige Peakbasis ${month}`, [['Wert', num(basis.kw) + ' kW'], ['Qualitaet', basis.quality], ['Quelle', basis.source], ['Beobachtet', time(basis.observedAt)], ['Hinweis', basis.notes || 'Kein Abrechnungsnachweis']], diagnostics);
const plan = state.plan;
if (plan) {
if (!state.fresh) panel.append(element('p', 'ACHTUNG: Plan veraltet oder Neuberechnung ausstehend.'));
const quality = plan.inputQuality || {};
const loadBasis = quality.loadBasis === 'base_load' ? 'Bereinigte Basislast' : quality.loadBasis === 'house_total' ? 'Aggregierte Hauslast; SDL-Trennung nicht nachgewiesen' : quality.loadBasis || 'Nicht angegeben';
panel.append(facts([['Plan berechnet', time(plan.generatedAt)], ['Netzfahrplan bis', time(plan.validUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]], 'forecast-plan-facts'));
table('Plan und Quellen', [['Plan-ID', plan.planId], ['Berechnet', time(plan.generatedAt)], ['Rechenstatus', plan.status || 'Nicht vorhanden'], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Lastgrundlage', loadBasis], ['Datenquelle der Lastprognose', quality.dataPipeline ? 'Bereinigte physische Lastprofile' : 'Eingegangene Prognose der angegebenen Modellfamilie'], ['Planungsmodus des Servers', plan.runMode || 'Nicht angegeben'], ['Optimierter Horizont bis', time(plan.validUntil)], ['Prognoseeingang bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]], diagnostics);
table('Kostenprognose', [['Energiekosten', num(plan.energyCostChf) + ' CHF'], ['Zusaetzliche Peakkosten zum vollen Tarif', num(plan.additionalPeakCostChf) + ' CHF'], ['Peakbewertung unter Restmonats-Szenarien', num(plan.planningPeakCostChf) + ' CHF'], ['Planung mit geschaetzter Peakbasis', plan.peakCostIsEstimate ? 'Ja - kein Abrechnungsnachweis' : 'Nein']], diagnostics);
for (const warning of plan.warnings || []) panel.append(element('p', warning));
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]], diagnostics);
const points = plan.points || [];
const forecast = forecastSeries(plan);
const forecastScope = forecast.fullHorizon ? 'Vollstaendige, fuer diesen Plan gespeicherte Eingangsprognose, auch ueber das Ende der bekannten Preise hinaus. Netz- und Batterieplan sowie Kosten bleiben auf den optimierten Zeitraum begrenzt.' : 'Aelterer Plan ohne separat gespeicherte Gesamtprognose: nur Eingangswerte im optimierten Zeitraum verfuegbar.';
curve('PV- und Hausprognose', forecast.points, [{ label: 'PV-Prognose', value: p => p.pvW, color: '#757b12' }, { label: quality.loadBasis === 'base_load' ? 'Hausprognose (bereinigte Basislast)' : 'Hausprognose (aggregierte Last)', value: p => p.loadW, color: '#4e8181' }, { label: 'Externe Leistung / SDL-Szenario', value: p => quality.loadBasis === 'base_load' ? p.externalW : undefined, color: '#ac4f68', dash: '6 3' }], 'W', forecastScope + ' Externe Leistung ist nur bei nachgewiesener Basislast separat bilanziert; ein fortgeschriebener SDL-Auftrag ist kein veroeffentlichter SDL-Fahrplan.');
curve('Strompreis Bezug und Einspeisung', points, [{ label: 'Bezugspreis', value: p => p.importPriceChfKwh, digits: 4 }, { label: 'Einspeisepreis', value: p => p.exportPriceChfKwh, digits: 4, dash: '6 3' }], 'CHF/kWh', 'Fuer diesen Plan gespeicherte Preise. Keine Fortschreibung ueber den bekannten Preiszeitraum hinaus.');
curve('Netzfahrplan', points, [{ label: 'Ohne Optimierung', value: p => p.baselineGridW, color: '#737c7e', dash: '6 3' }, { label: 'Netzziel fuer den Manager', value: p => p.gridTargetW, color: '#4e8181' }], 'W', 'Positiv: Netzbezug. Negativ: Einspeisung. Berechnetes Netzziel; keine gemessene Netzleistung.');
curve('Batterieleistung', points, [{ label: 'Batterieplan', value: p => p.batteryTargetW }], 'W', 'Positiv: Laden. Negativ: Entladen.');
const assets = new Set(points.flatMap(point => Object.keys(point.socEndPercent || {})));
for (const asset of assets) curve(`Ladezustand ${asset}`, points, [{ label: 'Geplanter SOC am Intervallende', value: p => p.socEndPercent?.[asset], position: 'end' }], '%');
curve('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.'));
}
else panel.append(element('p', 'Noch kein Netzfahrplan vorhanden. Sobald die erforderlichen Daten und Preise vorliegen, erscheint hier die Berechnungsgrundlage.'));
panel.append(settingsDetails, diagnostics);
message.textContent = '';
} catch (error) { if (ticket === generation) { clearCharts(); panel.replaceChildren(); message.textContent = error.message; } }
}
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 = globalThis.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; }
}