diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 8e63ec5..41d40b1 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -619,6 +619,39 @@ def _slots_with_charge_acquisition( ] +def _pv_store_value_czk_kwh( + slot: PlanningSlot, + charge_acquisition_czk_kwh: float, + min_spread: float, +) -> float: + """ + Minimální efektivní sell [Kč/kWh], pod kterým je FVE→síť horší než uložení + (večerní peak / náklad zásoby z levného nákupu). + """ + future = float( + slot.future_sell_opportunity_czk_kwh + if slot.future_sell_opportunity_czk_kwh is not None + else slot.sell_price + ) + return max(future, float(charge_acquisition_czk_kwh)) - min_spread + + +def _pv_forced_vent_export_allowed( + t: int, + *, + current_soc_wh: float, + battery, + soc_headroom_wh: float, + pv_surplus_w: float, +) -> bool: + """Přebytek FVE do sítě jen když baterie na konci předchozího slotu nemá kapacitu.""" + if pv_surplus_w <= 0: + return False + if t == 0: + return current_soc_wh >= float(battery.soc_max_wh) - soc_headroom_wh + return False + + def solve_dispatch_two_pass( slots: list[PlanningSlot], battery, @@ -880,6 +913,7 @@ def solve_dispatch( if charge_acq_raw is not None else min(float(s.buy_price) for s in slots) ) + soc_headroom_wh = max(2000.0, 0.05 * float(battery.soc_max_wh)) # Kotva: poslední slot před prvním sell<0 by měl končit u planner floor (pokud relaxace existuje). # Slack penalizujeme v objective; samotné omezení přidáme až po definici soc. @@ -1109,9 +1143,31 @@ def solve_dispatch( if s.sell_price < 0: prob += w_arb[t] == 0 prob += bd[t] <= pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) - # Tvrdý zákaz vývozu při záporné prodejní ceně, pokud: - # - site má GEN/MI cutoff model (binárky z_gen_cutoff — BA81), nebo - # - explicitně site_grid_connection.block_export_on_negative_sell (např. fixní nákup, bez pole B). + prob += ge_bat[t] == 0 + ev_cap_neg = sum( + float(vehicles[e].max_charge_power_w) + for e in range(EV) + if (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected) + ) + load_neg = ( + float(s.load_baseline_w) + + ev_cap_neg + + float(heat_pump.rated_heating_power_w) + ) + pv_surplus_neg_w = max( + 0.0, + float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_neg, + ) + # FVE→síť při záporném výkupu jen nucený vent (plná baterie); jinak bc_pv / load-first. + if not _pv_forced_vent_export_allowed( + t, + current_soc_wh=current_soc_wh, + battery=battery, + soc_headroom_wh=soc_headroom_wh, + pv_surplus_w=pv_surplus_neg_w, + ): + prob += ge_pv[t] == 0 + # Tvrdý zákaz celého vývozu (GEN / fixní nákup bez pole B). block_neg_sell_export = bool( getattr(grid, "block_export_on_negative_sell", False) ) @@ -1241,9 +1297,6 @@ def solve_dispatch( ref_buy_horizon = min(float(s.buy_price) for s in slots) min_spread = float(degradation_cost_effective) hp_rated_w = float(heat_pump.rated_heating_power_w) - soc_headroom_wh = max( - 2000.0, 0.05 * float(battery.soc_max_wh) - ) for t in range(T): s = slots[t] buy_t = float(s.buy_price) @@ -1258,15 +1311,18 @@ def solve_dispatch( 0.0, float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_t, ) - # FVE export: zakázat jen okamžitě ztrátový výkup vs plánovaná zásoba (ne sell < buy ve slotu). - if sell_t < charge_acquisition_czk_kwh - min_spread: - block_loss_pv_export = not ( - float(s.pv_b_forecast_w) > 0 and pv_surplus_w > 0 - ) - if t == 0 and current_soc_wh >= float(battery.soc_max_wh) - soc_headroom_wh: - block_loss_pv_export = False - if block_loss_pv_export: - prob += ge_pv[t] == 0 + # FVE export jen pokud sell ≥ hodnota uložení (večerní peak / acquisition − degradace). + pv_store_val = _pv_store_value_czk_kwh( + s, charge_acquisition_czk_kwh, min_spread + ) + if sell_t < pv_store_val and not _pv_forced_vent_export_allowed( + t, + current_soc_wh=current_soc_wh, + battery=battery, + soc_headroom_wh=soc_headroom_wh, + pv_surplus_w=pv_surplus_w, + ): + prob += ge_pv[t] == 0 # Drahý nákup oproti horizontu: import jen na load + EV + TČ, ne na grid-nabíjení. if buy_t >= 0 and buy_t > ref_buy_horizon + min_spread: prob += gi[t] <= load_t + ev_cap_t + hp_rated_w diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 0a3acb8..953ff26 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1782,6 +1782,75 @@ class LoadFirstDispatchTests(unittest.TestCase): self.assertEqual(r.export_mode, "PV_SURPLUS") +class Home01PvStoreValueTests(unittest.TestCase): + """FVE (zejména pole B) nesmí jít do sítě pod hodnotou uložení / večerní peak.""" + + def test_pv_b_low_sell_charges_not_exports(self) -> None: + """08:30 archetyp: sell ~0,09, večer ~5,5 → bc, ne ge_pv.""" + slots = [ + PlanningSlot( + interval_start=datetime(2026, 5, 22, 6, 30, tzinfo=timezone.utc), + buy_price=1.017, + sell_price=0.088, + pv_a_forecast_w=0, + pv_b_forecast_w=5313, + load_baseline_w=1961, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=False, + charge_acquisition_buy_czk_kwh=0.526, + future_sell_opportunity_czk_kwh=5.5, + ) + ] + battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0) + battery.max_charge_power_w = 18_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=17_000, max_export_power_w=13_500) + 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.45 * battery.usable_capacity_wh + results, _, _ = solve_dispatch( + slots, battery, hp, grid, [None, None], vehicles, soc0, 50.0, operating_mode="AUTO" + ) + r = results[0] + self.assertGreaterEqual(r.grid_setpoint_w, 0, "nízký sell: žádný export FVE") + self.assertGreater(r.battery_setpoint_w, 500, "přebytek PV do baterie") + + def test_negative_sell_no_pv_export_when_battery_has_room(self) -> None: + slots = [ + PlanningSlot( + interval_start=datetime(2026, 5, 22, 7, 45, tzinfo=timezone.utc), + buy_price=0.55, + sell_price=-0.266, + pv_a_forecast_w=0, + pv_b_forecast_w=5474, + load_baseline_w=1961, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=False, + charge_acquisition_buy_czk_kwh=0.526, + future_sell_opportunity_czk_kwh=5.5, + ) + ] + battery = _battery(uc_wh=64_000.0) + battery.max_charge_power_w = 18_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=17_000, max_export_power_w=13_500) + 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.5 * battery.usable_capacity_wh + results, _, _ = solve_dispatch( + slots, battery, hp, grid, [None, None], vehicles, soc0, 50.0, operating_mode="AUTO" + ) + self.assertGreaterEqual(results[0].grid_setpoint_w, 0) + + class SitePowerCapTests(unittest.TestCase): """Tvrdé limity site import a součtu nabíjení baterie.""" diff --git a/docs/04-modules/planning-arbitrage-accounting.md b/docs/04-modules/planning-arbitrage-accounting.md index a3e3677..cf573fa 100644 --- a/docs/04-modules/planning-arbitrage-accounting.md +++ b/docs/04-modules/planning-arbitrage-accounting.md @@ -110,7 +110,7 @@ Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19: 1. **`ems.fn_load_planning_slots_full`** (`R__063`): grid **B** = nejlevnější sloty v AM/PM do Wh rozpočtu; **nevyčerpaný AM rozpočet přejde do PM** (odpolední NT za ~0,5 Kč může nabíjet i po ranním dobití). `grid_target × charge_slot_buffer`, cap slotů též × buffer. **A** = PV jen pokud `sell ≥ future_sell_lookahead − degrad`. 2. **`solve_dispatch` (AUTO):** objective `gi×buy − ge_pv×sell − ge_bat×sell + ge_bat×acquisition` (export bat. jen v `allow_discharge_export`). Odstraněn cross-slot guard `ge_pv ≥ surplus` / `bc=0` dle `export_refill_net`. -3. **Guard FVE:** `ge_pv=0` jen při `sell < charge_acquisition − degrad` (ne `sell < buy` ve stejném slotu). +3. **Guard FVE:** `ge_pv=0` při `sell < max(future_sell_opportunity, charge_acquisition) − degrad` (PV store value); výjimka jen plná baterie v kotvícím slotu. Při `sell < 0` také `ge_pv=0` (home-01 bez `block_export_on_negative_sell`). Bez blanket výjimky „pole B má přebytek“. 4. **`solve_dispatch_two_pass`:** pass 1 → vážený `buy` z `bc`+`gi` v `allow_charge` → pass 2; volá `run_daily_plan` / `run_rolling_replan` v AUTO. Snapshot: `acquisition_pass1_czk_kwh`, `acquisition_pass2_czk_kwh`, `two_pass_enabled`. 5. **Regrese:** `Home01RegressionTests` v `backend/tests/test_planning_dispatch_milp.py`; masky v `test_planning_charge_slot_selection.py`. 6. **Load-first (Deye, AUTO):** proměnné `pv_ld` / `pv_sp`, `bc_pv` / `bc_gi`; přebytek FVE jen `bc_pv + ge_pv ≤ pv_sp`; `gi ≤ load + bc_gi` (žádný fiktivní import při PV exportu). Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`. Test `LoadFirstDispatchTests`. diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index a6a16cf..0e5ba93 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -16,6 +16,7 @@ - **LP (AUTO):** objective explicitně `−ge_pv×sell − ge_bat×sell + ge_bat×acquisition` v exportních slotech; **bez** cross-slot vynucení `ge_pv ≥ surplus`. Guard FVE: `ge_pv=0` jen pokud `sell < charge_acquisition − degrad` (ne `sell < buy` ve slotu). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). - **Load-first (Deye, AUTO):** proměnné `pv_ld` (PV → load+EV+TČ), `pv_sp` (přebytek), `bc_pv` / `bc_gi`. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`; `bc_pv + ge_pv ≤ pv_sp`; `gi ≤ load + bc_gi`; mimo `allow_discharge_export`: `bd ≤ load − pv_ld` a **`pv_ld ≥ load − gi − bd`**. Snapshot: `load_first_enabled=true`. Test `LoadFirstDispatchTests`. - **Tvrdé výkonové limity site/baterie:** `gi ≤ site_grid_connection.max_import_power_w` (breaker); **`bc_pv + bc_gi ≤ asset_battery.max_charge_power_w`**; **`ge ≤ max_export_power_w`** (proměnná `ge`, platí `ge = ge_pv + ge_bat`); **`bd + ge_bat ≤ asset_battery.max_discharge_power_w`** (vybíjení do domu + export z baterie nesmí současně překročit BMS). Dříve LP dovoloval import+nabíjení a dvojnásobné nabíjení; u prodeje hrozilo současné `bd` a `ge_bat` až 2× max discharge — viz `SitePowerCapTests`. + - **Hodnota FVE (PV store value):** `ge_pv = 0`, pokud `sell < max(future_sell_opportunity, charge_acquisition) − degradation` — přebytek jde do baterie (`bc_pv`), ne do sítě za haléře. Výjimka jen **nucený vent** (kotovací slot `t=0`, SoC u `soc_max`, přebytek PV). Dříve výjimka „jakékoli pole B s přebytkem“ obcházela guard → export i při sell 0,05 nebo −0,2 Kč/kWh (`Home01PvStoreValueTests`). Při **`sell < 0`** je `ge_pv = 0` (a `ge_bat = 0`) stejně, pokud není vent. - **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). - Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny. - **LP ekonomické guardy** (`solve_dispatch`, AUTO): `ge_pv=0` pokud `sell < charge_acquisition − degradation` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation` mimo charge masku → `gi` jen na load+EV+TČ. Viz `planning_engine.py` po slot pre-selection.