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

Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
This commit is contained in:
dh
2026-10-08 10:02:58 +00:00
parent af69151165
commit 74a2d6c685
82 changed files with 5054 additions and 617 deletions
@@ -4,7 +4,20 @@ import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { join } from 'node:path';
const { chromium } = createRequire(import.meta.url)('playwright');
const require = createRequire(process.env.BROWSER_PACKAGE || import.meta.url);
const { chromium } = require('playwright');
const browserOptions = { headless: true };
if (process.env.BROWSER_EXECUTABLE) {
browserOptions.executablePath = process.env.BROWSER_EXECUTABLE;
const loaded = require('@sparticuz/chromium');
const standalone = loaded.default || loaded;
browserOptions.args = standalone.args.filter(arg => ![
'--single-process', '--disable-web-security', '--allow-running-insecure-content',
'--disable-site-isolation-trials', '--no-sandbox', '--disable-setuid-sandbox'
].includes(arg) && !arg.startsWith('--disable-features='));
} else if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
browserOptions.executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
}
const stamp = minute => new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString();
const state = {
@@ -40,7 +53,7 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let browser;
try {
browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } : {}) });
browser = await chromium.launch(browserOptions);
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/**', async route => {
@@ -52,6 +65,19 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
result.plan.inputQuality.loadBasis = 'house_total';
result.plan.forecastPoints.forEach(point => { point.externalW = null; });
}
const scenario = new URL(request.url()).pathname.split('/')[3];
if (['disabled', 'collecting', 'learning', 'ready', 'active', 'interrupted', 'expired', 'contradictory', 'no-plan', 'hostile-reason'].includes(scenario)) {
const workflowState = scenario === 'expired' || scenario === 'contradictory' ? 'active' : scenario === 'no-plan' ? 'collecting' : scenario === 'hostile-reason' ? 'interrupted' : scenario;
const now = Date.now();
result.workflow = {
state: workflowState, learningEnabled: scenario !== 'disabled', controlRequested: workflowState === 'active',
controlActive: workflowState === 'active' && scenario !== 'contradictory', planningReady: ['ready', 'active'].includes(workflowState),
reportFresh: true, checkedAt: new Date(now).toISOString(), sourceReportAt: new Date(now - 1000).toISOString(),
sourceFreshUntil: new Date(now + (scenario === 'expired' ? -1 : 119000)).toISOString(),
reason: scenario === 'hostile-reason' ? '<img src=x onerror="window.injected=true">' : scenario === 'collecting' ? 'collecting_measurements' : ''
};
if (scenario === 'no-plan') result.plan = null;
}
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(request.method() === 'PUT' ? { revision: 3 } : result) });
});
await page.goto(`http://127.0.0.1:${server.address().port}/`);
@@ -59,10 +85,15 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 6);
assert.equal(await page.locator('#legacy-chart').evaluate(node => getComputedStyle(node).height), '320px');
assert.equal(await page.locator('#legacy-legend').evaluate(node => getComputedStyle(node).borderBottomWidth), '1px');
await page.locator('#legacy-chart, #legacy-legend').evaluateAll(nodes => {
for (const node of nodes) node.style.display = 'none';
});
assert.equal(await page.locator('select#plants').count(), 0);
assert.equal(requests.length, 1);
assert.match(await page.locator('#host').innerText(), /tatsaechliche Anlagenbetrieb wird hier nicht bestaetigt/);
assert.match(await page.locator('#host').innerText(), /Vorhanden; lokale Schutzpruefung/);
assert.equal(await page.locator('.forecast-workflow-state').innerText(), 'Betriebsstatus nicht bestaetigt');
assert.match(await page.locator('#host').innerText(), /berechnete Plaene, keine nachgewiesene Ausfuehrung/);
assert.equal(await page.locator('.forecast-planner details[open]').count(), 0);
assert.deepEqual(await page.locator('.forecast-planner .forecast-chart h3').allTextContents(), ['PV- und Hausprognose', 'Strompreis Bezug und Einspeisung', 'Netzfahrplan', 'Batterieleistung', 'Ladezustand battery', 'Kumulierte Kosten inklusive vollem Peakpreis']);
const chart = page.locator('.forecast-planner .forecast-chart').first();
const slider = chart.locator('input[type=range]');
assert.equal(await slider.getAttribute('max'), '23');
@@ -74,6 +105,7 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
await chart.getByLabel('PV-Prognose', { exact: true }).uncheck();
assert.equal(await chart.locator('dt').count(), 2);
await chart.getByLabel('PV-Prognose', { exact: true }).check();
assert.equal(requests.length, 1, 'chart inspection and visibility never change planner settings or controls');
await chart.locator('svg').scrollIntoViewIfNeeded();
const bounds = await chart.locator('svg').boundingBox();
await page.mouse.move(bounds.x + bounds.width - 20, bounds.y + 70);
@@ -85,13 +117,41 @@ test('embedded planner: responsive charts, keyboard/pointer inspection, toggles
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `no horizontal overflow at ${width}`);
assert.ok((await chart.boundingBox()).height > 320, 'legacy SVG height must not constrain new chart sections');
assert.ok(await chart.locator('polyline').count() >= 3);
if (process.env.FORECAST_SCREENSHOT_DIR) await chart.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-chart-${width}.png`) });
if (process.env.FORECAST_SCREENSHOT_DIR) {
await chart.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-chart-${width}.png`) });
await page.locator('.forecast-workflow').screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-workflow-${width}.png`) });
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-viewport-${width}.png`) });
await page.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-full-${width}.png`), fullPage: true });
}
}
await page.locator('summary').click();
await page.getByText('Erweiterte Planungseinstellungen', { exact: true }).click();
await page.getByRole('button', { name: 'Speichern und neu berechnen', exact: true }).click();
await page.getByRole('status').filter({ hasText: 'Revision 3 gespeichert' }).waitFor();
assert.equal(requests.at(-1).csrf, 'test-only');
assert.equal(requests.some(request => /trial|arm|live/.test(request.url)), false);
const showPlant = async plantId => page.evaluate(async id => {
window.planner.dispose();
window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: id, session: {} });
await window.planner.ready;
}, plantId);
for (const [scenario, label] of Object.entries({ disabled: 'Ausgeschaltet', collecting: 'Sammelt Daten', learning: 'Lernt', ready: 'Bereit', active: 'Aktiv', interrupted: 'Unterbrochen', expired: 'Unterbrochen', contradictory: 'Betriebsstatus nicht bestaetigt', 'no-plan': 'Sammelt Daten' })) {
await showPlant(scenario);
assert.equal(await page.locator('.forecast-workflow-state').innerText(), label, scenario);
assert.equal(await page.locator('.forecast-planner details[open]').count(), 0, 'technical details remain collapsed');
if (scenario === 'no-plan') assert.match(await page.locator('#host').innerText(), /Noch kein Netzfahrplan/);
}
await showPlant('hostile-reason');
assert.equal(await page.locator('.forecast-workflow-reason img').count(), 0);
assert.match(await page.locator('.forecast-workflow-reason').innerText(), /<img/);
assert.equal(await page.evaluate(() => window.injected), undefined);
await page.clock.install();
await showPlant('active');
assert.equal(await page.locator('.forecast-workflow-state').innerText(), 'Aktiv');
await page.clock.fastForward(120000);
assert.equal(await page.locator('.forecast-workflow-state').innerText(), 'Unterbrochen', 'open pages expire the local report without hiding charts');
assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 6);
await page.clock.resume();
await page.evaluate(async () => {
window.planner.dispose();
window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'legacy', session: {} });
+13 -1
View File
@@ -13,6 +13,18 @@
.forecast-planner button:disabled { opacity: .5; cursor: wait; }
.forecast-planner button:focus-visible, .forecast-planner input:focus-visible, .forecast-planner select:focus-visible { outline: 2px solid #305b5b; outline-offset: 3px; }
.forecast-planner-notice { padding: .85rem 1rem; border-left: 4px solid #dce169; background: #f2f6e7; }
.forecast-planner .forecast-workflow { padding: 0 0 1rem; border-bottom: 1px solid #d8e0de; }
.forecast-planner .forecast-workflow-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: 1rem; }
.forecast-planner .forecast-workflow-heading h3 { margin: 0; }
.forecast-planner .forecast-workflow-heading button { width: auto; background: #fff; color: #305b5b; }
.forecast-planner .forecast-workflow-state { display: block; font-size: 1.2rem; line-height: 1.35; font-weight: 600; overflow-wrap: anywhere; color: #305b5b; }
.forecast-planner .forecast-workflow[data-state=interrupted] .forecast-workflow-state { color: #933e3e; }
.forecast-planner .forecast-workflow[data-state=unknown] .forecast-workflow-state { color: #576467; }
.forecast-planner .forecast-workflow p { margin: .65rem 0 0; font-size: .85rem; }
.forecast-planner .forecast-workflow-facts, .forecast-planner .forecast-plan-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin: 1.25rem 0; }
.forecast-planner .forecast-workflow-facts > div, .forecast-planner .forecast-plan-facts > div { min-width: 0; }
.forecast-planner .forecast-workflow-facts dt, .forecast-planner .forecast-plan-facts dt { color: #576467; font-size: .8rem; }
.forecast-planner .forecast-workflow-facts dd, .forecast-planner .forecast-plan-facts dd { margin: .4rem 0 0; font-size: .9rem; line-height: 1.4; font-weight: 600; overflow-wrap: anywhere; }
.forecast-planner table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: .85rem; }
.forecast-planner th, .forecast-planner td { padding: .65rem .5rem; border-bottom: 1px solid #d8e0de; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
.forecast-planner th { width: 40%; font-weight: 600; }
@@ -42,7 +54,7 @@
.forecast-planner .forecast-chart-sr { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 600px) {
.forecast-page main { margin: 0; padding: 1rem; }
.forecast-planner form { grid-template-columns: minmax(0, 1fr); }
.forecast-planner form, .forecast-planner .forecast-workflow-facts, .forecast-planner .forecast-plan-facts { grid-template-columns: minmax(0, 1fr); }
.forecast-planner th { width: 43%; }
.forecast-planner .forecast-chart-slider { grid-template-columns: minmax(0, 1fr); gap: 0; }
.forecast-planner .forecast-chart-inspector dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+94 -34
View File
@@ -1,10 +1,44 @@
import { forecastSeries, renderForecastChart } from './forecast-chart.js';
const WORKFLOW_LABELS = {
disabled: 'Ausgeschaltet', collecting: 'Sammelt Daten', learning: 'Lernt',
ready: 'Bereit', active: 'Aktiv', interrupted: 'Unterbrochen'
};
const WORKFLOW_REASONS = {
manager_control_active: 'Der Manager bestaetigt die aktive Batterieregelung nach Netzfahrplan.',
manager_report_stale: 'Die Manager-Rueckmeldung ist abgelaufen. Aktiver Betrieb ist nicht mehr bestaetigt.',
planning_not_ready: 'Die Planung ist noch nicht bereit. Der Netzfahrplan ist nicht aktiv bestaetigt.',
manager_control_not_active: 'Der Netzfahrplan ist angefordert; der Manager bestaetigt noch keine aktive Regelung.',
learning_disabled: 'Die Datenuebertragung fuer die Prognose ist ausgeschaltet.',
plan_and_model_ready: 'Modell und Plan liegen vor. Die Aktivierung erfolgt im Manager nach lokaler Schutzpruefung.',
awaiting_fresh_plan: 'Das Modell liegt vor. Ein aktueller Netzfahrplan wird noch benoetigt.',
model_training: 'Aus den vorhandenen Messdaten wird das Prognosemodell aufgebaut.',
collecting_measurements: 'Messdaten werden gesammelt. Fuer das erste Modell fehlt noch eine ausreichende Datenbasis.',
setup_required: 'Die Einrichtung von Messquellen und Prognose ist noch nicht vollstaendig.'
};
export function workflowView(workflow, now = Date.now()) {
const confirmed = workflow && Object.hasOwn(WORKFLOW_LABELS, workflow.state)
&& typeof workflow.checkedAt === 'string' && Number.isFinite(Date.parse(workflow.checkedAt))
&& ['learningEnabled', 'controlRequested', 'controlActive', 'planningReady'].every(key => typeof workflow[key] === 'boolean');
const consistent = confirmed && (workflow.state !== 'active' || (workflow.controlActive && workflow.controlRequested && workflow.planningReady))
&& (workflow.state === 'active' || !workflow.controlActive)
&& (workflow.state !== 'ready' || workflow.planningReady);
if (!consistent) return { state: 'unknown', label: 'Betriebsstatus nicht bestaetigt', reason: 'Keine vollstaendige, bestaetigte Manager-Rueckmeldung vorhanden.' };
if (workflow.state === 'active' && (workflow.reportFresh !== true || !workflow.learningEnabled
|| !Number.isFinite(Date.parse(workflow.sourceReportAt)) || !Number.isFinite(Date.parse(workflow.sourceFreshUntil))
|| Date.parse(workflow.sourceReportAt) > now || Date.parse(workflow.sourceFreshUntil) <= now)) {
return { ...workflow, state: 'interrupted', label: WORKFLOW_LABELS.interrupted, controlActive: false, reason: WORKFLOW_REASONS.manager_report_stale };
}
return { ...workflow, label: WORKFLOW_LABELS[workflow.state], reason: Object.hasOwn(WORKFLOW_REASONS, workflow.reason) ? WORKFLOW_REASONS[workflow.reason] : (typeof workflow.reason === 'string' ? workflow.reason : '') };
}
export function createPlanner({ message, panel, plants, session }) {
let generation = 0;
let workflowTimer;
const charts = [];
const element = (tag, text) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e; };
const time = value => value ? new Date(value).toLocaleString('de-CH', { timeZone: 'Europe/Zurich' }) : 'nicht vorhanden';
const time = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString('de-CH', { timeZone: 'Europe/Zurich' }) : 'nicht vorhanden';
const num = (v, digits = 2) => typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'unbekannt';
async function request(url, method = 'GET', body) {
@@ -21,12 +55,20 @@ function select(label, choices, selected) {
for (const [value, caption] of choices) { const option = element('option', caption); option.value = value; field.append(option); }
field.value = selected; wrap.append(field); return { wrap, field };
}
function table(title, rows) {
panel.append(element('h3', title)); const grid = element('table');
function table(title, rows, target = panel) {
target.append(element('h3', title)); const grid = element('table');
for (const [name, value] of rows) { const row = element('tr'); row.append(element('th', name), element('td', String(value))); grid.append(row); }
panel.append(grid);
target.append(grid);
}
function clearCharts() { for (const chart of charts.splice(0)) chart.destroy(); }
function facts(rows, className) {
const list = element('dl'); list.className = className;
for (const [name, value] of rows) { const row = element('div'); row.append(element('dt', name), element('dd', value)); list.append(row); }
return list;
}
function details(caption) {
const node = element('details'); node.className = 'forecast-planner-settings'; node.append(element('summary', caption)); return node;
}
function clearCharts() { clearTimeout(workflowTimer); for (const chart of charts.splice(0)) chart.destroy(); }
function curve(title, points, series, unit, caption) {
charts.push(renderForecastChart(panel, { title, points, series, unit, caption }));
}
@@ -37,23 +79,44 @@ async function render() {
clearCharts(); panel.replaceChildren();
try {
const state = await request(base); if (ticket !== generation) return; clearCharts(); panel.replaceChildren();
const safety = element('p', 'SCHATTENBETRIEB der Serverplanung: Die Serverprognose allein schaltet keine Geraete. Eine lokale Nutzung durch den Forecast Switch braucht eine gesonderte Freigabe. Der tatsaechliche Anlagenbetrieb wird hier nicht bestaetigt.');
let workflow = workflowView(state.workflow);
const status = element('section'); status.className = 'forecast-workflow';
const heading = element('div'); heading.className = 'forecast-workflow-heading';
const title = element('h3', 'Prognose und Netzfahrplan');
const refresh = element('button', 'Status aktualisieren'); refresh.type = 'button'; refresh.addEventListener('click', render);
heading.append(title, refresh);
const stateBody = element('div'); status.append(heading, stateBody);
const showWorkflow = () => {
workflow = workflowView(state.workflow); status.dataset.state = workflow.state;
const badge = element('strong', workflow.label); badge.className = 'forecast-workflow-state';
const reason = element('p', workflow.reason); reason.className = 'forecast-workflow-reason';
stateBody.replaceChildren(badge, reason, facts([
['Prognose lernen', workflow.learningEnabled === undefined ? 'Nicht bestaetigt' : workflow.learningEnabled ? 'Eingeschaltet' : 'Ausgeschaltet'],
['Netzfahrplan aktivieren', workflow.controlRequested === undefined ? 'Nicht bestaetigt' : workflow.controlRequested ? 'Eingeschaltet' : 'Ausgeschaltet'],
['Regelung durch Manager', workflow.controlActive === undefined ? 'Nicht bestaetigt' : workflow.controlActive ? 'Aktiv gemeldet' : 'Kein aktiver Betrieb bestaetigt']
], 'forecast-workflow-facts'), element('p', 'Manager-Rueckmeldung: ' + time(workflow.sourceReportAt)));
};
showWorkflow();
if (workflow.state === 'active') workflowTimer = setTimeout(showWorkflow, Math.min(2147483647, Date.parse(workflow.sourceFreshUntil) - Date.now() + 1));
panel.append(status);
const safety = element('p', 'Der Manager regelt die Batterie auf das Netzziel und korrigiert Abweichungen mit lokalen Messwerten. Geraete- und Netzgrenzen behalten Vorrang. Die Kurven zeigen berechnete Plaene, keine nachgewiesene Ausfuehrung.');
safety.className = 'forecast-planner-notice'; panel.append(safety);
table('Freigabe und Datenstand', [['Serverseitige Probebetriebsfreigabe', state.controlledTrialAuthorized === true ? 'Vorhanden; lokale Schutzpruefung bleibt erforderlich' : state.controlledTrialAuthorized === false ? 'Nicht vorhanden' : 'Nicht gemeldet'], ['Lokale Ausfuehrung', 'In dieser Ansicht nicht nachgewiesen'], ['Status geprueft', time(state.checkedAt)], ['Letzte Planquittierung', state.acknowledgement?.status || 'Nicht vorhanden']]);
const settingsStart = panel.childNodes.length;
const settingsDetails = details('Erweiterte Planungseinstellungen');
const diagnostics = details('Technische Details, Daten und Training');
table('Betriebsnachweis', [['Planungsbereitschaft', workflow.planningReady === undefined ? 'Nicht bestaetigt' : workflow.planningReady ? 'Bereit gemeldet; lokale Schutzpruefung bleibt erforderlich' : 'Noch nicht bereit'], ['Serverstatus geprueft', time(state.checkedAt)], ['Letzte Planquittierung', state.acknowledgement?.status || 'Nicht vorhanden'], ['Bedeutung der Quittierung', 'Planempfang ist kein Ausfuehrungsnachweis']], diagnostics);
const form = element('form');
const family = select('Modellfamilie', [['auto', 'Automatisch – historischer Kostenvergleich'], ...state.families.map(f => [f.key, f.label])], state.settings.family);
const measurement = select('Messdatenbasis', [['verified_only', 'Nur verifizierte Peak-/Viertelstundenwerte'], ['allow_estimates', 'Gekennzeichnete Schaetzungen fuer die Planung zulassen']], state.settings.measurementPolicy || 'verified_only');
const outlook = select('Peakbewertung', [['full_incremental', 'Voller zusaetzlicher Monatstarif'], ['empirical_if_available', 'Restmonats-Szenarien bei ausreichender Historie']], state.settings.peakOutlookPolicy || 'full_incremental');
const dataSource = select('Verbrauchsprognose', [['legacy', 'Bisherige Datenquelle'], ['corrected_profile', 'Bereinigte physische Lastprofile']], state.settings.forecastSource || 'legacy');
const dataSource = select('Verbrauchsprognose', [['legacy', 'Importierte Eingangsprognose'], ['corrected_profile', 'Bereinigte physische Lastprofile']], state.settings.forecastSource || 'legacy');
const datasets = state.dataPipeline?.datasets || [];
const dataset = select('Messdatensatz', [['', 'Datensatz auswaehlen'], ...datasets.map(d => [d.datasetId, d.datasetId + ' (' + d.status + ')'])], state.settings.measurementDataset || '');
const cadence = select('Nachtraining', [['daily', 'Taeglich'], ['weekly', 'Woechentlich']], state.settings.trainingCadence);
const relianceWrap = element('label', 'Gewicht der Restmonatsprognose (0 bis 100 %) '), reliance = element('input');
reliance.type = 'number'; reliance.min = '0'; reliance.max = '100'; reliance.step = '1'; reliance.value = String(100 * (state.settings.peakOutlookReliance ?? .5)); relianceWrap.append(reliance);
const save = element('button', 'Speichern und neu berechnen'), replan = element('button', 'Neu berechnen'), refresh = element('button', 'Status aktualisieren');
save.type = 'submit'; replan.type = refresh.type = 'button';
form.append(family.wrap, dataSource.wrap, dataset.wrap, measurement.wrap, outlook.wrap, relianceWrap, cadence.wrap, save, replan, refresh); panel.append(form);
const save = element('button', 'Speichern und neu berechnen'), replan = element('button', 'Neu berechnen');
save.type = 'submit'; replan.type = 'button';
form.append(family.wrap, dataSource.wrap, dataset.wrap, measurement.wrap, outlook.wrap, relianceWrap, cadence.wrap, save, replan); settingsDetails.append(form);
let revision = state.settings.revision;
form.addEventListener('submit', async event => {
event.preventDefault(); save.disabled = true;
@@ -64,45 +127,42 @@ async function render() {
} catch (error) { if (ticket === generation) message.textContent = error.message; } finally { save.disabled = false; }
});
replan.addEventListener('click', async () => { replan.disabled = true; try { await request(base + '/replan', 'POST', {}); if (ticket === generation) message.textContent = 'Neuberechnung angefordert.'; } catch (error) { if (ticket === generation) message.textContent = error.message; } finally { replan.disabled = false; } });
refresh.addEventListener('click', render);
panel.append(element('p', 'Die Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe. Ohne ausreichende vergleichbare Historie gilt der volle zusaetzliche Monatstarif. Technische und konfigurierte Managergrenzen bleiben verbindlich.'));
panel.append(element('p', 'Fuer bereinigte Lastprofile steuert die Trainingsauswahl den automatischen Modellaufbau. Historische Prognosen bleiben unveraendert. Die wirtschaftliche Modellautomatik erstellt vergleichbare Kosten-Replays aus eingefrorenen Tagesplaenen und spaeter eingegangenen Messwerten. Ohne ausreichende Vergleichstage bleibt die bisherige Familie aktiv.'));
for (const d of datasets) table('Daten und Training: ' + d.datasetId, [['Zustand', d.status], ['Angenommene Aufnahmen', d.records], ['Letzte Aufnahme', time(d.lastCapture)], ['Letzter Serverempfang', time(d.lastReceived)], ['Nutzbare 5-Minutenwerte', d.detail?.usableWindows ?? 0], ['Datenbasis in Stunden', num(d.detail?.usableEquivalentHours)], ['Modellstand', d.detail?.modelId || 'noch in Datensammlung'], ['Letztes Training', time(d.detail?.trainedAt)], ['Validierung', d.detail?.validation?.status || 'noch ausstehend'], ['Messgrundlage', 'Konfigurierte physische Schaetzung; kein unabhaengiger Messnachweis']] );
settingsDetails.append(element('p', 'Die Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe. Ohne ausreichende vergleichbare Historie gilt der volle zusaetzliche Monatstarif. Technische und konfigurierte Managergrenzen bleiben verbindlich.'));
settingsDetails.append(element('p', 'Fuer bereinigte Lastprofile steuert die Trainingsauswahl den automatischen Modellaufbau. Historische Prognosen bleiben unveraendert. Die wirtschaftliche Modellautomatik erstellt vergleichbare Kosten-Replays aus eingefrorenen Tagesplaenen und spaeter eingegangenen Messwerten. Ohne ausreichende Vergleichstage bleibt die bisherige Familie aktiv.'));
for (const d of datasets) table('Daten und Training: ' + d.datasetId, [['Zustand', d.status], ['Angenommene Aufnahmen', d.records], ['Letzte Aufnahme', time(d.lastCapture)], ['Letzter Serverempfang', time(d.lastReceived)], ['Nutzbare 5-Minutenwerte', d.detail?.usableWindows ?? 0], ['Datenbasis in Stunden', num(d.detail?.usableEquivalentHours)], ['Modellstand', d.detail?.modelId || 'noch in Datensammlung'], ['Letztes Training', time(d.detail?.trainedAt)], ['Validierung', d.detail?.validation?.status || 'noch ausstehend'], ['Messgrundlage', 'Konfigurierte physische Schaetzung; kein unabhaengiger Messnachweis']], diagnostics);
if (state.economicComparison?.connected) {
table('Wirtschaftlicher Modellvergleich', [['Gespeicherte Tagesvergleiche', state.economicComparison.cohorts], ['Abgeschlossene Vergleiche', state.economicComparison.completedComparisons.length], ['Methode', 'Eingefrorener Tagesplan mit simulierter Netzregelung'], ['Grenze dieses Vergleichs', 'Eine netzladefaehige Batterie; keine vollstaendige Nachbildung laufender Neuoptimierungen'], ['Bewertung', 'Energiekosten + zusaetzlicher Peak + Verluste + gemeinsame Restenergiebewertung']]);
panel.append(element('p', 'Der SDL-Anteil der historischen Bilanz ist eine gekennzeichnete Schaetzung aus dem damaligen Auftrag. Die Vergleichskosten sind keine nachgewiesenen Einsparungen auf einer Stromrechnung.'));
for (const item of state.economicComparison.completedComparisons) table('Vergleich ' + item.day, Object.entries(item.results).map(([family, result]) => ['Familie ' + family, num(result.costChf) + ' CHF; Datenabdeckung ' + num(100 * result.coverage, 1) + ' %']));
table('Wirtschaftlicher Modellvergleich', [['Gespeicherte Tagesvergleiche', state.economicComparison.cohorts], ['Abgeschlossene Vergleiche', state.economicComparison.completedComparisons.length], ['Methode', 'Eingefrorener Tagesplan mit simulierter Netzregelung'], ['Grenze dieses Vergleichs', 'Eine netzladefaehige Batterie; keine vollstaendige Nachbildung laufender Neuoptimierungen'], ['Bewertung', 'Energiekosten + zusaetzlicher Peak + Verluste + gemeinsame Restenergiebewertung']], diagnostics);
diagnostics.append(element('p', 'Der SDL-Anteil der historischen Bilanz ist eine gekennzeichnete Schaetzung aus dem damaligen Auftrag. Die Vergleichskosten sind keine nachgewiesenen Einsparungen auf einer Stromrechnung.'));
for (const item of state.economicComparison.completedComparisons) table('Vergleich ' + item.day, Object.entries(item.results).map(([family, result]) => ['Familie ' + family, num(result.costChf) + ' CHF; Datenabdeckung ' + num(100 * result.coverage, 1) + ' %']), diagnostics);
}
if (state.maintenance) table('Datenaufbewahrung', [['Archivierung', state.maintenance.status], ['Originale', 'Nach verifizierter Kompression weiterhin wiederherstellbar; externe Sicherung separat betreiben']]);
panel.append(element('p', state.lastRun ? `Rechenstatus: ${state.lastRun.status} ${state.lastRun.detail?.reason || ''}` : 'Noch kein Rechenlauf.'));
if (state.maintenance) table('Datenaufbewahrung', [['Archivierung', state.maintenance.status], ['Originale', 'Nach verifizierter Kompression weiterhin wiederherstellbar; externe Sicherung separat betreiben']], diagnostics);
diagnostics.append(element('p', state.lastRun ? `Rechenstatus: ${state.lastRun.status} ${state.lastRun.detail?.reason || ''}` : 'Noch kein Rechenlauf.'));
if (state.pending) panel.append(element('p', 'Neuberechnung vorgemerkt oder aktiv.'));
for (const [month, basis] of Object.entries(state.peakPlanningBases || {})) table(`Vorlaeufige Peakbasis ${month}`, [['Wert', num(basis.kw) + ' kW'], ['Qualitaet', basis.quality], ['Quelle', basis.source], ['Beobachtet', time(basis.observedAt)], ['Hinweis', basis.notes || 'Kein Abrechnungsnachweis']]);
const settingsDetails = element('details');
settingsDetails.className = 'forecast-planner-settings';
settingsDetails.append(element('summary', 'Planungseinstellungen, Daten und Training'), ...Array.from(panel.childNodes).slice(settingsStart));
for (const [month, basis] of Object.entries(state.peakPlanningBases || {})) table(`Vorlaeufige Peakbasis ${month}`, [['Wert', num(basis.kw) + ' kW'], ['Qualitaet', basis.quality], ['Quelle', basis.source], ['Beobachtet', time(basis.observedAt)], ['Hinweis', basis.notes || 'Kein Abrechnungsnachweis']], diagnostics);
const plan = state.plan;
if (plan) {
if (!state.fresh) panel.append(element('p', 'ACHTUNG: Plan veraltet oder Neuberechnung ausstehend.'));
const quality = plan.inputQuality || {};
const loadBasis = quality.loadBasis === 'base_load' ? 'Bereinigte Basislast' : quality.loadBasis === 'house_total' ? 'Aggregierte Hauslast; SDL-Trennung nicht nachgewiesen' : quality.loadBasis || 'Nicht angegeben';
table('Plan und Quellen', [['Plan-ID', plan.planId], ['Berechnet', time(plan.generatedAt)], ['Rechenstatus', plan.status || 'Nicht vorhanden'], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Lastgrundlage', loadBasis], ['Datenquelle der Lastprognose', quality.dataPipeline ? 'Bereinigte physische Lastprofile' : 'Eingegangene Prognose der angegebenen Modellfamilie'], ['Planungsmodus des Servers', plan.runMode || 'Nicht angegeben'], ['Optimierter Horizont bis', time(plan.validUntil)], ['Prognoseeingang bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]]);
table('Kostenprognose', [['Energiekosten', num(plan.energyCostChf) + ' CHF'], ['Zusaetzliche Peakkosten zum vollen Tarif', num(plan.additionalPeakCostChf) + ' CHF'], ['Peakbewertung unter Restmonats-Szenarien', num(plan.planningPeakCostChf) + ' CHF'], ['Planung mit geschaetzter Peakbasis', plan.peakCostIsEstimate ? 'Ja – kein Abrechnungsnachweis' : 'Nein']]);
panel.append(facts([['Plan berechnet', time(plan.generatedAt)], ['Netzfahrplan bis', time(plan.validUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]], 'forecast-plan-facts'));
table('Plan und Quellen', [['Plan-ID', plan.planId], ['Berechnet', time(plan.generatedAt)], ['Rechenstatus', plan.status || 'Nicht vorhanden'], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Lastgrundlage', loadBasis], ['Datenquelle der Lastprognose', quality.dataPipeline ? 'Bereinigte physische Lastprofile' : 'Eingegangene Prognose der angegebenen Modellfamilie'], ['Planungsmodus des Servers', plan.runMode || 'Nicht angegeben'], ['Optimierter Horizont bis', time(plan.validUntil)], ['Prognoseeingang bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]], diagnostics);
table('Kostenprognose', [['Energiekosten', num(plan.energyCostChf) + ' CHF'], ['Zusaetzliche Peakkosten zum vollen Tarif', num(plan.additionalPeakCostChf) + ' CHF'], ['Peakbewertung unter Restmonats-Szenarien', num(plan.planningPeakCostChf) + ' CHF'], ['Planung mit geschaetzter Peakbasis', plan.peakCostIsEstimate ? 'Ja - kein Abrechnungsnachweis' : 'Nein']], diagnostics);
for (const warning of plan.warnings || []) panel.append(element('p', warning));
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]]);
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]], diagnostics);
const points = plan.points || [];
const forecast = forecastSeries(plan);
const forecastScope = forecast.fullHorizon ? 'Vollstaendige, fuer diesen Plan gespeicherte Eingangsprognose, auch ueber das Ende der bekannten Preise hinaus. Netz- und Batterieplan sowie Kosten bleiben auf den optimierten Zeitraum begrenzt.' : 'Aelterer Plan ohne separat gespeicherte Gesamtprognose: nur Eingangswerte im optimierten Zeitraum verfuegbar.';
curve('PV, Last und externe Leistung', forecast.points, [{ label: 'PV-Prognose', value: p => p.pvW, color: '#757b12' }, { label: quality.loadBasis === 'base_load' ? 'Basislast-Prognose' : 'Last-Prognose', value: p => p.loadW, color: '#4e8181' }, { label: 'Externe Leistung / SDL-Szenario', value: p => quality.loadBasis === 'base_load' ? p.externalW : undefined, color: '#ac4f68', dash: '6 3' }], 'W', forecastScope + ' Externe Leistung ist nur bei nachgewiesener Basislast separat bilanziert; ein fortgeschriebener SDL-Auftrag ist kein veroeffentlichter SDL-Fahrplan.');
curve('Netzleistung', points, [{ label: 'Ohne Optimierung', value: p => p.baselineGridW, color: '#737c7e', dash: '6 3' }, { label: 'Optimiert', value: p => p.gridTargetW, color: '#4e8181' }], 'W', 'Positiv: Netzbezug. Negativ: Einspeisung.');
curve('PV- und Hausprognose', forecast.points, [{ label: 'PV-Prognose', value: p => p.pvW, color: '#757b12' }, { label: quality.loadBasis === 'base_load' ? 'Hausprognose (bereinigte Basislast)' : 'Hausprognose (aggregierte Last)', value: p => p.loadW, color: '#4e8181' }, { label: 'Externe Leistung / SDL-Szenario', value: p => quality.loadBasis === 'base_load' ? p.externalW : undefined, color: '#ac4f68', dash: '6 3' }], 'W', forecastScope + ' Externe Leistung ist nur bei nachgewiesener Basislast separat bilanziert; ein fortgeschriebener SDL-Auftrag ist kein veroeffentlichter SDL-Fahrplan.');
curve('Strompreis Bezug und Einspeisung', points, [{ label: 'Bezugspreis', value: p => p.importPriceChfKwh, digits: 4 }, { label: 'Einspeisepreis', value: p => p.exportPriceChfKwh, digits: 4, dash: '6 3' }], 'CHF/kWh', 'Fuer diesen Plan gespeicherte Preise. Keine Fortschreibung ueber den bekannten Preiszeitraum hinaus.');
curve('Netzfahrplan', points, [{ label: 'Ohne Optimierung', value: p => p.baselineGridW, color: '#737c7e', dash: '6 3' }, { label: 'Netzziel fuer den Manager', value: p => p.gridTargetW, color: '#4e8181' }], 'W', 'Positiv: Netzbezug. Negativ: Einspeisung. Berechnetes Netzziel; keine gemessene Netzleistung.');
curve('Batterieleistung', points, [{ label: 'Batterieplan', value: p => p.batteryTargetW }], 'W', 'Positiv: Laden. Negativ: Entladen.');
const assets = new Set(points.flatMap(point => Object.keys(point.socEndPercent || {})));
for (const asset of assets) curve(`Ladezustand ${asset}`, points, [{ label: 'Geplanter SOC am Intervallende', value: p => p.socEndPercent?.[asset], position: 'end' }], '%');
curve('Energiepreise', points, [{ label: 'Bezugspreis', value: p => p.importPriceChfKwh, digits: 4 }, { label: 'Einspeisepreis', value: p => p.exportPriceChfKwh, digits: 4, dash: '6 3' }], 'CHF/kWh', 'Preise im optimierten Horizont, keine Fortschreibung ueber den bekannten Preiszeitraum hinaus.');
curve('Kumulierte Kosten inklusive vollem Peakpreis', points, [{ label: 'Ohne Optimierung', value: p => p.cumulativeBaselineCashCostChf, position: 'end', color: '#737c7e', dash: '6 3' }, { label: 'Optimiert', value: p => p.cumulativeCashCostChf, position: 'end', color: '#4e8181' }], 'CHF', 'Jeweils am Intervallende. Prognose von Energie- und zusaetzlichen vollen Monatspeakkosten, keine Rechnung und kein Nachweis realisierter Einsparungen.');
panel.append(element('p', 'Restwert der Batterie und Restmonatsbewertung sind keine bereits erzielten Erloese. Der angezeigte Kostenverlauf enthaelt diese nicht; auch Durchsatzkosten sind darin nicht enthalten.'));
}
panel.append(settingsDetails);
if (!plan) settingsDetails.open = true;
else panel.append(element('p', 'Noch kein Netzfahrplan vorhanden. Sobald die erforderlichen Daten und Preise vorliegen, erscheint hier die Berechnungsgrundlage.'));
panel.append(settingsDetails, diagnostics);
message.textContent = '';
} catch (error) { if (ticket === generation) { clearCharts(); panel.replaceChildren(); message.textContent = error.message; } }
}
@@ -117,7 +177,7 @@ export function mountPlanner(container, { plantId, session }) {
return { ...planner, ready: planner.reload() };
}
const standalone = document.querySelector('[data-standalone-planner]');
const standalone = globalThis.document?.querySelector('[data-standalone-planner]');
if (standalone) {
const message = standalone.querySelector('#message'), panel = standalone.querySelector('#panel'), plants = standalone.querySelector('#plants');
try {
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { workflowView } from './netplan-v4.js';
const now = Date.parse('2026-10-08T12:00:00Z');
const report = (state, overrides = {}) => ({
state, learningEnabled: state !== 'disabled', controlRequested: state === 'active',
controlActive: state === 'active', planningReady: ['ready', 'active'].includes(state),
reportFresh: true, checkedAt: new Date(now).toISOString(),
sourceReportAt: new Date(now - 10000).toISOString(), sourceFreshUntil: new Date(now + 110000).toISOString(),
reason: '', ...overrides
});
test('workflow reports all six states without deriving control from a plan', () => {
for (const [state, label] of Object.entries({ disabled: 'Ausgeschaltet', collecting: 'Sammelt Daten', learning: 'Lernt', ready: 'Bereit', active: 'Aktiv', interrupted: 'Unterbrochen' })) {
assert.equal(workflowView(report(state), now).label, label);
}
assert.equal(workflowView(undefined, now).label, 'Betriebsstatus nicht bestaetigt');
assert.equal(workflowView({ plan: { status: 'optimal', runMode: 'shadow' }, acknowledgement: { status: 'shadow_seen' } }, now).state, 'unknown');
});
test('missing, malformed or contradictory reports never claim active control', () => {
const malformed = [null, {}, report('other'), report('active', { checkedAt: 'invalid' }),
report('active', { checkedAt: 123 }), report('active', { controlActive: false }),
report('active', { controlRequested: false }), report('active', { planningReady: false }),
report('ready', { controlActive: true }), report('ready', { planningReady: false }),
report('active', { learningEnabled: 'true' })];
for (const input of malformed) assert.equal(workflowView(input, now).state, 'unknown');
});
test('active requires a fresh manager report and expires at its exact boundary', () => {
for (const overrides of [{ reportFresh: false }, { reportFresh: undefined }, { learningEnabled: false },
{ sourceReportAt: null }, { sourceReportAt: new Date(now + 1).toISOString() },
{ sourceFreshUntil: null }, { sourceFreshUntil: new Date(now).toISOString() },
{ sourceFreshUntil: new Date(now - 1).toISOString() }]) {
const view = workflowView(report('active', overrides), now);
assert.equal(view.state, 'interrupted'); assert.equal(view.controlActive, false);
}
assert.equal(workflowView(report('active'), now + 109999).state, 'active');
assert.equal(workflowView(report('active'), now + 110000).state, 'interrupted');
});
test('known reasons are understandable and unknown reasons remain visible as text', () => {
assert.match(workflowView(report('collecting', { reason: 'collecting_measurements' }), now).reason, /Messdaten werden gesammelt/);
assert.match(workflowView(report('active', { reason: 'manager_control_active' }), now).reason, /Manager bestaetigt/);
const reason = '<script>unexpected_reason</script>';
assert.equal(workflowView(report('interrupted', { reason }), now).reason, reason);
});
test('portal release assets match the canonical planner sources', async () => {
for (const file of ['netplan-v4.js', 'netplan-v4.css', 'netplan-v4.html']) {
const canonical = await readFile(new URL(file, import.meta.url), 'utf8');
const release = await readFile(new URL('../../license/changes/20261006-forecast-integration/public/' + file, import.meta.url), 'utf8');
assert.equal(release, canonical, file);
}
});