Files
ems/.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Dusan Vojacek 335c413232
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 15s
CI and deploy / deploy (pull_request) Has been skipped
planner battery tuning
2026-05-04 19:06:04 +02:00

14 KiB

name, overview, todos, isProject
name overview todos isProject
planner-battery-tuning 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.
id content status
fix-deye-passive-charge Opravit Deye PASSIVE překlad tak, aby plánované nabíjení z FVE přebytku nezapsalo reg108=0. completed
id content status
add-planner-debug-snapshot 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. pending
id content status
prevent-charge-deferral Doplnit near-term commitment / soft target před drahým sell oknem, aby rolling replan neodkládal nabíjení bez ekonomické náhrady. pending
id content status
add-daytime-safety-charge Spočítat safety-charge vstupy v SQL, předat je do LP a aplikovat jako měkkou penalizaci deficitu proti noční energii. pending
id content status
add-regression-test Přidat regresní testy pro PV surplus charge + současný net export a pro neodkládání nabíjení při receding horizon. completed
id content status
tune-small-site-terminal-soc 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. cancelled
id content status
update-docs Aktualizovat dokumentaci control/planning a ověřovací MCP dotazy. completed
id content status
verify Spustit testy/validaci a sepsat očekávané MCP ověření po deployi. completed
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:

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:

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:

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:

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:

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:

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:

{
  "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, 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:

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:

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.