From 335c4132323c5d7551ab10861c966558a0a7ace3 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 4 May 2026 19:06:04 +0200 Subject: [PATCH] planner battery tuning --- .../planner-battery-tuning_ae42fae3.plan.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 .cursor/plans/planner-battery-tuning_ae42fae3.plan.md diff --git a/.cursor/plans/planner-battery-tuning_ae42fae3.plan.md b/.cursor/plans/planner-battery-tuning_ae42fae3.plan.md new file mode 100644 index 0000000..59729a0 --- /dev/null +++ b/.cursor/plans/planner-battery-tuning_ae42fae3.plan.md @@ -0,0 +1,279 @@ +--- +name: planner-battery-tuning +overview: Opravíme nesoulad mezi plánem a zápisem do Deye při nabíjení z FVE přebytku, doplníme SQL-first vstupy pro denní safety charge, aplikujeme je v LP jako soft penalty a uložíme debug snapshot každého běhu planneru. +todos: + - id: fix-deye-passive-charge + content: Opravit Deye PASSIVE překlad tak, aby plánované nabíjení z FVE přebytku nezapsalo reg108=0. + status: completed + - id: add-planner-debug-snapshot + content: Ukládat ke každému planning_run kompaktní debug JSON do solver_params se sekcemi inputs, masks, soc_bounds, objective_terms a chosen_slots. + status: pending + - id: prevent-charge-deferral + content: Doplnit near-term commitment / soft target před drahým sell oknem, aby rolling replan neodkládal nabíjení bez ekonomické náhrady. + status: pending + - id: add-daytime-safety-charge + content: Spočítat safety-charge vstupy v SQL, předat je do LP a aplikovat jako měkkou penalizaci deficitu proti noční energii. + status: pending + - id: add-regression-test + content: Přidat regresní testy pro PV surplus charge + současný net export a pro neodkládání nabíjení při receding horizon. + status: completed + - id: tune-small-site-terminal-soc + content: Po debug ověření upravit parametry BA81/KV1 cíleně; nezačínat slepým přepsáním `planner_terminal_soc_value_factor` na 0.9. + status: cancelled + - id: update-docs + content: Aktualizovat dokumentaci control/planning a ověřovací MCP dotazy. + status: completed + - id: verify + content: Spustit testy/validaci a sepsat očekávané MCP ověření po deployi. + status: completed +isProject: false +--- + +# Stabilizace plánovače baterie + +## Cíl +Opravit tři související problémy: + +- Plán někdy chce nabíjet baterii z PV přebytku, ale Deye dostane `reg108 = 0`, takže fyzicky nenabíjí. +- Rolling replan umí posouvat plánované nabíjení dál a dál, až levné PV okno uteče. +- Malé baterie BA81/KV1 potřebují robustní denní nabití pro noc, ale zároveň nesmí ztratit schopnost ekonomicky cyklovat a prodávat v opravdu drahých sell oknech. + +## Datové zjištění +- `BA81` = site `3`, `KV1` = site `4`, `home-01` = site `2`. +- KV1 run `8101` pro slot 17:15 plánoval `battery_setpoint_w = 4737` W, `grid_setpoint_w = -13` W, `deye_physical_mode = PASSIVE`; `modbus_command` následně zapsal a ověřil Deye `register = 108`, `value_to_write = 0`. To je konkrétní bug v control exportu. +- BA81 historie rolling runů ukazuje posun prvního charge slotu s časem. To je částečně normální receding-horizon efekt, ale nesmí prodat levný PV přebytek, který je potřeba pro pozdější sell peak nebo noční baseload. +- `planner_terminal_soc_value_factor` není jediné řešení. BA81/KV1 mají `0.2`, home-01 má `0.9`; nezvyšovat BA81/KV1 plošně na `0.9`, protože to může vrátit starou neochotu malé baterie cyklovat. + +## Architektonické rozhodnutí +- SQL-first zůstává: výpočet vstupů pro planner patří do SQL funkcí / view. +- Safety charge nesmí být hard `allow_charge` maska. SQL má spočítat vstupní hodnoty, LP je použije jako soft penalty v objective. +- Debug snapshot ukládat do existujícího `ems.planning_run.solver_params`. Samostatnou tabulku nezavádět v první iteraci. +- Hodnota energie v baterii není jedna konstanta: `battery_value = max(future_avoided_buy, future_sell_opportunity) - degradation`, plus samostatný měkký noční buffer. + +## Implementace + +### 1. Oprava Deye exportéru +Soubory: +- [`backend/services/control/inverter.py`](backend/services/control/inverter.py) +- [`backend/services/control/setpoints.py`](backend/services/control/setpoints.py) + +Požadované chování: +- Pokud `ControlSetpoints.battery_w > 0`, Deye musí dostat nenulový nabíjecí proud podle `battery_w`, i když `grid_setpoint_w < 0`. +- V tomto scénáři zůstává `deye_physical_mode = PASSIVE`, pokud plán explicitně neurčí `CHARGE`. Nejde o grid-charge režim; jde o nabíjení z PV přebytku a současný export zbytku. +- `discharge_a` v tomto scénáři nastavit na `0` nebo jinak omezit tak, aby Deye současně nevybíjel baterii. +- Existující SELL a PRESERVE chování neměnit. + +Konkrétní místo: +- V `write_inverter_setpoints()` je problém v PASSIVE větvi, která přes `_deye_zero_export_amps_for_passive()` vrací `charge_a = 0`, když `grid_w < 0` a `bat_w >= 0`. +- Přidej před tuto větev explicitní případ `bat_w > 0`: `charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a)`, `discharge_a = 0`. + +### 2. SQL vstupy pro daytime safety charge +Soubory: +- [`db/routines/R__063_fn_load_planning_slots_full.sql`](db/routines/R__063_fn_load_planning_slots_full.sql) +- případně nová repeatable funkce v [`db/routines`](db/routines) + +Neimplementovat jako hard masku. Nezakazovat / nepovolovat sloty natvrdo jen kvůli safety charge. + +Doplnit SQL výstupy, které Python LP použije: +- `night_baseload_target_wh`: kolik Wh je potřeba od večera do dalšího ranního PV okna. +- `night_baseload_buffer_wh`: bezpečnostní přirážka, např. procento z cíle. +- `safety_soc_target_wh`: doporučený SoC cíl pro slot. +- `future_avoided_buy_czk_kwh`: odhad ceny, kterou baterie ušetří, pokud energii necháme pro vlastní spotřebu. +- `future_sell_opportunity_czk_kwh`: nejlepší relevantní budoucí sell příležitost v horizontu. +- `is_daytime_pv_surplus_slot`: pomocný boolean pro debug a vážení cíle. + +Preferovaný způsob: +- Rozšířit `ems.fn_load_planning_slots_full(...)`, protože už je hlavní zdroj slotových vstupů pro `_load_slots()`. +- Pokud by rozšíření funkce bylo příliš velké, vytvořit samostatnou `ems.fn_planning_safety_charge_inputs(site_id, from, to, current_soc_wh)` a joinovat podle `interval_start` v SQL/Pythonu. + +Výpočet nočního okna: +- Praktická první verze: noc = od lokálního západu / večerního konce PV surplus do dalšího rána, zjednodušeně `20:00-06:00 Europe/Prague`. +- Přesnější verze později: od posledního dnešního slotu s významným PV forecastem do prvního zítřejšího slotu s významným PV forecastem. +- Pro první implementaci stačí konzervativní a čitelná definice, hlavně ji uložit do debug snapshotu. + +### 3. Rozšíření Python datových tříd a načítání slotů +Soubor: +- [`backend/services/planning_engine.py`](backend/services/planning_engine.py) + +Upravit `PlanningSlot`: +- Přidat volitelná pole pro SQL safety vstupy: + - `safety_soc_target_wh: float | None` + - `night_baseload_target_wh: float | None` + - `night_baseload_buffer_wh: float | None` + - `future_avoided_buy_czk_kwh: float | None` + - `future_sell_opportunity_czk_kwh: float | None` + - `is_daytime_pv_surplus_slot: bool = False` + +Upravit `_load_slots()`: +- Načíst nové sloupce ze SQL. +- Pokud SQL sloupce dočasně nejsou k dispozici, použít bezpečný fallback `None` / `False`, aby testy starších DB funkcí nespadly. +- Nepočítat noční baseload ad-hoc v Pythonu, pokud už SQL funkce hodnotu vrací. + +### 4. LP objective: soft safety target +Soubor: +- [`backend/services/planning_engine.py`](backend/services/planning_engine.py) + +Přidat do `solve_dispatch()`: +- Pro každý slot `t` s `safety_soc_target_wh is not None` vytvořit spojitou proměnnou `safety_deficit_wh[t] >= 0`. +- Přidat omezení: + - `safety_deficit_wh[t] >= safety_soc_target_wh[t] - soc[t]` +- Přidat do objective penalizaci: + - `safety_deficit_wh[t] * safety_penalty_czk_per_wh[t]` + +Výpočet penalty: +- `battery_value_czk_kwh = max(future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh) - degradation_cost_effective` +- `safety_penalty_czk_per_wh = max(0, battery_value_czk_kwh) / 1000` +- Přidat rozumný clamp, aby penalty nebyla extrémní kvůli vadné ceně. + +Chování: +- Pokud je vysoký sell peak ekonomicky lepší než držet energii pro noc, LP smí target porušit a prodat. +- Pokud je budoucí nákup drahý, typicky KV1, deficit bude drahý a LP bude energii spíš držet pro vlastní spotřebu. +- Toto není hard constraint. + +### 5. Near-term commitment proti deferralu +Soubory: +- [`backend/services/planning_engine.py`](backend/services/planning_engine.py) +- DB čtení z `ems.planning_run` / `ems.planning_interval` přes SQL funkci nebo jednoduchý read model + +Cíl: +- Rolling replan nesmí bez náhrady odsunout nejbližší plánované nabíjení z PV přebytku, pokud předchozí aktivní plán pro stejný nebo nejbližší slot chtěl nabíjet. + +První jednoduchá implementace: +- Při rolling replanu načíst předchozí aktivní plán pro stejné `site_id`. +- Najít nejbližší 1-2 sloty od `replan_from`, kde předchozí plán měl: + - `battery_setpoint_w > 500` + - `pv_a_forecast_solver_w + pv_b_forecast_solver_w > load_baseline_w` + - ideálně `grid_setpoint_w <= 0` +- V novém LP pro odpovídající slot přidat soft proměnnou `charge_commitment_shortfall_w[t] >= previous_battery_charge_w - bc[t]`. +- Penalizace má být malá, ale nenulová: má zabránit bezdůvodnému odsunu, ne přebít skutečně lepší ekonomiku. +- Uložit do debug snapshotu, kdy commitment vznikl a kolik stál. + +Neimplementovat jako hard constraint. + +### 6. Debug snapshot do solver_params +Soubory: +- [`backend/services/planning_engine.py`](backend/services/planning_engine.py) +- [`db/routines/R__037_fn_planning_run_commit.sql`](db/routines/R__037_fn_planning_run_commit.sql) + +Upravit `_save_planning_run()`: +- Rozšířit `run_meta` o `solver_params`. +- `solver_params` bude JSON serializovatelný dict. + +Upravit `ems.fn_planning_run_commit(...)`: +- Při insertu do `ems.planning_run` uložit `solver_params = p_run_meta->'solver_params'`. + +Minimální struktura JSON: + +```json +{ + "version": 1, + "inputs": { + "current_soc_wh": 0, + "operating_mode": "AUTO", + "battery": { + "usable_capacity_wh": 0, + "min_soc_wh": 0, + "reserve_soc_wh": 0, + "degradation_cost_czk_kwh": 0, + "planner_terminal_soc_value_factor": 0.2 + } + }, + "masks": [ + { + "slot": "2026-05-04T15:45:00+00:00", + "allow_charge": true, + "allow_discharge_export": false + } + ], + "soc_bounds": [ + { + "slot": "2026-05-04T15:45:00+00:00", + "soc_min_wh": 0, + "arb_floor_wh": 0, + "soc_panel_min_wh": 0, + "safety_soc_target_wh": 0 + } + ], + "objective_terms": [ + { + "slot": "2026-05-04T15:45:00+00:00", + "buy_price": 0, + "sell_price": 0, + "future_avoided_buy_czk_kwh": 0, + "future_sell_opportunity_czk_kwh": 0, + "battery_value_czk_kwh": 0, + "safety_deficit_penalty_czk_per_wh": 0, + "commitment_penalty_czk_per_w": 0 + } + ], + "chosen_slots": { + "charge_commitment": [], + "high_sell_windows": [], + "night_window": { + "start": "2026-05-04T18:00:00+00:00", + "end": "2026-05-05T04:00:00+00:00", + "target_wh": 0 + } + } +} +``` + +### 7. Debug read model +Soubor: +- nová repeatable funkce v [`db/routines`](db/routines), např. `R__086_fn_planning_run_debug.sql` + +Vytvořit `ems.fn_planning_run_debug(p_run_id int)`: +- Vrátí jeden `jsonb`. +- Obsahuje: + - metadata z `planning_run`, + - `solver_params`, + - intervaly z `planning_interval` pro daný run, + - krátký souhrn: první charge slot, první battery export slot, nejdražší sell sloty, největší safety deficit. + +Použití přes MCP: + +```sql +select ems.fn_planning_run_debug(8107); +``` + +### 8. Parametry +Nepřepisovat plošně BA81/KV1 na `planner_terminal_soc_value_factor = 0.9`. + +Nové parametry preferovaně v `ems.asset_battery` přes novou migraci: +- `planner_daytime_charge_target_enabled boolean default true` +- `planner_night_baseload_buffer_percent numeric default 20` +- `planner_daytime_charge_price_quantile numeric default 0.70` +- `planner_charge_commitment_penalty_czk_kwh numeric default 0.20` + +Pokud je rozsah příliš velký, první iterace může mít konzervativní konstanty v Pythonu, ale plánovaná cílová podoba je DB parametrizace. + +### 9. Testy +Najít existující testovací styl v repu a přidat testy co nejblíže dotčeným modulům. + +Povinné scénáře: +- Control exporter: `battery_w > 0`, `grid_setpoint_w < 0`, `deye_physical_mode = PASSIVE` vede na `reg108 > 0`, `reg109 = 0`. +- Control exporter: SELL režim se nezmění. +- Planner safety: malá baterie, PV surplus přes den, noční baseload, pozdější drahý sell slot. LP má nabíjet v rozumně levném PV slotu a neodsunout charge donekonečna. +- Planner economics: pokud `sell_now` převyšuje budoucí avoided buy plus degradaci, LP smí porušit safety target a prodat. +- Planner economics KV1-like: pokud budoucí buy je drahý a sell není dost vysoký, LP má držet energii pro vlastní spotřebu. + +### 10. Dokumentace +Aktualizovat: +- [`docs/04-modules/control.md`](docs/04-modules/control.md) +- [`docs/04-modules/planning.md`](docs/04-modules/planning.md) + +Dokumentace musí popsat: +- rozdíl mezi plánem, Deye fyzickým režimem a registry `108/109`, +- PV-surplus charging při současném exportu, +- `solver_params` debug snapshot a `fn_planning_run_debug`, +- rozdíl mezi hard maskami (`allow_charge`, `allow_discharge_export`) a soft LP penalizacemi, +- že `planner_terminal_soc_value_factor` není jediný mechanismus ochrany malé baterie. + +## Ověření +- Spustit backend testy pro control a planner. +- Spustit Flyway validate lokálně. +- Přes MCP ověřit po nasazení: + - pro BA81/KV1 sloty s `battery_setpoint_w > 0` a `grid_setpoint_w < 0` má následný `modbus_command.register = 108` hodnotu > 0, + - `planning_run.solver_params` není `NULL` a obsahuje `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`, + - `select ems.fn_planning_run_debug()` vrací vysvětlitelný JSON, + - rolling replan neodkládá nabíjení z levného PV přebytku bez viditelného ekonomického důvodu v debug snapshotu. \ No newline at end of file