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