diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 7925fe1..54b997f 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -613,6 +613,11 @@ def solve_dispatch( + heat_pump.rated_heating_power_w ) + # Záporný prodej (sell < 0): nevybíjet baterii do sítě pro arbitráž. + # Export v tomto okně může vzniknout jen z přebytku FVE (pv_a/pv_b), ne z bd. + if s.sell_price < 0: + prob += w_arb[t] == 0 + soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1] arb_t = arb_floor_series[t] soc_low_t = soc_panel_min[t] diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 62209bb..3a718c1 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -439,6 +439,84 @@ class PlanningDispatchMilpTests(unittest.TestCase): msg="with relaxed soc_min, first-slot export should be able to finish below reserve %", ) + def test_negative_sell_forbids_battery_export_arbitrage(self) -> None: + """ + Pokud sell < 0, solver nesmí vybíjet baterii do sítě pro arbitráž (dump musí proběhnout předtím). + V okně sell<0 smí export vzniknout jen z přebytku FVE; zde ale FVE=0, takže očekáváme grid_setpoint>=0. + """ + base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc) + s0 = PlanningSlot( + interval_start=base, + buy_price=2.0, + sell_price=2.0, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=0, + 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=2.0, + sell_price=-0.5, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=0, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=True, + allow_discharge_export=True, + ) + s2 = PlanningSlot( + interval_start=base + timedelta(minutes=30), + buy_price=-15.0, + sell_price=-1.0, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=0, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=True, + allow_discharge_export=True, + ) + slots = [s0, s1, s2] + 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.9 * 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), 3) + # V sell<0 slotu bez FVE a bez zátěže nesmí být export (to by muselo být z baterie). + self.assertGreaterEqual(results[1].grid_setpoint_w, 0) + def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None: """ Soft cap: solver může nominálně překročit breaker, ale jen pokud se to vyplatí.