From 7c63fed2965fd4a2f4f1ca57f6e15dba9a7dbeea Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Thu, 21 May 2026 17:29:09 +0200 Subject: [PATCH] implementace load first --- backend/services/planning_engine.py | 78 +++++++++++++++---- backend/tests/test_planning_dispatch_milp.py | 62 +++++++++++++++ .../planning-arbitrage-accounting.md | 3 +- docs/04-modules/planning.md | 1 + 4 files changed, 126 insertions(+), 18 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 9de2664..e88801b 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -40,6 +40,8 @@ SOLVER_TIME_LIMIT = 10 # sekund GE_MIN_EXPORT_W = 1.0 # Dvouprůchodové solve: stop když acquisition z pass1 vs pass2 se liší méně než (Kč/kWh). ACQUISITION_TWO_PASS_EPS_KWH = 0.05 +# Load-first (Deye): PV nejdřív pokryje load+EV+TČ; bc_pv/ge_pv jen z pv_sp (přebytek). +LOAD_FIRST_INCENTIVE_CZK_KWH = 0.05 # Dokud je kotva pro hluboký dump (první sell < 0 v horizontu, jinak první extrémní buy) dál než # tento počet 15min slotů, držíme plánovací spodek na rezervě (arb_base_wh) místo planner floor — # priorita: beze „ztráty na prodeji“ (sell >= 0) držet buffer, hluboký vývoz až těsně před záporným prodejem. @@ -799,8 +801,11 @@ def solve_dispatch( 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)] + bc_pv = [pulp.LpVariable(f"bc_pv_{t}", 0, battery.max_charge_power_w) for t in range(T)] + bc_gi = [pulp.LpVariable(f"bc_gi_{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)] + pv_ld = [pulp.LpVariable(f"pv_ld_{t}", 0) for t in range(T)] + pv_sp = [pulp.LpVariable(f"pv_sp_{t}", 0) for t in range(T)] soc = [ pulp.LpVariable(f"soc_{t}", soc_panel_min[t], battery.soc_max_wh) for t in range(T) ] @@ -950,7 +955,12 @@ def solve_dispatch( else 0 ) + gi_over[t] * IMPORT_OVER_BREAKER_PENALTY_CZK_KWH * INTERVAL_H / 1000 - + 0.5 * (bc[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000 + + 0.5 * (bc_pv[t] + bc_gi[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000 + - ( + pv_ld[t] * LOAD_FIRST_INCENTIVE_CZK_KWH * INTERVAL_H / 1000 + if om == "AUTO" + else 0 + ) + ( ge_bat[t] * charge_acquisition_czk_kwh * INTERVAL_H / 1000 if om == "AUTO" and t in discharge_export_slots @@ -1006,13 +1016,42 @@ def solve_dispatch( if z_gen_cutoff is not None else float(s.pv_b_forecast_w) ) - prob += ( - pv_a_net + pv_b_effective + gi[t] + bd[t] - == s.load_baseline_w + ev_total_t + hp[t] + bc[t] + ge[t] - ) + pv_total_ub = float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) + + if om == "AUTO": + load_site_expr = float(s.load_baseline_w) + ev_total_t + hp[t] + prob += pv_ld[t] + pv_sp[t] == pv_a_net + pv_b_effective + prob += pv_ld[t] <= load_site_expr + prob += pv_ld[t] <= pv_a_net + pv_b_effective + prob += pv_sp[t] <= pv_total_ub + prob += pv_sp[t] >= pv_a_net + pv_b_effective - load_site_expr + prob += bc_pv[t] <= pv_sp[t] + prob += bc_gi[t] <= gi[t] + prob += ge_pv[t] <= pv_sp[t] + prob += bc_pv[t] + ge_pv[t] <= pv_sp[t] + # Import na deficit po PV→load, nebo na grid-nabíjení (bc_gi). + prob += gi[t] <= load_site_expr + bc_gi[t] + # Plná bilance (pv_ld+pv_sp rozpad je ortogonální k tokům přebytku). + prob += ( + pv_a_net + pv_b_effective + gi[t] + bd[t] + == float(s.load_baseline_w) + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] + ge[t] + ) + else: + prob += pv_ld[t] == 0 + prob += pv_sp[t] == pv_a_net + pv_b_effective + prob += bc_pv[t] <= pv_sp[t] + prob += bc_gi[t] <= gi[t] + prob += ( + pv_a_net + pv_b_effective + gi[t] + bd[t] + == s.load_baseline_w + ev_total_t + hp[t] + bc_pv[t] + bc_gi[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) + # Baterie nesmí „přestrojit“ FVE export: jen z pv_sp (po load-first). + if om == "AUTO": + prob += ge_bat[t] >= ge[t] - pv_sp[t] + else: + 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) @@ -1021,8 +1060,8 @@ def solve_dispatch( soc_prev = current_soc_wh if t == 0 else soc[t - 1] prob += soc[t] == ( soc_prev - + bc[t] * battery.charge_efficiency * INTERVAL_H - - bd[t] / battery.discharge_efficiency * INTERVAL_H + + (bc_pv[t] + bc_gi[t]) * battery.charge_efficiency * INTERVAL_H + - bd[t] / battery.discharge_efficiency * INTERVAL_H ) sv = safety_vars[t] @@ -1097,7 +1136,8 @@ def solve_dispatch( s.load_baseline_w + ev_total_t + hp[t] - + bc[t] + + bc_pv[t] + + bc_gi[t] ) else: prob += soc_prev_expr >= ( @@ -1107,7 +1147,8 @@ def solve_dispatch( s.load_baseline_w + ev_total_t + hp[t] - + bc[t] + + bc_pv[t] + + bc_gi[t] + battery.max_discharge_power_w * w_arb[t] ) @@ -1148,14 +1189,15 @@ def solve_dispatch( prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w for tt, cv, prev in commit_lp: - prob += cv >= prev - bc[tt] + prob += cv >= prev - (bc_pv[tt] + bc_gi[tt]) if om == "SELF_SUSTAIN": for t in range(T): prob += gi[t] <= slots[t].load_baseline_w elif om == "PRESERVE": for t in range(T): - prob += bc[t] == 0 + prob += bc_pv[t] == 0 + prob += bc_gi[t] == 0 prob += bd[t] == 0 elif om == "CHARGE_CHEAP": for t in range(T): @@ -1176,10 +1218,11 @@ def solve_dispatch( - int(s.load_baseline_w), ) # Mimo grid-charge masku smí nabíjet jen z PV přebytku (ne import ze sítě). + prob += bc_gi[t] == 0 if pv_surplus_w <= 0: - prob += bc[t] == 0 + prob += bc_pv[t] == 0 else: - prob += bc[t] <= pv_surplus_w + prob += bc_pv[t] <= float(pv_surplus_w) if t not in discharge_export_slots: prob += ge_bat[t] == 0 prob += z_export[t] == 0 @@ -1282,7 +1325,8 @@ def solve_dispatch( for t in range(T): hp_raw = pulp.value(hp[t]) hp_on = hp_raw > heat_pump.rated_heating_power_w * 0.3 - batt_w = round(pulp.value(bc[t]) - pulp.value(bd[t])) + bc_tot = float(pulp.value(bc_pv[t]) or 0) + float(pulp.value(bc_gi[t]) or 0) + batt_w = round(bc_tot - float(pulp.value(bd[t]) or 0)) 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 diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index e18c161..4a38522 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1720,5 +1720,67 @@ class Home01RegressionTests(unittest.TestCase): self.assertNotEqual(results[i].export_mode, "PV_SURPLUS") +class LoadFirstDispatchTests(unittest.TestCase): + """Deye load-first: PV do spotřeby dřív než bc_pv/ge_pv z přebytku.""" + + @staticmethod + def _solve_auto( + slots: list[PlanningSlot], + battery: SimpleNamespace, + soc0: float, + ) -> list[DispatchResult]: + 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=20_000, max_export_power_w=20_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), + ] + results, _, _ = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + operating_mode="AUTO", + ) + return results + + def test_high_pv_low_load_prefers_export_over_battery_charge(self) -> None: + """Mimo grid-charge masku nesmí LP nabíjet z celého PV při malé zátěži.""" + base = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc) + slots = [ + PlanningSlot( + interval_start=base, + buy_price=2.0, + sell_price=4.0, + pv_a_forecast_w=8000, + pv_b_forecast_w=0, + load_baseline_w=500, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=False, + allow_discharge_export=False, + ) + ] + battery = _battery(uc_wh=50_000.0) + soc0 = 0.5 * battery.usable_capacity_wh + r = self._solve_auto(slots, battery, soc0)[0] + self.assertLessEqual( + r.battery_setpoint_w, + 200, + msg="load-first: přebytek FVE má jít do exportu, ne do bc_pv", + ) + self.assertLess( + r.grid_setpoint_w, + -400, + msg="očekáván PV export (přebytek po load-first)", + ) + self.assertEqual(r.export_mode, "PV_SURPLUS") + + if __name__ == "__main__": unittest.main() diff --git a/docs/04-modules/planning-arbitrage-accounting.md b/docs/04-modules/planning-arbitrage-accounting.md index 064e3c0..a3e3677 100644 --- a/docs/04-modules/planning-arbitrage-accounting.md +++ b/docs/04-modules/planning-arbitrage-accounting.md @@ -113,6 +113,7 @@ Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19: 3. **Guard FVE:** `ge_pv=0` jen při `sell < charge_acquisition − degrad` (ne `sell < buy` ve stejném slotu). 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`. ### Co dál neřešit ad-hoc @@ -144,4 +145,4 @@ Očekávání: SoC před večerem **70–90 %** po levném pásmu; večer **expo --- -*Poslední aktualizace: 2026-05 — LP-first přestavba (masky B/A, two-pass acquisition, explicitní ge_pv/ge_bat objective). Po deployi: `solver_params.inputs.two_pass_enabled` na novém `planning_run`.* +*Poslední aktualizace: 2026-05 — LP-first přestavba (masky B/A, two-pass acquisition, explicitní ge_pv/ge_bat, load-first Deye). Po deployi: `solver_params.inputs.two_pass_enabled` na novém `planning_run`.* diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index be2bf47..905ba36 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -14,6 +14,7 @@ - **Grid ze sítě (vrstva B, před FVE):** spot, výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). Výběr: **nejlevnější `buy`** v pásmu (den plánu → před exportním oknem → `buy ASC`). Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`. - **PV vrstva A:** jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak, ne „nabíjet z FVE“ při nízkém sell). - **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` (nabíjení jen z `pv_sp` resp. `gi`). Bilance `pv_ld + gi + bd = load + ev + hp + bc_pv + bc_gi + ge`; `ge_bat ≥ ge − pv_sp`. Měkká preference `−LOAD_FIRST_INCENTIVE × pv_ld` v objective. Mimo `allow_charge`: `bc_gi=0`, `bc_pv ≤ pv_surplus` (forecast). Implementace: `solve_dispatch()` v `planning_engine.py`; test `LoadFirstDispatchTests`. - **`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.