Files
EOS/scripts/update_nodered_tail_flow.py
T
Andreas a2f4ef6f54 feat(optimization): split the control horizon from the forecast tail
The optimizer treated the end of `optimization.horizon_hours` as the end of the
world: energy left in the battery there was worth a single configured price per
kWh, so it either dumped the battery into the last hours or hoarded it,
depending on that one number.

The horizon is now two spans. `horizon_hours` still receives every control
command. The new `optimization.tail_horizon_hours` (default 48 h) is a pure
lookahead that never produces a command. In AUTO terminal-value mode a
deterministic dynamic program solves that tail backwards on a 101-point SoC
grid using the production battery and inverter models - SoC bounds, power caps,
conversion losses, configured charge and export rates, direct-marketing
permission and LCOS on delivered DC energy - and the existing AUTO proxy
supplies the continuation value at the tail end. Genetic fitness reads the
resulting curve. `tail_horizon_hours: 0` restores the plain proxy at the control
end, FIXED is unchanged.

The forecast budget is reported, never enforced by refusal: a tail that does not
fit is shortened to what the forecast covers and reported as
`effective_tail_hours`, and a control horizon that does not fit is warned about
at configuration time and rejected by the optimizer at run time, which knows
which series ran out. `prediction.hours` defaults to 72 so the new defaults fit
out of the box; existing shorter configurations keep starting.

Control arrays and warm-start genomes now begin at the run timestamp rather than
midnight, flagged by `controls_start_at_now` so the adapters still read older
solutions. `forecast_interval_seconds` declares the resolution of shortened
native quarter-hour inputs.

Required forecasts are no longer silently replaced by demo providers. A missing
PV, price, load, feed-in or weather forecast used to rewrite the configured
provider and retry, so a run could quietly optimize against invented data.
Missing values now stay missing, and provider values are held only within their
own source interval instead of being extended indefinitely.

Also fixes a config update that could leave EOS half-updated: the merged
candidate is validated before the singleton is reinitialized.

Four provider tests that hard-coded the old 48 h prediction default are rewritten
to derive their expectations from the configured horizon.
2026-09-09 07:56:38 +02:00

795 lines
33 KiB
Python

