diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 7f694f5..350a7fc 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-31-evening-push-any-relaxed-v55" +PLANNER_BUILD_TAG = "2026-06-01-evening-push-keep-on-relaxed-import-v57" # Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). DAWN_LOW_PV_NO_CURTAIL_W = 1500 # Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu). @@ -1922,6 +1922,39 @@ def _evening_battery_export_push_indices( return sorted(out) +def _evening_push_peak_fallback_indices( + slots: list[PlanningSlot], + *, + charge_acquisition_czk_kwh: float, + min_spread: float, + discharge_export_ok: set[int] | None, + first_neg_sell_idx: int | None, + kv1_evening_push: bool, +) -> set[int]: + """Alespoň jeden večerní peak slot (sell desc), když rozpočet Wh nevybral žádný push.""" + best_t: int | None = None + best_sell = -1.0 + for t, s in enumerate(slots): + if discharge_export_ok is not None and t not in discharge_export_ok: + continue + if not _in_evening_push_hour_window(s): + continue + if not _slot_evening_push_profitable( + s, + charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, + min_spread=min_spread, + slots=slots, + first_neg_sell_idx=first_neg_sell_idx, + kv1_evening_push=kv1_evening_push, + ): + continue + sell_t = float(s.sell_price) + if sell_t > best_sell: + best_sell = sell_t + best_t = t + return {best_t} if best_t is not None else set() + + def _evening_night_peak_sell_czk(slots: list[PlanningSlot]) -> float: sells = [ float(s.sell_price) @@ -2190,22 +2223,33 @@ def solve_dispatch_two_pass( slots2 = _slots_with_charge_acquisition(slots, acq2) relax_carry = _solve_dispatch_relax_carryover(snap1) - results2, ms2, snap2 = solve_dispatch( - slots2, - battery, - heat_pump, - grid, - ev_sessions, - vehicles, - current_soc_wh, - current_tuv_temp_c, - tuv_delta_stats=tuv_delta_stats, - operating_mode=operating_mode, - charge_commitment_prev_w=charge_commitment_prev_w, - planner_version=planner_version, - evening_push_ts_override=None, - **relax_carry, - ) + try: + results2, ms2, snap2 = solve_dispatch( + slots2, + battery, + heat_pump, + grid, + ev_sessions, + vehicles, + current_soc_wh, + current_tuv_temp_c, + tuv_delta_stats=tuv_delta_stats, + operating_mode=operating_mode, + charge_commitment_prev_w=charge_commitment_prev_w, + planner_version=planner_version, + evening_push_ts_override=None, + **relax_carry, + ) + except RuntimeError as exc: + if "Infeasible" in str(exc): + logger.warning( + "two_pass pass2 Infeasible (%s), using pass1 solution", + exc, + ) + if isinstance(snap1.get("inputs"), dict): + snap1["inputs"]["two_pass_pass2_infeasible_used_pass1"] = True + return results1, ms1, snap1 + raise if isinstance(snap2.get("inputs"), dict): snap2["inputs"]["acquisition_pass1_czk_kwh"] = round(acq1, 6) snap2["inputs"]["acquisition_pass2_czk_kwh"] = round(acq2, 6) @@ -2291,6 +2335,12 @@ def solve_dispatch( T = len(slots) if T < 1: raise RuntimeError("solve_dispatch requires at least one slot") + any_relaxed = ( + relaxed_expensive_import + or relaxed_neg_buy_charge + or relaxed_neg_prep_window + or neg_sell_phases_fallback + ) EV = len(vehicles) # počet EV (typicky 2) planner_version_resolved = _planner_engine_version(planner_version) planner_v2 = planner_version_resolved == "v2" @@ -2675,6 +2725,8 @@ def solve_dispatch( evening_push_hysteresis_retained = False push_override_raw: Optional[set[int]] = None push_override_eff: Optional[set[int]] = None + computed_evening_push_ts: set[int] = set() + evening_push_hard_suppressed = False if om == "AUTO": per_slot_discharge_wh_pre = max( float(battery.max_discharge_power_w) @@ -2730,14 +2782,21 @@ def solve_dispatch( evening_push_hysteresis_retained = True else: evening_push_ts = computed_evening_push_ts - if ( - relaxed_expensive_import - or relaxed_neg_buy_charge - or relaxed_neg_prep_window - or neg_sell_phases_fallback - ): - evening_push_ts = set() - evening_push_hysteresis_retained = False + if not evening_push_ts: + evening_push_ts = _evening_push_peak_fallback_indices( + slots, + charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, + min_spread=float(degradation_cost_effective), + discharge_export_ok=discharge_export_slots, + first_neg_sell_idx=first_neg_sell_idx, + kv1_evening_push=kv1_evening_push_pre, + ) + # Tvrdý ge_bat push vypnout jen v prep/fallback retry (ne při rei — jinak zmizí vývoz v špičce). + evening_push_hard_suppressed = bool( + relaxed_neg_prep_window or neg_sell_phases_fallback + ) + else: + evening_push_hard_suppressed = False last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy( slots, first_neg_buy_idx ) @@ -3382,35 +3441,38 @@ def solve_dispatch( profitable_export_ts = profitable_export_ts_pre export_push_w = _battery_export_cap_w(battery, grid) discharge_floor_wh = _planner_discharge_floor_wh(battery) - for t_peak in morning_pre_neg_export_ts: - if t_peak in profitable_export_ts: - if _battery_export_push_defer_to_pv(slots[t_peak]): + # Tvrdý ranní/pre-neg export jen ve strict režimu (jinak ~25 % SoC + neg den → Infeasible). + if not any_relaxed: + for t_peak in morning_pre_neg_export_ts: + if t_peak in profitable_export_ts: + if _battery_export_push_defer_to_pv(slots[t_peak]): + continue + prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] + prob += soc[t_peak] >= float(discharge_floor_wh) + for t_pnd in pre_neg_buy_discharge_ts: + if _battery_export_push_defer_to_pv(slots[t_pnd]): continue - prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] - prob += soc[t_peak] >= float(discharge_floor_wh) - for t_pnd in pre_neg_buy_discharge_ts: - if _battery_export_push_defer_to_pv(slots[t_pnd]): - continue - prob += ge_bat[t_pnd] >= export_push_w * z_export[t_pnd] - for t_empty in pre_neg_buy_empty_ts: - if t_empty in discharge_export_slots: - if _battery_export_push_defer_to_pv(slots[t_empty]): - continue - prob += ge_bat[t_empty] >= export_push_w * z_export[t_empty] + prob += ge_bat[t_pnd] >= export_push_w * z_export[t_pnd] + for t_empty in pre_neg_buy_empty_ts: + if t_empty in discharge_export_slots: + if _battery_export_push_defer_to_pv(slots[t_empty]): + continue + prob += ge_bat[t_empty] >= export_push_w * z_export[t_empty] for t_early in sorted(evening_early_export_penalty_ts): prob += ge_bat[t_early] == 0 - for t_peak in sorted(evening_push_ts): - if t_peak not in discharge_export_slots: - continue - if t_peak in battery_export_defer_pv_ts: - continue - push_floor_w = _evening_push_battery_export_w( - slots[t_peak], battery, grid - ) - if push_floor_w >= GE_MIN_EXPORT_W: - prob += z_export[t_peak] == 1 - prob += ge_bat[t_peak] >= push_floor_w - prob += soc[t_peak] >= float(discharge_floor_wh) + if not evening_push_hard_suppressed: + for t_peak in sorted(evening_push_ts): + if t_peak not in discharge_export_slots: + continue + if t_peak in battery_export_defer_pv_ts: + continue + push_floor_w = _evening_push_battery_export_w( + slots[t_peak], battery, grid + ) + if push_floor_w >= GE_MIN_EXPORT_W: + prob += z_export[t_peak] == 1 + prob += ge_bat[t_peak] >= push_floor_w + prob += soc[t_peak] >= float(discharge_floor_wh) for t_pv in sorted(battery_export_defer_pv_ts): if t_pv in evening_push_ts: continue @@ -4525,15 +4587,20 @@ def solve_dispatch( "evening_push_override_filtered_empty": bool( push_override_raw and not push_override_eff ), - "evening_push_cleared_on_relaxed_prep": bool( - relaxed_neg_prep_window - or relaxed_neg_buy_charge - or relaxed_expensive_import - or neg_sell_phases_fallback + "evening_push_hard_suppressed": bool(evening_push_hard_suppressed), + "evening_push_peak_fallback_used": bool( + om == "AUTO" + and not computed_evening_push_ts + and bool(evening_push_ts) + and not push_override_eff ), "charge_commitment_ignored_on_relaxed": bool( commitment_for_solve is None and charge_commitment_prev_w is not None ), + "morning_pre_neg_export_hard": bool( + om == "AUTO" and not any_relaxed and bool(morning_pre_neg_export_ts) + ), + "any_relaxed_solve": bool(any_relaxed), "kv1_evening_push_morning_peak_rule": _kv1_block_export_fixed_evening_push( grid, purchase_fixed=purchase_fixed_pre, diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index f79bd09..6b148e6 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -2897,14 +2897,14 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertNotIn(0, out) self.assertIn(1, out) - def test_relaxed_expensive_import_clears_evening_push(self) -> None: - """v55: už 1. retry (relaxed_expensive_import) vypne tvrdý evening push.""" + def test_relaxed_expensive_import_keeps_evening_push(self) -> None: + """v57: relaxed_expensive_import nesmí vymazat evening_push (regrese v55).""" prague = ZoneInfo("Europe/Prague") slots = [ PlanningSlot( interval_start=datetime(2026, 5, 30, 22, 0, tzinfo=prague).astimezone(timezone.utc), buy_price=3.0, - sell_price=6.0, + sell_price=9.5, pv_a_forecast_w=0, pv_b_forecast_w=0, load_baseline_w=500, @@ -2921,7 +2921,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): block_export_on_negative_sell=False, purchase_pricing_mode="spot", ) - _results, _ms, snap = solve_dispatch( + results, _ms, snap = solve_dispatch( slots, battery, SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0), @@ -2931,14 +2931,17 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0), SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0), ], - current_soc_wh=16_000.0, + current_soc_wh=50_000.0, current_tuv_temp_c=55.0, relaxed_expensive_import=True, ) - self.assertEqual(snap["inputs"].get("evening_push_ts"), []) + push_iso = snap["inputs"].get("evening_push_ts") or [] + self.assertEqual(len(push_iso), 1) + self.assertFalse(snap["inputs"].get("evening_push_hard_suppressed")) + self.assertLess(results[0].grid_setpoint_w, -1000) - def test_relaxed_neg_prep_clears_computed_evening_push(self) -> None: - """v54: relaxed_neg_prep_window vypne tvrdý ge_bat push (ne jen hysterézní override).""" + def test_relaxed_neg_prep_suppresses_hard_push_only(self) -> None: + """v57: relaxed_neg_prep_window vypne jen tvrdý push, ne seznam slotů.""" prague = ZoneInfo("Europe/Prague") slots = [ PlanningSlot( @@ -2988,8 +2991,9 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): current_tuv_temp_c=55.0, relaxed_neg_prep_window=True, ) - self.assertEqual(snap["inputs"].get("evening_push_ts"), []) - self.assertTrue(snap["inputs"].get("evening_push_cleared_on_relaxed_prep")) + self.assertTrue(snap["inputs"].get("evening_push_hard_suppressed")) + push_iso = snap["inputs"].get("evening_push_ts") or [] + self.assertGreaterEqual(len(push_iso), 1) def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None: """v52: KV1 večer ≥ ranní max (5–11) − degrad; pod prahem ne.""" diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index ac2ab56..544cc6d 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -127,9 +127,13 @@ flowchart TD 9. **v54 — relaxed prep + two-pass:** při **`relaxed_neg_prep_window`** i vypočtený **`evening_push_ts = ∅`**; pass2 two-pass **nepoužívá override** a dědí relax vlajky z pass1. Tag **`2026-05-31-evening-push-relaxed-clear-v54`**. -10. **v55 — jakýkoli relaxed retry:** tvrdý push off už od **`relaxed_expensive_import`**; commitment z minulého plánu ignorovat od **`relaxed_neg_buy_charge`**; comparison v2 **non-fatal**. Tag **`2026-05-31-evening-push-any-relaxed-v55`**. +10. **v55 — jakýkoli relaxed retry:** tvrdý push off už od **`relaxed_expensive_import`**; commitment ignorovat od **`relaxed_neg_buy_charge`**; comparison v2 **non-fatal**. Tag **`2026-05-31-evening-push-any-relaxed-v55`**. -**Funkce:** … Tag: **`2026-05-30-post-push-night-battery-v47`** (spot); KV1 v52 / home-01 v55 viz výše. +11. **v56 — ranní tvrdý export:** `morning_pre_neg_export` / pre-neg discharge **jen strict**; pass2 Infeasible → **pass1**. Tag **`2026-05-31-morning-export-relaxed-v56`**. + +12. **v57 — večerní push po rei:** `relaxed_expensive_import` **nesmí** vymazat `evening_push_ts`; tvrdý `ge_bat` push vypnut jen při `relaxed_neg_prep_window`. Tag **`2026-06-01-evening-push-keep-on-relaxed-import-v57`**. + +**Funkce:** … home-01 **v57**. ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 1150598..903a1b7 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,26 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-01 — home-01: večerní vývoz po relaxed_expensive_import (v57) + +**Problém:** v55 při **jakékoli** relaxed větvi vynulovalo `evening_push_ts` → `evening_early_export_ban` zakázal `ge_bat` i při sell **~9,6 Kč/kWh**; baterie jen samospotřeba, zítra export FVE za **~2 Kč**. + +**Změna (v57):** `evening_push_ts` se **nemazá** při `relaxed_expensive_import` / `relaxed_neg_buy_charge`; tvrdý push jen při `relaxed_neg_prep_window` / `neg_sell_phases_fallback` (`evening_push_hard_suppressed`). Fallback: alespoň jeden večerní peak slot. Snap: `evening_push_hard_suppressed`, `evening_push_peak_fallback_used`. + +Tag **`2026-06-01-evening-push-keep-on-relaxed-import-v57`**. + +--- + +## 2026-05-31 — home-01: ranní tvrdý export + pass2 (v56) + +**Problém:** Po v55 stále **`422 Solver: Infeasible`** u ručního replanu. Příčina: tvrdé **`ge_bat` push** v `morning_pre_neg_export_ts` zůstávalo aktivní i při `relaxed_*` (25 % SoC + neg den 31.5. → nelze exportovat ráno a zároveň splnit prep). Pass2 two-pass mohl spadnout i když pass1 prošel. + +**Změna (v56):** tvrdý ranní/pre-neg export **jen bez** `any_relaxed`; pass2 při Infeasible **vrátí pass1**. Snap: `morning_pre_neg_export_hard`, `any_relaxed_solve`, `two_pass_pass2_infeasible_used_pass1`. + +Tag **`2026-05-31-morning-export-relaxed-v56`**. + +--- + ## 2026-05-31 — home-01: evening push při každém relaxed retry (v55) **Problém:** v54 maže tvrdý push až u `relaxed_neg_prep_window` (3. retry). Retry 1–2 pořád držely **vypočtený** `evening_push_ts` → u ~25 % SoC často stále **Infeasible**. Ruční „Přeplánovat“ navíc spadlo, když **v2 comparison** peer selhal (active v1 prošel). V DB po pádu **žádný `api` run** — scheduler v51/v54 mezitím OK.