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

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