mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 07:56:40 +00:00
795 lines
33 KiB
Python
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()
|