"""Create an importable Node-RED flow for the split control/tail horizon."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
PREDICTION_FUNCTION = r'''const BASE_URL = "http://192.168.1.151:8503";
const PREDICTION_HOURS = 72;
const SLOT_MINUTES = 15;
const PREDICTION_KEY = "{key}";
const now = new Date();
const start = new Date(now);
start.setHours(0, 0, 0, 0);
// EOS schneidet den bereits vergangenen Teil des Tages selbst ab. Daher wird
// ab Mitternacht bis exakt 72 Stunden nach dem laufenden Viertelstunden-Slot
// abgefragt.
const slotStart = new Date(now);
slotStart.setSeconds(0, 0);
slotStart.setMinutes(Math.floor(slotStart.getMinutes() / SLOT_MINUTES) * SLOT_MINUTES);
const end = new Date(slotStart.getTime() + PREDICTION_HOURS * 3600000);
function toLocalIsoWithOffset(date) {{
const pad = n => String(Math.trunc(Math.abs(n))).padStart(2, "0");
const offsetMin = -date.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
return `${{date.getFullYear()}}-${{pad(date.getMonth() + 1)}}-${{pad(date.getDate())}}` +
`T${{pad(date.getHours())}}:${{pad(date.getMinutes())}}:${{pad(date.getSeconds())}}` +
`${{sign}}${{pad(offsetMin / 60)}}:${{pad(offsetMin % 60)}}`;
}}
msg.method = "GET";
msg.url = `${{BASE_URL}}/v1/prediction/list` +
`?key=${{encodeURIComponent(PREDICTION_KEY)}}` +
`&start_datetime=${{encodeURIComponent(toLocalIsoWithOffset(start))}}` +
`&end_datetime=${{encodeURIComponent(toLocalIsoWithOffset(end))}}` +
`&interval=${{encodeURIComponent("15 minutes")}}`;
return msg;
'''
PV_SERIES_FUNCTION = r'''const BASE_URL = "http://192.168.1.151:8503";
const PREDICTION_HOURS = 72;
const SLOT_MINUTES = 15;
const now = new Date();
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const slotStart = new Date(now);
slotStart.setSeconds(0, 0);
slotStart.setMinutes(Math.floor(slotStart.getMinutes() / SLOT_MINUTES) * SLOT_MINUTES);
const end = new Date(slotStart.getTime() + PREDICTION_HOURS * 3600000);
function iso(date) {
const pad = n => String(Math.trunc(Math.abs(n))).padStart(2, "0");
const offset = -date.getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
`${sign}${pad(offset / 60)}:${pad(offset % 60)}`;
}
// /list interpolates über das echte Forecast-Ende hinaus. /series liefert nur
// die tatsächlich vom Provider vorhandenen Zeitpunkte.
msg.method = "GET";
msg.url = `${BASE_URL}/v1/prediction/series?key=pvforecast_ac_power` +
`&start_datetime=${encodeURIComponent(iso(start))}` +
`&end_datetime=${encodeURIComponent(iso(end))}`;
return msg;
'''
PV_SERIES_TO_SLOTS_FUNCTION = r'''const SLOT_MINUTES = 15;
const PREDICTION_HOURS = 72;
const SLOT_MS = SLOT_MINUTES * 60000;
const raw = msg.payload && msg.payload.data;
if (!raw || typeof raw !== "object") {
node.warn("PV-Rohprognose fehlt oder hat kein data-Objekt.");
return null;
}
const points = Object.entries(raw)
.map(([timestamp, value]) => [new Date(timestamp).getTime(), Number(value)])
.filter(([timestamp, value]) => Number.isFinite(timestamp) && Number.isFinite(value))
.sort((a, b) => a[0] - b[0]);
if (points.length < 2) {
node.warn("PV-Rohprognose enthält weniger als zwei gültige Punkte.");
return null;
}
const now = new Date();
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const slotStart = new Date(now);
slotStart.setSeconds(0, 0);
slotStart.setMinutes(Math.floor(slotStart.getMinutes() / SLOT_MINUTES) * SLOT_MINUTES);
const endMs = slotStart.getTime() + PREDICTION_HOURS * 3600000;
const values = [];
let right = 1;
for (let timestamp = start.getTime(); timestamp < endMs; timestamp += SLOT_MS) {
if (timestamp < points[0][0]) {
values.push(0);
continue;
}
if (timestamp > points[points.length - 1][0]) {
// Absichtlich null: Function 3 und EOS verkürzen damit den Tail, statt
// einen erfundenen linearen PV-Rest als echten Forecast zu behandeln.
values.push(null);
continue;
}
while (right < points.length && points[right][0] < timestamp) right++;
const b = points[Math.min(right, points.length - 1)];
const a = points[Math.max(right - 1, 0)];
if (timestamp === b[0] || a[0] === b[0]) {
values.push(b[1]);
} else {
const fraction = (timestamp - a[0]) / (b[0] - a[0]);
values.push(a[1] + fraction * (b[1] - a[1]));
}
}
msg.topic = "pv_forecast";
msg.payload = values;
node.status({
fill: points[points.length - 1][0] >= endMs - SLOT_MS ? "green" : "yellow",
shape: "dot",
text: `PV echt bis ${new Date(points[points.length - 1][0]).toLocaleString()}`
});
return msg;
'''
LOAD_RESULT_FUNCTION = r'''// /gesamtlast aktualisiert den angepassten Last-Forecast. Die alte Endpoint-
// Antwort ist auf 48 Stunden begrenzt; anschließend lesen wir deshalb den
// aktualisierten 72-Stunden-Forecast über /v1/prediction/list.
const BASE_URL = "http://192.168.1.151:8503";
const PREDICTION_HOURS = 72;
const SLOT_MINUTES = 15;
const now = new Date();
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const slotStart = new Date(now);
slotStart.setSeconds(0, 0);
slotStart.setMinutes(Math.floor(slotStart.getMinutes() / SLOT_MINUTES) * SLOT_MINUTES);
const end = new Date(slotStart.getTime() + PREDICTION_HOURS * 3600000);
function iso(date) {
const pad = n => String(Math.trunc(Math.abs(n))).padStart(2, "0");
const offset = -date.getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
`${sign}${pad(offset / 60)}:${pad(offset % 60)}`;
}
msg.method = "GET";
msg.url = `${BASE_URL}/v1/prediction/list?key=loadforecast_power_w` +
`&start_datetime=${encodeURIComponent(iso(start))}` +
`&end_datetime=${encodeURIComponent(iso(end))}` +
`&interval=${encodeURIComponent("15 minutes")}`;
delete msg.payload;
return msg;
'''
TERMINAL_VALUE_FUNCTION = r'''// Tail und anschließenden Terminalwert der Optimierung -> MariaDB
const tv = msg.payload && msg.payload.terminal_value;
if (!tv) {
node.warn("Kein terminal_value im Payload - läuft EOS noch auf dem alten Stand?");
return null;
}
const curve = tv.curve;
const continuation = tv.continuation_curve;
const diag = tv.tail_diagnostics || {};
const d = new Date();
const p2 = v => ("0" + v).slice(-2);
const runTs = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ` +
`${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
const num = v => (v === null || v === undefined || isNaN(v)) ? "NULL" : Number(v).toFixed(8);
const str = v => "'" + String(v === null || v === undefined ? "" : v)
.replace(/'/g, "''").slice(0, 250) + "'";
function marginalAt(c, energyWh) {
if (!c || !Array.isArray(c.energy_wh) || c.energy_wh.length < 2) return null;
for (let i = 1; i < c.energy_wh.length; i++) {
if (energyWh <= c.energy_wh[i]) return c.marginal_euro_per_kwh[i - 1];
}
return c.marginal_euro_per_kwh[c.marginal_euro_per_kwh.length - 1] ?? null;
}
const res = msg.payload.result || {};
const priceNow = Array.isArray(res.Electricity_price) && res.Electricity_price.length
? res.Electricity_price[0] * 100000 : null;
const feedInNow = Array.isArray(res.Feed_in_tariff) && res.Feed_in_tariff.length
? res.Feed_in_tariff[0] * 100000 : null;
const marginalNow = marginalAt(curve, tv.battery_energy_wh);
const continuationMarginalNow = marginalAt(continuation, tv.battery_energy_wh);
const scalars = {
credited_euro: tv.credited_euro,
tail_operating_euro: tv.tail_operating_euro,
continuation_value_euro: tv.continuation_value_euro,
battery_energy_wh: tv.battery_energy_wh,
control_horizon_hours: tv.control_horizon_hours,
requested_tail_hours: tv.requested_tail_hours,
effective_tail_hours: tv.effective_tail_hours,
tail_end_hour: tv.tail_end_hour,
marginal_now_ct_kwh: marginalNow === null ? null : marginalNow * 100,
continuation_marginal_now_ct_kwh:
continuationMarginalNow === null ? null : continuationMarginalNow * 100,
price_now_ct_kwh: priceNow,
feed_in_now_ct_kwh: feedInNow,
tail_slots: diag.slots,
tail_slot_hours: diag.slot_hours,
tail_soc_grid_points: diag.soc_grid_points,
tail_min_import_ct_kwh: diag.min_import_price_euro_per_kwh * 100,
tail_max_import_ct_kwh: diag.max_import_price_euro_per_kwh * 100,
tail_min_feed_in_ct_kwh: diag.min_feed_in_tariff_euro_per_kwh * 100,
tail_max_feed_in_ct_kwh: diag.max_feed_in_tariff_euro_per_kwh * 100,
tail_negative_price_slots: diag.negative_import_price_slots,
tail_positive_export_slots: diag.positive_battery_export_slots,
mode_tail: tv.mode === "TAIL" ? 1 : 0,
mode_auto: (tv.mode === "TAIL" || tv.mode === "AUTO") ? 1 : 0
};
let sql = "START TRANSACTION;\n";
sql += `DELETE FROM eos_terminal_value WHERE run_ts = '${runTs}';\n`;
for (const topic in scalars) {
const info = topic === "mode_tail" || topic === "mode_auto"
? str(`${tv.mode}; continuation=${tv.continuation_mode}; ${tv.reason || "vollständiger Forecast"}`)
: "NULL";
sql += `INSERT INTO eos_terminal_value (run_ts, topic, data, info) VALUES ` +
`('${runTs}', '${topic}', ${num(scalars[topic])}, ${info});\n`;
}
if (curve && Array.isArray(curve.energy_wh) && curve.energy_wh.length) {
sql += `DELETE FROM eos_terminal_value_curve WHERE run_ts = '${runTs}';\n`;
const rows = curve.energy_wh.map((wh, i) => {
const marginal = i < curve.marginal_euro_per_kwh.length
? curve.marginal_euro_per_kwh[i] * 100 : null;
const operating = Array.isArray(curve.operating_value_euro)
? curve.operating_value_euro[i] : null;
const continuationValue = Array.isArray(curve.continuation_value_euro)
? curve.continuation_value_euro[i] : null;
return `('${runTs}', ${i}, ${num(wh)}, ${num(curve.value_euro[i])}, ` +
`${num(marginal)}, ${num(operating)}, ${num(continuationValue)})`;
});
sql += "INSERT INTO eos_terminal_value_curve " +
"(run_ts, point_idx, energy_wh, value_euro, marginal_ct_kwh, " +
"tail_operating_euro, continuation_value_euro) VALUES\n" + rows.join(",\n") + ";\n";
}
const tailPlan = Array.isArray(tv.tail_plan) ? tv.tail_plan : [];
if (tailPlan.length) {
const slotHours = Number(diag.slot_hours) || 0.25;
const slotMs = slotHours * 3600000;
const planStart = new Date(Math.floor(d.getTime() / slotMs) * slotMs);
sql += `DELETE FROM eos_tail_plan WHERE run_ts = '${runTs}';\n`;
const rows = tailPlan.map((slot, i) => {
const ts = new Date(planStart.getTime() + Number(slot.hour_from_start) * 3600000);
const tsSql = `${ts.getFullYear()}-${p2(ts.getMonth() + 1)}-${p2(ts.getDate())} ` +
`${p2(ts.getHours())}:${p2(ts.getMinutes())}:${p2(ts.getSeconds())}`;
return `('${runTs}', '${tsSql}', ${i}, ${str(slot.action)}, ` +
`${str(slot.alternative_action)}, ${num(slot.decision_margin_euro)}, ` +
`${num(slot.soc_start_percentage)}, ${num(slot.soc_end_percentage)}, ` +
`${num(slot.pv_wh)}, ${num(slot.load_wh)}, ${num(slot.grid_import_wh)}, ` +
`${num(slot.grid_export_wh)}, ${num(slot.battery_charge_wh)}, ` +
`${num(slot.battery_discharge_wh)}, ${num(slot.import_price_euro_per_kwh)}, ` +
`${num(slot.feed_in_tariff_euro_per_kwh)}, ${num(slot.slot_value_euro)}, ` +
`${num(slot.remaining_value_euro)}, ${num(slot.ac_charge_factor)}, ` +
`${num(slot.dc_charge_allowed)}, ${num(slot.discharge_allowed)}, ` +
`${num(slot.battery_grid_export_factor)})`;
});
sql += "INSERT INTO eos_tail_plan " +
"(run_ts, timestamp, slot, action, alternative_action, decision_margin_euro, " +
"soc_start_pct, soc_end_pct, pv_wh, load_wh, grid_import_wh, grid_export_wh, " +
"battery_charge_wh, battery_discharge_wh, import_price_euro_kwh, " +
"feed_in_tariff_euro_kwh, slot_value_euro, remaining_value_euro, " +
"ac_charge_factor, dc_charge_allowed, discharge_allowed, " +
"battery_grid_export_factor) VALUES\n" + rows.join(",\n") + ";\n";
}
sql += "DELETE FROM eos_terminal_value_curve WHERE run_ts < NOW() - INTERVAL 14 DAY;\n";
sql += "DELETE FROM eos_terminal_value WHERE run_ts < NOW() - INTERVAL 90 DAY;\n";
sql += "DELETE FROM eos_tail_plan WHERE run_ts < NOW() - INTERVAL 14 DAY;\n";
sql += "COMMIT;";
msg.topic = msg.payload = sql;
return msg;
'''
HORIZON_STATUS_FUNCTION = r'''const tv = msg.payload && msg.payload.terminal_value;
if (!tv) {
msg.payload = "Keine Horizont-Diagnose in der EOS-Antwort";
node.status({fill: "red", shape: "ring", text: msg.payload});
return msg;
}
const diag = tv.tail_diagnostics || {};
const status = {
genetische_steuerung: `${tv.control_horizon_hours} h`,
tail_optimierung: `${tv.effective_tail_hours} / ${tv.requested_tail_hours} h`,
tail_verfahren: "deterministische Batterie-DP (101 SoC-Stützstellen)",
terminalwert_nach_tail: tv.continuation_mode,
tail_ergebnis_euro: tv.tail_operating_euro,
terminalwert_euro: tv.continuation_value_euro,
gesamtgutschrift_euro: tv.credited_euro,
batterie_am_steuerende_wh: tv.battery_energy_wh,
tail_slots: diag.slots,
negative_preis_slots_im_tail: diag.negative_import_price_slots,
begruendung: tv.reason || "vollständiger Forecast"
};
msg.payload = status;
node.status({
fill: tv.effective_tail_hours === tv.requested_tail_hours ? "green" : "yellow",
shape: "dot",
text: `GA ${tv.control_horizon_hours}h | Tail ${tv.effective_tail_hours}/${tv.requested_tail_hours}h | TV ${tv.continuation_mode}`
});
return msg;
'''
def node_by_id(nodes: list[dict], node_id: str) -> dict:
return next(node for node in nodes if node.get("id") == node_id)
def replace_active_function_3(source: str) -> str:
source = source.split("\n/*const SPH", 1)[0].rstrip()
source = source.replace(
"const SPH = 4; // 4 bei 900 Sekunden\nconst N = 48 * SPH; // 192 Slots",
"const INTERVAL_SECONDS = 900;\n"
"const SPH = 3600 / INTERVAL_SECONDS;\n"
"const CONTROL_HOURS = 24;\n"
"const TAIL_HOURS = 48;\n"
"const PREDICTION_HOURS = CONTROL_HOURS + TAIL_HOURS;\n"
"const CONTROL_SLOTS = CONTROL_HOURS * SPH;\n"
"const PREDICTION_SLOTS = PREDICTION_HOURS * SPH;",
)
old = re.search(
r"function toSlots\(values, isEnergy\) \{.*?\n\}\n\nfunction clampPercentage",
source,
flags=re.DOTALL,
)
if old is None:
raise RuntimeError("toSlots block in function 3 was not found")
new = r'''function toSlots(values, isEnergy, name) {
if (!Array.isArray(values)) return null;
const now = new Date();
const midnight = new Date(now);
midnight.setHours(0, 0, 0, 0);
const slotStart = new Date(now);
slotStart.setSeconds(0, 0);
slotStart.setMinutes(Math.floor(slotStart.getMinutes() / 15) * 15);
const elapsedSlots = Math.floor((slotStart.getTime() - midnight.getTime()) / 900000);
const minimumLength = elapsedSlots + CONTROL_SLOTS;
const requestedLength = elapsedSlots + PREDICTION_SLOTS;
let validLength = values.length;
const clean = values.map((value, index) => {
if (value === null || value === undefined || value === "") {
// Dieser Bereich wird von EOS ohnehin abgeschnitten. Fehlende alte
// Tageswerte dürfen deshalb den rollenden Forecast nicht blockieren.
if (index < elapsedSlots) return 0;
validLength = Math.min(validLength, index);
return NaN;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
if (index < elapsedSlots) return 0;
validLength = Math.min(validLength, index);
}
return parsed;
});
if (validLength < minimumLength) {
node.warn(`${name}: nur ${validLength} gültige Viertelstundenwerte ab Mitternacht; ` +
`mindestens ${minimumLength} für den 24-h-Steuerhorizont erforderlich.`);
return null;
}
if (validLength < requestedLength) {
node.warn(`${name}: Tail verkürzt; ${validLength - elapsedSlots - CONTROL_SLOTS} von ` +
`${TAIL_HOURS * SPH} Tail-Slots verfügbar.`);
}
const slots = clean.slice(0, Math.min(validLength, requestedLength));
// Die v1-Endpoints liefern Leistung in W. Für den 15-min-EMS-Slot wird
// daraus bei PV und Last Energie in Wh; Preise bleiben EUR/Wh.
return isEnergy ? slots.map(value => value / SPH) : slots;
}
function clampPercentage'''
source = source[: old.start()] + new + source[old.end() :]
source = source.replace(
"const pv = toSlots(context.data.pv_forecast, true);\n"
"const preis = toSlots(context.data.strompreis, false);\n"
"const einsp = toSlots(context.data.feed_in_tariff_wh, false);\n"
"const last = toSlots(context.data.gesamtlast, true);",
"const pv = toSlots(context.data.pv_forecast, true, \"PV-Prognose\");\n"
"const preis = toSlots(context.data.strompreis, false, \"Strompreis\");\n"
"const einsp = toSlots(context.data.feed_in_tariff_wh, false, \"Einspeisevergütung\");\n"
"const last = toSlots(context.data.gesamtlast, true, \"Gesamtlast\");",
)
source = source.replace("N * (optimizeEv ? 2 : 1)", "CONTROL_SLOTS * (optimizeEv ? 2 : 1)")
source = source.replace(
"msg.payload = {\n ems:",
"msg.payload = {\n forecast_interval_seconds: INTERVAL_SECONDS,\n\n ems:",
)
source = source.replace(
"`Warm-Start=${startSolution !== null}`",
"`Horizonte=${CONTROL_HOURS}h GA + ${TAIL_HOURS}h Tail, ` +\n"
" `Forecast-Slots(PV/Preis/Tarif/Last)=${pv.length}/${preis.length}/${einsp.length}/${last.length}, ` +\n"
" `Warm-Start=${startSolution !== null}`",
)
return source + "\n"
def update_control_index(source: str) -> str:
source = source.replace(
"let indexForCurrentHour = values_eauto.length > 48\n"
" ? currentDate.getHours() * 4 + Math.floor(currentDate.getMinutes() / 15)\n"
" : currentHour;",
"let indexForCurrentHour = msg.payload.controls_start_at_now === true\n"
" ? 0\n"
" : (values_eauto.length > 48\n"
" ? currentDate.getHours() * 4 + Math.floor(currentDate.getMinutes() / 15)\n"
" : currentHour);",
)
source = source.replace(
"let i = dis.length > 48 ? d.getHours() * 4 + Math.floor(d.getMinutes() / 15) : d.getHours();",
"let i = msg.payload.controls_start_at_now === true\n"
" ? 0\n"
" : (dis.length > 48 ? d.getHours() * 4 + Math.floor(d.getMinutes() / 15) : d.getHours());",
)
source = source.replace(
"var idx = values.length > 48\n"
" ? now.getHours() * 4 + Math.floor(now.getMinutes() / 15)\n"
" : now.getHours();",
"var idx = msg.payload && msg.payload.controls_start_at_now === true\n"
" ? 0\n"
" : (values.length > 48\n"
" ? now.getHours() * 4 + Math.floor(now.getMinutes() / 15)\n"
" : now.getHours());",
)
return source
FUNCTION_4 = r'''const values = msg.payload.discharge_allowed || [];
const valuesAc = msg.payload.ac_charge || [];
const valuesDc = msg.payload.dc_charge || [];
const valuesExport = msg.payload.battery_grid_export_allowed || [];
const valuesEv = msg.payload.eautocharge_hours_float || [];
const sph = values.length > 48 ? 4 : 1;
const slotMs = 3600000 / sph;
const now = new Date();
const legacyMidnight = new Date(now);
legacyMidnight.setHours(0, 0, 0, 0);
const currentSlot = now.getHours() * sph + Math.floor(now.getMinutes() / (60 / sph));
const runRelative = msg.payload.controls_start_at_now === true;
const planStart = runRelative
? new Date(Math.floor(now.getTime() / slotMs) * slotMs)
: legacyMidnight;
const firstIndex = runRelative ? 0 : currentSlot;
const applianceStarts = (msg.payload.appliance_starts || {}).spuelmaschine || [];
const applianceStartMs = new Set(applianceStarts.map(value => new Date(value).getTime()));
function sqlValue(array, index) {
return Array.isArray(array) && index < array.length && Number.isFinite(Number(array[index]))
? Number(array[index]) : null;
}
function timestamp(date) {
const p2 = value => ("0" + value).slice(-2);
return `${date.getFullYear()}-${p2(date.getMonth() + 1)}-${p2(date.getDate())} ` +
`${p2(date.getHours())}:${p2(date.getMinutes())}:00`;
}
let sql = "START TRANSACTION;\n";
// Alte 48-h-Pläne oder frühere Läufe dürfen hinter dem neuen 24-h-Plan keine
// scheinbaren SoC-Sprünge und keine veralteten Schaltwerte hinterlassen.
sql += `DELETE FROM eos WHERE timestamp >= '${timestamp(planStart)}';\n`;
values.forEach((value, index) => {
if (index < firstIndex) return;
const ts = new Date(planStart.getTime() + index * slotMs);
const tsString = timestamp(ts);
sql += `DELETE FROM eos WHERE timestamp = '${tsString}';\n`;
sql += `INSERT INTO eos (timestamp, topic, data) VALUES ('${tsString}','discharge_allowed', ${Number(value)});\n`;
sql += `INSERT INTO eos (timestamp, topic, data) VALUES ('${tsString}','ac_charge', ${sqlValue(valuesAc, index) ?? 0});\n`;
sql += `INSERT INTO eos (timestamp, topic, data) VALUES ('${tsString}','dc_charge', ${sqlValue(valuesDc, index) ?? 0});\n`;
const exportValue = sqlValue(valuesExport, index);
const evValue = sqlValue(valuesEv, index);
if (exportValue !== null) sql += `INSERT INTO eos (timestamp, topic, data) VALUES ('${tsString}','battery_grid_export_allowed', ${exportValue});\n`;
if (evValue !== null) sql += `INSERT INTO eos (timestamp, topic, data) VALUES ('${tsString}','eautocharge_hours_float', ${evValue});\n`;
const startsHere = applianceStartMs.has(ts.getTime()) ? 1 : 0;
sql += `INSERT INTO eos (timestamp, topic, data) VALUES ('${tsString}','spuelstart_hours_bin', ${startsHere});\n`;
});
sql += "COMMIT;";
msg.topic = msg.payload = sql;
return msg;
'''
def update_simulation_timebase(source: str) -> str:
pattern = re.compile(
r"// Startzeitpunkt ab der jetzigen Stunde.*?let currentHour = now\.getHours\(\); // Offset für die 48h-Top-Level-Arrays",
re.DOTALL,
)
replacement = r'''// Neue Antworten beginnen mit dem laufenden Slot (controls_start_at_now=true).
// Der Legacy-Zweig bleibt für ältere EOS-Antworten erhalten.
let sph = (msg.payload.discharge_allowed || []).length > 48 ? 4 : 1;
let stepMs = 3600000 / sph;
let now = new Date();
if (msg.payload.controls_start_at_now === true) {
now = new Date(Math.floor(now.getTime() / stepMs) * stepMs);
} else {
let startSlot = msg.payload.discharge_allowed.length - data.Last_Wh_pro_Stunde.length - 1;
now.setHours(0, 0, 0, 0);
now = new Date(now.getTime() + startSlot * stepMs);
}
let currentHour = now.getHours();'''
updated, count = pattern.subn(replacement, source, count=1)
if count != 1:
raise RuntimeError("simulation_data timebase block was not found")
updated = updated.replace(
'let sqlStatements = "";',
'''const p2Start = value => ("0" + value).slice(-2);
const planStartSql = `${now.getFullYear()}-${p2Start(now.getMonth() + 1)}-${p2Start(now.getDate())} ` +
`${p2Start(now.getHours())}:${p2Start(now.getMinutes())}:00`;
let sqlStatements = "START TRANSACTION;\\n" +
`DELETE FROM eos_simulation_data WHERE timestamp >= '${planStartSql}';\\n`;''',
1,
)
updated = updated.replace(
"// Das generierte SQL-Statement in msg.topic einfügen\nmsg.topic = sqlStatements;",
'// Das generierte SQL-Statement in msg.topic einfügen\nsqlStatements += "COMMIT;\\n";\nmsg.topic = sqlStatements;',
1,
)
return updated
SCHEMA_INFO = r'''Einmalig auf MariaDB in der Datenbank `sensor` ausführen:
CREATE TABLE IF NOT EXISTS eos_terminal_value (
run_ts DATETIME NOT NULL,
topic VARCHAR(64) NOT NULL,
data DOUBLE NULL,
info VARCHAR(255) NULL,
PRIMARY KEY (run_ts, topic)
);
CREATE TABLE IF NOT EXISTS eos_terminal_value_curve (
run_ts DATETIME NOT NULL,
point_idx SMALLINT NOT NULL,
energy_wh DOUBLE NOT NULL,
value_euro DOUBLE NOT NULL,
marginal_ct_kwh DOUBLE NULL,
tail_operating_euro DOUBLE NULL,
continuation_value_euro DOUBLE NULL,
PRIMARY KEY (run_ts, point_idx),
KEY (run_ts)
);
CREATE TABLE IF NOT EXISTS eos_tail_plan (
run_ts DATETIME NOT NULL,
timestamp DATETIME NOT NULL,
slot SMALLINT NOT NULL,
action VARCHAR(32) NOT NULL,
alternative_action VARCHAR(32) NULL,
decision_margin_euro DOUBLE NULL,
soc_start_pct DOUBLE NULL,
soc_end_pct DOUBLE NULL,
pv_wh DOUBLE NULL,
load_wh DOUBLE NULL,
grid_import_wh DOUBLE NULL,
grid_export_wh DOUBLE NULL,
battery_charge_wh DOUBLE NULL,
battery_discharge_wh DOUBLE NULL,
import_price_euro_kwh DOUBLE NULL,
feed_in_tariff_euro_kwh DOUBLE NULL,
slot_value_euro DOUBLE NULL,
remaining_value_euro DOUBLE NULL,
ac_charge_factor DOUBLE NULL,
dc_charge_allowed TINYINT NULL,
discharge_allowed TINYINT NULL,
battery_grid_export_factor DOUBLE NULL,
PRIMARY KEY (run_ts, slot),
KEY ix_eos_tail_plan_timestamp (timestamp),
KEY ix_eos_tail_plan_run (run_ts)
);
Bei bereits vorhandener Tabelle einmalig ergänzen:
ALTER TABLE eos_terminal_value_curve
ADD COLUMN IF NOT EXISTS tail_operating_euro DOUBLE NULL,
ADD COLUMN IF NOT EXISTS continuation_value_euro DOUBLE NULL;
'''
GRAFANA_INFO = r'''Grafana-Ausgaben für die getrennten Bereiche:
1) Tail und Terminalwert pro Optimierungslauf (Time series)
SELECT run_ts AS time, data AS value, topic AS metric
FROM eos_terminal_value
WHERE $__timeFilter(run_ts)
AND topic IN ('tail_operating_euro','continuation_value_euro','credited_euro')
ORDER BY run_ts;
2) Effektiver Tail gegen Soll-Tail (Time series)
SELECT run_ts AS time, data AS value, topic AS metric
FROM eos_terminal_value
WHERE $__timeFilter(run_ts)
AND topic IN ('requested_tail_hours','effective_tail_hours')
ORDER BY run_ts;
3) Wertkurve des letzten Laufs, getrennt nach Komponenten (XY/Trend, X=energy_wh)
SELECT energy_wh, tail_operating_euro, continuation_value_euro, value_euro
FROM eos_terminal_value_curve
WHERE run_ts = (SELECT MAX(run_ts) FROM eos_terminal_value_curve)
ORDER BY point_idx;
Dabei gilt: value_euro = tail_operating_euro + continuation_value_euro.
4) Tail-Preisspanne und negative Preise (Time series)
SELECT run_ts AS time, data AS value, topic AS metric
FROM eos_terminal_value
WHERE $__timeFilter(run_ts)
AND topic IN ('tail_min_import_ct_kwh','tail_max_import_ct_kwh',
'tail_negative_price_slots','tail_positive_export_slots')
ORDER BY run_ts;
5) Grenzwert gegen aktuellen Preis (Time series)
SELECT run_ts AS time, data AS value, topic AS metric
FROM eos_terminal_value
WHERE $__timeFilter(run_ts)
AND topic IN ('marginal_now_ct_kwh','continuation_marginal_now_ct_kwh',
'price_now_ct_kwh','feed_in_now_ct_kwh')
ORDER BY run_ts;
'''
def update_flow(source_path: Path, output_path: Path) -> None:
nodes = json.loads(source_path.read_text(encoding="utf-8-sig"))
prediction_nodes = {
"9ea11560f93ca817": "elecprice_marketprice_wh",
"670b95cb8913ac03": "pvforecast_ac_power",
"62eba1e8e3fdf8a8": "feed_in_tariff_wh",
}
for node_id, key in prediction_nodes.items():
node_by_id(nodes, node_id)["func"] = PREDICTION_FUNCTION.format(key=key)
node_by_id(nodes, "670b95cb8913ac03")["func"] = PV_SERIES_FUNCTION
node_by_id(nodes, "9ea11560f93ca817")["name"] = "Prediction URL: Strompreis 72h"
node_by_id(nodes, "670b95cb8913ac03")["name"] = "Prediction URL: PV 72h"
node_by_id(nodes, "62eba1e8e3fdf8a8")["name"] = "Prediction URL: Einspeisetarif 72h"
function3 = node_by_id(nodes, "a10f03b13dc84c70")
function3["name"] = "EOS Request: 24h Steuerung + 48h Tail"
function3["func"] = replace_active_function_3(function3["func"])
# Preserve the adjusted-load update, then read the full native forecast.
load_builder = node_by_id(nodes, "79da21908d49f3a4")
load_builder["name"] = "Load forecast URL 72h"
load_builder["func"] = LOAD_RESULT_FUNCTION
load_builder["wires"] = [["e17d6c2f7a2db604"]]
nodes.extend(
[
{
"id": "e17d6c2f7a2db604",
"type": "http request",
"z": load_builder["z"],
"name": "Load prediction read 72h",
"method": "GET",
"ret": "obj",
"paytoqs": "ignore",
"url": "{{{url}}}",
"tls": "",
"persist": False,
"proxy": "",
"insecureHTTPParser": False,
"authType": "",
"senderr": False,
"headers": [],
"x": 1530,
"y": 940,
"wires": [["875e0ce6f508eb8e"]],
},
{
"id": "875e0ce6f508eb8e",
"type": "function",
"z": load_builder["z"],
"name": "rename gesamtlast",
"func": 'msg.topic = "gesamtlast";\nreturn msg;\n',
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1750,
"y": 940,
"wires": [["a10f03b13dc84c70", "40bd1d84308a5867"]],
},
]
)
# In the source flow PV and /gesamtlast shared node 79da... as a simple
# pass-through. That node now belongs exclusively to the two-stage load
# refresh, so PV must bypass it and reach function 3 directly.
pv_rename = node_by_id(nodes, "abaaa368bd4cf66a")
pv_rename["name"] = "PV series -> echte 15-min-Slots"
pv_rename["func"] = PV_SERIES_TO_SLOTS_FUNCTION
pv_rename["wires"] = [["01081b5bc242bbb9", "a10f03b13dc84c70"]]
# Controls returned by the new API are indexed from the current slot.
node_by_id(nodes, "c2a6307eb669063a")["name"] = "Plan speichern (ab jetzt)"
node_by_id(nodes, "c2a6307eb669063a")["func"] = FUNCTION_4
simulation_node = node_by_id(nodes, "600feebbd9c0503a")
simulation_node["name"] = "Simulation speichern (ab jetzt)"
simulation_node["func"] = update_simulation_timebase(simulation_node["func"])
for node_id in (
"80ad76b39bed8ad2",
"08393d5c53be1551",
"6ea3455e6d12ed84",
"3e1ffe2cf5c11f36",
):
node_by_id(nodes, node_id)["func"] = update_control_index(node_by_id(nodes, node_id)["func"])
node_by_id(nodes, "c7c1bc8ffb849dd9")["name"] = "Tail + Terminalwert speichern"
node_by_id(nodes, "c7c1bc8ffb849dd9")["func"] = TERMINAL_VALUE_FUNCTION
node_by_id(nodes, "b8ac7ea4ea819b95")["info"] = SCHEMA_INFO
node_by_id(nodes, "a8f4519fc6d671bf")["info"] = GRAFANA_INFO
# Fix the one inconsistent EOS host address in the manual force-update node.
node_by_id(nodes, "4b50b8d0c073da5d")["url"] = (
"http://192.168.1.151:8503/v1/prediction/update?force_update=true"
)
optimize = node_by_id(nodes, "89c6553101552b40")
optimize["name"] = "EOS /optimize"
optimize["wires"][0].append("f07e64ad81a369bf")
nodes.extend(
[
{
"id": "f07e64ad81a369bf",
"type": "function",
"z": optimize["z"],
"name": "Horizonte: GA / Tail / Terminalwert",
"func": HORIZON_STATUS_FUNCTION,
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1570,
"y": 1340,
"wires": [["972c33086928996f"]],
},
{
"id": "972c33086928996f",
"type": "debug",
"z": optimize["z"],
"name": "Tail-/Terminalwert-Diagnose",
"active": True,
"tosidebar": True,
"console": False,
"tostatus": False,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 1850,
"y": 1340,
"wires": [],
},
]
)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(nodes, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
update_flow(args.source, args.output)
if __name__ == "__main__":
main()