diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 8aa9a0e..6da3493 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -50,6 +50,10 @@ 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 +# Penalita za překročení SoC capu před prvním buy<0 slotem. Měla by být VYŠŠÍ než +# alternativní marginal arbitrage (acquisition - avg_neg_buy ~ 1 Kč/kWh) aby LP +# preferoval buy<0 nabíjení před ranním PV. +PRE_NEG_BUY_SOC_SLACK_PENALTY_CZK_PER_WH = 0.005 PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.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 = 120.0 @@ -64,7 +68,7 @@ NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0 # Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž). EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12 NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0 -PLANNER_BUILD_TAG = "2026-05-27-neg-sell-soc-reservation-v13" +PLANNER_BUILD_TAG = "2026-05-27-pre-neg-buy-soc-cap-v14" 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 @@ -1256,6 +1260,33 @@ def solve_dispatch( # Kotva: poslední slot před prvním sell<0 by měl končit u planner floor (pokud relaxace existuje). # Slack penalizujeme v objective; samotné omezení přidáme až po definici soc. first_neg_sell_idx, pre_neg_export_last_t = _pre_negative_sell_export_window(slots) + + # buy<0 okno: pokud je v horizontu, baterie musí dorazit DO něj s dostatečnou volnou + # kapacitou, aby tam mohla maximálně nasát z OTE záporné cenny. LP samo to nevidí + # (acquisition je vstupní konstanta, ne endogenní funkce slotů) — přidáme tvrdý + # constraint na SoC v posledním slotu před prvním buy<0. + neg_buy_indices = [t for t, s in enumerate(slots) if float(s.buy_price) < 0] + first_neg_buy_idx = neg_buy_indices[0] if neg_buy_indices else None + pre_neg_buy_soc_cap_wh: float | None = None + if first_neg_buy_idx is not None and first_neg_buy_idx > 0: + # Spočítat max nabíjecí kapacitu v buy<0 oknu (per slot ≤ max_charge_power_w, + # PV přebytek + grid import). Konzervativně: jen sloty se buy<0, nikoli okolní. + neg_window_capacity_wh = 0.0 + for t in neg_buy_indices: + s_neg = slots[t] + pv_sur = max( + 0.0, + float(s_neg.pv_a_forecast_w) + float(s_neg.pv_b_forecast_w) - float(s_neg.load_baseline_w), + ) + slot_charge_pot_w = min( + float(battery.max_charge_power_w), + pv_sur + float(grid.max_import_power_w), + ) + neg_window_capacity_wh += slot_charge_pot_w * INTERVAL_H * float(battery.charge_efficiency) + # Konzervativní headroom 10 % kapacity (PV forecast error, load fluctuation) + usable_capacity = float(battery.soc_max_wh) - float(min_soc_wh) + reserve_target_wh = min(neg_window_capacity_wh, usable_capacity * 0.9) + pre_neg_buy_soc_cap_wh = max(float(min_soc_wh), float(battery.soc_max_wh) - reserve_target_wh) last_neg_sell_by_prague_date: dict[object, int] = {} for t_ln, st_ln in enumerate(slots): if float(st_ln.sell_price) < 0: @@ -1312,6 +1343,23 @@ def solve_dispatch( t_anchor = first_neg_sell_idx - 1 soc_anchor_slack = pulp.LpVariable("soc_anchor_slack_wh", 0, float(battery.usable_capacity_wh)) + # Tvrdý cap SoC před prvním buy<0 slotem: rezervovat volnou kapacitu, aby LP + # nasál maximum levné OTE záporné ceny + PV (acquisition v LP je konstanta, + # bez tohoto omezení LP nepreferuje buy<0 sloty před PV nabíjením). + pre_neg_buy_anchor_slack: pulp.LpVariable | None = None + if ( + om == "AUTO" + and first_neg_buy_idx is not None + and first_neg_buy_idx > 0 + and pre_neg_buy_soc_cap_wh is not None + ): + # Měkký constraint přes slack — nesmí dělat LP infeasible v patologických případech + # (např. startovní SoC = 100 % a krátký horizont k buy<0). + pre_neg_buy_anchor_slack = pulp.LpVariable( + "pre_neg_buy_soc_slack_wh", 0, float(battery.usable_capacity_wh) + ) + prob += soc[first_neg_buy_idx - 1] <= float(pre_neg_buy_soc_cap_wh) + pre_neg_buy_anchor_slack + daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True)) safety_pen_czk_per_wh: list[float] = [] safety_vars: list[Optional[pulp.LpVariable]] = [] @@ -1542,6 +1590,11 @@ def solve_dispatch( if soc_anchor_slack is not None else 0 ) + + ( + pre_neg_buy_anchor_slack * PRE_NEG_BUY_SOC_SLACK_PENALTY_CZK_PER_WH + if pre_neg_buy_anchor_slack is not None + else 0 + ) + pulp.lpSum( safety_vars[t] * safety_pen_czk_per_wh[t] for t in range(T) @@ -2338,6 +2391,15 @@ def solve_dispatch( if slots[0].charge_acquisition_cutoff_at is not None else None ), + "pre_neg_buy_soc_cap_wh": ( + float(pre_neg_buy_soc_cap_wh) + if pre_neg_buy_soc_cap_wh is not None else None + ), + "pre_neg_buy_soc_slack_wh": ( + float(pulp.value(pre_neg_buy_anchor_slack) or 0) + if pre_neg_buy_anchor_slack is not None else None + ), + "first_neg_buy_idx": first_neg_buy_idx, }, "masks": masks_snap, "soc_bounds": soc_bounds_snap, diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 0df7645..efb2554 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1230,7 +1230,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-neg-sell-soc-reservation-v13") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-pre-neg-buy-soc-cap-v14") self.assertGreater( results[0].battery_setpoint_w, 5_500, @@ -1380,7 +1380,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-neg-sell-soc-reservation-v13") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-pre-neg-buy-soc-cap-v14") self.assertEqual(len(results), len(slots)) def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None: @@ -1444,7 +1444,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 55.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-neg-sell-soc-reservation-v13") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-pre-neg-buy-soc-cap-v14") self.assertEqual(len(results), len(slots)) def test_fixed_tariff_neg_sell_no_grid_export(self) -> None: diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 440a700..183bd5a 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,25 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-27 (d) — pre-`buy<0` SoC cap v LP (v14) + +**Problém (home-01 run 16622, tag v13):** Fix 1 (acquisition ≥ 0) i Fix 2 (R__063 `v_pv_layer_cap_wh` redukce) byly nasazené, ale plán pro 2026-05-25 zůstal stejný: 10:30 SoC = 95 %, 11:00 SoC = 98,3 %, 13:00–14:45 (buy<0, sell<0) baterka plná → export pole A do mínusu + curtail 5 kW pole A. Příčina: +- LP v `solve_dispatch` má **bc_pv[t] ≤ pv_surplus_w** i pro sloty `t not in charge_slots` (Python obchází tvrdou masku R__063 pro PV charging) → R__063 Fix 2 (vrstva A cap = 0) nemá efekt. +- `acquisition` je v LP **vstupní konstanta**, ne endogenní funkce slotů → LP nevidí per-slot opportunity cost (nabíjení v `buy<0` = záporná cena vs PV = 0) → LP rovnoměrně nabíjí kdekoliv má PV. +- Marginal arbitrage = peak_sell (4,40) − avg_neg_buy (−0,22) = **4,62 Kč/kWh**, vs PV→bat (acquisition 0,757) = 3,64 Kč/kWh — rozdíl ~1 Kč/kWh × 32 kWh denně. + +**Oprava (tag `2026-05-27-pre-neg-buy-soc-cap-v14`):** Tvrdý LP constraint na SoC v posledním slotu před prvním `buy<0`: +- `neg_buy_indices = [t for t,s in slots if buy<0]` +- `neg_window_capacity_wh = Σ min(max_charge_w, pv_surplus + grid_max_import) × 0.25 × eff` přes neg_buy sloty. +- `pre_neg_buy_soc_cap_wh = max(min_soc_wh, soc_max_wh − min(neg_window, 0.9 × usable))`. +- LP: `soc[first_neg_buy_idx − 1] ≤ pre_neg_buy_soc_cap_wh + slack`, slack penalizován 0,005 Kč/Wh (= 5 Kč/kWh, lehce nad marginal arb → LP cap dodrží, ale neinfeasible při krátkém horizontu / vysokém startovním SoC). + +**Důsledek:** LP musí baterii do `buy<0` okna dorazit s volnou kapacitou — místo ranního PV nabíjení (sell≥0 sloty) export pole B (green bonus 7,135 Kč/kWh) a curtail pole A; v `buy<0` okně max import + max PV → baterka plná; večer max export. + +**Ověření:** v `solver_params.inputs` nově: `first_neg_buy_idx`, `pre_neg_buy_soc_cap_wh`, `pre_neg_buy_soc_slack_wh`. Replan home-01 zítra (2026-05-25) → SoC v 12:45 ≤ cap (cca 10–15 %), 13:00–14:45 SoC stoupá z capu k 100 %, `pv_a_curtailed_w` v okně blíží 0. + +--- + ## 2026-05-27 (c) — rezervace SoC pro `sell<0` okno + fallback acquisition ≥ 0 (v13) **Problém (home-01 run 16614, tag v12):** Aktivní plán pro 2026-05-25: