diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index c364d60..03961ae 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,13 +71,14 @@ 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-v36b" +PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36d" # 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 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 +# Kotva: SoC na konci večera D−1 a těsně před 1. sell<0 ráno D ≤ reserve_soc. +NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH = 400.0 +NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 55.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 @@ -1244,7 +1245,7 @@ def _evening_discharge_before_neg_day_ts( h = _prague_hour(st) if not (17 <= h <= 23 or _in_night_battery_export_window(st)): continue - if float(st.sell_price) < PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH: + if float(st.sell_price) < 0.0: continue out.add(t) return out @@ -1256,8 +1257,8 @@ def _neg_evening_reserve_soc_anchors( 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). + Kotva SoC ≤ reserve_soc na konci večera D−1 (typ. 23:45) před pražským dnem D s sell<0. + Ranní slot před 1. sell<0 nekotvíme — koliduje s prep rampou v neg okně. """ from datetime import timedelta @@ -1265,13 +1266,14 @@ def _neg_evening_reserve_soc_anchors( getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) ) out: list[tuple[int, float]] = [] + seen: set[int] = set() 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 = [ + eve_slots = [ t for t, st in enumerate(slots) if _prague_calendar_date(st) == prev_date @@ -1280,9 +1282,11 @@ def _neg_evening_reserve_soc_anchors( or _in_night_battery_export_window(st) ) ] - if not anchors: - continue - out.append((max(anchors), reserve_wh)) + if eve_slots: + t_eve = max(eve_slots) + if t_eve not in seen: + out.append((t_eve, reserve_wh)) + seen.add(t_eve) return out @@ -2403,13 +2407,6 @@ def solve_dispatch( for t_ev in sorted(neg_evening_before_neg_ts): if t_ev not in discharge_export_slots: continue - if not _slot_profitable_battery_export( - slots[t_ev], - charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, - min_spread=float(degradation_cost_effective), - fixed_tariff=fixed_tariff_like, - ): - continue sf_ev = pulp.LpVariable( f"neg_eve_prep_discharge_{t_ev}", 0, @@ -2417,16 +2414,22 @@ def solve_dispatch( ) 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, + float(NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH), ) neg_evening_reserve_soc_slack.append((t_anchor, sl, float(reserve_tgt))) + if t_anchor in discharge_export_slots and t_anchor not in { + t for t, _sf, _c in neg_evening_before_neg_shortfall + }: + cap_ev = _battery_export_cap_w(battery, grid) + sf_ra = pulp.LpVariable( + f"neg_eve_reserve_ge_{t_anchor}", + 0, + cap_ev, + ) + neg_evening_before_neg_shortfall.append((t_anchor, sf_ra, cap_ev)) for t in range(T): if not post_neg_pv_topup[t]: continue @@ -3137,7 +3140,10 @@ def solve_dispatch( if ( om == "AUTO" and t in discharge_export_slots - and t in evening_peak_export_ts + and ( + t in evening_peak_export_ts + or t in neg_evening_before_neg_ts + ) ): export_soc_floor_t = float(min_soc_wh) # Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 8503ce6..9a7a3f7 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -4139,7 +4139,74 @@ class NegSellPrepWindowV36Tests(unittest.TestCase): 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) + self.assertGreaterEqual(len(a11), 1) + + def test_evening_reserve_soc_near_reserve_after_discharge(self) -> None: + """v36d: capped slack + večerní ge_bat → SoC u kotvy ≤ reserve + max slack.""" + base = datetime(2026, 6, 10, 10, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone( + timezone.utc + ) + slots: list[PlanningSlot] = [] + for i in range(96): + local = (base + timedelta(minutes=15 * i)).astimezone( + ZoneInfo("Europe/Prague") + ) + h = local.hour + local.minute / 60.0 + if local.date().day == 10: + sell = 3.2 + else: + sell = -0.2 if 9 <= h < 15 else 2.8 + slots.append( + PlanningSlot( + interval_start=base + timedelta(minutes=15 * i), + buy_price=2.0, + sell_price=sell, + pv_a_forecast_w=4000, + pv_b_forecast_w=5000, + load_baseline_w=500, + 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.reserve_soc_wh = 0.20 * bat.usable_capacity_wh + bat.planner_neg_sell_prep_soc_percent = 80.0 + bat.planner_neg_sell_full_soc_tail_slots = 4 + 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=20_000, + max_export_power_w=13_500, + block_export_on_negative_sell=False, + ) + vehicles = [ + 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), + ] + results, _, snap = solve_dispatch( + slots, + bat, + hp, + grid, + [None, None], + vehicles, + 0.55 * bat.soc_max_wh, + 50.0, + operating_mode="AUTO", + ) + self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36d") + anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or [] + self.assertGreaterEqual(len(anchors), 1) + anchor_iso = anchors[-1]["slot"] + anchor_idx = next( + i for i, s in enumerate(slots) if s.interval_start.isoformat() == anchor_iso + ) + cap_wh = float(bat.reserve_soc_wh) + 400.0 + soc_wh = results[anchor_idx].battery_soc_target / 100.0 * bat.soc_max_wh + self.assertLessEqual(soc_wh, cap_wh + 800.0) + eve_slots = snap["inputs"].get("neg_evening_before_neg_slots") or [] + self.assertGreater(len(eve_slots), 8) if __name__ == "__main__": diff --git a/docs/06-open-questions.md b/docs/06-open-questions.md index e727b41..e913523 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-v36b`, kotva **reserve_soc**). +- [x] **v36 prep okno** — oprava **T**, pre-neg **per den** (cushion A+B), večerní výboj před neg dnem; kotva **reserve_soc** večer D−1 (`2026-05-28-neg-prep-window-v36d`, slack max 400 Wh — v36b měl neomezený slack → ~50 % 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 6e74369..34c6cdc 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -11,15 +11,17 @@ 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 / v36b) +## 2026-05-28 — Přípravné okno neg dne (v36 / v36b / v36d) -**Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-prep-window-v36b`. +**Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-prep-window-v36d`. **Změna (v36):** Bod **T**, pre-neg per den (cushion A+B), večerní `neg_evening_before_neg_slots`. -**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. +**Změna (v36b):** Kotva **`neg_evening_reserve_soc_anchors`** — SoC na konci večera D−1 ≤ **`reserve_soc_wh`** (+ slack). **Chyba:** slack horní mez = `soc_max − reserve` → LP nechal ~50 % SoC (penalizace 4 Kč/Wh na obří slack). -**Ověření:** `test_evening_reserve_anchor_before_neg_day`; MCP `solver_params.inputs.neg_evening_reserve_soc_anchors`. +**Změna (v36d):** Slack max **400 Wh**, penalizace **55 Kč/Wh**; večerní `ge_bat` shortfall **bez** filtru profitable export; exportní podlaha u `neg_evening_before_neg_ts` = **`min_soc`** (ne `arb_base`). Kotva jen **večer D−1** (ranní slot před 1. `sell<0` nekoliduje s prep rampou). + +**Ověření:** `NegSellPrepWindowV36Tests` (vč. `test_evening_reserve_soc_near_reserve_after_discharge`); MCP: `planner_build_tag` = v36d, `battery_soc_target_pct` u kotvy ≤ ~22 % (reserve 20 % + slack). ## 2026-05-28 — Rampa SoC z PV B, bod T (v35)