diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 336beb0..c364d60 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,11 +71,13 @@ 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-28-neg-prep-window-v36" +PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36b" # Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0 -# Večer před neg dnem: výboj směrem k soc_need na začátku zítřejšího sell<0 okna. -NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 70.0 +# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat). +NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0 +# Kotva: SoC na konci večera D−1 ≤ reserve_soc (+ slack) před ranním sell<0 dnem D. +NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 4.0 # Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl. PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15 PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0 @@ -1248,6 +1250,42 @@ def _evening_discharge_before_neg_day_ts( return out +def _neg_evening_reserve_soc_anchors( + slots: list[PlanningSlot], + neg_sell_day_meta: dict[str, Any], + battery: Any, +) -> list[tuple[int, float]]: + """ + Poslední večerní slot kalendářního dne D−1 před dnem D s sell<0: cíl SoC ≤ reserve_soc. + Headroom pro ranní nabíjení z FVE / levného spotu v neg okně (ne držet 60 %+ přes noc). + """ + from datetime import timedelta + + reserve_wh = float( + getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) + ) + out: list[tuple[int, float]] = [] + for day_info in neg_sell_day_meta.get("days") or []: + first_neg = int(day_info.get("first_neg_idx", -1)) + if first_neg < 0 or first_neg >= len(slots): + continue + neg_date = _prague_calendar_date(slots[first_neg]) + prev_date = neg_date - timedelta(days=1) + anchors = [ + t + for t, st in enumerate(slots) + if _prague_calendar_date(st) == prev_date + and ( + 17 <= _prague_hour(st) <= 23 + or _in_night_battery_export_window(st) + ) + ] + if not anchors: + continue + out.append((max(anchors), reserve_wh)) + return out + + MORNING_PRENEG_START_HOUR = 5 MORNING_PRENEG_END_HOUR = 11 @@ -2043,6 +2081,7 @@ def solve_dispatch( pre_neg_cushion_by_day: dict[str, bool] = {} pre_neg_pv_export_ts: set[int] = set() neg_evening_before_neg_ts: set[int] = set() + neg_evening_reserve_anchors: list[tuple[int, float]] = [] if om == "AUTO" and not purchase_fixed_pre and neg_sell_phases_en: pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle( slots, @@ -2056,6 +2095,11 @@ def solve_dispatch( slots, neg_sell_day_meta, ) + neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors( + slots, + neg_sell_day_meta, + battery, + ) elif om == "AUTO" and not purchase_fixed_pre: legacy_ok = bool( first_neg_sell_idx is not None @@ -2247,6 +2291,7 @@ def solve_dispatch( pre_neg_pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pre_neg_pv_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] neg_evening_before_neg_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] + neg_evening_reserve_soc_slack: list[tuple[int, pulp.LpVariable, float]] = [] neg_sell_bat_dump_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] neg_sell_soc_underfill: list[tuple[int, pulp.LpVariable, float]] = [] neg_buy_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] @@ -2371,6 +2416,17 @@ def solve_dispatch( export_cap_evening, ) neg_evening_before_neg_shortfall.append((t_ev, sf_ev, export_cap_evening)) + for t_anchor, reserve_tgt in neg_evening_reserve_anchors: + slack_cap = max( + 0.0, + float(battery.soc_max_wh) - float(reserve_tgt), + ) + sl = pulp.LpVariable( + f"neg_eve_reserve_soc_slack_{t_anchor}", + 0, + slack_cap, + ) + neg_evening_reserve_soc_slack.append((t_anchor, sl, float(reserve_tgt))) for t in range(T): if not post_neg_pv_topup[t]: continue @@ -2599,6 +2655,10 @@ def solve_dispatch( / 1000.0 for _t, sf, _cap in neg_evening_before_neg_shortfall ) + + pulp.lpSum( + sl * NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH + for _t, sl, _tgt in neg_evening_reserve_soc_slack + ) + pulp.lpSum( bc_pv[t] * PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH @@ -2701,6 +2761,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_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( float(battery.max_discharge_power_w) * float(battery.discharge_efficiency) @@ -3543,6 +3605,11 @@ def solve_dispatch( "neg_evening_before_neg": ( t in neg_evening_before_neg_ts if neg_sell_phases_en else None ), + "neg_evening_reserve_anchor": ( + any(t == ta for ta, _ in neg_evening_reserve_anchors) + if neg_sell_phases_en + else None + ), } ) tgt_s = st.safety_soc_target_wh if daytime_en else None @@ -3663,6 +3730,13 @@ def solve_dispatch( slots[i].interval_start.isoformat() for i in sorted(neg_evening_before_neg_ts) ], + "neg_evening_reserve_soc_anchors": [ + { + "slot": slots[t_a].interval_start.isoformat(), + "target_reserve_soc_wh": float(tgt_wh), + } + for t_a, tgt_wh in neg_evening_reserve_anchors + ], "neg_sell_prep_window_v36": bool(neg_sell_phases_en), "neg_sell_day_pv_usable_wh": ( _neg_sell_day_pv_usable_wh( diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index c09c180..8503ce6 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -19,7 +19,9 @@ from services.planning_engine import ( _in_night_battery_export_window, _neg_sell_day_phases, _neg_sell_phases_enabled, + _neg_evening_reserve_soc_anchors, _pre_neg_pv_export_bundle, + _prague_calendar_date, _pre_neg_buy_soc_ceiling_wh, _pre_neg_peak_sell_idx, _pre_neg_pv_export_forecast_cushion_ok, @@ -4093,6 +4095,52 @@ class NegSellPrepWindowV36Tests(unittest.TestCase): "morning before 2nd neg day should allow pre-neg export", ) + def test_evening_reserve_anchor_before_neg_day(self) -> None: + base = datetime(2026, 6, 10, 10, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone( + timezone.utc + ) + slots: list[PlanningSlot] = [] + for i in range(120): + local = (base + timedelta(minutes=15 * i)).astimezone( + ZoneInfo("Europe/Prague") + ) + h = local.hour + local.minute / 60.0 + if local.date().day == 10: + sell = -0.2 if h >= 14 else 2.5 + elif local.date().day == 11: + sell = -0.2 if 9 <= h < 15 else 2.8 + else: + sell = 2.5 + slots.append( + PlanningSlot( + interval_start=base + timedelta(minutes=15 * i), + buy_price=2.0, + sell_price=sell, + pv_a_forecast_w=3000, + pv_b_forecast_w=3000, + load_baseline_w=1500, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=True, + ) + ) + bat = _battery(uc_wh=64_000.0, max_pct=95.0, arb_pct=20.0) + bat.planner_neg_sell_prep_soc_percent = 80.0 + bat.planner_neg_sell_full_soc_tail_slots = 4 + _ph, _tg, _w, meta = _neg_sell_day_phases(slots, bat) + anchors = _neg_evening_reserve_soc_anchors(slots, meta, bat) + self.assertGreaterEqual(len(anchors), 1) + t_a, tgt = anchors[0] + self.assertAlmostEqual(tgt, bat.reserve_soc_wh, delta=100.0) + self.assertEqual(_prague_calendar_date(slots[t_a]).day, 10) + # Kotva pro den 11: večer 10.6. (i když odpoledne 10.6. už bylo sell<0). + if len(meta["days"]) >= 2: + day11_first = int(meta["days"][1]["first_neg_idx"]) + prev = _prague_calendar_date(slots[day11_first]) - timedelta(days=1) + a11 = [(t, w) for t, w in anchors if _prague_calendar_date(slots[t]) == prev] + self.assertEqual(len(a11), 1) + if __name__ == "__main__": unittest.main() diff --git a/docs/06-open-questions.md b/docs/06-open-questions.md index eee56e6..e727b41 100644 --- a/docs/06-open-questions.md +++ b/docs/06-open-questions.md @@ -38,7 +38,7 @@ Kompletní návrh: [`docs/04-modules/planning-neg-sell-strategy.md`](04-modules/ - [x] **TUV — večerní doklep** — **19:00** Europe/Prague (rozhodnuto 2026-05); implementace v **v36**; doplnit `tuv_comfort_temp_c` / `tuv_preheat_temp_c` do konfigurace site. - [ ] **Vizualizace flexibilních zátěží v UI** — **probrat a navrhnout před v37+** (neimplementovat bazén/TČ sink do FE naslepo). Viz [`planning-neg-sell-strategy.md` § 9.1](04-modules/planning-neg-sell-strategy.md). Návrhy k diskusi: pásma dne (pre-neg / sell<0 / bod **T**), rozpočet hodin bazénu vs. `E_surplus_after_t`, slotový rozpad `hp` / EV / (budoucí pool), srovnání běhů plánu. - [x] **v35 implementace** — rampa B, **t_detach**, `E_surplus_after_t` (`2026-05-28-neg-sell-b-ramp-v35`). -- [x] **v36 prep okno** — oprava **T**, pre-neg **per den** (cushion A+B), večerní výboj před neg dnem (`2026-05-28-neg-prep-window-v36`). +- [x] **v36 prep okno** — oprava **T**, pre-neg **per den** (cushion A+B), večerní výboj před neg dnem (`2026-05-28-neg-prep-window-v36b`, kotva **reserve_soc**). - [ ] **v36 termika** — blok TČ v pre-neg exportu, TUV po **T**, doklep **19:00** (zatím jen plán). #### Roadmap (pořadí) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 9bcd2f9..6e74369 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -11,13 +11,15 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen **Rozhodnutí home-01** (souhrn v [`docs/06-open-questions.md`](06-open-questions.md)): rampa/**T** odvozené z PV B (bez fixních 80 % v LP); TČ ne v pre-neg exportu; bazén min 4 h/den + Shelly; spirála Loxone; **workshop UI flex zátěží před v37** (§ 9.1 strategie). -## 2026-05-28 — Přípravné okno neg dne (v36) +## 2026-05-28 — Přípravné okno neg dne (v36 / v36b) -**Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-prep-window-v36`. +**Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-prep-window-v36b`. -**Změna:** (1) **Bod T** — oprava: `t_detach` až když `soc_need[t] ≥ 85 % soc_max` a suffix B pokryje zbytek (ne hned na 1. neg slotu). (2) **Pre-neg per pražský den** — `_pre_neg_pv_export_bundle`, cushion **A+B** v neg okně dne; ráno před každým `sell<0` dnem export FVE pokud cushion OK. (3) **Večer D−1** — `_evening_discharge_before_neg_day_ts` + výboj před neg dnem. +**Změna (v36):** Bod **T**, pre-neg per den (cushion A+B), večerní `neg_evening_before_neg_slots`. -**Ověření:** `NegSellPrepWindowV36Tests`, `test_t_detach_not_first_neg_on_long_sunny_day`; MCP `solver_params.inputs.pre_neg_cushion_by_day`, `t_detach_idx` pro 27. 5. +**Změna (v36b):** Kotva **`neg_evening_reserve_soc_anchors`** — SoC na konci večera D−1 ≤ **`reserve_soc_wh`** (+ slack, penalizace 4 Kč/Wh). Důvod: evening push jen na peak sell nechal ~60 % SoC přes noc místo headroomu pro ranní neg okno. + +**Ověření:** `test_evening_reserve_anchor_before_neg_day`; MCP `solver_params.inputs.neg_evening_reserve_soc_anchors`. ## 2026-05-28 — Rampa SoC z PV B, bod T (v35)