diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 992331a..5a28259 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 -PLANNER_BUILD_TAG = "2026-05-28-pre-neg-pv-export-forecast-v33" +PLANNER_BUILD_TAG = "2026-05-28-load-first-hard-v34" # Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl. PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15 PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0 @@ -2437,6 +2437,16 @@ def solve_dispatch( if om == "AUTO": load_site_expr = float(s.load_baseline_w) + ev_total_t + hp[t] + ev_cap_slot_w = 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) + ) + max_load_site_w = ( + float(s.load_baseline_w) + + ev_cap_slot_w + + float(heat_pump.rated_heating_power_w) + ) # BMS: jedno vybíjení — bilance při gi≈0 dá bd≈load+ge_bat; bd+ge_bat≤max by export # započítalo dvakrát ((max−load)/2). Exportní sloty: load+ge_bat; jinak bd≤max. prob += bd[t] <= battery.max_discharge_power_w @@ -2451,12 +2461,25 @@ def solve_dispatch( 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] - # Vybíjení do domu až po pv_ld (Deye load-first); v exportních slotech smí bd→síť. + # Tvrdý load-first (Deye): při dostatečné FVE jen grid-nabíjení (bc_gi); jinak gi smí + # krmit deficit domu (noc / nízká FVE), ne fiktivně paralelně s plným PV→bc_pv. + house_grid_import_cap_w = max( + 0.0, + max_load_site_w - pv_total_ub, + ) + prob += gi[t] <= bc_gi[t] + house_grid_import_cap_w + pv_covers_load_site = ( + pv_total_ub >= max_load_site_w + NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W + ) + if pv_covers_load_site: + prob += pv_ld[t] >= load_site_expr + # Vybíjení do domu až po pv_ld; v exportních slotech smí bd→síť. if t not in discharge_export_slots: prob += bd[t] <= load_site_expr - pv_ld[t] - prob += pv_ld[t] >= load_site_expr - gi[t] - bd[t] + if pv_covers_load_site: + prob += pv_ld[t] >= load_site_expr - bd[t] + else: + prob += pv_ld[t] >= load_site_expr - gi[t] - bd[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] diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 3429da2..42f41e8 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1414,7 +1414,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32") + self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG) self.assertGreater( results[0].battery_setpoint_w, 2_500, @@ -1564,7 +1564,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32") + self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG) self.assertEqual(len(results), len(slots)) def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None: @@ -1628,7 +1628,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 55.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32") + self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG) self.assertEqual(len(results), len(slots)) def test_fixed_tariff_neg_sell_no_grid_export(self) -> None: @@ -2314,7 +2314,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32") + self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG) peak_idx = sells.index(4.04) peak = results[peak_idx] self.assertIn(peak.export_mode, ("BATTERY_SELL", "PV_SURPLUS")) @@ -2392,7 +2392,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32") + self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG) r_midnight = results[2] self.assertEqual(r_midnight.export_mode, "BATTERY_SELL") self.assertGreaterEqual(abs(r_midnight.grid_setpoint_w), 12_500) @@ -2435,7 +2435,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32") + self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG) r = results[0] self.assertEqual(r.export_mode, "BATTERY_SELL") self.assertGreaterEqual(abs(r.grid_setpoint_w), 12_500) @@ -3114,6 +3114,34 @@ class LoadFirstDispatchTests(unittest.TestCase): ) self.assertEqual(r.export_mode, "PV_SURPLUS") + def test_neg_sell_prep_no_fictitious_grid_import_for_load(self) -> None: + """sell<0 prep: FVE >> load → dům z PV, ne grid_setpoint == load_baseline.""" + base = datetime(2026, 5, 26, 7, 45, tzinfo=timezone.utc) + slots = [ + PlanningSlot( + interval_start=base, + buy_price=1.45, + sell_price=-0.07, + pv_a_forecast_w=3137, + pv_b_forecast_w=3418, + load_baseline_w=447, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=False, + ) + ] + bat = _battery(uc_wh=64_000.0, max_pct=95.0) + bat.planner_neg_sell_prep_soc_percent = 80.0 + bat.planner_neg_sell_full_soc_tail_slots = 4 + r = self._solve_auto(slots, bat, 0.24 * bat.soc_max_wh)[0] + self.assertLessEqual( + abs(r.grid_setpoint_w), + 100, + msg="tvrdý load-first: žádný fiktivní import = load při vysoké FVE", + ) + self.assertGreater(r.battery_setpoint_w, 3000) + class PreNegativeSellExportTests(unittest.TestCase): """Před prvním sell<0: export přebytku (BA81/KV1 strategie), ne nabíjení + pozdní vývoz.""" @@ -3211,7 +3239,7 @@ class Home01PvStoreValueTests(unittest.TestCase): results, _, snap = solve_dispatch( slots, battery, hp, grid, [None, None], vehicles, 0.55 * battery.soc_max_wh, 50.0, operating_mode="AUTO" ) - self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32") + self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG) r0 = results[0] self.assertLess( r0.pv_a_curtailed_w, diff --git a/docs/04-modules/planning-arbitrage-accounting.md b/docs/04-modules/planning-arbitrage-accounting.md index bd11009..87b2c71 100644 --- a/docs/04-modules/planning-arbitrage-accounting.md +++ b/docs/04-modules/planning-arbitrage-accounting.md @@ -113,7 +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` při `sell < future_sell_opportunity − degrad` **jen pokud `sell < 0`** (spot) nebo fixní tarif — u **`sell ≥ 0`** spot neblokuje export FVE kvůli budoucímu peak sell (solver export vs. nabíjení; baterii šetří `ge_bat`). Při `sell < 0` home-01: `ge_pv=0` / ventil pole B. Tag `2026-05-28-pv-positive-sell-solver-v29`. 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`. +6. **Load-first (Deye, AUTO, tvrdý v34):** `pv_ld` / `pv_sp`, `bc_pv` / `bc_gi`; `bc_pv + ge_pv ≤ pv_sp`; **`gi ≤ bc_gi + max(0, max_load − pv_forecast)`**; při `pv ≥ load + 500 W` **`pv_ld ≥ load`**. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`. Test `LoadFirstDispatchTests`. 7. **Self-konzistentní vrstva B (`R__063`, 2026-05):** iterativní filtr v plpgsql — vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 % deficitu SoC` (levnější alternativa dál v horizontu). Failsafe unlock pokud výsledek nepokryje safety target. Důsledek: `acquisition_pass1 ~ acquisition_pass2` v drtivé většině případů. Nové debug sloupce: `min_buy_before_cutoff_czk_kwh`, `pv_charge_wh_ahead`, `neg_buy_wh_ahead`, `grid_charge_suppressed_reason` (`cheaper_pv_ahead` / `cheaper_neg_buy_ahead` / `safety_failsafe_unlock`). 8. **Ekonomická transparentnost plánu (`V081`, 2026-05):** `planning_interval` — `cashflow_czk`, `battery_arbitrage_czk`, `penalty_czk`, `green_bonus_czk`; `fn_plan_explain_bundle` → `economics_summary`; post-processing v `solve_dispatch()`. diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 2966844..ef09d3c 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -15,7 +15,7 @@ - **Grid ze sítě (vrstva B, před FVE):** 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`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. 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:** při `sell ≥ 0` jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak). Při **`sell < 0`** vrstva A **bez** tohoto filtru (nabít z FVE v záporném výkupním okně). Historie: [`docs/planning-changelog.md`](../planning-changelog.md). - **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`. + - **Load-first (Deye, AUTO, tvrdý od v34):** 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 ≤ bc_gi + max(0, max_load − pv_forecast)`** (při vysoké FVE žádný fiktivní import = load); při `pv ≥ load + 500 W` **`pv_ld ≥ load`**; mimo `allow_discharge_export`: `bd ≤ load − pv_ld`, `pv_ld ≥ load − bd`. Tag `2026-05-28-load-first-hard-v34`. 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):** tvrdé `ge_pv = 0` jen pokud `sell < future_sell_opportunity − degradation` **a** `sell < 0` (spot), nebo u fixního tarifu dle `fixed_pv_b_export_cap`. Při **`sell ≥ 0` (spot home-01, KV1):** `ge_pv` **neblokuje** pv_store — solver volí export vs. `bc_pv` podle `−ge_pv×sell` a degradace; **baterii** na večerní peak drží `ge_bat` (`evening_early` / push), ne curtail FVE. **v31:** při `sell ≥ 0` + PV přebytek **není** plný `ge_bat` push z `pre_neg_buy_discharge` / ranních shortfallů (export cap pro FVE). **Před prvním `sell < 0`:** `allow_pre_neg_pv_export`. Tag `2026-05-28-morning-pv-export-priority-v31`. Testy `Home01PvStoreValueTests`, `PreNegativeSellExportTests`. - **Drahý nákup → vlastní spotřeba z baterie:** mimo `allow_charge` platí `bd + pv_ld ≥ load_baseline + hp[t]` a `gi ≤ EV + hp[t]` (ne `hp_rated`). **Spot:** drahý slot = `buy > min(buy≥0) + degradace`. **Fixní nákup (DB `purchase_pricing_mode=fixed` nebo heuristika rozptylu buy < 0,25):** navíc `buy > charge_acquisition + degradace`. Na spotu **nesmí** `charge_acquisition` (~0,9 Kč) označit všechny sloty jako drahé → Infeasible (home-01). Při **Infeasible** solver jednou opakuje s `relaxed_expensive_import` (síť smí krmit baseload v drahých slotech; v `solver_params.inputs.relaxed_expensive_import=true`). Testy `AutoPassiveSelfConsumptionTests`, `test_spot_low_acquisition_does_not_mark_all_slots_expensive`, `test_negative_buy_in_horizon_does_not_block_all_grid_import`. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 1108322..a97281d 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,12 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-28 — Tvrdý load-first v LP (v34) + +**Problém:** V sell<0 prep plán ukazoval `grid_setpoint_w ≈ load_baseline` při FVE ≫ load — LP účetně posílal dům přes `gi`, zatímco Deye load-first krmit dům z FVE. + +**Změna (tag `2026-05-28-load-first-hard-v34`):** `gi ≤ bc_gi + max(0, max_load − pv_forecast)`; při dostatečné FVE `pv_ld ≥ load` (žádný fiktivní import = load při vysoké FVE). Test `LoadFirstDispatchTests::test_neg_sell_prep_no_fictitious_grid_import_for_load`. + ## 2026-05-28 — Před sell<0: export FVE jen při dostatečné predikci v záporném okně (v33) **Problém:** Při kladném sell ráno LP nabíjel na večerní peak (~6,5 Kč) místo exportu (~3 Kč). Uživatel chce export teď, ale ne když forecast v sell<0 okně nestačí na dobítí (déšť).