From 095676e3b1c5b6834a463d7f30c03730bcc0afff Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 25 May 2026 01:05:23 +0200 Subject: [PATCH] revert a nove upravy --- backend/services/planning_engine.py | 42 +++++++++++++++++++- backend/tests/test_planning_dispatch_milp.py | 6 +-- docs/planning-changelog.md | 25 ++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 8aa9a0e..5dac2d9 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -64,7 +64,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-simple-buy-neg-window-v16" 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 @@ -1200,6 +1200,28 @@ def solve_dispatch( discharge_export_slots = { t for t, s in enumerate(slots) if s.allow_discharge_export } + # Vybití baterie před `buy<0` oknem: pokud je v horizontu buy<0, můžeme baterii + # vybít teď za `sell` a v buy<0 okně ji nabít za záporný buy (= příjem). + # Ekonomicky výhodné dokud: sell_t > avg_buy_neg + degradation + # (vybíjet ztratíme ~discharge_eff loss, nabíjení v buy<0 nás platí; marže ~ sell − buy_neg − degrad). + # Cílem je obejít to, že R__063 v noci dává allow_discharge_export=false a LP + # by jinak nemohl baterku vyklidit přes ge_bat. + _neg_buy_for_disch = next( + (t for t, s in enumerate(slots) if float(s.buy_price) < 0), None + ) + if _neg_buy_for_disch is not None and _neg_buy_for_disch > 0: + _neg_buy_prices = [ + float(slots[t].buy_price) + for t in range(_neg_buy_for_disch, T) + if float(slots[t].buy_price) < 0 + ] + _avg_neg_buy = sum(_neg_buy_prices) / len(_neg_buy_prices) if _neg_buy_prices else 0.0 + # Práh = avg buy<0 + degradation cycle overhead; default fallback 0.1 Kč/kWh + # když z nějakého důvodu neumíme spočítat (ochrana proti vybití do mínusu). + _disch_sell_thr = max(_avg_neg_buy + float(degradation_cost_effective), 0.1) + for t in range(_neg_buy_for_disch): + if float(slots[t].sell_price) >= _disch_sell_thr: + discharge_export_slots.add(t) # SELF_SUSTAIN dřív vynucoval ge[t] == 0, což umí udělat MILP infeasible v okamžiku, kdy: # - baterie je na max SoC (nelze nabíjet), # - PV pole B není curtailable, @@ -1913,6 +1935,16 @@ def solve_dispatch( prob += bd[t] == 0 # Slot pre-selection (z DB fn_load_planning_slots_full → allow_*) + # PŘED prvním buy<0 slotem v horizontu (= rezervační okno): + # - sell ≥ 0 → bc_pv = 0 (PV poteče do gridu / curtail, baterka se nenabíjí z PV + # protože v buy<0 okně bude akvizice levnější — záporná). + # - sell < 0 → slot je v charge_slots (R__063), bc_pv ≤ pv_surplus (= nemůžeme + # pole A vyhodit do mínusu, raději nabít baterii). + # JINDY (po buy<0 okně, nebo žádné buy<0 v horizontu): původní permissive + # bc_pv ≤ pv_surplus aby nedošlo k regresi normálních dnů. + _neg_buy_idx_main = next( + (t for t, s in enumerate(slots) if float(s.buy_price) < 0), None + ) if om == "AUTO": for t in range(T): if t not in charge_slots: @@ -1923,11 +1955,17 @@ def solve_dispatch( + int(s.pv_b_forecast_w) - int(s.load_baseline_w), ) - # Mimo grid-charge masku: jen PV přebytek; výjimka záporný buy (spot arbitráž). if float(s.buy_price) >= 0.0: prob += bc_gi[t] == 0 + in_pre_neg_buy_window = ( + _neg_buy_idx_main is not None and t < _neg_buy_idx_main + ) if pv_surplus_w <= 0: prob += bc_pv[t] == 0 + elif in_pre_neg_buy_window: + # Strukturální preference: PV jde do gridu (sell≥0) nebo curtail, + # ne do baterie — kapacitu si šetříme na buy<0 okno. + prob += bc_pv[t] == 0 else: prob += bc_pv[t] <= float(pv_surplus_w) if t not in discharge_export_slots and t not in neg_sell_bat_dump_slots: diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 0df7645..b40fbbc 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-simple-buy-neg-window-v16") 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-simple-buy-neg-window-v16") 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-simple-buy-neg-window-v16") 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..0e0577b 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,31 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-27 (f) — zjednodušená strategie pro buy<0 okno (v16, revert v14+v15) + +**Problém v14/v15 (run 16622, 16636, 16642):** Vrstvy soft penalty (cap+slack, PV charge suppressed penalty) LP **nedonutily** vybít baterii ani omezit PV pumping. LP přijímal sloupec slack 24 kWh × 50 Kč/kWh = 1190 Kč a baterii nabíjel z ranního PV (10:30 SoC=95 %), pak v `buy<0` okně (13:00–14:45) curtail pole A 5–9 kW + export pole A do mínusu. + +**Strukturální root cause (3 vrstvy):** +1. R__063 `allow_charge=false` ze SQL Pythonský `solve_dispatch` ignoruje pro PV charging (`bc_pv ≤ pv_surplus` i pro `t not in charge_slots`). +2. `discharge_export_slots` v noci `false` (R__063) → LP nemá cestu jak baterii vybít přes ge_bat. +3. `acquisition` v LP je vstupní konstanta — LP nevidí, že buy<0 okno je „lepší cesta" než ranní PV pumping. + +**Oprava (tag `2026-05-27-simple-buy-neg-window-v16`):** Reverted v14+v15, znovu postaveno **2 jednoduchá pravidla** podle business logiky: + +1. **Tvrdé `bc_pv[t] = 0` pre-first_neg_buy_idx** (slots kde `t not in charge_slots`): PV poteče do gridu (sell≥0) nebo curtail, ne do baterie. R__063 už pro `sell<0+pv_surplus` přidává `allow_charge=true` (= `t in charge_slots`), takže pole A v `sell<0` slotech může nabíjet baterii (= nevyhodit do mínusu). +2. **Rozšíření `discharge_export_slots`** o pre-`buy<0` sloty se **dynamickým prahem** `sell ≥ max(avg(buy<0) + degradation_cost, 0.1) Kč/kWh`. Pro home-01 (avg buy<0 ≈ −0,22, degrad ≈ 0,15) to dělá práh ~0,1 Kč → prakticky všechny noční sloty se `sell > 0`. Ekonomická logika: marže `sell_t − acquisition_in_neg_buy_window − degradation`, a pokud `acquisition ≈ záporný` (buy<0 v okně), je výhodné vybít a znovu nabít i za sell ~1 Kč/kWh. + +**Business logika (od uživatele):** +- Noc před `buy<0`: vybít baterii za sell ~3 Kč/kWh. +- Ráno: minimální SoC. +- `buy<0` okno: PV B necurtailovat (R__063 už řeší), nabíjet ze sítě (LP samo, buy záporný = `t in charge_slots`). +- Po `sell>0`: baterie plná, max prodej. +- Večer: prodat zbytek. + +**Ověření:** `pytest backend/tests/test_planning_dispatch_milp.py tests/test_planning_charge_slot_selection.py` — 87 passed (1 pre-existing fail nesouvisí). Po deploy MCP: `select pr.solver_params->'planner_build_tag'` = `…-v16`, plán home-01 25.5.: SoC v 12:45 < 50 %, 13:00–14:45 SoC roste z capu k ~95 %, `pv_a_curtailed_w` blízko 0 v okně. + +--- + ## 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: