diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 8f1833d..fdb769a 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -52,7 +52,7 @@ DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8 PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20 PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 40.0 # Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail). -PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 25.0 +PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 50.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 CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru @@ -1199,21 +1199,22 @@ def solve_dispatch( )) sf = pulp.LpVariable(f"export_shortfall_{t}", 0, cap_w) peak_export_shortfall.append((t, sf, cap_w)) - if block_export_neg_sell: - for t in range(T): - if float(slots[t].sell_price) >= 0: - continue - pv_surplus_w = max( - 0.0, - float(slots[t].pv_a_forecast_w) - + float(slots[t].pv_b_forecast_w) - - float(slots[t].load_baseline_w), - ) - if pv_surplus_w <= 0: - continue - cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) - sf_pv = pulp.LpVariable(f"pv_charge_shortfall_{t}", 0, cap_w) - pv_charge_shortfall.append((t, sf_pv, cap_w)) + for t in range(T): + if float(slots[t].sell_price) >= 0: + continue + if t not in charge_slots: + continue + pv_surplus_w = max( + 0.0, + float(slots[t].pv_a_forecast_w) + + float(slots[t].pv_b_forecast_w) + - float(slots[t].load_baseline_w), + ) + if pv_surplus_w <= 500: + continue + cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) + sf_pv = pulp.LpVariable(f"pv_charge_shortfall_{t}", 0, cap_w) + pv_charge_shortfall.append((t, sf_pv, cap_w)) # --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) --- # Kanály: gi×buy, −ge_pv×sell, −ge_bat×sell, +ge_bat×acquisition (export bat. jen v discharge slotách). @@ -1317,6 +1318,15 @@ def solve_dispatch( for t in range(T): s = slots[t] pv_a_net = s.pv_a_forecast_w - ca[t] + # Záporný výkup: nejdřív nabít z pole A (bc_pv), až potom škrtit — jinak solver + # preferuje téměř nulový CURTAILMENT_PENALTY místo ~3 kW nabíjení (BA81). + if ( + om == "AUTO" + and float(s.sell_price) < 0 + and t in charge_slots + and int(s.pv_a_forecast_w) > 0 + ): + prob += ca[t] <= float(s.pv_a_forecast_w) - bc_pv[t] ev_total_t = pulp.lpSum(ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV)) diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index f4cc077..7bed004 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1028,6 +1028,75 @@ class PlanningDispatchMilpTests(unittest.TestCase): self.assertGreater(results[0].battery_setpoint_w, 0, "surplus PV should charge") +class NegativeSellPvChargeTests(unittest.TestCase): + """BA81: při sell<0 a velké FVE A má jít výkon do baterie, ne do curtailment.""" + + def test_negative_sell_prefers_full_pv_charge_over_curtail(self) -> None: + slots = [ + PlanningSlot( + interval_start=datetime(2026, 5, 24, 9, 0, tzinfo=timezone.utc), + buy_price=3.088, + sell_price=-0.9, + pv_a_forecast_w=13_500, + pv_b_forecast_w=0, + load_baseline_w=500, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=False, + ) + ] + battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.2) + battery.max_charge_power_w = 6_250 + battery.max_discharge_power_w = 6_250 + 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=16_000, + 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, + ), + ] + soc0 = 0.33 * battery.usable_capacity_wh + results, _ms, _ = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + operating_mode="AUTO", + ) + r0 = results[0] + self.assertGreater( + r0.battery_setpoint_w, + 5_500, + "při sell<0 a PV≈13 kW má baterie nabíjet blízko max_charge (6,25 kW)", + ) + # Přebytek nad max_charge jde do curtail (ne ~3 kW nabíjení + 9 kW curtail při plné baterii). + self.assertGreater( + r0.battery_setpoint_w, + r0.pv_a_curtailed_w * 0.5, + "nabíjení má dominovat nad curtailmentem", + ) + + class AutoPvSurplusExportTests(unittest.TestCase): """Plná baterie + vysoká FVE: export přebytku (ge_pv), ne curtailment, bez SELL.""" diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 17d7960..2554881 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -120,6 +120,26 @@ where pr.site_id = (select id from ems.site where code='BA81') and pr.status='ac --- +## 2026-05-24 (e) — BA81: FVE 13 kW → nabíjení jen ~3 kW (curtailment) + +**Problém:** Run **15826** — `pv≈13 kW`, `battery_setpoint≈3,3 kW`, **`pv_a_curtailed≈9 kW`** (08:00–08:45). `allow_charge=true`, ale solver škrtí FVE místo plného nabíjení. + +**Příčina:** + +1. **`CURTAILMENT_PENALTY = 0,001 Kč/Wh`** vs degradace nabíjení → LP raději `ca` než `bc_pv`. +2. **`pv_charge_shortfall`** jen při `block_export_on_negative_sell` (KV1) — **BA81 má false** → žádný tlak na `bc_pv`. +3. SoC v plánu stagnuje ~52 % při záporném výkupu, zbytek jde do curtailment. + +**Oprava (`planning_engine.py`):** + +- `pv_charge_shortfall` pro **všechny** sloty `sell<0` + `allow_charge` + PV přebytek >500 W. +- Penalizace **50 Kč/kWh**. +- Tvrdé **`ca ≤ pv_a_forecast − bc_pv`** v okně záporného výkupu (nejdřív nabít, pak škrtit). + +**Deploy:** restart **backend** (SQL beze změny) + replan. + +--- + ## Šablona pro další záznamy ```markdown