diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 20950a1..40264eb 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -14,7 +14,7 @@ Discharge-export mask: from __future__ import annotations import unittest -from datetime import datetime, timezone, timedelta +from datetime import date, datetime, timezone, timedelta from types import SimpleNamespace from zoneinfo import ZoneInfo @@ -27,6 +27,24 @@ _BUY_CHARGE_BAND = 0.40 _MAX_GRID_CHARGE_CAP = 24 +def _prague_date(s: PlanningSlot) -> date: + return s.interval_start.astimezone(_PRAGUE).date() + + +def _export_window_start(slots: list[PlanningSlot], degrad: float) -> datetime | None: + """Kopie R__063: sell > min(buy) téhož kalendářního dne (Prague) + degrad.""" + result: datetime | None = None + for s in slots: + day = _prague_date(s) + day_min = min( + float(x.buy_price) for x in slots if _prague_date(x) == day + ) + if float(s.sell_price) > day_min + degrad: + if result is None or s.interval_start < result: + result = s.interval_start + return result + + def _future_sell(slots: list[PlanningSlot], t: int) -> float: tail = [float(slots[i].sell_price) for i in range(t + 1, len(slots))] return max(tail) if tail else float(slots[t].sell_price) @@ -82,11 +100,8 @@ def _select_charge_slots( default=min(float(s.buy_price) for s in slots), ) ref_buy_global = min(float(s.buy_price) for s in slots) - export_window_start: datetime | None = None - for s in slots: - if float(s.sell_price) > ref_buy_global + degrad: - if export_window_start is None or s.interval_start < export_window_start: - export_window_start = s.interval_start + export_window_start = _export_window_start(slots, degrad) + plan_day = _prague_date(slots[0]) eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0) max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0) @@ -135,13 +150,15 @@ def _select_charge_slots( return False return True - def _grid_sort_key(t: int, pred: bool, price: float) -> tuple[int, int, float, int]: - before_export = 0 - if export_window_start is not None and slots[t].interval_start < export_window_start: - before_export = 0 - else: - before_export = 1 - return (before_export, int(pred), price, t) + def _grid_sort_key(t: int, pred: bool, price: float) -> tuple[int, int, int, float, int]: + today_first = 0 if _prague_date(slots[t]) == plan_day else 1 + before_export = ( + 0 + if export_window_start is not None + and slots[t].interval_start < export_window_start + else 1 + ) + return (today_first, before_export, int(pred), price, t) if purchase_pricing_mode != "fixed": am_candidates = [ 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 114b8a3..475b78a 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -83,7 +83,9 @@ declare v_est_grid_cost numeric; v_est_pv_cost numeric; v_export_window_start timestamptz; + v_plan_day_prague date; begin + v_plan_day_prague := (p_from at time zone 'Europe/Prague')::date; drop table if exists _ems_plan_slot_wk; create temp table _ems_plan_slot_wk on commit drop as with @@ -289,12 +291,17 @@ begin from _ems_plan_slot_wk wk where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12; - -- První „výkupní“ okno v horizontu (stejná logika jako discharge maska) — grid nabíjení - -- před tím má prioritu (dnes PM levně → dnes večer prodáš), ne nejlevnější slot zítra. + -- První výkupní okno: sell nad min(buy) **téhož kalendářního dne** (Prague), ne globální + -- min(buy) zítra (NT) — jinak okno začne už ~15:30 a dnešní PM grid dostane 1 slot. select min(wk.interval_start) into v_export_window_start from _ems_plan_slot_wk wk - where wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh; + where wk.sell_price > ( + select coalesce(min(w2.buy_price), wk.buy_price) + v_degrad_czk_kwh + from _ems_plan_slot_wk w2 + where (w2.interval_start at time zone 'Europe/Prague')::date + = (wk.interval_start at time zone 'Europe/Prague')::date + ); -- Lookahead min buy (VT→NT) a store_score pro vrstvu A. alter table _ems_plan_slot_wk @@ -399,6 +406,11 @@ begin or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps ) order by + case + when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague + then 0 + else 1 + end, case when v_export_window_start is not null and wk.interval_start < v_export_window_start @@ -433,6 +445,11 @@ begin or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps ) order by + case + when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague + then 0 + else 1 + end, case when v_export_window_start is not null and wk.interval_start < v_export_window_start diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 4376d00..f1f23d9 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -11,7 +11,7 @@ - **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon). - **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):** spot, **AM/PM rozpočet Wh 50/50** z `grid_target`. Výběr slotů: nejdřív sloty **před prvním výkupním oknem** (`sell > min(buy)+degrad`), pak teprve zbytek horizontu — aby dnes PM nevyhrál zítra `min(buy)`. Lookahead VT→NT jen mezi sloty před tímto oknem. **`charge_acquisition`:** vážený `buy` jen u `allow_grid_charge` (ne FVE vrstva A). + - **Grid ze sítě (vrstva B, před FVE):** spot, **AM/PM rozpočet Wh 50/50** z `grid_target`. Výběr: nejdřív **kalendářní den plánu** (`p_from` Prague), pak sloty před **výkupním oknem daného dne** (`sell > min(buy téhož dne)+degrad` — ne globální min zítra). Lookahead VT→NT jen před tímto oknem. **`charge_acquisition`:** vážený `buy` jen u `allow_grid_charge` (ne FVE vrstva A). - **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). - Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny. - **LP ekonomické guardy** (`solve_dispatch`, AUTO): pokud `sell < buy − degradation` → `ge_pv=0` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation` → `gi` jen na load+EV+TČ. Viz `planning_engine.py` sekce po slot pre-selection.