diff --git a/.cursor/rules/ems-planning-agent-discipline.mdc b/.cursor/rules/ems-planning-agent-discipline.mdc new file mode 100644 index 0000000..ac62776 --- /dev/null +++ b/.cursor/rules/ems-planning-agent-discipline.mdc @@ -0,0 +1,37 @@ +--- +description: EMS plánování — doptat se, ekonomický zisk, bez mikrocyklů +alwaysApply: true +--- + +# EMS agent — plánování a ekonomika + +## Doptat se + +- Pokud zadání **není exaktní** (lokalita, časové okno, cílové SoC, co je bug vs. záměr), **vždy se doptat** před větší změnou kódu/SQL. +- Nehádat záměr uživatele (příklad: večerní export za ~3 Kč při buy ~5 Kč může být **správně** pro vyprázdnění před neg dnem). + +## Ekonomický cíl + +- Návrhy a implementace směřuj k **provoznímu zisku** (arbitráž, FVE, neg-sell okno, večerní špičky). +- **Výjimka:** neoptimalizovat **mikrocyklování** (souběžný import + export / zbytečné cykly v jednom slotu). + +## Dvě podlahy SoC (home-01, sloupce v `ems.asset_battery`) + +| Sloupec | % | Role | +|--------|---|------| +| **`reserve_soc_percent`** | 20 | **Export / strategie:** večerní push, ranní peak před `sell<0`, kotvy `neg_evening_reserve_soc_anchors` — cíl „ráno ~20 % před FVE“. Pod tímto plánovač **neplánuje zbytečný export** (V027 komentář). | +| **`min_soc_percent`** | 10 | **Spotřeba domu (Deye PASSIVE):** LP a exekuce smí vybíjet baterii pro load až sem — rezerva na **nenadálou spotřebu**, aby se nekupovalo ze sítě za draho. | +| **`planner_discharge_floor_percent`** | 5 | Jen **LP relaxace** pod `min_soc` (ne provozní cíl). | + +**Nesplést:** vybít kvůli **prodeji** → podlaha **reserve**; vybít kvůli **domu v noci** → může jít k **min_soc**. + +## Neg okno vs. `buy < 0` + +- **`sell < 0`:** export zakázán; **headroom** = místo v baterii pro FVE v okně (v44 `neg_day_no_grid_before_neg_sell`, prep rampa). **Ne** totéž co „vyčerpat před sell<0“ u **`buy < 0`**. +- **`buy < 0`:** levné **nabíjení ze sítě** (priorita importu), ne strategie „vyprázdnit před neg výkupen“. + +Před implementací změny exportních podlah: **zeptat se**, jestli cíl je „k 20 % před svítáním“ vs. „ještě níž pro headroom v sell<0“. + +## Komunikace + +- Bez ritualního „máš pravdu“; konkrétní fakta z DB/MCP, co změnit, jak ověřit. diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 5707623..2d9aef5 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,7 +71,9 @@ 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-30-post-push-night-battery-v47" +PLANNER_BUILD_TAG = "2026-05-31-evening-push-budget-primary-night-v49" +# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). +DAWN_LOW_PV_NO_CURTAIL_W = 1500 # 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). @@ -1636,19 +1638,27 @@ def _evening_peak_export_indices( return sorted(out) +def _planner_discharge_floor_wh(battery: Any) -> float: + """Provozní podlaha vývoje: reserve_soc (domluva), ne jen min_soc.""" + return max( + float(getattr(battery, "min_soc_wh", 0.0)), + float(getattr(battery, "reserve_soc_wh", 0.0)), + ) + + def _evening_push_discharge_budget_wh( *, current_soc_wh: float, - min_soc_wh: float, + discharge_floor_wh: float, soc_max_wh: float, discharge_slot_buffer: float, ) -> float: """ Rozpočet Wh pro tvrdý večerní push — stejný princip jako R__063 (discharge_slot_buffer). - Tvrdý push nesmí překročit energii nad min_soc na začátku horizontu (jinak Infeasible). + Podlaha = reserve_soc (typ. 20 %), ne min_soc (10 %). """ - exportable_full_wh = max(0.0, float(soc_max_wh) - float(min_soc_wh)) - available_wh = max(0.0, float(current_soc_wh) - float(min_soc_wh)) + exportable_full_wh = max(0.0, float(soc_max_wh) - float(discharge_floor_wh)) + available_wh = max(0.0, float(current_soc_wh) - float(discharge_floor_wh)) buf = float(discharge_slot_buffer) if buf <= 0.0: return available_wh @@ -1745,6 +1755,36 @@ def _evening_push_calendar_segments( return [sorted(v) for v in by_date.values() if v] +def _primary_night_export_segment_indices(slots: list[PlanningSlot]) -> set[int]: + """ + První noční epizoda v horizontu (17h → půlnoc → do východu FVE), která platí pro + rozpočet Wh z aktuální SoC. Další večery v horizontu (po dni FVE / nabíjení) se + plánují až vlastním rolling replanem — nesdílí dnešní baterii. + """ + segs = _night_export_window_segments(slots) + if not segs: + return set() + for seg in segs: + if 0 in seg: + return set(seg) + return set(segs[0]) + + +def _evening_push_soc_budget_calendar_segments( + slots: list[PlanningSlot], + discharge_export_ok: set[int] | None = None, +) -> list[list[int]]: + """Kalendářní večery jen v primární noční epizodě — vhodné pro push_budget z current_soc.""" + primary = _primary_night_export_segment_indices(slots) + if not primary: + return [] + return [ + seg + for seg in _evening_push_calendar_segments(slots, discharge_export_ok) + if seg and all(t in primary for t in seg) + ] + + def _night_self_consume_discourage_import_indices( slots: list[PlanningSlot], *, @@ -1786,7 +1826,9 @@ def _evening_battery_export_push_indices( ) -> list[int]: """ Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh - **per kalendářní večer** — druhý den v horizontu nedostane nulový push (v42 bug). + z aktuální SoC jen pro **primární noční epizodu** (dnešní večer → ráno). + Zítřejší večer v horizontu se nekrade polovinou budgetu (v43 split) — nabije se + přes den / neg okno; push přidá zítřejší rolling replan. per_slot_discharge_wh: min(BMS, export cap) × účinnost × 0,25 h. """ _ = evening_start_hour # kompatibilita volání @@ -1794,41 +1836,45 @@ def _evening_battery_export_push_indices( return [] push_budget_wh = _evening_push_discharge_budget_wh( current_soc_wh=current_soc_wh, - min_soc_wh=min_soc_wh, + discharge_floor_wh=min_soc_wh, soc_max_wh=soc_max_wh, discharge_slot_buffer=discharge_slot_buffer, ) if push_budget_wh < per_slot_discharge_wh * 0.5: return [] - evening_segments = _evening_push_calendar_segments( + evening_segments = _evening_push_soc_budget_calendar_segments( slots, discharge_export_ok=discharge_export_ok, ) if not evening_segments: return [] - seg_budget_wh = push_budget_wh / float(len(evening_segments)) - out: list[int] = [] + candidates: list[int] = [] + seen: set[int] = set() for seg in evening_segments: - candidates = _evening_push_segment_candidates( + for t in _evening_push_segment_candidates( slots, seg, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=degrad_czk_kwh, discharge_export_ok=discharge_export_ok, - ) - if not candidates: - continue - ranked = sorted( - candidates, - key=lambda i: (float(slots[i].sell_price), -i), - reverse=True, - ) - remaining_wh = float(seg_budget_wh) - for t in ranked: - if remaining_wh + 1e-6 < per_slot_discharge_wh: - break - out.append(t) - remaining_wh -= per_slot_discharge_wh + ): + if t not in seen: + seen.add(t) + candidates.append(t) + if not candidates: + return [] + ranked = sorted( + candidates, + key=lambda i: (float(slots[i].sell_price), -i), + reverse=True, + ) + remaining_wh = float(push_budget_wh) + out: list[int] = [] + for t in ranked: + if remaining_wh + 1e-6 < per_slot_discharge_wh: + break + out.append(t) + remaining_wh -= per_slot_discharge_wh return sorted(out) @@ -2532,13 +2578,14 @@ def solve_dispatch( export_cap_push_w * float(battery.discharge_efficiency) * INTERVAL_H, ) discharge_buf_pre = float(getattr(battery, "discharge_slot_buffer", 0) or 0) + discharge_floor_wh = _planner_discharge_floor_wh(battery) computed_evening_push_ts = set( _evening_battery_export_push_indices( slots, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, degrad_czk_kwh=float(degradation_cost_effective), current_soc_wh=float(current_soc_wh), - min_soc_wh=float(min_soc_wh), + min_soc_wh=float(discharge_floor_wh), soc_max_wh=float(battery.soc_max_wh), per_slot_discharge_wh=per_slot_push_wh_pre, discharge_slot_buffer=discharge_buf_pre, @@ -3181,11 +3228,13 @@ def solve_dispatch( if om == "AUTO": profitable_export_ts = profitable_export_ts_pre export_push_w = _battery_export_cap_w(battery, grid) + discharge_floor_wh = _planner_discharge_floor_wh(battery) for t_peak in morning_pre_neg_export_ts: if t_peak in profitable_export_ts: if _battery_export_push_defer_to_pv(slots[t_peak]): continue prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] + prob += soc[t_peak] >= float(discharge_floor_wh) for t_pnd in pre_neg_buy_discharge_ts: if _battery_export_push_defer_to_pv(slots[t_pnd]): continue @@ -3206,6 +3255,7 @@ def solve_dispatch( if push_floor_w >= GE_MIN_EXPORT_W: prob += z_export[t_peak] == 1 prob += ge_bat[t_peak] >= push_floor_w + prob += soc[t_peak] >= float(discharge_floor_wh) # Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push). if ( last_pos_sell_pre_neg_buy is not None @@ -3730,6 +3780,10 @@ def solve_dispatch( and not skip_pv_store_block and not fixed_pv_b_export_cap and sell_t < pv_store_val + and not ( + sell_t >= 0.0 + and int(s.pv_a_forecast_w) < DAWN_LOW_PV_NO_CURTAIL_W + ) and not _pv_forced_vent_export_allowed( t, current_soc_wh=current_soc_wh, @@ -4865,6 +4919,15 @@ async def _rolling_evening_push_override( if not isinstance(prev_iso, list) or not prev_iso: return None prev_push = _evening_push_ts_from_iso(slots, [str(x) for x in prev_iso]) + if not prev_push: + return None + budget_eligible = { + t + for seg in _evening_push_soc_budget_calendar_segments(slots, None) + for t in seg + } + if budget_eligible: + prev_push = {t for t in prev_push if t in budget_eligible} if not prev_push: return None prev_peak = inputs.get("evening_push_peak_sell_czk_kwh") diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index c406a74..33c82b9 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -17,7 +17,9 @@ from services.planning_engine import ( _evening_peak_export_indices, _slot_evening_push_profitable, _evening_push_calendar_segments, + _evening_push_soc_budget_calendar_segments, _evening_push_discharge_budget_wh, + _primary_night_export_segment_indices, _in_evening_push_hour_window, _in_night_battery_export_window, _neg_sell_day_phases, @@ -285,28 +287,64 @@ class EveningPushBudgetTests(unittest.TestCase): ) self.assertEqual(push, []) - def test_per_calendar_evening_push_budget_split(self) -> None: - """Dva večery v horizontu → každý dostane část Wh rozpočtu (druhý den ne prázdný).""" + def test_evening_push_budget_only_primary_night_episode(self) -> None: + """v49: Wh z current_soc jen pro první noční epizodu — ne zítřejší večer po dni FVE.""" prague = ZoneInfo("Europe/Prague") slots: list[PlanningSlot] = [] - for day in (25, 26): - for h, m in ((18, 0), (18, 15), (18, 30)): - slots.append( - PlanningSlot( - interval_start=datetime(2026, 5, day, h, m, tzinfo=prague), - buy_price=5.0, - sell_price=4.0 + 0.1 * (h - 18), - pv_a_forecast_w=0, - pv_b_forecast_w=0, - load_baseline_w=800, - ev1_connected=False, - ev2_connected=False, - allow_discharge_export=True, - charge_acquisition_buy_czk_kwh=0.5, - ) + for h, m in ((18, 0), (18, 15), (18, 30)): + slots.append( + PlanningSlot( + interval_start=datetime(2026, 5, 25, h, m, tzinfo=prague), + buy_price=5.0, + sell_price=4.0 + 0.1 * (h - 18), + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=800, + ev1_connected=False, + ev2_connected=False, + allow_discharge_export=True, + charge_acquisition_buy_czk_kwh=0.5, ) - segs = _evening_push_calendar_segments(slots, discharge_export_ok=set(range(len(slots)))) - self.assertEqual(len(segs), 2) + ) + # Denní FVE mezi večery → druhá noční epizoda (zítřejší večer nesmí brát SoC rozpočet). + slots.append( + PlanningSlot( + interval_start=datetime(2026, 5, 26, 11, 0, tzinfo=prague), + buy_price=3.0, + sell_price=2.0, + pv_a_forecast_w=3000, + pv_b_forecast_w=0, + load_baseline_w=800, + ev1_connected=False, + ev2_connected=False, + allow_discharge_export=True, + charge_acquisition_buy_czk_kwh=0.5, + ) + ) + for h, m in ((18, 0), (18, 15), (18, 30)): + slots.append( + PlanningSlot( + interval_start=datetime(2026, 5, 26, h, m, tzinfo=prague), + buy_price=5.0, + sell_price=4.0 + 0.1 * (h - 18), + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=800, + ev1_connected=False, + ev2_connected=False, + allow_discharge_export=True, + charge_acquisition_buy_czk_kwh=0.5, + ) + ) + self.assertEqual(len(_evening_push_calendar_segments(slots, set(range(len(slots))))), 2) + n = len(slots) + budget_segs = _evening_push_soc_budget_calendar_segments( + slots, discharge_export_ok=set(range(n)) + ) + self.assertEqual(len(budget_segs), 1) + self.assertTrue(all(slots[t].interval_start.day == 25 for seg in budget_segs for t in seg)) + primary = _primary_night_export_segment_indices(slots) + self.assertEqual(primary, {0, 1, 2}) bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0) per_slot = 13_500 * 0.95 * 0.25 push = _evening_battery_export_push_indices( @@ -318,24 +356,25 @@ class EveningPushBudgetTests(unittest.TestCase): soc_max_wh=bat.soc_max_wh, per_slot_discharge_wh=per_slot, discharge_slot_buffer=1.5, - discharge_export_ok=set(range(len(slots))), + discharge_export_ok=set(range(n)), ) day25 = {t for t in push if slots[t].interval_start.day == 25} day26 = {t for t in push if slots[t].interval_start.day == 26} self.assertGreaterEqual(len(day25), 1) - self.assertGreaterEqual(len(day26), 1) + self.assertEqual(day26, set(), "zítřejší večer nesmí krást dnešní Wh rozpočet") def test_evening_push_budget_matches_r063_formula(self) -> None: bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0) soc = 0.85 * bat.soc_max_wh + floor = max(bat.min_soc_wh, bat.reserve_soc_wh) budget = _evening_push_discharge_budget_wh( current_soc_wh=soc, - min_soc_wh=bat.min_soc_wh, + discharge_floor_wh=floor, soc_max_wh=bat.soc_max_wh, discharge_slot_buffer=1.5, ) - exportable_full = bat.soc_max_wh - bat.min_soc_wh - available = soc - bat.min_soc_wh + exportable_full = bat.soc_max_wh - floor + available = soc - floor self.assertAlmostEqual(budget, min(available, exportable_full * 1.5)) def test_push_slot_count_follows_wh_budget_not_fixed_top_n(self) -> None: @@ -360,26 +399,27 @@ class EveningPushBudgetTests(unittest.TestCase): ] bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0) per_slot = 17_000 * 0.95 * 0.25 - soc_three_slots = bat.min_soc_wh + 3.2 * per_slot + floor = bat.min_soc_wh + soc_for_budget = floor + 3.2 * per_slot budget = _evening_push_discharge_budget_wh( - current_soc_wh=soc_three_slots, - min_soc_wh=bat.min_soc_wh, + current_soc_wh=soc_for_budget, + discharge_floor_wh=floor, soc_max_wh=bat.soc_max_wh, discharge_slot_buffer=1.5, ) - expected_n = min(3, max(0, int(budget // per_slot))) + expected_n = max(0, int(budget // per_slot)) push = _evening_battery_export_push_indices( slots, charge_acquisition_czk_kwh=0.5, degrad_czk_kwh=0.15, - current_soc_wh=soc_three_slots, - min_soc_wh=bat.min_soc_wh, + current_soc_wh=soc_for_budget, + min_soc_wh=floor, soc_max_wh=bat.soc_max_wh, per_slot_discharge_wh=per_slot, discharge_slot_buffer=1.5, ) self.assertEqual(len(push), expected_n) - self.assertEqual(push, [0, 1, 2], "nejdražší sloty první, ne jeden slot") + self.assertEqual(push[:3], [0, 1, 2], "nejdražší sloty první") self.assertNotIn(3, push) # Více SoC → více push slotů (dynamicky, ne strop 3). push_hi = _evening_battery_export_push_indices( diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index f86f1fb..05b5f3b 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -97,8 +97,8 @@ flowchart TD - v **celém nočním okně** pro **všechny** sloty s `allow_discharge_export` **mimo** `evening_push_ts` (výjimky: pre-neg / neg-evening větve); - **nezakazuje** přebytek FVE do sítě (`ge_pv`). -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ý); +3. **v43 / v49 — večerní push + nocí vlastní spotřeba + odpolední arbitráž** (`evening_push_ts`): + - push jen **≥17h Prague** + `allow_discharge_export`; **v49:** rozpočet Wh z **aktuální SoC** jen pro **první noční epizodu** v horizontu (dnes večer → ráno), **ne** dělení se zítřejším večerem — zítřek přidá vlastní rolling replan po FVE/neg dni; - mimo push: **`night_self_consume_discourage`** — baterie krmí dům, ne import ~5 Kč/kWh; - **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ý. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 78fb6fe..b58b71c 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,34 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-31 — Večerní push: celý Wh rozpočet jen pro dnešní noc (v49) + +**Problém (v43):** `push_budget / počet_kalendářních_večerů` dělil **aktuální SoC** mezi dnešní a **zítřejší** večer v horizontu — přes den FVE / neg nabíjení. Dnes večer dostal ~polovinu rozpočtu → chyběly sloty (např. 23:15); zítra večer push z dnešní SoC nedává smysl. + +**Změna (v49):** +- **`_primary_night_export_segment_indices`** — první noční epizoda (17h → východ FVE) od začátku horizontu. +- **`_evening_push_soc_budget_calendar_segments`** — push Wh jen pro kalendářní večer v této epizodě; **jeden společný** rozpočet, kandidáti **sell desc** přes zbývající sloty. +- **Hysteréze** (`_rolling_evening_push_override`): drží jen sloty z budget-eligible množiny. + +Tag **`2026-05-31-evening-push-budget-primary-night-v49`**. Zítřejší večer → vlastní rolling replan po dni. + +**Ověření:** `pytest … -k evening_push_budget_only_primary`; MCP: `planner_build_tag` v49, `evening_push_ts` bez zítřejších 18:30+ při replanu dnes večer; více dnešních push slotů při stejné SoC. + +--- + +## 2026-05-31 — Podlaha vývoje reserve 20 %, žádný curtail slabé FVE za úsvitu (v48) + +**Problém (běh 20728, v47):** Večer + **03:00–03:15** ranní peak export → SoC **~13,5 %** (pod **reserve 20 %**). **05:15–06:00 Prague** (= 03:15–03:45 UTC) plán **řeže celou PV A** (`curt_a = pv_a` při ~86–346 W) — `ge_pv=0` kvůli `sell < future_sell` (večerní peak v horizontu). + +**Změna (v48):** +- Rozpočet push + podlaha SoC: **`reserve_soc_wh`**, ne `min_soc_wh` (10 %). +- Ranní peak export: **`soc[t] ≥ reserve`** v peak slotu. +- **`DAWN_LOW_PV_NO_CURTAIL_W`:** při `sell≥0` a `pv_a < 1500 W` neblokovat `ge_pv` (žádný úsvitní curtail). + +Tag **`2026-05-31-reserve-floor-no-dawn-curtail-v48`**. Pravidlo agenta: `.cursor/rules/ems-planning-agent-discipline.mdc`. + +--- + ## 2026-05-30 — Po večerním pushu noc z baterie, ne import za 5 Kč (v47) **Záměr uživatele:** Večerní vývoz za **~3 Kč/kWh** (sell<buy) je **správně** — vyprázdnění před neg dnem/FVE. Špatně je **po pushu držet SoC a kupovat dům za ~5 Kč**.