From 18ace46ea9c7697b8f3269532d7c58b2433d8faa Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 25 May 2026 13:44:30 +0200 Subject: [PATCH] fix spravneho prodeje do site --- backend/services/planning_engine.py | 23 +++++----- backend/tests/test_planning_dispatch_milp.py | 46 +++++++++++++++++++- docs/04-modules/planning.md | 4 +- docs/planning-changelog.md | 8 ++++ 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 08eaa44..4df5a49 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -68,7 +68,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-evening-peak-full-export-v27" +PLANNER_BUILD_TAG = "2026-05-28-evening-peak-full-export-v28" POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30 PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25 PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 @@ -820,8 +820,9 @@ def _evening_push_battery_export_w( grid: Any, ) -> float: """ - Nejvyšší ge_bat v push slotu při drahém importu (gi≈0): bilance dá bd ≈ load + ge_bat, - tedy ge_bat + bd ≤ max_discharge → ge_bat ≤ (max_discharge − load) / 2. + Tvrdý push ge_bat: min(site/inverter export cap, BMS − load). + Stejná fyzika jako Deye SELL — load pokryje baterie, zbytek výkonu jde do sítě + (ne (max−load)/2 z dvojího započtení bd+ge_bat v LP). """ cap = _battery_export_cap_w(battery, grid) load_w = max(0.0, float(slot.load_baseline_w)) @@ -829,10 +830,7 @@ def _evening_push_battery_export_w( 0.0, float(battery.max_discharge_power_w) - load_w, ) - if discharge_headroom <= 0.0: - return 0.0 - export_from_balance = discharge_headroom / 2.0 - return min(cap, discharge_headroom, export_from_balance) + return min(cap, discharge_headroom) def _dispatch_grid_setpoint_w( @@ -2013,11 +2011,8 @@ def solve_dispatch( slots[t_peak], battery, grid ) if push_floor_w >= GE_MIN_EXPORT_W: - load_push_w = float(slots[t_peak].load_baseline_w) prob += z_export[t_peak] == 1 prob += ge_bat[t_peak] >= push_floor_w - # Drahý import (gi≈0): bez bd ≥ load + ge_bat zůstane jen vybíjení do domu. - prob += bd[t_peak] + ge_bat[t_peak] >= load_push_w + push_floor_w # Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push). if ( last_pos_sell_pre_neg_buy is not None @@ -2054,13 +2049,16 @@ def solve_dispatch( # Součet nabíjení z FVE + ze sítě nesmí překročit max_charge_power_w baterie. prob += bc_pv[t] + bc_gi[t] <= battery.max_charge_power_w - # Vybíjení do domu (bd) + export z baterie (ge_bat) sdílí jeden BMS limit. - prob += bd[t] + ge_bat[t] <= battery.max_discharge_power_w # Breaker: import ze site je tvrdě omezen (gi_over jen numerická pojistka). prob += gi[t] <= gi_upper if om == "AUTO": load_site_expr = float(s.load_baseline_w) + ev_total_t + hp[t] + # 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 + if t in discharge_export_slots: + prob += load_site_expr + ge_bat[t] <= battery.max_discharge_power_w 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 @@ -2090,6 +2088,7 @@ def solve_dispatch( 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 += bd[t] + ge_bat[t] <= battery.max_discharge_power_w prob += ge[t] == ge_pv[t] + ge_bat[t] # Baterie nesmí „přestrojit“ FVE export: jen z pv_sp (po load-first). diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index ba6ad6b..7e58b9d 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -2239,7 +2239,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap["planner_build_tag"], "2026-05-28-evening-peak-full-export-v27") + self.assertEqual(snap["planner_build_tag"], "2026-05-28-evening-peak-full-export-v28") peak_idx = sells.index(4.04) peak = results[peak_idx] self.assertIn(peak.export_mode, ("BATTERY_SELL", "PV_SURPLUS")) @@ -2254,6 +2254,50 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): msg=f"slot {i} sell={sells[i]} must not battery-export before first push", ) + def test_evening_push_export_near_site_cap_home01(self) -> None: + """home-01 večer: export ≈ min(13.5 kW, 18 kW − load), ne (max−load)/2.""" + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 5, 25, 18, 45, tzinfo=prague) + slots = [ + PlanningSlot( + interval_start=base, + buy_price=7.3, + sell_price=4.4, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=1797, + ev1_connected=False, + ev2_connected=False, + allow_charge=False, + allow_discharge_export=True, + charge_acquisition_buy_czk_kwh=0.8, + ) + ] + battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0) + battery.max_discharge_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), + ] + results, _ms, snap = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + 0.9 * battery.soc_max_wh, + 50.0, + operating_mode="AUTO", + ) + self.assertEqual(snap["planner_build_tag"], "2026-05-28-evening-peak-full-export-v28") + r = results[0] + self.assertEqual(r.export_mode, "BATTERY_SELL") + self.assertGreaterEqual(abs(r.grid_setpoint_w), 12_500) + self.assertLessEqual(abs(r.grid_setpoint_w), 13_500) + def test_evening_battery_export_when_sell_above_acquisition(self) -> None: base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc) cheap = (0.75, 0.25) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index e61c0ca..432b538 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -100,7 +100,7 @@ flowchart TD - kandidáti: profitable ∩ večer ∩ `sell ≥ max_večer − 0,05` (úzké pásmo u **absolutní** večerní špičky, ne široké „peak−degrad“ pro push); - řazení podle **`sell` sestupně**; - přidávat sloty, dokud `kumulované_Wh ≤` rozpočet (`discharge_slot_buffer`, SoC nad `min_soc`); - - **v27 push fyzika:** při drahém importu `bd + ge_bat ≥ load + ge_bat` a cap `ge_bat ≈ min(export_cap, (max_discharge−load)/2)` — jinak LP drží jen `bd≈load` bez exportu (home-01); + - **v28 push fyzika:** cap `ge_bat ≈ min(export_cap, max_discharge − load)` a v push slotech BMS `load + ge_bat ≤ max_discharge` (ne `bd+ge_bat`, které dvojí započítávalo export); odpovídá Deye SELL — load z baterie, zbytek do sítě až po site cap; - **výsledek:** jeden nejdražší slot → export řádově kW; další drahé sloty **po** prvním push mohou exportovat dle ekonomiky LP. **Není to** „prodávat jen v jednom jediném nejdražším slotu“ — je to „prodávat **plným výkonem** v **tolika nejdražších večerních** slotech, kolik unese baterie“. @@ -113,7 +113,7 @@ flowchart TD | Měkká `peak_export_shortfall` → často ~50 % výkonu v mnoha slotech | Na `evening_push` slotech tvrdý push na cap; shortfall na push vypnutý | | `grid_setpoint = gi − ge` → Deye vidí ~0 W při velkém `ge_bat` | `_dispatch_grid_setpoint_w` z reálného exportu | -**Funkce:** `_evening_battery_export_push_indices`, `_evening_push_discharge_budget_wh`, `_evening_push_battery_export_w`, `_dispatch_grid_setpoint_w` v `planning_engine.py`. Tag: `2026-05-28-evening-peak-full-export-v27`. +**Funkce:** `_evening_battery_export_push_indices`, `_evening_push_discharge_budget_wh`, `_evening_push_battery_export_w`, `_dispatch_grid_setpoint_w` v `planning_engine.py`. Tag: `2026-05-28-evening-peak-full-export-v28`. ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 257be59..ef3ead0 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-28 — večerní export: plný site cap (v28) + +**Problém (v27):** Push používal `ge_bat ≤ (max_discharge−load)/2` kvůli LP limitu `bd+ge_bat ≤ BMS` při bilanci `bd≈load+ge_bat` — plán ~8 kW místo až **13,5 kW** (home-01). + +**Změna (tag `2026-05-28-evening-peak-full-export-v28`):** Push cap `min(export_cap, max_discharge−load)`; v `evening_push_ts` BMS **`load + ge_bat ≤ max_discharge`** místo `bd+ge_bat`. Deye realtime dál řídí load-first na zařízení. + +**Ověření:** `pytest … -k evening_push_export_near_site_cap_home01` · `planner_build_tag` **v28** · `|grid_setpoint_w|` ≈ **13,5 kW** při typickém večerním load ~1,8 kW. + ## 2026-05-28 — večerní export: oprava home-01 bez prodeje (v27) **Problém (v26, home-01 run 17010):** Večer baterie vybíjela jen do domu (`export_mode` NONE, `grid_setpoint_w` 0). Dva důvody: (1) `evening_early` (`ge_bat=0`) platilo i **po** nejvyšším sell slotu, takže 19–21 h nemohly exportovat; (2) při **drahém importu** (`buy` ≫ ranní `ref_buy`) bilance s `gi≈0` dává `ge_bat≈0` při `bd≈load`, takže tvrdý push na `ge_bat` bez `bd≥load+ge_bat` byl neřešitelný / ignorovaný; **terminal SoC** dále tlumil `z_export`.