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
@@ -1,23 +1,23 @@
[
{
"path": "public/app.js",
"before": "b4828a1bd58a5cc29a3970475c8eebadf89a3fda788c269d6951572603fa5dd9",
"after": "95a5a0b0958ad0bd7b444225b90598c2a8c2e92b54631265211bd26b520cdd56"
"before": "95a5a0b0958ad0bd7b444225b90598c2a8c2e92b54631265211bd26b520cdd56",
"after": "6d0e4f9eb3fa8acb73bdc2a07c1a08796ef8a2bba7f0cf4b44965174a3f4aeb8"
},
{
"path": "public/index.html",
"before": "7010e36746b6739d50c13b91cd2ad29de1ced2bc4ca42b49b3f43cc66f9c1ec2",
"after": "26ed3c78ce3deff774f5016357928120d764e434fbe844374656953791d021b0"
"before": "26ed3c78ce3deff774f5016357928120d764e434fbe844374656953791d021b0",
"after": "db5568f87895d4c3a74481d2f0e9e126a7e951fd36056e6337a2635f6019b3e1"
},
{
"path": "public/netplan-v4.js",
"before": "245af626f3322dcaec5afbbc9772707ee6de00f07fd939e59709e7349f2d12d9",
"after": "9c17da0bc6014c1dc09a84bb6015a6af160c59a07c8acda096da9f4e72b3b55d"
"before": "9c17da0bc6014c1dc09a84bb6015a6af160c59a07c8acda096da9f4e72b3b55d",
"after": "b06eea3ae7564cb38b6b11cd4bbcc52274db0a0832faf6df5204c289c33c353d"
},
{
"path": "public/netplan-v4.css",
"before": "f7e55386157aa64db5be62319b9f37e27052f0f001fb28f8233d886d3f9b40b5",
"after": "1189b8d9b7a092bbdff5ec77a99207d1f0fecab56357a44b856ed161ab80cabe"
"before": "1189b8d9b7a092bbdff5ec77a99207d1f0fecab56357a44b856ed161ab80cabe",
"after": "ca3f95d558554ec992deb5123d5bd4a598f5e41a6416cfc2b133d984ddd4e5e0"
},
{
"path": "public/netplan-v4.html",
@@ -28,7 +28,7 @@ const settle = () => new Promise(resolve => setImmediate(resolve));
function node(id, active = false) {
const classes = new Set(active ? ['active'] : []);
return {
id, textContent: '',
id, textContent: '', open: false, dataset: {},
replaceChildren() { this.textContent = ''; },
classList: {
contains(name) { return classes.has(name); },
@@ -41,7 +41,11 @@ function node(id, active = false) {
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));
if (!nodes.has(id)) {
const item = node(id, id === 'view-forecast' && options.active !== false);
if (id === 'forecastHistory') item.open = options.historyOpen === true;
nodes.set(id, item);
}
return nodes.get(id);
};
const calls = { imports: [], api: [], mounted: [], disposed: [], rendered: [], legacyStatus: [], configuration: [] };
@@ -66,14 +70,18 @@ function harness(options = {}) {
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);
vm.runInContext(executableSource + '\nthis.integration = {updateForecast, loadForecastHistory, historicalForecastResult, 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.equal(html.match(/src="\/app\.js[^"\s]*"/)?.[0], 'src="/app.js?v=20261008-workflow"', 'entrypoint version must invalidate the previous mixed forecast app');
assert.match(mountSource, /netplan-v4\.js\?v=20261008-workflow/);
assert.match(html, /<details id="forecastHistory"[^>]*>/);
assert.doesNotMatch(html, /<details id="forecastHistory"[^>]*\bopen\b/);
assert.doesNotMatch(html, /<input[^>]+name="(?:forecast_pv|forecast_load|grid_schedule)"/);
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)['"]/);
@@ -85,20 +93,22 @@ 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);
assert.equal(unlinked.calls.mounted.length, 0); assert.equal(unlinked.calls.api.length, 0);
assert.match(unlinked.byId('forecastPlanner').textContent, /Lizenzcode/);
assert.equal(unlinked.calls.configuration.length, 1, 'tariff and PV configuration remains available');
});
test('changing plants invalidates stale imports and legacy responses', async () => {
test('changing plants invalidates stale imports and explicitly opened history 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 app = harness({ historyOpen: true, 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']);
assert.deepEqual(app.calls.configuration.map(entry => entry.plant), ['A', 'B']);
});
test('leaving the forecast view disposes the mounted planner and invalidates pending imports', async () => {
@@ -122,31 +132,95 @@ test('returning to authentication disposes the planner and invalidates pending r
assert.equal(app.byId('forecastPlanner').textContent, '');
const imported = deferred(), result = deferred();
const pending = harness({ importPlanner: () => imported.promise, api: () => result.promise });
const pending = harness({ historyOpen: true, 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, []);
assert.equal(pending.calls.configuration.length, 1, 'configuration request starts independently and has its own response guard');
});
test('legacy API failures do not clear or prevent the independent planner', async () => {
const app = harness({ api: () => Promise.reject(new Error('legacy unavailable')) });
test('default view loads V4 and configuration without calling the old forecast endpoint', async () => {
const app = harness(); await app.updateForecast(); await settle();
assert.deepEqual(app.calls.mounted, ['A']);
assert.equal(app.calls.api.length, 0); assert.equal(app.calls.rendered.length, 0);
assert.equal(app.calls.configuration.length, 1);
});
test('historical API failures do not clear or prevent the independent planner', async () => {
const app = harness({ historyOpen: true, api: () => Promise.reject(new Error('history 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');
assert.equal(app.calls.legacyStatus.at(-1), 'history 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.equal(app.calls.rendered.length, 0);
assert.equal(app.calls.configuration.length, 1, 'configuration is independent of the planner import');
assert.match(app.byId('forecastPlanner').textContent, /planner unavailable/);
assert.ok(!app.calls.legacyStatus.includes('planner unavailable'));
});
test('history is lazy, cached per plant and range, and never remounts the active planner', async () => {
const app = harness(); await app.updateForecast(); await settle();
assert.equal(app.calls.api.length, 0);
app.byId('forecastHistory').open = true; await app.loadForecastHistory();
assert.equal(app.calls.api.length, 1); assert.equal(app.calls.rendered.length, 1);
await app.loadForecastHistory(); assert.equal(app.calls.api.length, 1);
app.state.forecastHistoryDays = 7; await app.loadForecastHistory();
assert.equal(app.calls.api.length, 2); assert.match(app.calls.api.at(-1), /historyDays=7/);
assert.deepEqual(app.calls.mounted, ['A']); assert.equal(app.calls.disposed.length, 0);
});
test('historical rendering excludes future and legacy schedule curves without mutating stored results', () => {
const now = Date.parse('2026-10-08T12:00:00Z');
const points = [{ time: '2026-10-08T11:00:00Z', value: 1 }, { time: '2026-10-08T13:00:00Z', value: 2 }, { time: 'invalid', value: 3 }];
const result = { scheduleSourceModel: 'prog_var_3', series: [
{ key: 'PV', points }, { key: 'prog_var_1', points }, { key: 'prog_var_3', points }, { key: 'prog_var_3_battery', points }
] };
const output = harness().historicalForecastResult(result, now);
assert.equal(output.scheduleSourceModel, null);
assert.deepEqual(Array.from(output.series, row => row.key), ['PV', 'prog_var_1']);
for (const row of output.series) assert.equal(row.points.length, 1);
assert.equal(result.series.length, 4); assert.equal(points.length, 3); assert.equal(result.scheduleSourceModel, 'prog_var_3');
});
test('tariff changes preserve existing generator flags without recreating legacy operator switches', () => {
const code = section(' var forecastCapabilityVariants =', ' var forecastTariffs =')
+ section(' function forecastGeneratorFlags(', ' var integratedPlanner =');
const context = vm.createContext({ byId() { return { querySelectorAll() { return []; } }; } });
vm.runInContext(code + '\nthis.payload = forecastConfigurationPayload; this.flags = forecastGeneratorFlags;', context);
const flags = { prog_var_1: true, prog_var_2: false, prog_var_3: true, prog_var_10: false, unknown: true };
const form = { dataset: { forecastVariants: JSON.stringify(flags) }, elements: new Proxy({}, { get() { return { value: '1' }; } }) };
const payload = context.payload(form);
assert.equal(payload.prog_var_1, true); assert.equal(payload.prog_var_2, false);
assert.equal(payload.prog_var_3, true); assert.equal(payload.prog_var_10, false);
assert.equal(Object.hasOwn(payload, 'prog_var_13'), false, 'absent flags are not manufactured');
assert.equal(Object.hasOwn(payload, 'unknown'), false);
assert.equal(payload.tarif_bezug_fest, 1); assert.equal(payload.tarif_einspeisung_fest, 1);
});
test('configuration failures cannot expose the previous plant as an editable form', async () => {
const code = section(' async function loadForecastConfiguration(', ' function forecastGeneratorFlags(');
const nodes = new Map();
const byId = id => { if (!nodes.has(id)) nodes.set(id, node(id)); return nodes.get(id); };
const pending = deferred(), state = { forecastRequest: 1 }, filled = [];
const context = vm.createContext({ state, byId, api() { return pending.promise; }, fillForecastConfiguration(plant) { filled.push(plant.id); } });
vm.runInContext(code + '\nthis.load = loadForecastConfiguration;', context);
byId('forecastConfigForm').dataset.plantId = 'old-plant';
const load = context.load({ id: 'new-plant' }, 1);
assert.equal(byId('forecastConfigForm').classList.contains('hidden'), true);
assert.equal(byId('forecastConfigForm').dataset.plantId, '');
pending.reject(new Error('configuration unavailable')); await load;
assert.equal(byId('forecastConfigError').textContent, 'configuration unavailable');
assert.equal(byId('forecastConfigForm').classList.contains('hidden'), true);
assert.equal(filled.length, 0);
});
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();
@@ -845,20 +845,14 @@
schedule: true
});
if (result.status === "license_required") {
byId("forecastApiStatus").textContent = "Keine aktive Prognoselizenz";
return setForecastState("Für dieses System ist eine aktive Prognoselizenz erforderlich.");
}
if (result.status === "not_connected") {
byId("forecastApiStatus").textContent = "Manager noch nicht verbunden";
return setForecastState("Verbinde den Manager zuerst mit dem Lizenzcode dieses Systems.");
}
if (result.status === "no_data") {
byId("forecastApiStatus").textContent = "Verbindung bereit, noch keine Daten";
return setForecastState("Für dieses System liegen im gewählten Zeitfenster noch keine Prognosedaten vor.");
return setForecastState("Für dieses System liegen im gewählten Zeitfenster noch keine historischen Daten vor.");
}
byId("forecastApiStatus").textContent = result.configured
? "Prognose aktiv"
: "Daten empfangen, Konfiguration offen";
renderForecastChart(result.series || []);
}
@@ -1167,13 +1161,7 @@
form.elements.tarif_peak_fest.value = forecastNumber(configuration.tarif_peak_fest, 5);
renderTariffSelector(form, "import", configuration.tarif_bezug_modus, configuration.tarif_bezug, configuration.tarif_bezug_fest, true);
renderTariffSelector(form, "export", configuration.tarif_einspeisung_modus, configuration.tarif_einspeisung, configuration.tarif_einspeisung_fest, true);
Object.entries(forecastCapabilityVariants).forEach(function (entry) {
var capability = entry[0];
var variants = entry[1];
form.elements[capability].checked = variants.some(function (variant) {
return Boolean(configuration["prog_var_" + variant]);
});
});
form.dataset.forecastVariants = JSON.stringify(forecastGeneratorFlags(configuration));
var managerOwned = result.technicalSource === "manager" && Boolean(result.topology);
renderForecastRoofs(configuration.daecher || [], managerOwned);
renderForecastTopology(result.topology || null);
@@ -1192,59 +1180,47 @@
control.disabled = true;
});
}
var licensed = result.forecastCapabilities || {
pv: true,
load: true,
schedule: true
};
[
["forecast_pv", "pv"],
["forecast_load", "load"],
["grid_schedule", "schedule"]
].forEach(function (entry) {
if (!licensed[entry[1]]) {
form.elements[entry[0]].checked = false;
form.elements[entry[0]].disabled = true;
}
});
var licensed = result.forecastCapabilities;
byId("forecastLicensedScope").textContent = licensed ? ([
licensed.pv ? "PV-Ertragsprognose" : "", licensed.load ? "Hausprognose" : "", licensed.schedule ? "Netzfahrplan" : ""
].filter(Boolean).join(" · ") || "Keine Prognosefunktion lizenziert") : "Lizenzumfang nicht bestätigt";
byId("forecastConfigStatus").textContent = managerOwned
? "Technik vom Manager"
: (result.configured ? "Gespeichert" : "Noch nicht gespeichert");
byId("forecastConfigStatus").className = "status " + (result.configured ? "available" : "review");
form.classList.remove("hidden");
byId("forecastConfigSection").classList.remove("hidden");
}
async function loadForecastConfiguration(plant, requestId) {
var form = byId("forecastConfigForm");
form.classList.add("hidden");
form.dataset.plantId = "";
try {
var result = await api("/api/plants/" + plant.id + "/prognosis/configuration");
if (requestId !== state.forecastRequest) return;
fillForecastConfiguration(plant, result);
} catch (error) {
if (requestId !== state.forecastRequest) return;
byId("forecastConfigStatus").textContent = "Nicht verfügbar";
byId("forecastConfigError").textContent = error.message;
byId("forecastConfigSection").classList.remove("hidden");
}
}
function forecastConfigurationPayload(form) {
var selected = {
forecast_pv: form.elements.forecast_pv.checked,
forecast_load: form.elements.forecast_load.checked,
grid_schedule: form.elements.grid_schedule.checked
};
if (selected.grid_schedule) {
selected.forecast_pv = true;
selected.forecast_load = true;
}
if (!Object.values(selected).some(Boolean)) {
throw new Error("Bitte mindestens eine Prognosefunktion aktivieren.");
}
function forecastGeneratorFlags(configuration) {
var variants = {};
Object.entries(forecastCapabilityVariants).forEach(function (entry) {
entry[1].forEach(function (variant) {
variants["prog_var_" + variant] = selected[entry[0]];
var key = "prog_var_" + variant;
if (Object.prototype.hasOwnProperty.call(configuration, key)) variants[key] = configuration[key];
});
});
return variants;
}
function forecastConfigurationPayload(form) {
var variants = forecastGeneratorFlags(JSON.parse(form.dataset.forecastVariants || "{}"));
var roofs = Array.from(byId("forecastRoofs").querySelectorAll(".forecast-roof-row")).map(function (row) {
return {
kwp: Number(row.querySelector("[data-roof-field='kwp']").value),
@@ -1271,13 +1247,15 @@
}
var integratedPlanner = null;
var forecastHistoryRequest = 0;
var forecastHistoryKey = null;
async function loadIntegratedPlanner(plant, requestId) {
var host = byId("forecastPlanner");
if (!host || !plant.installation_id) return;
host.textContent = "Aktueller Netzfahrplan wird geladen.";
try {
var plannerModule = await import("/netplan-v4.js?v=20261006-integrated");
var plannerModule = await import("/netplan-v4.js?v=20261008-workflow");
if (requestId !== state.forecastRequest || !byId("view-forecast").classList.contains("active")) return;
integratedPlanner = plannerModule.mountPlanner(host, { plantId: plant.id, session: state.session });
await integratedPlanner.ready;
@@ -1292,6 +1270,8 @@
if (byId("forecastPlanner")) byId("forecastPlanner").replaceChildren();
var plant = selectedPlant("forecastPlant");
var requestId = ++state.forecastRequest;
++forecastHistoryRequest;
forecastHistoryKey = null;
if (!plant || state.forecastPlantId !== plant.id) {
state.forecastPlantId = plant ? plant.id : null;
state.forecastResult = null;
@@ -1300,27 +1280,51 @@
state.forecastRangeEnd = null;
}
resetForecastChart();
if (!plant) return setForecastState("Kein System verfügbar.");
if (!plant) {
byId("forecastPlanner").textContent = "Kein System verfügbar.";
return;
}
byId("forecastVariant").textContent = managerLabel(plant.manager_variant);
byId("forecastConnection").textContent = plant.installation_id ? "Verbunden mit " + plant.installation_id : "Noch nicht verknüpft";
renderForecastLicenseWarning(plant);
if (!byId("view-forecast").classList.contains("active")) return;
byId("forecastApiStatus").textContent = plant.installation_id
? "Betriebsstatus aus der Manager-Rückmeldung"
: "Manager-Verknüpfung erforderlich";
if (plant.installation_id) void loadIntegratedPlanner(plant, requestId);
else byId("forecastPlanner").textContent = "Verbinde den Manager zuerst mit dem Lizenzcode dieses Systems.";
void loadForecastConfiguration(plant, requestId);
void loadForecastHistory();
}
void loadIntegratedPlanner(plant, requestId);
function historicalForecastResult(result, now) {
var keys = ["PV", "Hausverbrauch", "Netzleistung", "SOC", "prog_var_1", "prog_var_2", "prog_var_10", "prog_var_11", "prog_var_21", "prog_var_22"];
return Object.assign({}, result, {
scheduleSourceModel: null,
series: (result.series || []).filter(function (series) { return keys.includes(series.key); }).map(function (series) {
return Object.assign({}, series, { points: (series.points || []).filter(function (point) {
var stamp = Date.parse(point.time);
return Number.isFinite(stamp) && stamp <= now;
}) });
})
});
}
setForecastState("Prognosedaten werden geladen.");
byId("forecastApiStatus").textContent = "Datenverbindung wird geprüft";
async function loadForecastHistory() {
var plant = selectedPlant("forecastPlant"), requestId = state.forecastRequest;
if (!plant || !byId("view-forecast").classList.contains("active") || !byId("forecastHistory").open) return;
var key = plant.id + ":" + state.forecastHistoryDays;
if (forecastHistoryKey === key) return;
var historyId = ++forecastHistoryRequest;
setForecastState("Historische Messwerte und Modellvergleiche werden geladen.");
try {
var result = await api("/api/plants/" + plant.id + "/prognosis?historyDays=" + encodeURIComponent(state.forecastHistoryDays));
if (requestId !== state.forecastRequest) return;
renderForecastResult(result);
if (["available", "no_data"].includes(result.status)) {
await loadForecastConfiguration(plant, requestId);
}
if (requestId !== state.forecastRequest || historyId !== forecastHistoryRequest) return;
forecastHistoryKey = key;
renderForecastResult(historicalForecastResult(result, Date.now()));
} catch (error) {
if (requestId !== state.forecastRequest) return;
byId("forecastApiStatus").textContent = "Prognosedienst nicht erreichbar";
if (requestId !== state.forecastRequest || historyId !== forecastHistoryRequest) return;
setForecastState(error.message);
}
}
@@ -2167,12 +2171,13 @@
byId("peakUpgrade").addEventListener("change", updateOrder);
byId("orderPlant").addEventListener("change", function () { prepareOrderEditor(); });
byId("forecastPlant").addEventListener("change", updateForecast);
byId("forecastHistory").addEventListener("toggle", loadForecastHistory);
byId("forecastHistoryDays").addEventListener("change", function (event) {
state.forecastHistoryDays = Number(event.target.value) || 1;
state.forecastRangeStart = null;
state.forecastRangeEnd = null;
state.forecastVisibleSeries = null;
updateForecast();
loadForecastHistory();
});
byId("forecastSeriesControls").addEventListener("change", function (event) {
var control = event.target.closest("[data-forecast-series]");
@@ -11,7 +11,7 @@
<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">
<link rel="stylesheet" href="/netplan-v4.css?v=20261008-workflow">
</head>
<body>
<div id="toast" class="toast" role="status"></div>
@@ -215,7 +215,7 @@
</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 class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Prognose und Netzfahrplan</h1><p>Berechnungsgrundlagen und vom Manager gemeldeter Betriebsstatus.</p></div></div>
<div id="forecastEmpty" class="empty-state hidden"><strong>Kein System verfügbar</strong></div>
<div id="forecastContent">
<section class="forecast-toolbar">
@@ -223,13 +223,16 @@
<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>
<details id="forecastHistory" class="config-details">
<summary>Messhistorie und frühere Modellvergleiche</summary>
<p>Vergangene Messwerte und Modellprognosen. Nicht die Grundlage des aktuellen Netzfahrplans.</p>
<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><span id="forecastWindowValue">–</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="section-head"><div><h2>Historischer Vergleich</h2><p>Nur vergangene PV-, Haus-, Netz- und SOC-Werte; frühere Netz- und Batteriepläne sind hier ausgeblendet.</p></div></div>
<div class="forecast-series-panel">
<strong>Kurven auswählen</strong>
<div id="forecastSeriesControls" class="forecast-series-controls"></div>
@@ -266,9 +269,11 @@
</table>
</div>
</section>
<section id="forecastConfigSection" class="forecast-config-section hidden">
</details>
<details id="forecastConfigSection" class="forecast-config-section config-details hidden">
<summary>Anlagendaten und Tarife</summary>
<div class="section-head">
<div><h2>Prognosekonfiguration</h2><p>Anlagenparameter für Berechnung und Tarife.</p></div>
<div><h2>Standort und Tarife</h2><p>Gemeinsame Eingangsdaten für Prognosen und Netzfahrplan.</p></div>
<span id="forecastConfigStatus" class="status review">Nicht gespeichert</span>
</div>
<form id="forecastConfigForm">
@@ -295,20 +300,15 @@
<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 class="config-block-head"><div><h3>Lizenzierter Prognoseumfang</h3><p id="forecastLicensedScope">Wird geprüft.</p></div></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>
<p id="forecastConfigError" class="form-error" role="alert"></p>
</details>
<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>
<section class="forecast-scope"><p class="eyebrow">Regelung</p><h2>Netzfahrplan</h2><dl class="detail-list"><div><dt>Planung</dt><dd>Server</dd></div><div><dt>Ausführung</dt><dd>Manager und Batteriemodul</dd></div><div><dt>Aktivierung</dt><dd>Im Manager</dd></div></dl></section>
</div>
</div>
</section>
@@ -377,6 +377,6 @@
<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>
<script src="/app.js?v=20261008-workflow"></script>
</body>
</html>
@@ -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)); }
@@ -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 {