From 96d0d52b07e4a54d9bfa605fb693c40ec00ed2e5 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 30 May 2026 22:11:03 +0200 Subject: [PATCH] oprava battery hold --- backend/services/planning_engine.py | 35 ++++-- backend/tests/test_planning_dispatch_milp.py | 112 +++++++++++++++++-- docs/04-modules/planning.md | 6 +- docs/planning-changelog.md | 12 ++ 4 files changed, 146 insertions(+), 19 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index b9b4c0d..f0ca10f 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 -PLANNER_BUILD_TAG = "2026-05-29-neg-window-charge-night-v45" +PLANNER_BUILD_TAG = "2026-05-30-evening-spot-sell-ge-buy-v46" # Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu). NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0 # Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). @@ -1660,9 +1660,20 @@ def _slot_evening_push_profitable( *, charge_acquisition_czk_kwh: float, min_spread: float, + spot_push_sell_ge_buy: bool = False, ) -> bool: - """Push večerní špičky: spot marže (acq+spread), ne fixní buy z konstantního horizontu.""" - return float(slot.sell_price) > float(charge_acquisition_czk_kwh) + float(min_spread) + """ + Push večerní špičky: acq+spread (historická zásoba). + Spot (home-01): navíc sell >= buy−spread — jinak vývoz za 3 Kč a noc import za 5 Kč. + Fixní tarif (KV1): jen acq+spread (sell často < konstantní buy). + """ + sell_t = float(slot.sell_price) + buy_t = float(slot.buy_price) + spread = float(min_spread) + if spot_push_sell_ge_buy and buy_t >= 0.0 and sell_t >= 0.0: + if sell_t < buy_t - spread: + return False + return sell_t > float(charge_acquisition_czk_kwh) + spread def _evening_push_segment_candidates( @@ -1671,6 +1682,7 @@ def _evening_push_segment_candidates( *, charge_acquisition_czk_kwh: float, min_spread: float, + spot_push_sell_ge_buy: bool = False, discharge_export_ok: set[int] | None = None, ) -> list[int]: """Profitable sloty v nočním úseku — výběr pořadí a strop dělá rozpočet Wh (sell desc).""" @@ -1686,6 +1698,7 @@ def _evening_push_segment_candidates( slots[t], charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=min_spread, + spot_push_sell_ge_buy=spot_push_sell_ge_buy, ): continue out.append(t) @@ -1745,6 +1758,7 @@ def _evening_battery_export_push_indices( soc_max_wh: float, per_slot_discharge_wh: float, discharge_slot_buffer: float, + spot_push_sell_ge_buy: bool = False, discharge_export_ok: set[int] | None = None, evening_start_hour: int = 17, ) -> list[int]: @@ -1778,6 +1792,7 @@ def _evening_battery_export_push_indices( seg, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=degrad_czk_kwh, + spot_push_sell_ge_buy=spot_push_sell_ge_buy, discharge_export_ok=discharge_export_ok, ) if not candidates: @@ -2505,6 +2520,7 @@ def solve_dispatch( soc_max_wh=float(battery.soc_max_wh), per_slot_discharge_wh=per_slot_push_wh_pre, discharge_slot_buffer=discharge_buf_pre, + spot_push_sell_ge_buy=not purchase_fixed_pre, discharge_export_ok=discharge_export_slots, ) ) @@ -3720,11 +3736,16 @@ def solve_dispatch( buy_t > charge_acquisition_czk_kwh + min_spread ) if expensive_import_slot and t not in charge_slots and buy_t >= 0.0: - # Strict: síť jen EV+TČ; baseload z baterie/FVE. Relaxed: síť smí krmit baseload (nouzový režim). - prob += gi[t] <= ev_cap_t + hp[t] + ( - float(s.load_baseline_w) if relaxed_expensive_import else 0.0 + # Strict: síť jen EV+TČ; baseload z baterie/FVE. + # Relaxed: síť smí baseload jen mimo night_self_consume (v46). + night_self_consume_slot = ( + om == "AUTO" and t in night_self_consume_discourage_ts ) - if not relaxed_expensive_import and om == "AUTO": + if relaxed_expensive_import and not night_self_consume_slot: + prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w) + else: + prob += gi[t] <= ev_cap_t + hp[t] + if (not relaxed_expensive_import or night_self_consume_slot) and om == "AUTO": prob += ( bd[t] + pv_ld[t] >= float(s.load_baseline_w) + hp[t] diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index f326e06..2bc593d 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -15,6 +15,7 @@ from services.planning_engine import ( _dispatch_result_comparison, _evening_battery_export_push_indices, _evening_peak_export_indices, + _slot_evening_push_profitable, _evening_push_calendar_segments, _evening_push_discharge_budget_wh, _in_evening_push_hour_window, @@ -2547,7 +2548,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): slots = [ PlanningSlot( interval_start=base, - buy_price=7.3, + buy_price=3.0, sell_price=4.4, pv_a_forecast_w=0, pv_b_forecast_w=0, @@ -2587,7 +2588,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): def test_evening_battery_export_when_sell_above_acquisition(self) -> None: base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc) cheap = (0.75, 0.25) - peak = (7.0, 4.8) + peak = (3.5, 4.8) slots: list[PlanningSlot] = [] for i in range(6): buy, sell = cheap if i < 2 else peak @@ -2701,14 +2702,14 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertEqual(r.export_mode, "BATTERY_SELL") def test_evening_no_spread_export_below_segment_peak_home01(self) -> None: - """home-01 večer: plný export v top push slotech dle rozpočtu Wh, ne v levnějších mimo push.""" + """Spot večer sell≥buy: push jen top sell sloty; levnější mimo push bez exportu.""" prague = ZoneInfo("Europe/Prague") sells = [3.834, 3.518, 3.204, 3.204, 3.136, 3.020] base = datetime(2026, 5, 29, 20, 15, tzinfo=prague) slots = [ PlanningSlot( interval_start=base + timedelta(minutes=15 * i), - buy_price=5.5, + buy_price=3.0, sell_price=sells[i], pv_a_forecast_w=0, pv_b_forecast_w=0, @@ -2795,16 +2796,105 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertLessEqual(len(push), 4) self.assertEqual(push, [0, 1, 2, 3][: len(push)]) - def test_night_self_consume_prefers_battery_over_grid(self) -> None: - """v43: mezi push sloty baterie krmí dům místo importu za ~5 Kč.""" + def test_home01_evening_no_push_when_sell_below_buy(self) -> None: + """v46: OTE večer sell None: + """v46: spot nepush když sell < buy (3 Kč vývoz / 5 Kč nákup).""" + base = datetime(2026, 5, 30, 20, 0, tzinfo=ZoneInfo("Europe/Prague")) + bad = PlanningSlot( + interval_start=base.astimezone(timezone.utc), + buy_price=5.5, + sell_price=3.3, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=1400, + ev1_connected=False, + ev2_connected=False, + allow_discharge_export=True, + ) + ok = PlanningSlot( + interval_start=base.astimezone(timezone.utc), + buy_price=2.0, + sell_price=4.0, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=1400, + ev1_connected=False, + ev2_connected=False, + allow_discharge_export=True, + ) + self.assertFalse( + _slot_evening_push_profitable( + bad, + charge_acquisition_czk_kwh=0.61, + min_spread=0.15, + spot_push_sell_ge_buy=True, + ) + ) + self.assertTrue( + _slot_evening_push_profitable( + ok, + charge_acquisition_czk_kwh=0.61, + min_spread=0.15, + spot_push_sell_ge_buy=True, + ) + ) + self.assertTrue( + _slot_evening_push_profitable( + bad, + charge_acquisition_czk_kwh=0.61, + min_spread=0.15, + spot_push_sell_ge_buy=False, + ) + ) + + def test_night_self_consume_prefers_battery_over_grid(self) -> None: + """v43/v46: mimo push baterie krmí dům, ne import za ~5 Kč.""" + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 5, 29, 20, 0, tzinfo=prague) + slot_specs = [ + (3.0, 3.9), + (3.0, 3.8), + (5.0, 3.1), + (5.0, 3.0), + ] + slots = [ + PlanningSlot( + interval_start=base + timedelta(minutes=15 * i), + buy_price=buy, + sell_price=sell, pv_a_forecast_w=0, pv_b_forecast_w=0, load_baseline_w=2000, @@ -2813,7 +2903,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): allow_discharge_export=True, charge_acquisition_buy_czk_kwh=0.7, ) - for i in range(4) + for i, (buy, sell) in enumerate(slot_specs) ] battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0) battery.max_discharge_power_w = 18_000 diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 822ada2..c2a51fa 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -113,7 +113,11 @@ flowchart TD - **`night_self_consume_discourage`** na **celé** noční okno mimo push; - při `relaxed_neg_prep_window` bez prep shortfall penalizace. -**Funkce:** … Tag: **`2026-05-29-neg-window-charge-night-v45`**. +6. **v46 — večerní push spot vs. buy:** + - push jen když **sell ≥ buy − spread** (ne vývoz za 3 Kč při buy 5 Kč); + - **`relaxed_expensive_import`** neobchází nocí **bd ≥ load** v `night_self_consume` slotech. + +**Funkce:** … Tag: **`2026-05-30-evening-spot-sell-ge-buy-v46`**. ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index f366592..b2508cc 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,18 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-30 — Večer: neprodávat pod buy; noc z bat i po relaxed (v46) + +**Problém (v45 běh 20722):** Večer **push export** bat při **sell ~3,3 Kč** a **buy ~5,5 Kč** (acq ~0,61 → LP „zisk“), pak **22:00+ import ~5 Kč** pro dům při **SoC 36 %** (`relaxed_expensive_import` vypnul `bd≥load`). + +**Změna (v46):** +- **`_slot_evening_push_profitable`:** spot (`not purchase_fixed`) vyžaduje **sell ≥ buy − spread**; fixní tarif (KV1) jen **acq+spread**. +- **`relaxed_expensive_import`:** v **`night_self_consume_discourage_ts`** pořád **gi jen EV+TČ**, **bd krmí baseload**. + +Tag **`2026-05-30-evening-spot-sell-ge-buy-v46`**. + +--- + ## 2026-05-29 — Neg okno: grid nabíjení + noc z baterie (v45) **Problém (v44 běh 20282):** (1) Po večerním pushu **22:00+** import ze sítě ~3,3 kW při SoC **56 %** — `night_self_consume` jen na podmnožině `evening_early_export_ban`, ne celá noc. (2) **07:45–08:15** sell<0 prep: **`allow_charge=false`** (jen `pv_surplus>0`) → SoC stojí, **penalty ~11k Kč/slot**, solver **`relaxed_neg_prep_window`**. (3) **11:45** panické grid+bat 17 kW.