diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 11c0d92..346c1e3 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -215,6 +215,63 @@ def _select_charge_slots( if float(s.buy_price) < 0: selected.add(t) + elif purchase_pricing_mode == "fixed" and any( + float(s.sell_price) > float(s.buy_price) + degrad for s in slots + ): + am_candidates = [ + (t, getattr(slots[t], "is_predicted_price", False)) + for t in range(len(slots)) + if _prague_hour(slots[t]) < 12 + ] + am_candidates.sort( + key=lambda x: ( + _grid_sort_key(x[0], x[1], 0.0)[0], + _grid_sort_key(x[0], x[1], 0.0)[1], + _grid_sort_key(x[0], x[1], 0.0)[2], + x[0], + ) + ) + cum = 0.0 + grid_am = 0 + for t, _pred 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 + chg_pm = max(chg_pm, charge_target_wh - grid_filled_wh) + if per_slot_full_wh > 0: + cap_pm = max( + cap_pm, + min( + _MAX_GRID_CHARGE_CAP, + int(chg_pm / per_slot_full_wh * buf_mult) + 1, + ), + ) + pm_candidates = [ + (t, getattr(slots[t], "is_predicted_price", False)) + for t in range(len(slots)) + if _prague_hour(slots[t]) >= 12 + ] + pm_candidates.sort( + key=lambda x: ( + _grid_sort_key(x[0], x[1], 0.0)[0], + _grid_sort_key(x[0], x[1], 0.0)[1], + _grid_sort_key(x[0], x[1], 0.0)[2], + x[0], + ) + ) + cum = 0.0 + grid_pm = 0 + for t, _pred 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): @@ -636,7 +693,8 @@ class SelectDischargeExportSlotsTests(unittest.TestCase): class FixedPurchasePricingTests(unittest.TestCase): - def test_fixed_skips_non_pv_grid_charge_slots(self) -> None: + def test_fixed_skips_grid_charge_when_no_sell_arbitrage(self) -> None: + """Fixní buy bez výkupu nad buy+degrad → žádné grid nabíjení.""" slots = [ _slot(buy=6.35, sell=2.0, hour_utc=14, load=500), _slot(buy=6.35, sell=3.5, hour_utc=18, load=500), @@ -650,6 +708,31 @@ class FixedPurchasePricingTests(unittest.TestCase): ) self.assertEqual(out, set()) + def test_fixed_grid_charge_before_evening_export(self) -> None: + """BA81: konstantní buy, večerní sell > buy+degrad → NT/AM grid sloty.""" + base = datetime(2026, 5, 24, 0, 0, tzinfo=_PRAGUE) + slots: list[PlanningSlot] = [] + for i in range(96): + t = base + timedelta(minutes=15 * i) + sell = 3.75 if t.hour >= 18 else 2.8 + 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", + ) + 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_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 b48067b..4d6639e 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -503,6 +503,88 @@ begin update _ems_plan_slot_wk wk set allow_charge = true, allow_grid_charge = true where wk.buy_price < 0; + elsif exists ( + select 1 + from _ems_plan_slot_wk w2 + where w2.sell_price > w2.buy_price + v_degrad_czk_kwh + ) then + -- Fixní nákup (BA81): buy konstantní — grid nabíjení před exportním oknem, AM/PM rozpočet. + v_cum := 0; + v_grid_slots_am := 0; + for r_slot in + select wk.slot_ord + from _ems_plan_slot_wk wk + where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 + 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 + then 0 + else 1 + end, + wk.is_predicted_price::int, + 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, 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; + v_grid_filled_wh := v_grid_filled_wh + v_cum; + + v_chg_pm_wh := greatest(v_chg_pm_wh, v_grid_target_wh - v_grid_filled_wh); + if v_per_slot_charge_wh > 0 and v_charge_buf > 0 then + v_grid_charge_cap_pm := greatest( + v_grid_charge_cap_pm, + least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int) + ); + elsif v_per_slot_charge_wh > 0 then + v_grid_charge_cap_pm := greatest( + v_grid_charge_cap_pm, + least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int) + ); + end if; + + v_cum := 0; + v_grid_slots_pm := 0; + for r_slot in + select wk.slot_ord + from _ems_plan_slot_wk wk + where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 + 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 + then 0 + else 1 + end, + wk.is_predicted_price::int, + 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, 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; + v_grid_filled_wh := v_grid_filled_wh + v_cum; end if; -- A) PV-surplus: jen zbytek kapacity po grid vrstvě B diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 4ff6a62..895af59 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -12,7 +12,7 @@ - **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]` i `ge_bat[t]`** (vybíjení do domu i do sítě). Bez `ge_bat` v bilanci SoC LP „exportovalo“ bez vybití — arbitrážní dump v pozdních slotech místo ranního peaku. - **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, výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). Výběr: **nejlevnější `buy`** v pásmu (den plánu → před exportním oknem → `buy ASC`). Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **Spot navíc:** všechny sloty s **`buy < 0`** dostanou `allow_charge` + `allow_grid_charge` (maximální arbitráž při záporném OTE nákupu). **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`. + - **Grid ze sítě (vrstva B, před FVE):** výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`. - **PV vrstva A:** při `sell ≥ 0` jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak). Při **`sell < 0`** vrstva A **bez** tohoto filtru (nabít z FVE v záporném výkupním okně). Historie: [`docs/planning-changelog.md`](../planning-changelog.md). - **LP (AUTO):** objective explicitně `−ge_pv×sell − ge_bat×sell + ge_bat×acquisition` v exportních slotech; **bez** cross-slot vynucení `ge_pv ≥ surplus`. Guard FVE: `ge_pv=0` jen pokud `sell < charge_acquisition − degrad` (ne `sell < buy` ve slotu). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). - **Load-first (Deye, AUTO):** proměnné `pv_ld` (PV → load+EV+TČ), `pv_sp` (přebytek), `bc_pv` / `bc_gi`. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`; `bc_pv + ge_pv ≤ pv_sp`; `gi ≤ load + bc_gi`; mimo `allow_discharge_export`: `bd ≤ load − pv_ld` a **`pv_ld ≥ load − gi − bd`**. Snapshot: `load_first_enabled=true`. Test `LoadFirstDispatchTests`. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index a6f035b..1f30df9 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -83,6 +83,31 @@ where pr.site_id=4 and pr.status='active' --- +## 2026-05-24 (c) — BA81: fixní tarif bez grid nabíjení + +**Problém:** Po deployi run **15810** — `max_chg ≈ 3275 W`, **`allow_grid_charge = 0`** na všech slotech. Noc 00–04 jen import pro dům (~100 W), žádné NT nabíjení ze sítě. HW limit BA81 je **6250 W** (`bms_max_charge_w`), ne 18 kW. + +**Příčina:** V `R__063` vrstva **B (grid)** běžela jen pro `purchase_pricing_mode <> 'fixed'`. BA81 má **`fixed`** → masky povolily jen **PV vrstvu A** (Wh rozpočet rozdělený přes denní FVE sloty → postupné ~3 kW). + +**Oprava:** Pro `fixed` + existuje arbitráž (`sell > buy + degrad`) → stejná AM/PM logika grid slotů jako u spotu, řazení podle času slotu (`slot_ord`), před `export_window_start`. + +**Ověření po `flyway migrate` + replan:** + +```sql +select count(*) filter (where (m->>'allow_grid_charge')::boolean) as grid_slots +from ems.planning_run pr, jsonb_array_elements(pr.solver_params->'masks') m +where pr.site_id = (select id from ems.site where code='BA81') and pr.status='active'; +-- očekáváno > 0 + +select max(pi.battery_setpoint_w), max(pi.grid_setpoint_w) filter (where pi.grid_setpoint_w > 1000) +from ems.planning_interval pi +join ems.planning_run pr on pr.id = pi.run_id +where pr.site_id = (select id from ems.site where code='BA81') and pr.status='active'; +-- battery/grid nabíjení řádově k 6250 W v NT slotech +``` + +--- + ## Šablona pro další záznamy ```markdown