From 4e5de5df909089d6cf6910117b64451d910b7ed7 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Wed, 27 May 2026 07:45:50 +0200 Subject: [PATCH] dalsi oprava --- backend/services/planning_engine.py | 61 +++++++++---- backend/tests/test_planning_dispatch_milp.py | 92 +++++++++++++++++++- docs/planning-changelog.md | 8 ++ 3 files changed, 144 insertions(+), 17 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index eda2bfb..8d0d27d 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-28-neg-prep-window-v36f" +PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36g" # 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). @@ -1221,6 +1221,20 @@ def _pre_neg_pv_export_slot_indices( return out +def _discharge_before_first_neg_sell_ts( + slots: list[PlanningSlot], + first_neg_sell_idx: int | None, +) -> set[int]: + """Všechny kladné-sell sloty před 1. sell<0 (funguje i v rolling bez D−1 večera v horizontu).""" + if first_neg_sell_idx is None or first_neg_sell_idx <= 0: + return set() + return { + t + for t in range(first_neg_sell_idx) + if float(slots[t].sell_price) >= 0.0 + } + + def _evening_discharge_before_neg_day_ts( slots: list[PlanningSlot], neg_sell_day_meta: dict[str, Any], @@ -1257,8 +1271,9 @@ def _neg_evening_reserve_soc_anchors( battery: Any, ) -> list[tuple[int, float]]: """ - 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ě. + Kotvy SoC ≤ reserve_soc před neg oknem: + - večer D−1 (23:45) pokud je v horizontu, + - slot těsně před 1. sell<0 (rolling: ráno bez včerejška v okně). """ from datetime import timedelta @@ -1287,6 +1302,14 @@ def _neg_evening_reserve_soc_anchors( if t_eve not in seen: out.append((t_eve, reserve_wh)) seen.add(t_eve) + if first_neg > 0: + t_pre = first_neg - 1 + if ( + t_pre not in seen + and float(slots[t_pre].sell_price) >= 0.0 + ): + out.append((t_pre, reserve_wh)) + seen.add(t_pre) return out @@ -2112,6 +2135,10 @@ def solve_dispatch( slots, neg_sell_day_meta, ) + neg_evening_before_neg_ts |= _discharge_before_first_neg_sell_ts( + slots, + first_neg_sell_idx, + ) neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors( slots, neg_sell_day_meta, @@ -3297,6 +3324,15 @@ def solve_dispatch( # FVE export před sell<0 jen pokud forecast v sell<0 okně pokryje dobítí (v33). allow_pre_neg_pv_export = t in pre_neg_pv_export_ts pv_store_val = _pv_store_value_czk_kwh(s, min_spread) + fixed_pre_neg_pv_export = ( + purchase_fixed_pre + and sell_t >= 0.0 + and pv_surplus_w > 500.0 + and ( + first_neg_sell_idx is None + or t < first_neg_sell_idx + ) + ) skip_pv_store_block = ( float(s.pv_b_forecast_w) > 0 and not getattr(grid, "block_export_on_negative_sell", False) @@ -3312,25 +3348,18 @@ def solve_dispatch( not purchase_fixed_pre and sell_t >= 0 and pv_surplus_w > 500 - ) or ( - # Fixed (BA81/KV1): před prvním sell<0 a sell≥0 neblokovat ge_pv — export A+B do site, - # ne curtail (fixed_pv_b_export_cap pokrývá jen MI / pole B). - purchase_fixed_pre - and sell_t >= 0.0 - and pv_surplus_w > 500.0 - and ( - first_neg_sell_idx is None - or t < first_neg_sell_idx - ) - ) - # BA81: export pole B jen při kladném sell (po sell<0 jinak ge==0 výše). + ) or fixed_pre_neg_pv_export + # BA81: export pole B jen při kladném sell mimo pre-neg okno (jinak jen ge_pv≤pv_b → curtail A). fixed_pv_b_export_cap = ( purchase_fixed_pre and float(s.pv_b_forecast_w) > 0 and not getattr(grid, "block_export_on_negative_sell", False) and sell_t >= 0 + and not fixed_pre_neg_pv_export ) - if fixed_pv_b_export_cap: + if fixed_pre_neg_pv_export: + prob += ge_pv[t] <= max(0.0, pv_surplus_w) + elif fixed_pv_b_export_cap: if z_gen_cutoff is not None: prob += ge_pv[t] <= float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t]) else: diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 00747d7..60e47b6 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -3206,6 +3206,96 @@ class PreNegativeSellExportTests(unittest.TestCase): self.assertGreater(neg.battery_setpoint_w, 500, "záporný sell: PV do baterie") self.assertEqual(neg.export_mode, "NONE") + def test_ba81_fixed_morning_exports_pv_a_not_curtail(self) -> None: + """BA81: před sell<0 export celého přebytku FVE, ne jen MI (pv_b).""" + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 5, 27, 7, 30, tzinfo=prague) + slots: list[PlanningSlot] = [] + for i in range(12): + sell = 3.2 if i < 8 else -0.2 + slots.append( + PlanningSlot( + interval_start=(base + timedelta(minutes=15 * i)).astimezone(timezone.utc), + buy_price=3.088, + sell_price=sell, + pv_a_forecast_w=5000, + pv_b_forecast_w=700, + load_baseline_w=200, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=False, + charge_acquisition_buy_czk_kwh=3.088, + future_sell_opportunity_czk_kwh=6.5, + ) + ) + battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0) + battery.max_charge_power_w = 6250 + 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, + max_export_power_w=8000, + block_export_on_negative_sell=False, + purchase_pricing_mode="fixed", + ) + 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), + ] + res, _, _ = solve_dispatch( + slots, battery, hp, grid, [None, None], vehicles, + 0.95 * battery.soc_max_wh, 50.0, operating_mode="AUTO", + ) + r0 = res[0] + self.assertLess(r0.pv_a_curtailed_w, 500, "pole A nesmí jít do curtail při sell>0 před neg") + self.assertLess(r0.grid_setpoint_w, -4000, "export přebytku A+B do site") + + def test_rolling_horizon_drains_to_reserve_before_first_neg(self) -> None: + """Rolling bez D−1 večera: výboj před 1. sell<0 na reserve (+ slack).""" + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 5, 27, 7, 0, tzinfo=prague) + slots: list[PlanningSlot] = [] + for i in range(16): + local = base + timedelta(minutes=15 * i) + sell = 3.0 if i < 10 else -0.2 + slots.append( + PlanningSlot( + interval_start=local.astimezone(timezone.utc), + buy_price=5.0, + sell_price=sell, + pv_a_forecast_w=3000, + pv_b_forecast_w=1500, + load_baseline_w=500, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=True, + ) + ) + bat = NegSellSocPhaseTests._phase_battery() + bat.reserve_soc_wh = 0.20 * bat.usable_capacity_wh + 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), + ] + res, _, snap = solve_dispatch( + slots, bat, hp, grid, [None, None], vehicles, + 0.55 * bat.soc_max_wh, 50.0, operating_mode="AUTO", + ) + anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or [] + self.assertGreaterEqual(len(anchors), 1) + anchor_iso = anchors[-1]["slot"] + 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 = res[idx].battery_soc_target / 100.0 * bat.soc_max_wh + self.assertLessEqual(soc_wh, cap_wh + 800.0) + def test_kv1_evening_battery_push_when_sell_below_fixed_buy(self) -> None: """KV1: večerní sell < fixní buy — přesto vývoz bat (ne jen jeden peak slot).""" prague = ZoneInfo("Europe/Prague") @@ -4248,7 +4338,7 @@ class NegSellPrepWindowV36Tests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36e") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36g") anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or [] self.assertGreaterEqual(len(anchors), 1) anchor_iso = anchors[-1]["slot"] diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index bc716c6..698b013 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -11,6 +11,14 @@ 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 — BA81 export A+B + rolling drain (v36g) + +**Problém (v36f):** BA81 — `skip_pv_store` nestačil: `fixed_pv_b_export_cap` držel `ge_pv ≤ pv_b` → curtail pole A. home-01 rolling — prázdné `neg_evening_*` (D−1 večer mimo horizont), SoC ~29 % místo ~20 % před `sell<0`. + +**Změna (v36g):** Fixed pre-neg: `ge_pv ≤ pv_surplus` (A+B). Spot neg: kotva i na `first_neg−1` + výboj ve **všech** kladných sell slotech před 1. `sell<0` (ne jen D−1 večer). + +**Ověření:** `test_ba81_fixed_morning_exports_pv_a_not_curtail`, `test_rolling_horizon_drains_to_reserve_before_first_neg`; tag **v36g**. + ## 2026-05-28 — Fixed tarif: export FVE před sell<0 (v36f) **Problém:** BA81 (fixed, sell>3 Kč ráno): plán **curtail** PV A (~3 kW) + export jen **~600 W** (`ge_pv` jen přes pole B). Střídač reálně valí celou FVE — ekonomicky správně, ale plán nesedí. Příčina: `ge_pv=0` při `sell < future_sell` (pv_store); `fixed_pv_b_export_cap` uvolní jen MI.