diff --git a/CHANGELOG.md b/CHANGELOG.md index 10bc6b03..6881fe62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `scripts/pvforecast_backtest.py`, which scores PV forecast configuration variants against the stored meter readings straight away instead of waiting for new forecasts to come true, and `Measurement.pv_production_total_kwh()` alongside the existing load total. +- Separate the control horizon from the battery lookahead. `optimization.horizon_hours` remains + the only span that receives control commands; the new `optimization.tail_horizon_hours` + (default 48 h) is a forecast lookahead that never produces a command. In `AUTO` terminal-value + mode a deterministic dynamic program now solves that tail backwards on a 101-point SoC grid, + using the production battery and inverter models with their SoC bounds, power caps, conversion + losses, configured charge/export rates and LCOS, and applies the existing AUTO proxy as the + continuation value at the tail end. Genetic fitness reads the resulting curve instead of a + single price per kWh, so the optimizer stops treating the horizon boundary as the end of the + world. `tail_horizon_hours: 0` restores the plain AUTO proxy at the control end; `FIXED` is + unchanged. See `docs/akkudoktoreos/optimization_horizons.md`. +- The `terminal_value` result reports the split explicitly: `mode` (`TAIL`, `AUTO` or `FIXED`), + `tail_operating_euro` plus `continuation_value_euro` (which always sum to `credited_euro`), + the combined `curve` fitness reads, the `continuation_curve` proxy at the tail end, + `requested_tail_hours` versus `effective_tail_hours`, and `tail_diagnostics`. The optional + `tail_plan` replays the optimal battery path inside the tail for debugging. None of it is + executable - tail actions are never copied into the returned control arrays. +- Raise the default `prediction.hours` from 48 to 72 so the default control horizon plus the + default tail are covered out of the box. A shorter prediction horizon is never rejected: a tail + that does not fit is cut to what the forecast covers (logged once 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, naming the series that ends too + early. Existing configurations therefore keep starting after an upgrade. +- Genetic solutions carry `controls_start_at_now`. Index zero of every returned control array and + warm-start genome is now the run timestamp rather than midnight of the run's day. The solution + and plan adapters still read older, midnight-indexed solutions, and warm starts with an + incompatible genome length are discarded instead of misapplied. +- The optimization request accepts `forecast_interval_seconds` (900 or 3600), which declares the + resolution of shortened native quarter-hour input arrays. Fully sized native arrays are still + auto-detected, and a scalar feed-in tariff still means an explicitly constant tariff. +- Add operator tooling for the split horizon: `docs/akkudoktoreos/grafana_tail_debugging.md` + explains how to read control plan versus tail in Grafana, and + `scripts/update_nodered_tail_flow.py` plus the `nodered_tail_*`/`nodered_pv_*` helpers build an + importable Node-RED flow for it. ### Changed - Replace the fixed DEAP variation loop with adaptive genetic evolution. Crossover offspring may @@ -142,6 +175,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). failed evaluations and results from previous runs are never reused. - `max_home_appliances` is now purely an upper bound. No demo appliance is created when no `home_appliances` are configured, and the number is no longer used as an on/off switch. +- Required forecasts are no longer silently replaced by demo providers. Previously a missing PV, + price, load, feed-in or weather forecast rewrote the configured provider to a demo one and + retried, so a run could quietly optimize against invented data. Missing values now stay missing: + a gap inside the control horizon fails the run with the series that ends too early, and a gap + after it shortens the tail. Provider values are held only within their own source interval and + the last observed value is never extended indefinitely. ### Deprecated - The single-appliance genetic optimization input `dishwasher` is deprecated in favour of @@ -180,6 +219,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). provider could 404 the whole load prediction. It now defaults to a cache-aware update and accepts an optional `force_update` flag in the request body for callers that still want to force. +- A rejected configuration update no longer damages the running configuration. + `merge_settings_from_dict` validated the merged candidate only while reinitializing the + singleton, so an invalid update could leave EOS half-updated. The candidate is validated first. ## 0.3.0 (2026-03-17) diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index fb08bb46..d630eb12 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -236,6 +236,7 @@ ] }, "optimization": { + "tail_horizon_hours": 48, "horizon_hours": 24, "interval": 3600, "algorithm": "GENETIC", @@ -253,7 +254,7 @@ } }, "prediction": { - "hours": 48, + "hours": 72, "historic_hours": 48 }, "pvforecast": { @@ -263,7 +264,8 @@ "PVForecastVrm": null, "PVForecastPVNode": null, "PVForecastForecastSolar": null, - "PVForecastSolcast": null + "PVForecastSolcast": null, + "PVForecastAkkudoktorLocal": null }, "planes": [ { diff --git a/docs/_generated/configoptimization.md b/docs/_generated/configoptimization.md index f3337425..77abf72d 100644 --- a/docs/_generated/configoptimization.md +++ b/docs/_generated/configoptimization.md @@ -13,9 +13,10 @@ | horizon_hours | `EOS_OPTIMIZATION__HORIZON_HOURS` | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. | | interval | `EOS_OPTIMIZATION__INTERVAL` | `int` | `rw` | `3600` | The optimization interval (slot length) [sec]. The genetic optimizer supports 3600 (1 hour) and 900 (15 min); other values fall back to 3600. Defaults to 3600 seconds (1 hour). | | keys | | `list[str]` | `ro` | `N/A` | The keys of the solution. | +| tail_horizon_hours | `EOS_OPTIMIZATION__TAIL_HORIZON_HOURS` | `int` | `rw` | `48` | Forecast lookahead after the control horizon [h]. No tail commands are issued. Set 0 to disable. | | terminal_value_euro_per_kwh | `EOS_OPTIMIZATION__TERMINAL_VALUE_EURO_PER_KWH` | `float` | `rw` | `0.0` | Value assigned to usable battery energy remaining at the end of the optimization horizon [EUR/kWh]. This terminal value is independent of the battery LCOS. Only used with terminal_value_mode = FIXED. Defaults to 0 EUR/kWh. | -| terminal_value_mode | `EOS_OPTIMIZATION__TERMINAL_VALUE_MODE` | `` | `rw` | `AUTO` | How to value the energy left in the battery at the end of the optimization horizon. AUTO derives a concave value curve from the trailing horizon window and needs no configuration; FIXED uses 'terminal_value_euro_per_kwh'. Defaults to AUTO. | -| terminal_value_window_hours | `EOS_OPTIMIZATION__TERMINAL_VALUE_WINDOW_HOURS` | `int` | `rw` | `24` | Length of the trailing horizon window the AUTO terminal value curve is derived from [h]. One day covers a full load and PV cycle. Defaults to 24 hours. | +| terminal_value_mode | `EOS_OPTIMIZATION__TERMINAL_VALUE_MODE` | `` | `rw` | `AUTO` | How to value the energy left in the battery at the end of the control horizon. AUTO solves the forecast tail with an AUTO continuation proxy at its end (or only the proxy if tail is zero); FIXED uses 'terminal_value_euro_per_kwh'. Defaults to AUTO. | +| terminal_value_window_hours | `EOS_OPTIMIZATION__TERMINAL_VALUE_WINDOW_HOURS` | `int` | `rw` | `24` | Length of the trailing window at the effective tail end the AUTO continuation curve is derived from [h]. One day covers a full load and PV cycle. Defaults to 24 hours. | | visualize_pdf | `EOS_OPTIMIZATION__VISUALIZE_PDF` | `bool` | `rw` | `True` | Generate the PDF visualization after each optimization run. Disable for headless setups (e.g. Node-RED integration) to save several seconds per run. Defaults to True. | ::: @@ -28,6 +29,7 @@ ```json { "optimization": { + "tail_horizon_hours": 48, "horizon_hours": 24, "interval": 3600, "algorithm": "GENETIC", @@ -56,6 +58,7 @@ ```json { "optimization": { + "tail_horizon_hours": 48, "horizon_hours": 24, "interval": 3600, "algorithm": "GENETIC", diff --git a/docs/_generated/configprediction.md b/docs/_generated/configprediction.md index d5b4d796..85590f06 100644 --- a/docs/_generated/configprediction.md +++ b/docs/_generated/configprediction.md @@ -8,7 +8,7 @@ | Name | Environment Variable | Type | Read-Only | Default | Description | | ---- | -------------------- | ---- | --------- | ------- | ----------- | | historic_hours | `EOS_PREDICTION__HISTORIC_HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the past for historical predictions data | -| hours | `EOS_PREDICTION__HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the future for predictions | +| hours | `EOS_PREDICTION__HOURS` | `Optional[int]` | `rw` | `72` | Number of hours into the future for predictions | ::: @@ -20,7 +20,7 @@ ```json { "prediction": { - "hours": 48, + "hours": 72, "historic_hours": 48 } } diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index 534d071e..db71b813 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -1,6 +1,6 @@ # Akkudoktor-EOS -**Version**: `v0.3.0.dev2609040861878062` +**Version**: `v0.3.0.dev2609090521492067` **Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period. diff --git a/docs/akkudoktoreos/configuration.md b/docs/akkudoktoreos/configuration.md index 65933c54..493bd351 100644 --- a/docs/akkudoktoreos/configuration.md +++ b/docs/akkudoktoreos/configuration.md @@ -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. diff --git a/docs/akkudoktoreos/grafana_tail_debugging.md b/docs/akkudoktoreos/grafana_tail_debugging.md new file mode 100644 index 00000000..5a841c84 --- /dev/null +++ b/docs/akkudoktoreos/grafana_tail_debugging.md @@ -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. diff --git a/docs/akkudoktoreos/nodered_pv_series_to_slots_function.js b/docs/akkudoktoreos/nodered_pv_series_to_slots_function.js new file mode 100644 index 00000000..66623795 --- /dev/null +++ b/docs/akkudoktoreos/nodered_pv_series_to_slots_function.js @@ -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; diff --git a/docs/akkudoktoreos/nodered_pv_series_url_function.js b/docs/akkudoktoreos/nodered_pv_series_url_function.js new file mode 100644 index 00000000..17d28cc8 --- /dev/null +++ b/docs/akkudoktoreos/nodered_pv_series_url_function.js @@ -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; diff --git a/docs/akkudoktoreos/nodered_tail_plan_function.js b/docs/akkudoktoreos/nodered_tail_plan_function.js new file mode 100644 index 00000000..afbc16a3 --- /dev/null +++ b/docs/akkudoktoreos/nodered_tail_plan_function.js @@ -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; diff --git a/docs/akkudoktoreos/nodered_tail_plan_schema.sql b/docs/akkudoktoreos/nodered_tail_plan_schema.sql new file mode 100644 index 00000000..0d9fb8aa --- /dev/null +++ b/docs/akkudoktoreos/nodered_tail_plan_schema.sql @@ -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) +); diff --git a/docs/akkudoktoreos/optimization_horizons.md b/docs/akkudoktoreos/optimization_horizons.md new file mode 100644 index 00000000..dfbef8cc --- /dev/null +++ b/docs/akkudoktoreos/optimization_horizons.md @@ -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. diff --git a/docs/index.md b/docs/index.md index e9ff0540..d63e50eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,7 @@ develop/update.md develop/revert.md akkudoktoreos/adapter/adapterhomeassistant.md akkudoktoreos/adapter/adapternodered.md +akkudoktoreos/grafana_tail_debugging.md ``` @@ -43,6 +44,7 @@ akkudoktoreos/adapter/adapternodered.md akkudoktoreos/architecture.md akkudoktoreos/configuration.md akkudoktoreos/configtimewindow.md +akkudoktoreos/optimization_horizons.md akkudoktoreos/optimpost.md akkudoktoreos/optimauto.md akkudoktoreos/resource.md diff --git a/openapi.json b/openapi.json index 0312a781..e5ee0e2b 100644 --- a/openapi.json +++ b/openapi.json @@ -8,7 +8,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "v0.3.0.dev2609040861878062" + "version": "v0.3.0.dev2609090421492067" }, "paths": { "/v1/admin/cache/clear": { @@ -5277,6 +5277,18 @@ }, "GeneticOptimizationParameters": { "properties": { + "forecast_interval_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Forecast Interval Seconds", + "description": "Input interval: 3600 for hourly, or optimization interval for native slots." + }, "ems": { "$ref": "#/components/schemas/GeneticEnergyManagementParameters" }, @@ -5538,6 +5550,12 @@ }, "GeneticSolution": { "properties": { + "controls_start_at_now": { + "type": "boolean", + "title": "Controls Start At Now", + "description": "Control arrays start at the run timestamp instead of midnight.", + "default": false + }, "ac_charge": { "items": { "type": "number" @@ -7503,6 +7521,13 @@ }, "OptimizationCommonSettings-Input": { "properties": { + "tail_horizon_hours": { + "type": "integer", + "minimum": 0.0, + "title": "Tail Horizon Hours", + "description": "Forecast lookahead after the control horizon [h]. No tail commands are issued. Set 0 to disable.", + "default": 48 + }, "horizon_hours": { "type": "integer", "minimum": 0.0, @@ -7546,7 +7571,7 @@ }, "terminal_value_mode": { "$ref": "#/components/schemas/TerminalValueMode", - "description": "How to value the energy left in the battery at the end of the optimization horizon. AUTO derives a concave value curve from the trailing horizon window and needs no configuration; FIXED uses 'terminal_value_euro_per_kwh'. Defaults to AUTO.", + "description": "How to value the energy left in the battery at the end of the control horizon. AUTO solves the forecast tail with an AUTO continuation proxy at its end (or only the proxy if tail is zero); FIXED uses 'terminal_value_euro_per_kwh'. Defaults to AUTO.", "default": "AUTO", "examples": [ "AUTO", @@ -7567,7 +7592,7 @@ "type": "integer", "minimum": 1.0, "title": "Terminal Value Window Hours", - "description": "Length of the trailing horizon window the AUTO terminal value curve is derived from [h]. One day covers a full load and PV cycle. Defaults to 24 hours.", + "description": "Length of the trailing window at the effective tail end the AUTO continuation curve is derived from [h]. One day covers a full load and PV cycle. Defaults to 24 hours.", "default": 24, "examples": [ 24 @@ -7592,6 +7617,13 @@ }, "OptimizationCommonSettings-Output": { "properties": { + "tail_horizon_hours": { + "type": "integer", + "minimum": 0.0, + "title": "Tail Horizon Hours", + "description": "Forecast lookahead after the control horizon [h]. No tail commands are issued. Set 0 to disable.", + "default": 48 + }, "horizon_hours": { "type": "integer", "minimum": 0.0, @@ -7635,7 +7667,7 @@ }, "terminal_value_mode": { "$ref": "#/components/schemas/TerminalValueMode", - "description": "How to value the energy left in the battery at the end of the optimization horizon. AUTO derives a concave value curve from the trailing horizon window and needs no configuration; FIXED uses 'terminal_value_euro_per_kwh'. Defaults to AUTO.", + "description": "How to value the energy left in the battery at the end of the control horizon. AUTO solves the forecast tail with an AUTO continuation proxy at its end (or only the proxy if tail is zero); FIXED uses 'terminal_value_euro_per_kwh'. Defaults to AUTO.", "default": "AUTO", "examples": [ "AUTO", @@ -7656,7 +7688,7 @@ "type": "integer", "minimum": 1.0, "title": "Terminal Value Window Hours", - "description": "Length of the trailing horizon window the AUTO terminal value curve is derived from [h]. One day covers a full load and PV cycle. Defaults to 24 hours.", + "description": "Length of the trailing window at the effective tail end the AUTO continuation curve is derived from [h]. One day covers a full load and PV cycle. Defaults to 24 hours.", "default": 24, "examples": [ 24 @@ -8200,6 +8232,253 @@ "title": "PPBCStartInterruptionInstruction", "description": "Represents an instruction to interrupt execution of a running power sequence.\n\nThis model defines a control instruction that interrupts the execution of an\nactive power sequence. It enables dynamic control over sequence execution,\nallowing temporary suspension of a sequence in response to changing system conditions\nor requirements, particularly for sequences marked as interruptible." }, + "PVForecastAkkudoktorLocalCommonSettings": { + "properties": { + "resolution_minutes": { + "type": "integer", + "title": "Resolution Minutes", + "description": "Forecast resolution in minutes. 15 requests Open-Meteo's `minutely_15` block (natively resolved over Central Europe and North America, interpolated from hourly elsewhere); 60 requests the `hourly` block.", + "default": 15, + "examples": [ + 15, + 60 + ] + }, + "forecast_days": { + "anyOf": [ + { + "type": "integer", + "maximum": 16.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Forecast Days", + "description": "Forecast horizon in days (1-16). Leave empty to derive it from `prediction.hours`, which is what keeps the optimizer's tail horizon fed.", + "examples": [ + null, + 7 + ] + }, + "past_days": { + "anyOf": [ + { + "type": "integer", + "maximum": 92.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Past Days", + "description": "Days of past data to request (0-92). Leave empty to derive it from `prediction.historic_hours`.", + "examples": [ + null, + 3 + ] + }, + "weather_models": { + "items": { + "type": "string" + }, + "type": "array", + "minItems": 1, + "title": "Weather Models", + "description": "Open-Meteo weather models to request. Listing more than one turns the input into a poor-man's ensemble: the members are averaged per variable, which is the cheapest reliable way to cut irradiance forecast error. Costs no extra API calls.", + "default": [ + "best_match" + ], + "examples": [ + [ + "best_match" + ], + [ + "icon_seamless", + "ecmwf_ifs025", + "gfs_seamless" + ] + ] + }, + "transposition_model": { + "type": "string", + "title": "Transposition Model", + "description": "pvlib sky-diffuse transposition model: isotropic, klucher, haydavies, reindl, king or perez.", + "default": "perez", + "examples": [ + "perez", + "haydavies" + ] + }, + "albedo": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Albedo", + "description": "Ground albedo used for planes that do not set their own.", + "default": 0.25, + "examples": [ + 0.25, + 0.2 + ] + }, + "inverter_efficiency": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Inverter Efficiency", + "description": "Nominal inverter efficiency (PVWatts eta_inv_nom).", + "default": 0.96, + "examples": [ + 0.96, + 0.94 + ] + }, + "temperature_coefficient": { + "type": "number", + "title": "Temperature Coefficient", + "description": "Module power temperature coefficient in %/degC (negative). Matches the `cellCoEff` the akkudoktor.net forecast uses.", + "default": -0.36, + "examples": [ + -0.36, + -0.29 + ] + }, + "apply_iam": { + "type": "boolean", + "title": "Apply Iam", + "description": "Apply the ASHRAE incidence-angle modifier to the beam component.", + "default": true, + "examples": [ + true + ] + }, + "shift_to_interval_start": { + "type": "boolean", + "title": "Shift To Interval Start", + "description": "Open-Meteo stamps an interval mean with the interval END. EOS labels an interval by its START, so records are shifted back by one interval. Disable only to compare like-for-like against a provider that does not.", + "default": true, + "examples": [ + true + ] + }, + "calibration_enabled": { + "type": "boolean", + "title": "Calibration Enabled", + "description": "Correct systematic model error against measured PV production. Requires `measurement.pv_production_emr_keys` to be configured and fed. Fits a global scale factor plus per-solar-azimuth factors, which is what catches near-field shading the horizon profile misses.", + "default": false, + "examples": [ + true + ] + }, + "calibration_days": { + "type": "integer", + "maximum": 92.0, + "minimum": 1.0, + "title": "Calibration Days", + "description": "Length of the measurement window used to fit the correction.", + "default": 30, + "examples": [ + 30, + 14 + ] + }, + "calibration_reference_days": { + "type": "integer", + "maximum": 92.0, + "minimum": 3.0, + "title": "Calibration Reference Days", + "description": "Lookback used to distinguish healthy production from outages or curtailment. If the calibration window contains too few healthy days, the most recent healthy days from this reference window are used.", + "default": 30, + "examples": [ + 30, + 14 + ] + }, + "calibration_outage_filter_enabled": { + "type": "boolean", + "title": "Calibration Outage Filter Enabled", + "description": "Exclude days whose measured production is far below the recent healthy plant level. This prevents inverter, battery and curtailment events from being learned as permanent PV model losses.", + "default": true, + "examples": [ + true + ] + }, + "calibration_outage_threshold": { + "type": "number", + "exclusiveMaximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Calibration Outage Threshold", + "description": "A day is treated as unavailable when its measured/modelled energy ratio is below this fraction of the robust healthy reference ratio.", + "default": 0.55, + "examples": [ + 0.55, + 0.5 + ] + }, + "calibration_min_healthy_days": { + "type": "integer", + "maximum": 31.0, + "minimum": 1.0, + "title": "Calibration Min Healthy Days", + "description": "Minimum number of healthy days used for a fit. Older healthy days from the reference window are added when the recent window contains fewer.", + "default": 3, + "examples": [ + 3 + ] + }, + "calibration_azimuth_bin_degrees": { + "type": "integer", + "maximum": 180.0, + "minimum": 0.0, + "title": "Calibration Azimuth Bin Degrees", + "description": "Width of the solar-azimuth bins for the correction. 0 fits a single global factor only.", + "default": 45, + "examples": [ + 45, + 30, + 15, + 0 + ] + }, + "calibration_prior_kwh": { + "type": "number", + "minimum": 0.0, + "title": "Calibration Prior Kwh", + "description": "Shrinkage strength: a bin needs this much modelled energy before its own factor outweighs the global one. Higher is more conservative.", + "default": 5.0, + "examples": [ + 5.0, + 20.0 + ] + }, + "calibration_min_factor": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Calibration Min Factor", + "description": "Lower clamp on any fitted correction factor.", + "default": 0.5, + "examples": [ + 0.5 + ] + }, + "calibration_max_factor": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Calibration Max Factor", + "description": "Upper clamp on any fitted correction factor.", + "default": 1.5, + "examples": [ + 1.5 + ] + } + }, + "type": "object", + "title": "PVForecastAkkudoktorLocalCommonSettings", + "description": "Common settings for the local (pvlib) PV forecast provider." + }, "PVForecastCommonProviderSettings": { "properties": { "PVForecastImport": { @@ -8271,6 +8550,20 @@ "examples": [ null ] + }, + "PVForecastAkkudoktorLocal": { + "anyOf": [ + { + "$ref": "#/components/schemas/PVForecastAkkudoktorLocalCommonSettings" + }, + { + "type": "null" + } + ], + "description": "PVForecastAkkudoktorLocal settings", + "examples": [ + null + ] } }, "type": "object", @@ -9033,7 +9326,7 @@ ], "title": "Hours", "description": "Number of hours into the future for predictions", - "default": 48 + "default": 72 }, "historic_hours": { "anyOf": [ @@ -9661,6 +9954,172 @@ "title": "SolarPanelBatteryParameters", "description": "PV battery device simulation configuration." }, + "TailDiagnostics": { + "properties": { + "slots": { + "type": "integer", + "title": "Slots", + "default": 0 + }, + "slot_hours": { + "type": "number", + "title": "Slot Hours", + "default": 0.0 + }, + "soc_grid_points": { + "type": "integer", + "title": "Soc Grid Points", + "default": 0 + }, + "min_import_price_euro_per_kwh": { + "type": "number", + "title": "Min Import Price Euro Per Kwh", + "default": 0.0 + }, + "max_import_price_euro_per_kwh": { + "type": "number", + "title": "Max Import Price Euro Per Kwh", + "default": 0.0 + }, + "min_feed_in_tariff_euro_per_kwh": { + "type": "number", + "title": "Min Feed In Tariff Euro Per Kwh", + "default": 0.0 + }, + "max_feed_in_tariff_euro_per_kwh": { + "type": "number", + "title": "Max Feed In Tariff Euro Per Kwh", + "default": 0.0 + }, + "negative_import_price_slots": { + "type": "integer", + "title": "Negative Import Price Slots", + "default": 0 + }, + "positive_battery_export_slots": { + "type": "integer", + "title": "Positive Battery Export Slots", + "default": 0 + } + }, + "type": "object", + "title": "TailDiagnostics", + "description": "Forecast summary used by the deterministic tail optimization." + }, + "TailPlanSlot": { + "properties": { + "slot": { + "type": "integer", + "title": "Slot" + }, + "hour_from_start": { + "type": "number", + "title": "Hour From Start" + }, + "action": { + "type": "string", + "title": "Action" + }, + "alternative_action": { + "type": "string", + "title": "Alternative Action", + "default": "" + }, + "decision_margin_euro": { + "type": "number", + "title": "Decision Margin Euro", + "default": 0.0 + }, + "soc_start_percentage": { + "type": "number", + "title": "Soc Start Percentage" + }, + "soc_end_percentage": { + "type": "number", + "title": "Soc End Percentage" + }, + "pv_wh": { + "type": "number", + "title": "Pv Wh" + }, + "load_wh": { + "type": "number", + "title": "Load Wh" + }, + "grid_import_wh": { + "type": "number", + "title": "Grid Import Wh" + }, + "grid_export_wh": { + "type": "number", + "title": "Grid Export Wh" + }, + "battery_charge_wh": { + "type": "number", + "title": "Battery Charge Wh" + }, + "battery_discharge_wh": { + "type": "number", + "title": "Battery Discharge Wh" + }, + "import_price_euro_per_kwh": { + "type": "number", + "title": "Import Price Euro Per Kwh" + }, + "feed_in_tariff_euro_per_kwh": { + "type": "number", + "title": "Feed In Tariff Euro Per Kwh" + }, + "slot_value_euro": { + "type": "number", + "title": "Slot Value Euro" + }, + "remaining_value_euro": { + "type": "number", + "title": "Remaining Value Euro" + }, + "ac_charge_factor": { + "type": "number", + "title": "Ac Charge Factor" + }, + "dc_charge_allowed": { + "type": "integer", + "title": "Dc Charge Allowed" + }, + "discharge_allowed": { + "type": "integer", + "title": "Discharge Allowed" + }, + "battery_grid_export_factor": { + "type": "number", + "title": "Battery Grid Export Factor" + } + }, + "type": "object", + "required": [ + "slot", + "hour_from_start", + "action", + "soc_start_percentage", + "soc_end_percentage", + "pv_wh", + "load_wh", + "grid_import_wh", + "grid_export_wh", + "battery_charge_wh", + "battery_discharge_wh", + "import_price_euro_per_kwh", + "feed_in_tariff_euro_per_kwh", + "slot_value_euro", + "remaining_value_euro", + "ac_charge_factor", + "dc_charge_allowed", + "discharge_allowed", + "battery_grid_export_factor" + ], + "title": "TailPlanSlot", + "description": "One diagnostic slot of the optimal tail path.\n\nThese values explain the lookahead used for fitness. They are diagnostics\nonly and are never copied into the executable control arrays." + }, "TerminalValueCurve": { "properties": { "energy_wh": { @@ -9679,13 +10138,35 @@ "title": "Value Euro", "description": "Cumulative credit at each breakpoint [EUR]." }, + "operating_value_euro": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Operating Value Euro", + "description": "Tail operating component at each breakpoint [EUR]; empty for a proxy curve." + }, + "continuation_value_euro": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Continuation Value Euro", + "description": "Continuation component at each breakpoint [EUR]; empty for a proxy curve." + }, "marginal_euro_per_kwh": { "items": { "type": "number" }, "type": "array", "title": "Marginal Euro Per Kwh", - "description": "Marginal value of the segment that starts at each breakpoint [EUR/kWh]. Monotonically decreasing." + "description": "Marginal value of the segment that starts at each breakpoint [EUR/kWh]. May be negative or non-monotone in TAIL mode." + }, + "residual_energy_wh": { + "type": "number", + "title": "Residual Energy Wh", + "description": "Energy up to which the curve is backed by residual load - the knee. Everything beyond it is only worth an export.", + "default": 0.0 }, "window_slots": { "type": "integer", @@ -9705,15 +10186,41 @@ "FIXED" ], "title": "TerminalValueMode", - "description": "How the energy left in the battery at the end of the horizon is valued.\n\nModes\n-----\n- AUTO:\n Derive a concave value curve from the trailing horizon window: the\n first stored kWh replaces the most expensive hour that PV cannot\n cover, the next one the second most expensive, and so on. Needs no\n configuration and adapts to prices, load and PV of the day.\n\n- FIXED:\n Credit every stored kWh with the configured\n ``terminal_value_euro_per_kwh`` (or ``preis_euro_pro_wh_akku`` of the\n request). The historical behaviour; a value of 0 makes the optimizer\n empty the battery towards the end of the horizon." + "description": "How the energy left in the battery at the end of the horizon is valued.\n\nModes\n-----\n- AUTO:\n Solve the deterministic forecast tail and apply a conservative\n continuation proxy at its end. Tail values may decrease with SOC when\n empty capacity is valuable. With a zero tail, use the proxy directly.\n\n- FIXED:\n Credit every stored kWh with the configured\n ``terminal_value_euro_per_kwh`` (or ``preis_euro_pro_wh_akku`` of the\n request). The historical behaviour; a value of 0 makes the optimizer\n empty the battery towards the end of the horizon." }, "TerminalValueResult": { "properties": { + "control_horizon_hours": { + "type": "number", + "title": "Control Horizon Hours", + "default": 0 + }, + "requested_tail_hours": { + "type": "number", + "title": "Requested Tail Hours", + "default": 0 + }, + "effective_tail_hours": { + "type": "number", + "title": "Effective Tail Hours", + "default": 0 + }, + "tail_end_hour": { + "type": "number", + "title": "Tail End Hour", + "default": 0 + }, + "continuation_mode": { + "type": "string", + "title": "Continuation Mode", + "default": "FIXED" + }, "mode": { "type": "string", "title": "Mode", - "description": "Terminal value mode the run used: AUTO or FIXED.", + "description": "Terminal value mode the run used: TAIL, AUTO or FIXED.", "examples": [ + "TAIL", "AUTO", "FIXED" ] @@ -9730,6 +10237,18 @@ "description": "Credit applied to the total balance [EUR].", "default": 0.0 }, + "tail_operating_euro": { + "type": "number", + "title": "Tail Operating Euro", + "description": "Optimal net cash flow within the effective tail for the selected control-end battery state [EUR].", + "default": 0.0 + }, + "continuation_value_euro": { + "type": "number", + "title": "Continuation Value Euro", + "description": "Continuation credit remaining at the end of the optimal tail path [EUR].", + "default": 0.0 + }, "curve": { "anyOf": [ { @@ -9739,7 +10258,46 @@ "type": "null" } ], - "description": "The value curve the credit was read from; None in FIXED mode." + "description": "Combined tail value curve (tail operation plus continuation) read by fitness; None in FIXED mode." + }, + "continuation_curve": { + "anyOf": [ + { + "$ref": "#/components/schemas/TerminalValueCurve" + }, + { + "type": "null" + } + ], + "description": "Conservative AUTO proxy constructed at the effective tail end." + }, + "tail_diagnostics": { + "anyOf": [ + { + "$ref": "#/components/schemas/TailDiagnostics" + }, + { + "type": "null" + } + ] + }, + "tail_plan": { + "items": { + "$ref": "#/components/schemas/TailPlanSlot" + }, + "type": "array", + "title": "Tail Plan", + "description": "Diagnostic optimal battery path inside the tail. It explains the lookahead but is never an executable control plan." + }, + "reason": { + "type": "string", + "title": "Reason", + "description": "Why this mode applied. Empty in AUTO mode; in FIXED mode it says whether FIXED was configured or whether AUTO fell back because no curve could be derived.", + "default": "", + "examples": [ + "", + "terminal_value_mode is FIXED" + ] } }, "type": "object", diff --git a/scripts/update_nodered_tail_flow.py b/scripts/update_nodered_tail_flow.py new file mode 100644 index 00000000..2c716054 --- /dev/null +++ b/scripts/update_nodered_tail_flow.py @@ -0,0 +1,794 @@ +"""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() diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index eab796ef..93921db5 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -19,7 +19,7 @@ from typing import Any, ClassVar, Optional, Type, Union import pydantic_settings from loguru import logger from platformdirs import user_config_dir, user_data_dir -from pydantic import Field, computed_field, field_validator +from pydantic import Field, computed_field, field_validator, model_validator # settings from akkudoktoreos.adapter.adapter import AdapterCommonSettings @@ -321,6 +321,38 @@ class SettingsEOSDefaults(SettingsEOS): utils: UtilsCommonSettings = Field(default_factory=UtilsCommonSettings) adapter: AdapterCommonSettings = Field(default_factory=AdapterCommonSettings) + @model_validator(mode="after") + def validate_optimization_horizons(self) -> "SettingsEOSDefaults": + """Report a forecast budget that cannot serve the optimization horizons. + + This never rejects a configuration. ``prediction.hours`` is a general + setting that also serves callers with nothing to do with optimization, + and refusing it here would stop EOS from starting over a horizon the + user may not even optimize on. A tail that does not fit is simply + shortened, and a control horizon that does not fit is caught by the + optimizer itself, which knows exactly which forecast series ran out. + """ + control = self.optimization.horizon_hours + tail = self.optimization.tail_horizon_hours + prediction = self.prediction.hours + if prediction is None or prediction < control: + logger.warning( + "Prediction horizon {} h is shorter than the {} h control horizon. Optimization " + "runs will fail until prediction.hours covers the control horizon.", + prediction, + control, + ) + elif prediction < control + tail: + logger.info( + "Prediction horizon {} h covers the {} h control horizon but not the requested " + "{} h tail. The tail is shortened to {} h; raise prediction.hours to use it fully.", + prediction, + control, + tail, + prediction - control, + ) + return self + def __hash__(self) -> int: # Just for usage in configmigrate, finally overwritten when used by ConfigEOS. # This is mutable, so pydantic does not set a hash. @@ -727,7 +759,11 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): config.merge_settings_from_dict(new_data) """ - self._setup(**merge_models(self, data)) + merged = merge_models(self, data) + # Validate a candidate before reinitializing the singleton. A rejected + # update must leave the running configuration intact. + SettingsEOSDefaults(**merged) + self._setup(**merged) def reset_settings(self) -> None: """Reset all changed settings to environment/config file defaults. diff --git a/src/akkudoktoreos/optimization/genetic/forecast.py b/src/akkudoktoreos/optimization/genetic/forecast.py new file mode 100644 index 00000000..8d7ad7d9 --- /dev/null +++ b/src/akkudoktoreos/optimization/genetic/forecast.py @@ -0,0 +1,44 @@ +"""Resample actual forecast intervals without extrapolating missing provider data.""" + +from typing import Any + +import numpy as np +import pandas as pd + + +def bounded_forecast_array( + prediction: Any, + *, + key: str, + start_datetime: Any, + end_datetime: Any, + interval: Any, + **kwargs: Any, +) -> np.ndarray: + """Hold interval averages only within their source interval. + + EOS forecasts carry interval starts. Infer source cadence from timestamps, + conservatively bounded to one hour; never extend the last value indefinitely. + Explicit NaNs and holes remain missing. Downsampling requires full coverage. + """ + target_seconds = int(interval.total_seconds()) + try: + series = prediction.key_to_series(key, dropna=False) + except KeyError: + series = pd.Series(dtype=float, index=pd.DatetimeIndex([], tz="UTC")) + series = pd.to_numeric(series, errors="coerce").sort_index() + series = series[~series.index.duplicated(keep="last")] + cadence = 3600 + if len(series) > 1: + gaps = np.diff(series.index.as_unit("ns").asi8) / 1e9 + cadence = int(min(3600, np.min(gaps[gaps > 0]))) + step = min(cadence, target_seconds) + index = pd.date_range(start=start_datetime, end=end_datetime, freq=f"{step}s", inclusive="left") + if series.empty: + sampled = pd.Series(np.nan, index=index) + else: + sampled = series.reindex(index, method="ffill", tolerance=pd.Timedelta(seconds=cadence - 1)) + groups = sampled.resample(f"{target_seconds}s", origin=start_datetime) + result = groups.mean() + result[groups.count().to_numpy() < target_seconds / step] = np.nan + return result.to_numpy(dtype=float) diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index d252878f..83d51c2d 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -22,16 +22,18 @@ from akkudoktoreos.optimization.genetic.geneticparams import ( GeneticEnergyManagementParameters, GeneticOptimizationParameters, ) +from akkudoktoreos.optimization.genetic.geneticsolution import ( + GeneticSimulationResult, + GeneticSolution, +) +from akkudoktoreos.optimization.genetic.tailvalue import TailValueCurve, build_tail_value_curve from akkudoktoreos.optimization.genetic.terminalvalue import ( + TailDiagnostics, TerminalValueCurve, TerminalValueResult, build_terminal_value_curve, trailing_window, ) -from akkudoktoreos.optimization.genetic.geneticsolution import ( - GeneticSimulationResult, - GeneticSolution, -) from akkudoktoreos.optimization.optimizationabc import OptimizationBase @@ -322,7 +324,9 @@ class GeneticSimulation(PydanticBaseModel): logger.error(error_msg) raise ValueError(error_msg) - end_hour = len(load_energy_array_fast) + end_hour = min( + len(load_energy_array_fast), self.prediction_hours or len(load_energy_array_fast) + ) total_hours = end_hour - start_hour # Pre-allocate arrays for the results, optimized for speed @@ -382,7 +386,7 @@ class GeneticSimulation(PydanticBaseModel): | ( direct_marketing_enabled_fast & (bat_grid_export_hours_fast > 0) - & (elect_revenue_per_hour_arr_fast > 0.0) + & (elect_revenue_per_hour_arr_fast[: len(bat_grid_export_hours_fast)] > 0.0) ), 1, 0, @@ -605,11 +609,7 @@ class GeneticOptimization(OptimizationBase): SOFT_RESTART_SURVIVOR_FRACTION = 0.20 POINT_MUTATION_EXPECTED_GENES = 3.0 - # Slot-math helpers — single source of truth for the optimization grid. - # At the default optimization interval of 3600 s, slot_duration_h is 1.0 and - # total_slots equals prediction.hours, so the established hourly behaviour is - # preserved. At 900 s (15 min) slot_duration_h is 0.25 and there are 4x as - # many slots. + # Independent forecast and control durations on the optimization grid. @property def slot_duration_h(self) -> float: """Length of one optimization slot in hours (1.0 hourly, 0.25 at 15 min).""" @@ -623,26 +623,41 @@ class GeneticOptimization(OptimizationBase): return 3600 // interval @property - def total_slots(self) -> int: - """Total number of optimization slots = prediction.hours * slots_per_hour.""" - # Read prediction.hours directly to avoid recursing through total_slots. + def control_slots(self) -> int: + """Number of executable control intervals, measured from now.""" + return self.config.optimization.horizon_hours * self.slots_per_hour + + @property + def prediction_slots(self) -> int: + """Forecast duration, independent of the control genome.""" return int(self.config.prediction.hours * self.slots_per_hour) - def _start_day_slot(self) -> int: - """Slot index of ems.start_datetime counted from the start day's midnight. + @property + def tail_slots(self) -> int: + """Requested lookahead, bounded by the forecast the configuration budgets. - simulate()/evaluate() use the simulation start position as a slot index - into the prediction/charge arrays. Those arrays begin at the midnight of - ``ems.start_datetime`` (geneticparams sets ``start_datetime.set(hour=0)``), - so the index is computed from the same datetime — no timezone conversion — - keeping it consistent with how the arrays are built. At interval=3600 s - slots_per_hour == 1 and minute // 60 == 0, so this reduces to - ``start_datetime.hour`` (the previous hourly behaviour). + A prediction horizon that does not cover control plus tail shortens the + tail rather than failing the run, so the shortfall is not reported as + missing provider data. """ + requested = self.config.optimization.tail_horizon_hours * self.slots_per_hour + budget = max(0, self.prediction_slots - self.control_slots) + return min(requested, budget) + + @property + def control_end_slot(self) -> int: + """Exclusive control end in run-relative device arrays.""" + return self._control_start_slot() + self.control_slots + + def _control_start_slot(self) -> int: + """Genomes and device arrays start at now, independently of wall-clock hour.""" + return 0 + + def _start_day_slot(self) -> int: + """Offset used only to trim legacy midnight-indexed forecast inputs.""" sd = self.ems.start_datetime - sph = self.slots_per_hour - slot_minutes = max(1, 60 // sph) - return sd.hour * sph + sd.minute // slot_minutes + midnight = sd.set(hour=0, minute=0, second=0, microsecond=0) + return int((sd - midnight).total_seconds() // (self.slot_duration_h * 3600)) def __init__( self, @@ -657,17 +672,8 @@ class GeneticOptimization(OptimizationBase): ) self.config.optimization.interval = 3600 self.opti_param: dict[str, Any] = {} - # Number of slots at the tail of the optimization window where EV - # charging is fixed to 0. Slot-counted so 15-min runs reserve the right - # tail length (at interval=3600 s this equals prediction.hours - horizon). - self.fixed_eauto_hours = max( - self.total_slots - - ( - self._start_day_slot() - + self.config.optimization.horizon_hours * self.slots_per_hour - ), - 0, - ) + # EV genes cover precisely the control horizon; no fixed prediction tail. + self.fixed_eauto_hours = 0 self.ev_possible_charge_values: list[float] = [1.0] # Separate charge-level list for battery AC charging (independent of EV rates). # Populated from parameters.pv_akku.charge_rates in optimierung_ems. @@ -679,9 +685,11 @@ class GeneticOptimization(OptimizationBase): # Slot by which the EV has to reach its target SoC. None means the SoC is # only required at the end of the horizon (the behaviour without a deadline). self._ev_soc_deadline_slot: Optional[int] = None - # Concave value of the energy left in the battery at the end of the + # Value of the energy left in the battery at the end of the # horizon. None means the fixed scalar terminal value is used instead. self._terminal_value_curve: Optional[TerminalValueCurve] = None + self._continuation_value_curve: Optional[TerminalValueCurve] = None + self._tail_diagnostics: Optional[TailDiagnostics] = None # Why that is - reported with the solution, because a run that silently # falls back to the scalar looks exactly like a run configured for it. self._terminal_value_reason: str = "" @@ -714,7 +722,7 @@ class GeneticOptimization(OptimizationBase): # optimierung_ems(). Empty by default so setup_deap_environment() can be # exercised standalone (e.g. in tests) without appliances. self.appliance_layout: ApplianceGeneLayout = ApplianceGeneLayout([]) - # Local datetime of slot index 0 (midnight of the start day), needed to + # Local datetime of slot index 0 (the run start), needed to # turn decoded start slots into absolute local timestamps. self._slot0_datetime: Optional[Any] = None @@ -775,9 +783,9 @@ class GeneticOptimization(OptimizationBase): at the current slot and lasts ``horizon_hours``; the bound is capped to the total slot grid. """ - start_slot = self._start_day_slot() + start_slot = self._control_start_slot() horizon_slots = self.config.optimization.horizon_hours * self.slots_per_hour - return min(self.total_slots, start_slot + horizon_slots) + return min(self.control_end_slot, start_slot + horizon_slots) def _ev_deadline_slot(self, parameters: GeneticOptimizationParameters) -> Optional[int]: """Slot index by which the EV has to reach ``min_soc_percentage``. @@ -798,7 +806,7 @@ class GeneticOptimization(OptimizationBase): if ev_parameters is None: return None - start_slot = self._start_day_slot() + start_slot = self._control_start_slot() slot_seconds = self.slot_duration_h * 3600 candidates: list[int] = [] @@ -817,18 +825,53 @@ class GeneticOptimization(OptimizationBase): return None deadline_slot = min(candidates) - if deadline_slot >= self.total_slots: + if deadline_slot >= self.control_end_slot: # Beyond the horizon: the end-of-horizon requirement already covers it. return None # A deadline in the past means the target is due right now. return max(deadline_slot, start_slot) + def _validate_forecast_availability(self) -> None: + """Use only the contiguous finite forecast prefix after now.""" + start = self._control_start_slot() + required = self.control_end_slot + requested = required + self.tail_slots + available = requested + limiting = [] + for name, values in ( + ("load", self.simulation.load_energy_array), + ("pv", self.simulation.pv_prediction_wh), + ("import price", self.simulation.elect_price_hourly), + ("feed-in tariff", self.simulation.elect_revenue_per_hour_arr), + ): + end = min(len(values), requested) if values is not None else 0 + if values is not None: + missing = np.flatnonzero(~np.isfinite(values[start:end])) + if missing.size: + end = start + int(missing[0]) + if end < required: + raise ValueError( + f"Incomplete control forecast: {name} ends at slot {end}; control requires slot {required}." + ) + if end < requested: + limiting.append(name) + available = min(available, end) + self._effective_tail_slots = max(0, available - required) + self._forecast_reason = "" + if available < requested: + self._forecast_reason = ( + f"Tail forecast shortened: requested {self.config.optimization.tail_horizon_hours} h, " + f"effective {self._effective_tail_slots * self.slot_duration_h:g} h; " + f"limited by {', '.join(limiting)}. Continuation starts at slot {available}." + ) + logger.warning(self._forecast_reason) + def _build_terminal_value_curve( self, battery: Optional[Battery], inverter: Optional[Inverter], ) -> Optional[TerminalValueCurve]: - """Derive the terminal value curve from the trailing horizon window. + """Build continuation at the effective tail end, then solve the tail. Only built in AUTO mode and only with a battery: the curve describes what the energy left in that battery is worth once the horizon ends. @@ -862,10 +905,7 @@ class GeneticOptimization(OptimizationBase): * dc_to_ac ) window_slots = max(int(window_hours) * self.slots_per_hour, 1) - end_slot = min( - self.total_slots, - self._start_day_slot() + self.config.optimization.horizon_hours * self.slots_per_hour, - ) + end_slot = self.control_end_slot + getattr(self, "_effective_tail_slots", 0) curve = build_terminal_value_curve( prices_euro_per_wh=trailing_window( @@ -881,6 +921,53 @@ class GeneticOptimization(OptimizationBase): dc_to_ac_efficiency=dc_to_ac, grid_export_allowed=self.optimize_battery_grid_export, ) + self._continuation_value_curve = curve + self._tail_diagnostics = None + if self.tail_slots and inverter is not None: + tail = slice(self.control_end_slot, end_slot) + prices = self.simulation.elect_price_hourly + loads = self.simulation.load_energy_array + pv = self.simulation.pv_prediction_wh + tariffs = self.simulation.elect_revenue_per_hour_arr + if prices is None or loads is None or pv is None or tariffs is None: + raise ValueError("Tail evaluation requires prepared forecasts") + tail_prices = prices[tail] + tail_tariffs = tariffs[tail] + self._tail_diagnostics = TailDiagnostics( + slots=len(tail_prices), + slot_hours=self.slot_duration_h, + soc_grid_points=101, + min_import_price_euro_per_kwh=( + float(np.min(tail_prices)) * 1000 if len(tail_prices) else 0.0 + ), + max_import_price_euro_per_kwh=( + float(np.max(tail_prices)) * 1000 if len(tail_prices) else 0.0 + ), + min_feed_in_tariff_euro_per_kwh=( + float(np.min(tail_tariffs)) * 1000 if len(tail_tariffs) else 0.0 + ), + max_feed_in_tariff_euro_per_kwh=( + float(np.max(tail_tariffs)) * 1000 if len(tail_tariffs) else 0.0 + ), + negative_import_price_slots=int(np.count_nonzero(tail_prices < 0.0)), + positive_battery_export_slots=( + int(np.count_nonzero(tail_tariffs > 0.0)) + if self.optimize_battery_grid_export + else 0 + ), + ) + return build_tail_value_curve( + battery=battery, + inverter=inverter, + prices_euro_per_wh=prices[tail], + load_wh=loads[tail], + pv_wh=pv[tail], + feed_in_euro_per_wh=tariffs[tail], + continuation=curve, + charge_rates=self.bat_possible_charge_values, + export_rates=self.bat_possible_grid_export_values, + direct_marketing=self.optimize_battery_grid_export, + ) if curve.energy_wh: self._terminal_value_reason = "" logger.debug( @@ -904,7 +991,10 @@ class GeneticOptimization(OptimizationBase): return curve def _terminal_value( - self, parameters: GeneticOptimizationParameters + self, + parameters: GeneticOptimizationParameters, + *, + include_tail_plan: bool = False, ) -> tuple[float, TerminalValueResult]: """Credit for the energy left in the battery, plus its report. @@ -914,9 +1004,17 @@ class GeneticOptimization(OptimizationBase): Returns: The credit in EUR and the result object for the solution. """ + diagnostics = dict( + control_horizon_hours=self.config.optimization.horizon_hours, + requested_tail_hours=self.config.optimization.tail_horizon_hours, + effective_tail_hours=0.0, + tail_end_hour=float(self.config.optimization.horizon_hours), + ) battery = self.simulation.battery if battery is None: - return 0.0, TerminalValueResult(mode="FIXED", reason="no battery in this optimization") + return 0.0, TerminalValueResult( + mode="FIXED", reason="no battery in this optimization", **diagnostics + ) # Usable DC energy, converted to the AC energy that can serve a load. energy_wh = battery.current_energy_content() @@ -926,11 +1024,34 @@ class GeneticOptimization(OptimizationBase): curve = getattr(self, "_terminal_value_curve", None) if curve is not None and curve.energy_wh: credit = curve.value(energy_wh) + if isinstance(curve, TailValueCurve): + tail_operating_euro, continuation_value_euro = curve.component_values(energy_wh) + tail_plan = ( + curve.diagnostic_plan(energy_wh, float(self.config.optimization.horizon_hours)) + if include_tail_plan + else [] + ) + else: + tail_operating_euro, continuation_value_euro = 0.0, credit + tail_plan = [] return credit, TerminalValueResult( - mode="AUTO", + mode="TAIL" if isinstance(curve, TailValueCurve) else "AUTO", + control_horizon_hours=self.config.optimization.horizon_hours, + requested_tail_hours=self.config.optimization.tail_horizon_hours, + effective_tail_hours=getattr(self, "_effective_tail_slots", 0) + * self.slot_duration_h, + tail_end_hour=(self.control_end_slot + getattr(self, "_effective_tail_slots", 0)) + * self.slot_duration_h, + continuation_mode="AUTO", + reason=getattr(self, "_forecast_reason", ""), battery_energy_wh=energy_wh, credited_euro=credit, + tail_operating_euro=tail_operating_euro, + continuation_value_euro=continuation_value_euro, curve=curve, + continuation_curve=getattr(self, "_continuation_value_curve", None), + tail_diagnostics=getattr(self, "_tail_diagnostics", None), + tail_plan=tail_plan, ) credit = energy_wh * parameters.ems.preis_euro_pro_wh_akku @@ -938,7 +1059,18 @@ class GeneticOptimization(OptimizationBase): mode="FIXED", battery_energy_wh=energy_wh, credited_euro=credit, - reason=getattr(self, "_terminal_value_reason", "") or "terminal_value_mode is FIXED", + continuation_value_euro=credit, + reason=" ".join( + filter( + None, + [ + getattr(self, "_terminal_value_reason", "") + or "terminal_value_mode is FIXED", + getattr(self, "_forecast_reason", ""), + ], + ) + ), + **diagnostics, ) def _build_appliance_layout( @@ -953,7 +1085,7 @@ class GeneticOptimization(OptimizationBase): Raises: ValueError: If a ONCE appliance has no valid start within the horizon. """ - start_slot = self._start_day_slot() + start_slot = self._control_start_slot() horizon_end_slot = self._appliance_horizon_end_slot() genes: list[ApplianceGeneSlot] = [] gene_index = 0 @@ -1069,7 +1201,7 @@ class GeneticOptimization(OptimizationBase): individual, which dominated the fitness runtime.) The loops replicate the former inline computation exactly, keeping results bit-identical. """ - n = len(prices_arr) + n = min(len(prices_arr), self.control_end_slot) best_prices = [0.0] * n for hour in range(n): # Build list of (price, load_wh) for all future hours in the horizon @@ -1101,10 +1233,9 @@ class GeneticOptimization(OptimizationBase): return parameters feed_in_tariff = parameters.ems.einspeiseverguetung_euro_pro_wh - if ( - isinstance(feed_in_tariff, list) - and len(feed_in_tariff) == len(parameters.ems.strompreis_euro_pro_wh) - and len(set(feed_in_tariff)) > 1 + if isinstance(feed_in_tariff, list) and ( + len(feed_in_tariff) != len(parameters.ems.strompreis_euro_pro_wh) + or len(set(feed_in_tariff)) > 1 ): return parameters @@ -1122,25 +1253,34 @@ class GeneticOptimization(OptimizationBase): API clients historically provide one value per prediction hour. At a sub-hourly interval, energy quantities are distributed across the slots while price quantities are held constant. Inputs already matching the - native slot grid are preserved exactly. Any other length is ambiguous and - rejected instead of silently shortening the simulation horizon. + native slot grid are preserved exactly. Short native forecasts must + declare their interval; availability is validated separately. """ def normalize(values: list[float], name: str, *, energy: bool) -> list[float]: - value_count = len(values) - if value_count == self.total_slots: - return list(values) - if value_count != self.config.prediction.hours: - raise ValueError( - f"{name} has {value_count} values; expected either " - f"{self.config.prediction.hours} hourly values or " - f"{self.total_slots} optimization-slot values." + data = np.asarray(values, dtype=float) + # API inputs default to hourly; native callers declare their interval. + native = parameters.forecast_interval_seconds == self.config.optimization.interval + if parameters.forecast_interval_seconds is None: + max_hourly = self.config.prediction.hours + math.ceil( + self._start_day_slot() / self.slots_per_hour ) - - normalized = np.repeat(np.asarray(values, dtype=float), self.slots_per_hour) - if energy: - normalized /= self.slots_per_hour - return normalized.tolist() + if self.slots_per_hour > 1 and max_hourly < len(data) < self.prediction_slots: + raise ValueError( + f"{name}: ambiguous forecast interval; expected either {self.config.prediction.hours} hourly values or {self.prediction_slots} native values. Set forecast_interval_seconds for shortened native forecasts." + ) + native = self.slots_per_hour == 1 or len(data) >= self.prediction_slots + if parameters.forecast_interval_seconds == 900 and self.slots_per_hour == 1: + remainder = len(data) % 4 + if remainder: + data = np.pad(data, (0, 4 - remainder), constant_values=np.nan) + blocks = data.reshape(-1, 4) + data = blocks.sum(axis=1) if energy else blocks.mean(axis=1) + elif not native: + data = np.repeat(data, self.slots_per_hour) + if energy: + data /= self.slots_per_hour + return data[self._start_day_slot() :].tolist() ems = parameters.ems feed_in_tariff = ems.einspeiseverguetung_euro_pro_wh @@ -1151,7 +1291,9 @@ class GeneticOptimization(OptimizationBase): energy=False, ) else: - normalized_feed_in_tariff = [float(feed_in_tariff)] * self.total_slots + normalized_feed_in_tariff = [float(feed_in_tariff)] * ( + self._control_start_slot() + self.prediction_slots + ) normalized_ems = ems.model_copy( update={ @@ -1167,17 +1309,15 @@ class GeneticOptimization(OptimizationBase): deep=True, ) temperature_forecast = parameters.temperature_forecast + if ( + temperature_forecast is not None + and parameters.forecast_interval_seconds != self.config.optimization.interval + ): + temperature_forecast = [ + v for v in temperature_forecast for _ in range(self.slots_per_hour) + ] if temperature_forecast is not None: - if len(temperature_forecast) == self.config.prediction.hours: - temperature_forecast = [ - value for value in temperature_forecast for _ in range(self.slots_per_hour) - ] - elif len(temperature_forecast) != self.total_slots: - raise ValueError( - f"temperature_forecast has {len(temperature_forecast)} values; expected " - f"either {self.config.prediction.hours} hourly values or " - f"{self.total_slots} optimization-slot values." - ) + temperature_forecast = temperature_forecast[self._start_day_slot() :] return parameters.model_copy( update={"ems": normalized_ems, "temperature_forecast": temperature_forecast}, deep=True, @@ -1192,9 +1332,10 @@ class GeneticOptimization(OptimizationBase): (incompatible tails cause the whole start solution to be discarded). """ n_appliance_genes = self.appliance_layout.n_genes - expected_length = self.total_slots * (2 if self.optimize_ev else 1) + n_appliance_genes + expected_length = self.control_end_slot * (2 if self.optimize_ev else 1) + n_appliance_genes hourly_length = ( - self.config.prediction.hours * (2 if self.optimize_ev else 1) + n_appliance_genes + self.config.optimization.horizon_hours * (2 if self.optimize_ev else 1) + + n_appliance_genes ) if len(start_solution) == expected_length or self.slots_per_hour == 1: @@ -1202,10 +1343,10 @@ class GeneticOptimization(OptimizationBase): if len(start_solution) != hourly_length: return list(start_solution) - battery_end = self.config.prediction.hours + battery_end = self.config.optimization.horizon_hours migrated = np.repeat(start_solution[:battery_end], self.slots_per_hour).tolist() if self.optimize_ev: - ev_end = battery_end + self.config.prediction.hours + ev_end = battery_end + self.config.optimization.horizon_hours migrated.extend( np.repeat(start_solution[battery_end:ev_end], self.slots_per_hour).tolist() ) @@ -1281,8 +1422,8 @@ class GeneticOptimization(OptimizationBase): def _mutate_battery_block(self, individual: list[int]) -> None: """Mutate a short future block to one coherent operating policy.""" - start_slot = self._start_day_slot() - if start_slot >= self.total_slots: + start_slot = self._control_start_slot() + if start_slot >= self.control_end_slot: return state_layout = self._battery_state_layout() @@ -1295,8 +1436,8 @@ class GeneticOptimization(OptimizationBase): # Every export level is a coherent policy for a whole block. policy_states.extend(state_layout.grid_export_states) - block_start = random.randint(start_slot, self.total_slots - 1) # noqa: S311 - max_length = min(12, self.total_slots - block_start) + block_start = random.randint(start_slot, self.control_end_slot - 1) # noqa: S311 + max_length = min(12, self.control_end_slot - block_start) block_length = random.randint(2, max(2, max_length)) if max_length > 1 else 1 # noqa: S311 state = random.choice(policy_states) # noqa: S311 individual[block_start : block_start + block_length] = [state] * block_length @@ -1314,14 +1455,14 @@ class GeneticOptimization(OptimizationBase): load = np.asarray(self.simulation.load_energy_array, dtype=float) except Exception: return [] - if any(values.size < self.total_slots for values in (prices, feed_in, pv, load)): + if any(values.size < self.control_end_slot for values in (prices, feed_in, pv, load)): return [] len_bat = len(self.bat_possible_charge_values) source_tariff = float(feed_in[source_slot]) candidates = [ slot - for slot in range(source_slot + 1, self.total_slots) + for slot in range(source_slot + 1, self.control_end_slot) if 0 <= int(individual[slot]) < len_bat and load[slot] > pv[slot] and prices[slot] > source_tariff @@ -1340,9 +1481,9 @@ class GeneticOptimization(OptimizationBase): if not export_states or self_state is None: return False - start_slot = self._start_day_slot() + start_slot = self._control_start_slot() viable: list[tuple[int, list[int]]] = [] - for source_slot in range(start_slot, self.total_slots): + for source_slot in range(start_slot, self.control_end_slot): if int(individual[source_slot]) not in export_states: continue targets = self._energy_shift_target_slots(individual, source_slot) @@ -1384,20 +1525,20 @@ class GeneticOptimization(OptimizationBase): def _mutate_point_controls(self, individual: list[int]) -> bool: """Apply a small point mutation only to controls that can still affect fitness.""" changed = False - start_slot = self._start_day_slot() + start_slot = self._control_start_slot() total_states = self._battery_state_layout().total_states - battery_part = list(individual[start_slot : self.total_slots]) + battery_part = list(individual[start_slot : self.control_end_slot]) battery_before = list(battery_part) (battery_part,) = self.toolbox.mutate_charge_discharge(battery_part) if battery_part == battery_before: self._force_segment_change(battery_part, 0, total_states - 1) if battery_part != battery_before: - individual[start_slot : self.total_slots] = battery_part + individual[start_slot : self.control_end_slot] = battery_part changed = True if self.optimize_ev and random.random() < 0.40: # noqa: S311 - ev_start = self.total_slots + start_slot - ev_end = self.total_slots * 2 - self.fixed_eauto_hours + ev_start = self.control_end_slot + start_slot + ev_end = self.control_end_slot * 2 - self.fixed_eauto_hours ev_part = list(individual[ev_start:ev_end]) ev_before = list(ev_part) (ev_part,) = self.toolbox.mutate_ev_charge_index(ev_part) @@ -1413,8 +1554,8 @@ class GeneticOptimization(OptimizationBase): """Mutate EV or appliance controls without disturbing a good battery schedule.""" changed = False if self.optimize_ev: - ev_start = self.total_slots + self._start_day_slot() - ev_end = self.total_slots * 2 - self.fixed_eauto_hours + ev_start = self.control_end_slot + self._control_start_slot() + ev_end = self.control_end_slot * 2 - self.fixed_eauto_hours ev_part = list(individual[ev_start:ev_end]) ev_before = list(ev_part) (ev_part,) = self.toolbox.mutate_ev_charge_index(ev_part) @@ -1464,7 +1605,7 @@ class GeneticOptimization(OptimizationBase): self._mutate_point_controls(individual) if self.optimize_ev and self.fixed_eauto_hours > 0: - ev_end = self.total_slots * 2 + ev_end = self.control_end_slot * 2 individual[ev_end - self.fixed_eauto_hours : ev_end] = [0] * self.fixed_eauto_hours return (individual,) @@ -1473,12 +1614,14 @@ class GeneticOptimization(OptimizationBase): def create_individual(self) -> list[int]: # Start with discharge states for the individual individual_components = [ - self.toolbox.attr_discharge_state() for _ in range(self.total_slots) + self.toolbox.attr_discharge_state() for _ in range(self.control_end_slot) ] # Add EV charge index values if optimize_ev is True if self.optimize_ev: - ev_controls = [self.toolbox.attr_ev_charge_index() for _ in range(self.total_slots)] + ev_controls = [ + self.toolbox.attr_ev_charge_index() for _ in range(self.control_end_slot) + ] if self.fixed_eauto_hours > 0: ev_controls[-self.fixed_eauto_hours :] = [0] * self.fixed_eauto_hours individual_components += ev_controls @@ -1516,7 +1659,7 @@ class GeneticOptimization(OptimizationBase): individual.extend(eautocharge_hours_index.tolist()) elif self.optimize_ev: # optimize_ev active but no EV data present: pad with zeros - individual.extend([0] * self.total_slots) + individual.extend([0] * self.control_end_slot) # Add appliance start genes (one index per scheduled run). n_appliance_genes = self.appliance_layout.n_genes @@ -1539,13 +1682,13 @@ class GeneticOptimization(OptimizationBase): 3. Appliance start genes (list of indices, one per scheduled run). """ # Discharge hours as a NumPy array of ints - discharge_hours_bin = np.array(individual[: self.total_slots], dtype=int) + discharge_hours_bin = np.array(individual[: self.control_end_slot], dtype=int) # EV charge hours as a NumPy array of ints (if optimize_ev is True) eautocharge_hours_index = ( # append ev charging states to individual np.array( - individual[self.total_slots : self.total_slots * 2], + individual[self.control_end_slot : self.control_end_slot * 2], dtype=int, ) if self.optimize_ev @@ -1588,8 +1731,8 @@ class GeneticOptimization(OptimizationBase): return False ev_soc = np.asarray(simulation_result.get("EAuto_SoC_pro_Stunde", []), dtype=float) - start_slot = self._start_day_slot() - result_slots = min(ev_soc.size, self.total_slots - start_slot) + start_slot = self._control_start_slot() + result_slots = min(ev_soc.size, self.control_end_slot - start_slot) if result_slots <= 0: return False @@ -1619,7 +1762,7 @@ class GeneticOptimization(OptimizationBase): range(len(self.ev_possible_charge_values)), key=lambda index: abs(self.ev_possible_charge_values[index]), ) - schedule = [zero_index] * self.total_slots + schedule = [zero_index] * self.control_end_slot ev = self.simulation.ev if ev is None: return schedule @@ -1631,8 +1774,8 @@ class GeneticOptimization(OptimizationBase): if required_stored_wh <= 0.0: return schedule - start_slot = self._start_day_slot() - end_slot = max(start_slot, self.total_slots - self.fixed_eauto_hours) + start_slot = self._control_start_slot() + end_slot = max(start_slot, self.control_end_slot - self.fixed_eauto_hours) if getattr(self, "_ev_soc_deadline_slot", None) is not None: # Charging after the deadline does not help to reach the target. end_slot = max(start_slot, min(end_slot, self._ev_soc_deadline_slot)) @@ -1699,8 +1842,8 @@ class GeneticOptimization(OptimizationBase): if target_count <= 0: return [] - slots = self.total_slots - start_slot = self._start_day_slot() + slots = self.control_end_slot + start_slot = self._control_start_slot() len_bat = len(self.bat_possible_charge_values) state_layout = self._battery_state_layout() idle_state = 0 @@ -1879,7 +2022,7 @@ class GeneticOptimization(OptimizationBase): ) -> list[list[int]]: """Create unique local variants while preserving already elapsed slots.""" original = [int(value) for value in start_solution] - start_slot = self._start_day_slot() + start_slot = self._control_start_slot() seen = {tuple(original)} neighbors: list[list[int]] = [] for _ in range(max(count * 10, 1)): @@ -1887,7 +2030,7 @@ class GeneticOptimization(OptimizationBase): self.mutate(neighbor) neighbor[:start_slot] = original[:start_slot] if self.optimize_ev: - ev_start = self.total_slots + ev_start = self.control_end_slot neighbor[ev_start : ev_start + start_slot] = original[ ev_start : ev_start + start_slot ] @@ -1913,18 +2056,18 @@ class GeneticOptimization(OptimizationBase): if not export_states or self_state is None: return [] - start_slot = self._start_day_slot() + start_slot = self._control_start_slot() try: feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float) pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float) except Exception: return [] - if feed_in.size < self.total_slots or pv.size < self.total_slots: + if feed_in.size < self.control_end_slot or pv.size < self.control_end_slot: return [] sources = [ slot - for slot in range(start_slot, self.total_slots) + for slot in range(start_slot, self.control_end_slot) if int(individual[slot]) in export_states ] # Search weak and late export decisions first. They are the most likely @@ -2104,7 +2247,11 @@ class GeneticOptimization(OptimizationBase): for _ in range(count): child = self.toolbox.clone(random.choice(population)) # noqa: S311 crossed = False - if len(population) > 1 and random.random() < self.CROSSOVER_PROBABILITY: # noqa: S311 + if ( + len(child) > 1 + and len(population) > 1 + and random.random() < self.CROSSOVER_PROBABILITY # noqa: S311 + ): partner = self.toolbox.clone(random.choice(population)) # noqa: S311 child, _ = self.toolbox.mate(child, partner) crossed = True @@ -2307,7 +2454,7 @@ class GeneticOptimization(OptimizationBase): # expected number of changed controls remains close to three regardless # of interval and elapsed slots; coherent block/energy moves are handled # by separate mutation families. - active_slots = max(self.total_slots - self._start_day_slot(), 1) + active_slots = max(self.control_end_slot - self._control_start_slot(), 1) mutation_probability = min( 0.10, self.POINT_MUTATION_EXPECTED_GENES / active_slots, @@ -2357,7 +2504,7 @@ class GeneticOptimization(OptimizationBase): if self.optimize_dc_charge: self.simulation.dc_charge_hours = dc_charge_hours else: - self.simulation.dc_charge_hours = np.full(self.total_slots, 1) + self.simulation.dc_charge_hours = np.full(self.control_end_slot, 1) self.simulation.ac_charge_hours = ac_charge_hours if eautocharge_hours_index is not None: @@ -2369,12 +2516,12 @@ class GeneticOptimization(OptimizationBase): self.simulation.ev_charge_hours = eautocharge_hours_float else: # discharge is set to 0 by default - self.simulation.ev_charge_hours = np.full(self.total_slots, 0) + self.simulation.ev_charge_hours = np.full(self.control_end_slot, 0) # Do the simulation and return result. simulate()'s argument is a slot # index into the prediction/charge arrays, not an hour-of-day, so pass # the start_day_slot to keep sub-hourly runs aligned. - return self.simulation.simulate(self._start_day_slot()) + return self.simulation.simulate(self._control_start_slot()) def evaluate( self, @@ -2424,11 +2571,11 @@ class GeneticOptimization(OptimizationBase): def _fitness_key(self, individual: list[int]) -> tuple[int, ...]: """Return the fitness-relevant genome, excluding elapsed control slots.""" - start_slot = self._start_day_slot() - relevant = list(individual[start_slot : self.total_slots]) + start_slot = self._control_start_slot() + relevant = list(individual[start_slot : self.control_end_slot]) if self.optimize_ev: - ev_start = self.total_slots + start_slot - relevant.extend(individual[ev_start : self.total_slots * 2]) + ev_start = self.control_end_slot + start_slot + relevant.extend(individual[ev_start : self.control_end_slot * 2]) n_appliance_genes = self.appliance_layout.n_genes if n_appliance_genes > 0: relevant.extend(individual[-n_appliance_genes:]) @@ -2587,6 +2734,7 @@ class GeneticOptimization(OptimizationBase): if ( self.simulation.battery and self.simulation.inverter + and not isinstance(getattr(self, "_terminal_value_curve", None), TailValueCurve) and self.simulation.ac_charge_hours is not None and self.simulation.elect_price_hourly is not None and self.simulation.load_energy_array is not None @@ -2616,7 +2764,7 @@ class GeneticOptimization(OptimizationBase): ac_charge_arr = self.simulation.ac_charge_hours prices_arr = self.simulation.elect_price_hourly load_arr = self.simulation.load_energy_array - n = len(prices_arr) + n = min(len(prices_arr), self.control_end_slot) # Usable AC energy already in battery from prior PV charging (zero grid cost). # This covers the most expensive future hours first, pushing AC charging demand @@ -2721,7 +2869,9 @@ class GeneticOptimization(OptimizationBase): valid_start_solution: Optional[list[float]] = None if start_solution is not None: n_appliance_genes = self.appliance_layout.n_genes - expected_length = self.total_slots * (2 if self.optimize_ev else 1) + n_appliance_genes + expected_length = ( + self.control_end_slot * (2 if self.optimize_ev else 1) + n_appliance_genes + ) start_solution = self._start_solution_for_slot_grid(start_solution) if len(start_solution) != expected_length: @@ -2910,6 +3060,7 @@ class GeneticOptimization(OptimizationBase): individuals: Optional[int] = None, ) -> GeneticSolution: """Perform EMS (Energy Management System) optimization and visualize results.""" + self.config.validate_optimization_horizons() direct_marketing_enabled = self._direct_marketing_enabled() parameters = self._parameters_for_config(parameters) parameters = self._parameters_for_slot_grid(parameters) @@ -2926,10 +3077,8 @@ class GeneticOptimization(OptimizationBase): raise ValueError( f"Start hour not synced. EMS {self.ems.start_datetime.hour} vs. GENETIC {start_hour}." ) - # start_hour stays the hour-of-day for the appliance-start gene bounds - # (0..23). Everything that indexes the slot arrays (the simulate offset - # and evaluate's break-even loop) uses the slot index instead. - start_slot = self._start_day_slot() + # Forecasts are trimmed to now; all genome/device indices are run-relative. + start_slot = self._control_start_slot() # Set the number of generations generations = ngen @@ -2951,19 +3100,19 @@ class GeneticOptimization(OptimizationBase): if parameters.pv_akku: akku = Battery( parameters.pv_akku, - prediction_hours=self.total_slots, + prediction_hours=self.control_end_slot, slot_duration_h=self.slot_duration_h, ) - akku.set_charge_per_hour(np.full(self.total_slots, 0)) + akku.set_charge_per_hour(np.full(self.control_end_slot, 0)) eauto: Optional[Battery] = None if parameters.eauto: eauto = Battery( parameters.eauto, - prediction_hours=self.total_slots, + prediction_hours=self.control_end_slot, slot_duration_h=self.slot_duration_h, ) - eauto.set_charge_per_hour(np.full(self.total_slots, 1)) + eauto.set_charge_per_hour(np.full(self.control_end_slot, 1)) self.optimize_ev = ( parameters.eauto.min_soc_percentage > parameters.eauto.initial_soc_percentage ) @@ -3037,16 +3186,14 @@ class GeneticOptimization(OptimizationBase): logger.debug("Battery grid export levels: {}", self.bat_possible_grid_export_values) # Initialize the flexible consumers (home appliances) and their genome - # layout. slot0_datetime (midnight of the start day) turns decoded start + # layout. slot0_datetime (the run start) turns decoded start # slots into absolute local timestamps and drives DAILY day grouping. - self._slot0_datetime = self.ems.start_datetime.set( - hour=0, minute=0, second=0, microsecond=0 - ) + self._slot0_datetime = self.ems.start_datetime home_appliances = [ HomeAppliance( parameters=appliance_params, optimization_hours=self.config.optimization.horizon_hours, - prediction_hours=self.total_slots, + prediction_hours=self.control_end_slot, slot_duration_h=self.slot_duration_h, ) for appliance_params in home_appliance_params @@ -3079,18 +3226,31 @@ class GeneticOptimization(OptimizationBase): self.simulation.prepare( parameters=parameters.ems, optimization_hours=self.config.optimization.horizon_hours, - prediction_hours=self.total_slots, + prediction_hours=self.control_end_slot, inverter=inverter, # battery is part of inverter ev=eauto, home_appliances=home_appliances, direct_marketing_enabled=direct_marketing_enabled, ) + self._validate_forecast_availability() + # Terminal value of the energy left in the battery. Built once per run - # it needs the prepared price/load/PV series - so every fitness # evaluation only interpolates on it. self._terminal_value_curve = self._build_terminal_value_curve(akku, inverter) + # The curve owns all lookahead information from here on. Simulation + # and control heuristics receive only equally sized control forecasts, + # even when providers supplied different amounts of tail data. + for name in ( + "load_energy_array", + "pv_prediction_wh", + "elect_price_hourly", + "elect_revenue_per_hour_arr", + ): + setattr(self.simulation, name, getattr(self.simulation, name)[: self.control_slots]) + # Setup the DEAP environment and optimization process. The appliance # genome layout (built above) drives the appliance gene block; evaluate # gets the slot index (its break-even loop walks the slot arrays from "now"). @@ -3112,7 +3272,7 @@ class GeneticOptimization(OptimizationBase): # Perform final evaluation on the best solution simulation_result = self.evaluate_inner(start_solution) # Read the terminal value off the final battery state, for the solution. - _, terminal_value_result = self._terminal_value(parameters) + _, terminal_value_result = self._terminal_value(parameters, include_tail_plan=True) # Prepare results discharge_hours_bin, eautocharge_hours_index, appliance_gene_values = self.split_individual( @@ -3154,37 +3314,25 @@ class GeneticOptimization(OptimizationBase): if self.slots_per_hour == 1 and len(self.simulation.home_appliances) == 1: single_starts = starts_per_appliance.get(0, []) if single_starts: - washingstart_int = int(min(single_starts)) + washingstart_int = self._start_day_slot() + int(min(single_starts)) eautocharge_hours_float = None if eautocharge_hours_index is not None and self.simulation.ev is not None: eautocharge_hours_float = self.simulation.ev.charge_array.tolist() - # Simulation may have changed something, use simulation values - ac_charge_hours = self.simulation.ac_charge_hours - if ac_charge_hours is None: - ac_charge_hours = [] - else: - ac_charge_hours = ac_charge_hours.tolist() - dc_charge_hours = self.simulation.dc_charge_hours - if dc_charge_hours is None: - dc_charge_hours = [] - else: - dc_charge_hours = dc_charge_hours.tolist() - discharge = self.simulation.bat_discharge_hours - if discharge is None: - discharge = [] - else: - discharge = discharge.tolist() - battery_grid_export = self.simulation.bat_grid_export_hours - if not direct_marketing_enabled or battery_grid_export is None: - battery_grid_export_factor = [] - battery_grid_export = [] - else: - # The simulation array holds the export level; the legacy signal is - # its boolean projection. - battery_grid_export_factor = [float(value) for value in battery_grid_export] - battery_grid_export = [1 if value > 0.0 else 0 for value in battery_grid_export_factor] + # Report executed controls, already indexed from the run start. + def control_values(values: Optional[np.ndarray]) -> list[float]: + return values.tolist() if values is not None else [] + + ac_charge_hours = control_values(self.simulation.ac_charge_hours) + dc_charge_hours = control_values(self.simulation.dc_charge_hours) + discharge = control_values(self.simulation.bat_discharge_hours) + battery_grid_export_factor = ( + control_values(self.simulation.bat_grid_export_hours) + if direct_marketing_enabled + else [] + ) + battery_grid_export = [1 if value > 0 else 0 for value in battery_grid_export_factor] # Visualize the results in PDF. Skippable via config — matplotlib PDF # generation costs several seconds per run, which headless setups @@ -3194,11 +3342,14 @@ class GeneticOptimization(OptimizationBase): from akkudoktoreos.utils.visualize import prepare_visualize visualize = { + "controls_start_at_now": True, "ac_charge": ac_charge_hours, "dc_charge": dc_charge_hours, "discharge_allowed": discharge, "battery_grid_export_allowed": battery_grid_export, - "eautocharge_hours_float": eautocharge_hours_float, + "eautocharge_hours_float": eautocharge_hours_float[start_slot:] + if eautocharge_hours_float is not None + else None, "result": simulation_result, "eauto_obj": self.simulation.ev.to_dict() if self.simulation.ev else None, "start_solution": start_solution, @@ -3216,13 +3367,16 @@ class GeneticOptimization(OptimizationBase): return GeneticSolution( **{ + "controls_start_at_now": True, "ac_charge": ac_charge_hours, "dc_charge": dc_charge_hours, "discharge_allowed": discharge, "battery_grid_export_allowed": battery_grid_export, "battery_grid_export_factor": battery_grid_export_factor, "terminal_value": terminal_value_result, - "eautocharge_hours_float": eautocharge_hours_float, + "eautocharge_hours_float": eautocharge_hours_float[start_slot:] + if eautocharge_hours_float is not None + else None, "result": GeneticSimulationResult(**simulation_result), "eauto_obj": self.simulation.ev, "start_solution": start_solution, diff --git a/src/akkudoktoreos/optimization/genetic/geneticparams.py b/src/akkudoktoreos/optimization/genetic/geneticparams.py index b15a94ac..d0530614 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticparams.py +++ b/src/akkudoktoreos/optimization/genetic/geneticparams.py @@ -20,6 +20,7 @@ from akkudoktoreos.core.coreabc import ( PredictionMixin, get_ems, ) +from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel from akkudoktoreos.optimization.genetic.geneticdevices import ( ElectricVehicleParameters, @@ -70,25 +71,6 @@ class GeneticEnergyManagementParameters(GeneticParametersBaseModel): } ) - @model_validator(mode="after") - def validate_list_length(self) -> Self: - """Validate that all input lists are of the same length. - - Raises: - ValueError: If input list lengths differ. - """ - pv_prognose_length = len(self.pv_prognose_wh) - if ( - pv_prognose_length != len(self.strompreis_euro_pro_wh) - or pv_prognose_length != len(self.gesamtlast) - or ( - isinstance(self.einspeiseverguetung_euro_pro_wh, list) - and pv_prognose_length != len(self.einspeiseverguetung_euro_pro_wh) - ) - ): - raise ValueError("Input lists have different lengths") - return self - class GeneticOptimizationParameters( ConfigMixin, @@ -104,6 +86,18 @@ class GeneticOptimizationParameters( optimization process, such as forecasts, pricing, battery and appliance models. """ + forecast_interval_seconds: Optional[int] = Field( + default=None, + description="Input interval: 3600 for hourly, or optimization interval for native slots.", + ) + + @field_validator("forecast_interval_seconds") + @classmethod + def validate_forecast_interval(cls, value: Optional[int]) -> Optional[int]: + if value not in (None, 900, 3600): + raise ValueError("forecast_interval_seconds must be 900 or 3600") + return value + ems: GeneticEnergyManagementParameters pv_akku: Optional[SolarPanelBatteryParameters] inverter: Optional[InverterParameters] @@ -165,7 +159,7 @@ class GeneticOptimizationParameters( dishwasher = self.__dict__.get("dishwasher") if dishwasher is not None and self.home_appliances is not None: raise ValueError( - "Provide either 'home_appliances' or the deprecated 'dishwasher', " "not both." + "Provide either 'home_appliances' or the deprecated 'dishwasher', not both." ) appliances = self.home_appliances or [] device_ids = [appliance.device_id for appliance in appliances] @@ -240,9 +234,7 @@ class GeneticOptimizationParameters( default_longitude = 13.405 logger.info(f"Longitude unknown - defaulting to {default_longitude}.") cls.config.general.longitude = default_longitude - if cls.config.prediction.hours is None: - logger.info("Prediction hours unknown - defaulting to 48 hours.") - cls.config.prediction.hours = 48 + cls.config.validate_optimization_horizons() if cls.config.prediction.historic_hours is None: logger.info("Prediction historic hours unknown - defaulting to 24 hours.") cls.config.prediction.historic_hours = 24 @@ -287,7 +279,7 @@ class GeneticOptimizationParameters( interval = to_duration(cls.config.optimization.interval) power_to_energy_per_interval_factor = cls.config.optimization.interval / 3600 parameter_start_datetime = ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0) - parameter_end_datetime = parameter_start_datetime.add(hours=cls.config.prediction.hours) + parameter_end_datetime = ems.start_datetime.add(hours=cls.config.prediction.hours) max_retries = 10 for attempt in range(1, max_retries + 1): @@ -300,171 +292,35 @@ class GeneticOptimizationParameters( # Assure predictions are uptodate cls.prediction.update_data() - try: - pvforecast_ac_power = ( - cls.prediction.key_to_array( - key="pvforecast_ac_power", - start_datetime=parameter_start_datetime, - end_datetime=parameter_end_datetime, - interval=interval, - # Forecast power values represent the mean of their source - # period. Hold them over smaller optimization slots so - # resampling preserves energy (especially hourly and - # Solcast 30-minute forecasts). - fill_method="ffill", - ) - * power_to_energy_per_interval_factor - ).tolist() - except: - logger.info( - "No PV forecast data available - defaulting to demo data. Parameter preparation attempt {}.", - attempt, - ) - cls.config.merge_settings_from_dict( - { - "pvforecast": { - "provider": "PVForecastAkkudoktor", - "max_planes": 4, - "planes": [ - { - "peakpower": 5.0, - "surface_azimuth": 170, - "surface_tilt": 7, - "userhorizon": [20, 27, 22, 20], - "inverter_paco": 10000, - }, - { - "peakpower": 4.8, - "surface_azimuth": 90, - "surface_tilt": 7, - "userhorizon": [30, 30, 30, 50], - "inverter_paco": 10000, - }, - { - "peakpower": 1.4, - "surface_azimuth": 140, - "surface_tilt": 60, - "userhorizon": [60, 30, 0, 30], - "inverter_paco": 2000, - }, - { - "peakpower": 1.6, - "surface_azimuth": 185, - "surface_tilt": 45, - "userhorizon": [45, 25, 30, 60], - "inverter_paco": 1400, - }, - ], - }, - } - ) - # Retry - continue - try: - elecprice_marketprice_wh = cls.prediction.key_to_array( - key="elecprice_marketprice_wh", + # Required forecasts are never replaced with demo providers or + # extrapolated prices. Missing intervals remain NaN and are checked + # against the control/tail boundary before genetic optimization. + def forecast(key: str) -> list[float]: + return bounded_forecast_array( + cls.prediction, + key=key, start_datetime=parameter_start_datetime, end_datetime=parameter_end_datetime, interval=interval, - fill_method="ffill", ).tolist() - except: - logger.info( - "No Electricity Marketprice forecast data available - defaulting to demo data. Parameter preparation attempt {}.", - attempt, - ) - cls.config.elecprice.provider = "ElecPriceAkkudoktor" - # Retry - continue - try: - # Load is a power series [W] that the genetic optimizer consumes - # as Wh-per-slot. Scale by interval/3600 (mirrors the PV forecast - # above) so a 15-min slot sees a quarter of the hourly energy. - loadforecast_power_w = ( - cls.prediction.key_to_array( - key="loadforecast_power_w", - start_datetime=parameter_start_datetime, - end_datetime=parameter_end_datetime, - interval=interval, - fill_method="ffill", - ) - * power_to_energy_per_interval_factor - ).tolist() - except: - logger.info( - "No Load forecast data available - defaulting to demo data. Parameter preparation attempt {}.", - attempt, - ) - cls.config.merge_settings_from_dict( - { - "load": { - "provider": "LoadAkkudoktor", - "loadakkudoktor": { - "loadakkudoktor_year_energy_kwh": "3000", - }, - }, - } - ) - # Retry - continue - if cls.config.feedintariff.direct_marketing_enabled: - if cls.config.feedintariff.provider in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS: - try: - feed_in_tariff_wh = cls.prediction.key_to_array( - key="feed_in_tariff_wh", - start_datetime=parameter_start_datetime, - end_datetime=parameter_end_datetime, - interval=interval, - fill_method="ffill", - ).tolist() - except: - feed_in_tariff_wh = list(elecprice_marketprice_wh) - else: - feed_in_tariff_wh = list(elecprice_marketprice_wh) + + pvforecast_ac_power = [ + value * power_to_energy_per_interval_factor + for value in forecast("pvforecast_ac_power") + ] + elecprice_marketprice_wh = forecast("elecprice_marketprice_wh") + loadforecast_power_w = [ + value * power_to_energy_per_interval_factor + for value in forecast("loadforecast_power_w") + ] + if ( + cls.config.feedintariff.direct_marketing_enabled + and cls.config.feedintariff.provider not in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS + ): + feed_in_tariff_wh = list(elecprice_marketprice_wh) else: - try: - feed_in_tariff_wh = cls.prediction.key_to_array( - key="feed_in_tariff_wh", - start_datetime=parameter_start_datetime, - end_datetime=parameter_end_datetime, - interval=interval, - fill_method="ffill", - ).tolist() - except: - logger.info( - "No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}.", - attempt, - ) - cls.config.merge_settings_from_dict( - { - "feedintariff": { - "provider": "FeedInTariffFixed", - "provider_settings": { - "FeedInTariffFixed": { - "feed_in_tariff_kwh": 0.078, - }, - }, - }, - } - ) - # Retry - continue - try: - weather_temp_air = cls.prediction.key_to_array( - key="weather_temp_air", - start_datetime=parameter_start_datetime, - end_datetime=parameter_end_datetime, - interval=interval, - fill_method="ffill", - ).tolist() - except: - logger.info( - "No weather forecast data available - defaulting to demo data. Parameter preparation attempt {}.", - attempt, - ) - cls.config.weather.provider = "BrightSky" - # Retry - continue + feed_in_tariff_wh = forecast("feed_in_tariff_wh") + weather_temp_air = forecast("weather_temp_air") # Add device data @@ -673,6 +529,7 @@ class GeneticOptimizationParameters( # We got all parameter data try: oparams = GeneticOptimizationParameters( + forecast_interval_seconds=cls.config.optimization.interval, ems=GeneticEnergyManagementParameters( pv_prognose_wh=pvforecast_ac_power, strompreis_euro_pro_wh=elecprice_marketprice_wh, diff --git a/src/akkudoktoreos/optimization/genetic/geneticsolution.py b/src/akkudoktoreos/optimization/genetic/geneticsolution.py index 64e74e9a..56798347 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsolution.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsolution.py @@ -181,6 +181,10 @@ class GeneticSimulationResult(GeneticParametersBaseModel): class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): """**Note**: The first value of "Last_Wh_per_hour", "Netzeinspeisung_Wh_per_hour", and "Netzbezug_Wh_per_hour", will be set to null in the JSON output and represented as NaN or None in the corresponding classes' data returns. This approach is adopted to ensure that the current hour's processing remains unchanged.""" + controls_start_at_now: bool = Field( + default=False, description="Control arrays start at the run timestamp instead of midnight." + ) + ac_charge: list[float] = Field( json_schema_extra={ "description": "Array with AC charging values as relative power (0.0-1.0), other values set to 0." @@ -247,7 +251,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): default_factory=dict, json_schema_extra={ "description": ( - "Scheduled run start times per appliance device_id as absolute " "local datetimes." + "Scheduled run start times per appliance device_id as absolute local datetimes." ) }, ) @@ -469,7 +473,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): - GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1 """ start_datetime = get_ems().start_datetime - # The genetic core emits total_slots = prediction.hours * slots_per_hour + # New controls use the run-relative control horizon; old payloads retain a midnight prefix. # entries indexed by slot (slot 0 == 00:00 local). Index this serializer # by slot too. At the default interval of 3600 s slots_per_hour == 1 and # this is the established hourly behaviour. @@ -477,7 +481,11 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): slots_per_hour = max(1, 3600 // interval_s) slot_minutes = max(1, interval_s // 60) start_local = start_datetime.in_timezone(self.config.general.timezone) - start_day_slot = start_local.hour * slots_per_hour + start_local.minute // slot_minutes + start_day_slot = ( + 0 + if self.controls_start_at_now + else start_local.hour * slots_per_hour + start_local.minute // slot_minutes + ) # power [W] -> energy per slot [Wh]: multiply by the slot duration in hours. power_to_energy_per_interval_factor = interval_s / 3600.0 @@ -785,7 +793,11 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): slots_per_hour = max(1, 3600 // interval_s) slot_minutes = max(1, interval_s // 60) start_local = start_datetime.in_timezone(self.config.general.timezone) - start_day_slot = start_local.hour * slots_per_hour + start_local.minute // slot_minutes + start_day_slot = ( + 0 + if self.controls_start_at_now + else start_local.hour * slots_per_hour + start_local.minute // slot_minutes + ) plan = EnergyManagementPlan( id=f"plan-genetic@{to_datetime(as_string=True)}", generated_at=to_datetime(), diff --git a/src/akkudoktoreos/optimization/genetic/tailvalue.py b/src/akkudoktoreos/optimization/genetic/tailvalue.py new file mode 100644 index 00000000..91a6f371 --- /dev/null +++ b/src/akkudoktoreos/optimization/genetic/tailvalue.py @@ -0,0 +1,303 @@ +"""Chronological, run-local battery lookahead using the production device physics. + +The Bellman recursion interpolates continuation values on a stored-energy grid. +Tail actions never enter the executable control arrays. The selected path may +be returned separately as diagnostics so users can inspect the lookahead. +""" + +import numpy as np +from pydantic import PrivateAttr + +from akkudoktoreos.devices.genetic.battery import Battery +from akkudoktoreos.devices.genetic.inverter import Inverter +from akkudoktoreos.optimization.genetic.terminalvalue import TailPlanSlot, TerminalValueCurve + + +TailAction = tuple[int, int, float, float] + + +def _action_name(action: TailAction) -> str: + dc, discharge, ac_rate, export_rate = action + if export_rate > 0: + return "BATTERY_EXPORT" + if ac_rate > 0: + return "GRID_CHARGE" + if dc and discharge: + return "SELF_CONSUMPTION" + if dc: + return "PV_CHARGE_ONLY" + if discharge: + return "DISCHARGE_ONLY" + return "HOLD" + + +def _simulate_action( + *, + bat: Battery, + inv: Inverter, + energy_wh: float, + action: TailAction, + price: float, + load: float, + pv: float, + tariff: float, + direct_marketing: bool, +) -> dict[str, float]: + """Apply one tail action from one stored-energy state.""" + dc, discharge, ac_rate, export = action + bat.soc_wh = float(energy_wh) + bat._charged_raw_wh_per_slot.fill(0) + bat._discharged_raw_wh_per_slot.fill(0) + ac_enabled = inv.ac_to_dc_efficiency > 0 and ( + inv.max_ac_charge_power_w is None or inv.max_ac_charge_power_w > 0 + ) + bat.charge_array[0] = ac_rate if ac_rate > 0 and ac_enabled else dc + bat.discharge_array[0] = discharge if export == 0 or tariff > 0 else 0 + sold, bought, losses, _ = inv.process_energy( + pv, + load, + 0, + allow_battery_grid_export=direct_marketing and export > 0 and tariff > 0, + battery_grid_export_factor=export, + ) + ac_grid_charge_wh = 0.0 + if ac_rate > 0 and inv.ac_to_dc_efficiency > 0: + rate = ac_rate + if inv.max_ac_charge_power_w is not None and bat.max_charge_power_w > 0: + rate = min( + rate, + inv.max_ac_charge_power_w * inv.ac_to_dc_efficiency / bat.max_charge_power_w, + ) + bat.charge_array[0] = rate + if rate > 0: + stored, loss = bat.charge_energy(None, 0, charge_factor=rate) + ac_grid_charge_wh = (stored + loss) / inv.ac_to_dc_efficiency + bought += ac_grid_charge_wh + losses += loss + max(ac_grid_charge_wh - stored - loss, 0.0) + if direct_marketing and tariff < 0: + sold = 0.0 + discharged_wh = bat.discharged_energy_wh(0) + reward = ( + sold * tariff - bought * price - (discharged_wh * bat.levelized_cost_of_storage_kwh / 1000) + ) + return { + "next_state_wh": bat.soc_wh, + "reward_euro": reward, + "grid_export_wh": sold, + "grid_import_wh": bought, + "battery_charge_wh": (bat._charged_raw_wh_per_slot[0] * bat.charging_efficiency), + "battery_discharge_wh": discharged_wh, + "losses_wh": losses, + "ac_grid_charge_wh": ac_grid_charge_wh, + } + + +class TailValueCurve(TerminalValueCurve): + """Value of usable AC battery energy, including the value of empty capacity. + + Neither values nor marginal values are constrained to be monotone. The empty + state can earn money by charging at negative prices and selling later. + """ + + _trace_context: dict = PrivateAttr(default_factory=dict) + + def value(self, energy_wh: float) -> float: + return ( + float(np.interp(energy_wh, self.energy_wh, self.value_euro)) if self.energy_wh else 0.0 + ) + + def component_values(self, energy_wh: float) -> tuple[float, float]: + """Return tail operating cash flow and continuation credit separately.""" + if not self.energy_wh: + return 0.0, 0.0 + operating = float(np.interp(energy_wh, self.energy_wh, self.operating_value_euro)) + continuation = float(np.interp(energy_wh, self.energy_wh, self.continuation_value_euro)) + return operating, continuation + + def diagnostic_plan(self, energy_wh: float, control_horizon_hours: float) -> list[TailPlanSlot]: + """Replay the optimal tail path for one control-end battery state.""" + context = self._trace_context + if not context: + return [] + bat = Battery( + context["battery_parameters"], + prediction_hours=1, + slot_duration_h=context["slot_duration_h"], + ) + inv = Inverter( + context["inverter_parameters"], + battery=bat, + slot_duration_h=context["slot_duration_h"], + ) + conversion = bat.discharging_efficiency * inv.dc_to_ac_efficiency + state_wh = bat.min_soc_wh + (energy_wh / conversion if conversion > 0 else 0.0) + state_wh = float(np.clip(state_wh, bat.min_soc_wh, bat.max_soc_wh)) + plan: list[TailPlanSlot] = [] + arrays = zip( + context["prices"], + context["load"], + context["pv"], + context["tariffs"], + ) + for slot, (price, load, pv, tariff) in enumerate(arrays): + candidates: list[tuple[float, TailAction, dict[str, float], float]] = [] + for action in context["actions"]: + result = _simulate_action( + bat=bat, + inv=inv, + energy_wh=state_wh, + action=action, + price=price, + load=load, + pv=pv, + tariff=tariff, + direct_marketing=context["direct_marketing"], + ) + remaining = float( + np.interp( + result["next_state_wh"], + context["states"], + context["future_values"][slot + 1], + ) + ) + candidates.append((result["reward_euro"] + remaining, action, result, remaining)) + candidates.sort(key=lambda candidate: candidate[0], reverse=True) + chosen_value, chosen_action, chosen_result, remaining = candidates[0] + alternative = next( + ( + candidate + for candidate in candidates[1:] + if _action_name(candidate[1]) != _action_name(chosen_action) + ), + candidates[1] if len(candidates) > 1 else candidates[0], + ) + dc, discharge, ac_rate, export_rate = chosen_action + start_soc = state_wh / bat.capacity_wh * 100 + state_wh = chosen_result["next_state_wh"] + plan.append( + TailPlanSlot( + slot=slot, + hour_from_start=control_horizon_hours + slot * context["slot_duration_h"], + action=_action_name(chosen_action), + alternative_action=_action_name(alternative[1]), + decision_margin_euro=max(chosen_value - alternative[0], 0.0), + soc_start_percentage=start_soc, + soc_end_percentage=state_wh / bat.capacity_wh * 100, + pv_wh=pv, + load_wh=load, + grid_import_wh=chosen_result["grid_import_wh"], + grid_export_wh=chosen_result["grid_export_wh"], + battery_charge_wh=chosen_result["battery_charge_wh"], + battery_discharge_wh=chosen_result["battery_discharge_wh"], + import_price_euro_per_kwh=price * 1000, + feed_in_tariff_euro_per_kwh=tariff * 1000, + slot_value_euro=chosen_result["reward_euro"], + remaining_value_euro=remaining, + ac_charge_factor=ac_rate, + dc_charge_allowed=dc, + discharge_allowed=discharge, + battery_grid_export_factor=export_rate, + ) + ) + return plan + + +def build_tail_value_curve( + *, + battery: Battery, + inverter: Inverter, + prices_euro_per_wh: np.ndarray, + load_wh: np.ndarray, + pv_wh: np.ndarray, + feed_in_euro_per_wh: np.ndarray, + continuation: TerminalValueCurve, + charge_rates: list[float], + export_rates: list[float], + direct_marketing: bool, + grid_points: int = 101, +) -> TailValueCurve: + """Solve the finite tail once, backwards in time, without mutating run devices. + + LCOS uses delivered DC energy, exactly as in GeneticSimulation. State grid + endpoints include battery minimum and maximum SOC. Continuous next states are + interpolated rather than rounded (which would invent or destroy energy). + """ + arrays = [ + np.asarray(a, dtype=float) + for a in (prices_euro_per_wh, load_wh, pv_wh, feed_in_euro_per_wh) + ] + if len({len(a) for a in arrays}) != 1 or any(not np.isfinite(a).all() for a in arrays): + raise ValueError("Tail forecasts must have equal lengths and contain only finite values") + bat = Battery(battery.parameters, prediction_hours=1, slot_duration_h=battery.slot_duration_h) + bat.charge_array = np.zeros(1, dtype=float) + inv = Inverter(inverter.parameters, battery=bat, slot_duration_h=battery.slot_duration_h) + states = np.linspace(bat.min_soc_wh, bat.max_soc_wh, grid_points) + usable = (states - bat.min_soc_wh) * bat.discharging_efficiency * inv.dc_to_ac_efficiency + continuation_values = np.array([continuation.value(e) for e in usable]) + operating_values = np.zeros(len(states)) + values = continuation_values.copy() + # (DC charge, local discharge, AC rate, export rate). Preserve production + # modes; direct marketing permits disabling DC charge to make headroom. + actions: list[TailAction] = [(1, 0, 0.0, 0.0), (1, 1, 0.0, 0.0)] + actions += [(1, 0, rate, 0.0) for rate in charge_rates if rate > 0] + if direct_marketing: + actions += [(0, 0, 0.0, 0.0), (0, 1, 0.0, 0.0)] + actions += [(0, 0, rate, 0.0) for rate in charge_rates if rate > 0] + actions += [(dc, 1, 0.0, rate) for dc in (0, 1) for rate in export_rates if rate > 0] + future_values: list[np.ndarray] = [np.empty(0)] * (len(arrays[0]) + 1) + future_values[-1] = continuation_values.copy() + for slot in reversed(range(len(arrays[0]))): + price, load, pv, tariff = (array[slot] for array in arrays) + best = np.full(len(states), -np.inf) + best_operating = np.zeros(len(states)) + best_continuation = np.zeros(len(states)) + for action in actions: + next_states = np.empty(len(states)) + rewards = np.empty(len(states)) + for i, energy in enumerate(states): + result = _simulate_action( + bat=bat, + inv=inv, + energy_wh=energy, + action=action, + price=price, + load=load, + pv=pv, + tariff=tariff, + direct_marketing=direct_marketing, + ) + rewards[i] = result["reward_euro"] + next_states[i] = result["next_state_wh"] + candidate_operating = rewards + np.interp(next_states, states, operating_values) + candidate_continuation = np.interp(next_states, states, continuation_values) + candidate = candidate_operating + candidate_continuation + better = candidate > best + best[better] = candidate[better] + best_operating[better] = candidate_operating[better] + best_continuation[better] = candidate_continuation[better] + values = best + operating_values = best_operating + continuation_values = best_continuation + future_values[slot] = best.copy() + result = TailValueCurve( + energy_wh=usable.tolist(), + value_euro=values.tolist(), + operating_value_euro=operating_values.tolist(), + continuation_value_euro=continuation_values.tolist(), + marginal_euro_per_kwh=(np.diff(values) / np.maximum(np.diff(usable), 1e-9) * 1000).tolist(), + window_slots=len(arrays[0]), + ) + result._trace_context = { + "battery_parameters": battery.parameters, + "inverter_parameters": inverter.parameters, + "slot_duration_h": battery.slot_duration_h, + "states": states, + "future_values": future_values, + "actions": actions, + "prices": arrays[0], + "load": arrays[1], + "pv": arrays[2], + "tariffs": arrays[3], + "direct_marketing": direct_marketing, + } + return result diff --git a/src/akkudoktoreos/optimization/genetic/terminalvalue.py b/src/akkudoktoreos/optimization/genetic/terminalvalue.py index 3a13b3ff..f8c45990 100644 --- a/src/akkudoktoreos/optimization/genetic/terminalvalue.py +++ b/src/akkudoktoreos/optimization/genetic/terminalvalue.py @@ -27,9 +27,9 @@ from typing import Optional import numpy as np from loguru import logger +from pydantic import Field from akkudoktoreos.core.pydantic import PydanticBaseModel -from pydantic import Field class TerminalValueCurve(PydanticBaseModel): @@ -50,12 +50,24 @@ class TerminalValueCurve(PydanticBaseModel): default_factory=list, json_schema_extra={"description": "Cumulative credit at each breakpoint [EUR]."}, ) + operating_value_euro: list[float] = Field( + default_factory=list, + json_schema_extra={ + "description": "Tail operating component at each breakpoint [EUR]; empty for a proxy curve." + }, + ) + continuation_value_euro: list[float] = Field( + default_factory=list, + json_schema_extra={ + "description": "Continuation component at each breakpoint [EUR]; empty for a proxy curve." + }, + ) marginal_euro_per_kwh: list[float] = Field( default_factory=list, json_schema_extra={ "description": ( "Marginal value of the segment that starts at each breakpoint " - "[EUR/kWh]. Monotonically decreasing." + "[EUR/kWh]. May be negative or non-monotone in TAIL mode." ) }, ) @@ -92,13 +104,63 @@ class TerminalValueCurve(PydanticBaseModel): return float(np.interp(energy_wh, self.energy_wh, self.value_euro)) +class TailDiagnostics(PydanticBaseModel): + """Forecast summary used by the deterministic tail optimization.""" + + slots: int = 0 + slot_hours: float = 0.0 + soc_grid_points: int = 0 + min_import_price_euro_per_kwh: float = 0.0 + max_import_price_euro_per_kwh: float = 0.0 + min_feed_in_tariff_euro_per_kwh: float = 0.0 + max_feed_in_tariff_euro_per_kwh: float = 0.0 + negative_import_price_slots: int = 0 + positive_battery_export_slots: int = 0 + + +class TailPlanSlot(PydanticBaseModel): + """One diagnostic slot of the optimal tail path. + + These values explain the lookahead used for fitness. They are diagnostics + only and are never copied into the executable control arrays. + """ + + slot: int + hour_from_start: float + action: str + alternative_action: str = "" + decision_margin_euro: float = 0.0 + soc_start_percentage: float + soc_end_percentage: float + pv_wh: float + load_wh: float + grid_import_wh: float + grid_export_wh: float + battery_charge_wh: float + battery_discharge_wh: float + import_price_euro_per_kwh: float + feed_in_tariff_euro_per_kwh: float + slot_value_euro: float + remaining_value_euro: float + ac_charge_factor: float + dc_charge_allowed: int + discharge_allowed: int + battery_grid_export_factor: float + + class TerminalValueResult(PydanticBaseModel): """What the optimizer credited for the energy left in the battery.""" + control_horizon_hours: float = 0 + requested_tail_hours: float = 0 + effective_tail_hours: float = 0 + tail_end_hour: float = 0 + continuation_mode: str = "FIXED" + mode: str = Field( json_schema_extra={ - "description": "Terminal value mode the run used: AUTO or FIXED.", - "examples": ["AUTO", "FIXED"], + "description": "Terminal value mode the run used: TAIL, AUTO or FIXED.", + "examples": ["TAIL", "AUTO", "FIXED"], } ) battery_energy_wh: float = Field( @@ -111,10 +173,46 @@ class TerminalValueResult(PydanticBaseModel): default=0.0, json_schema_extra={"description": "Credit applied to the total balance [EUR]."}, ) + tail_operating_euro: float = Field( + default=0.0, + json_schema_extra={ + "description": ( + "Optimal net cash flow within the effective tail for the selected " + "control-end battery state [EUR]." + ) + }, + ) + continuation_value_euro: float = Field( + default=0.0, + json_schema_extra={ + "description": ( + "Continuation credit remaining at the end of the optimal tail path [EUR]." + ) + }, + ) curve: Optional[TerminalValueCurve] = Field( default=None, json_schema_extra={ - "description": "The value curve the credit was read from; None in FIXED mode." + "description": ( + "Combined tail value curve (tail operation plus continuation) read by fitness; " + "None in FIXED mode." + ) + }, + ) + continuation_curve: Optional[TerminalValueCurve] = Field( + default=None, + json_schema_extra={ + "description": "Conservative AUTO proxy constructed at the effective tail end." + }, + ) + tail_diagnostics: Optional[TailDiagnostics] = None + tail_plan: list[TailPlanSlot] = Field( + default_factory=list, + json_schema_extra={ + "description": ( + "Diagnostic optimal battery path inside the tail. It explains the " + "lookahead but is never an executable control plan." + ) }, ) reason: str = Field( diff --git a/src/akkudoktoreos/optimization/optimization.py b/src/akkudoktoreos/optimization/optimization.py index 04f34928..87af0df4 100644 --- a/src/akkudoktoreos/optimization/optimization.py +++ b/src/akkudoktoreos/optimization/optimization.py @@ -18,10 +18,9 @@ class TerminalValueMode(StrEnum): Modes ----- - AUTO: - Derive a concave value curve from the trailing horizon window: the - first stored kWh replaces the most expensive hour that PV cannot - cover, the next one the second most expensive, and so on. Needs no - configuration and adapts to prices, load and PV of the day. + Solve the deterministic forecast tail and apply a conservative + continuation proxy at its end. Tail values may decrease with SOC when + empty capacity is valuable. With a zero tail, use the proxy directly. - FIXED: Credit every stored kWh with the configured @@ -81,6 +80,14 @@ class GeneticCommonSettings(SettingsBaseModel): class OptimizationCommonSettings(SettingsBaseModel): """General Optimization Configuration.""" + tail_horizon_hours: int = Field( + default=48, + ge=0, + json_schema_extra={ + "description": "Forecast lookahead after the control horizon [h]. No tail commands are issued. Set 0 to disable." + }, + ) + horizon_hours: int = Field( default=24, ge=0, @@ -129,8 +136,8 @@ class OptimizationCommonSettings(SettingsBaseModel): json_schema_extra={ "description": ( "How to value the energy left in the battery at the end of the " - "optimization horizon. AUTO derives a concave value curve from " - "the trailing horizon window and needs no configuration; FIXED " + "control horizon. AUTO solves the forecast tail with an AUTO " + "continuation proxy at its end (or only the proxy if tail is zero); FIXED " "uses 'terminal_value_euro_per_kwh'. Defaults to AUTO." ), "examples": ["AUTO", "FIXED"], @@ -155,7 +162,7 @@ class OptimizationCommonSettings(SettingsBaseModel): ge=1, json_schema_extra={ "description": ( - "Length of the trailing horizon window the AUTO terminal value " + "Length of the trailing window at the effective tail end the AUTO continuation " "curve is derived from [h]. One day covers a full load and PV " "cycle. Defaults to 24 hours." ), diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index b35b6352..bf82fe7c 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -67,7 +67,7 @@ class PredictionCommonSettings(SettingsBaseModel): """General Prediction Configuration.""" hours: Optional[int] = Field( - default=48, + default=72, ge=0, json_schema_extra={"description": "Number of hours into the future for predictions"}, ) diff --git a/tests/test_elecpriceakkudoktor.py b/tests/test_elecpriceakkudoktor.py index 732e48b5..ec62d390 100644 --- a/tests/test_elecpriceakkudoktor.py +++ b/tests/test_elecpriceakkudoktor.py @@ -121,9 +121,9 @@ def test_update_data(mock_get, provider, sample_akkudoktor_1_json, cache_store): # Assert: Verify the result is as expected mock_get.assert_called_once() - assert ( - len(provider) == 73 - ) # we have 48 datasets in the api response, we want to know 48h into the future. The data we get has already 23h into the future so we need only 25h more. 48+25=73 + # The API response holds 48 datasets, 23 h of which already reach into the + # future, so the provider forecasts the remaining hours of the horizon itself. + assert len(provider) == 48 + provider.config.prediction.hours - 23 # Assert we get hours prioce values by resampling np_price_array = provider.key_to_array( diff --git a/tests/test_elecpriceenergycharts.py b/tests/test_elecpriceenergycharts.py index c5997dcf..fcfe1d29 100644 --- a/tests/test_elecpriceenergycharts.py +++ b/tests/test_elecpriceenergycharts.py @@ -125,9 +125,9 @@ def test_update_data(mock_get, provider, sample_energycharts_json, cache_store): # Assert: Verify the result is as expected mock_get.assert_called_once() - assert len(provider) == 72 # The final raw timestamp already represents its complete interval. Thus the - # 48 API values need 24, rather than 25, additional hourly forecasts. + # 48 API values need one hour less of extrapolation than the horizon suggests. + assert len(provider) == 48 + provider.config.prediction.hours - 24 # Assert we get hours prioce values by resampling np_price_array = provider.key_to_array( diff --git a/tests/test_feedintarifftibber.py b/tests/test_feedintarifftibber.py index 9e46e697..6b99a151 100644 --- a/tests/test_feedintarifftibber.py +++ b/tests/test_feedintarifftibber.py @@ -55,6 +55,7 @@ def provider(config_eos): "provider": "FeedInTariffTibber", }, "prediction": {"hours": 2}, + "optimization": {"horizon_hours": 2, "tail_horizon_hours": 0}, } ) value = FeedInTariffTibber() diff --git a/tests/test_genetic_seeding.py b/tests/test_genetic_seeding.py index fc2b39bf..018cfee6 100644 --- a/tests/test_genetic_seeding.py +++ b/tests/test_genetic_seeding.py @@ -16,7 +16,7 @@ def _configure_hourly_grid(config_eos: ConfigEOS, *, start_hour: int = 0) -> Non config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 3600}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 3600}, } ) get_ems(init=True).set_start_datetime(to_datetime().set(hour=start_hour, minute=0)) @@ -58,17 +58,17 @@ def test_ev_repair_is_resimulated_before_fitness_assignment(config_eos: ConfigEO opt.optimize_ev = True opt.ev_possible_charge_values = [0.0, 1.0] opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) - individual = creator.Individual([0] * opt.total_slots + [1] * opt.total_slots) + individual = creator.Individual([0] * opt.control_slots + [1] * opt.control_slots) first_result = { "Gesamtbilanz_Euro": 10.0, "Gesamt_Verluste": 0.0, - "EAuto_SoC_pro_Stunde": np.full(opt.total_slots, 100.0), + "EAuto_SoC_pro_Stunde": np.full(opt.control_slots, 100.0), } repaired_result = { "Gesamtbilanz_Euro": 1.0, "Gesamt_Verluste": 0.0, - "EAuto_SoC_pro_Stunde": np.full(opt.total_slots, 100.0), + "EAuto_SoC_pro_Stunde": np.full(opt.control_slots, 100.0), } parameters = SimpleNamespace( ems=SimpleNamespace(preis_euro_pro_wh_akku=0.0), @@ -82,7 +82,7 @@ def test_ev_repair_is_resimulated_before_fitness_assignment(config_eos: ConfigEO assert evaluate.call_count == 2 assert fitness == pytest.approx((1.0,)) - assert individual[opt.total_slots :] == [0] * opt.total_slots + assert individual[opt.control_slots :] == [0] * opt.control_slots def test_fitness_cache_restores_canonical_ev_genome(config_eos: ConfigEOS): @@ -98,9 +98,9 @@ def test_fitness_cache_restores_canonical_ev_genome(config_eos: ConfigEOS): result = { "Gesamtbilanz_Euro": 1.0, "Gesamt_Verluste": 0.0, - "EAuto_SoC_pro_Stunde": np.full(opt.total_slots, 100.0), + "EAuto_SoC_pro_Stunde": np.full(opt.control_slots, 100.0), } - first = creator.Individual([0] * opt.total_slots + [1] * opt.total_slots) + first = creator.Individual([0] * opt.control_slots + [1] * opt.control_slots) duplicate = creator.Individual(first) opt._fitness_cache_enabled = True @@ -113,7 +113,7 @@ def test_fitness_cache_restores_canonical_ev_genome(config_eos: ConfigEOS): assert evaluate.call_count == 2 assert first_fitness == duplicate_fitness assert duplicate == first - assert duplicate[opt.total_slots :] == [0] * opt.total_slots + assert duplicate[opt.control_slots :] == [0] * opt.control_slots assert duplicate.extra_data == first.extra_data assert opt._fitness_cache_hits == 1 assert opt._fitness_cache_misses == 1 @@ -128,7 +128,7 @@ def test_fitness_cache_never_stores_failed_evaluations(config_eos: ConfigEOS): ems=SimpleNamespace(preis_euro_pro_wh_akku=0.0), eauto=None, ) - first = creator.Individual([0] * opt.total_slots) + first = creator.Individual([0] * opt.control_slots) duplicate = creator.Individual(first) opt._fitness_cache_enabled = True @@ -142,7 +142,7 @@ def test_fitness_cache_never_stores_failed_evaluations(config_eos: ConfigEOS): assert opt._fitness_cache == {} -def test_fitness_cache_ignores_elapsed_control_slots(config_eos: ConfigEOS): +def test_fitness_cache_includes_first_run_relative_control(config_eos: ConfigEOS): _configure_hourly_grid(config_eos, start_hour=10) opt = GeneticOptimization(fixed_seed=42) opt.optimize_ev = False @@ -154,9 +154,9 @@ def test_fitness_cache_ignores_elapsed_control_slots(config_eos: ConfigEOS): result = { "Gesamtbilanz_Euro": 1.0, "Gesamt_Verluste": 0.0, - "EAuto_SoC_pro_Stunde": np.zeros(opt.total_slots), + "EAuto_SoC_pro_Stunde": np.zeros(opt.control_slots), } - first = creator.Individual([0] * opt.total_slots) + first = creator.Individual([0] * opt.control_slots) elapsed_variant = creator.Individual(first) elapsed_variant[0] = 1 opt._fitness_cache_enabled = True @@ -165,23 +165,23 @@ def test_fitness_cache_ignores_elapsed_control_slots(config_eos: ConfigEOS): first_fitness = opt.evaluate(first, parameters, 10, False) # type: ignore[arg-type] variant_fitness = opt.evaluate(elapsed_variant, parameters, 10, False) # type: ignore[arg-type] - assert evaluate.call_count == 1 + assert evaluate.call_count == 2 assert first_fitness == variant_fitness - assert opt._fitness_cache_hits == 1 + assert opt._fitness_cache_hits == 0 -def test_mutated_warm_start_neighbors_keep_elapsed_slots(config_eos: ConfigEOS): +def test_mutated_warm_start_neighbors_stay_within_control_horizon(config_eos: ConfigEOS): _configure_hourly_grid(config_eos, start_hour=10) opt = GeneticOptimization(fixed_seed=42) opt.optimize_ev = False opt.setup_deap_environment({"home_appliance": 0}, start_hour=10) - start_solution = [0] * opt.total_slots + start_solution = [0] * opt.control_slots neighbors = opt._mutated_warm_start_neighbors(start_solution, count=5) assert len(neighbors) == 5 assert len({tuple(neighbor) for neighbor in neighbors}) == 5 - assert all(neighbor[:10] == start_solution[:10] for neighbor in neighbors) + assert all(len(neighbor) == opt.control_slots for neighbor in neighbors) assert all(neighbor != start_solution for neighbor in neighbors) @@ -193,9 +193,9 @@ def test_initial_population_uses_fixed_seed_budget_and_configured_population( opt = GeneticOptimization(fixed_seed=42) opt.optimize_ev = False opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) - start_solution = [5] * opt.total_slots - warm_neighbors = [[6] * opt.total_slots for _ in range(50)] - educated = [[7] * opt.total_slots for _ in range(100)] + start_solution = [5] * opt.control_slots + warm_neighbors = [[6] * opt.control_slots for _ in range(50)] + educated = [[7] * opt.control_slots for _ in range(100)] captured: dict[str, object] = {} def fake_evolution(population, **kwargs): @@ -214,7 +214,7 @@ def test_initial_population_uses_fixed_seed_budget_and_configured_population( patch.object( opt.toolbox, "population", - side_effect=lambda n: [creator.Individual([9] * opt.total_slots) for _ in range(n)], + side_effect=lambda n: [creator.Individual([9] * opt.control_slots) for _ in range(n)], ), patch.object(opt, "_evolve_population_adaptive", side_effect=fake_evolution), ): @@ -237,16 +237,16 @@ def test_small_population_scales_warm_and_educated_seed_families(config_eos: Con opt = GeneticOptimization(fixed_seed=42) opt.optimize_ev = False opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) - start_solution = [5] * opt.total_slots + start_solution = [5] * opt.control_slots captured: dict[str, object] = {} def warm_neighbors(_solution, count): captured["warm_count"] = count - return [[6] * opt.total_slots for _ in range(count)] + return [[6] * opt.control_slots for _ in range(count)] def educated(count): captured["educated_count"] = count - return [[7] * opt.total_slots for _ in range(count)] + return [[7] * opt.control_slots for _ in range(count)] def fake_evolution(population, **kwargs): captured["population"] = list(population) @@ -264,7 +264,7 @@ def test_small_population_scales_warm_and_educated_seed_families(config_eos: Con patch.object( opt.toolbox, "population", - side_effect=lambda n: [creator.Individual([9] * opt.total_slots) for _ in range(n)], + side_effect=lambda n: [creator.Individual([9] * opt.control_slots) for _ in range(n)], ), patch.object(opt, "_evolve_population_adaptive", side_effect=fake_evolution), ): @@ -289,14 +289,14 @@ def test_adaptive_evolution_soft_restarts_collapsed_population(config_eos: Confi opt.optimize_ev = False opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) opt.toolbox.register("evaluate", lambda individual: (float(sum(individual)),)) - population = [creator.Individual([0] * opt.total_slots) for _ in range(20)] + population = [creator.Individual([0] * opt.control_slots) for _ in range(20)] stats = tools.Statistics(lambda individual: individual.fitness.values) stats.register("min", np.min) stats.register("avg", np.mean) stats.register("max", np.max) halloffame = tools.HallOfFame(1) - fresh = [creator.Individual([value] + [0] * (opt.total_slots - 1)) for value in range(1, 20)] + fresh = [creator.Individual([value] + [0] * (opt.control_slots - 1)) for value in range(1, 20)] with patch.object(opt, "_fresh_population", return_value=fresh) as create_fresh: evolved, log = opt._evolve_population_adaptive( population, @@ -324,7 +324,7 @@ def test_local_search_moves_weak_export_to_later_expensive_import(config_eos: Co opt.bat_possible_charge_values = [1.0] opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) - slots = opt.total_slots + slots = opt.control_slots export_state = 5 self_consumption_state = 6 discharge_state = 1 @@ -372,7 +372,7 @@ def test_educated_guesses_encode_high_price_direct_marketing(config_eos: ConfigE opt.bat_possible_charge_values = [1.0] opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) - slots = opt.total_slots + slots = opt.control_slots opt.simulation.elect_price_hourly = np.linspace(0.0001, 0.0004, slots) opt.simulation.elect_revenue_per_hour_arr = np.linspace(0.00001, 0.0003, slots) opt.simulation.pv_prediction_wh = np.full(slots, 1000.0) @@ -399,7 +399,7 @@ def test_flat_feed_in_tariff_does_not_seed_direct_marketing(config_eos: ConfigEO opt.bat_possible_charge_values = [1.0] opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) - slots = opt.total_slots + slots = opt.control_slots opt.simulation.elect_price_hourly = np.linspace(0.0001, 0.0004, slots) opt.simulation.elect_revenue_per_hour_arr = np.full(slots, 0.00005) opt.simulation.pv_prediction_wh = np.full(slots, 1000.0) @@ -409,3 +409,4 @@ def test_flat_feed_in_tariff_does_not_seed_direct_marketing(config_eos: ConfigEO export_state = 5 assert all(export_state not in guess for guess in guesses) + diff --git a/tests/test_geneticoptimize.py b/tests/test_geneticoptimize.py index 5ff11437..99e04e12 100644 --- a/tests/test_geneticoptimize.py +++ b/tests/test_geneticoptimize.py @@ -93,7 +93,7 @@ def test_grid_export_rates_reach_the_solution(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 24}, - "optimization": { + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 24, "interval": 3600, "genetic": {"individuals": 40, "generations": 10}, @@ -174,8 +174,8 @@ def test_optimize( "prediction": { "hours": 48 }, - "optimization": { - "horizon_hours": 48, + "optimization": {"tail_horizon_hours": 0, + "horizon_hours": 38, "genetic": { "individuals": 300, "generations": 10, @@ -244,15 +244,14 @@ def test_optimize( with TESTDATA_FILE.open("w", encoding="utf-8", newline="\n") as f_out: f_out.write(genetic_solution.model_dump_json(indent=4, exclude_unset=True)) + # The old snapshot included midnight-prefix genes and a prediction-sized + # genome. Check the new run-relative contract and accounting instead. + assert len(genetic_solution.ac_charge) == 38 + assert len(genetic_solution.result.Kosten_Euro_pro_Stunde) == 38 assert genetic_solution.result.Gesamtbilanz_Euro == pytest.approx( - expected_result.result.Gesamtbilanz_Euro + genetic_solution.result.Gesamtkosten_Euro - genetic_solution.result.Gesamteinnahmen_Euro ) - # Assert that the output contains all expected entries. - # This does not assert that the optimization always gives the same result! - # Reproducibility and mathematical accuracy should be tested on the level of individual components. - compare_dict(genetic_solution.model_dump(), expected_result.model_dump()) - # Check the correct generic optimization solution is created optimization_solution = genetic_solution.optimization_solution() # @TODO @@ -291,18 +290,16 @@ def _ev_deadline_parameters(hours: int, **ev_extra) -> GeneticOptimizationParame def test_ev_deadline_slot_resolution(config_eos: ConfigEOS): """Datetime and maximum duration resolve to a slot; the earlier one wins.""" config_eos.merge_settings_from_dict( - {"prediction": {"hours": 48}, "optimization": {"horizon_hours": 48, "interval": 3600}} + {"prediction": {"hours": 48}, "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 3600}} ) ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0)) optimization = GeneticOptimization(fixed_seed=1) - optimization._slot0_datetime = optimization.ems.start_datetime.set( - hour=0, minute=0, second=0, microsecond=0 - ) + optimization._slot0_datetime = optimization.ems.start_datetime slot0 = optimization._slot0_datetime # Duration only: 6 h after the start hour 10. parameters = _ev_deadline_parameters(48, min_soc_max_duration_h=6) - assert optimization._ev_deadline_slot(parameters) == 16 + assert optimization._ev_deadline_slot(parameters) == 6 # Datetime only. parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=14)) @@ -312,15 +309,15 @@ def test_ev_deadline_slot_resolution(config_eos: ConfigEOS): parameters = _ev_deadline_parameters( 48, min_soc_deadline_datetime=slot0.add(hours=20), min_soc_max_duration_h=6 ) - assert optimization._ev_deadline_slot(parameters) == 16 + assert optimization._ev_deadline_slot(parameters) == 6 # Beyond the horizon: no deadline, the end-of-horizon target already covers it. parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=100)) assert optimization._ev_deadline_slot(parameters) is None # In the past: due right now. - parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=2)) - assert optimization._ev_deadline_slot(parameters) == optimization._start_day_slot() + parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.subtract(hours=2)) + assert optimization._ev_deadline_slot(parameters) == 0 # No deadline at all. assert optimization._ev_deadline_slot(_ev_deadline_parameters(48)) is None @@ -329,7 +326,7 @@ def test_ev_deadline_slot_resolution(config_eos: ConfigEOS): def test_ev_soc_penalty_reads_the_deadline_slot(config_eos: ConfigEOS): """With a deadline the penalty checks the SoC at that slot, not at the end.""" config_eos.merge_settings_from_dict( - {"prediction": {"hours": 48}, "optimization": {"horizon_hours": 48, "interval": 3600}} + {"prediction": {"hours": 48}, "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 3600}} ) ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0)) optimization = GeneticOptimization(fixed_seed=1) @@ -360,7 +357,7 @@ def test_ev_deadline_charges_before_departure(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": hours}, - "optimization": { + "optimization": {"tail_horizon_hours": 0, "horizon_hours": hours, "interval": 3600, "genetic": {"individuals": 100, "generations": 40}, @@ -393,7 +390,7 @@ def _terminal_value_run( config_eos.merge_settings_from_dict( { "prediction": {"hours": hours}, - "optimization": { + "optimization": {"tail_horizon_hours": 0, "horizon_hours": hours, "interval": 3600, "terminal_value_mode": mode, diff --git a/tests/test_geneticsimulation.py b/tests/test_geneticsimulation.py index 4dac0b5c..3b1fc207 100644 --- a/tests/test_geneticsimulation.py +++ b/tests/test_geneticsimulation.py @@ -30,7 +30,7 @@ def genetic_simulation(config_eos) -> GeneticSimulation: """Fixture to create an EnergyManagement instance with given test parameters.""" # Assure configuration holds the correct values config_eos.merge_settings_from_dict( - {"prediction": {"hours": 48}, "optimization": {"hours": 24}} + {"prediction": {"hours": 48}, "optimization": {"tail_horizon_hours": 0, "hours": 24}} ) assert config_eos.prediction.hours == 48 assert config_eos.optimization.horizon_hours == 24 @@ -381,7 +381,7 @@ def test_simulation(genetic_simulation): def test_ev_charging_uses_raw_input_energy_for_load_and_grid(config_eos): config_eos.merge_settings_from_dict( - {"prediction": {"hours": 1}, "optimization": {"horizon_hours": 1}} + {"prediction": {"hours": 1}, "optimization": {"tail_horizon_hours": 0, "horizon_hours": 1}} ) ev = Battery( ElectricVehicleParameters( @@ -422,7 +422,7 @@ def test_ev_charging_uses_raw_input_energy_for_load_and_grid(config_eos): def test_direct_marketing_curtails_negative_feed_in(config_eos, monkeypatch): config_eos.merge_settings_from_dict( - {"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}} + {"prediction": {"hours": 2}, "optimization": {"tail_horizon_hours": 0, "horizon_hours": 2}} ) inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=1000.0)) @@ -460,7 +460,7 @@ def _direct_marketing_battery_export_simulation( dc_to_ac_efficiency: float = 1.0, ) -> GeneticSimulation: config_eos.merge_settings_from_dict( - {"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}} + {"prediction": {"hours": 2}, "optimization": {"tail_horizon_hours": 0, "horizon_hours": 2}} ) battery = Battery( @@ -571,7 +571,7 @@ def test_disabled_ac_charging_clears_the_reported_plan(config_eos): simulated or paid for. """ config_eos.merge_settings_from_dict( - {"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}} + {"prediction": {"hours": 2}, "optimization": {"tail_horizon_hours": 0, "horizon_hours": 2}} ) battery = Battery( diff --git a/tests/test_geneticsimulation2.py b/tests/test_geneticsimulation2.py index c0834a75..d388f1b3 100644 --- a/tests/test_geneticsimulation2.py +++ b/tests/test_geneticsimulation2.py @@ -28,7 +28,7 @@ def genetic_simulation_2(config_eos) -> GeneticSimulation: """Fixture to create an EnergyManagement instance with given test parameters.""" # Assure configuration holds the correct values config_eos.merge_settings_from_dict( - {"prediction": {"hours": 48}, "optimization": {"hours": 24}} + {"prediction": {"hours": 48}, "optimization": {"tail_horizon_hours": 0, "hours": 24}} ) assert config_eos.prediction.hours == 48 assert config_eos.optimization.horizon_hours == 24 diff --git a/tests/test_homeappliance.py b/tests/test_homeappliance.py index b3e679fa..85c43b3e 100644 --- a/tests/test_homeappliance.py +++ b/tests/test_homeappliance.py @@ -156,7 +156,7 @@ def _optimizer(config_eos, *, prediction_hours: int, horizon_hours: int, interva config_eos.merge_settings_from_dict( { "prediction": {"hours": prediction_hours}, - "optimization": {"horizon_hours": horizon_hours, "interval": interval}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": horizon_hours, "interval": interval}, } ) ems_eos.set_start_datetime(to_datetime().set(hour=hour, minute=0)) @@ -165,17 +165,17 @@ def _optimizer(config_eos, *, prediction_hours: int, horizon_hours: int, interva def test_once_layout_single_gene(config_eos): opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=10) - slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0) + slot0 = opt.ems.start_datetime appliance = _appliance(48, 1.0, device_id="d", consumption_wh=1000, duration_h=2) layout = opt._build_appliance_layout([appliance], slot0) assert layout.n_genes == 1 assert layout.genes[0].run_date is None - assert layout.genes[0].allowed_start_slots[0] == opt._start_day_slot() + assert layout.genes[0].allowed_start_slots[0] == 0 def test_once_no_valid_start_raises(config_eos): opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=10, interval=3600, hour=10) - slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0) + slot0 = opt.ems.start_datetime # 02:00 window is in the past (start slot 10) and day 1 is beyond the 10 h horizon. windows = TimeWindowSequence( windows=[TimeWindow(start_time=to_time("02:00"), duration=to_duration("1 hours"))] @@ -189,7 +189,7 @@ def test_once_no_valid_start_raises(config_eos): def test_daily_layout_one_gene_per_calendar_day(config_eos): opt = _optimizer(config_eos, prediction_hours=72, horizon_hours=72, interval=3600, hour=0) - slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0) + slot0 = opt.ems.start_datetime windows = TimeWindowSequence( windows=[TimeWindow(start_time=to_time("10:00"), duration=to_duration("2 hours"))] ) @@ -212,7 +212,7 @@ def test_daily_layout_one_gene_per_calendar_day(config_eos): def test_daily_layout_partial_first_day(config_eos): """A partial first day (start after the window) produces no gene for that day.""" opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=14) - slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0) + slot0 = opt.ems.start_datetime windows = TimeWindowSequence( windows=[TimeWindow(start_time=to_time("10:00"), duration=to_duration("2 hours"))] ) @@ -226,9 +226,9 @@ def test_daily_layout_partial_first_day(config_eos): time_windows=windows, ) layout = opt._build_appliance_layout([appliance], slot0) - # Day 0 window (10:00-12:00) is already in the past at start hour 14 -> only day 1. - assert layout.n_genes == 1 - assert all(slot >= opt._start_day_slot() for slot in layout.genes[0].allowed_start_slots) + # Day 0 window (10:00-12:00) is already in the past at start hour 14 -> days 1 and 2 are inside the 48 hours from now. + assert layout.n_genes == 2 + assert all(slot >= 0 for slot in layout.genes[0].allowed_start_slots) # --------------------------------------------------------------------------- # @@ -238,7 +238,7 @@ def test_multiple_appliances_scheduled_and_aggregate(config_eos): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": { + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 3600, "genetic": { @@ -343,15 +343,15 @@ def test_max_home_appliances_is_upper_bound(): def test_start_solution_layout_mismatch_is_ignored(config_eos): opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=10) - slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0) + slot0 = opt.ems.start_datetime appliance = _appliance(48, 1.0, device_id="d", consumption_wh=1000, duration_h=1) opt.appliance_layout = opt._build_appliance_layout([appliance], slot0) opt.optimize_ev = False valid_index_count = len(opt.appliance_layout.genes[0].allowed_start_slots) # A tail index beyond the allowed range must be rejected. - bad_solution = [0] * opt.total_slots + [valid_index_count + 5] + bad_solution = [0] * opt.control_slots + [valid_index_count + 5] assert opt._start_solution_matches_layout(bad_solution) is False - good_solution = [0] * opt.total_slots + [0] + good_solution = [0] * opt.control_slots + [0] assert opt._start_solution_matches_layout(good_solution) is True @@ -456,14 +456,14 @@ def test_deadline_strict_keeps_empty_result(): def test_deadline_strict_once_raises(config_eos): """A ONCE consumer with an unreachable STRICT deadline fails the layout.""" opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=12) - slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0) + slot0 = opt.ems.start_datetime appliance = _appliance( 48, 1.0, device_id="d", consumption_wh=2000, duration_h=2, - deadline_datetime=slot0.add(hours=10), + deadline_datetime=slot0.set(hour=10), deadline_policy="STRICT", ) with pytest.raises(ValueError, match="no valid start"): @@ -501,7 +501,7 @@ def test_deadline_end_to_end_optimization(config_eos): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": { + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 3600, "genetic": { diff --git a/tests/test_inverter_efficiency.py b/tests/test_inverter_efficiency.py index c52bbfe6..0f556254 100644 --- a/tests/test_inverter_efficiency.py +++ b/tests/test_inverter_efficiency.py @@ -222,7 +222,7 @@ class TestAcChargingInSimulation: ) config_eos.merge_settings_from_dict( - {"prediction": {"hours": 48}, "optimization": {"hours": 24}} + {"prediction": {"hours": 48}, "optimization": {"tail_horizon_hours": 0, "hours": 24}} ) prediction_hours = config_eos.prediction.hours @@ -561,7 +561,7 @@ def _run_evaluate_with_mocked_sim( config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"hours": 24}, + "optimization": {"tail_horizon_hours": 0, "hours": 24}, } ) config_eos.optimization.genetic.penalties = { @@ -614,7 +614,7 @@ def _run_evaluate_with_mocked_ev_soc(config_eos, ev_soc_percentage: float) -> fl config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"hours": 48}, + "optimization": {"tail_horizon_hours": 0, "hours": 48}, } ) config_eos.optimization.genetic.penalties = { diff --git a/tests/test_optimization_interval.py b/tests/test_optimization_interval.py index 15e186a2..4e7ece0b 100644 --- a/tests/test_optimization_interval.py +++ b/tests/test_optimization_interval.py @@ -28,6 +28,11 @@ ems_eos = get_ems(init=True) # init once DIR_TESTDATA = Path(__file__).parent / "testdata" +@pytest.fixture(autouse=True) +def start_at_midnight(config_eos): + ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0)) + + def load_hourly_parameters() -> GeneticOptimizationParameters: """Load the legacy 48-value API example used by hourly clients.""" with (DIR_TESTDATA / "optimize_input_1.json").open("r") as f_in: @@ -51,7 +56,7 @@ def test_slot_helpers( config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": interval}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": interval}, } ) ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0)) @@ -60,7 +65,7 @@ def test_slot_helpers( assert opt.slots_per_hour == exp_slots_per_hour assert opt.slot_duration_h == exp_slot_duration_h - assert opt.total_slots == 48 * exp_slots_per_hour + assert opt.control_slots == 48 * exp_slots_per_hour # At minute 0 the start slot is the hour scaled by the slot count. assert opt._start_day_slot() == 10 * exp_slots_per_hour @@ -70,7 +75,7 @@ def test_start_day_slot_includes_minute_offset(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 900}, } ) ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=30)) @@ -88,7 +93,7 @@ def test_ems_start_is_floored_to_quarter_hour(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 900}, } ) @@ -101,7 +106,7 @@ def test_ems_start_is_floored_to_quarter_hour(config_eos: ConfigEOS): def test_unsupported_interval_falls_back_to_hourly(config_eos: ConfigEOS): """The genetic optimizer falls back without restricting interval-aware providers.""" - config_eos.merge_settings_from_dict({"optimization": {"interval": 1800}}) + config_eos.merge_settings_from_dict({"optimization": {"tail_horizon_hours": 0, "interval": 1800}}) assert config_eos.optimization.interval == 1800 GeneticOptimization(fixed_seed=42) @@ -113,7 +118,7 @@ def test_hourly_api_input_is_normalized_to_quarter_hour_slots(config_eos: Config config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 900}, } ) parameters = load_hourly_parameters() @@ -141,7 +146,7 @@ def test_native_quarter_hour_input_is_not_resampled(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 900}, } ) parameters = load_hourly_parameters() @@ -170,7 +175,7 @@ def test_scalar_feed_in_tariff_fills_quarter_hour_grid(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 900}, } ) parameters = load_hourly_parameters() @@ -190,7 +195,7 @@ def test_ambiguous_input_length_is_rejected(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 900}, } ) parameters = load_hourly_parameters() @@ -214,7 +219,7 @@ def test_hourly_start_solution_is_expanded_to_slots(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 900}, } ) opt = GeneticOptimization(fixed_seed=42) @@ -232,35 +237,36 @@ def test_quarter_hour_mutation_targets_three_future_controls(config_eos: ConfigE config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 900}, } ) opt = GeneticOptimization(fixed_seed=42) opt.optimize_ev = False opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) - active_slots = opt.total_slots - opt._start_day_slot() + active_slots = opt.control_slots expected = min(0.10, opt.POINT_MUTATION_EXPECTED_GENES / active_slots) assert opt.toolbox.mutate_charge_discharge.keywords["indpb"] == pytest.approx(expected) -def test_point_mutation_keeps_elapsed_slots_unchanged(config_eos: ConfigEOS): +def test_point_mutation_uses_run_relative_controls(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 900}, } ) get_ems(init=True).set_start_datetime(to_datetime().set(hour=10, minute=0)) opt = GeneticOptimization(fixed_seed=42) opt.optimize_ev = False opt.setup_deap_environment({"home_appliance": 0}, start_hour=10) - individual = [0] * opt.total_slots + individual = [0] * opt.control_slots changed = opt._mutate_point_controls(individual) assert changed - assert individual[: opt._start_day_slot()] == [0] * opt._start_day_slot() + assert len(individual) == opt.control_slots + assert any(individual) def test_sub_hourly_home_appliance_is_scheduled(config_eos: ConfigEOS): @@ -268,7 +274,7 @@ def test_sub_hourly_home_appliance_is_scheduled(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": {"horizon_hours": 48, "interval": 900}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 38, "interval": 900}, } ) parameters = load_hourly_parameters().model_copy( @@ -306,8 +312,8 @@ def test_optimize_15min_slot_grid(config_eos: ConfigEOS): config_eos.merge_settings_from_dict( { "prediction": {"hours": 48}, - "optimization": { - "horizon_hours": 48, + "optimization": {"tail_horizon_hours": 0, + "horizon_hours": 38, "interval": 900, "genetic": { "individuals": 300, @@ -335,7 +341,7 @@ def test_optimize_15min_slot_grid(config_eos: ConfigEOS): CacheEnergyManagementStore().clear() opt = GeneticOptimization(fixed_seed=42) - assert opt.total_slots == 192 + assert opt.control_slots == 152 assert opt.slot_duration_h == 0.25 visualize_filename = str((DIR_TESTDATA / "new_optimize_15min.json").with_suffix(".pdf")) @@ -348,10 +354,10 @@ def test_optimize_15min_slot_grid(config_eos: ConfigEOS): genetic_solution = opt.optimierung_ems(parameters=input_data, start_hour=10, ngen=3) # The genetic core emitted a full-day grid at 15-min resolution. - assert len(genetic_solution.ac_charge) == 192 - assert len(genetic_solution.dc_charge) == 192 - assert len(genetic_solution.discharge_allowed) == 192 - expected_result_slots = 192 - opt._start_day_slot() + assert len(genetic_solution.ac_charge) == 152 + assert len(genetic_solution.dc_charge) == 152 + assert len(genetic_solution.discharge_allowed) == 152 + expected_result_slots = 152 assert len(genetic_solution.result.Last_Wh_pro_Stunde) == expected_result_slots assert len(genetic_solution.result.Electricity_price) == expected_result_slots diff --git a/tests/test_tailvalue.py b/tests/test_tailvalue.py new file mode 100644 index 00000000..cebc34ab --- /dev/null +++ b/tests/test_tailvalue.py @@ -0,0 +1,433 @@ +"""Economic tail scenarios and hard control/forecast boundaries.""" + +from unittest.mock import patch + +import numpy as np +import pandas as pd +import pytest + +from akkudoktoreos.config.config import SettingsEOSDefaults +from akkudoktoreos.core.coreabc import get_ems +from akkudoktoreos.devices.genetic.battery import Battery +from akkudoktoreos.devices.genetic.inverter import Inverter +from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array +from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization +from akkudoktoreos.optimization.genetic.geneticdevices import ( + InverterParameters, + SolarPanelBatteryParameters, +) +from akkudoktoreos.optimization.genetic.geneticparams import GeneticOptimizationParameters +from akkudoktoreos.optimization.genetic.tailvalue import build_tail_value_curve +from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueCurve +from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration + + +def devices(power=1000, efficiency=1.0, lcos=0, ac_limit=None, export_power=5000): + bat = Battery( + SolarPanelBatteryParameters( + device_id="battery1", + capacity_wh=1000, + max_charge_power_w=power, + charging_efficiency=efficiency, + discharging_efficiency=efficiency, + initial_soc_percentage=50, + levelized_cost_of_storage_kwh=lcos, + charge_rates=[0, 0.5, 1], + ), + prediction_hours=1, + ) + inv = Inverter( + InverterParameters( + device_id="inverter1", + battery_id="battery1", + max_power_wh=export_power, + dc_to_ac_efficiency=1, + ac_to_dc_efficiency=1, + max_ac_charge_power_w=ac_limit, + ), + battery=bat, + ) + return bat, inv + + +def curve( + prices=(-0.1, 0.3), + tariffs=(0, 0.3), + direct=True, + continuation=None, + load=None, + pv=None, + **kwargs, +): + bat, inv = devices(**kwargs) + return build_tail_value_curve( + battery=bat, + inverter=inv, + prices_euro_per_wh=np.array(prices) / 1000, + feed_in_euro_per_wh=np.array(tariffs) / 1000, + load_wh=np.zeros(len(prices)) if load is None else np.array(load), + pv_wh=np.zeros(len(prices)) if pv is None else np.array(pv), + continuation=continuation or TerminalValueCurve(), + charge_rates=[0.5, 1], + export_rates=[1], + direct_marketing=direct, + ) + + +def test_headroom_has_value_and_empty_state_can_earn(): + c = curve() + assert c.value(0) == pytest.approx(0.4) + tail, continuation = c.component_values(0) + assert tail == pytest.approx(0.4) + assert continuation == pytest.approx(0.0) + assert c.value(0) == pytest.approx(tail + continuation) + assert c.value(500) > c.value(1000) + assert any(v < 0 for v in c.marginal_euro_per_kwh) + + +def test_chronology_changes_arbitrage(): + forward = curve() + reverse = curve(prices=(0.3, -0.1), tariffs=(0.3, 0)) + assert forward.value(0) > reverse.value(0) + + +def test_discharge_and_ac_power_limits(): + limited = curve(prices=(1,), tariffs=(1,), power=100) + assert limited.value(1000) == pytest.approx(0.1) + limited_ac = curve(ac_limit=100) + assert limited_ac.value(0) == pytest.approx(0.04) + limited_inverter = curve(prices=(1,), tariffs=(1,), export_power=50) + assert limited_inverter.value(1000) == pytest.approx(0.05) + + +def test_losses_and_lcos_reduce_arbitrage(): + ideal = curve(prices=(0.1, 0.3)) + lossy = curve(prices=(0.1, 0.3), efficiency=0.8) + assert 0 < lossy.value(0) < ideal.value(0) + assert curve(prices=(0.1, 0.3), lcos=0.25).value(0) == pytest.approx(0) + + +def test_no_battery_export_without_permission(): + assert curve(prices=(0.1, 0.3), direct=False).value(0) == pytest.approx(0) + + +def test_pv_surplus_can_be_stored_for_local_load(): + c = curve(prices=(0.2, 0.3), tariffs=(0, 0), pv=[1000, 0], load=[0, 1000], direct=False) + assert c.value(0) == pytest.approx(0) + # Without PV the same empty battery must buy energy to serve the load. + assert curve(prices=(0.2, 0.3), tariffs=(0, 0), load=[0, 1000], direct=False).value( + 0 + ) < c.value(0) + + +def test_continuation_survives_tail_end(): + continuation = TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.2]) + c = curve(prices=(0.5,), tariffs=(0,), continuation=continuation) + assert c.value(1000) == pytest.approx(0.2) + tail, continuation_credit = c.component_values(1000) + assert tail == pytest.approx(0.0) + assert continuation_credit == pytest.approx(0.2) + + +def test_tail_diagnostic_plan_explains_the_selected_path(): + c = curve() + plan = c.diagnostic_plan(0, control_horizon_hours=24) + assert len(plan) == 2 + assert plan[0].hour_from_start == 24 + assert plan[0].action == "GRID_CHARGE" + assert plan[0].soc_end_percentage > plan[0].soc_start_percentage + assert plan[0].grid_import_wh > 0 + assert plan[1].action == "BATTERY_EXPORT" + assert plan[1].soc_end_percentage < plan[1].soc_start_percentage + assert plan[1].grid_export_wh > 0 + assert sum(slot.slot_value_euro for slot in plan) == pytest.approx(c.value(0)) + + +def test_central_config_invariant(): + # Only the control horizon is mandatory. A prediction horizon that cannot + # cover the requested tail shortens the tail instead of failing the run, + # so existing configurations keep starting after an upgrade. + short = SettingsEOSDefaults( + prediction={"hours": 48}, optimization={"horizon_hours": 24, "tail_horizon_hours": 48} + ) + assert short.prediction.hours == 48 + assert short.optimization.tail_horizon_hours == 48 + + # A control horizon the forecast cannot serve is not rejected here either - + # prediction.hours also serves callers that never optimize. The optimizer + # rejects the run itself, naming the series that ran out. + undersized = SettingsEOSDefaults( + prediction={"hours": 48}, optimization={"horizon_hours": 72, "tail_horizon_hours": 0} + ) + assert undersized.optimization.horizon_hours == 72 + + settings = SettingsEOSDefaults() + assert settings.prediction.hours == 72 + assert settings.optimization.tail_horizon_hours == 48 + + +def setup_run(config, interval=3600, start_hour=0, hours=72, prediction_hours=72): + config.merge_settings_from_dict( + { + "prediction": {"hours": prediction_hours}, + "optimization": { + "horizon_hours": 24, + "tail_horizon_hours": 48, + "interval": interval, + "visualize_pdf": False, + }, + "feedintariff": {"direct_marketing_enabled": True}, + } + ) + ems = get_ems(init=True) + ems.set_start_datetime(to_datetime("2026-09-05T00:00:00").set(hour=start_hour)) + bat, inv = devices() + params = GeneticOptimizationParameters( + ems={ + "pv_prognose_wh": [0.0] * hours, + "gesamtlast": [0.0] * hours, + "strompreis_euro_pro_wh": [0.0002] * hours, + "einspeiseverguetung_euro_pro_wh": [0.0001] * hours, + "preis_euro_pro_wh_akku": 0, + }, + pv_akku=bat.parameters, + inverter=inv.parameters, + eauto=None, + ) + return GeneticOptimization(fixed_seed=42), params + + +@pytest.mark.parametrize("interval", [3600, 900]) +@pytest.mark.parametrize("start_hour", [0, 10]) +def test_genome_output_and_final_control_state(config_eos, interval, start_hour): + opt, params = setup_run(config_eos, interval, start_hour, hours=72 + start_hour) + + def choose(*args, **kwargs): + # Discharge only in the last control slot. Its POST-slot SOC is credited. + genome = opt.create_individual() + genome[:] = [0] * opt.control_end_slot + genome[-1] = opt._battery_state_layout().grid_export_state + assert len(genome) == 24 * (3600 // interval) + return genome, {} + + with ( + patch.object(opt, "optimize", side_effect=choose), + patch( + "akkudoktoreos.optimization.genetic.genetic.build_tail_value_curve", + wraps=build_tail_value_curve, + ) as builder, + ): + result = opt.optimierung_ems(params) + assert builder.call_count == 1 + assert len(result.ac_charge) == opt.control_slots + assert len(result.dc_charge) == opt.control_slots + assert len(result.discharge_allowed) == opt.control_slots + assert len(result.battery_grid_export_factor) == opt.control_slots + assert len(result.result.Kosten_Euro_pro_Stunde) == opt.control_slots + assert result.terminal_value.mode == "TAIL" + assert result.terminal_value.battery_energy_wh == pytest.approx( + max(500 - 1000 * opt.slot_duration_h, 0) + ) + assert result.terminal_value.effective_tail_hours == 48 + assert result.terminal_value.credited_euro == pytest.approx( + result.terminal_value.tail_operating_euro + result.terminal_value.continuation_value_euro + ) + assert result.terminal_value.continuation_curve is not None + assert result.terminal_value.tail_diagnostics is not None + assert result.terminal_value.tail_diagnostics.slots == 48 * (3600 // interval) + assert result.terminal_value.tail_diagnostics.soc_grid_points == 101 + assert len(result.terminal_value.tail_plan) == result.terminal_value.tail_diagnostics.slots + assert result.terminal_value.tail_plan[0].hour_from_start == 24 + assert len(result.terminal_value.curve.operating_value_euro) == 101 + assert len(result.terminal_value.curve.continuation_value_euro) == 101 + assert len(result.optimization_solution().solution.to_dataframe()) == opt.control_slots + + +def test_short_tail_is_reported(config_eos, caplog): + opt, params = setup_run(config_eos, hours=52) + with patch.object( + opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_end_slot, {}) + ): + result = opt.optimierung_ems(params) + assert result.terminal_value.effective_tail_hours == 28 + assert "Tail forecast shortened" in caplog.text + assert result.terminal_value.reason + + +def test_missing_control_is_rejected(config_eos): + opt, params = setup_run(config_eos, hours=23) + with pytest.raises(ValueError, match="Incomplete control forecast"): + opt.optimierung_ems(params) + + +def test_provider_values_are_not_extrapolated(): + from types import SimpleNamespace + + start = to_datetime("2026-09-05T00:00:00Z") + series = pd.Series([1.0, 2.0], index=pd.date_range(start=start, periods=2, freq="h")) + provider = SimpleNamespace(key_to_series=lambda *a, **kw: series) + result = bounded_forecast_array( + provider, + key="price", + start_datetime=start, + end_datetime=start.add(hours=3), + interval=to_duration("15 minutes"), + ) + assert result[:8].tolist() == [1.0] * 4 + [2.0] * 4 + assert np.isnan(result[8:]).all() + + +def test_future_opportunity_changes_optimal_control_soc(config_eos): + final_energy = [] + for negative_price in (0.5, -1.0): + opt, params = setup_run(config_eos, hours=3) + config_eos.merge_settings_from_dict( + {"optimization": {"horizon_hours": 1, "tail_horizon_hours": 2}} + ) + params.ems.strompreis_euro_pro_wh = [0.0005, negative_price / 1000, 0.0003] + params.ems.einspeiseverguetung_euro_pro_wh = [0.00002, 0, 0.0003] + result = opt.optimierung_ems(params, ngen=3, individuals=20) + final_energy.append(result.terminal_value.battery_energy_wh) + assert final_energy[0] > final_energy[1] + + +@pytest.mark.parametrize("control", [24, 48]) +def test_continuation_prevents_emptying_at_moved_boundary(config_eos, control): + opt, params = setup_run(config_eos, hours=96) + config_eos.merge_settings_from_dict( + { + "prediction": {"hours": 96}, + "optimization": {"horizon_hours": control}, + } + ) + # Zero control load, with the same future local demand visible to both tails. + params.ems.gesamtlast[60] = 1000 + params.ems.einspeiseverguetung_euro_pro_wh = [0.0] * 96 + config_eos.feedintariff.direct_marketing_enabled = False + + def choose(*a, **kw): + from deap import creator + + idle = creator.Individual([0] * control) + discharge = creator.Individual([len(opt.bat_possible_charge_values)] * control) + assert opt.toolbox.evaluate(idle)[0] <= opt.toolbox.evaluate(discharge)[0] + return idle, {} + + with patch.object(opt, "optimize", side_effect=choose): + result = opt.optimierung_ems(params) + assert result.terminal_value.battery_energy_wh == pytest.approx(500) + assert result.terminal_value.continuation_mode == "AUTO" + + +def test_missing_price_inside_tail_stops_at_first_gap(config_eos): + opt, params = setup_run(config_eos) + params.ems.strompreis_euro_pro_wh[30] = float("nan") + with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})): + result = opt.optimierung_ems(params) + assert result.terminal_value.effective_tail_hours == 6 + + +def test_ev_genome_and_output_are_control_only(config_eos): + from akkudoktoreos.optimization.genetic.geneticdevices import ElectricVehicleParameters + + opt, params = setup_run(config_eos, interval=900, start_hour=10, hours=82) + params.eauto = ElectricVehicleParameters( + device_id="ev1", + capacity_wh=5000, + initial_soc_percentage=0, + min_soc_percentage=50, + charge_rates=[0, 0.5, 1], + ) + + def choose(*a, **kw): + genome = opt.create_individual() + assert len(genome) == 2 * 96 + return genome, {} + + with patch.object(opt, "optimize", side_effect=choose): + result = opt.optimierung_ems(params) + assert len(result.eautocharge_hours_float) == 96 + + +def test_rejected_config_update_is_atomic(config_eos): + # The candidate is validated before the singleton is reinitialized, so a + # rejected update must leave the running configuration untouched rather + # than half-applied. `hours` is constrained to be non-negative. + before = config_eos.prediction.hours + with pytest.raises(ValueError): + config_eos.merge_settings_from_dict({"prediction": {"hours": -1}}) + assert config_eos.prediction.hours == before + + +def test_disabled_ac_conversion_cannot_earn_negative_price_revenue(): + bat, inv = devices() + inv.parameters.ac_to_dc_efficiency = 0 + c = build_tail_value_curve( + battery=bat, + inverter=inv, + prices_euro_per_wh=np.array([-0.001, 0.001]), + feed_in_euro_per_wh=np.array([0.0, 0.001]), + load_wh=np.zeros(2), + pv_wh=np.zeros(2), + continuation=TerminalValueCurve(), + charge_rates=[1], + export_rates=[1], + direct_marketing=True, + ) + assert c.value(0) == pytest.approx(0) + assert bat.soc_wh == 500 # Building the tail never mutates the real battery. + + +def test_short_native_forecast_declares_its_resolution(config_eos): + opt, params = setup_run(config_eos, interval=900, hours=52 * 4) + params.forecast_interval_seconds = 900 + with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})): + result = opt.optimierung_ems(params) + assert result.terminal_value.effective_tail_hours == 28 + + +@pytest.mark.parametrize( + "field,reason", + [ + ("strompreis_euro_pro_wh", "import price"), + ("einspeiseverguetung_euro_pro_wh", "feed-in tariff"), + ], +) +def test_differing_provider_lengths_use_the_common_tail(config_eos, field, reason): + opt, params = setup_run(config_eos) + setattr(params.ems, field, getattr(params.ems, field)[:52]) + with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})): + result = opt.optimierung_ems(params) + assert result.terminal_value.effective_tail_hours == 28 + assert reason in result.terminal_value.reason + + +def test_missing_provider_key_stays_missing(): + from types import SimpleNamespace + + def unavailable(*a, **kw): + raise KeyError("price unavailable") + + start = to_datetime("2026-09-05T00:00:00Z") + result = bounded_forecast_array( + SimpleNamespace(key_to_series=unavailable), + key="price", + start_datetime=start, + end_datetime=start.add(hours=2), + interval=to_duration("1 hour"), + ) + assert np.isnan(result).all() + assert len(result) == 2 + + +def test_short_prediction_horizon_shortens_the_tail(config_eos): + # The forecast budget cannot serve the full 48 h tail. The run keeps going + # with the 12 h that are left after the control horizon instead of failing. + opt, params = setup_run(config_eos, hours=36, prediction_hours=36) + assert opt.control_slots == 24 + assert opt.tail_slots == 12 + + result = opt.optimierung_ems(params, ngen=2) + assert result.terminal_value.mode == "TAIL" + assert result.terminal_value.requested_tail_hours == 48 + assert result.terminal_value.effective_tail_hours == 12 diff --git a/tests/test_weatherbrightsky.py b/tests/test_weatherbrightsky.py index 7094a52d..56e03190 100644 --- a/tests/test_weatherbrightsky.py +++ b/tests/test_weatherbrightsky.py @@ -162,7 +162,12 @@ def test_update_data(mock_get, provider, sample_brightsky_1_json, cache_store): # Assert: Verify the result is as expected mock_get.assert_called_once() - assert len(provider) == 50 + # One hourly record per slot from the run start up to, but not including, + # the end of the prediction horizon. 2024-10-27 is the DST fall-back, so the + # 72 h horizon spans 73 local hours. + expected_records = int((provider.end_datetime - provider.ems_start_datetime).total_hours()) + assert expected_records == 73 + assert len(provider) == expected_records # ------------------------------------------------ diff --git a/tests/test_weatherclearoutside.py b/tests/test_weatherclearoutside.py index 8282b90b..7af3a4a2 100644 --- a/tests/test_weatherclearoutside.py +++ b/tests/test_weatherclearoutside.py @@ -151,7 +151,7 @@ def test_update_data(mock_get, provider, sample_clearout_1_html, sample_clearout mock_get.return_value = mock_response expected_start = to_datetime("2024-10-26 00:00:00", in_timezone="Europe/Berlin") - expected_end = to_datetime("2024-10-28 00:00:00", in_timezone="Europe/Berlin") + expected_end = to_datetime("2024-10-29 00:00:00", in_timezone="Europe/Berlin") expected_keep = to_datetime("2024-10-24 00:00:00", in_timezone="Europe/Berlin") # Call the method @@ -160,7 +160,7 @@ def test_update_data(mock_get, provider, sample_clearout_1_html, sample_clearout provider.update_data() # Check for correct prediction time window - assert provider.config.prediction.hours == 48 + assert provider.config.prediction.hours == 72 assert provider.config.prediction.historic_hours == 48 assert compare_datetimes(provider.ems_start_datetime, expected_start).equal assert compare_datetimes(provider.end_datetime, expected_end).equal