diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 0b7aba2..20950a1 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -32,10 +32,17 @@ def _future_sell(slots: list[PlanningSlot], t: int) -> float: return max(tail) if tail else float(slots[t].sell_price) -def _buy_min_next_n(slots: list[PlanningSlot], t: int, n: int = _LOOKAHEAD_SLOTS) -> float | None: +def _buy_min_next_n( + slots: list[PlanningSlot], + t: int, + n: int = _LOOKAHEAD_SLOTS, + *, + export_window_start: 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 ] return min(tail) if tail else None @@ -74,6 +81,12 @@ def _select_charge_slots( (float(s.buy_price) for s in slots if _prague_hour(s) >= 12), 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 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) @@ -117,18 +130,26 @@ def _select_charge_slots( buy = float(s.buy_price) if buy > ref_buy_seg + _BUY_CHARGE_BAND: return False - nxt = _buy_min_next_n(slots, t) + nxt = _buy_min_next_n(slots, t, export_window_start=export_window_start) if nxt is not None and buy > nxt + _BUY_LOOKAHEAD_EPS: 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) + if purchase_pricing_mode != "fixed": am_candidates = [ (t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price)) for t in range(len(slots)) if _grid_b_ok(t, ref_buy_am) and _prague_hour(slots[t]) < 12 ] - am_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0])) + am_candidates.sort(key=lambda x: _grid_sort_key(x[0], x[1], x[2])) cum = 0.0 grid_am = 0 for t, _pred, _price in am_candidates: @@ -144,7 +165,7 @@ def _select_charge_slots( for t in range(len(slots)) if _grid_b_ok(t, ref_buy_pm) and _prague_hour(slots[t]) >= 12 ] - pm_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0])) + pm_candidates.sort(key=lambda x: _grid_sort_key(x[0], x[1], x[2])) cum = 0.0 grid_pm = 0 for t, _pred, _price in pm_candidates: @@ -326,6 +347,37 @@ class SelectChargeSlotsTests(unittest.TestCase): out = _select_charge_slots(slots, battery, current_soc_wh=0.0) self.assertIn(2, out, "Nejlevnější buy v horizontu (PM) musí být vybrán") + def test_pm_grid_prefers_today_before_export_over_tomorrow_cheaper(self) -> None: + """Dnes PM levné před večerním exportem má prioritu před zítřejším min(buy).""" + base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc) + slots = [ + _slot(buy=0.72, sell=-0.1, pv=500, load=3000, interval_start=base), + _slot(buy=0.68, sell=-0.15, pv=500, load=3000, interval_start=base + timedelta(minutes=15)), + _slot( + buy=5.5, + sell=3.8, + pv=0, + load=2500, + interval_start=base + timedelta(hours=7, minutes=30), + ), + _slot( + buy=0.50, + sell=-0.3, + pv=2000, + load=5000, + interval_start=base + timedelta(hours=26), + ), + ] + battery = _battery(uc_wh=64_000.0) + out = _select_charge_slots(slots, battery, current_soc_wh=0.46 * battery.usable_capacity_wh) + self.assertIn(0, out) + self.assertIn(1, out) + self.assertEqual( + min(out), + 0, + "Před exportním oknem musí být vybrány dnešní levné PM sloty dřív než zítřejší min(buy)", + ) + def test_cheap_pm_with_pv_surplus_gets_grid_charge(self) -> None: """Regrese home-01: levné PM VT (~0,8) i s FVE musí projít grid maskou B.""" base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc) 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 8ad37b3..114b8a3 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -82,6 +82,7 @@ declare v_est_pv_wh numeric; v_est_grid_cost numeric; v_est_pv_cost numeric; + v_export_window_start timestamptz; begin drop table if exists _ems_plan_slot_wk; create temp table _ems_plan_slot_wk on commit drop as @@ -288,11 +289,19 @@ 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. + 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; + -- 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 store_score numeric, + add column if not exists allow_grid_charge boolean default false; update _ems_plan_slot_wk wk set @@ -309,6 +318,10 @@ begin from _ems_plan_slot_wk w2 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 + ) ), store_score = coalesce( @@ -385,12 +398,23 @@ begin wk.buy_min_next_n is null or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps ) - order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord + order by + case + when v_export_window_start is not null + and wk.interval_start < v_export_window_start + then 0 + else 1 + end, + wk.is_predicted_price::int, + wk.buy_price, + wk.slot_ord loop exit when v_cum >= v_chg_am_wh; exit when v_per_slot_charge_wh <= 0; exit when v_grid_slots_am >= v_grid_charge_cap_am; - update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; + update _ems_plan_slot_wk wk + set allow_charge = true, allow_grid_charge = true + where wk.slot_ord = r_slot.slot_ord; v_cum := v_cum + v_per_slot_charge_wh; v_grid_slots_am := v_grid_slots_am + 1; end loop; @@ -408,12 +432,23 @@ begin wk.buy_min_next_n is null or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps ) - order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord + order by + case + when v_export_window_start is not null + and wk.interval_start < v_export_window_start + then 0 + else 1 + end, + wk.is_predicted_price::int, + wk.buy_price, + wk.slot_ord loop exit when v_cum >= v_chg_pm_wh; exit when v_per_slot_charge_wh <= 0; exit when v_grid_slots_pm >= v_grid_charge_cap_pm; - update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; + update _ems_plan_slot_wk wk + set allow_charge = true, allow_grid_charge = true + where wk.slot_ord = r_slot.slot_ord; v_cum := v_cum + v_per_slot_charge_wh; v_grid_slots_pm := v_grid_slots_pm + 1; end loop; @@ -471,11 +506,11 @@ begin from _ems_plan_slot_wk wk where wk.allow_discharge_export; - -- Acquisition: vážený buy v allow_charge slotech před 1. exportem (ne future_sell z FVE). + -- Acquisition: jen grid vrstva B (ne odpolední FVE z vrstvy A) před 1. exportem. select coalesce(sum( case - when wk.allow_charge + when wk.allow_grid_charge and ( v_acquisition_cutoff is null or wk.interval_start < v_acquisition_cutoff @@ -486,7 +521,7 @@ begin ), 0), coalesce(sum( case - when wk.allow_charge + when wk.allow_grid_charge and ( v_acquisition_cutoff is null or wk.interval_start < v_acquisition_cutoff @@ -494,10 +529,22 @@ begin then wk.buy_price * v_per_slot_charge_wh else 0 end - ), 0) + ), 0), + min( + case + when wk.allow_grid_charge + and ( + v_acquisition_cutoff is null + or wk.interval_start < v_acquisition_cutoff + ) + then wk.buy_price + else null + end + ) into v_est_grid_wh, - v_est_grid_cost + v_est_grid_cost, + v_charge_acquisition from _ems_plan_slot_wk wk; v_est_pv_wh := 0; @@ -505,12 +552,13 @@ begin if v_est_grid_wh > 0 then v_charge_acquisition := v_est_grid_cost / v_est_grid_wh; - else + elsif v_charge_acquisition is null then v_charge_acquisition := coalesce( (v_ref_buy_am_czk_kwh + v_ref_buy_pm_czk_kwh) / 2.0, v_ref_buy_czk_kwh ); end if; + -- v_charge_acquisition z min(grid) zůstane, pokud je jen jeden grid slot před exportem return query with night_tot as ( diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 7c49a3b..4376d00 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` (polovina deficitu na dopoledne, polovina na odpoledne — cyklus „dnes PM nabít / večer prodat / zítra znovu“). `buy ≤ min(buy v pásmu AM nebo PM) + 0,40 Kč`, lookahead 4 sloty, **i při FVE surplus**; počet slotů `ceil(rozpočet_Wh / per_slot_charge_wh)` (max 24). **FVE (vrstva A):** doplní zbytek po B dle `store_score`. **KV1/fixed:** vrstva B vypnutá. **`charge_acquisition`:** vážený `buy` v `allow_charge` před 1. exportem (ne `future_sell`). + - **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). - **`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.