feat(forecast): integrate full forecast charts and complete SDL visualization
Tests / test (push) Failing after 52s

This commit is contained in:
dh
2026-10-06 17:40:45 +00:00
parent 5687596799
commit 372b997876
30 changed files with 4377 additions and 53 deletions
@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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 stamp = minute => new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString();
const state = {
checkedAt: stamp(0), controlledTrialAuthorized: true, fresh: true, settings: { revision: 2, family: '3', trainingCadence: 'daily' },
families: [{ key: '3', label: 'Testfamilie' }],
plan: {
planId: 'test-plan', sourceFamily: '3', configRevision: 2, status: 'optimal', runMode: 'shadow', generatedAt: stamp(0), validUntil: stamp(60), forecastUntil: stamp(120), pricesKnownUntil: stamp(60),
inputQuality: { loadBasis: 'base_load' },
forecastPoints: Array.from({ length: 24 }, (_, index) => ({
time: stamp(index * 5), validUntil: stamp((index + 1) * 5), pvW: 1200 + index * 150, loadW: 2400, externalW: index === 4 ? null : 200
})),
points: Array.from({ length: 12 }, (_, index) => ({
time: stamp(index * 5), validUntil: stamp((index + 1) * 5), pvW: 1200 + index * 150, loadW: 2400,
externalW: index === 4 ? null : 200, gridTargetW: index * -50 + 400, baselineGridW: 1400 - index * 150,
batteryTargetW: index * 100 - 800, socEndPercent: { battery: 50 + index },
importPriceChfKwh: .2512, exportPriceChfKwh: .0801,
cumulativeCashCostChf: index * .15, cumulativeBaselineCashCostChf: index * .25
}))
}
};
test('embedded planner: responsive charts, keyboard/pointer inspection, toggles and lifecycle', async () => {
const errors = [], requests = [];
const html = '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>.forecast-chart{display:block;width:100%;height:320px}.forecast-chart-legend{border-bottom:1px solid black}</style><link rel="stylesheet" href="/netplan-v4.css"></head><body style="margin:16px"><svg id="legacy-chart" class="forecast-chart"></svg><div id="legacy-legend" class="forecast-chart-legend">Legacy</div><div id="host"></div><script type="module">import {mountPlanner} from "/netplan-v4.js"; window.mountPlanner=mountPlanner; window.planner=mountPlanner(document.querySelector("#host"),{plantId:"test",session:{csrfToken:"test-only"}});</script></body></html>';
const server = createServer(async (request, response) => {
try {
if (request.url === '/') { response.setHeader('Content-Type', 'text/html'); response.end(html); return; }
if (!['/netplan-v4.js', '/forecast-chart.js', '/netplan-v4.css'].includes(request.url)) { response.writeHead(404); response.end(); return; }
response.setHeader('Content-Type', request.url.endsWith('.css') ? 'text/css' : 'text/javascript');
response.end(await readFile(new URL('.' + request.url, import.meta.url)));
} catch { response.writeHead(500); response.end(); }
});
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 } : {}) });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/**', async route => {
const request = route.request(); requests.push({ url: request.url(), method: request.method(), csrf: request.headers()['x-csrf-token'] });
if (request.url().includes('/slow/')) await new Promise(resolve => setTimeout(resolve, 150));
const result = structuredClone(state);
if (request.url().includes('/legacy/')) delete result.plan.forecastPoints;
if (request.url().includes('/house-total/')) {
result.plan.inputQuality.loadBasis = 'house_total';
result.plan.forecastPoints.forEach(point => { point.externalW = 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}/`);
await page.waitForSelector('.forecast-chart svg');
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');
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/);
const chart = page.locator('.forecast-planner .forecast-chart').first();
const slider = chart.locator('input[type=range]');
assert.equal(await slider.getAttribute('max'), '23');
assert.equal(await page.locator('.forecast-planner .forecast-chart').nth(1).locator('input[type=range]').getAttribute('max'), '11');
assert.match(await chart.locator('.forecast-chart-caption').innerText(), /Vollstaendige/);
await slider.focus(); await slider.press('ArrowRight');
assert.equal(await slider.inputValue(), '1');
assert.match(await slider.getAttribute('aria-valuetext'), /1.350 W/);
await chart.getByLabel('PV-Prognose', { exact: true }).uncheck();
assert.equal(await chart.locator('dt').count(), 2);
await chart.getByLabel('PV-Prognose', { exact: true }).check();
await chart.locator('svg').scrollIntoViewIfNeeded();
const bounds = await chart.locator('svg').boundingBox();
await page.mouse.move(bounds.x + bounds.width - 20, bounds.y + 70);
assert.equal(await slider.inputValue(), '23');
for (const width of [1280, 390, 320]) {
await page.setViewportSize({ width, height: 900 });
await chart.scrollIntoViewIfNeeded();
await page.waitForTimeout(60);
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`) });
}
await page.locator('summary').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);
await page.evaluate(async () => {
window.planner.dispose();
window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'legacy', session: {} });
await window.planner.ready;
});
assert.equal(await slider.getAttribute('max'), '11');
assert.match(await chart.locator('.forecast-chart-caption').innerText(), /Aelterer Plan/);
await page.evaluate(async () => {
window.planner.dispose();
window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'house-total', session: {} });
await window.planner.ready;
});
assert.equal(await chart.locator('dd').nth(2).innerText(), 'nicht vorhanden');
await page.evaluate(async () => {
window.planner.dispose();
window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'slow', session: {} });
window.planner.dispose();
await window.planner.ready;
});
assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 0);
assert.deepEqual(errors, []);
} finally {
await browser?.close(); await new Promise(resolve => server.close(resolve));
}
});
+182
View File
@@ -0,0 +1,182 @@
const SVG_NS = 'http://www.w3.org/2000/svg';
const COLORS = ['#4e8181', '#757b12', '#ac4f68', '#386b91', '#737c7e', '#9c6335'];
let sequence = 0;
export const finiteValue = value => typeof value === 'number' && Number.isFinite(value);
export const formatNumber = (value, digits = 2) => finiteValue(value)
? value.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'nicht vorhanden';
export function forecastSeries(plan) {
const fullHorizon = Array.isArray(plan.forecastPoints) && plan.forecastPoints.length > 0;
return { fullHorizon, points: fullHorizon ? plan.forecastPoints : Array.isArray(plan.points) ? plan.points : [] };
}
export function formatTime(value) {
const stamp = Date.parse(value);
return Number.isFinite(stamp) ? new Date(stamp).toLocaleString('de-CH', {
timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZoneName: 'short'
}) : 'nicht vorhanden';
}
export function chartDomain(points, series) {
let low = 0, high = 0, start = Infinity, end = -Infinity, valid = false;
for (const point of points) {
const at = Date.parse(point.time), until = Date.parse(point.validUntil || point.time);
if (!Number.isFinite(at) || !Number.isFinite(until) || until < at) continue;
start = Math.min(start, at); end = Math.max(end, until);
for (const item of series) {
const value = item.value(point);
if (!finiteValue(value)) continue;
low = Math.min(low, value); high = Math.max(high, value); valid = true;
}
}
if (!Number.isFinite(start)) return null;
return { low, high: high === low ? low + 1 : high, start, end: Math.max(start + 1, end), valid };
}
// Keep missing values and non-contiguous intervals as visible gaps, never interpolated data.
export function seriesSegments(points, series, x, y) {
const segments = [];
let section = [], previousEnd;
const flush = () => { if (section.length) segments.push(section); section = []; };
for (const point of points) {
const value = series.value(point), at = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
if (!finiteValue(value) || !Number.isFinite(at) || !Number.isFinite(end) || end < at) {
flush(); previousEnd = undefined; continue;
}
if (previousEnd !== undefined && (at > previousEnd || at < previousEnd)) flush();
if (series.position === 'end') section.push([x(end), y(value)]);
else {
section.push([x(at), y(value)]);
if (end > at) section.push([x(end), y(value)]);
}
previousEnd = end;
}
flush(); return segments;
}
export function pointIndexAt(points, stamp) {
let selected = -1, distance = Infinity;
points.forEach((point, index) => {
const start = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return;
const next = stamp < start ? start - stamp : stamp >= end ? stamp - end : 0;
if (next <= distance) { distance = next; selected = index; }
});
return selected;
}
const element = (tag, text, className) => {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
};
const svgElement = (tag, attributes, text) => {
const node = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
if (text !== undefined) node.textContent = text;
return node;
};
export function renderForecastChart(container, { title, points = [], series = [], unit = '', caption = '' }) {
const id = 'forecast-chart-' + ++sequence;
const root = element('section', undefined, 'forecast-chart');
const heading = element('h3', title); heading.id = id;
root.setAttribute('aria-labelledby', id); root.append(heading); container.append(root);
const records = points.filter(point => Number.isFinite(Date.parse(point.time)) && Number.isFinite(Date.parse(point.validUntil || point.time)) && Date.parse(point.validUntil || point.time) >= Date.parse(point.time));
if (!records.length || !chartDomain(records, series)?.valid) {
root.append(element('p', 'Keine Prognosewerte vorhanden.', 'forecast-chart-empty'));
if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
return { element: root, destroy() { root.remove(); } };
}
const sources = series.map((source, index) => ({ ...source, color: source.color || COLORS[index % COLORS.length], visible: source.visible !== false }));
const legend = element('fieldset', undefined, 'forecast-chart-legend');
legend.append(element('legend', 'Datenreihen', 'forecast-chart-sr'));
const plot = element('div', undefined, 'forecast-chart-plot');
const svg = svgElement('svg', { role: 'img', 'aria-label': `${title}, ${unit}. Prognosewerte pro Zeitintervall.` });
plot.append(svg);
const inspector = element('div', undefined, 'forecast-chart-inspector');
const timestamp = element('p', undefined, 'forecast-chart-time');
const readout = element('dl'); inspector.append(timestamp, readout);
const sliderLabel = element('label', 'Prognosezeitpunkt', 'forecast-chart-slider');
const slider = element('input'); slider.type = 'range'; slider.min = '0'; slider.max = String(records.length - 1); slider.step = '1'; slider.value = '0';
slider.disabled = records.length === 1; sliderLabel.append(slider);
root.append(legend, plot, sliderLabel, inspector);
if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
let index = 0, domain, width = 900, marker;
const left = 68, right = 16, top = 18, bottom = 238, height = 284;
const x = value => left + (width - left - right) * (value - domain.start) / (domain.end - domain.start);
const y = value => bottom - (bottom - top) * (value - domain.low) / (domain.high - domain.low);
function inspect(next) {
index = Math.max(0, Math.min(records.length - 1, next)); slider.value = String(index);
const point = records[index];
timestamp.textContent = `${formatTime(point.time)} bis ${formatTime(point.validUntil || point.time)}`;
readout.replaceChildren();
const descriptions = [];
for (const source of sources.filter(item => item.visible)) {
const row = element('div'), name = element('dt', source.label), value = source.value(point);
name.style.setProperty('--series-color', source.color);
const text = finiteValue(value) ? `${formatNumber(value, source.digits ?? 2)} ${unit}` : 'nicht vorhanden';
row.append(name, element('dd', text)); readout.append(row); descriptions.push(`${source.label}: ${text}`);
}
if (!descriptions.length) readout.append(element('div', 'Keine Datenreihe ausgewaehlt.'));
slider.setAttribute('aria-valuetext', `${timestamp.textContent}. ${descriptions.join('; ')}`);
if (marker) {
const active = sources.filter(item => item.visible);
const endPosition = active.length && active.every(item => item.position === 'end');
const at = x(Date.parse(endPosition ? point.validUntil || point.time : point.time)); marker.setAttribute('x1', at); marker.setAttribute('x2', at);
}
}
function draw() {
width = Math.max(300, Math.round(plot.getBoundingClientRect().width || 900));
const active = sources.filter(source => source.visible);
domain = chartDomain(records, active.length ? active : sources);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.replaceChildren();
for (let tick = 0; tick <= 4; tick++) {
const value = domain.low + (domain.high - domain.low) * tick / 4, at = y(value);
svg.append(svgElement('line', { x1: left, x2: width - right, y1: at, y2: at, class: 'forecast-chart-grid' }));
svg.append(svgElement('text', { x: left - 8, y: at + 4, 'text-anchor': 'end', class: 'forecast-chart-axis' }, formatNumber(value, Math.max(1, ...active.map(source => source.digits ?? 1)))));
}
svg.append(svgElement('text', { x: left, y: 12, class: 'forecast-chart-axis' }, unit));
const tickCount = width < 550 ? 2 : 4;
for (let tick = 0; tick <= tickCount; tick++) {
const stamp = domain.start + (domain.end - domain.start) * tick / tickCount;
const anchor = tick === 0 ? 'start' : tick === tickCount ? 'end' : 'middle';
const at = new Date(stamp);
svg.append(svgElement('text', { x: x(stamp), y: bottom + 20, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleTimeString('de-CH', { timeZone: 'Europe/Zurich', hour: '2-digit', minute: '2-digit' })));
svg.append(svgElement('text', { x: x(stamp), y: bottom + 36, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleDateString('de-CH', { timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit' })));
}
for (const source of active) {
for (const section of seriesSegments(records, source, x, y)) {
const attributes = { fill: 'none', stroke: source.color, 'stroke-width': 2, 'vector-effect': 'non-scaling-stroke', 'stroke-linecap': 'round' };
if (source.dash) attributes['stroke-dasharray'] = source.dash;
if (section.length === 1) svg.append(svgElement('circle', { cx: section[0][0], cy: section[0][1], r: 3, fill: source.color }));
else svg.append(svgElement('polyline', { ...attributes, points: section.map(pair => pair.join(',')).join(' ') }));
}
}
marker = svgElement('line', { y1: top, y2: bottom, class: 'forecast-chart-marker' }); svg.append(marker);
inspect(index);
}
sources.forEach(source => {
const label = element('label'), input = element('input'), swatch = element('span', undefined, 'forecast-chart-swatch');
input.type = 'checkbox'; input.checked = source.visible; swatch.style.background = source.color;
if (source.dash) swatch.style.background = `repeating-linear-gradient(90deg, ${source.color} 0 5px, transparent 5px 8px)`;
label.append(input, swatch, document.createTextNode(source.label)); legend.append(label);
input.addEventListener('change', () => { source.visible = input.checked; draw(); });
});
slider.addEventListener('input', () => inspect(Number(slider.value)));
const pointer = event => {
const bounds = svg.getBoundingClientRect();
if (!bounds.width) return;
const position = Math.max(left, Math.min(width - right, (event.clientX - bounds.left) * width / bounds.width));
const stamp = domain.start + (position - left) / (width - left - right) * (domain.end - domain.start);
inspect(pointIndexAt(records, stamp));
};
svg.addEventListener('pointermove', event => { if (event.pointerType !== 'touch') pointer(event); });
svg.addEventListener('pointerdown', pointer);
draw();
const resize = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(draw) : null;
resize?.observe(plot);
return { element: root, destroy() { resize?.disconnect(); root.remove(); } };
}
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = await readFile(new URL('./forecast-chart.js', import.meta.url), 'utf8');
const { finiteValue, formatNumber, formatTime, chartDomain, seriesSegments, pointIndexAt, forecastSeries } = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64'));
const point = (minute, value, duration = 5) => ({ time: new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString(), validUntil: new Date(Date.UTC(2026, 9, 6, 10, minute + duration)).toISOString(), value });
const series = { value: row => row.value };
test('missing values are not fabricated as zero', () => {
for (const value of [null, undefined, '12', NaN, Infinity]) assert.equal(finiteValue(value), false);
assert.equal(finiteValue(0), true);
assert.equal(formatNumber(null), 'nicht vorhanden');
assert.equal(chartDomain([point(0, null)], [series]).valid, false);
});
test('domains include negative values and interval ends', () => {
const result = chartDomain([point(0, -10), point(5, 20)], [series]);
assert.equal(result.low, -10); assert.equal(result.high, 20);
assert.equal(result.end - result.start, 600000);
assert.equal(chartDomain([], [series]), null);
assert.equal(chartDomain([{ time: 'invalid', value: 5 }], [series]), null);
});
test('power curves cover entire intervals and split missing values', () => {
const rows = [point(0, 5), point(5, null), point(10, 7)];
const segments = seriesSegments(rows, series, value => value, value => value);
assert.equal(segments.length, 2); assert.equal(segments[0].length, 2);
assert.equal(segments[0][1][0], Date.parse(rows[0].validUntil));
});
test('time gaps and reversed intervals do not join curves', () => {
assert.equal(seriesSegments([point(0, 5), point(10, 7)], series, x => x, y => y).length, 2);
assert.equal(seriesSegments([point(0, 5, -1)], series, x => x, y => y).length, 0);
});
test('cumulative and SOC samples use the interval end', () => {
const row = point(0, 7);
assert.deepEqual(seriesSegments([row], { ...series, position: 'end' }, x => x, y => y), [[[Date.parse(row.validUntil), 7]]]);
});
test('pointer selection identifies the containing interval and clamps endpoints', () => {
const rows = [point(0, 1), point(5, 2), point(10, 3)];
assert.equal(pointIndexAt(rows, Date.parse(rows[0].time) - 100), 0);
assert.equal(pointIndexAt(rows, Date.parse(rows[1].time)), 1);
assert.equal(pointIndexAt(rows, Date.parse(rows[1].time) + 1000), 1);
assert.equal(pointIndexAt(rows, Date.parse(rows[2].validUntil) + 100), 2);
});
test('time labels use Zurich and reject invalid timestamps', () => {
assert.match(formatTime('2026-10-06T10:00:00Z'), /12:00/);
assert.equal(formatTime('invalid'), 'nicht vorhanden');
});
test('full forecast horizon takes precedence without extending optimized plan points', () => {
const optimized = [point(0, 1)], full = [point(0, 1), point(5, 2)];
const plan = { points: optimized, forecastPoints: full };
assert.deepEqual(forecastSeries(plan), { fullHorizon: true, points: full });
assert.equal(plan.points.length, 1);
});
test('older or empty forecasts fall back to explicitly limited optimized points', () => {
const optimized = [point(0, 1)];
for (const forecastPoints of [undefined, null, [], {}, 'invalid']) {
assert.deepEqual(forecastSeries({ points: optimized, forecastPoints }), { fullHorizon: false, points: optimized });
}
assert.deepEqual(forecastSeries({}), { fullHorizon: false, points: [] });
});
test('chart styles stay scoped to the planner, including grouped and mobile selectors', async () => {
const css = await readFile(new URL('./netplan-v4.css', import.meta.url), 'utf8');
const selectors = [...css.matchAll(/([^{}]+)\{/g)].flatMap(match => match[1].split(',').map(selector => selector.trim()));
const chartSelectors = selectors.filter(selector => selector.includes('.forecast-chart'));
assert.ok(chartSelectors.length > 20, 'chart selectors are present and checked');
for (const selector of chartSelectors) assert.match(selector, /^\.forecast-planner\s+/, `unscoped chart selector: ${selector}`);
assert.match(css, /\.forecast-planner \.forecast-chart\s*\{[^}]*height:\s*auto\s*;/, 'legacy SVG height must not constrain the new chart sections');
});
+49 -1
View File
@@ -1 +1,49 @@
body{font:16px system-ui,sans-serif;background:#eef4f4;color:#243c42}main{max-width:1100px;margin:2rem auto;padding:1.5rem;background:white}a{color:#006b83}label{display:block;margin:.8rem 0}select,button{font:inherit;padding:.6rem;margin:.3rem;border:1px solid #bacaca;border-radius:.25rem}button{background:#006b83;color:white;cursor:pointer}.notice{background:#e9f7fa;padding:1rem;border-left:4px solid #006b83}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#eef4f4;padding:1rem}svg{width:100%;height:auto}.curve{fill:none;stroke-width:2}.baseline{stroke:#7d888c}.optimized{stroke:#006b83}.axis{stroke:#b7c8cc}button:disabled{opacity:.5}
.forecast-page { margin: 0; background: #f2f5f4; color: #172124; font-family: Figtree, sans-serif; letter-spacing: 0; }
.forecast-page main { max-width: 1100px; margin: 2rem auto; padding: 1.5rem; background: #fff; }
.forecast-page a { color: #305b5b; }
.forecast-planner { min-width: 0; color: #172124; font-family: Figtree, sans-serif; letter-spacing: 0; }
.forecast-planner * { box-sizing: border-box; }
.forecast-planner h3 { margin: 1.6rem 0 .8rem; font-size: 1.05rem; line-height: 1.35; }
.forecast-planner p { line-height: 1.5; overflow-wrap: anywhere; }
.forecast-planner form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin: 1.5rem 0; }
.forecast-planner form label { display: grid; gap: .4rem; align-content: start; font-size: .85rem; font-weight: 600; }
.forecast-planner select, .forecast-planner input:not([type=checkbox]):not([type=range]), .forecast-planner button { width: 100%; min-height: 42px; margin: 0; padding: .65rem .75rem; border: 1px solid #bacaca; border-radius: 4px; background: #fff; color: #172124; font: inherit; }
.forecast-planner button { align-self: end; background: #4e8181; color: #fff; cursor: pointer; }
.forecast-planner button[type=submit] { background: #dce169; color: #172124; border-color: #dce169; }
.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 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; }
.forecast-planner-settings { margin-top: 1.5rem; border-top: 1px solid #d8e0de; padding-top: 1rem; }
.forecast-planner-settings > summary { padding: .5rem 0; cursor: pointer; font-weight: 600; }
.forecast-planner .forecast-chart { min-width: 0; height: auto; padding: 1.2rem 0; border-top: 1px solid #d8e0de; font-family: Figtree, sans-serif; letter-spacing: 0; }
.forecast-planner .forecast-chart h3 { margin: 0 0 .75rem; color: #172124; font-size: 1.05rem; line-height: 1.35; }
.forecast-planner .forecast-chart-legend { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; min-width: 0; margin: 0 0 .75rem; padding: 0; border: 0; }
.forecast-planner .forecast-chart-legend label { display: inline-flex; align-items: center; gap: .4rem; min-height: 32px; margin: 0; color: #172124; font-size: .8rem; font-weight: 500; cursor: pointer; }
.forecast-planner .forecast-chart-legend input { width: 16px; height: 16px; margin: 0; accent-color: #4e8181; }
.forecast-planner .forecast-chart-swatch { display: inline-block; width: 20px; height: 3px; flex: 0 0 20px; }
.forecast-planner .forecast-chart-plot { width: 100%; min-width: 0; min-height: 284px; }
.forecast-planner .forecast-chart svg { display: block; width: 100%; height: 284px; overflow: visible; }
.forecast-planner .forecast-chart-grid { stroke: #e3e9e7; stroke-width: 1; }
.forecast-planner .forecast-chart-axis { fill: #576467; font-size: 11px; font-family: Figtree, sans-serif; }
.forecast-planner .forecast-chart-marker { stroke: #172124; stroke-width: 1; stroke-dasharray: 3 3; pointer-events: none; }
.forecast-planner .forecast-chart-slider { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: .75rem; margin: .2rem 0 .75rem; color: #576467; font-size: .75rem; font-weight: 500; }
.forecast-planner .forecast-chart-slider input[type=range] { width: 100%; height: 28px; margin: 0; padding: 0; border: 0; box-shadow: none; accent-color: #4e8181; }
.forecast-planner .forecast-chart input:focus-visible { outline: 2px solid #305b5b; outline-offset: 3px; }
.forecast-planner .forecast-chart-inspector { padding: .75rem 0; border-top: 1px solid #d8e0de; }
.forecast-planner .forecast-chart-time { margin: 0 0 .5rem; color: #172124; font-size: .8rem; font-weight: 600; }
.forecast-planner .forecast-chart-inspector dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .6rem 1.25rem; margin: 0; }
.forecast-planner .forecast-chart-inspector dl > div { min-width: 0; }
.forecast-planner .forecast-chart-inspector dt { padding-left: .5rem; border-left: 3px solid var(--series-color); color: #576467; font-size: .75rem; font-weight: 500; overflow-wrap: anywhere; }
.forecast-planner .forecast-chart-inspector dd { margin: .25rem 0 0 .7rem; color: #172124; font-size: 1rem; font-variant-numeric: tabular-nums; }
.forecast-planner .forecast-chart-caption, .forecast-planner .forecast-chart-empty { margin: .5rem 0 0; color: #576467; font-size: .8rem; line-height: 1.5; }
.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 th { width: 43%; }
.forecast-planner .forecast-chart-slider { grid-template-columns: minmax(0, 1fr); gap: 0; }
.forecast-planner .forecast-chart-inspector dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ENELIX Netzfahrplan V4</title><link rel="stylesheet" href="/netplan-v4.css"></head><body><main><a href="/">Zurueck zum Portal</a><h1>Netzfahrplan V4</h1><p class="notice">Schattenbetrieb. Diese Ansicht steuert keine Verbraucher. Die bestehende Regelung bleibt unveraendert.</p><label>Anlage <select id="plants"></select></label><p id="message" role="status"></p><section id="panel"></section></main><script type="module" src="/netplan-v4.js"></script></body></html>
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ENELIX Prognose und Netzfahrplan</title><link rel="stylesheet" href="/netplan-v4.css"></head><body class="forecast-page"><main class="forecast-planner" data-standalone-planner><a href="/">Zurueck zum Portal</a><h1>Prognose und Netzfahrplan</h1><label>Anlage <select id="plants"></select></label><p id="message" role="status"></p><section id="panel"></section></main><script type="module" src="/netplan-v4.js"></script></body></html>
+68 -43
View File
@@ -1,7 +1,8 @@
const message = document.getElementById('message');
const panel = document.getElementById('panel');
const plants = document.getElementById('plants');
let session, generation = 0;
import { forecastSeries, renderForecastChart } from './forecast-chart.js';
export function createPlanner({ message, panel, plants, session }) {
let generation = 0;
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 num = (v, digits = 2) => typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'unbekannt';
@@ -25,35 +26,21 @@ function table(title, rows) {
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);
}
function curve(title, points, series) {
panel.append(element('h3', title));
if (!points.length) { panel.append(element('p', 'Keine Daten.')); return; }
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 1000 210'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', title);
const values = points.flatMap(p => series.map(s => s.value(p))).filter(v => typeof v === 'number' && Number.isFinite(v));
if (!values.length) { panel.append(element('p', 'Keine gueltigen Werte.')); return; }
const low = Math.min(0, ...values), high = Math.max(1, ...values);
const start = Date.parse(points[0].time), end = Date.parse(points[points.length - 1].validUntil || points[points.length - 1].time);
for (const [i, source] of series.entries()) {
let section = [];
const flush = () => { if (!section.length) return; const line = document.createElementNS(svg.namespaceURI, 'polyline'); line.setAttribute('class', 'curve ' + (i ? 'optimized' : 'baseline')); line.setAttribute('points', section.join(' ')); svg.append(line); section = []; };
for (const p of points) {
const value = source.value(p);
if (typeof value !== 'number' || !Number.isFinite(value)) { flush(); continue; }
const x = 10 + 980 * (Date.parse(p.time) - start) / Math.max(1, end - start);
const y = 190 - (value - low) / (high - low) * 180; section.push(`${x},${y}`);
}
flush();
}
panel.append(svg, element('p', `${num(low)} bis ${num(high)}. ${series.map((s, i) => (i ? 'Blau: ' : 'Grau: ') + s.label).join('; ')}. ${time(points[0].time)} – ${time(points[points.length - 1].validUntil)}.`));
function clearCharts() { for (const chart of charts.splice(0)) chart.destroy(); }
function curve(title, points, series, unit, caption) {
charts.push(renderForecastChart(panel, { title, points, series, unit, caption }));
}
async function render() {
const ticket = ++generation, id = plants.value; if (!id) return;
const base = `/api/plants/${encodeURIComponent(id)}/prognosis/planner-v4`;
message.textContent = 'Lade Planungsstand ...';
clearCharts(); panel.replaceChildren();
try {
const state = await request(base); if (ticket !== generation) return; panel.replaceChildren();
panel.append(element('p', 'SCHATTENBETRIEB: Die angezeigten Vorgaben steuern keine Geraete.'));
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.');
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 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');
@@ -72,10 +59,11 @@ async function render() {
event.preventDefault(); save.disabled = true;
try {
const r = await request(base + '/settings', 'PUT', { expectedRevision: revision, changes: { family: family.field.value, forecastSource: dataSource.field.value, measurementDataset: dataset.field.value, measurementPolicy: measurement.field.value, peakOutlookPolicy: outlook.field.value, peakOutlookReliance: Number(reliance.value) / 100, trainingCadence: cadence.field.value } });
if (ticket !== generation) return;
revision = r.revision; message.textContent = `Revision ${revision} gespeichert. Neuberechnung angefordert, noch nicht abgeschlossen.`;
} catch (error) { message.textContent = error.message; } finally { save.disabled = false; }
} catch (error) { if (ticket === generation) message.textContent = error.message; } finally { save.disabled = false; }
});
replan.addEventListener('click', async () => { try { await request(base + '/replan', 'POST', {}); message.textContent = 'Neuberechnung angefordert.'; } catch (error) { message.textContent = error.message; } });
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.'));
@@ -89,25 +77,62 @@ async function render() {
panel.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));
const plan = state.plan;
if (plan) {
if (!state.fresh) panel.append(element('p', 'ACHTUNG: Plan veraltet oder Neuberechnung ausstehend.'));
table('Plan und Kosten', [['Plan-ID', plan.planId], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Prognose bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)], ['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'], ['V4 steuert die Anlage', 'Nein']]);
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']]);
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]]);
curve('Netzleistung (W)', plan.points, [{ label: 'ohne Optimierung', value: p => p.baselineGridW }, { label: 'optimiert', value: p => p.gridTargetW }]);
curve('Batterieleistung (W; positiv = Laden)', plan.points, [{ label: 'Batterieplan', value: p => p.batteryTargetW }]);
for (const asset of Object.keys(plan.points[0]?.socEndPercent || {})) curve(`SOC ${asset} (%)`, plan.points, [{ label: 'geplanter SOC', value: p => p.socEndPercent?.[asset] }]);
curve('Kumulierte Zahlungen inklusive vollem Peakpreis (CHF)', plan.points, [{ label: 'ohne Optimierung', value: p => p.cumulativeBaselineCashCostChf }, { label: 'optimiert', value: p => p.cumulativeCashCostChf }]);
panel.append(element('p', 'Kostenverlaeufe sind Prognosen. Restwert der Batterie und Restmonatsbewertung sind keine bereits erzielten Erloese.'));
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('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;
message.textContent = '';
} catch (error) { if (ticket === generation) { panel.replaceChildren(); message.textContent = error.message; } }
} catch (error) { if (ticket === generation) { clearCharts(); panel.replaceChildren(); message.textContent = error.message; } }
}
return { reload: render, dispose() { ++generation; clearCharts(); panel.replaceChildren(); } };
}
export function mountPlanner(container, { plantId, session }) {
container.classList.add('forecast-planner');
const message = document.createElement('p'), panel = document.createElement('div');
message.setAttribute('role', 'status'); container.replaceChildren(message, panel);
const planner = createPlanner({ message, panel, plants: { value: plantId }, session });
return { ...planner, ready: planner.reload() };
}
const standalone = document.querySelector('[data-standalone-planner]');
if (standalone) {
const message = standalone.querySelector('#message'), panel = standalone.querySelector('#panel'), plants = standalone.querySelector('#plants');
try {
const response = await fetch('/api/session', { credentials: 'same-origin' });
const session = await response.json();
if (!response.ok || !session.authenticated) throw new Error('Bitte zuerst im Portal anmelden.');
const plantsResponse = await fetch('/api/plants', { credentials: 'same-origin' });
const data = await plantsResponse.json();
if (!plantsResponse.ok) throw new Error(data.error || 'Anlagen konnten nicht geladen werden.');
for (const plant of data.plants || []) {
if (!plant.installation_id) continue;
const option = document.createElement('option'); option.textContent = plant.name; option.value = plant.id; plants.append(option);
}
if (!plants.options.length) throw new Error('Keine verknuepfte Anlage vorhanden.');
const planner = createPlanner({ message, panel, plants, session });
plants.addEventListener('change', planner.reload); await planner.reload();
} catch (error) { message.textContent = error.message; }
}
try {
session = await request('/api/session'); if (!session.authenticated) throw new Error('Bitte zuerst im Portal anmelden.');
const data = await request('/api/plants');
for (const plant of data.plants || []) { if (!plant.installation_id) continue; const option = element('option', plant.name); option.value = plant.id; plants.append(option); }
if (!plants.options.length) throw new Error('Keine verknuepfte Anlage vorhanden.');
plants.addEventListener('change', render); await render();
} catch (error) { message.textContent = error.message; }
@@ -162,6 +162,7 @@ def optimize(steps,batteries,*,at,limits=None,observed_peaks=None,peak_prices=No
elif battery_w< -1:intent='export' if p_export>.001 else 'discharge'
else:intent='hold'
points.append({'time':utc(step.start).isoformat(),'validUntil':step.end.isoformat(),
'pvW':float(step.pv_w),'loadW':float(step.base_load_w),'externalW':float(step.external_w),
'baselineGridW':float(step.residual_w),'gridTargetW':(p_import-p_export)*1000,
'batteryTargetW':battery_w,'assetTargetsW':targets,'socEndPercent':soc_end,
'pvCurtailmentW':float(x[curtail[i]]*1000),'intent':intent,
+8 -2
View File
@@ -176,7 +176,7 @@ def price_at(store,plant,side,config,start,end,now):
class AwaitingInput(ValueError):pass
def assemble(store,plant,family,now):
def assemble(store,plant,family,now,*,include_forecast_points=False):
values={k:latest(store,plant,k) for k in ('operation','forecast','tariffs')}
for k,v in values.items():
if not v:raise AwaitingInput('Missing '+k+' input')
@@ -206,6 +206,11 @@ def assemble(store,plant,family,now):
external=p.get('externalW',0.) if source['loadBasis']=='base_load' else 0.
steps.append(Step(start,p['loadW'],p['pvW'],price_at(store,plant,'import',tariffs['import'],start,end,decision),price_at(store,plant,'export',tariffs['export'],start,end,decision),external,int((end-start).total_seconds())))
if not steps or steps[0].start!=decision:raise AwaitingInput('Forecast has no current interval')
if include_forecast_points:
# Preserve the exact selected input horizon separately from price-limited dispatch.
input_quality['forecastPoints']=[{'time':s.start.isoformat(),'validUntil':s.end.isoformat(),
'pvW':float(s.pv_w),'loadW':float(s.base_load_w),
'externalW':float(s.external_w) if source['loadBasis']=='base_load' else None} for s in steps]
full_end=steps[-1].end;steps=priced_prefix(steps,decision)
if not steps:raise AwaitingInput('No complete published-price billing quarter')
q=quarter_start(decision);elapsed=int((decision-q).total_seconds());past={}
@@ -291,10 +296,11 @@ def run_once(store,now):
selection=choose_family(settings['family'],current,economic_replay.scores(store,plant,now),registry=store.registry,now=now,
lookback_days=settings['autoLookbackDays'],minimum_days=settings['autoMinimumDays'],
minimum_coverage=settings['autoMinimumCoverage'],margin_chf=settings['autoSwitchMarginChf'])
data,full_end,quality=assemble(store,plant,selection['family'],now)
data,full_end,quality=assemble(store,plant,selection['family'],now,include_forecast_points=True)
data['batteries']=[replace(b,roundtrip_efficiency=settings['roundtripEfficiency']) for b in data['batteries']]
result=optimize(**data,config_revision=settings['revision'],family=selection['family'])
if result['executable']:
result['forecastPoints']=quality.pop('forecastPoints')
result.update({'installationId':plant,'inputRefs':quality.pop('inputRefs'),'controlContext':quality.pop('controlContext'),'runMode':'shadow','liveEnabled':False,'sourceSelection':selection,'forecastUntil':full_end.isoformat(),'pricesKnownUntil':result['validUntil'],'inputQuality':quality,'warnings':quality['warnings']+([] if quality['loadBasis']=='base_load' else ['Aggregate house forecast: base-load/SDL separation not verified; shadow only'])})
store.publish_shadow(plant,result,settings['revision'],now,claim['sequence'],claim['lease_token'])
economic_replay.capture(store,plant,now,assemble,optimize)
+19
View File
@@ -36,6 +36,16 @@ def inputs(at=AT):
return op,forecast,tariff
class OptimizerTest(unittest.TestCase):
def test_plan_preserves_exact_forecast_components(self):
steps = [replace(s, external_w=725.) for s in seq([2100., 2200., 2300.], pv=[3100., 3200., 3300.])]
plan = solve(steps)
self.assertTrue(plan['executable'])
for step, point in zip(steps, plan['points']):
self.assertEqual(point['pvW'], step.pv_w)
self.assertEqual(point['loadW'], step.base_load_w)
self.assertEqual(point['externalW'], step.external_w)
self.assertAlmostEqual(point['baselineGridW'], point['loadW'] + point['externalW'] - point['pvW'])
def test_grid_price_arbitrage(self):
p=solve(seq([1000.]*6,buy=[.01]*3+[.7]*3),[batt()]);self.assertTrue(p['executable']);self.assertGreater(p['points'][0]['batteryTargetW'],0);self.assertLess(p['points'][-1]['batteryTargetW'],0)
def test_no_future_energy(self):
@@ -157,12 +167,21 @@ class ServiceIntegrationTest(unittest.TestCase):
op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar))
price={'version':1,'eventId':'price1','observedAt':AT.isoformat(),'periods':[{'tariffId':'buy','side':'import','start':AT.isoformat(),'end':(AT+timedelta(minutes=15)).isoformat(),'value':-2.,'unit':'Rp/kWh','observedAt':AT.isoformat(),'sourceKind':'published_interval'}]}
ingest(self.store,AID,'prices',price,AT);p=run_once(self.store,AT);self.assertEqual(len(p['points']),3);self.assertEqual(p['points'][0]['importPriceChfKwh'],-.02)
self.assertEqual(len(p['forecastPoints']), 6)
self.assertEqual(p['forecastPoints'][-1]['validUntil'], p['forecastUntil'])
self.assertGreater(p['forecastUntil'], p['pricesKnownUntil'])
self.assertNotIn('gridTargetW', p['forecastPoints'][-1])
self.assertNotIn('forecastPoints', p['inputQuality'])
def test_carried_price_does_not_extend_horizon(self):
op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar))
price={'version':1,'eventId':'price1','observedAt':AT.isoformat(),'periods':[{'tariffId':'buy','side':'import','start':AT.isoformat(),'end':(AT+timedelta(hours=48)).isoformat(),'value':.1,'unit':'CHF/kWh','observedAt':AT.isoformat(),'sourceKind':'carried_forward'}]}
ingest(self.store,AID,'prices',price,AT);self.assertEqual(run_once(self.store,AT)['status'],'awaiting_inputs')
def test_aggregate_load_warning(self):
op,fc,tar=inputs();fc['families']['3']['loadBasis']='house_total';self.load((op,fc,tar));self.assertTrue(run_once(self.store,AT)['warnings'])
def test_aggregate_forecast_does_not_invent_separate_sdl(self):
op,fc,tar=inputs();fc['families']['3']['loadBasis']='house_total'
self.load((op,fc,tar));plan=run_once(self.store,AT)
self.assertTrue(all(point['externalW'] is None for point in plan['forecastPoints']))
def test_extra_credentials_not_stored(self):
op,fc,tar=inputs();op['password']='do-not-store'
with self.assertRaises(ValueError):ingest(self.store,AID,'operation',op,AT)