Files
EOS/docs/akkudoktoreos/nodered_tail_plan_function.js
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

125 lines
6.4 KiB
JavaScript

// 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;