planner battery tuning
This commit is contained in:
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal file
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal file
@@ -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(<run_id>)` 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.
|
||||||
Reference in New Issue
Block a user