mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-13 17:41:56 +00:00
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.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user