From b1e124416d5950db3b3a51a51b5d8a2bf4879e68 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 26 Apr 2026 01:15:31 +0200 Subject: [PATCH] fix solver- vybiti do site pred zapornym nakupem --- backend/services/planning_engine.py | 12 ++- backend/tests/test_planning_dispatch_milp.py | 78 +++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 18f4b55..8150eb5 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -33,7 +33,9 @@ TERMINAL_SOC_VALUE_FACTOR = 0.9 INTERVAL_H = 0.25 # 15 minut v hodinách CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A SOLVER_TIME_LIMIT = 10 # sekund -# MILP: jakýkoli významný výkon exportu ge (W) ⇒ koncové soc[t] ≥ arb_base_wh (rezerva z DB) +# MILP: významný export ge (W) ⇒ koncové soc[t] ≥ podlaha; mimo arbitrážní relax je to arb_base_wh +# (rezerva z DB). Při relaxaci spodku před extrémně záporným buy je podlaha soc_min_series[t] +# (planner floor), jinak by šlo jen do zátěže a nešlo by „vypustit do sítě“ před levným nákupem. GE_MIN_EXPORT_W = 1.0 CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru @@ -518,7 +520,13 @@ def solve_dispatch( m_soc_bigm = float(battery.usable_capacity_wh) prob += ge[t] <= m_ge * z_export[t] prob += ge[t] >= GE_MIN_EXPORT_W * z_export[t] - prob += soc[t] >= arb_base_wh - m_soc_bigm * (1 - z_export[t]) + # Bez relaxace: export končí ≥ rezerva (arb_base). Při relaxaci (_soc_min_wh_series pod min_soc) + # sladit s LP spodkem — jinak z_export vynutil arb_base a blokoval vývoz k planner floor. + if soc_min_series[t] < min_soc_wh - 1e-3: + export_soc_floor_t = float(soc_min_series[t]) + else: + export_soc_floor_t = float(arb_base_wh) + prob += soc[t] >= export_soc_floor_t - m_soc_bigm * (1 - z_export[t]) # EV – limity a připojení for e in range(EV): diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 2d70ccf..cf74001 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -208,7 +208,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): self.assertGreaterEqual(results[0].grid_setpoint_w, 0) def test_export_implies_end_soc_at_least_reserve(self) -> None: - """Při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB).""" + """Bez arbitrážní relaxace: při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB).""" slots = [ _slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0), _slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0), @@ -254,6 +254,82 @@ class PlanningDispatchMilpTests(unittest.TestCase): msg="export slot must end at or above reserve SoC", ) + def test_export_before_extreme_negative_buy_can_end_below_reserve(self) -> None: + """ + Při relaxovaném soc_min (záporný buy v lookahead) smí významný export skončit u planner floor, + ne u provozní rezervy — jinak nejde ráno vypustit do sítě a nachystat kapacitu před levným nákupem. + """ + base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc) + s0 = PlanningSlot( + interval_start=base, + buy_price=2.5, + sell_price=2.0, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=True, + allow_discharge_export=True, + ) + s1 = PlanningSlot( + interval_start=base + timedelta(minutes=15), + buy_price=-12.0, + sell_price=-0.5, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=True, + allow_discharge_export=True, + ) + slots = [s0, s1] + battery = _battery(uc_wh=10_000.0, min_pct=10.0, arb_pct=20.0) + battery.planner_extreme_buy_threshold_czk_kwh = -2.0 + battery.planner_discharge_floor_percent = 5.0 + battery.max_charge_power_w = 50_000 + battery.max_discharge_power_w = 50_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=50_000, max_export_power_w=50_000) + 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, + ), + ] + soc0 = 0.88 * battery.usable_capacity_wh + results, _ms = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + tuv_delta_stats=None, + operating_mode="AUTO", + ) + self.assertEqual(len(results), 2) + if results[0].grid_setpoint_w < 0: + self.assertLess( + results[0].battery_soc_target, + 19.0, + msg="with relaxed soc_min, first-slot export should be able to finish below reserve %", + ) def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None: """