From ba1cdcbee4ff214141130cd39bb3d43f17e64c98 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Thu, 21 May 2026 13:44:13 +0200 Subject: [PATCH] dalsi fixy --- .../test_planning_charge_slot_selection.py | 166 ++++++++++++------ .../R__063_fn_load_planning_slots_full.sql | 137 ++++++++------- .../planning-arbitrage-accounting.md | 5 +- docs/04-modules/planning.md | 2 +- 4 files changed, 188 insertions(+), 122 deletions(-) diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 10d339b..0b7aba2 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -3,8 +3,8 @@ Logika je v DB: ems.fn_load_planning_slots_full. Kopie algoritmu pro unit testy bez PG. Charge mask: - A) PV-surplus: store_score DESC, dokud PV nepokryje charge target. - B) Non-PV: AM/PM, OTE-first, buy≤ref+degrad, lookahead, cap 6 slotů. + B) Grid ze sítě první: AM/PM 50/50 Wh, buy≤min(buy v pásmu)+band, i s FVE. + A) PV-surplus: store_score DESC, doplní zbytek po vrstvě B. Discharge-export mask: ref_buy = min(buy) celého horizontu. @@ -22,9 +22,9 @@ from services.planning_engine import INTERVAL_H, PlanningSlot _PRAGUE = ZoneInfo("Europe/Prague") _LOOKAHEAD_SLOTS = 4 -_GRID_CHARGE_CAP_AM = 6 -_GRID_CHARGE_CAP_PM = 6 _BUY_LOOKAHEAD_EPS = 0.05 +_BUY_CHARGE_BAND = 0.40 +_MAX_GRID_CHARGE_CAP = 24 def _future_sell(slots: list[PlanningSlot], t: int) -> float: @@ -66,7 +66,14 @@ def _select_charge_slots( reserve_wh = float(getattr(battery, "reserve_soc_wh", 0) or 0) degrad = float(getattr(battery, "degradation_cost_czk_kwh", 0.15) or 0.15) - ref_buy = min(float(s.buy_price) for s in slots) + ref_buy_am = min( + (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_pm = min( + (float(s.buy_price) for s in slots if _prague_hour(s) >= 12), + default=min(float(s.buy_price) for s in slots), + ) 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) @@ -92,8 +99,63 @@ def _select_charge_slots( chg_pm = charge_target_wh - chg_am selected: set[int] = set() + grid_filled_wh = 0.0 - # A) PV-surplus: highest store_score first + cap_am = ( + max(1, min(_MAX_GRID_CHARGE_CAP, int(chg_am / per_slot_full_wh) + 1)) + if per_slot_full_wh > 0 + else 6 + ) + cap_pm = ( + max(1, min(_MAX_GRID_CHARGE_CAP, int(chg_pm / per_slot_full_wh) + 1)) + if per_slot_full_wh > 0 + else 6 + ) + + def _grid_b_ok(t: int, ref_buy_seg: float) -> bool: + s = slots[t] + buy = float(s.buy_price) + if buy > ref_buy_seg + _BUY_CHARGE_BAND: + return False + nxt = _buy_min_next_n(slots, t) + if nxt is not None and buy > nxt + _BUY_LOOKAHEAD_EPS: + return False + return True + + 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])) + cum = 0.0 + grid_am = 0 + for t, _pred, _price in am_candidates: + if cum >= chg_am or per_slot_full_wh <= 0 or grid_am >= cap_am: + break + selected.add(t) + cum += per_slot_full_wh + grid_am += 1 + grid_filled_wh += cum + + pm_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_pm) and _prague_hour(slots[t]) >= 12 + ] + pm_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0])) + cum = 0.0 + grid_pm = 0 + for t, _pred, _price in pm_candidates: + if cum >= chg_pm or per_slot_full_wh <= 0 or grid_pm >= cap_pm: + break + selected.add(t) + cum += per_slot_full_wh + grid_pm += 1 + grid_filled_wh += cum + + pv_layer_cap = max(charge_target_wh - grid_filled_wh, 0.0) pv_candidates: list[tuple[int, float, float]] = [] for t, s in enumerate(slots): pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w) @@ -103,58 +165,11 @@ def _select_charge_slots( pv_candidates.sort(key=lambda x: (-x[1], x[0])) cum = 0.0 for t, _score, pv_surplus_w in pv_candidates: - if cum >= charge_target_wh: + if cum >= pv_layer_cap: break selected.add(t) cum += min(pv_surplus_w, max_p_w) * eta * INTERVAL_H - if purchase_pricing_mode == "fixed": - return selected - - def _grid_b_ok(t: int) -> bool: - s = slots[t] - if max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w) > 0: - return False - buy = float(s.buy_price) - sell = float(s.sell_price) - if buy > ref_buy + degrad: - return False - nxt = _buy_min_next_n(slots, t) - if nxt is not None and buy > nxt + _BUY_LOOKAHEAD_EPS: - return False - return True - - # B) AM - am_candidates = [ - (t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price)) - for t in range(len(slots)) - if t not in selected and _grid_b_ok(t) and _prague_hour(slots[t]) < 12 - ] - am_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0])) - cum = 0.0 - grid_am = 0 - for t, _pred, _price in am_candidates: - if cum >= chg_am or per_slot_full_wh <= 0 or grid_am >= _GRID_CHARGE_CAP_AM: - break - selected.add(t) - cum += per_slot_full_wh - grid_am += 1 - - pm_candidates = [ - (t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price)) - for t in range(len(slots)) - if t not in selected and _grid_b_ok(t) and _prague_hour(slots[t]) >= 12 - ] - pm_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0])) - cum = 0.0 - grid_pm = 0 - for t, _pred, _price in pm_candidates: - if cum >= chg_pm or per_slot_full_wh <= 0 or grid_pm >= _GRID_CHARGE_CAP_PM: - break - selected.add(t) - cum += per_slot_full_wh - grid_pm += 1 - return selected @@ -311,6 +326,49 @@ 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_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) + slots = [ + _slot( + buy=0.80, + sell=-0.08, + pv=2_500, + load=3_400, + interval_start=base, + ), + _slot( + buy=0.72, + sell=-0.13, + pv=500, + load=3_400, + interval_start=base + timedelta(minutes=15), + ), + _slot( + buy=2.50, + sell=1.40, + pv=2_000, + load=3_800, + interval_start=base + timedelta(hours=5), + ), + _slot( + buy=5.50, + sell=3.80, + pv=100, + load=2_900, + interval_start=base + timedelta(hours=9), + ), + ] + battery = _battery(uc_wh=64_000.0) + soc = 0.46 * battery.usable_capacity_wh + out = _select_charge_slots(slots, battery, current_soc_wh=soc) + self.assertIn(1, out, "Levnější PM slot (lookahead) má allow_charge i s FVE") + self.assertNotIn( + 2, + out, + "Drahý odpolední slot nemá být v grid maskě B jen kvůli globálnímu min", + ) + def test_vt_before_nt_skips_expensive_pm_slot(self) -> None: """Regrese home-01: 12:45 VT drahý, za 15 min NT levný → PM grid charge ne v 12:45.""" base = datetime(2026, 5, 21, 10, 45, 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 77e6d0f..8ad37b3 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -64,11 +64,16 @@ declare v_night_buf_pct numeric; v_degrad_czk_kwh numeric; v_ref_buy_czk_kwh numeric; + v_ref_buy_am_czk_kwh numeric; + v_ref_buy_pm_czk_kwh numeric; v_purchase_pricing_mode text; v_lookahead_slots int := 4; - v_grid_charge_cap_am int := 6; - v_grid_charge_cap_pm int := 6; + v_grid_charge_cap_am int; + v_grid_charge_cap_pm int; v_buy_lookahead_eps numeric := 0.05; + v_buy_charge_band_czk_kwh numeric := 0.40; + v_grid_filled_wh numeric := 0; + v_pv_layer_cap_wh numeric; v_grid_slots_am int := 0; v_grid_slots_pm int := 0; v_acquisition_cutoff timestamptz; @@ -268,11 +273,21 @@ begin end if; v_discharge_target_wh := v_exportable * v_discharge_buf; - -- Referenční nákup pro arbitráž (celý horizont, ne jen allow_charge). + -- Referenční nákup: globální min (export brána) + per AM/PM pás (grid nabíjení). select coalesce(min(wk.buy_price), 0) into v_ref_buy_czk_kwh from _ems_plan_slot_wk wk; + select coalesce(min(wk.buy_price), v_ref_buy_czk_kwh) + into v_ref_buy_am_czk_kwh + from _ems_plan_slot_wk wk + where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12; + + select coalesce(min(wk.buy_price), v_ref_buy_czk_kwh) + into v_ref_buy_pm_czk_kwh + from _ems_plan_slot_wk wk + where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12; + -- 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, @@ -330,45 +345,42 @@ begin v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh; end if; - -- charge mask: dvě nezávislé vrstvy (tenký anti-mikrocyklus, ekonomika z cen) + if v_per_slot_charge_wh > 0 then + v_grid_charge_cap_am := greatest( + 1, + least(24, ceil(v_chg_am_wh / v_per_slot_charge_wh)::int) + ); + v_grid_charge_cap_pm := greatest( + 1, + least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int) + ); + else + v_grid_charge_cap_am := 6; + v_grid_charge_cap_pm := 6; + end if; + + -- charge mask: grid arbitráž (B) před FVE (A); AM/PM rozpočet Wh zůstává 50/50. -- - -- A) PV-surplus: ranking store_score DESC (future_sell − sell − max(0,buy−sell)). - -- Sloty s nejvyšší hodnotou uložení vs export pokrývají charge target. - -- Zbylé PV-surplus → allow_charge=false (PV jen do sítě / bc≤surplus v LP). - -- - -- B) Non-PV grid: jen spot, buy ≤ ref_buy+degrad, buy ≤ min(next N)+ε, - -- cap K slotů AM/PM; nikdy při sell < buy − degrad (ztrátový slot). + -- B) Grid ze sítě: spot, buy v pásmu AM/PM ≤ min(buy v pásmu)+band, lookahead VT→NT; + -- i při pv_surplus>0; cap slotů ∝ rozpočet Wh / per_slot_charge_wh. + -- A) PV-surplus: store_score DESC, doplní jen zbytek do energy_to_fill po vrstvě B. if v_charge_buf <= 0 then update _ems_plan_slot_wk wk set allow_charge = true; elsif v_energy_to_fill <= 0 then update _ems_plan_slot_wk wk set allow_charge = true; else update _ems_plan_slot_wk wk set allow_charge = false; - - -- A) PV-surplus: nejvyšší store_score (ukládat FVE vs exportovat) - v_cum := 0; - for r_slot in - select wk.slot_ord, wk.pv_surplus_w - from _ems_plan_slot_wk wk - where wk.pv_surplus_w > 0 - and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh - order by wk.store_score desc nulls last, wk.slot_ord - loop - exit when v_cum >= v_grid_target_wh; - update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; - v_cum := v_cum + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25; - end loop; + v_grid_filled_wh := 0; if v_purchase_pricing_mode <> 'fixed' then - -- B) Non-PV AM: OTE-first, levný buy + lookahead, cap slotů + -- B) Grid AM (dříve než PV, vlastní 50 % rozpočtu Wh) v_cum := 0; v_grid_slots_am := 0; for r_slot in select wk.slot_ord from _ems_plan_slot_wk wk - where wk.pv_surplus_w <= 0 - and extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 - and wk.buy_price <= v_ref_buy_czk_kwh + v_degrad_czk_kwh + where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 + and wk.buy_price <= v_ref_buy_am_czk_kwh + v_buy_charge_band_czk_kwh and ( wk.buy_min_next_n is null or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps @@ -382,16 +394,16 @@ begin v_cum := v_cum + v_per_slot_charge_wh; v_grid_slots_am := v_grid_slots_am + 1; end loop; + v_grid_filled_wh := v_grid_filled_wh + v_cum; - -- B) Non-PV PM + -- B) Grid PM v_cum := 0; v_grid_slots_pm := 0; for r_slot in select wk.slot_ord from _ems_plan_slot_wk wk - where wk.pv_surplus_w <= 0 - and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 - and wk.buy_price <= v_ref_buy_czk_kwh + v_degrad_czk_kwh + where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 + and wk.buy_price <= v_ref_buy_pm_czk_kwh + v_buy_charge_band_czk_kwh and ( wk.buy_min_next_n is null or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps @@ -405,7 +417,23 @@ begin v_cum := v_cum + v_per_slot_charge_wh; v_grid_slots_pm := v_grid_slots_pm + 1; end loop; + v_grid_filled_wh := v_grid_filled_wh + v_cum; end if; + + -- A) PV-surplus: jen zbytek kapacity po grid vrstvě B + v_pv_layer_cap_wh := greatest(v_energy_to_fill - v_grid_filled_wh, 0); + v_cum := 0; + for r_slot in + select wk.slot_ord, wk.pv_surplus_w + from _ems_plan_slot_wk wk + where wk.pv_surplus_w > 0 + and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh + order by wk.store_score desc nulls last, wk.slot_ord + loop + exit when v_cum >= v_pv_layer_cap_wh; + update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; + v_cum := v_cum + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25; + end loop; end if; -- discharge-export mask @@ -443,6 +471,7 @@ 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). select coalesce(sum( case @@ -455,17 +484,6 @@ begin else 0 end ), 0), - coalesce(sum( - case - when wk.pv_surplus_w > 0 - and ( - v_acquisition_cutoff is null - or wk.interval_start < v_acquisition_cutoff - ) - then least(wk.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25 - else 0 - end - ), 0), coalesce(sum( case when wk.allow_charge @@ -476,31 +494,22 @@ begin then wk.buy_price * v_per_slot_charge_wh else 0 end - ), 0), - coalesce(sum( - case - when wk.pv_surplus_w > 0 - and ( - v_acquisition_cutoff is null - or wk.interval_start < v_acquisition_cutoff - ) - then coalesce(wk.future_sell_lookahead, wk.sell_price) - * least(wk.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25 - else 0 - end ), 0) into v_est_grid_wh, - v_est_pv_wh, - v_est_grid_cost, - v_est_pv_cost + v_est_grid_cost from _ems_plan_slot_wk wk; - if (v_est_grid_wh + v_est_pv_wh) > 0 then - v_charge_acquisition := (v_est_grid_cost + v_est_pv_cost) - / (v_est_grid_wh + v_est_pv_wh); + v_est_pv_wh := 0; + v_est_pv_cost := 0; + + if v_est_grid_wh > 0 then + v_charge_acquisition := v_est_grid_cost / v_est_grid_wh; else - v_charge_acquisition := v_ref_buy_czk_kwh; + 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; return query @@ -602,5 +611,5 @@ comment on function ems.fn_load_planning_slots_full is 'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). ' 'Denní safety vstupy: night_baseload_* (20:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), ' 'lookahead max buy/sell pro měkké LP penalizace. ' - 'charge_acquisition_buy_czk_kwh: vážený průměr grid (allow_charge) + FVE (pv_surplus, opportunity future_sell) ' - 'jen pro sloty před charge_acquisition_cutoff_at (= začátek prvního allow_discharge_export).'; + 'charge_acquisition_buy_czk_kwh: vážený buy v allow_charge slotech před charge_acquisition_cutoff_at. ' + 'Grid maska B běží před PV vrstvou A; AM/PM rozpočet Wh 50/50; cap slotů z rozpočtu / per_slot_charge_wh.'; diff --git a/docs/04-modules/planning-arbitrage-accounting.md b/docs/04-modules/planning-arbitrage-accounting.md index 810a2e1..b23504e 100644 --- a/docs/04-modules/planning-arbitrage-accounting.md +++ b/docs/04-modules/planning-arbitrage-accounting.md @@ -108,9 +108,8 @@ Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19: 1. **`ems.fn_load_planning_slots_full`** (`R__063`): sloupce `charge_acquisition_buy_czk_kwh`, `charge_acquisition_cutoff_at`. -2. **Vážený průměr** jen pro sloty **před** prvním `allow_discharge_export` (`cutoff = min(interval_start)` exportních slotů): - - **grid:** `buy × per_slot_charge_wh` pro `allow_charge`; - - **FVE:** `future_sell_lookahead` (fallback `sell`) × odhad Wh z `pv_surplus` (`least(surplus, max_charge)×η×0,25 h`). +2. **Vážený průměr `buy`** v `allow_charge` slotech **před** prvním `allow_discharge_export` (`cutoff`): + `Σ(buy × per_slot_charge_wh) / Σ(per_slot_charge_wh)` — bez `future_sell` z odpolední FVE (jinak acquisition nafukovala večerní export). 3. **`solve_dispatch`:** v exportních slotech (`allow_discharge_export`) přičíst k objective `+ ge_bat[t] × charge_acquisition × INTERVAL_H/1000` (náklad uložené energie), ponechat `−ge×sell`. Snapshot v `solver_params.inputs`. 4. **FVE opportunity:** varianta **B** — lookahead `future_sell_opportunity`, ne jen `sell[t]` v odhadu PV Wh. diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index fcf5f71..7c49a3b 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). - - **Non-PV grid (vrstva B):** jen **spot** nákup (`purchase_pricing_mode <> 'fixed'`), `buy ≤ min(buy horizontu) + degradation`, **lookahead** `buy ≤ min(buy v příštích 4 slotech) + 0,05 Kč` (VT→NT), max **6 slotů** AM a PM; AM/PM rozpočet 50/50 z `grid_target`. **KV1/fixed:** vrstva B vypnutá. + - **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`). - **`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.