diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 37be1eb..e896865 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-06-06-home01-degraded-night-guard-v3" +PLANNER_BUILD_TAG = "2026-06-06-home01-degraded-night-guard-v4" SOLVER_RELAX_STEPS: tuple[str, ...] = ( "strict", "relaxed_expensive_import", @@ -1958,6 +1958,35 @@ def _degraded_relaxed_night_self_consume_indices( return out +def _degraded_relaxed_evening_export_to_reserve_indices( + slots: list[PlanningSlot], + *, + observed_soc_wh: float, + reserve_soc_wh: float, + first_neg_buy_idx: int | None, +) -> set[int]: + """ + Nouzový solve: večer D0 smí vývoz bat k reserve_soc před dnem s buy<0 (headroom na zítra). + Jen kalendářní večer 17–22h — po 22h už noc (dům z baterie, ne držet kvůli exportu). + """ + if first_neg_buy_idx is None or first_neg_buy_idx <= 0: + return set() + if observed_soc_wh <= float(reserve_soc_wh) + 500.0: + return set() + replan_day = _prague_calendar_date(slots[0]) + out: set[int] = set() + for t, s in enumerate(slots): + if _prague_calendar_date(s) != replan_day: + continue + h = _prague_hour(s) + if h < NIGHT_EXPORT_EVENING_START_HOUR or h > 22: + continue + if float(s.sell_price) < 0.0: + continue + out.add(t) + return out + + def _post_evening_push_night_self_consume_indices( slots: list[PlanningSlot], evening_push_ts: set[int], @@ -3046,6 +3075,7 @@ def solve_dispatch( night_self_consume_discourage_ts: set[int] = set() post_evening_push_night_ts: set[int] = set() degraded_relaxed_night_ts: set[int] = set() + degraded_evening_export_ts: set[int] = set() evening_push_hysteresis_retained = False push_override_raw: Optional[set[int]] = None push_override_eff: Optional[set[int]] = None @@ -3215,6 +3245,15 @@ def solve_dispatch( battery_export_defer_pv_ts = set() evening_push_hard_suppressed = True degraded_relaxed_night_ts = _degraded_relaxed_night_self_consume_indices(slots) + reserve_wh_degraded = float( + getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) + ) + degraded_evening_export_ts = _degraded_relaxed_evening_export_to_reserve_indices( + slots, + observed_soc_wh=observed_soc_wh, + reserve_soc_wh=reserve_wh_degraded, + first_neg_buy_idx=first_neg_buy_idx, + ) night_self_consume_discourage_ts |= degraded_relaxed_night_ts post_evening_push_night_ts |= degraded_relaxed_night_ts pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh( @@ -3325,6 +3364,7 @@ def solve_dispatch( neg_buy_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pre_neg_batt_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pre_neg_buy_empty_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] + degraded_evening_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] fixed_tariff_like = fixed_tariff_like_pre block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False)) if om == "AUTO": @@ -3373,6 +3413,15 @@ def solve_dispatch( continue sf_e = pulp.LpVariable(f"pre_neg_buy_empty_sf_{t_empty}", 0, export_cap_w) pre_neg_buy_empty_shortfall.append((t_empty, sf_e, export_cap_w)) + if relaxed_solver_masks and degraded_evening_export_ts: + deg_cap = _battery_export_cap_w(battery, grid) + for t_deg in sorted(degraded_evening_export_ts): + sf_deg = pulp.LpVariable( + f"deg_eve_reserve_export_{t_deg}", + 0, + deg_cap, + ) + degraded_evening_export_shortfall.append((t_deg, sf_deg, deg_cap)) if not relaxed_neg_buy_charge: neg_buy_slot_indices = [ t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 @@ -3681,6 +3730,10 @@ def solve_dispatch( sf * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in peak_export_shortfall ) + + pulp.lpSum( + sf * NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 + for _t, sf, _cap in degraded_evening_export_shortfall + ) + pulp.lpSum( sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in pv_charge_shortfall @@ -3817,6 +3870,8 @@ def solve_dispatch( prob += sf >= cap_w - ge_pv[t_sf] for t_sf, sf, cap_w in neg_evening_before_neg_shortfall: prob += sf >= cap_w - ge_bat[t_sf] + for t_sf, sf, cap_w in degraded_evening_export_shortfall: + prob += sf >= cap_w - ge_bat[t_sf] for t_sl, sl, reserve_tgt in neg_evening_reserve_soc_slack: prob += soc[t_sl] <= float(reserve_tgt) + sl preneg_export_min_soc_wh = float(min_soc_wh) + max( @@ -3878,13 +3933,23 @@ def solve_dispatch( continue prob += ge_bat[t_pv] == 0 prob += z_export[t_pv] == 0 - # Nouzový relax: spot v noci neexportovat baterii za ~2,5 Kč (žádný tvrdý evening dump). + # Nouzový relax: v noci jen vývoz k reserve večer D0; jinak ge_bat=0. if relaxed_solver_masks and not purchase_fixed_pre: + reserve_wh_blk = float( + getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) + ) for t_blk in range(T): + if t_blk in degraded_evening_export_ts: + continue if not _in_night_battery_export_window(slots[t_blk]): continue prob += ge_bat[t_blk] == 0 prob += z_export[t_blk] == 0 + for t_ev in sorted(degraded_evening_export_ts): + m_soc_deg = float(battery.usable_capacity_wh) + prob += soc[t_ev] >= float(reserve_wh_blk) - m_soc_deg * ( + 1 - z_export[t_ev] + ) # Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push). if ( last_pos_sell_pre_neg_buy is not None @@ -4500,25 +4565,37 @@ def solve_dispatch( expensive_import_slot = expensive_import_slot or ( 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í baseload jen mimo night_self_consume (v46). - night_self_consume_slot = ( - om == "AUTO" - and ( - t in night_self_consume_discourage_ts - or t in post_evening_push_night_ts - ) + if expensive_import_slot and buy_t >= 0.0: + force_night_self_consume = ( + relaxed_solver_masks + and t in degraded_relaxed_night_ts + and t not in degraded_evening_export_ts ) - 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] + if force_night_self_consume or ( + expensive_import_slot and t not in charge_slots + ): + # 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 + or t in post_evening_push_night_ts + or force_night_self_consume + ) ) + 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 ( + force_night_self_consume + or (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] + ) # Anti souběžný vývoz FVE + významný import (mikrocyklus). if buy_t > sell_t + min_spread and pv_surplus_w > 0: prob += ge_pv[t] <= pv_surplus_w @@ -5191,6 +5268,10 @@ def solve_dispatch( slots[i].interval_start.isoformat() for i in sorted(degraded_relaxed_night_ts) ], + "degraded_evening_export_ts": [ + slots[i].interval_start.isoformat() + for i in sorted(degraded_evening_export_ts) + ], }, "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 34b1bac..8681beb 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -3698,8 +3698,8 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertLess(results[0].grid_setpoint_w, -500) self.assertLess(results[0].battery_soc_target, 70.0) - def test_degraded_relaxed_solver_no_night_dump_and_self_consume(self) -> None: - """relaxed_solver_masks: žádný večerní dump za ~2,5 Kč; noc dům z baterie, ne import ~4 Kč.""" + def test_degraded_relaxed_solver_evening_to_reserve_and_night_self_consume(self) -> None: + """relaxed_solver_masks: večer vývoz k ~20 %, noc dům z baterie (ne import ~4 Kč).""" prague = ZoneInfo("Europe/Prague") base = datetime(2026, 6, 6, 21, 30, tzinfo=prague).astimezone(timezone.utc) rows: list[tuple[float, float, int]] = [ @@ -3723,7 +3723,10 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): buy, sell, load = rows[i] pv_a, pv_b = 0, 0 elif local.hour >= 5 and local.hour < 12: - buy, sell, load, pv_a, pv_b = 0.5, -0.3, 800, 2000, 2500 + if 8 <= local.hour < 11: + buy, sell, load, pv_a, pv_b = -0.4, -0.3, 800, 2000, 2500 + else: + buy, sell, load, pv_a, pv_b = 0.5, -0.3, 800, 2000, 2500 else: buy, sell, load, pv_a, pv_b = 3.0, 2.0, 500, 0, 0 slots.append( @@ -3742,6 +3745,8 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): ) bat = _battery(uc_wh=64_000.0, arb_pct=20.0, terminal_soc_value_factor=0.9) bat.planner_neg_sell_prep_soc_percent = 100.0 + bat.max_charge_power_w = 18_000 + bat.max_discharge_power_w = 18_000 hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0) grid = SimpleNamespace( max_import_power_w=17_000, @@ -3753,7 +3758,7 @@ 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), ] - soc = 0.71 * bat.soc_max_wh + soc = 0.56 * bat.soc_max_wh results, _ms, snap = solve_dispatch( slots, bat, @@ -3774,13 +3779,21 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): ) inp = snap.get("inputs") or {} self.assertTrue(inp.get("relaxed_solver_masks")) - self.assertGreater(len(inp.get("degraded_relaxed_night_ts") or []), 0) - for i, r in enumerate(results[:12]): - self.assertGreaterEqual(r.battery_soc_target, 10.0) - if i < 4: - self.assertGreater(r.grid_setpoint_w, -500, msg=f"slot {i} evening dump") - if 8 <= i <= 11 and rows[i][0] > 3.0: - self.assertLessEqual(r.grid_setpoint_w, rows[i][2] + 50, msg=f"slot {i} grid import") + self.assertGreater(len(inp.get("degraded_evening_export_ts") or []), 0) + evening_soc_end = min(r.battery_soc_target for r in results[:8]) + self.assertLess(evening_soc_end, 55.0) + for i in range(8, 12): + if rows[i][0] > 3.0: + self.assertLessEqual( + results[i].grid_setpoint_w, + 50, + msg=f"slot {i} should not import for baseload", + ) + self.assertLess( + results[i].battery_setpoint_w, + -100, + msg=f"slot {i} should discharge for house", + ) 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/planning-changelog.md b/docs/planning-changelog.md index 6449447..9c5acc3 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -17,13 +17,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen - Nový retry **`relaxed_pos_sell_ge_block`** (+ **`relaxed_solver_masks`** nouzový) v `SOLVER_RELAX_STEPS`. - **v2:** two-pass pass2 dědí všechny relax flagy; při pass2 Infeasible fallback na pass1; override push zrušen už od `relaxed_neg_prep_hold_only`. - **v3 (`degraded-night-guard-v3`):** v **`relaxed_solver_masks`** — spot **bez nočního/večerního `ge_bat` exportu**; **`_degraded_relaxed_night_self_consume_indices`** + tvrdý expensive-import guard (dům z baterie až **`min_soc`**, ne import za ~4 Kč); exportní podlaha SoC ≥ **`reserve_soc`**. +- **v4 (`degraded-night-guard-v4`):** oprava v3 — `charge_slots=all` obcházela expensive-import guard → import za ~4 Kč. **Večer D0 (17–22h):** vývoz k **`reserve_soc`** (`degraded_evening_export_ts` + shortfall). **Po 22h / půlnoc:** tvrdý **`bd ≥ load`** (`force_night_self_consume`) i když `t ∈ charge_slots`. Večerní export sloty **ne** sahají do 23h+ (jinak blokují noc). -**Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, testy `test_home01_late_replan_high_soc_realistic_masks`, `test_degraded_relaxed_solver_no_night_dump_and_self_consume`. +**Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, testy `test_home01_late_replan_high_soc_realistic_masks`, `test_degraded_relaxed_solver_evening_to_reserve_and_night_self_consume`. **Ověření:** - `PYTHONPATH=backend python3 scripts/repro_home01_23840.py` → `OK two_pass` - `pytest backend/tests/test_planning_dispatch_milp.py -k "home01_late_replan or degraded_relaxed"` -- Po deployi: aktivní run `planner_build_tag` končí **`degraded-night-guard-v3`**; při `relax_chain` obsahujícím `relaxed_solver_masks` večer **bez** masivního exportu, noc **bez** importu pro baseload nad ~load setpoint. +- Po deployi: aktivní run `planner_build_tag` končí **`degraded-night-guard-v4`**; při `relax_chain` obsahujícím `relaxed_solver_masks`: večer vývoz k ~**20 %**, noc **bez** importu pro baseload, ráno headroom na FVE + **`buy<0`**. ---