--- 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.