From d3e9caf0fbfbdf632e9d4fbb157af7e18f6d9dab Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 29 May 2026 23:34:16 +0200 Subject: [PATCH] dalsi --- backend/services/planning_engine.py | 28 +++++- backend/tests/test_planning_dispatch_milp.py | 87 ++++++++++++++++++- .../R__063_fn_load_planning_slots_full.sql | 23 +++-- docs/04-modules/planning.md | 11 ++- docs/planning-changelog.md | 16 ++++ 5 files changed, 154 insertions(+), 11 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index b8e8efa..8b663eb 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-29-night-selfconsume-evening-arb-v43" +PLANNER_BUILD_TAG = "2026-05-29-neg-day-pv-headroom-v44" # Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu). NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0 # Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). @@ -895,6 +895,20 @@ def _neg_sell_pv_b_charge_wh(slot: PlanningSlot, battery: Any) -> float: return cap_w * INTERVAL_H * float(battery.charge_efficiency) +def _neg_sell_pv_forecast_charge_wh(slot: PlanningSlot, battery: Any) -> float: + """Odhad Wh z FVE A+B v sell<0 slotu pro zpětnou projekci soc_need (v44).""" + pv_surplus = max( + 0.0, + float(slot.pv_a_forecast_w) + + float(slot.pv_b_forecast_w) + - float(slot.load_baseline_w), + ) + if pv_surplus <= 500.0: + return 0.0 + cap_w = min(pv_surplus, float(battery.max_charge_power_w)) + return cap_w * INTERVAL_H * float(battery.charge_efficiency) + + def _neg_sell_day_pv_b_usable_wh( slots: list[PlanningSlot], first_neg_sell_idx: int | None, @@ -975,7 +989,9 @@ def _neg_sell_day_phases( indices.sort() last_t = indices[-1] tail_start = max(indices[0], last_t - tail_n + 1) if tail_n > 0 else last_t + 1 - charge_b = {t: _neg_sell_pv_b_charge_wh(slots[t], battery) for t in indices} + charge_b = { + t: _neg_sell_pv_forecast_charge_wh(slots[t], battery) for t in indices + } soc_need: dict[int, float] = {last_t: soc_max} for i in range(len(indices) - 1, 0, -1): t_cur = indices[i] @@ -3586,6 +3602,14 @@ def solve_dispatch( # v33: při dostatečné FVE v sell<0 okně neukládat ranní PV do baterie — export. prob += bc_pv[t_pne] == 0 + # v44: neg den — před 1. sell<0 žádné grid→bat (AM sloty za ~3 Kč vs FVE v okně). + if neg_sell_phases_en and first_neg_sell_idx is not None: + neg_day = _prague_calendar_date(slots[first_neg_sell_idx]) + for t_blk in range(first_neg_sell_idx): + if _prague_calendar_date(slots[t_blk]) != neg_day: + continue + prob += bc_gi[t_blk] == 0 + # Ekonomické guardy: ceny v objective nestačí proti maskám / terminal SoC. # Referenční buy jen z ne-záporných slotů: jinak jeden buy<0 v horizontu označí # téměř všechny sloty jako „drahé“ (gi=0 pro dům) → Infeasible (home-01). diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index ae1368e..f326e06 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -4260,10 +4260,19 @@ class NegSellSocPhaseTests(unittest.TestCase): _ph, _tg, _w, meta = _neg_sell_day_phases(slots, bat) self.assertIsNotNone(meta.get("t_detach_idx")) self.assertGreaterEqual(int(meta["t_detach_idx"]), 0) - self.assertLess(int(meta["t_detach_idx"]), 8) + self.assertLessEqual(int(meta["t_detach_idx"]), 8) self.assertGreater(float(meta.get("e_surplus_after_t_wh") or 0), 0.0) self.assertIn("post_detach_prep_ts", meta) + def test_prep_leaves_headroom_when_pv_a_b_forecast_high(self) -> None: + """v44: zpětná soc_need z A+B FVE, ne jen B — 1. sell<0 cíl pod soc_max.""" + slots = self._neg_sell_slots(12, pv_a=8000, pv_b=6000) + bat = self._phase_battery(tail_slots=4) + _ph, targets, _w, meta = _neg_sell_day_phases(slots, bat) + first_neg = int(meta["days"][0]["first_neg_idx"]) + tgt_first = float(targets[first_neg] or 0) + self.assertLess(tgt_first, bat.soc_max_wh * 0.95) + def test_t_detach_not_first_neg_on_long_sunny_day(self) -> None: """Bod T až po nabití rampy (~85 % soc_max), ne na prvním sell<0 slotu.""" slots = self._neg_sell_slots(24, pv_b=7000, pv_a=5000) @@ -4703,6 +4712,82 @@ class NegSellPrepWindowV36Tests(unittest.TestCase): self.assertIsNotNone(snap["inputs"].get("neg_evening_export_budget_wh")) +class NegDayPvHeadroomV44Tests(unittest.TestCase): + """v44: neg den — žádný grid před sell<0; headroom pro FVE + levný buy v okně.""" + + def test_no_grid_charge_before_first_negative_sell(self) -> None: + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 5, 30, 5, 45, tzinfo=prague) + slots: list[PlanningSlot] = [] + first_neg_idx: int | None = None + for i in range(24): + local = base + timedelta(minutes=15 * i) + sell = ( + -0.18 + if local.hour > 7 or (local.hour == 7 and local.minute >= 45) + else 3.0 + ) + if first_neg_idx is None and sell < 0: + first_neg_idx = i + buy = 3.2 if local.hour < 8 else 0.48 + allow_chg = sell < 0 + slots.append( + PlanningSlot( + interval_start=local.astimezone(timezone.utc), + buy_price=buy, + sell_price=sell, + pv_a_forecast_w=4000 if local.hour >= 8 else 500, + pv_b_forecast_w=3000 if local.hour >= 8 else 500, + load_baseline_w=2000, + ev1_connected=False, + ev2_connected=False, + allow_charge=allow_chg, + allow_discharge_export=True, + ) + ) + self.assertIsNotNone(first_neg_idx) + bat = NegSellSocPhaseTests._phase_battery() + 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, + block_export_on_negative_sell=False, + ) + 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 + ), + ] + res, _, _ = solve_dispatch( + slots, + bat, + hp, + grid, + [None, None], + vehicles, + 0.50 * bat.soc_max_wh, + 50.0, + operating_mode="AUTO", + ) + assert first_neg_idx is not None + for t in range(first_neg_idx): + self.assertLessEqual( + res[t].battery_setpoint_w, + 200, + msg=f"grid/PV bat charge before neg at slot {t}", + ) + self.assertLess( + res[first_neg_idx].battery_soc_target, + 92.0, + "baterie nesmí být plná těsně před sell<0 oknem", + ) + + class ObservedSocNegPrepTests(unittest.TestCase): """v40: neg-prep a večerní výboj z pozorovaného SoC (telemetrie), ne z LP trajektorie.""" diff --git a/db/routines/R__063_fn_load_planning_slots_full.sql b/db/routines/R__063_fn_load_planning_slots_full.sql index de542d4..07960ef 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -874,7 +874,7 @@ begin end if; end loop; - -- v43: levný grid před prvním sell<0, když tentýž den večer (≥17h) dává arbitráž buy→sell. + -- v43: levný grid před prvním sell<0 jen na dnech BEZ sell<0 (normální arbitráž odpoledne→večer). update _ems_plan_slot_wk wk set allow_charge = true, allow_grid_charge = true, @@ -899,12 +899,25 @@ begin and wk.buy_price >= 0 and wk.buy_price + v_degrad_czk_kwh < ep.evening_peak_sell and extract(hour from wk.interval_start at time zone 'Europe/Prague') - < v_evening_peak_start_hour - and ( - v_first_neg_sell_ord is null - or wk.slot_ord < v_first_neg_sell_ord + between 11 and 16 + and not exists ( + select 1 + from _ems_plan_slot_wk wn + where wn.sell_price < 0 + and (wn.interval_start at time zone 'Europe/Prague')::date = ep.plan_date ); + -- v44: neg den — žádné grid nabíjení před 1. sell<0 (místo pro FVE; ne 3 Kč místo 0,5 Kč v okně). + if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null then + update _ems_plan_slot_wk wk + set allow_charge = false, + allow_grid_charge = false, + grid_charge_suppressed_reason = 'neg_day_no_grid_before_neg_sell' + where wk.slot_ord < v_first_neg_sell_ord + and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date + and wk.allow_grid_charge; + end if; + -- Ranní pásmo před prvním sell<0 (5–11 Prague): lokální peak, ne půlnoc celého dne. if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 4928af3..09a6128 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -12,7 +12,7 @@ - **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]`** — výkon vybíjení na AC sběrnici z energetické bilance `pv + gi + bd = load + bc + ge`. Při exportu z baterie je v `bd` už započten i tok do sítě (`ge_bat` je součást `ge`); **`ge_bat` se v SoC znovu neodečítá** (dříve double-count → plán klesal ~2× rychleji než BMS ve večerním exportu). Tag `2026-05-28-evening-export-soc-balance-v39`. - **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie. - **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP). - - **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). **v43 `evening_arbitrage_unlock`:** před prvním sell<0 povolí grid nabíjení, když tentýž den večer (≥17h) `buy + degrad < evening_peak_sell`. 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`. + - **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). **v43 `evening_arbitrage_unlock`:** grid **11–16h** jen na dnech **bez sell<0**, když večer `buy + degrad < evening_peak_sell`. **v44 `neg_day_no_grid_before_neg_sell`:** na neg den **žádný grid před 1. sell<0**. 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, 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`. @@ -100,10 +100,15 @@ flowchart TD 3. **v43 — večerní push + nocí vlastní spotřeba + odpolední arbitráž** (`evening_push_ts`): - push jen **≥17h Prague** + `allow_discharge_export`; rozpočet Wh **per kalendářní večer** (druhý den v horizontu ne prázdný); - mimo push: **`night_self_consume_discourage`** — baterie krmí dům, ne import ~5 Kč/kWh; - - **R__063 `evening_arbitrage_unlock`:** grid nabíjení odpoledne před sell<0, když večerní peak sell > buy + degrad; + - **R__063 `evening_arbitrage_unlock`:** grid nabíjení **11–16h** jen na dnech **bez sell<0**, když večerní peak sell > buy + degrad; - **bez predawn push** (02–06h); **`peak_export_shortfall`** v noci vypnutý. -**Funkce:** `_evening_push_calendar_segments`, `_night_self_consume_discourage_import_indices`, `_in_evening_push_hour_window`, … Tag: **`2026-05-29-night-selfconsume-evening-arb-v43`**. +4. **v44 — neg den: místo pro FVE před sell<0 oknem:** + - **`neg_day_no_grid_before_neg_sell`:** na kalendářní den s sell<0 **žádné grid nabíjení před 1. sell<0** (ne 3 Kč ráno místo 0,5 Kč v okně); + - **`_neg_sell_pv_forecast_charge_wh`:** zpětná soc_need z **A+B** FVE, ne jen pole B; + - LP **`bc_gi=0`** před 1. sell<0 na neg den. + +**Funkce:** `_evening_push_calendar_segments`, `_night_self_consume_discourage_import_indices`, `_neg_sell_pv_forecast_charge_wh`, … Tag: **`2026-05-29-neg-day-pv-headroom-v44`**. ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index b412b64..fce21fc 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,22 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-29 — Neg den: headroom pro FVE, ne grid za 3 Kč před sell<0 (v44) + +**Problém (v43 na home-01 30. 5.):** Ráno **05:45–07:30** grid+bat nabíjení za **~2,6–3,7 Kč/kWh** → SoC **~99 %** ještě před **07:45 sell<0**. Pak **PV A plně utlumena**, **PV B** do site za záporný sell; levný **buy ~0,48 Kč** v 11h nevyužit. Příčiny: (1) **`evening_arbitrage_unlock`** povolil drahý grid před neg oknem; (2) AM maska brala nejlevnější buy **před polednem**, ne v neg okně; (3) **`soc_need`** zpětně počítal jen **PV B**, ne A+B → cíl prep ≈ **soc_max**. + +**Změna (v44):** +- **`evening_arbitrage_unlock`** jen na dnech **bez sell<0**, hodiny **11–16** (normální odpolední→večerní arbitráž). +- **`neg_day_no_grid_before_neg_sell`:** na neg kalendářní den **`allow_grid_charge=false`** pro všechny sloty **před 1. sell<0**. +- **`_neg_sell_pv_forecast_charge_wh`:** zpětná projekce soc_need z **FVE A+B** surplusu, ne jen B. +- **LP:** `bc_gi[t]=0` před 1. sell<0 na neg den (pás pro případ masky). + +**Soubory:** `planning_engine.py`, `R__063_fn_load_planning_slots_full.sql`, `test_planning_dispatch_milp.py`, `planning.md`. Tag **`2026-05-29-neg-day-pv-headroom-v44`**. + +**Ověření:** `pytest … -k "NegDayPvHeadroom or prep_leaves_headroom"`; MCP: před 07:45 `allow_grid_charge=false`, `grid_charge_suppressed_reason=neg_day_no_grid_before_neg_sell`; SoC před neg < ~90 %; po svítání PV A ne plný curtail. + +--- + ## 2026-05-29 — Noc: vlastní spotřeba + večerní arbitráž + push per den (v43) **Problém:** (1) Po v42 push exportu plán přes noc **držel SoC ~60 %** a krmil dům ze sítě za **~5 Kč/kWh** místo baterie (acq ~0,7 Kč). (2) Tvrdý push zahrnoval **02–06h** (sell < buy). (3) **Druhý večer** v horizontu neměl push — rozpočet Wh se vyčerpal první nocí. (4) Před neg dnem **grid 0,5 Kč** odpoledne nešel nabíjet (`allow_charge=false`, cheaper_pv_ahead), přitom večer sell **~4 Kč** — arbitráž neproběhla.