From 2e27c8c5de11604e43f685fdd03d49685205259e Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 25 May 2026 12:20:51 +0200 Subject: [PATCH] oprava exportu bateir do site vecer --- backend/services/planning_engine.py | 49 +++++++++++++------- backend/tests/test_planning_dispatch_milp.py | 7 +-- docs/04-modules/planning.md | 15 +++--- docs/planning-changelog.md | 8 ++++ 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 18d7008..08eaa44 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -68,11 +68,13 @@ 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-v26" +PLANNER_BUILD_TAG = "2026-05-28-evening-peak-full-export-v27" 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 EVENING_PEAK_SELL_EPS_CZK_KWH = 0.05 +# Převáží terminal SoC shadow price při krátkém večerním horizontu (home-01). +EVENING_PUSH_Z_EXPORT_BONUS_CZK = 2500.0 # buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B). PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0 CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru @@ -818,8 +820,8 @@ def _evening_push_battery_export_w( grid: Any, ) -> float: """ - Nejvyšší ge_bat v push slotu při load-first: bd+ge_bat ≤ max_discharge, gi ≤ load+bc_gi. - Prakticky max export z baterie ≈ min(site/inverter cap, max_discharge − load). + 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. """ cap = _battery_export_cap_w(battery, grid) load_w = max(0.0, float(slot.load_baseline_w)) @@ -827,7 +829,10 @@ def _evening_push_battery_export_w( 0.0, float(battery.max_discharge_power_w) - load_w, ) - return min(cap, discharge_headroom) + if discharge_headroom <= 0.0: + return 0.0 + export_from_balance = discharge_headroom / 2.0 + return min(cap, discharge_headroom, export_from_balance) def _dispatch_grid_setpoint_w( @@ -1575,17 +1580,23 @@ def solve_dispatch( max_evening_sell_by_day.get(d_ev, 0.0), float(s_ev.sell_price), ) - for t_ev, s_ev in enumerate(slots): - if _prague_hour(s_ev) < 17: - continue - if t_ev not in profitable_export_ts_pre or t_ev not in discharge_export_slots: - continue - if t_ev in evening_push_ts: - continue - d_ev = _prague_calendar_date(s_ev) - peak_sell = max_evening_sell_by_day.get(d_ev, 0.0) - if float(s_ev.sell_price) < peak_sell - EVENING_PEAK_SELL_EPS_CZK_KWH: - evening_early_export_penalty_ts.add(t_ev) + # Zákaz ge_bat jen *před* prvním push slotem (ne po něm — jinak terminal SoC + load + # drží energii pro 19–21 h bez prodeje, home-01 téměř neexportuje). + first_evening_push_t = min(evening_push_ts) if evening_push_ts else None + if first_evening_push_t is not None: + for t_ev, s_ev in enumerate(slots): + if _prague_hour(s_ev) < 17: + continue + if t_ev >= first_evening_push_t: + continue + if t_ev not in profitable_export_ts_pre or t_ev not in discharge_export_slots: + continue + if t_ev in evening_push_ts: + continue + d_ev = _prague_calendar_date(s_ev) + peak_sell = max_evening_sell_by_day.get(d_ev, 0.0) + if float(s_ev.sell_price) < peak_sell - EVENING_PEAK_SELL_EPS_CZK_KWH: + evening_early_export_penalty_ts.add(t_ev) last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy( slots, first_neg_buy_idx ) @@ -1947,7 +1958,7 @@ def solve_dispatch( if t in discharge_export_slots and t in profitable_export_ts_pre ) + pulp.lpSum( - -250.0 * z_export[t] + -EVENING_PUSH_Z_EXPORT_BONUS_CZK * z_export[t] for t in evening_push_ts ) ) @@ -2002,7 +2013,11 @@ def solve_dispatch( slots[t_peak], battery, grid ) if push_floor_w >= GE_MIN_EXPORT_W: - prob += ge_bat[t_peak] >= push_floor_w * z_export[t_peak] + 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 diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 59f59b5..ba6ad6b 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -2239,18 +2239,19 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap["planner_build_tag"], "2026-05-28-evening-peak-full-export-v26") + self.assertEqual(snap["planner_build_tag"], "2026-05-28-evening-peak-full-export-v27") peak_idx = sells.index(4.04) peak = results[peak_idx] self.assertIn(peak.export_mode, ("BATTERY_SELL", "PV_SURPLUS")) self.assertGreater(abs(peak.grid_setpoint_w), 5000) + # v27: ge_bat=0 jen před prvním push slotem, ne u všech sell < peak−0.05. for i, r in enumerate(results): - if i == peak_idx or sells[i] >= 4.04 - 0.05: + if i >= peak_idx: continue self.assertNotEqual( r.export_mode, "BATTERY_SELL", - msg=f"slot {i} sell={sells[i]} must not battery-export before peak", + msg=f"slot {i} sell={sells[i]} must not battery-export before first push", ) def test_evening_battery_export_when_sell_above_acquisition(self) -> None: diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 3d98c24..e61c0ca 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -64,7 +64,7 @@ Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z - pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie) - kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu -### Večerní export z baterie (v24–v26) — co plánovač dělá a co ne +### Večerní export z baterie (v24–v27) — co plánovač dělá a co ne Cíl zůstává **maximální ekonomický užitek v celém horizontu**: prodat (a nabít) v časech, kdy to dává smysl podle cen a kapacity baterie. Večerní logika **neřeší ráno před FVE** a **nevnucuje jediný slot**. @@ -91,16 +91,17 @@ flowchart TD 1. **SQL masky (R__063, vrstva 2)** — které večerní sloty *smí* export z baterie vůbec (`allow_discharge_export`): mimo jiné sloty v pásmu „denní večerní max − degrad“ (SQL), plus globální Wh rozpočet (vrstva 1). -2. **v26 — zákaz předčasného večerního vývozu** (`evening_early_export_penalty_ts` → tvrdé `ge_bat[t] = 0`): - - jen **hodiny ≥ 17** téhož kalendářního dne; - - jen pokud `sell` je **výrazně nižší** než denní večerní maximum: `sell < max_večer − 0,05` Kč/kWh (`EVENING_PEAK_SELL_EPS_CZK_KWH`); +2. **v27 — zákaz předčasného večerního vývozu** (`evening_early_export_penalty_ts` → tvrdé `ge_bat[t] = 0`): + - jen **hodiny ≥ 17** a **časově před** prvním slotem v `evening_push_ts` (ne po něm — v26 blokovalo i 19–21 h); + - jen pokud `sell < max_večer − 0,05` Kč/kWh; - **nezakazuje** přebytek FVE do sítě (`ge_pv`). -3. **v24 + v26 — plný výkon v top večerních slotech** (`evening_push_ts`): +3. **v24 + v27 — plný výkon v top večerních slotech** (`evening_push_ts`): - 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`); - - **výsledek:** jeden nejdražší slot → jeden slot na plný výkon; několik slotů na 4,0–4,2 Kč → několik slotů na plný výkon; málo SoC → jen 1–2 nejlepší. + - **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); + - **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“. @@ -112,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-v26`. +**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`. ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index a8f8582..257be59 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: 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`. + +**Změna (tag `2026-05-28-evening-peak-full-export-v27`):** `evening_early` jen pro sloty **před** `min(evening_push_ts)`; push: `ge_bat` cap ≈ `(max_discharge−load)/2`, `bd+ge_bat≥load+ge_bat`, `z_export=1`; vyšší bonus `EVENING_PUSH_Z_EXPORT_BONUS_CZK`. Detail: [`planning.md`](04-modules/planning.md). + +**Ověření:** `pytest … -k evening_peak_battery_export` · po deployi `planner_build_tag` **v27** · večerní špička: `BATTERY_SELL` a `|grid_setpoint_w|` řádově kW (ne jen vybíjení do load). + ## 2026-05-28 — večerní export: plný výkon u top sell, bez předčasného vybití (v26) **Problém:** Ve **stejném večeru** LP rozlévalo vývoz baterie do více slotů v širokém pásmu „denní večerní max − degrad“ (řádově 0,15 Kč/kWh), často jen na **~50 %** výkonu (např. ~3,1 kW místo 6,25 kW u BA81). Před **nejdražší** čtvrthodinou už nezůstala energie na plný výkon; Deye pak jede na hard cap, ale plán to neodrážel (`grid_setpoint_w ≈ −1` při `BATTERY_SELL` u home-01).