mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-15 02:21: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:
@@ -108,3 +108,5 @@ Some of the `configuration keys` have default values by definition. For most of
|
||||
:relative-docs: ..
|
||||
:relative-images:
|
||||
```
|
||||
|
||||
See [Control horizon and battery lookahead](optimization_horizons.md) for horizon validation, forecast availability and control-array indexing.
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
# Tail-Optimierung in Grafana debuggen
|
||||
|
||||
Ziel sind drei vorhandene, zeitlich übereinanderliegende Panels:
|
||||
|
||||
1. **Strompreis** – welcher wirtschaftliche Anreiz besteht?
|
||||
2. **Steuerplan** – welche Betriebsart wurde gewählt?
|
||||
3. **Batterie-SoC** – was bewirkt die Entscheidung?
|
||||
|
||||
Die ersten 24 Stunden sind der echte Steuerplan. Danach folgt der intern
|
||||
optimierte Tail. Der Tail ist ausschließlich Diagnose und wird nicht als
|
||||
Steuerbefehl ausgegeben.
|
||||
|
||||
> Im Grafana Query Editor alle Namen ohne Backslashes eingeben. Richtig ist
|
||||
> `eos_tail_plan`, nicht `eos\_tail\_plan`. Auch vor `$__timeFilter` und
|
||||
> `$__timeGroupAlias` steht kein Backslash.
|
||||
|
||||
## 1. Prüfen, ob die neue EOS-Version läuft
|
||||
|
||||
EOS neu starten und einmal `/optimize` ausführen. In der vollständigen Antwort
|
||||
muss `terminal_value.tail_plan` vorhanden und gefüllt sein.
|
||||
|
||||
Bei 15-Minuten-Intervallen enthält es normalerweise 192 Einträge bei 48 Stunden
|
||||
Tail oder 190 Einträge bei einem auf 47,5 Stunden gekürzten Tail. Fehlt das Feld
|
||||
oder ist es `[]`, läuft noch die alte EOS-Version. Dann kann Node-RED nichts für
|
||||
Grafana speichern.
|
||||
|
||||
## 2. MariaDB-Tabelle einmalig anlegen
|
||||
|
||||
Das SQL aus [`nodered_tail_plan_schema.sql`](nodered_tail_plan_schema.sql)
|
||||
einmal in der Datenbank `sensor` ausführen. Danach prüfen:
|
||||
|
||||
```sql
|
||||
SHOW TABLES LIKE 'eos_tail_plan';
|
||||
```
|
||||
|
||||
Es muss eine Zeile mit `eos_tail_plan` erscheinen.
|
||||
|
||||
## 3. Nur einen Node-RED-Node ändern
|
||||
|
||||
1. Den Function-Node **Tail + Terminalwert speichern** öffnen.
|
||||
2. Ausschließlich dessen Funktionsinhalt ersetzen.
|
||||
3. Den Inhalt aus
|
||||
[`nodered_tail_plan_function.js`](nodered_tail_plan_function.js) verwenden.
|
||||
4. **Done** und danach **Deploy** drücken.
|
||||
5. Die EOS-Optimierung erneut ausführen.
|
||||
|
||||
## 4. Vor Grafana prüfen, ob Node-RED Daten geschrieben hat
|
||||
|
||||
Diese Abfrage direkt in Grafana Explore ausführen. Format: `Table`.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) AS "Tail-Zeilen",
|
||||
MAX(run_ts) AS "Letzter Lauf",
|
||||
MIN(timestamp) AS "Tail beginnt",
|
||||
MAX(timestamp) AS "Letzter Tail-Slot"
|
||||
FROM eos_tail_plan;
|
||||
```
|
||||
|
||||
Erwartet werden 192 Zeilen für einen vollständigen Lauf beziehungsweise 190
|
||||
Zeilen für 47,5 Stunden. Steht dort `0`, liegt das Problem noch bei EOS oder
|
||||
Node-RED. Dann kann keine Grafana-Zeitabfrage Daten zeigen.
|
||||
|
||||
Zur Kontrolle der Inhalte:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
run_ts,
|
||||
timestamp,
|
||||
slot,
|
||||
action,
|
||||
soc_start_pct,
|
||||
soc_end_pct,
|
||||
import_price_euro_kwh
|
||||
FROM eos_tail_plan
|
||||
ORDER BY run_ts DESC, slot
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
## 5. Dashboard-Zeitbereich einstellen
|
||||
|
||||
Oben rechts:
|
||||
|
||||
```text
|
||||
From: now-2h
|
||||
To: now+72h
|
||||
```
|
||||
|
||||
Panels mit Ist-Daten wie Tesla, Wärmepumpen und Temperaturen behalten ihre
|
||||
kurzen Panel-Zeitbereiche.
|
||||
|
||||
## 6. Panel „Strompreis“ erweitern
|
||||
|
||||
Im vorhandenen Strompreis-Panel eine Query hinzufügen. Format: `Time series`.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias(timestamp,$__interval),
|
||||
AVG(import_price_euro_kwh) AS "Preis Tail – nur Bewertung"
|
||||
FROM eos_tail_plan
|
||||
WHERE
|
||||
$__timeFilter(timestamp)
|
||||
AND run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan)
|
||||
GROUP BY 1
|
||||
ORDER BY 1;
|
||||
```
|
||||
|
||||
Für die Tail-Linie Farbe Gelb, Line style `Dashes`, Line width `2` und Fill
|
||||
opacity `0` einstellen. Die Werte sind bereits in €/kWh. Kein weiteres
|
||||
`* 1000` anwenden.
|
||||
|
||||
## 7. Panel „Steuerplan“ erweitern
|
||||
|
||||
Die vorhandenen Zeilen `Disch`, `Spuel`, `AC`, `DC` und `DV` sind einzelne
|
||||
0/1-Steuersignale des ausführbaren 24-h-Plans. Der Tail hat dagegen genau eine
|
||||
gewählte Betriebsart pro Slot. Deshalb wird er als **eine zusätzliche
|
||||
Text-Zeile** dargestellt und nicht auf die vorhandenen 0/1-Zeilen verteilt.
|
||||
|
||||
Im State-Timeline-Panel eine neue Query hinzufügen. Format: `Table`.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
timestamp AS time,
|
||||
CASE action
|
||||
WHEN 'HOLD' THEN 'Halten'
|
||||
WHEN 'PV_CHARGE_ONLY' THEN 'Nur PV laden'
|
||||
WHEN 'SELF_CONSUMPTION' THEN 'Haus aus PV/Akku'
|
||||
WHEN 'DISCHARGE_ONLY' THEN 'Akku entladen'
|
||||
WHEN 'GRID_CHARGE' THEN 'Akku aus Netz laden'
|
||||
WHEN 'BATTERY_EXPORT' THEN 'Akku ins Netz verkaufen'
|
||||
ELSE 'Unbekannt'
|
||||
END AS "Tail – gedachte Entscheidung"
|
||||
FROM eos_tail_plan
|
||||
WHERE
|
||||
$__timeFilter(timestamp)
|
||||
AND run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan)
|
||||
ORDER BY timestamp;
|
||||
```
|
||||
|
||||
`Merge equal consecutive values` einschalten und `Show values` auf `Always`
|
||||
setzen. Für das Feld `Tail – gedachte Entscheidung` Value mappings mit Farben
|
||||
anlegen: Netzladen blau, Batterieverkauf orange, Haus aus PV/Akku grün,
|
||||
Nur-PV-Laden türkis, Entladen gelb und Halten grau. Weil die Abfrage bereits
|
||||
verständliche Texte zurückgibt, dienen die Mappings nur noch der Farbe.
|
||||
|
||||
Die Tail-Zeile bleibt während der ersten 24 Stunden absichtlich leer. Sie
|
||||
beginnt erst an der Grenze zum Tail. In den Standard options `No value` leeren,
|
||||
damit Grafana für diesen Abschnitt nicht `-1` im Tooltip anzeigt.
|
||||
|
||||
## 8. Panel „Batterie SoC / Current“ erweitern
|
||||
|
||||
Neue Query, Format `Time series`:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias(timestamp,$__interval),
|
||||
AVG(soc_end_pct) AS "SoC Tail – nur Bewertung"
|
||||
FROM eos_tail_plan
|
||||
WHERE
|
||||
$__timeFilter(timestamp)
|
||||
AND run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan)
|
||||
GROUP BY 1
|
||||
ORDER BY 1;
|
||||
```
|
||||
|
||||
Override für `SoC Tail – nur Bewertung`: Einheit `Percent (0-100)`, dieselbe
|
||||
Achse wie der bisherige Prognose-SoC, gestrichelte hellblaue Linie, Breite 2,
|
||||
Punkte aus.
|
||||
|
||||
## 9. Panel „Bezug & Einspeisung“ erweitern
|
||||
|
||||
Zuerst die Batterieleistung ergänzen:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias(p.timestamp,$__interval),
|
||||
AVG(
|
||||
(p.battery_charge_wh - p.battery_discharge_wh)
|
||||
/ NULLIF(v.data, 0)
|
||||
) AS "Akku Tail (+ Laden / - Entladen)"
|
||||
FROM eos_tail_plan p
|
||||
JOIN eos_terminal_value v
|
||||
ON v.run_ts = p.run_ts
|
||||
AND v.topic = 'tail_slot_hours'
|
||||
WHERE
|
||||
$__timeFilter(p.timestamp)
|
||||
AND p.run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan)
|
||||
GROUP BY 1
|
||||
ORDER BY 1;
|
||||
```
|
||||
|
||||
Diese Reihe beschreibt den Akku, nicht den Netzanschluss. Positive Werte sind
|
||||
Ladung, negative Werte Entladung. Vorhandene PV kann auch direkt die Last
|
||||
decken oder ins Netz fließen und erzeugt deshalb nicht zwingend einen positiven
|
||||
Batteriewert.
|
||||
|
||||
Tail-Netzbezug, Format `Time series`:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias(p.timestamp,$__interval),
|
||||
AVG(p.grid_import_wh / NULLIF(v.data, 0)) AS "Bezug Tail"
|
||||
FROM eos_tail_plan p
|
||||
JOIN eos_terminal_value v
|
||||
ON v.run_ts = p.run_ts
|
||||
AND v.topic = 'tail_slot_hours'
|
||||
WHERE
|
||||
$__timeFilter(p.timestamp)
|
||||
AND p.run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan)
|
||||
GROUP BY 1
|
||||
ORDER BY 1;
|
||||
```
|
||||
|
||||
Tail-Einspeisung, Format `Time series`:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias(p.timestamp,$__interval),
|
||||
-AVG(p.grid_export_wh / NULLIF(v.data, 0)) AS "Einspeisung Tail"
|
||||
FROM eos_tail_plan p
|
||||
JOIN eos_terminal_value v
|
||||
ON v.run_ts = p.run_ts
|
||||
AND v.topic = 'tail_slot_hours'
|
||||
WHERE
|
||||
$__timeFilter(p.timestamp)
|
||||
AND p.run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan)
|
||||
GROUP BY 1
|
||||
ORDER BY 1;
|
||||
```
|
||||
|
||||
Beide Tail-Reihen gestrichelt darstellen. Positive Werte sind Bezug, negative
|
||||
Werte Einspeisung.
|
||||
|
||||
## 10. Grenzen der drei Bereiche markieren
|
||||
|
||||
Unter **Dashboard settings → Annotations → Add annotation query**:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
MIN(timestamp) AS time,
|
||||
'Tail beginnt – ab hier nur Bewertung' AS text
|
||||
FROM eos_tail_plan
|
||||
WHERE run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
DATE_ADD(MAX(timestamp), INTERVAL 15 MINUTE) AS time,
|
||||
'Prognoseende – danach greift der Restwert' AS text
|
||||
FROM eos_tail_plan
|
||||
WHERE run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan);
|
||||
```
|
||||
|
||||
Die erste senkrechte Linie trennt die reale Steuerung vom Tail. Die zweite
|
||||
Linie markiert den Terminalpunkt.
|
||||
|
||||
Unter **Dashboard settings → General → Graph tooltip** zusätzlich `Shared
|
||||
crosshair` wählen. Beim Überfahren eines Zeitpunkts steht der Cursor dann in
|
||||
Strompreis, Steuerplan, SoC, PV sowie Bezug/Einspeisung an derselben Stelle.
|
||||
Das macht einzelne Entscheidungen wesentlich leichter nachvollziehbar.
|
||||
|
||||
## 11. Kleines Panel „Wie eindeutig war die Entscheidung?“
|
||||
|
||||
Den bisherigen zeitunabhängigen Terminalwert-Kurvenplot durch ein kleines
|
||||
`Time series`-Panel ersetzen:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
timestamp AS time,
|
||||
decision_margin_euro * 100 AS "Vorsprung vor Alternative"
|
||||
FROM eos_tail_plan
|
||||
WHERE
|
||||
$__timeFilter(timestamp)
|
||||
AND run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan)
|
||||
ORDER BY timestamp;
|
||||
```
|
||||
|
||||
Einheit: `Currency → Cent`, Minimum `0`, Linienbreite `2`, Punkte `Auto`.
|
||||
Der Wert vergleicht die gewählte Betriebsart einschließlich ihrer späteren
|
||||
Folgen mit der besten anders benannten Betriebsart. Nahe `0 ct` war die Wahl
|
||||
fast gleichwertig und kann schon durch kleine Prognoseänderungen umspringen.
|
||||
Ein größerer Wert bedeutet eine robuste Entscheidung.
|
||||
|
||||
## 12. Verdächtige Entscheidungen prüfen
|
||||
|
||||
Für einzelne Lade- oder Entladeereignisse ein temporäres Table-Panel anlegen:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
timestamp AS "Zeit",
|
||||
CASE action
|
||||
WHEN 'GRID_CHARGE' THEN 'Netzladen'
|
||||
WHEN 'BATTERY_EXPORT' THEN 'Batterieverkauf'
|
||||
WHEN 'SELF_CONSUMPTION' THEN 'Normalbetrieb'
|
||||
WHEN 'PV_CHARGE_ONLY' THEN 'PV-Laden'
|
||||
WHEN 'DISCHARGE_ONLY' THEN 'Entladen'
|
||||
ELSE 'Halten'
|
||||
END AS "Gewählt",
|
||||
alternative_action AS "Beste andere Betriebsart",
|
||||
ROUND(import_price_euro_kwh, 3) AS "Preis €/kWh",
|
||||
ROUND(soc_start_pct, 1) AS "SoC vorher %",
|
||||
ROUND(soc_end_pct, 1) AS "SoC nachher %",
|
||||
ROUND(battery_charge_wh, 0) AS "Akku geladen Wh",
|
||||
ROUND(battery_discharge_wh, 0) AS "Akku entladen Wh",
|
||||
ROUND(grid_import_wh, 0) AS "Netzbezug Wh",
|
||||
ROUND(grid_export_wh, 0) AS "Einspeisung Wh",
|
||||
ROUND(decision_margin_euro * 100, 3) AS "Vorteil ct"
|
||||
FROM eos_tail_plan
|
||||
WHERE run_ts = (SELECT MAX(run_ts) FROM eos_tail_plan)
|
||||
ORDER BY timestamp;
|
||||
```
|
||||
|
||||
`Vorteil ct` deutlich positiv bedeutet, dass die Aktion einschließlich der
|
||||
späteren Slots besser als die beste andere Betriebsart war. Ein Wert nahe null
|
||||
zeigt einen nahezu gleichwertigen und damit instabilen Entschluss. Fällt der
|
||||
SoC bei positiver Einspeisung, wurde Energie verkauft. Fällt er ohne
|
||||
Einspeisung bei vorhandener Last, versorgt die Batterie das Haus. Fällt der SoC
|
||||
ohne `battery_discharge_wh`, besteht ein Fehler in Pfad oder Zeitzuordnung.
|
||||
|
||||
## 13. Empfohlene kompakte Debug-Ansicht
|
||||
|
||||
Für das Verständnis reichen fünf übereinander ausgerichtete Zeitreihen:
|
||||
|
||||
1. `Strompreis` – wirtschaftlicher Anreiz.
|
||||
2. `PV und Last` – verfügbare und benötigte Energie.
|
||||
3. `Steuerplan` – fünf ausführbare 0/1-Zeilen plus eine Tail-Textzeile.
|
||||
4. `Batterie-SoC` – Wirkung auf den Energiespeicher.
|
||||
5. `Bezug & Einspeisung` – Wirkung am Netzanschluss.
|
||||
|
||||
Daneben oder darunter genügt das kleine Panel `Wie eindeutig war die
|
||||
Entscheidung?`. Die Informationstabelle kann auf `24 h Steuerung`, `48 h Tail`,
|
||||
`Prognose vollständig` und `Prognoseende` verkürzt werden. Der alte Plot der
|
||||
Terminalwert-Kurve über Batterie-kWh ist für die tägliche Fehlersuche entbehrlich:
|
||||
Er ist keine Zeitprognose, sondern nur die interne Bewertung möglicher
|
||||
Restladungen am Prognoseende.
|
||||
|
||||
## 14. Keine erfundene PV-Prognose hinter dem Provider-Ende verwenden
|
||||
|
||||
`PVForecastForecastSolar` liefert im geprüften Lauf echte Werte nur bis zum
|
||||
Abend des Folgetags. Der Endpoint `/v1/prediction/list` interpoliert trotzdem
|
||||
bis zum angefragten Ende und erzeugt dadurch kleine scheinbare PV-Werte. Diese
|
||||
Werte dürfen nicht als echter 72-h-Forecast in den Tail gelangen.
|
||||
|
||||
In Node-RED genau zwei Function-Nodes ändern:
|
||||
|
||||
1. **Prediction URL: PV 72h** durch den Inhalt von
|
||||
[`nodered_pv_series_url_function.js`](nodered_pv_series_url_function.js)
|
||||
ersetzen.
|
||||
2. Den direkt hinter **PV read** liegenden bisherigen Node **rename** durch den
|
||||
Inhalt von
|
||||
[`nodered_pv_series_to_slots_function.js`](nodered_pv_series_to_slots_function.js)
|
||||
ersetzen und in **PV series -> echte 15-min-Slots** umbenennen.
|
||||
|
||||
Diese Variante interpoliert nur zwischen tatsächlich vorhandenen
|
||||
Provider-Zeitpunkten. Hinter dem letzten echten Wert liefert sie `null`. EOS
|
||||
verkürzt den Tail dann sichtbar, statt mit einer erfundenen Rest-PV zu rechnen.
|
||||
|
||||
Für einen vollständigen 48-h-Tail muss der PV-Provider mindestens 72 Stunden
|
||||
ab jetzt liefern. Dafür `pvforecast.provider` beispielsweise auf
|
||||
`PVForecastAkkudoktor` oder auf den mit API-Zugang konfigurierten
|
||||
`PVForecastSolcast` umstellen. Mit `PVForecastForecastSolar` ist ein vollständiger
|
||||
rollender 72-h-PV-Horizont nicht gewährleistet.
|
||||
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,27 @@
|
||||
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)
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
# Control horizon and battery lookahead
|
||||
|
||||
The genetic optimizer issues controls only for `optimization.horizon_hours`,
|
||||
measured from the run start. The default is 24 hours. Battery and EV genomes,
|
||||
appliance schedules, simulation costs and returned control arrays all stop at
|
||||
that boundary. A larger prediction horizon does not add control genes.
|
||||
|
||||
```json
|
||||
{
|
||||
"optimization": {
|
||||
"horizon_hours": 24,
|
||||
"tail_horizon_hours": 48,
|
||||
"terminal_value_mode": "AUTO"
|
||||
},
|
||||
"prediction": {"hours": 72}
|
||||
}
|
||||
```
|
||||
|
||||
The forecast budget is never enforced by rejecting the configuration.
|
||||
`prediction.hours` also serves callers that do not optimize at all, so a budget
|
||||
that cannot serve the optimization horizons is reported rather than refused.
|
||||
|
||||
A `prediction.hours` below `horizon_hours + tail_horizon_hours` shortens the
|
||||
tail to `prediction.hours - horizon_hours`, logged once at INFO and reported per
|
||||
run as `effective_tail_hours`. A `prediction.hours` below `horizon_hours` is
|
||||
logged as a warning at configuration time and rejected by the optimizer itself
|
||||
when a run starts, naming the forecast series that ends too early. EOS never
|
||||
rewrites the settings; raise `prediction.hours` to use the requested horizons in
|
||||
full.
|
||||
|
||||
## Tail value and continuation
|
||||
|
||||
In AUTO mode, a deterministic dynamic program evaluates the battery state at
|
||||
the end of the control horizon against the following forecast intervals. It
|
||||
uses a 101-point SOC grid and interpolates between states. Each transition uses
|
||||
the production battery and inverter models, including SOC bounds, power caps,
|
||||
conversion losses, configured charging/export rates, PV, load, direct-marketing
|
||||
permission and LCOS on delivered DC energy.
|
||||
|
||||
The value curve is computed once per run. Genetic fitness subtracts its
|
||||
interpolated value from the simulated control cost. The older AC break-even
|
||||
penalty is disabled in TAIL mode because it does not account for future tail
|
||||
opportunities. Other feasibility penalties, including EV targets, still apply.
|
||||
|
||||
The curve may decrease with SOC: free capacity can earn money at negative
|
||||
prices. Even an empty battery can have nonzero value. Values include net tail
|
||||
cash flows and the continuation credit; this constant baseline does not affect
|
||||
which control plan wins within a run. Tail actions are never returned.
|
||||
|
||||
The existing AUTO proxy supplies the continuation value at the **effective tail
|
||||
end**. Its trailing window is controlled by `terminal_value_window_hours`. A
|
||||
window with no valuable load or export opportunities conservatively has zero
|
||||
continuation credit. With `tail_horizon_hours: 0`, AUTO uses the existing proxy
|
||||
directly at the control end. FIXED preserves the scalar terminal credit directly
|
||||
at the control end and does not solve a tail.
|
||||
|
||||
This is a deterministic approximation on a discretized SOC grid, using the
|
||||
configured action levels. It does not model forecast uncertainty or schedule
|
||||
additional EV/appliance activity beyond the control horizon.
|
||||
|
||||
## Forecast availability and API indexing
|
||||
|
||||
The four required series are load, PV, import price and feed-in tariff. Every
|
||||
control interval must contain a finite value. Missing control data rejects the
|
||||
run. The tail stops at the first missing value in any required series, logs a
|
||||
warning and moves continuation to that point. No missing price becomes zero.
|
||||
Provider interval values are held within their source interval, never extended
|
||||
indefinitely beyond the last observed forecast timestamp.
|
||||
|
||||
Legacy API forecast arrays still begin at midnight of the run's start day.
|
||||
They must therefore include the elapsed prefix plus the desired forecast
|
||||
coverage from now. The prefix is removed before optimization. Declare
|
||||
`forecast_interval_seconds: 900` for shortened native quarter-hour inputs;
|
||||
hourly inputs use `3600`. Fully sized native arrays remain auto-detected for
|
||||
compatibility. Scalar feed-in tariffs represent an explicitly constant tariff.
|
||||
|
||||
New solutions set `controls_start_at_now: true`: index zero of every returned
|
||||
control array and warm-start genome corresponds to the run timestamp. The
|
||||
generic solution and plan adapters still understand older, midnight-indexed
|
||||
solutions where this flag is absent. Incompatible old genome lengths are
|
||||
discarded by warm-start validation.
|
||||
|
||||
`terminal_value` reports the mode (`TAIL`, `AUTO`, or `FIXED`), usable remaining
|
||||
AC energy, control hours, requested/effective tail hours, continuation mode and
|
||||
any degradation reason. `tail_end_hour` is elapsed hours from the run start.
|
||||
FIXED mode reports zero effective tail hours.
|
||||
|
||||
The credited amount is explicitly decomposed:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "TAIL",
|
||||
"battery_energy_wh": 5230,
|
||||
"credited_euro": 1.84,
|
||||
"tail_operating_euro": 1.21,
|
||||
"continuation_value_euro": 0.63,
|
||||
"control_horizon_hours": 24,
|
||||
"requested_tail_hours": 48,
|
||||
"effective_tail_hours": 48,
|
||||
"tail_end_hour": 72,
|
||||
"continuation_mode": "AUTO",
|
||||
"curve": {
|
||||
"energy_wh": [],
|
||||
"value_euro": [],
|
||||
"operating_value_euro": [],
|
||||
"continuation_value_euro": [],
|
||||
"marginal_euro_per_kwh": []
|
||||
},
|
||||
"continuation_curve": {
|
||||
"energy_wh": [],
|
||||
"value_euro": [],
|
||||
"marginal_euro_per_kwh": []
|
||||
},
|
||||
"tail_diagnostics": {
|
||||
"slots": 192,
|
||||
"slot_hours": 0.25,
|
||||
"soc_grid_points": 101,
|
||||
"min_import_price_euro_per_kwh": -0.08,
|
||||
"max_import_price_euro_per_kwh": 0.34,
|
||||
"min_feed_in_tariff_euro_per_kwh": 0.0,
|
||||
"max_feed_in_tariff_euro_per_kwh": 0.29,
|
||||
"negative_import_price_slots": 4,
|
||||
"positive_battery_export_slots": 160
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`credited_euro` always equals `tail_operating_euro +
|
||||
continuation_value_euro`. The combined `curve` is the function read by genetic
|
||||
fitness. It contains the same decomposition at every SOC breakpoint.
|
||||
`continuation_curve` is the proxy at the effective tail end before the dynamic
|
||||
program folds the tail backwards onto it. Tail diagnostics summarize the actual
|
||||
forecast segment; they do not contain executable actions.
|
||||
Reference in New Issue
Block a user