From 49d0aa68a2ef051c3cf13c0e53545910abb61e25 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 16 May 2026 16:09:03 +0200 Subject: [PATCH] dalsi pokus o opravu --- backend/services/planning_engine.py | 55 +++++-- backend/tests/test_planning_dispatch_milp.py | 162 +++++++++++++++++-- docs/04-modules/planning.md | 2 +- 3 files changed, 197 insertions(+), 22 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index b5051d9..86de43d 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -685,6 +685,8 @@ def solve_dispatch( for t in range(T) ] ge = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)] + ge_pv = [pulp.LpVariable(f"ge_pv_{t}", 0, grid.max_export_power_w) for t in range(T)] + ge_bat = [pulp.LpVariable(f"ge_bat_{t}", 0, grid.max_export_power_w) for t in range(T)] bc = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)] bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)] soc = [ @@ -881,6 +883,9 @@ def solve_dispatch( pv_a_net + pv_b_effective + gi[t] + bd[t] == s.load_baseline_w + ev_total_t + hp[t] + bc[t] + ge[t] ) + prob += ge[t] == ge_pv[t] + ge_bat[t] + # Baterie nesmí „přestrojit“ FVE export: přebytek nad PV musí jít přes ge_bat. + prob += ge_bat[t] >= ge[t] - (pv_a_net + pv_b_effective) # Měkký breaker cap: gi_over[t] >= max(0, gi[t] - breaker). prob += gi_over[t] >= gi[t] - float(grid.max_import_power_w) @@ -936,6 +941,8 @@ def solve_dispatch( ) if z_gen_cutoff is not None or block_neg_sell_export: prob += ge[t] == 0 + prob += ge_pv[t] == 0 + prob += ge_bat[t] == 0 soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1] arb_t = arb_floor_series[t] @@ -946,10 +953,7 @@ def solve_dispatch( arb_cap_t = min(arb_t, soc_low_t) else: arb_cap_t = arb_t - if om == "AUTO" and t not in discharge_export_slots: - # PASSIVE na střídači: EMS neplánuje vybíjení do load (Deye pokryje skutečnou zátěž). - pass - elif om == "AUTO" and t in discharge_export_slots: + if om == "AUTO" and t in discharge_export_slots: prob += soc_prev_expr >= ( arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t]) ) @@ -957,6 +961,17 @@ def solve_dispatch( battery.max_discharge_power_w * w_arb[t] + pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) ) + elif om == "AUTO": + # PASSIVE: vlastní spotřeba (bd); export baterie jen ge_bat (ge_bat=0 níže). + prob += soc_prev_expr >= ( + arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t]) + ) + prob += bd[t] <= ( + s.load_baseline_w + + ev_total_t + + hp[t] + + bc[t] + ) else: prob += soc_prev_expr >= ( arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t]) @@ -969,11 +984,11 @@ def solve_dispatch( + battery.max_discharge_power_w * w_arb[t] ) - # Významný export ⇒ koncové SoC ≥ podlaha (viz soc_panel_min / arb_base). + # Významný export z baterie ⇒ koncové SoC ≥ podlaha (FVE export ge_pv bez této podlahy). m_ge = float(grid.max_export_power_w) 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 += ge_bat[t] <= m_ge * z_export[t] + prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t] # Bez hluboké relaxace: export končí ≥ rezerva. Při hluboké relaxaci (soc_panel_min pod min_soc) # sladit s LP spodkem — jinak z_export vynutil arb_base a blokoval vývoz k planner floor. if soc_panel_min[t] < min_soc_wh - 1e-3: @@ -1018,16 +1033,29 @@ def solve_dispatch( elif om == "CHARGE_CHEAP": for t in range(T): prob += ge[t] == 0 + prob += ge_pv[t] == 0 + prob += ge_bat[t] == 0 prob += bd[t] == 0 # Slot pre-selection (z DB fn_load_planning_slots_full → allow_*) if om == "AUTO": for t in range(T): if t not in charge_slots: - prob += bc[t] == 0 + s = slots[t] + pv_surplus_w = max( + 0, + int(s.pv_a_forecast_w) + + int(s.pv_b_forecast_w) + - int(s.load_baseline_w), + ) + # Mimo grid-charge masku smí nabíjet jen z PV přebytku (ne import ze sítě). + if pv_surplus_w <= 0: + prob += bc[t] == 0 + else: + prob += bc[t] <= pv_surplus_w if t not in discharge_export_slots: - prob += bd[t] == 0 - prob += w_arb[t] == 0 + prob += ge_bat[t] == 0 + prob += z_export[t] == 0 # Deadline constraints pro EV for e, session in enumerate(ev_sessions): @@ -1094,9 +1122,14 @@ def solve_dispatch( grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t])) soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1) export_limit_w = int(grid.max_export_power_w) if grid_w < 0 else 0 + ge_bat_w = round(float(pulp.value(ge_bat[t]) or 0)) export_mode = "NONE" if grid_w < 0: - export_mode = "BATTERY_SELL" if batt_w < 0 else "PV_SURPLUS" + export_mode = ( + "BATTERY_SELL" + if ge_bat_w >= GE_MIN_EXPORT_W + else "PV_SURPLUS" + ) # Deye: default PASSIVE (střídač pokryje load). CHARGE/SELL jen v maskovaných AUTO slotech. deye_mode = "PASSIVE" diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 558fa8d..9b7997f 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -335,7 +335,19 @@ class PlanningDispatchMilpTests(unittest.TestCase): def test_pv_surplus_export_uses_hard_export_cap(self) -> None: slots = [ - _slot(load=0, buy=3.0, sell=2.5, pv_a=20_000, pv_b=0), + PlanningSlot( + interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc), + buy_price=3.0, + sell_price=2.5, + pv_a_forecast_w=20_000, + pv_b_forecast_w=0, + load_baseline_w=0, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=False, + allow_discharge_export=False, + ), ] battery = _battery() hp = SimpleNamespace( @@ -1009,10 +1021,145 @@ class PlanningDispatchMilpTests(unittest.TestCase): self.assertGreater(results[0].battery_setpoint_w, 0, "surplus PV should charge") -class AutoPassiveNoLoadFollowingDischargeTests(unittest.TestCase): - """AUTO bez allow_discharge_export: žádné plánované vybíjení do load (Deye PASSIVE).""" +class AutoPvSurplusExportTests(unittest.TestCase): + """Plná baterie + vysoká FVE: export přebytku (ge_pv), ne curtailment, bez SELL.""" - def test_no_battery_export_on_inflated_baseline_without_discharge_mask(self) -> None: + def test_pv_surplus_exports_when_battery_export_disallowed(self) -> None: + slots = [ + PlanningSlot( + interval_start=datetime(2026, 5, 17, 10, 0, tzinfo=timezone.utc), + buy_price=1.20, + sell_price=0.80, + pv_a_forecast_w=0, + pv_b_forecast_w=12_000, + load_baseline_w=2000, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=False, + allow_discharge_export=False, + ), + ] + battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0) + battery.planner_terminal_soc_value_factor = 0.0 + battery.planner_daytime_charge_target_enabled = False + 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) + 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.95 * battery.soc_max_wh + results, _, _ = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + tuv_delta_stats=None, + operating_mode="AUTO", + ) + self.assertLess(results[0].grid_setpoint_w, 0, "PV surplus should export to grid") + self.assertEqual(results[0].deye_physical_mode, "PASSIVE") + self.assertEqual(results[0].export_mode, "PV_SURPLUS") + self.assertLess(results[0].pv_a_curtailed_w, 5000, "should not curtail all PV") + + +class AutoPassiveSelfConsumptionTests(unittest.TestCase): + """AUTO bez allow_discharge_export: vlastní spotřeba, ne export do sítě.""" + + def test_expensive_slot_prefers_battery_over_grid_import(self) -> None: + base = datetime(2026, 5, 16, 22, 0, tzinfo=timezone.utc) + slots = [ + PlanningSlot( + interval_start=base, + buy_price=4.80, + sell_price=2.90, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=1200, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=False, + allow_discharge_export=False, + ), + PlanningSlot( + interval_start=base + timedelta(minutes=15), + buy_price=0.50, + sell_price=-0.20, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=1200, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=True, + allow_discharge_export=False, + ), + ] + battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0) + battery.planner_terminal_soc_value_factor = 0.0 + battery.planner_daytime_charge_target_enabled = False + 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) + 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.23 * battery.usable_capacity_wh + results, _, _ = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + tuv_delta_stats=None, + operating_mode="AUTO", + ) + self.assertLess( + results[0].battery_setpoint_w, + 0, + msg="expensive slot should discharge for self-consumption before cheap charge", + ) + self.assertGreaterEqual(results[0].grid_setpoint_w, 0) + self.assertEqual(results[0].deye_physical_mode, "PASSIVE") + + +class AutoPassiveNoLoadFollowingDischargeTests(unittest.TestCase): + """AUTO bez allow_discharge_export: žádný export do sítě (Deye PASSIVE).""" + + def test_no_grid_export_on_inflated_baseline_without_discharge_mask(self) -> None: slots = [ PlanningSlot( interval_start=datetime(2026, 5, 16, 9, 45, tzinfo=timezone.utc), @@ -1061,16 +1208,11 @@ class AutoPassiveNoLoadFollowingDischargeTests(unittest.TestCase): operating_mode="AUTO", ) self.assertEqual(len(results), 1) - self.assertGreaterEqual( - results[0].battery_setpoint_w, - 0, - msg="must not plan load-following discharge when allow_discharge_export=false", - ) self.assertEqual(results[0].deye_physical_mode, "PASSIVE") self.assertGreaterEqual( results[0].grid_setpoint_w, 0, - msg="must not export at a loss when discharge is disallowed", + msg="must not export to grid when allow_discharge_export=false", ) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index e977e30..648e863 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -30,7 +30,7 @@ - měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše. - **Dynamická ekonomická podlaha (fáze 2):** - `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB – silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění. -- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50). Ekonomická podmínka je **arbitráž mezi sloty**: `sell_price > ref_buy + degradation_cost_czk_kwh`, kde `ref_buy` = `min(buy_price)` mezi sloty s `allow_charge=true` (fallback `min` v celém horizontu) — **ne** porovnání sell vs buy ve stejném intervalu. V `solve_dispatch` (AUTO): mimo tyto sloty platí **`bd[t] = 0`** a **`w_arb[t] = 0`** — EMS **neplánuje** vybíjení do predikovaného `load_baseline`; skutečnou zátěž pokrývá střídač v **PASSIVE**. **CHARGE** jen v `allow_charge` slotech; **SELL** jen v `allow_discharge_export`. +- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50). Ekonomická podmínka je **arbitráž mezi sloty**: `sell_price > ref_buy + degradation_cost_czk_kwh`, kde `ref_buy` = `min(buy_price)` mezi sloty s `allow_charge=true` (fallback `min` v celém horizontu) — **ne** porovnání sell vs buy ve stejném intervalu. V `solve_dispatch` (AUTO) je export rozdělen: **`ge_pv`** (kanál FVE) a **`ge_bat`** (baterie do sítě, jen v `allow_discharge_export`, vázáno na `z_export` a SoC podlahu); platí `ge = ge_pv + ge_bat` a `ge_bat ≥ ge − (pv_a + pv_b)` — baterie nesmí „přestrojit“ FVE. Mimo exportní sloty: **`ge_bat = 0`**, **`bd`** smí pokrýt vlastní spotřebu; **`bc`** smí nabíjet jen z **PV přebytku** i bez grid-charge masky (plná baterie + přebytek pole B jinak nejde do sítě). **`deye_physical_mode`** = PASSIVE kromě CHARGE/SELL. - **Záporná nákupní cena:** - horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu). - **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —