diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 346c1e3..bbc99b0 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -31,18 +31,26 @@ 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 +def _export_window_start_by_day( + slots: list[PlanningSlot], degrad: float +) -> dict[date, datetime]: + """Kopie R__063: první sell > min(buy) téhož kalendářního dne (Prague) + degrad.""" + out: dict[date, datetime] = {} for s in slots: day = _prague_date(s) - day_min = min( - float(x.buy_price) for x in slots if _prague_date(x) == day - ) + 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 + prev = out.get(day) + if prev is None or s.interval_start < prev: + out[day] = s.interval_start + return out + + +def _before_day_export( + slots: list[PlanningSlot], t: int, export_by_day: dict[date, datetime] +) -> bool: + start = export_by_day.get(_prague_date(slots[t])) + return start is not None and slots[t].interval_start < start def _future_sell(slots: list[PlanningSlot], t: int) -> float: @@ -55,13 +63,13 @@ def _buy_min_next_n( t: int, n: int = _LOOKAHEAD_SLOTS, *, - export_window_start: datetime | None = None, + export_by_day: dict[date, datetime] | None = None, ) -> float | None: - tail = [ - float(slots[i].buy_price) - for i in range(t + 1, min(t + 1 + n, len(slots))) - if export_window_start is None or slots[i].interval_start < export_window_start - ] + tail: list[float] = [] + for i in range(t + 1, min(t + 1 + n, len(slots))): + day_start = export_by_day.get(_prague_date(slots[i])) if export_by_day else None + if day_start is None or slots[i].interval_start < day_start: + tail.append(float(slots[i].buy_price)) return min(tail) if tail else None @@ -100,7 +108,7 @@ 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 = _export_window_start(slots, degrad) + export_by_day = _export_window_start_by_day(slots, degrad) plan_day = _prague_date(slots[0]) eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0) @@ -161,12 +169,7 @@ def _select_charge_slots( 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 - ) + before_export = 0 if _before_day_export(slots, t, export_by_day) else 1 return (today_first, before_export, int(pred), price, t) if purchase_pricing_mode != "fixed": @@ -733,6 +736,38 @@ class FixedPurchasePricingTests(unittest.TestCase): night = {t for t in out if _prague_hour(slots[t]) < 8} self.assertGreater(len(night), 0, "očekáváno grid nabíjení v noci před večerním výkupem") + def test_fixed_nt_charge_after_yesterday_evening_peak(self) -> None: + """Včerejší večerní peak nesmí zablokovat dnešní 00–06 grid (per-day export okno).""" + base = datetime(2026, 5, 23, 20, 0, tzinfo=_PRAGUE) + slots: list[PlanningSlot] = [] + for i in range(64): + t = base + timedelta(minutes=15 * i) + sell = 3.8 if t.hour >= 20 and t.date() == date(2026, 5, 23) else 2.9 + if t.date() == date(2026, 5, 24) and t.hour >= 19: + sell = 3.7 + slots.append( + _slot( + buy=3.088, + sell=sell, + load=200, + interval_start=t.astimezone(timezone.utc), + ) + ) + battery = _battery(charge_buf=1.3, uc_wh=12_500.0) + out = _select_charge_slots( + slots, + battery, + current_soc_wh=0.33 * battery.usable_capacity_wh, + purchase_pricing_mode="fixed", + ) + may24_night = { + t + for t in out + if _prague_date(slots[t]) == date(2026, 5, 24) + and _prague_hour(slots[t]) < 6 + } + self.assertGreater(len(may24_night), 0) + def test_fixed_allows_discharge_on_high_sell(self) -> None: slots = [ _slot(buy=3.09, sell=1.0, hour_utc=10), 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 4d6639e..15235d3 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -304,24 +304,33 @@ begin from _ems_plan_slot_wk wk where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12; - -- 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 > ( - 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 add column if not exists future_sell_lookahead numeric, add column if not exists buy_min_next_n numeric, add column if not exists store_score numeric, - add column if not exists allow_grid_charge boolean default false; + add column if not exists allow_grid_charge boolean default false, + add column if not exists export_window_start_at timestamptz; + + -- První výkupní okno **per kalendářní den** (Prague). Globální min přes dny by + -- zablokoval NT grid nabíjení (včerejší večerní peak → dnešní 00–06 už „po okně“). + update _ems_plan_slot_wk wk + set export_window_start_at = ( + select min(wx.interval_start) + from _ems_plan_slot_wk wx + where (wx.interval_start at time zone 'Europe/Prague')::date + = (wk.interval_start at time zone 'Europe/Prague')::date + and wx.sell_price > ( + select coalesce(min(w2.buy_price), wx.buy_price) + v_degrad_czk_kwh + from _ems_plan_slot_wk w2 + where (w2.interval_start at time zone 'Europe/Prague')::date + = (wx.interval_start at time zone 'Europe/Prague')::date + ) + ); + + select min(wk.export_window_start_at) + into v_export_window_start + from _ems_plan_slot_wk wk; update _ems_plan_slot_wk wk set @@ -339,8 +348,8 @@ begin where w2.slot_ord > wk.slot_ord and w2.slot_ord <= wk.slot_ord + v_lookahead_slots and ( - v_export_window_start is null - or w2.interval_start < v_export_window_start + wk.export_window_start_at is null + or w2.interval_start < wk.export_window_start_at ) ), store_score = @@ -431,8 +440,8 @@ begin else 1 end, case - when v_export_window_start is not null - and wk.interval_start < v_export_window_start + when wk.export_window_start_at is not null + and wk.interval_start < wk.export_window_start_at then 0 else 1 end, @@ -479,8 +488,8 @@ begin else 1 end, case - when v_export_window_start is not null - and wk.interval_start < v_export_window_start + when wk.export_window_start_at is not null + and wk.interval_start < wk.export_window_start_at then 0 else 1 end, @@ -522,8 +531,8 @@ begin else 1 end, case - when v_export_window_start is not null - and wk.interval_start < v_export_window_start + when wk.export_window_start_at is not null + and wk.interval_start < wk.export_window_start_at then 0 else 1 end, @@ -567,8 +576,8 @@ begin else 1 end, case - when v_export_window_start is not null - and wk.interval_start < v_export_window_start + when wk.export_window_start_at is not null + and wk.interval_start < wk.export_window_start_at then 0 else 1 end, diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 1f30df9..17d7960 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -108,6 +108,18 @@ where pr.site_id = (select id from ems.site where code='BA81') and pr.status='ac --- +## 2026-05-24 (d) — BA81: grid jen 1 slot (globální export okno) + +**Problém:** Run **15820** — mírné zlepšení (1× ~4,5 kW grid+bat o půlnoci), ale **00:45–05:45 `allow_charge=false`**, max nabíjení pořád ~3,3 kW z FVE. + +**Příčina:** `v_export_window_start` = **min přes celý horizont** (včerejší večerní sell 3,7 → čas ~22:15). Grid vrstva B řadí „před oknem“ vůči tomuto **jednomu** času → dnešní NT sloty (00–06) už jsou „po okně“ a nedostanou `allow_grid_charge`. + +**Oprava:** Sloupec **`export_window_start_at` per kalendářní den** (Prague); grid AM/PM i `buy_min_next_n` používají `wk.interval_start < wk.export_window_start_at`. + +**Deploy:** `flyway migrate` (R__063) + replan. + +--- + ## Šablona pro další záznamy ```markdown