diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index e06c9f3..5ebb783 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -106,7 +106,13 @@ def _select_charge_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) per_slot_full_wh = max_p_w * eta * INTERVAL_H - if current_soc_wh >= reserve_wh: + soc_max_wh = float(getattr(battery, "soc_max_wh", 0) or 0) + if charge_buf > 0: + charge_target_wh = min( + max(energy_to_fill, 0.0) * charge_buf, + max(soc_max_wh - float(current_soc_wh), 0.0), + ) + elif current_soc_wh >= reserve_wh: charge_target_wh = max(energy_to_fill, 0.0) else: charge_target_wh = min( @@ -129,13 +135,26 @@ def _select_charge_slots( selected: set[int] = set() grid_filled_wh = 0.0 + buf_mult = charge_buf if charge_buf > 0 else 1.0 cap_am = ( - max(1, min(_MAX_GRID_CHARGE_CAP, int(chg_am / per_slot_full_wh) + 1)) + max( + 1, + min( + _MAX_GRID_CHARGE_CAP, + int(chg_am / per_slot_full_wh * buf_mult) + 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)) + max( + 1, + min( + _MAX_GRID_CHARGE_CAP, + int(chg_pm / per_slot_full_wh * buf_mult) + 1, + ), + ) if per_slot_full_wh > 0 else 6 ) @@ -166,6 +185,15 @@ def _select_charge_slots( 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), float(slots[t].buy_price)) @@ -462,6 +490,24 @@ class SelectChargeSlotsTests(unittest.TestCase): self.assertNotIn(0, out, "Při malém rozpočtu má přednost levnější NT, ne VT 1.49") self.assertTrue({1, 2} & out, "NT slot(y) mohou být vybrány") + def test_pm_grid_gets_unused_am_wh_budget(self) -> None: + """Nečerpaný AM rozpočet → odpolední levné PM sloty mohou dostat allow_charge.""" + base = datetime(2026, 5, 22, 6, 0, tzinfo=timezone.utc) + slots = [ + _slot(buy=0.55, sell=-0.2, hour_utc=6, interval_start=base), + _slot(buy=0.58, sell=-0.2, hour_utc=7, interval_start=base + timedelta(hours=1)), + _slot(buy=0.52, sell=-0.25, hour_utc=14, interval_start=base + timedelta(hours=8)), + _slot(buy=0.50, sell=-0.25, hour_utc=15, interval_start=base + timedelta(hours=9)), + _slot(buy=5.5, sell=3.8, hour_utc=20, interval_start=base + timedelta(hours=14)), + ] + battery = _battery(charge_buf=1.3, uc_wh=64_000.0) + out = _select_charge_slots(slots, battery, current_soc_wh=0.12 * battery.usable_capacity_wh) + pm_cheap = {2, 3} + self.assertTrue( + pm_cheap & out, + "po levném AM má PM dostat grid charge z nevyčerpaného rozpočtu", + ) + def test_ote_slots_prioritized_over_predicted(self) -> None: """Při stejné ceně má OTE (is_predicted=false) přednost před predikovaným.""" slots = [ 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 27a6ab5..03a0039 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -265,8 +265,13 @@ begin v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25; v_energy_to_fill := v_soc_max_wh - p_current_soc_wh; v_exportable := v_soc_max_wh - v_min_soc_wh; - -- Rozpočet masek: buffer neinfluje počet slotů nad skutečný deficit; nad reserve jen deficit. - if p_current_soc_wh >= v_reserve_wh then + -- Rozpočet masek: charge_slot_buffer zvětší Wh cíl (do soc_max) i cap počtu grid slotů. + if v_charge_buf > 0 then + v_grid_target_wh := least( + greatest(v_energy_to_fill, 0) * v_charge_buf, + greatest(v_soc_max_wh - p_current_soc_wh, 0) + ); + elsif p_current_soc_wh >= v_reserve_wh then v_grid_target_wh := greatest(v_energy_to_fill, 0); else v_grid_target_wh := least( @@ -366,14 +371,25 @@ begin end if; 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) - ); + if v_charge_buf > 0 then + v_grid_charge_cap_am := greatest( + 1, + least(24, ceil((v_chg_am_wh / v_per_slot_charge_wh) * v_charge_buf)::int) + ); + v_grid_charge_cap_pm := greatest( + 1, + least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int) + ); + else + 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) + ); + end if; else v_grid_charge_cap_am := 6; v_grid_charge_cap_pm := 6; @@ -427,6 +443,20 @@ begin end loop; v_grid_filled_wh := v_grid_filled_wh + v_cum; + -- PM dostane i nevyčerpaný AM rozpočet (levné NT dopoledne ≠ vyčerpání celého grid_target). + 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; + -- B) Grid PM v_cum := 0; v_grid_slots_pm := 0; diff --git a/docs/04-modules/planning-arbitrage-accounting.md b/docs/04-modules/planning-arbitrage-accounting.md index af0ec2b..064e3c0 100644 --- a/docs/04-modules/planning-arbitrage-accounting.md +++ b/docs/04-modules/planning-arbitrage-accounting.md @@ -108,7 +108,7 @@ Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19: ### Hotovo -1. **`ems.fn_load_planning_slots_full`** (`R__063`): grid **B** = nejlevnější sloty v AM/PM do Wh rozpočtu (bez `buy≤min+band` a lookahead gate na grid); **A** = PV jen pokud `sell ≥ future_sell_lookahead − degrad`. `charge_acquisition` z `allow_grid_charge` před 1. exportem. +1. **`ems.fn_load_planning_slots_full`** (`R__063`): grid **B** = nejlevnější sloty v AM/PM do Wh rozpočtu; **nevyčerpaný AM rozpočet přejde do PM** (odpolední NT za ~0,5 Kč může nabíjet i po ranním dobití). `grid_target × charge_slot_buffer`, cap slotů též × buffer. **A** = PV jen pokud `sell ≥ future_sell_lookahead − degrad`. 2. **`solve_dispatch` (AUTO):** objective `gi×buy − ge_pv×sell − ge_bat×sell + ge_bat×acquisition` (export bat. jen v `allow_discharge_export`). Odstraněn cross-slot guard `ge_pv ≥ surplus` / `bc=0` dle `export_refill_net`. 3. **Guard FVE:** `ge_pv=0` jen při `sell < charge_acquisition − degrad` (ne `sell < buy` ve stejném slotu). 4. **`solve_dispatch_two_pass`:** pass 1 → vážený `buy` z `bc`+`gi` v `allow_charge` → pass 2; volá `run_daily_plan` / `run_rolling_replan` v AUTO. Snapshot: `acquisition_pass1_czk_kwh`, `acquisition_pass2_czk_kwh`, `two_pass_enabled`. diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 6fe23ec..be2bf47 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: **nejlevnější `buy`** v pásmu (kalendářní den plánu → před výkupním oknem dne → `buy ASC`), bez pásma `min+0,40` a bez lookahead gate na grid B. **`charge_acquisition`:** vážený `buy` jen u `allow_grid_charge` před 1. exportem; po solve **dvouprůchodově** přepočet z `bc`+`gi` (`solve_dispatch_two_pass` v `planning_engine.py`). + - **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`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`. - **PV vrstva A:** jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak, ne „nabíjet z FVE“ při nízkém sell). - **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). - **`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).