diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index f0ca10f..5707623 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-30-evening-spot-sell-ge-buy-v46" +PLANNER_BUILD_TAG = "2026-05-30-post-push-night-battery-v47" # 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,20 +1660,12 @@ 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: 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). + Push večerní špičky: sell > acq+spread (zásoba z levného nabití). + Večer sell= 0.0 and sell_t >= 0.0: - if sell_t < buy_t - spread: - return False - return sell_t > float(charge_acquisition_czk_kwh) + spread + return float(slot.sell_price) > float(charge_acquisition_czk_kwh) + float(min_spread) def _evening_push_segment_candidates( @@ -1682,7 +1674,6 @@ 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).""" @@ -1698,13 +1689,45 @@ 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) return out +def _post_evening_push_night_self_consume_indices( + slots: list[PlanningSlot], + evening_push_ts: set[int], +) -> set[int]: + """ + Po posledním evening_push daného večera až do rána: dům z baterie, ne import za ~5 Kč. + """ + if not evening_push_ts: + return set() + last_push_by_day: dict[object, int] = {} + for t in evening_push_ts: + last_push_by_day[_prague_calendar_date(slots[t])] = max( + last_push_by_day.get(_prague_calendar_date(slots[t]), -1), + t, + ) + out: set[int] = set() + for t, s in enumerate(slots): + day = _prague_calendar_date(s) + t_last = last_push_by_day.get(day) + if t_last is None or t <= t_last: + continue + if t in evening_push_ts: + continue + if not _in_night_battery_export_window(s): + continue + if float(s.buy_price) <= 0.0: + continue + if float(s.load_baseline_w) <= 0: + continue + out.add(t) + return out + + def _evening_push_calendar_segments( slots: list[PlanningSlot], discharge_export_ok: set[int] | None = None, @@ -1758,7 +1781,6 @@ 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]: @@ -1792,7 +1814,6 @@ 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: @@ -2496,6 +2517,7 @@ def solve_dispatch( evening_push_ts: set[int] = set() evening_early_export_penalty_ts: set[int] = set() night_self_consume_discourage_ts: set[int] = set() + post_evening_push_night_ts: set[int] = set() evening_push_hysteresis_retained = False if om == "AUTO": per_slot_discharge_wh_pre = max( @@ -2520,7 +2542,6 @@ 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, ) ) @@ -2557,6 +2578,10 @@ def solve_dispatch( charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=float(degradation_cost_effective), ) + post_evening_push_night_ts = _post_evening_push_night_self_consume_indices( + slots, evening_push_ts + ) + night_self_consume_discourage_ts |= post_evening_push_night_ts pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh( slots, first_neg_buy_idx=first_neg_buy_idx, @@ -3739,7 +3764,11 @@ def solve_dispatch( # 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 + om == "AUTO" + and ( + t in night_self_consume_discourage_ts + or t in post_evening_push_night_ts + ) ) if relaxed_expensive_import and not night_self_consume_slot: prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w) diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 2bc593d..c406a74 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -2796,45 +2796,12 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertLessEqual(len(push), 4) self.assertEqual(push, [0, 1, 2, 3][: len(push)]) - 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), + def test_evening_push_ok_when_sell_below_buy_vs_acq(self) -> None: + """v47: večer sellacq — push pro vyprázdnění před neg dnem.""" + slot = PlanningSlot( + interval_start=datetime( + 2026, 5, 30, 20, 0, tzinfo=ZoneInfo("Europe/Prague") + ).astimezone(timezone.utc), buy_price=5.5, sell_price=3.3, pv_a_forecast_w=0, @@ -2844,39 +2811,9 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): 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, + slot, charge_acquisition_czk_kwh=0.61, min_spread=0.15 ) ) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index c2a51fa..f86f1fb 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -113,11 +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. -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. +6. **v47 — po večerním pushu noc z baterie:** + - večerní push zůstává **sell > acq+spread** (sell<buy je záměr před neg dnem); + - **`post_evening_push_night_ts`:** po pushu **bd ≥ load**, ne import ~5 Kč i při relaxed solve. -**Funkce:** … Tag: **`2026-05-30-evening-spot-sell-ge-buy-v46`**. +**Funkce:** … Tag: **`2026-05-30-post-push-night-battery-v47`**. ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index b2508cc..78fb6fe 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,15 +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) +## 2026-05-30 — Po večerním pushu noc z baterie, ne import za 5 Kč (v47) -**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`). +**Záměr uživatele:** Večerní vývoz za **~3 Kč/kWh** (sell<buy) je **správně** — vyprázdnění před neg dnem/FVE. Špatně je **po pushu držet SoC a kupovat dům za ~5 Kč**. -**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**. +**Problém (v45–v46):** Po pushu **SoC ~36 %**, pak **22:00+ grid import** pro baseload; `relaxed_expensive_import` obešel `bd≥load`. -Tag **`2026-05-30-evening-spot-sell-ge-buy-v46`**. +**Změna (v47):** +- **Večerní push:** zůstává **sell > acq+spread** (v46 sell≥buy **zrušeno**). +- **`post_evening_push_night_ts`:** po posledním push slotu večera → tvrdé **bd krmí dům** i při `relaxed_expensive_import`. +- **`night_self_consume`** + v45 neg okno beze změny. + +Tag **`2026-05-30-post-push-night-battery-v47`**. (v46 na serveru nepoužívat — blokoval večerní push.) ---