feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s
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:
@@ -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",
|
||||
|
||||
+88
-14
@@ -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 {
|
||||
|
||||
@@ -48,13 +48,15 @@
|
||||
"compose.yaml": "aae681e18be81633589926db93b27043fca983e255f2f62108ad38df42e1d607",
|
||||
"deploy_integrated_shadow.py": "ec8b324dd5041f89ee84849937f27e9feffb80c6301efe48e9504b326e1f7898",
|
||||
"forecast_acceptance.py": "cc018773c65b61e37f13dfafe2c53b71eec87311d9f3abb514e78cd5917251f9",
|
||||
"gui/netplan-v4.css": "216959a2d90f346a167a4ac809e6cf96c00461abd9853f5c5b5052d6e34d7efd",
|
||||
"gui/forecast-chart.browser-test.mjs": "a6789acb4fd76a3272ee6e91f3193082e7011cd0939f18c9612e88aef42e469a",
|
||||
"gui/netplan-v4.css": "ca3f95d558554ec992deb5123d5bd4a598f5e41a6416cfc2b133d984ddd4e5e0",
|
||||
"gui/netplan-v4.html": "296a247a7a9724dbe8c873372b1d5536ec02ea6eede823c1545e1d8749d64755",
|
||||
"gui/netplan-v4.js": "245af626f3322dcaec5afbbc9772707ee6de00f07fd939e59709e7349f2d12d9",
|
||||
"gui/netplan-v4.js": "b06eea3ae7564cb38b6b11cd4bbcc52274db0a0832faf6df5204c289c33c353d",
|
||||
"gui/netplan-v4.test.mjs": "e29f811baf78c62de08c1da69b61cda491bcc7d26e8efae660f5b60677a3d0fc",
|
||||
"install_hooks.py": "1c16586f970742c994cd0cf9ffb41e921ad34f64fa6043fef89d6bdd686799f3",
|
||||
"integrations/NetzfahrplanV4.php": "8ecb7311c6db5998db5fa1b03bd70536bf1fb29f4fea45d257824c033358baf3",
|
||||
"integrations/netplan-v4-bridge.mjs": "f67c28a148b9233da44be817940425265ca98278dc5e04cd36942432febf8dc7",
|
||||
"integrations/netplan_v4_publisher.py": "cb8efe10d2799215b347985c53c96a6420e5461fff4c4e232b6f918d8bff2161",
|
||||
"integrations/netplan-v4-bridge.mjs": "b2fdb30bf2567a8d3cf39445535325ae4c5009de326bd8306531545270272e93",
|
||||
"integrations/netplan_v4_publisher.py": "e1ad0ad9e11ddf55a18e51d4333e5599a38975e51dd833357ce388f0412cb61a",
|
||||
"netplan_v4/__init__.py": "8fe1793927dcdd2d15d0ce1bd3f53b759c0622d6754b5b8efdbb9764e76fde48",
|
||||
"netplan_v4/battery_model.py": "2142f9173e872da8718ce3f5fa6a0062bd4f2f8ed75d42889bc41e2bd00ef332",
|
||||
"netplan_v4/controlled_trial.py": "4d4bd8ed97050b3bf5872c839e4bcc2fc9dcb9f2e0a8c66a4611bbd50bf9e1c4",
|
||||
@@ -63,7 +65,7 @@
|
||||
"netplan_v4/forecast_quality.py": "b656868538cb822dc1dec97c7726bcc47d78744a1db3ce02a11d259ba9c2a840",
|
||||
"netplan_v4/history_timing.py": "d2992e15d8d1a4f79d068405eefba4d4b37a4326afb09064cfd7c098822ec63f",
|
||||
"netplan_v4/mapping_identity.py": "199bd4b80c618e0d3d8c5f0a7a9168820022cffa403ee6861ac2e3b933f9fc4d",
|
||||
"netplan_v4/measurement_pipeline.py": "32e9b245c0e35f1047f08cd6286193ebcb2db267c11cefe36885bcc3d9c8ca7f",
|
||||
"netplan_v4/measurement_pipeline.py": "12ca01a39815fc5bd52447576be575d32670925d75f3e0bc34eb0b1d14e98b8f",
|
||||
"netplan_v4/meter_runtime.py": "43275072a212351fa35d34ed2f4932a259112d516d594fad19a7e2b3fd44c547",
|
||||
"netplan_v4/metering.py": "9ed4d3747de76f646d7be603cbdf37a3fc971109605705017c6644c0baa80987",
|
||||
"netplan_v4/optimizer.py": "bf1ad3dcadf10c84e76f6758525fc1309b9f665853c660a1107c1347f1ce9063",
|
||||
@@ -71,14 +73,15 @@
|
||||
"netplan_v4/receiver_contract.py": "a43bec2bc2ad8d621f6e85594013b6da3bee1a6ff8ef523f25dafa04672f401c",
|
||||
"netplan_v4/retention.py": "a902024f00fdc22452e391917c6064a9fa30d60c30cebd0cdd192ce8bc1a0cc3",
|
||||
"netplan_v4/selection.py": "8a2fd034b7a74c9d00d12e12cd76da113c29c3458541c89831c25874c98298ad",
|
||||
"netplan_v4/service.py": "80f0c835a8269a78ba62d52dea3da67a664f6b786627ed38ac02674b6f0f491f",
|
||||
"netplan_v4/store.py": "cb6a87e6f08f8f36840e6e1fd0e7b8f09c1bcb012193ce1d8819c5f963eab3b2",
|
||||
"netplan_v4/service.py": "62ed950b4b00c009e40e1cae48aeb1323137bf5a3eceaad68119c0cb9cdad167",
|
||||
"netplan_v4/store.py": "e9a25e5b9cbddd1bf3feb94c335f6218941f5a0a599561df45df11d73b23570b",
|
||||
"netplan_v4/workflow.py": "668109935feb6e64eb4a44e545952849b72dda69a6a721a93b861d6013251e4e",
|
||||
"release_preflight.py": "85755aea15daa4709b29838fa25b96cf4aef0166114a8b8474ef0d04422927d5",
|
||||
"requirements.txt": "0b6febdec6a430645b1a17068c19799f9bc451b0b5b174ea119f026063f92464",
|
||||
"run_tests.py": "17192e693fb8a97be1f0a2f166568d84e86056d4a7ff97f94dcbe1f4391719a4",
|
||||
"runtime_preflight.py": "69b0fa8925c00cbd2399375301c63acc60f6dd5fe1438ad4458eaa07f9d087a1",
|
||||
"tests/manager_protocol.php": "043c763f578d176b09224170690c1fb0a204c7e821b3687fbcf33895355e6f93",
|
||||
"tests/portal.test.mjs": "4e08acda7cbf5eac5b9e3d8d032ca1403250a993ac022e94f440d54f65f90f0a",
|
||||
"tests/portal.test.mjs": "b9cf560a96e3d12ab136f5c4d2cbbcf985a384fed1347c3e87a2110a37c6145f",
|
||||
"tests/test_application_deployment.py": "9fa667e08f9003dd942c6bbb1e8b77321f88b8d4ec2c042f91450e7d34bdeee5",
|
||||
"tests/test_battery_recovery.py": "5185416073b4144f00c643acba48dc93be29d03163de10b1f29a8fd84c191ca3",
|
||||
"tests/test_container_access.py": "181aa47e5c09235ae45a25e3221fc6871bbf89c49ce541599e9b8f7da8570df9",
|
||||
@@ -94,12 +97,15 @@
|
||||
"tests/test_mapping_recovery.py": "5c85eedf7ae780c8efa11ced856a7816ce24df0347d01e1a08e19f6dc101468e",
|
||||
"tests/test_measurement_pipeline.py": "761887486922762dd12a1b3beb00f41e0000ec3cbfe3b101484d0fe8f2364eb4",
|
||||
"tests/test_metering.py": "6c7af6e624c93cde4b6cca00e66fc8fec0b72a047ac77e16778dc1926194a69d",
|
||||
"tests/test_native_measurement_setup.py": "88c7254d7a4e389d5bd6a82af5c536ca095af39ce9422922ae972c36e2320867",
|
||||
"tests/test_peak_release.py": "223f4374111cfab99ef352a71a7b91a7f71abc4ed126f2c180b138d1bd390d3d",
|
||||
"tests/test_receiver_contract.py": "21daccb358fa62a983d2292d5de2b9b3c7300a08e314e850749316a24d3d254e",
|
||||
"tests/test_release_preflight.py": "e9f0dfc3dd752c2cc25908e47781554bf71e2e1af4f4d2514621fab37205ca68",
|
||||
"tests/test_release_preflight.py": "f5ab9623adc7925f744f36980d81c6da67cea89b4f8fd1187bdcb31f30470a96",
|
||||
"tests/test_retention.py": "dfe62da159be4b103643d61c10307395e22612c65017aaadd469d50a65398b51",
|
||||
"tests/test_unified_release.py": "3665b3ffd0872e1956312ac16ab9527802ebc396e52ae0d2954bad53890d5236",
|
||||
"tests/test_v4.py": "c35db22a864aa0afc4d0abc357ae854f038e86de8e3001760c0e63ce036a7763"
|
||||
"tests/test_v4.py": "c35db22a864aa0afc4d0abc357ae854f038e86de8e3001760c0e63ce036a7763",
|
||||
"tests/test_workflow.py": "6dc2d29555774a8c403779853d292f20653d991dae911a0f4f894c6acf213307",
|
||||
"tests/test_workflow_core.py": "a667c7c9b333bd28d3e0c7e8b0a41c45d687a58749f678c82e66fd3bf8eabd29"
|
||||
},
|
||||
"scope": "application_source_only",
|
||||
"productionReady": false
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -26,13 +26,13 @@ export function createPlannerV4Bridge({configuredPrognosisPlant,roleAllowed,body
|
||||
if(method!=='GET'&&!roleAllowed(access.session,['owner','admin','operator'])){json(res,403,{error:'Keine Aenderungsberechtigung.'});return true;}
|
||||
await forward(req,res,access.plant.installation_id,action?`/${action}`:'',method);return true;
|
||||
}
|
||||
const d=url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/planner-v4(?:\/(operation|ack|measurements))?$/i);
|
||||
const d=url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/planner-v4(?:\/(operation|ack|measurements|setup|workflow))?$/i);
|
||||
if(d){
|
||||
const action=d[2]||'';if(req.method!==(action?'POST':'GET')){json(res,405,{error:'Methode nicht erlaubt.'});return true;}
|
||||
if(!checkDeviceRate(req,d[1],'planner-v4')){json(res,429,{error:'Zu viele Anfragen.'});return true;}
|
||||
const activation=deviceActivation(req,res,d[1]);if(!activation)return true;
|
||||
if(Number(ownedLicenseState(activation.plant_id).quantities.grid_schedule||0)<1){json(res,403,{error:'Netzfahrplan nicht lizenziert.'});return true;}
|
||||
await forward(req,res,d[1],action==='operation'?'/inputs/operation':action==='measurements'?'/measurements':action?'/ack':'',req.method);return true;
|
||||
await forward(req,res,d[1],action==='operation'?'/inputs/operation':action?`/${action}`:'',req.method);return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -4,10 +4,12 @@ Legacy UTC-naive forecast indices are explicitly interpreted as UTC here.
|
||||
"""
|
||||
from datetime import datetime,timezone
|
||||
from hashlib import sha256
|
||||
from uuid import uuid4
|
||||
from uuid import UUID,uuid4
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request,urlopen
|
||||
import json,logging,math,os
|
||||
import json,logging,math,os,time
|
||||
|
||||
_enrollment_cache = {'key': None, 'checked': 0., 'plants': set()}
|
||||
|
||||
def timestamp(value):
|
||||
if hasattr(value,'to_pydatetime'):value=value.to_pydatetime()
|
||||
@@ -71,13 +73,42 @@ def ckw_payload(label,rows,publication_timestamp=None,at=None):
|
||||
result['periods']=periods
|
||||
return result
|
||||
|
||||
def enabled(plant):return bool(os.getenv('NETPLAN_V4_URL') and plant in {p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()})
|
||||
def planning_plants():
|
||||
"""Discover enrolled planning tenants; this registry has no actuator authority."""
|
||||
base=os.getenv('NETPLAN_V4_URL','').rstrip('/');token=os.getenv('PROGNOSIS_SERVICE_TOKEN','')
|
||||
if not base:return set()
|
||||
explicit={p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()}
|
||||
if not token:return explicit
|
||||
url=urlparse(base)
|
||||
if url.scheme not in ('http','https') or url.username or url.password:return explicit
|
||||
key=(base,token);now=time.monotonic()
|
||||
if _enrollment_cache['key']==key and now-_enrollment_cache['checked']<60:
|
||||
return explicit|_enrollment_cache['plants']
|
||||
plants=set()
|
||||
try:
|
||||
req=Request(base+'/internal/v2/planner/installations',headers={'X-Enelix-Service-Token':token})
|
||||
with urlopen(req,timeout=5) as response:
|
||||
raw=response.read(500001)
|
||||
if len(raw)>500000:raise ValueError('Oversized enrollment response')
|
||||
data=json.loads(raw)
|
||||
values=data['installationIds']
|
||||
if not isinstance(values,list) or len(values)>10000 or data.get('controlEnabled') is not False:
|
||||
raise ValueError('Invalid planning enrollment response')
|
||||
if any(not isinstance(p,str) or str(UUID(p))!=p for p in values):
|
||||
raise ValueError('Invalid planning installation ID')
|
||||
plants=set(values)
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).warning('V4 planning enrollment unavailable: %s',type(exc).__name__)
|
||||
_enrollment_cache.update(key=key,checked=now,plants=plants)
|
||||
return explicit|plants
|
||||
|
||||
def enabled(plant):return plant in planning_plants()
|
||||
|
||||
def send(plant,kind,payload):
|
||||
if not enabled(plant):return {'status':'disabled'}
|
||||
token=os.getenv('PROGNOSIS_SERVICE_TOKEN','');base=os.environ['NETPLAN_V4_URL'].rstrip('/');url=urlparse(base)
|
||||
if not token or url.scheme not in ('http','https') or url.username or url.password:raise ValueError('Private V4 transport not configured')
|
||||
UUID=__import__('uuid').UUID;UUID(plant)
|
||||
if str(UUID(plant))!=plant:raise ValueError('Canonical installation UUID required')
|
||||
if kind not in ('forecast','tariffs','prices'):raise ValueError('Unsupported publisher input kind')
|
||||
req=Request(base+'/internal/v2/prognosis/'+plant+'/planner/inputs/'+kind,
|
||||
json.dumps(payload,allow_nan=False).encode(),{'Content-Type':'application/json','X-Enelix-Service-Token':token},method='POST')
|
||||
@@ -99,7 +130,7 @@ def publish_tariffs(plant,config):
|
||||
except Exception as exc:logging.getLogger(__name__).warning('V4 shadow tariffs for %s: %s',plant,type(exc).__name__)
|
||||
|
||||
def publish_ckw(label,rows,publication_timestamp=None,tariff_type='integrated'):
|
||||
plants=[p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()]
|
||||
plants=sorted(planning_plants())
|
||||
if not os.getenv('NETPLAN_V4_URL') or not plants:return
|
||||
try:
|
||||
if tariff_type!='integrated':raise ValueError('Full integrated tariff required')
|
||||
|
||||
@@ -144,7 +144,7 @@ def validate_config(c):
|
||||
return c
|
||||
|
||||
|
||||
def register_dataset(con, plant, c, now):
|
||||
def register_dataset(con, plant, c, now, *, own_transaction=True):
|
||||
"""Operator endpoint only; device append endpoint cannot change units or limits."""
|
||||
validate_config(c)
|
||||
if c.get('sourceDatasetId'):
|
||||
@@ -155,15 +155,16 @@ def register_dataset(con, plant, c, now):
|
||||
if canonical(comparable(origin)) != canonical(comparable(c)):
|
||||
raise ValueError('Referenced observations must retain identical measurement meaning')
|
||||
value = canonical(c)
|
||||
con.execute('BEGIN IMMEDIATE')
|
||||
if own_transaction: con.execute('BEGIN IMMEDIATE')
|
||||
try:
|
||||
old = con.execute('SELECT config FROM planner_data_sets WHERE plant=? AND dataset=?', (plant,c['datasetId'])).fetchone()
|
||||
if old and old[0] != value:
|
||||
raise ValueError('Dataset is immutable; use a new datasetId for changed measurement meaning')
|
||||
con.execute('INSERT OR IGNORE INTO planner_data_sets VALUES(?,?,?,?)', (plant,c['datasetId'],value,now))
|
||||
con.commit()
|
||||
if own_transaction: con.commit()
|
||||
except Exception:
|
||||
con.rollback(); raise
|
||||
if own_transaction: con.rollback()
|
||||
raise
|
||||
return {'status':'configured', 'datasetId':c['datasetId'], 'mappingSha256':c['mappingSha256'], 'controlEnabled':False}
|
||||
|
||||
|
||||
@@ -483,11 +484,14 @@ def apply_load_forecast(con,plant,dataset,forecast,decision):
|
||||
last=json.loads(last[0])
|
||||
if decision-last['capturedAt']>120: raise ValueError('Corrected measurements older than 120 seconds')
|
||||
sdl_sources=[s for s in c['sources'] if s['role']=='sdl_request']
|
||||
if len(sdl_sources)!=1: raise ValueError('Explicit SDL request channel needed for the labelled persistence scenario')
|
||||
s=sdl_sources[0];r=last['raw'][s['key']]
|
||||
if not r['valid'] or decision-r['sourceUpdatedAt']>s['maxAgeSeconds']:
|
||||
raise ValueError('No current external SDL request for the persistence scenario')
|
||||
sdl=r['value']*s['factorToW']
|
||||
if len(sdl_sources)>1: raise ValueError('At most one explicit SDL request channel is supported')
|
||||
sdl=0.; external_observed=None; external_policy='no_external_sdl_channel'
|
||||
if sdl_sources:
|
||||
s=sdl_sources[0];r=last['raw'][s['key']]
|
||||
if not r['valid'] or decision-r['sourceUpdatedAt']>s['maxAgeSeconds']:
|
||||
raise ValueError('No current external SDL request for the persistence scenario')
|
||||
sdl=r['value']*s['factorToW'];external_observed=iso(r['sourceUpdatedAt'])
|
||||
external_policy='last_sdl_request_persistence_estimate'
|
||||
result=json.loads(canonical(forecast)); result['families']={}
|
||||
result['observedAt']=iso(max(availability_epoch(forecast['observedAt']),model['trainedAt'],last['capturedAt']))
|
||||
for family,old in forecast['families'].items():
|
||||
@@ -501,7 +505,7 @@ def apply_load_forecast(con,plant,dataset,forecast,decision):
|
||||
'loadMethodVersion':model['methodVersion'],'loadVariant':family,
|
||||
'loadModelTrainedAt':iso(model['trainedAt']),'pvForecastEventId':forecast.get('eventId'),
|
||||
'measurementBasis':'configured_physical_estimate','measurementBoundaryVerified':False,
|
||||
'externalPolicy':'last_sdl_request_persistence_estimate','externalObservedAt':iso(r['sourceUpdatedAt']),
|
||||
'externalPolicy':external_policy,'externalObservedAt':external_observed,
|
||||
'externalPowerW':sdl,'futureSdlPublished':False,'validation':model['validation'],
|
||||
'historyTimingMethod':model.get('historyTimingMethod','strict_expiry'),
|
||||
'observationDatasetId':model.get('observationDatasetId',dataset)}}
|
||||
|
||||
@@ -16,7 +16,7 @@ from .domain import Battery,Limits,Price,QuarterPast,Step,month_key,quarter_star
|
||||
from .store import PlannerStore,canonical
|
||||
from .selection import choose_family
|
||||
from .optimizer import optimize
|
||||
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline
|
||||
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline, workflow
|
||||
from .forecast_quality import assess_family
|
||||
from .receiver_contract import provenance
|
||||
from .peak_policy import basis_record, RestMonthOutlook, empirical_rest_month
|
||||
@@ -198,7 +198,9 @@ def assemble(store,plant,family,now,*,include_forecast_points=False):
|
||||
input_quality['forecastAssessment']=assessment
|
||||
if source.get('dataPipeline'):
|
||||
input_quality['dataPipeline']=source['dataPipeline']
|
||||
input_quality['warnings'].append('Corrected physical-load profile uses configured measurement mapping; external SDL is an explicitly labelled last-request persistence scenario, not a published future SDL schedule')
|
||||
input_quality['warnings'].append('Corrected physical-load profile uses configured measurement mapping')
|
||||
if source['dataPipeline'].get('externalPolicy')=='last_sdl_request_persistence_estimate':
|
||||
input_quality['warnings'].append('External SDL is an explicitly labelled last-request persistence scenario, not a published future SDL schedule')
|
||||
for p in source['points']:
|
||||
start=utc(p['time']);end=start+timedelta(minutes=5)
|
||||
if end<=decision:continue
|
||||
@@ -276,6 +278,7 @@ def run_once(store,now):
|
||||
stamp=int(now.timestamp())//300
|
||||
plants=[r[0] for r in store.con.execute('SELECT plant FROM planner_settings UNION SELECT DISTINCT plant FROM planner_input_current UNION SELECT plant FROM planner_data_sets')]
|
||||
for plant in plants:
|
||||
if not workflow.learning_enabled(store.con,plant):continue
|
||||
config=store.settings(plant)
|
||||
economic_replay.advance(store,plant,now)
|
||||
datasets=[r[0] for r in store.con.execute('SELECT dataset FROM planner_data_sets WHERE plant=?',(plant,))]
|
||||
@@ -291,6 +294,9 @@ def run_once(store,now):
|
||||
if not claim:return {'status':'idle'}
|
||||
plant=claim['plant'];result={'status':'internal_error','executable':False,'points':[]}
|
||||
try:
|
||||
if not workflow.learning_enabled(store.con,plant):
|
||||
result={'status':'learning_disabled','executable':False,'points':[]}
|
||||
return result
|
||||
settings=store.settings(plant);previous=store.current(plant)
|
||||
current=previous['sourceFamily'] if previous else store.registry.entries()[0].key
|
||||
selection=choose_family(settings['family'],current,economic_replay.scores(store,plant,now),registry=store.registry,now=now,
|
||||
@@ -316,16 +322,18 @@ def run_once(store,now):
|
||||
def status(store,plant,now):
|
||||
plan=store.current(plant);settings=store.settings(plant)
|
||||
row=store.con.execute('SELECT * FROM planner_run_status WHERE plant=?',(plant,)).fetchone()
|
||||
pending=store.con.execute('SELECT reasons,requested_at FROM planner_work WHERE plant=?',(plant,)).fetchone()
|
||||
pending=store.con.execute('SELECT revision,reasons,requested_at FROM planner_work WHERE plant=?',(plant,)).fetchone()
|
||||
ack=store.con.execute('SELECT * FROM planner_ack WHERE plant=?',(plant,)).fetchone()
|
||||
fresh=bool(plan and plan['configRevision']==settings['revision'] and utc(plan['validUntil'])>now and 0<=(now-utc(plan['generatedAt'])).total_seconds()<=900 and not pending and row and row['status'] in ('optimal','feasible_time_limit'))
|
||||
return {'receiverProtocolVersion':1,'installationId':plant,'checkedAt':utc(now).isoformat(),'settings':settings,'peakPlanningBases':meter_runtime.assumptions(store.con,plant),'families':[asdict(f) for f in store.registry.entries()],'plan':plan,'fresh':fresh,'pending':dict(pending) if pending else None,'lastRun':{**dict(row),'detail':json.loads(row['detail'])} if row else None,'acknowledgement':dict(ack) if ack else None,'liveEnabled':False,'dataPipeline':measurement_pipeline.pipeline_status(store.con,plant),'economicComparison':economic_replay.status(store,plant),'maintenance':retention.status(store.con)}
|
||||
state={'receiverProtocolVersion':1,'installationId':plant,'checkedAt':utc(now).isoformat(),'settings':settings,'peakPlanningBases':meter_runtime.assumptions(store.con,plant),'families':[asdict(f) for f in store.registry.entries()],'plan':plan,'fresh':fresh,'pending':dict(pending) if pending else None,'lastRun':{**dict(row),'detail':json.loads(row['detail'])} if row else None,'acknowledgement':dict(ack) if ack else None,'liveEnabled':False,'dataPipeline':measurement_pipeline.pipeline_status(store.con,plant),'economicComparison':economic_replay.status(store,plant),'maintenance':retention.status(store.con)}
|
||||
state['workflow']=workflow.view(store,plant,state,now)
|
||||
return state
|
||||
|
||||
def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial_plants=()):
|
||||
allowed={str(UUID(p)) for p in plants}
|
||||
trial_allowed={str(UUID(p)) for p in controlled_trial_plants}
|
||||
if not trial_allowed <= allowed:raise ValueError('Trial allowlist must be a subset of plant allowlist')
|
||||
if not allowed or not service_token or len(service_token)<24:raise ValueError('Private service token and explicit plant allowlist required')
|
||||
if not service_token or len(service_token)<24:raise ValueError('Private service token required')
|
||||
path=Path(db_path).resolve()
|
||||
if path.name in ('users.db','portal.sqlite','settings.json'):raise ValueError('Dedicated planner database required')
|
||||
path.parent.mkdir(parents=True,exist_ok=True);stop=Event()
|
||||
@@ -346,14 +354,38 @@ def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial
|
||||
if start_worker:thread.join(35)
|
||||
app=FastAPI(title='ENELIX V4 - Schattenbetrieb',lifespan=lifespan)
|
||||
app.state.store_factory=factory
|
||||
def authorize(plant,token):
|
||||
def authorize(plant,token,*,onboarding=False):
|
||||
if not secrets.compare_digest(token or '',service_token):raise HTTPException(401,'Unauthorized')
|
||||
try:plant=str(UUID(plant))
|
||||
try:
|
||||
if str(UUID(plant))!=plant:raise ValueError('Canonical UUID required')
|
||||
except ValueError:raise HTTPException(400,'Invalid installation ID')
|
||||
if plant not in allowed:raise HTTPException(403,'Installation not enabled for shadow trial')
|
||||
return factory()
|
||||
s=factory()
|
||||
if not onboarding and plant not in allowed and not workflow.enrolled(s.con,plant):
|
||||
s.close();raise HTTPException(403,'Installation not enabled for planning')
|
||||
return s
|
||||
@app.post('/internal/v2/prognosis/{plant}/planner/setup')
|
||||
def setup(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
|
||||
s=authorize(plant,token,onboarding=True)
|
||||
try:return workflow.setup(s,plant,payload,datetime.now(timezone.utc))
|
||||
except (ValueError,KeyError,TypeError,AttributeError) as exc:raise HTTPException(409 if 'immutable' in str(exc) else 400,str(exc)[:200])
|
||||
finally:s.close()
|
||||
@app.post('/internal/v2/prognosis/{plant}/planner/workflow')
|
||||
def workflow_report(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
|
||||
s=authorize(plant,token)
|
||||
try:return workflow.report(s,plant,payload,datetime.now(timezone.utc))
|
||||
except (ValueError,KeyError,TypeError,AttributeError) as exc:raise HTTPException(400,str(exc)[:200])
|
||||
finally:s.close()
|
||||
@app.get('/health')
|
||||
def health():return {'status':'ok','mode':'shadow','liveEnabled':False,'receiverProtocolVersion':1,'applicationRelease':'unified-rc1','mappingCompatibilityVersion':1,'economicReplayVersion':1,'archiveVersion':1}
|
||||
@app.get('/internal/v2/planner/installations')
|
||||
def planning_installations(token:str=Header(default='',alias='X-Enelix-Service-Token')):
|
||||
if not secrets.compare_digest(token or '',service_token):raise HTTPException(401,'Unauthorized')
|
||||
s=factory()
|
||||
try:
|
||||
registered={r[0] for r in s.con.execute('SELECT plant FROM planner_enrollment WHERE learning_enabled=1')}
|
||||
paused={r[0] for r in s.con.execute('SELECT plant FROM planner_enrollment WHERE learning_enabled=0')}
|
||||
return {'installationIds':sorted((allowed|registered)-paused),'controlEnabled':False}
|
||||
finally:s.close()
|
||||
@app.get('/internal/v2/prognosis/{plant}/planner')
|
||||
def read(plant:str,token:str=Header(default='',alias='X-Enelix-Service-Token')):
|
||||
s=authorize(plant,token)
|
||||
@@ -419,6 +451,7 @@ def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial
|
||||
def measurement_batch(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
|
||||
s=authorize(plant,token)
|
||||
try:
|
||||
if not workflow.learning_enabled(s.con,plant):raise ValueError('Learning is disabled for this installation')
|
||||
now=datetime.now(timezone.utc)
|
||||
result=measurement_pipeline.ingest_batch(s.con,plant,payload,int(now.timestamp()))
|
||||
# Existing five-minute worker handles rollup/training; no per-record optimizer flood.
|
||||
|
||||
@@ -4,7 +4,7 @@ import sqlite3
|
||||
from datetime import datetime,timedelta
|
||||
from uuid import uuid4
|
||||
from .domain import default_registry,month_key,number,quarter_start,utc
|
||||
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline
|
||||
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline, workflow
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value,sort_keys=True,separators=(',',':'),allow_nan=False)
|
||||
@@ -36,6 +36,7 @@ class PlannerStore:
|
||||
economic_replay.schema(self.con)
|
||||
retention.schema(self.con)
|
||||
measurement_pipeline.schema(self.con)
|
||||
workflow.schema(self.con)
|
||||
def close(self):self.con.close()
|
||||
def settings(self,plant):
|
||||
row=self.con.execute('SELECT revision,value FROM planner_settings WHERE plant=?',(plant,)).fetchone()
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Authenticated data onboarding and observed workflow, never actuator authority."""
|
||||
from datetime import timedelta
|
||||
from uuid import UUID
|
||||
import json
|
||||
|
||||
from . import measurement_pipeline
|
||||
from .domain import utc
|
||||
from .receiver_contract import control_context
|
||||
|
||||
REPORT_MAX_AGE_SECONDS = 120
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)
|
||||
|
||||
|
||||
def schema(con):
|
||||
con.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS planner_enrollment(
|
||||
plant TEXT PRIMARY KEY, dataset TEXT NOT NULL, learning_enabled INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS planner_workflow_report(
|
||||
plant TEXT PRIMARY KEY, observed_at TEXT NOT NULL, received_at TEXT NOT NULL,
|
||||
value TEXT NOT NULL);
|
||||
''')
|
||||
|
||||
|
||||
def identity(plant, value):
|
||||
if not isinstance(plant, str) or str(UUID(plant)) != plant:
|
||||
raise ValueError('Canonical installation UUID required')
|
||||
if type(value.get('version')) is not int or value['version'] != 1 or value.get('installationId') != plant:
|
||||
raise ValueError('Installation path/payload mismatch or unsupported version')
|
||||
|
||||
|
||||
def enrolled(con, plant):
|
||||
return con.execute('SELECT * FROM planner_enrollment WHERE plant=?', (plant,)).fetchone()
|
||||
|
||||
|
||||
def learning_enabled(con, plant):
|
||||
row = enrolled(con, plant)
|
||||
if row is not None:
|
||||
return bool(row['learning_enabled'])
|
||||
report_row = con.execute('SELECT value FROM planner_workflow_report WHERE plant=?', (plant,)).fetchone()
|
||||
return report_row is None or json.loads(report_row['value'])['learningEnabled']
|
||||
|
||||
|
||||
def setup(store, plant, value, now):
|
||||
identity(plant, value)
|
||||
if set(value) != {'version', 'installationId', 'dataset', 'learningEnabled'} or type(value['learningEnabled']) is not bool:
|
||||
raise ValueError('Explicit dataset and boolean learningEnabled required')
|
||||
config = measurement_pipeline.validate_config(value['dataset'])
|
||||
# Compatibility proofs and relaxed history-timing policies remain operator-only.
|
||||
existing = store.con.execute('SELECT config FROM planner_data_sets WHERE plant=? AND dataset=?', (plant, config['datasetId'])).fetchone()
|
||||
if not existing and (config.get('sourceDatasetId') or config.get('historyTimingPolicy')):
|
||||
raise ValueError('Device setup cannot grant history compatibility or timing exceptions')
|
||||
if not existing and (config['minimumCoverage'] < .95 or config['maximumGapSeconds'] > 5 or config['minimumTrainingHours'] < 24):
|
||||
raise ValueError('New datasets require coverage >= .95, gap <= 5s and training >= 24h')
|
||||
con = store.con
|
||||
con.execute('BEGIN IMMEDIATE')
|
||||
try:
|
||||
measurement_pipeline.register_dataset(con, plant, config, int(now.timestamp()), own_transaction=False)
|
||||
current = store.settings(plant)
|
||||
initialized = con.execute('SELECT 1 FROM planner_settings WHERE plant=?', (plant,)).fetchone() is None
|
||||
if initialized:
|
||||
# Native interval estimates retain explicit provenance and gap checks;
|
||||
# this is a planning policy, never billing or actuator evidence.
|
||||
current.update(forecastSource='corrected_profile', measurementDataset=config['datasetId'], trainingCadence='daily', measurementPolicy='allow_estimates')
|
||||
current.pop('revision')
|
||||
store._validate_settings(current)
|
||||
con.execute('INSERT INTO planner_settings VALUES(?,?,?)', (plant, 1, canonical(current)))
|
||||
store._request(plant, 1, 'onboarding', now)
|
||||
con.execute('INSERT INTO planner_audit(plant,at,kind,detail) VALUES(?,?,?,?)',
|
||||
(plant, utc(now).isoformat(), 'onboarding', canonical({'datasetId': config['datasetId'], 'controlEnabled': False})))
|
||||
stamp = utc(now).isoformat()
|
||||
con.execute('''INSERT INTO planner_enrollment VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(plant) DO UPDATE SET dataset=excluded.dataset,
|
||||
learning_enabled=excluded.learning_enabled,updated_at=excluded.updated_at''',
|
||||
(plant, config['datasetId'], int(value['learningEnabled']), stamp, stamp))
|
||||
con.commit()
|
||||
except Exception:
|
||||
con.rollback()
|
||||
raise
|
||||
settings = store.settings(plant)
|
||||
return {'status': 'configured', 'installationId': plant, 'datasetId': config['datasetId'],
|
||||
'selectedDatasetId': settings['measurementDataset'], 'settingsInitialized': initialized,
|
||||
'settingsRevision': settings['revision'], 'learningEnabled': value['learningEnabled'],
|
||||
'controlEnabled': False, 'controlledTrialAuthorized': False}
|
||||
|
||||
|
||||
def report(store, plant, value, now):
|
||||
identity(plant, value)
|
||||
if set(value) != {'version', 'installationId', 'observedAt', 'learningEnabled', 'controlRequested', 'controlActive', 'reason'}:
|
||||
raise ValueError('Explicit workflow report required')
|
||||
for key in ('learningEnabled', 'controlRequested', 'controlActive'):
|
||||
if type(value[key]) is not bool:
|
||||
raise ValueError('Workflow flags must be boolean')
|
||||
reason = value['reason']
|
||||
if not isinstance(reason, str) or len(reason) > 300 or any(ord(c) < 32 for c in reason):
|
||||
raise ValueError('Workflow reason must be plain text up to 300 characters')
|
||||
observed = measurement_pipeline.epoch(value['observedAt'])
|
||||
if not -30 <= now.timestamp() - observed <= REPORT_MAX_AGE_SECONDS:
|
||||
raise ValueError('Fresh whole-second UTC workflow report required')
|
||||
if value['controlActive'] and not (value['learningEnabled'] and value['controlRequested']):
|
||||
raise ValueError('Active control requires learning and requested control')
|
||||
normalized = {**value, 'observedAt': measurement_pipeline.iso(observed)}
|
||||
data = canonical(normalized)
|
||||
con = store.con
|
||||
con.execute('BEGIN IMMEDIATE')
|
||||
try:
|
||||
old = con.execute('SELECT observed_at,value FROM planner_workflow_report WHERE plant=?', (plant,)).fetchone()
|
||||
if old and utc(old['observed_at']).timestamp() > observed:
|
||||
raise ValueError('Workflow report moved backwards')
|
||||
if old and utc(old['observed_at']).timestamp() == observed:
|
||||
if old['value'] != data:
|
||||
raise ValueError('Conflicting workflow report at the same time')
|
||||
con.commit()
|
||||
return {'status': 'duplicate', 'controlEnabled': False}
|
||||
con.execute('''INSERT INTO planner_workflow_report VALUES(?,?,?,?)
|
||||
ON CONFLICT(plant) DO UPDATE SET observed_at=excluded.observed_at,
|
||||
received_at=excluded.received_at,value=excluded.value''',
|
||||
(plant, normalized['observedAt'], utc(now).isoformat(), data))
|
||||
# The authenticated first switch also pauses autonomous training. A stop
|
||||
# remains effective after report expiry; stale telemetry cannot re-enable it.
|
||||
registration = enrolled(con, plant)
|
||||
if registration and observed >= int(utc(registration['updated_at']).timestamp()):
|
||||
con.execute('UPDATE planner_enrollment SET learning_enabled=?,updated_at=? WHERE plant=?',
|
||||
(int(value['learningEnabled']), utc(now).isoformat(), plant))
|
||||
con.commit()
|
||||
except Exception:
|
||||
con.rollback()
|
||||
raise
|
||||
return {'status': 'recorded', 'controlEnabled': False}
|
||||
|
||||
|
||||
def planning_continuity(store, plant, state, now):
|
||||
"""Keep readiness during routine telemetry replans, never renew a receipt.
|
||||
|
||||
This does not change receiver ``fresh``. The Manager must still validate its
|
||||
originally accepted envelope and stop when that local envelope expires.
|
||||
"""
|
||||
if state['fresh']:
|
||||
return True
|
||||
try:
|
||||
pending = state['pending']
|
||||
plan = state['plan']
|
||||
settings = state['settings']
|
||||
run = state['lastRun']
|
||||
if not pending or not plan or not run:
|
||||
return False
|
||||
reasons = json.loads(pending['reasons'])
|
||||
if not isinstance(reasons, list) or not reasons or not set(reasons) <= {'operation_changed', 'telemetry_changed', 'five_minute_tick'}:
|
||||
return False
|
||||
revision = settings['revision']
|
||||
if pending['revision'] != revision or plan['configRevision'] != revision:
|
||||
return False
|
||||
if run['status'] not in ('optimal', 'feasible_time_limit') or run['detail'].get('planId') != plan['planId']:
|
||||
return False
|
||||
if run['detail'].get('configRevision') != revision:
|
||||
return False
|
||||
if not utc(plan['validFrom']) <= now < utc(plan['validUntil']) or not 0 <= (now-utc(plan['generatedAt'])).total_seconds() <= 900:
|
||||
return False
|
||||
rows = store.con.execute('''SELECT i.kind,i.value FROM planner_inputs i
|
||||
JOIN planner_input_current c ON i.plant=c.plant AND i.kind=c.kind AND i.event_id=c.event_id
|
||||
WHERE i.plant=? AND i.kind IN ('operation','forecast','tariffs')''', (plant,))
|
||||
inputs = {row['kind']: json.loads(row['value']) for row in rows}
|
||||
if any(plan['inputRefs'][kind] != inputs[kind]['eventId'] for kind in ('forecast', 'tariffs')):
|
||||
return False
|
||||
operation = inputs['operation']
|
||||
if not 0 <= (now-utc(operation['observedAt'])).total_seconds() <= 120:
|
||||
return False
|
||||
return control_context(operation) == plan['controlContext']
|
||||
except (KeyError, TypeError, ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def view(store, plant, state, now):
|
||||
settings = state['settings']
|
||||
registration = enrolled(store.con, plant)
|
||||
row = store.con.execute('SELECT value FROM planner_workflow_report WHERE plant=?', (plant,)).fetchone()
|
||||
report_value = json.loads(row['value']) if row else None
|
||||
source = utc(report_value['observedAt']) if report_value else None
|
||||
report_fresh = bool(source and 0 <= (now-source).total_seconds() <= REPORT_MAX_AGE_SECONDS)
|
||||
learning = bool(registration['learning_enabled']) if registration else bool(report_fresh and report_value['learningEnabled'])
|
||||
requested = bool(report_value and report_value['controlRequested'])
|
||||
dataset = settings['measurementDataset']
|
||||
model = measurement_pipeline.current_model(store.con, plant, dataset, int(now.timestamp())) if dataset else None
|
||||
pipeline = next((d for d in state['dataPipeline']['datasets'] if d['datasetId'] == dataset), {})
|
||||
plan = state.get('plan') or {}
|
||||
selected = plan.get('inputQuality', {}).get('dataPipeline', {})
|
||||
ready = bool(settings['forecastSource'] == 'corrected_profile' and model and planning_continuity(store, plant, state, now)
|
||||
and plan.get('executable') is True and selected.get('datasetId') == dataset
|
||||
and selected.get('modelId') == model['modelId'])
|
||||
active = bool(learning and requested and report_fresh and report_value['controlActive'] and ready)
|
||||
if active:
|
||||
name, reason = 'active', report_value['reason'] or 'manager_control_active'
|
||||
elif requested:
|
||||
name = 'interrupted'
|
||||
reason = 'manager_report_stale' if not report_fresh else report_value['reason'] or ('planning_not_ready' if not ready else 'manager_control_not_active')
|
||||
elif not learning:
|
||||
name, reason = 'disabled', 'learning_disabled'
|
||||
elif ready:
|
||||
name, reason = 'ready', 'plan_and_model_ready'
|
||||
elif model:
|
||||
name = 'learning'
|
||||
reason = (state.get('lastRun') or {}).get('detail', {}).get('reason') or 'awaiting_fresh_plan'
|
||||
elif pipeline.get('status') in ('training', 'candidate_pending'):
|
||||
name, reason = 'learning', 'model_training'
|
||||
else:
|
||||
name, reason = 'collecting', 'collecting_measurements' if registration else 'setup_required'
|
||||
return {'state': name, 'reason': reason, 'learningEnabled': learning, 'controlRequested': requested,
|
||||
'controlActive': active, 'planningReady': ready, 'checkedAt': utc(now).isoformat(),
|
||||
'sourceReportAt': source.isoformat() if source else None,
|
||||
'sourceFreshUntil': (source+timedelta(seconds=REPORT_MAX_AGE_SECONDS)).isoformat() if source else None,
|
||||
'reportFresh': report_fresh, 'usingPreviousPlan': bool(ready and not state['fresh'])}
|
||||
@@ -8,7 +8,7 @@ function fixture(options={}){
|
||||
configuredPrognosisPlant:(req,res,id,csrf)=>{auth.push({id,csrf});return options.denied?null:{plant:{installation_id:INSTALL},license:{quantities:{grid_schedule:options.unlicensed?0:1}},session:{}};},
|
||||
roleAllowed:()=>!options.viewer,bodyJson:async()=>({expectedRevision:0,changes:{family:'23'}}),
|
||||
json:(res,status,payload)=>sent.push({status,payload}),deviceActivation:()=>options.deviceDenied?null:{plant_id:PLANT},
|
||||
checkDeviceRate:()=>!options.ratelimited,ownedLicenseState:()=>({quantities:{grid_schedule:1}}),serviceToken:'synthetic-test-only',
|
||||
checkDeviceRate:()=>!options.ratelimited,ownedLicenseState:()=>({quantities:{grid_schedule:options.unlicensed?0:1}}),serviceToken:'synthetic-test-only',
|
||||
fetchImpl:async(url,args)=>{upstream.push({url:String(url),args});if(options.down)throw new Error('secret-host-detail');return {ok:!options.conflict,status:options.conflict?409:200,json:async()=>options.conflict?{detail:'internal-field'}:{liveEnabled:false,plan:{planId:'p1'}}};}
|
||||
});return {bridge,sent,auth,upstream};
|
||||
}
|
||||
@@ -30,3 +30,16 @@ test('measurement batches use authenticated dedicated data ingress',async()=>{co
|
||||
test('unauthenticated measurement upload cannot reach storage',async()=>{const f=fixture({deviceDenied:true});await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/measurements`));assert.equal(f.upstream.length,0);});
|
||||
test('device cannot change dataset mapping through public proxy',async()=>{const f=fixture();assert.equal(await f.bridge({method:'PUT'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/datasets/physical-v1`)),false);assert.equal(f.upstream.length,0);});
|
||||
test('measurement upload keeps device rate protection',async()=>{const f=fixture({ratelimited:true});await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/measurements`));assert.equal(f.sent[0].status,429);assert.equal(f.upstream.length,0);});
|
||||
|
||||
for(const action of ['setup','workflow']){
|
||||
const endpoint=id=>new URL(`https://portal.test/api/v1/installations/${id}/prognosis/planner-v4/${action}`);
|
||||
test(`${action} uses authenticated device license and installation binding`,async()=>{
|
||||
const f=fixture();assert.equal(await f.bridge({method:'POST'},{},endpoint(INSTALL)),true);
|
||||
assert.match(f.upstream[0].url,new RegExp(`/prognosis/${INSTALL}/planner/${action}$`));
|
||||
assert.equal(f.upstream[0].args.method,'POST');
|
||||
});
|
||||
for(const option of ['deviceDenied','unlicensed','ratelimited'])test(`${action} rejects ${option} before forwarding`,async()=>{
|
||||
const f=fixture({[option]:true});await f.bridge({method:'POST'},{},endpoint(INSTALL));assert.equal(f.upstream.length,0);
|
||||
});
|
||||
test(`${action} cannot use a read request to mutate`,async()=>{const f=fixture();await f.bridge({method:'GET'},{},endpoint(INSTALL));assert.equal(f.sent[0].status,405);assert.equal(f.upstream.length,0);});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Cross-language contract for the ordinary Manager's generated measurement map."""
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from netplan_v4 import measurement_pipeline as pipeline
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('php'), 'Native PHP CLI required for capture contract')
|
||||
class NativeMeasurementSetupTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
fixture = Path(__file__).resolve().parents[3] / 'tests' / 'V4Setup' / 'checks.php'
|
||||
result = subprocess.run(['php', str(fixture), '--fixture'], check=True, capture_output=True, text=True)
|
||||
if result.stderr:
|
||||
raise AssertionError(result.stderr)
|
||||
cls.fixture = json.loads(result.stdout)
|
||||
|
||||
def test_dataset_matches_backend_and_exact_php_capture_hash(self):
|
||||
setup = self.fixture['setup']
|
||||
config = pipeline.validate_config(setup['dataset'])
|
||||
self.assertEqual(config['mappingSha256'], sha256(self.fixture['captureJson'].encode()).hexdigest())
|
||||
self.assertEqual(config['mappingSha256'], self.fixture['record']['mappingSha256'])
|
||||
self.assertEqual(config['inventorySha256'], self.fixture['record']['reportedInventorySha256'])
|
||||
self.assertEqual(config['datasetId'], setup['datasetId'])
|
||||
self.assertEqual(len(config['sources']), 3)
|
||||
|
||||
def test_capture_ingests_without_virtual_double_count_or_permissions(self):
|
||||
setup = self.fixture['setup']
|
||||
record = self.fixture['record']
|
||||
config = setup['dataset']
|
||||
plant = record['installationId']
|
||||
now = pipeline.epoch(record['capturedAt'])
|
||||
con = sqlite3.connect(':memory:')
|
||||
try:
|
||||
pipeline.schema(con)
|
||||
registered = pipeline.register_dataset(con, plant, config, now)
|
||||
self.assertFalse(registered['controlEnabled'])
|
||||
receipt = pipeline.ingest_batch(con, plant, {'version': 1, 'datasetId': setup['datasetId'], 'records': [record]}, now)
|
||||
self.assertEqual(receipt['stored'], 1)
|
||||
self.assertFalse(receipt['controlEnabled'])
|
||||
values = {key: source['value'] for key, source in record['raw'].items()}
|
||||
self.assertEqual(pipeline.physical_value(values, config), 4000)
|
||||
self.assertFalse(record['trainingEligible'])
|
||||
self.assertFalse(record['measurementBoundaryVerified'])
|
||||
changed = json.loads(json.dumps(config))
|
||||
changed['sources'][0]['factorToW'] *= -1
|
||||
with self.assertRaises(ValueError):
|
||||
pipeline.register_dataset(con, plant, changed, now)
|
||||
with self.assertRaises(ValueError):
|
||||
pipeline.ingest_batch(con, 'other-installation', {'version': 1, 'datasetId': setup['datasetId'], 'records': [record]}, now)
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -40,7 +40,12 @@ class ReleasePreflightTest(unittest.TestCase):
|
||||
self.assertIn('?mode=ro',text);self.assertNotIn('ALTER TABLE',text)
|
||||
def test_gui_labels_estimates_and_incomplete_features(self):
|
||||
text=(ROOT/'gui/netplan-v4.js').read_text()
|
||||
self.assertIn('SCHATTENBETRIEB',text)
|
||||
self.assertIn('Die Kurven zeigen berechnete Plaene, keine nachgewiesene Ausfuehrung',text)
|
||||
self.assertIn('Geraete- und Netzgrenzen behalten Vorrang',text)
|
||||
self.assertIn('Planempfang ist kein Ausfuehrungsnachweis',text)
|
||||
self.assertIn('kein Nachweis realisierter Einsparungen',text)
|
||||
self.assertIn('Planung mit geschaetzter Peakbasis',text)
|
||||
self.assertIn('kein Abrechnungsnachweis',text)
|
||||
self.assertIn('measurementPolicy',text)
|
||||
self.assertIn('erstellt vergleichbare Kosten-Replays',text)
|
||||
self.assertIn('keine vollstaendige Nachbildung laufender Neuoptimierungen',text)
|
||||
@@ -48,4 +53,15 @@ class ReleasePreflightTest(unittest.TestCase):
|
||||
self.assertIn('trainingCadence',text)
|
||||
self.assertIn('Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe',text)
|
||||
|
||||
def test_gui_separates_planning_from_confirmed_manager_control(self):
|
||||
text=(ROOT/'gui/netplan-v4.js').read_text()
|
||||
self.assertIn('Betriebsstatus nicht bestaetigt',text)
|
||||
self.assertIn('workflow.controlActive && workflow.controlRequested && workflow.planningReady',text)
|
||||
self.assertIn('workflow.reportFresh !== true',text)
|
||||
self.assertIn('Date.parse(workflow.sourceFreshUntil) <= now',text)
|
||||
self.assertIn("state: 'interrupted'",text)
|
||||
self.assertIn('controlActive: false',text)
|
||||
self.assertIn('lokale Schutzpruefung bleibt erforderlich',text)
|
||||
self.assertIn("setTimeout(showWorkflow",text)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import copy
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from netplan_v4 import measurement_pipeline as m, workflow, meter_runtime
|
||||
from netplan_v4.service import create_app, run_once, status, ingest
|
||||
from netplan_v4.store import PlannerStore, canonical
|
||||
from test_measurement_pipeline import config, record
|
||||
from test_v4 import AID, TOKEN, inputs
|
||||
|
||||
OTHER = '00000000-0000-4000-8000-000000000002'
|
||||
NOW = datetime(2026, 10, 2, 12, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def setup_payload(plant=AID):
|
||||
c = config(maximumGapSeconds=5, minimumTrainingHours=24, historyDays=30)
|
||||
c['sources'] = [s for s in c['sources'] if s['role'] != 'sdl_request']
|
||||
return {'version': 1, 'installationId': plant, 'dataset': c, 'learningEnabled': True}
|
||||
|
||||
|
||||
def report_payload(plant=AID, at=NOW, **changes):
|
||||
return {'version': 1, 'installationId': plant, 'observedAt': at.isoformat(),
|
||||
'learningEnabled': True, 'controlRequested': False, 'controlActive': False,
|
||||
'reason': '', **changes}
|
||||
|
||||
|
||||
class WorkflowTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.path = str(Path(self.temp.name)/'workflow.sqlite')
|
||||
self.store = PlannerStore(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def setup(self, payload=None, plant=AID):
|
||||
return workflow.setup(self.store, plant, payload or setup_payload(plant), NOW)
|
||||
|
||||
def test_setup_initializes_conservative_planning_only(self):
|
||||
answer = self.setup()
|
||||
self.assertTrue(answer['settingsInitialized'])
|
||||
self.assertFalse(answer['controlEnabled'])
|
||||
settings = self.store.settings(AID)
|
||||
self.assertEqual(settings['forecastSource'], 'corrected_profile')
|
||||
self.assertEqual(settings['trainingCadence'], 'daily')
|
||||
self.assertEqual(settings['measurementPolicy'], 'allow_estimates')
|
||||
self.assertEqual(settings['runMode'], 'shadow')
|
||||
self.assertEqual(status(self.store, AID, NOW)['workflow']['state'], 'collecting')
|
||||
|
||||
def test_repeat_setup_keeps_user_settings_revision_and_history(self):
|
||||
self.setup()
|
||||
self.store.save_settings(AID, {'family': '23', 'trainingCadence': 'weekly'}, 1, NOW)
|
||||
c = setup_payload()['dataset']
|
||||
m.ingest_batch(self.store.con, AID, {'version': 1, 'datasetId': c['datasetId'],
|
||||
'records': [record(int(NOW.timestamp()), c)]}, int(NOW.timestamp()))
|
||||
before = self.store.settings(AID)
|
||||
answer = self.setup()
|
||||
self.assertFalse(answer['settingsInitialized'])
|
||||
self.assertEqual(before, self.store.settings(AID))
|
||||
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_observations').fetchone()[0], 1)
|
||||
|
||||
def test_existing_custom_dataset_selection_is_not_overwritten(self):
|
||||
m.register_dataset(self.store.con, AID, config(datasetId='custom-derived'), int(NOW.timestamp()))
|
||||
self.store.save_settings(AID, {'forecastSource': 'corrected_profile', 'measurementDataset': 'custom-derived'}, 0, NOW)
|
||||
result = self.setup()
|
||||
self.assertEqual(result['selectedDatasetId'], 'custom-derived')
|
||||
self.assertEqual(result['settingsRevision'], 1)
|
||||
self.assertEqual(self.store.settings(AID)['measurementPolicy'], 'verified_only')
|
||||
|
||||
def test_drift_requires_new_dataset_and_keeps_old_selection(self):
|
||||
self.setup()
|
||||
payload = setup_payload()
|
||||
payload['dataset']['mappingSha256'] = 'c'*64
|
||||
with self.assertRaisesRegex(ValueError, 'immutable'):
|
||||
self.setup(payload)
|
||||
payload['dataset']['datasetId'] = 'physical-v2'
|
||||
result = self.setup(payload)
|
||||
self.assertEqual(result['selectedDatasetId'], 'physical-v1')
|
||||
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_data_sets').fetchone()[0], 2)
|
||||
|
||||
def test_two_plants_remain_isolated(self):
|
||||
self.setup()
|
||||
self.setup(plant=OTHER)
|
||||
self.store.save_settings(AID, {'family': '23'}, 1, NOW)
|
||||
self.assertEqual(self.store.settings(OTHER)['family'], '3')
|
||||
workflow.report(self.store, AID, report_payload(controlRequested=True), NOW)
|
||||
self.assertFalse(status(self.store, OTHER, NOW)['workflow']['controlRequested'])
|
||||
bad = setup_payload(OTHER)
|
||||
with self.assertRaises(ValueError):
|
||||
self.setup(bad)
|
||||
|
||||
def test_setup_cannot_supply_safety_or_relaxed_policies(self):
|
||||
for mutate in (lambda v: v.update(controlEnabled=True),
|
||||
lambda v: v['dataset'].update(minimumTrainingHours=1),
|
||||
lambda v: v['dataset'].update(maximumGapSeconds=10),
|
||||
lambda v: v.update(version=True),
|
||||
lambda v: v.update(learningEnabled=1)):
|
||||
value = setup_payload()
|
||||
mutate(value)
|
||||
with self.assertRaises(ValueError):
|
||||
self.setup(value)
|
||||
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_enrollment').fetchone()[0], 0)
|
||||
|
||||
def test_reports_are_monotonic_idempotent_and_tenant_bound(self):
|
||||
self.setup()
|
||||
value = report_payload()
|
||||
self.assertEqual(workflow.report(self.store, AID, value, NOW)['status'], 'recorded')
|
||||
self.assertEqual(workflow.report(self.store, AID, value, NOW)['status'], 'duplicate')
|
||||
for bad in ({**value, 'reason': 'different'}, report_payload(at=NOW-timedelta(seconds=1)),
|
||||
report_payload(plant=OTHER)):
|
||||
with self.assertRaises(ValueError):
|
||||
workflow.report(self.store, AID, bad, NOW)
|
||||
|
||||
def test_reports_reject_invalid_types_time_and_false_active_claim(self):
|
||||
self.setup()
|
||||
for change in ({'controlActive': True}, {'controlRequested': 1}, {'reason': 'x'*301},
|
||||
{'reason': 'line\nbreak'}, {'version': True}, {'observedAt': '2026-10-02T12:00:00'},
|
||||
{'observedAt': (NOW-timedelta(seconds=121)).isoformat()},
|
||||
{'observedAt': (NOW+timedelta(seconds=31)).isoformat()}):
|
||||
with self.assertRaises(ValueError):
|
||||
workflow.report(self.store, AID, report_payload(**change), NOW)
|
||||
|
||||
def ready(self, native_meter=False):
|
||||
self.setup()
|
||||
c = setup_payload()['dataset']
|
||||
t = int(NOW.timestamp())
|
||||
rows = [record(ts, c) for ts in range(t-25*3600, t+1, 30)]
|
||||
for i in range(0, len(rows), 120):
|
||||
m.ingest_batch(self.store.con, AID, {'version': 1, 'datasetId': c['datasetId'], 'records': rows[i:i+120]}, t)
|
||||
m.advance(self.store.con, AID, c['datasetId'], self.store.settings(AID), t)
|
||||
values = inputs(NOW)
|
||||
if native_meter:
|
||||
with self.store.con:
|
||||
for seconds in range(-900, 1, 30):
|
||||
at = NOW+timedelta(seconds=seconds)
|
||||
observation = {'meterId': 'symcon-active-import:'+'c'*64, 'sampleAt': at.isoformat(),
|
||||
'powerW': 3000., 'totalImportKwh': 100.+(seconds+900)/1200,
|
||||
'controlPolicyId': 'native-test'}
|
||||
meter_runtime.observe(self.store.con, AID, observation, at)
|
||||
values[0]['measuredPeaks'] = {}
|
||||
values[0]['meterObservation'] = observation
|
||||
for kind, value in zip(('operation', 'forecast', 'tariffs'), values):
|
||||
ingest(self.store, AID, kind, value, NOW)
|
||||
self.assertTrue(run_once(self.store, NOW)['executable'])
|
||||
|
||||
def test_physical_only_ready_plan_retains_pv_and_prices(self):
|
||||
self.ready()
|
||||
view = status(self.store, AID, NOW)
|
||||
self.assertEqual(view['workflow']['state'], 'ready')
|
||||
self.assertFalse(view['workflow']['controlActive'])
|
||||
plan = view['plan']
|
||||
self.assertEqual(plan['points'][0]['pvW'], 5000.)
|
||||
self.assertEqual(plan['points'][0]['externalW'], 0.)
|
||||
self.assertEqual(plan['inputQuality']['dataPipeline']['externalPolicy'], 'no_external_sdl_channel')
|
||||
|
||||
def test_native_onboarding_is_ready_without_manual_peak_evidence(self):
|
||||
self.ready(native_meter=True)
|
||||
state = status(self.store, AID, NOW)
|
||||
self.assertEqual(state['workflow']['state'], 'ready')
|
||||
self.assertEqual(state['plan']['peakBasis']['2026-10']['quality'], 'estimated')
|
||||
self.assertEqual(state['plan']['measuredPeaksKw'], {})
|
||||
self.assertFalse(state['liveEnabled'])
|
||||
|
||||
def test_report_false_pauses_autonomous_worker_not_only_display(self):
|
||||
self.setup()
|
||||
workflow.report(self.store, AID, report_payload(learningEnabled=False), NOW)
|
||||
self.assertEqual(run_once(self.store, NOW)['status'], 'learning_disabled')
|
||||
|
||||
def test_ordinary_replan_retains_readiness_but_never_receiver_freshness(self):
|
||||
self.ready()
|
||||
original = self.store.current(AID)
|
||||
self.store.request(AID, 'telemetry_changed', NOW)
|
||||
state = status(self.store, AID, NOW)
|
||||
self.assertFalse(state['fresh'])
|
||||
self.assertTrue(state['workflow']['planningReady'])
|
||||
self.assertTrue(state['workflow']['usingPreviousPlan'])
|
||||
self.assertEqual(state['plan'], original)
|
||||
self.store.save_settings(AID, {'family': '23'}, 1, NOW)
|
||||
self.assertFalse(status(self.store, AID, NOW)['workflow']['planningReady'])
|
||||
|
||||
def test_optimal_or_ack_alone_never_means_active(self):
|
||||
self.ready()
|
||||
plan = self.store.current(AID)
|
||||
self.store.acknowledge(AID, plan['planId'], 1, NOW, status='received')
|
||||
self.assertEqual(status(self.store, AID, NOW)['workflow']['state'], 'ready')
|
||||
|
||||
def test_active_requires_fresh_report_and_matching_fresh_plan(self):
|
||||
self.ready()
|
||||
workflow.report(self.store, AID, report_payload(controlRequested=True, controlActive=True), NOW)
|
||||
view = status(self.store, AID, NOW)['workflow']
|
||||
self.assertEqual(view['state'], 'active')
|
||||
self.assertTrue(view['controlActive'])
|
||||
stale = status(self.store, AID, NOW+timedelta(seconds=121))['workflow']
|
||||
self.assertEqual(stale['state'], 'interrupted')
|
||||
self.assertEqual(stale['reason'], 'manager_report_stale')
|
||||
self.assertFalse(stale['controlActive'])
|
||||
self.store.request(AID, 'manual', NOW)
|
||||
self.assertFalse(status(self.store, AID, NOW)['workflow']['controlActive'])
|
||||
|
||||
def test_active_report_without_usable_model_is_interrupted(self):
|
||||
self.setup()
|
||||
workflow.report(self.store, AID, report_payload(controlRequested=True, controlActive=True), NOW)
|
||||
view = status(self.store, AID, NOW)['workflow']
|
||||
self.assertEqual(view['state'], 'interrupted')
|
||||
self.assertFalse(view['controlActive'])
|
||||
|
||||
def test_learning_disabled_pauses_worker_but_retains_data(self):
|
||||
self.setup({**setup_payload(), 'learningEnabled': False})
|
||||
self.assertEqual(run_once(self.store, NOW)['status'], 'learning_disabled')
|
||||
self.assertEqual(status(self.store, AID, NOW)['workflow']['state'], 'disabled')
|
||||
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_data_sets').fetchone()[0], 1)
|
||||
|
||||
def test_private_setup_enrolls_without_expanding_actuator_allowlist(self):
|
||||
with TestClient(create_app(self.path, TOKEN, [], start_worker=False)) as client:
|
||||
path = '/internal/v2/prognosis/'+OTHER+'/planner'
|
||||
headers = {'X-Enelix-Service-Token': TOKEN}
|
||||
self.assertEqual(client.post(path+'/setup', json=setup_payload(OTHER)).status_code, 401)
|
||||
self.assertEqual(client.get(path, headers=headers).status_code, 403)
|
||||
result = client.post(path+'/setup', json=setup_payload(OTHER), headers=headers)
|
||||
self.assertEqual(result.status_code, 200, result.text)
|
||||
state = client.get(path, headers=headers).json()
|
||||
self.assertFalse(state['controlledTrialAuthorized'])
|
||||
self.assertIsNone(state['controlledTrial'])
|
||||
self.assertFalse(state['liveEnabled'])
|
||||
registry = client.get('/internal/v2/planner/installations', headers=headers)
|
||||
self.assertEqual(registry.json()['installationIds'], [OTHER])
|
||||
self.assertEqual(client.get('/internal/v2/planner/installations').status_code, 401)
|
||||
self.assertEqual(client.post(path+'/trial/arm', json={}, headers=headers).status_code, 409)
|
||||
self.assertEqual(client.post(path+'/setup', json=setup_payload(AID), headers=headers).status_code, 400)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Stdlib-only enrollment/status tests, independent of the HTTP/solver runtime."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from copy import deepcopy
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from netplan_v4 import workflow, measurement_pipeline as m, meter_runtime
|
||||
from netplan_v4.store import PlannerStore, canonical
|
||||
from netplan_v4.receiver_contract import control_context
|
||||
|
||||
PLANT = '00000000-0000-4000-8000-000000000001'
|
||||
OTHER = '00000000-0000-4000-8000-000000000002'
|
||||
NOW = datetime(2026, 10, 8, 12, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def payload(plant=PLANT):
|
||||
config = {'datasetId': 'physical-v1', 'mappingSha256': 'a'*64, 'inventorySha256': 'b'*64,
|
||||
'formula': 'physical_sum_v1', 'solarReference': None, 'minimumCoverage': .95,
|
||||
'maximumGapSeconds': 5, 'minimumTrainingHours': 24, 'historyDays': 30,
|
||||
'sources': [{'key': role, 'role': role, 'variableId': i+101, 'factorToW': 1,
|
||||
'maxAgeSeconds': 60} for i, role in enumerate(('grid', 'pv', 'physical_storage'))]}
|
||||
return {'version': 1, 'installationId': plant, 'dataset': config, 'learningEnabled': True}
|
||||
|
||||
|
||||
def report(**changes):
|
||||
return {'version': 1, 'installationId': PLANT, 'observedAt': NOW.isoformat(),
|
||||
'learningEnabled': True, 'controlRequested': False, 'controlActive': False,
|
||||
'reason': '', **changes}
|
||||
|
||||
|
||||
class WorkflowCoreTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.s = PlannerStore(str(Path(self.temp.name)/'planner.sqlite'))
|
||||
|
||||
def tearDown(self):
|
||||
self.s.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def state(self, at=NOW, **changes):
|
||||
state = {'settings': self.s.settings(PLANT), 'fresh': False, 'plan': None,
|
||||
'dataPipeline': {'datasets': []}, 'lastRun': None, **changes}
|
||||
return workflow.view(self.s, PLANT, state, at)
|
||||
|
||||
def test_new_installation_enrolls_and_duplicate_retains_user_revision(self):
|
||||
result = workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
self.assertTrue(result['settingsInitialized'])
|
||||
self.assertFalse(result['controlEnabled'])
|
||||
self.assertFalse(result['controlledTrialAuthorized'])
|
||||
self.s.save_settings(PLANT, {'family': '23', 'trainingCadence': 'weekly'}, 1, NOW)
|
||||
old = self.s.settings(PLANT)
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
self.assertEqual(old, self.s.settings(PLANT))
|
||||
self.assertEqual(old['measurementPolicy'], 'allow_estimates')
|
||||
self.assertEqual(self.state()['state'], 'collecting')
|
||||
|
||||
def test_two_installations_and_dataset_drift_are_isolated(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
workflow.setup(self.s, OTHER, payload(OTHER), NOW)
|
||||
drift = payload()
|
||||
drift['dataset']['mappingSha256'] = 'c'*64
|
||||
with self.assertRaisesRegex(ValueError, 'immutable'):
|
||||
workflow.setup(self.s, PLANT, drift, NOW)
|
||||
self.assertEqual(self.s.con.execute('SELECT COUNT(*) FROM planner_data_sets').fetchone()[0], 2)
|
||||
workflow.report(self.s, PLANT, report(), NOW)
|
||||
self.assertIsNone(self.s.con.execute('SELECT value FROM planner_workflow_report WHERE plant=?', (OTHER,)).fetchone())
|
||||
|
||||
def test_device_cannot_weaken_thresholds_or_claim_authority(self):
|
||||
for mutate in (lambda x: x.update(controlEnabled=True), lambda x: x.update(learningEnabled=1),
|
||||
lambda x: x.update(installationId=OTHER), lambda x: x['dataset'].update(minimumTrainingHours=1),
|
||||
lambda x: x['dataset'].update(maximumGapSeconds=10)):
|
||||
value = payload()
|
||||
mutate(value)
|
||||
with self.assertRaises(ValueError):
|
||||
workflow.setup(self.s, PLANT, value, NOW)
|
||||
self.assertIsNone(workflow.enrolled(self.s.con, PLANT))
|
||||
|
||||
def test_report_duplicate_monotonicity_and_exact_types(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
self.assertEqual(workflow.report(self.s, PLANT, report(), NOW)['status'], 'recorded')
|
||||
self.assertEqual(workflow.report(self.s, PLANT, report(), NOW)['status'], 'duplicate')
|
||||
for changes in ({'reason': 'conflict'}, {'controlRequested': 1}, {'version': True},
|
||||
{'controlActive': True}, {'reason': 'x'*301}, {'reason': 'line\nbreak'},
|
||||
{'observedAt': (NOW-timedelta(seconds=1)).isoformat()},
|
||||
{'observedAt': '2026-10-08T12:00:00'}, {'installationId': OTHER}):
|
||||
with self.assertRaises(ValueError):
|
||||
workflow.report(self.s, PLANT, report(**changes), NOW)
|
||||
|
||||
def test_fresh_plan_and_model_do_not_imply_active_manager(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
with self.s.con:
|
||||
self.s.con.execute('INSERT INTO planner_load_models VALUES(?,?,?,?,?,?)',
|
||||
(PLANT, 'physical-v1', 'model-1', int(NOW.timestamp()), int(NOW.timestamp()), '{}'))
|
||||
self.s.con.execute('INSERT INTO planner_model_current VALUES(?,?,?)', (PLANT, 'physical-v1', 'model-1'))
|
||||
plan = {'executable': True, 'inputQuality': {'dataPipeline': {'datasetId': 'physical-v1', 'modelId': 'model-1'}}}
|
||||
self.assertEqual(self.state(fresh=True, plan=plan)['state'], 'ready')
|
||||
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
|
||||
self.assertEqual(self.state(fresh=True, plan=plan)['state'], 'active')
|
||||
self.assertFalse(self.state(fresh=False, plan=plan)['controlActive'])
|
||||
stale = self.state(at=NOW+timedelta(seconds=121), fresh=True, plan=plan)
|
||||
self.assertEqual(stale['state'], 'interrupted')
|
||||
self.assertEqual(stale['reason'], 'manager_report_stale')
|
||||
self.assertFalse(stale['controlActive'])
|
||||
|
||||
def test_model_mismatch_and_unready_plan_block_active(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
|
||||
self.assertEqual(self.state()['state'], 'interrupted')
|
||||
self.assertFalse(self.state()['planningReady'])
|
||||
|
||||
def test_learning_toggle_preserves_registration_and_plan_settings(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
old = self.s.settings(PLANT)
|
||||
workflow.setup(self.s, PLANT, {**payload(), 'learningEnabled': False}, NOW)
|
||||
self.assertEqual(self.state()['state'], 'disabled')
|
||||
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
|
||||
self.assertEqual(old, self.s.settings(PLANT))
|
||||
|
||||
def test_explicit_learning_stop_overrides_earlier_fresh_active_report(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
|
||||
workflow.setup(self.s, PLANT, {**payload(), 'learningEnabled': False}, NOW+timedelta(seconds=1))
|
||||
self.assertFalse(self.state()['learningEnabled'])
|
||||
self.assertFalse(self.state()['controlActive'])
|
||||
|
||||
def test_workflow_learning_stop_pauses_worker_until_new_enable_report(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
original = self.s.settings(PLANT)
|
||||
workflow.report(self.s, PLANT, report(learningEnabled=False), NOW)
|
||||
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
|
||||
self.assertFalse(self.state(at=NOW+timedelta(seconds=121))['learningEnabled'])
|
||||
later = NOW+timedelta(seconds=122)
|
||||
workflow.report(self.s, PLANT, report(observedAt=later.isoformat()), later)
|
||||
self.assertTrue(workflow.learning_enabled(self.s.con, PLANT))
|
||||
self.assertEqual(original, self.s.settings(PLANT))
|
||||
|
||||
def test_pre_enrollment_legacy_plant_can_pause_learning_via_report(self):
|
||||
workflow.report(self.s, PLANT, report(learningEnabled=False), NOW)
|
||||
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
|
||||
|
||||
def test_report_captured_before_setup_stop_cannot_reenable_learning(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
workflow.setup(self.s, PLANT, {**payload(), 'learningEnabled': False}, NOW+timedelta(seconds=10))
|
||||
delayed = report(observedAt=(NOW+timedelta(seconds=5)).isoformat())
|
||||
workflow.report(self.s, PLANT, delayed, NOW+timedelta(seconds=11))
|
||||
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
|
||||
self.assertFalse(self.state(at=NOW+timedelta(seconds=11))['learningEnabled'])
|
||||
|
||||
def test_native_meter_bootstrap_preserves_estimated_quality_and_gap_checks(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
meter = 'symcon-active-import:'+'c'*64
|
||||
with self.s.con:
|
||||
for seconds in range(-900, 61, 30):
|
||||
at = NOW+timedelta(seconds=seconds)
|
||||
observation = {'meterId': meter, 'sampleAt': at.isoformat(), 'powerW': 3000.,
|
||||
'totalImportKwh': 100.+(seconds+900)/1200, 'controlPolicyId': 'native-test'}
|
||||
meter_runtime.observe(self.s.con, PLANT, observation, at)
|
||||
peaks = meter_runtime.assumptions(self.s.con, PLANT)
|
||||
self.assertEqual(peaks['2026-10']['quality'], 'estimated')
|
||||
self.assertAlmostEqual(peaks['2026-10']['kw'], 3.)
|
||||
self.assertEqual(self.s.peaks(PLANT), {})
|
||||
quarter = meter_runtime.current_quarter(self.s.con, PLANT, observation, NOW+timedelta(seconds=60))
|
||||
self.assertEqual(quarter['quality'], 'estimated')
|
||||
self.assertEqual(quarter['coverage'], 1.)
|
||||
self.assertIsNone(meter_runtime.current_quarter(self.s.con, PLANT, observation, NOW+timedelta(seconds=181)))
|
||||
|
||||
def continuity_state(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
operation = {'eventId': 'new-telemetry', 'observedAt': NOW.isoformat(),
|
||||
'batteries': [{'id': 'battery', 'capacityKwh': 10., 'minSocPercent': 10.,
|
||||
'maxSocPercent': 90., 'gridCharging': True}],
|
||||
'limits': {'importW': 10000., 'exportW': 5000., 'managerMonthLimitsW': {}}}
|
||||
inputs = {'operation': operation, 'forecast': {'eventId': 'forecast-1'}, 'tariffs': {'eventId': 'tariffs-1'}}
|
||||
with self.s.con:
|
||||
for kind, value in inputs.items():
|
||||
self.s.con.execute('INSERT INTO planner_inputs VALUES(?,?,?,?,?)',
|
||||
(PLANT, kind, value['eventId'], NOW.isoformat(), canonical(value)))
|
||||
self.s.con.execute('INSERT INTO planner_input_current VALUES(?,?,?)', (PLANT, kind, value['eventId']))
|
||||
self.s.con.execute('INSERT INTO planner_load_models VALUES(?,?,?,?,?,?)',
|
||||
(PLANT, 'physical-v1', 'model-1', int(NOW.timestamp()), int(NOW.timestamp()), '{}'))
|
||||
self.s.con.execute('INSERT INTO planner_model_current VALUES(?,?,?)', (PLANT, 'physical-v1', 'model-1'))
|
||||
plan = {'planId': 'plan-1', 'configRevision': 1, 'executable': True,
|
||||
'generatedAt': NOW.isoformat(), 'validFrom': NOW.isoformat(), 'validUntil': (NOW+timedelta(minutes=15)).isoformat(),
|
||||
'controlContext': control_context(operation), 'inputRefs': {'operation': 'old-telemetry', 'forecast': 'forecast-1', 'tariffs': 'tariffs-1'},
|
||||
'inputQuality': {'dataPipeline': {'datasetId': 'physical-v1', 'modelId': 'model-1'}}}
|
||||
return {'settings': self.s.settings(PLANT), 'fresh': False, 'plan': plan,
|
||||
'pending': {'revision': 1, 'reasons': canonical(['operation_changed', 'five_minute_tick'])},
|
||||
'dataPipeline': {'datasets': []},
|
||||
'lastRun': {'status': 'optimal', 'detail': {'planId': 'plan-1', 'configRevision': 1}}}
|
||||
|
||||
def test_routine_same_policy_replan_keeps_readiness_without_renewing_envelope(self):
|
||||
state = self.continuity_state()
|
||||
original = deepcopy(state)
|
||||
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
|
||||
view = workflow.view(self.s, PLANT, state, NOW)
|
||||
self.assertTrue(view['planningReady'])
|
||||
self.assertTrue(view['usingPreviousPlan'])
|
||||
self.assertTrue(view['controlActive'])
|
||||
self.assertEqual(original, state)
|
||||
self.assertFalse(state['fresh'])
|
||||
|
||||
def test_replan_continuity_rejects_revision_prices_model_and_failed_run(self):
|
||||
state = self.continuity_state()
|
||||
for change in (lambda s: s['pending'].update(reasons='["prices_changed"]'),
|
||||
lambda s: s['pending'].update(reasons='["forecast_changed"]'),
|
||||
lambda s: s['pending'].update(revision=2),
|
||||
lambda s: s['settings'].update(revision=2),
|
||||
lambda s: s['plan'].update(validUntil=NOW.isoformat()),
|
||||
lambda s: s['plan'].update(generatedAt=(NOW-timedelta(seconds=901)).isoformat()),
|
||||
lambda s: s['lastRun'].update(status='awaiting_inputs'),
|
||||
lambda s: s['plan']['inputRefs'].update(forecast='changed'),
|
||||
lambda s: s['plan']['inputRefs'].update(tariffs='changed'),
|
||||
lambda s: s['plan']['controlContext']['limits'].update(importW=500.),
|
||||
lambda s: s['plan']['inputQuality']['dataPipeline'].update(modelId='old-model')):
|
||||
altered = deepcopy(state)
|
||||
change(altered)
|
||||
self.assertFalse(workflow.view(self.s, PLANT, altered, NOW)['planningReady'])
|
||||
self.assertFalse(workflow.view(self.s, PLANT, state, NOW+timedelta(seconds=121))['planningReady'])
|
||||
|
||||
def test_physical_only_training_keeps_pv_and_needs_no_fabricated_sdl(self):
|
||||
workflow.setup(self.s, PLANT, payload(), NOW)
|
||||
c = payload()['dataset']
|
||||
end = int(NOW.timestamp())
|
||||
rows = []
|
||||
for stamp in range(end-25*3600, end+1, 30):
|
||||
rows.append({'schemaVersion': 1, 'kind': 'raw_accounting_capture', 'installationId': PLANT,
|
||||
'capturedAt': m.iso(stamp), 'captureStartedAt': m.iso(stamp),
|
||||
'mappingSha256': c['mappingSha256'], 'reportedInventorySha256': c['inventorySha256'],
|
||||
'raw': {s['key']: {'variableId': s['variableId'], 'value': {'grid': 3000, 'pv': 5000, 'physical_storage': 2000}[s['key']],
|
||||
'sourceUpdatedAt': stamp, 'issues': []} for s in c['sources']}})
|
||||
for start in range(0, len(rows), 120):
|
||||
m.ingest_batch(self.s.con, PLANT, {'version': 1, 'datasetId': c['datasetId'], 'records': rows[start:start+120]}, end)
|
||||
m.advance(self.s.con, PLANT, c['datasetId'], self.s.settings(PLANT), end)
|
||||
model = m.current_model(self.s.con, PLANT, c['datasetId'], end)
|
||||
self.assertIsNotNone(model)
|
||||
forecast = {'observedAt': NOW.isoformat(), 'families': {'3': {'points': [{'time': NOW.isoformat(), 'pvW': 5000, 'loadW': 9999}]}}}
|
||||
result = m.apply_load_forecast(self.s.con, PLANT, c['datasetId'], forecast, end)['families']['3']
|
||||
self.assertEqual(result['points'][0]['loadW'], 6000)
|
||||
self.assertEqual(result['points'][0]['pvW'], 5000)
|
||||
self.assertEqual(result['points'][0]['externalW'], 0)
|
||||
self.assertEqual(result['dataPipeline']['externalPolicy'], 'no_external_sdl_channel')
|
||||
|
||||
|
||||
class PublisherEnrollmentTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
path = Path(__file__).resolve().parents[1]/'integrations/netplan_v4_publisher.py'
|
||||
spec = importlib.util.spec_from_file_location('workflow_test_publisher', path)
|
||||
self.publisher = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(self.publisher)
|
||||
|
||||
def response(self, plants):
|
||||
class Response:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
def read(self, limit): return json.dumps({'installationIds': plants, 'controlEnabled': False}).encode()
|
||||
return Response()
|
||||
|
||||
def test_registry_adds_new_installations_without_static_manual_configuration(self):
|
||||
env = {'NETPLAN_V4_URL': 'http://planner.test', 'PROGNOSIS_SERVICE_TOKEN': 'synthetic-test-token', 'NETPLAN_V4_PLANTS': PLANT}
|
||||
with patch.dict(os.environ, env), patch.object(self.publisher, 'urlopen', return_value=self.response([OTHER])) as request:
|
||||
self.assertTrue(self.publisher.enabled(PLANT))
|
||||
self.assertTrue(self.publisher.enabled(OTHER))
|
||||
self.assertEqual(request.call_count, 1)
|
||||
self.assertEqual(request.call_args.args[0].full_url, 'http://planner.test/internal/v2/planner/installations')
|
||||
|
||||
def test_registry_failure_preserves_explicit_plants_not_cached_dynamic_grants(self):
|
||||
env = {'NETPLAN_V4_URL': 'http://planner.test', 'PROGNOSIS_SERVICE_TOKEN': 'synthetic-test-token', 'NETPLAN_V4_PLANTS': PLANT}
|
||||
with patch.dict(os.environ, env), patch.object(self.publisher, 'urlopen', side_effect=TimeoutError):
|
||||
self.assertEqual(self.publisher.planning_plants(), {PLANT})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user