From dbc004a94966ccaac3883d97db565a63d5d4dac8 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 23 May 2026 22:20:25 +0200 Subject: [PATCH] fix refaktoru --- backend/services/planning_engine.py | 62 +++++++++++-------- .../R__063_fn_load_planning_slots_full.sql | 6 ++ docs/planning-changelog.md | 47 ++++++++++++++ 3 files changed, 89 insertions(+), 26 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 77f9541..8f1833d 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -50,9 +50,9 @@ DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8 # Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila # bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0. PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20 -PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 12.0 +PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 40.0 # Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail). -PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 8.0 +PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 25.0 CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru @@ -643,11 +643,13 @@ def _slot_profitable_battery_export( min_spread: float, fixed_tariff: bool, ) -> bool: - """Export z baterie do sítě má kladnou marži oproti acquisition / fixnímu buy.""" + """ + Export z baterie do sítě má kladnou marži vs. cena zásoby (acquisition). + U fixed tarifu nepoužívat buy v slotu (může být predikovaný OTE jiný den) — jen acquisition. + """ sell_t = float(slot.sell_price) - if fixed_tariff: - return sell_t > float(slot.buy_price) + min_spread - return sell_t > charge_acquisition_czk_kwh + min_spread + acq = float(charge_acquisition_czk_kwh) + return sell_t > acq + min_spread def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool: @@ -1106,6 +1108,19 @@ def solve_dispatch( else min(float(s.buy_price) for s in slots) ) min_spread_pre = float(degradation_cost_effective) + fixed_tariff_like_pre = _horizon_fixed_tariff_like(slots) + profitable_export_ts_pre: set[int] = set() + if om == "AUTO": + for _t in range(T): + if _t not in discharge_export_slots: + continue + if _slot_profitable_battery_export( + slots[_t], + charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, + min_spread=min_spread_pre, + fixed_tariff=fixed_tariff_like_pre, + ): + profitable_export_ts_pre.add(_t) if first_neg_sell_idx is not None and first_neg_sell_idx > 0 and floor_pct is not None: # Kotva na ranním peaku (ne na posledním slotu před sell<0) — jinak dump až v 07:30. if ( @@ -1165,7 +1180,7 @@ def solve_dispatch( peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] - fixed_tariff_like = _horizon_fixed_tariff_like(slots) + fixed_tariff_like = fixed_tariff_like_pre block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False)) if om == "AUTO": for t in range(T): @@ -1282,28 +1297,19 @@ def solve_dispatch( 1000.0, ) if om == "AUTO": - profitable_export_ts: set[int] = set() - for t in range(T): - if t not in discharge_export_slots: - continue - if _slot_profitable_battery_export( - slots[t], - charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, - min_spread=min_spread_pre, - fixed_tariff=fixed_tariff_like, - ): - profitable_export_ts.add(t) + profitable_export_ts = profitable_export_ts_pre + export_push_w = min( + float(EVENING_BATTERY_EXPORT_MIN_W), + float(battery.max_discharge_power_w), + float(grid.max_export_power_w), + ) for t_peak in morning_pre_neg_export_ts: if t_peak in profitable_export_ts: - prob += ge_bat[t_peak] >= PRENEG_MORNING_EXPORT_MIN_W * z_export[t_peak] + prob += ge_bat[t_peak] >= float(PRENEG_MORNING_EXPORT_MIN_W) * z_export[t_peak] for t_peak in evening_peak_export_ts: if t_peak in profitable_export_ts: - prob += ge_bat[t_peak] >= EVENING_BATTERY_EXPORT_MIN_W * z_export[t_peak] - # Všechny ekonomicky výhodné discharge sloty (ne jen „globální maximum“ high_sell). - for t_peak in profitable_export_ts: - if t_peak in morning_pre_neg_export_ts or t_peak in evening_peak_export_ts: - continue - prob += ge_bat[t_peak] >= EVENING_BATTERY_EXPORT_MIN_W * z_export[t_peak] + prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] + # Ostatní profitable sloty: jen shortfall penalizace (ne tvrdý push na celý horizont). if t_anchor is not None and soc_anchor_slack is not None: target_floor_wh = float(planner_floor_effective_wh) prob += soc[t_anchor] <= target_floor_wh + soc_anchor_slack @@ -1524,7 +1530,11 @@ def solve_dispatch( # Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro # robustnost/noční baseload. Použije se pouze pokud je safety target v SQL vyplněný. tgt_s = slots[t].safety_soc_target_wh if daytime_en else None - if tgt_s is not None and not high_sell_slot[t]: + if ( + tgt_s is not None + and not high_sell_slot[t] + and t not in profitable_export_ts_pre + ): export_soc_floor_t = max( export_soc_floor_t, min( diff --git a/db/routines/R__063_fn_load_planning_slots_full.sql b/db/routines/R__063_fn_load_planning_slots_full.sql index 0c91dec..b48067b 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -693,6 +693,12 @@ begin set allow_charge = true, allow_grid_charge = true where wk.buy_price < 0; + -- Záporný výkup + PV přebytek: nabíjení z FVE (KV1/BA81 block_export), bez filtru future_sell. + update _ems_plan_slot_wk wk + set allow_charge = true + where wk.sell_price < 0 + and wk.pv_surplus_w > 0; + -- Acquisition: grid nabíjení před prvním exportem ve STEJNÝ den jako záporné výkupní okno -- (ne dřívější večerní export v horizontu rolling replanu). select min(wk.interval_start) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index a98e03f..a6f035b 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -36,6 +36,53 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-24 (b) — Po deployi: export stále slabý (oprava #2) + +**Problém:** Po prvním deployi MCP stále `max_discharge ~300 W`, KV1 `allow_charge=false` při `sell<0`, 0× `BATTERY_SELL` u BA81/KV1. home-01 částečně OK (backend běží). + +**Příčiny z MCP:** + +1. **Flyway `R__063` neaplikovaný** na DB → masky bez `allow_charge` u záporného výkupu (`ch_true=0` na celém runu KV1). +2. **Fixed marže:** `_slot_profitable_battery_export` používal `buy` v slotu (predikce 4,08 Kč) místo **`charge_acquisition`** (~3,09) → večerní export vypnutý i při `sell` 3,7. +3. **`ge_bat ≤ max_export × z_export`:** solver volil `z_export=0` → `ge_bat=0` navzdory push. +4. **Safety SoC floor** (~91 %) na ne-high-sell večerních slotech → téměř žádný export. + +**Opravy:** + +| Změna | Soubor | +|--------|--------| +| Explicitní `allow_charge` pro `sell<0` + `pv_surplus>0` | `R__063` | +| Marže exportu: vždy `sell > acquisition + degrad` | `planning_engine._slot_profitable_battery_export` | +| `ge_bat` push bez násobení `z_export`; `z_export ≥ ge_bat/max_export` | `solve_dispatch` | +| Safety export floor ne na `profitable_export_ts` | `solve_dispatch` | +| Tvrdé `bc_pv ≥ 0.9×pv_surplus` v `charge_slots` + `sell<0` | `solve_dispatch` | +| Penalizace shortfall 40 / 25 Kč/kWh | konstanty | + +**Deploy checklist (povinné obojí):** + +```bash +# 1) SQL masky +flyway migrate # nebo deploy skript s R__063 + +# 2) Backend +docker compose build ems-api && docker compose up -d ems-api +# rolling replan nebo počkat :15 +``` + +**Ověření v MCP:** + +```sql +-- musí být > 0 po novém runu KV1: +select count(*) from ems.planning_run pr, + jsonb_array_elements(pr.solver_params->'masks') m +where pr.site_id=4 and pr.status='active' + and (m->>'allow_charge')::boolean + and (select effective_sell_price from ems.planning_interval pi + where pi.run_id=pr.id and pi.interval_start=(m->>'slot')::timestamptz) < 0; +``` + +--- + ## Šablona pro další záznamy ```markdown