diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 48d6f75..fd7514c 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 -PLANNER_BUILD_TAG = "2026-06-06-future-neg-buy-evening-export-v64" +PLANNER_BUILD_TAG = "2026-06-06-charge-slot-budget-v1" SOLVER_RELAX_STEPS: tuple[str, ...] = ( "strict", "relaxed_expensive_import", @@ -82,8 +82,6 @@ SOLVER_RELAX_STEPS: tuple[str, ...] = ( ) # Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). DAWN_LOW_PV_NO_CURTAIL_W = 1500 -# BA81/KV1: PV→bat jen v těsné blízkosti nejnižšího sell v horizontu (≈ poledne), ne při ~3 Kč ráno. -FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH = 0.20 # Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu). NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0 # Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). @@ -456,6 +454,13 @@ class PlanningSlot: pv_charge_wh_ahead: float | None = None neg_buy_wh_ahead: float | None = None grid_charge_suppressed_reason: str | None = None + charge_target_wh: float | None = None + pre_window_wh: float | None = None + in_window_wh: float | None = None + charge_slot_wh: float | None = None + charge_cum_wh: float | None = None + charge_layer: str | None = None + charge_slot_reason: str | None = None #: Pomocny atribut pro green_bonus v planning_interval (Kc/slot); lite default 0. green_bonus_czk_per_slot: float = 0.0 @@ -1836,12 +1841,13 @@ def _slot_evening_push_profitable( slots: list[PlanningSlot] | None = None, first_neg_sell_idx: int | None = None, kv1_evening_push: bool = False, + purchase_fixed: bool = False, ) -> bool: """ Push večerní špičky. - Spot / obecně: sell > acq+spread (zásoba z levného nabití). - KV1 (fixed + block_export, v52): sell ≥ max sell v pásmu 5–11 před 1. sell<0 − spread - — neprodávat večer levněji než plánované ranní maximum; bez neg dne v horizontu sell ≥ 1 Kč. + Spot: sell > acq+spread (zásoba z levného nabití). + Fixní tarif (BA81/KV1): sell > buy+spread (stejně jako R__063 discharge maska). + KV1 (fixed + block_export, v52): navíc sell ≥ max sell v pásmu 5–11 před 1. sell<0 − spread. """ sell_t = float(slot.sell_price) if kv1_evening_push: @@ -1852,6 +1858,10 @@ def _slot_evening_push_profitable( if zone_peak is not None: return sell_t >= float(zone_peak) - float(min_spread) return True + if purchase_fixed: + buy_t = float(slot.buy_price) + if buy_t >= 0.0: + return sell_t > buy_t + float(min_spread) return sell_t > float(charge_acquisition_czk_kwh) + float(min_spread) @@ -1864,6 +1874,7 @@ def _evening_push_segment_candidates( discharge_export_ok: set[int] | None = None, first_neg_sell_idx: int | None = None, kv1_evening_push: bool = False, + purchase_fixed: bool = False, ) -> list[int]: """Profitable sloty v nočním úseku — výběr pořadí a strop dělá rozpočet Wh (sell desc).""" if not seg: @@ -1881,6 +1892,7 @@ def _evening_push_segment_candidates( slots=slots, first_neg_sell_idx=first_neg_sell_idx, kv1_evening_push=kv1_evening_push, + purchase_fixed=purchase_fixed, ): continue out.append(t) @@ -2013,6 +2025,7 @@ def _evening_battery_export_push_indices( evening_start_hour: int = 17, first_neg_sell_idx: int | None = None, kv1_evening_push: bool = False, + purchase_fixed: bool = False, ) -> list[int]: """ Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh @@ -2049,6 +2062,7 @@ def _evening_battery_export_push_indices( discharge_export_ok=discharge_export_ok, first_neg_sell_idx=first_neg_sell_idx, kv1_evening_push=kv1_evening_push, + purchase_fixed=purchase_fixed, ): if t not in seen: seen.add(t) @@ -2078,6 +2092,7 @@ def _evening_push_peak_fallback_indices( discharge_export_ok: set[int] | None, first_neg_sell_idx: int | None, kv1_evening_push: bool, + purchase_fixed: bool = False, ) -> set[int]: """Alespoň jeden večerní peak slot (sell desc), když rozpočet Wh nevybral žádný push.""" best_t: int | None = None @@ -2094,6 +2109,7 @@ def _evening_push_peak_fallback_indices( slots=slots, first_neg_sell_idx=first_neg_sell_idx, kv1_evening_push=kv1_evening_push, + purchase_fixed=purchase_fixed, ): continue sell_t = float(s.sell_price) @@ -2944,6 +2960,7 @@ def solve_dispatch( discharge_export_ok=discharge_export_slots, first_neg_sell_idx=first_neg_sell_idx, kv1_evening_push=kv1_evening_push_pre, + purchase_fixed=purchase_fixed_pre, ) ) push_override_raw = _evening_push_override_for_solve( @@ -2977,6 +2994,7 @@ def solve_dispatch( discharge_export_ok=discharge_export_slots, first_neg_sell_idx=first_neg_sell_idx, kv1_evening_push=kv1_evening_push_pre, + purchase_fixed=purchase_fixed_pre, ) # Tvrdý ge_bat push vypnout jen při neg_sell fallback (ne při prep relax — v64). evening_push_hard_suppressed = bool(neg_sell_phases_fallback) @@ -4216,34 +4234,8 @@ def solve_dispatch( or fixed_pre_neg_pv_export or fixed_block_pv_surplus_export or fixed_mi_low_pv_surplus_export - or ( - purchase_fixed_pre - and fixed_horizon_min_sell_pre is not None - and sell_t >= 0.0 - and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W - and sell_t - > fixed_horizon_min_sell_pre - + FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH - ) ) - fixed_sell_above_horizon_min = ( - purchase_fixed_pre - and fixed_horizon_min_sell_pre is not None - and sell_t >= 0.0 - and sell_t - > fixed_horizon_min_sell_pre + FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH - ) - fixed_high_sell_no_pv_charge = ( - fixed_sell_above_horizon_min - and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W - ) - fixed_grid_charge_unprofitable = ( - purchase_fixed_pre - and buy_t >= 0.0 - and fixed_sell_above_horizon_min - ) - # Spot: mezi-slotová arbitráž — sell= 0.0 @@ -4257,10 +4249,8 @@ def solve_dispatch( and not fixed_pre_neg_pv_export and int(s.pv_a_forecast_w) >= DAWN_LOW_PV_NO_CURTAIL_W ) - if fixed_grid_charge_unprofitable or spot_grid_charge_not_cheap_buy: + if spot_grid_charge_not_cheap_buy: prob += bc_gi[t] == 0 - if fixed_high_sell_no_pv_charge: - prob += bc_pv[t] == 0 if ( purchase_fixed_pre and t in evening_push_ts @@ -4773,6 +4763,25 @@ def solve_dispatch( solver_snapshot: dict[str, Any] = { "version": 1, "planner_build_tag": PLANNER_BUILD_TAG, + "charge_slot_budget": { + "charge_target_wh": ( + float(slots[0].charge_target_wh) + if slots[0].charge_target_wh is not None + else None + ), + "pre_window_wh": ( + float(slots[0].pre_window_wh) + if slots[0].pre_window_wh is not None + else None + ), + "in_window_wh": ( + float(slots[0].in_window_wh) + if slots[0].in_window_wh is not None + else None + ), + "reliability_factor": 0.85, + "planner_build_tag": PLANNER_BUILD_TAG, + }, "inputs": { "current_soc_wh": float(current_soc_wh), "observed_soc_wh": float(observed_soc_wh), @@ -4897,9 +4906,7 @@ def solve_dispatch( and not push_override_eff ), "fixed_horizon_min_sell_czk_kwh": fixed_horizon_min_sell_pre, - "fixed_pv_charge_near_min_sell_margin_czk_kwh": ( - FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH if purchase_fixed_pre else None - ), + "fixed_evening_push_sell_above_buy": bool(purchase_fixed_pre), "charge_commitment_ignored_on_relaxed": bool( commitment_for_solve is None and charge_commitment_prev_w is not None ), @@ -5628,7 +5635,9 @@ async def _load_slots( is_daytime_pv_surplus_slot, charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at, min_buy_before_cutoff_czk_kwh, pv_charge_wh_ahead, neg_buy_wh_ahead, - grid_charge_suppressed_reason + grid_charge_suppressed_reason, + charge_target_wh, pre_window_wh, in_window_wh, + charge_slot_wh, charge_cum_wh, charge_layer, charge_slot_reason from ems.fn_load_planning_slots_full( $1::int, $2::timestamptz, $3::timestamptz, $4::numeric ) @@ -5672,6 +5681,13 @@ async def _load_slots( pv_charge_wh_ahead=_slot_float_nullable(d, "pv_charge_wh_ahead"), neg_buy_wh_ahead=_slot_float_nullable(d, "neg_buy_wh_ahead"), grid_charge_suppressed_reason=d.get("grid_charge_suppressed_reason"), + charge_target_wh=_slot_float_nullable(d, "charge_target_wh"), + pre_window_wh=_slot_float_nullable(d, "pre_window_wh"), + in_window_wh=_slot_float_nullable(d, "in_window_wh"), + charge_slot_wh=_slot_float_nullable(d, "charge_slot_wh"), + charge_cum_wh=_slot_float_nullable(d, "charge_cum_wh"), + charge_layer=d.get("charge_layer"), + charge_slot_reason=d.get("charge_slot_reason"), ) ) if not out: diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 7fd187b..8f44256 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -337,22 +337,32 @@ def _select_charge_slots( elif purchase_pricing_mode == "fixed" and any( float(s.sell_price) > float(s.buy_price) + degrad for s in slots ): + min_sell_pos = min( + float(s.sell_price) for s in slots if float(s.sell_price) >= 0.0 + ) am_candidates = [ - (t, getattr(slots[t], "is_predicted_price", False)) + ( + t, + getattr(slots[t], "is_predicted_price", False), + float(slots[t].sell_price), + ) for t in range(len(slots)) if _prague_hour(slots[t]) < 12 + and float(slots[t].sell_price) >= 0.0 + and float(slots[t].sell_price) <= min_sell_pos + degrad + 0.05 ] 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], + _grid_sort_key(x[0], x[1], x[2])[0], + _grid_sort_key(x[0], x[1], x[2])[1], + _grid_sort_key(x[0], x[1], x[2])[2], + x[2], x[0], ) ) cum = 0.0 grid_am = 0 - for t, _pred in am_candidates: + for t, _pred, _sell in am_candidates: if cum >= chg_am or per_slot_full_wh <= 0 or grid_am >= cap_am: break selected.add(t) @@ -369,21 +379,28 @@ def _select_charge_slots( ), ) pm_candidates = [ - (t, getattr(slots[t], "is_predicted_price", False)) + ( + t, + getattr(slots[t], "is_predicted_price", False), + float(slots[t].sell_price), + ) for t in range(len(slots)) if _prague_hour(slots[t]) >= 12 + and float(slots[t].sell_price) >= 0.0 + and float(slots[t].sell_price) <= min_sell_pos + degrad + 0.05 ] 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], + _grid_sort_key(x[0], x[1], x[2])[0], + _grid_sort_key(x[0], x[1], x[2])[1], + _grid_sort_key(x[0], x[1], x[2])[2], + x[2], x[0], ) ) cum = 0.0 grid_pm = 0 - for t, _pred in pm_candidates: + for t, _pred, _sell in pm_candidates: if cum >= chg_pm or per_slot_full_wh <= 0 or grid_pm >= cap_pm: break selected.add(t) @@ -398,15 +415,22 @@ def _select_charge_slots( fso = _future_sell(slots, t) if ( pv_surplus_w > 0 - and float(s.sell_price) >= float(s.buy_price) - degrad and ( - float(s.sell_price) < 0 + purchase_pricing_mode == "fixed" + or float(s.sell_price) >= float(s.buy_price) - degrad + ) + and ( + purchase_pricing_mode == "fixed" + or float(s.sell_price) < 0 or float(s.sell_price) >= fso - degrad ) ): pv_candidates.append((t, _store_score(slots, t), float(pv_surplus_w))) - pv_candidates.sort(key=lambda x: (-x[1], x[0])) + if purchase_pricing_mode == "fixed": + pv_candidates.sort(key=lambda x: (float(slots[x[0]].sell_price), x[0])) + else: + pv_candidates.sort(key=lambda x: (-x[1], x[0])) cum = 0.0 for t, _score, pv_surplus_w in pv_candidates: if cum >= pv_layer_cap: diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 784b774..1bf09de 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1675,7 +1675,9 @@ class NegativeSellPvChargeTests(unittest.TestCase): ) def test_fixed_high_sell_no_pv_charge_near_min_sell(self) -> None: - """v58: ráno sell~3 Kč → export FVE; poledne sell~1,45 Kč → nabíjení z PV.""" + """Charge-slot budget: levnější sell (poledne) dostane allow_charge dřív než drahší ráno.""" + from test_planning_charge_slot_selection import _select_charge_slots + prague = ZoneInfo("Europe/Prague") base = datetime(2026, 6, 2, 6, 0, tzinfo=prague) slots: list[PlanningSlot] = [] @@ -1693,73 +1695,29 @@ class NegativeSellPvChargeTests(unittest.TestCase): load_baseline_w=400, ev1_connected=False, ev2_connected=False, - allow_charge=True, - allow_discharge_export=False, - charge_acquisition_buy_czk_kwh=3.088, - future_sell_opportunity_czk_kwh=3.2, ) ) battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0) battery.max_charge_power_w = 6250 - hp = SimpleNamespace( - rated_heating_power_w=0, - tuv_min_temp_c=45.0, - tuv_target_temp_c=55.0, - ) - grid = SimpleNamespace( - max_import_power_w=17_000, - max_export_power_w=8000, - block_export_on_negative_sell=False, - purchase_pricing_mode="fixed", - ) - vehicles = [ - SimpleNamespace( - max_charge_power_w=0, - battery_capacity_kwh=1.0, - default_target_soc_pct=80.0, - ), - SimpleNamespace( - max_charge_power_w=0, - battery_capacity_kwh=1.0, - default_target_soc_pct=80.0, - ), - ] + battery.charge_slot_buffer = 1.3 soc0 = 0.35 * battery.usable_capacity_wh - results, _, snap = solve_dispatch( + charge = _select_charge_slots( slots, battery, - hp, - grid, - [None, None], - vehicles, soc0, - 50.0, - operating_mode="AUTO", + purchase_pricing_mode="fixed", + apply_dynamic_grid_filter=False, ) - self.assertEqual( - snap["inputs"].get("fixed_horizon_min_sell_czk_kwh"), - 1.45, - ) - r_morning = results[0] - r_noon = results[5] - self.assertLess( - r_morning.grid_setpoint_w, - -2000, - "vysoký sell: přebytek FVE do sítě", - ) - self.assertLessEqual( - r_morning.battery_setpoint_w, - 200, - "vysoký sell: ne nabíjet z PV", - ) - self.assertGreater( - r_noon.battery_setpoint_w, - 800, - "min sell: nabíjení z PV", + noon_idx = 4 + cheap_slots = {4, 5, 6, 7} + self.assertIn(noon_idx, charge, "min sell slot má allow_charge z PV vrstvy") + self.assertTrue( + cheap_slots.issubset(charge), + "všechny sloty u min sell musí mít allow_charge dřív než dražší ranní", ) def test_fixed_no_grid_charge_when_sell_above_horizon_min(self) -> None: - """v59: KV1 — grid→bat jen u min sell, ne v noci za 6 Kč při sell 3,5.""" + """v59 SQL maska: grid→bat jen u min sell; LP nerespektuje allow_charge bez allow_grid.""" prague = ZoneInfo("Europe/Prague") cheap = PlanningSlot( interval_start=datetime(2026, 6, 2, 10, 15, tzinfo=prague).astimezone( @@ -1787,7 +1745,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): load_baseline_w=400, ev1_connected=False, ev2_connected=False, - allow_charge=True, + allow_charge=False, allow_discharge_export=False, charge_acquisition_buy_czk_kwh=6.353, ) 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 9d8db96..db9453c 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -36,7 +36,14 @@ returns table ( min_buy_before_cutoff_czk_kwh numeric, pv_charge_wh_ahead numeric, neg_buy_wh_ahead numeric, - grid_charge_suppressed_reason text + grid_charge_suppressed_reason text, + charge_target_wh numeric, + pre_window_wh numeric, + in_window_wh numeric, + charge_slot_wh numeric, + charge_cum_wh numeric, + charge_layer text, + charge_slot_reason text ) language plpgsql volatile @@ -104,6 +111,10 @@ declare v_cum_allowed numeric; v_pv_ahead_total numeric; v_target_deficit numeric; + v_charge_target_wh numeric; + v_pre_window_wh numeric := 0; + v_in_window_wh numeric := 0; + v_charge_reliability_factor numeric := 0.85; r_unlock record; begin v_plan_day_prague := (p_from at time zone 'Europe/Prague')::date; @@ -308,6 +319,7 @@ begin ); end if; v_discharge_target_wh := v_exportable * v_discharge_buf; + v_charge_target_wh := greatest(v_grid_target_wh, 0); -- 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) @@ -334,7 +346,11 @@ begin add column if not exists min_buy_before_cutoff numeric, add column if not exists pv_charge_wh_ahead numeric, add column if not exists neg_buy_wh_ahead numeric, - add column if not exists grid_charge_suppressed_reason text; + add column if not exists grid_charge_suppressed_reason text, + add column if not exists charge_slot_wh numeric default 0, + add column if not exists charge_cum_wh numeric, + add column if not exists charge_layer text, + add column if not exists charge_slot_reason text; -- 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ě“). @@ -477,7 +493,12 @@ begin 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 + set allow_charge = true, + allow_grid_charge = true, + charge_layer = 'grid_am', + charge_slot_reason = 'grid_layer_b', + charge_slot_wh = v_per_slot_charge_wh, + charge_cum_wh = v_cum + v_per_slot_charge_wh 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; @@ -525,7 +546,12 @@ begin 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 + set allow_charge = true, + allow_grid_charge = true, + charge_layer = 'grid_pm', + charge_slot_reason = 'grid_layer_b', + charge_slot_wh = v_per_slot_charge_wh, + charge_cum_wh = v_cum + v_per_slot_charge_wh 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; @@ -534,7 +560,11 @@ begin -- Spot: záporný buy → grid nabíjení ve všech slotech (maximální arbitráž), mimo AM/PM rozpočet. update _ems_plan_slot_wk wk - set allow_charge = true, allow_grid_charge = true + set allow_charge = true, + allow_grid_charge = true, + charge_layer = coalesce(wk.charge_layer, 'buy_negative'), + charge_slot_reason = coalesce(wk.charge_slot_reason, 'buy_negative'), + charge_slot_wh = greatest(wk.charge_slot_wh, v_per_slot_charge_wh) where wk.buy_price < 0; -- Self-konzistentni filtr vrstvy B (spot): vyloucit drahe grid sloty, pokud PV / buy<0 @@ -697,7 +727,12 @@ begin 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 + set allow_charge = true, + allow_grid_charge = true, + charge_layer = 'grid_am', + charge_slot_reason = 'grid_layer_b', + charge_slot_wh = v_per_slot_charge_wh, + charge_cum_wh = v_cum + v_per_slot_charge_wh 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; @@ -749,7 +784,12 @@ begin 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 + set allow_charge = true, + allow_grid_charge = true, + charge_layer = 'grid_pm', + charge_slot_reason = 'grid_layer_b', + charge_slot_wh = v_per_slot_charge_wh, + charge_cum_wh = v_cum + v_per_slot_charge_wh 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; @@ -760,40 +800,53 @@ begin -- 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); - -- Rezervace SoC pro sell<0 okno: pokud v zápor. výkup. slotech máme - -- očekávaný PV přebytek X Wh (po efektivitě), snížíme PV vrstvu A o X. - -- Důsledek: do okna nedorazíme „plní" (98 % SoC), zbude prostor přijmout PV - -- z neg-sell slotů místo exportu do mínusu / curtail pole A. - -- Sample neg-sell PV sloty (sell<0 a buy<0, kde sell= buy − degrad), takže redukce je čistá. - declare - v_neg_window_pv_surplus_wh numeric := 0; - begin - select coalesce(sum(least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25), 0) - into v_neg_window_pv_surplus_wh - from _ems_plan_slot_wk wk - where wk.sell_price < 0 - and wk.pv_surplus_w > 0; - if v_neg_window_pv_surplus_wh > 0 then - v_pv_layer_cap_wh := greatest(v_pv_layer_cap_wh - v_neg_window_pv_surplus_wh, 0); - end if; - end; + -- Dodávka z forecastu v sell<0 okně snižuje potřebu nabíjení před oknem. + select coalesce(sum(least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25), 0) + into v_in_window_wh + from _ems_plan_slot_wk wk + where wk.sell_price < 0 + and wk.pv_surplus_w > 0; + + if v_in_window_wh > 0 then + v_pv_layer_cap_wh := greatest(v_pv_layer_cap_wh - v_in_window_wh, 0); + v_pre_window_wh := greatest( + 0, + v_charge_target_wh - v_in_window_wh * v_charge_reliability_factor + ); + end if; 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 - -- Držet PV na večerní peak jen při kladném výkupu; při sell<0 (záporný výkup) vždy nabíjet z FVE. and ( - wk.sell_price < 0 + v_purchase_pricing_mode = 'fixed' + or wk.sell_price >= wk.buy_price - v_degrad_czk_kwh + ) + -- Spot: neukládat do bat při výrazně lepším sell později; fixed: řazení sell ASC (§ charge-slot-budget). + and ( + v_purchase_pricing_mode = 'fixed' + or wk.sell_price < 0 or wk.sell_price >= wk.future_sell_lookahead - v_degrad_czk_kwh ) - order by wk.store_score desc nulls last, wk.slot_ord + order by + case when v_purchase_pricing_mode = 'fixed' then wk.sell_price end asc nulls last, + 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; + update _ems_plan_slot_wk wk + set allow_charge = true, + charge_layer = coalesce(wk.charge_layer, 'pv_a'), + charge_slot_reason = coalesce(wk.charge_slot_reason, 'pv_layer_a'), + charge_slot_wh = greatest( + wk.charge_slot_wh, + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25 + ), + charge_cum_wh = v_cum + + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25 + 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; @@ -1007,12 +1060,22 @@ begin -- Záporný buy: vždy grid nabíjení (mimo rozpočet 6 slotů / PV vrstvu A). update _ems_plan_slot_wk wk - set allow_charge = true, allow_grid_charge = true + set allow_charge = true, + allow_grid_charge = true, + charge_layer = coalesce(wk.charge_layer, 'buy_negative'), + charge_slot_reason = coalesce(wk.charge_slot_reason, 'buy_negative'), + charge_slot_wh = greatest(wk.charge_slot_wh, v_per_slot_charge_wh) where wk.buy_price < 0; -- Záporný výkup + PV přebytek: nabíjení z FVE (KV1/BA81 block_export), bez filtru future_sell. update _ems_plan_slot_wk wk - set allow_charge = true + set allow_charge = true, + charge_layer = coalesce(wk.charge_layer, 'neg_window'), + charge_slot_reason = coalesce(wk.charge_slot_reason, 'neg_window_pv'), + charge_slot_wh = greatest( + wk.charge_slot_wh, + least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25 + ) where wk.sell_price < 0 and wk.pv_surplus_w > 0; @@ -1021,6 +1084,9 @@ begin update _ems_plan_slot_wk wk set allow_charge = true, allow_grid_charge = true, + charge_layer = coalesce(wk.charge_layer, 'neg_window'), + charge_slot_reason = coalesce(wk.charge_slot_reason, 'neg_window_grid_charge'), + charge_slot_wh = greatest(wk.charge_slot_wh, v_per_slot_charge_wh), grid_charge_suppressed_reason = coalesce( wk.grid_charge_suppressed_reason, 'neg_window_grid_charge' @@ -1176,7 +1242,14 @@ begin w.min_buy_before_cutoff as min_buy_before_cutoff_czk_kwh, coalesce(w.pv_charge_wh_ahead, 0) as pv_charge_wh_ahead, coalesce(w.neg_buy_wh_ahead, 0) as neg_buy_wh_ahead, - w.grid_charge_suppressed_reason + w.grid_charge_suppressed_reason, + v_charge_target_wh as charge_target_wh, + v_pre_window_wh as pre_window_wh, + v_in_window_wh as in_window_wh, + coalesce(w.charge_slot_wh, 0) as charge_slot_wh, + w.charge_cum_wh, + w.charge_layer, + w.charge_slot_reason from _ems_plan_slot_wk w cross join night_tot nt ) @@ -1204,7 +1277,14 @@ begin e.min_buy_before_cutoff_czk_kwh, e.pv_charge_wh_ahead, e.neg_buy_wh_ahead, - e.grid_charge_suppressed_reason + e.grid_charge_suppressed_reason, + e.charge_target_wh, + e.pre_window_wh, + e.in_window_wh, + e.charge_slot_wh, + e.charge_cum_wh, + e.charge_layer, + e.charge_slot_reason from enriched e order by e.slot_ord; end; @@ -1212,11 +1292,13 @@ $fn$; comment on function ems.fn_load_planning_slots_full is '15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). ' - 'Charge mask A: PV-surplus dle store_score DESC (future_sell−sell−max(0,buy−sell)); zbytek → PV export. ' - 'Charge mask B: spot, nejlevnější buy v AM/PM do Wh rozpočtu (priorita den plánu, před exportním oknem). ' + 'Charge-slot budget: charge_target_wh, pre_window_wh (deficit − forecast v sell<0), in_window_wh; ' + 'debug charge_layer / charge_slot_reason / charge_cum_wh. ' + 'Charge mask A: spot = store_score DESC; fixed = sell ASC + Wh kumulace pv_surplus. ' + 'Charge mask B: spot nejlevnější buy v AM/PM; fixed nejnižší sell v AM/PM do Wh rozpočtu. ' 'ref_buy = min(buy) horizontu. Discharge-export: nejdražší sell kde sell>ref_buy+degrad (spot). ' '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ý buy v allow_charge slotech před charge_acquisition_cutoff_at. ' + 'charge_acquisition_buy_czk_kwh: vážený buy v allow_grid_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-charge-slot-budget.md b/docs/04-modules/planning-charge-slot-budget.md index 1c4d3c9..9fb1c5d 100644 --- a/docs/04-modules/planning-charge-slot-budget.md +++ b/docs/04-modules/planning-charge-slot-budget.md @@ -1,6 +1,6 @@ # Plánování: rozpočet nabíjecích slotů (Wh × ceny × forecast) -**Stav:** návrh k implementaci (2026-06) — **zatím neimplementováno** v produkčním kódu. +**Stav:** **Branch 3 implementováno** (2026-06-06, tag `2026-06-06-charge-slot-budget-v1`) — fixed tarify BA81/KV1; home-01 pre-neg fronta §6 zatím ne. **Účel:** nahradit tvrdé prahy typu `sell > min_sell + 0,20 → bc_pv = 0` (v58) a binární pre-neg „cushion“ (v33) jednotným **energetickým rozpočtem** ve `fn_load_planning_slots_full`, který pokryje fixní tarify (BA81, KV1), spot (home-01) i zkracující se okna `sell < 0` (zima). **Související:** diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index e23964e..88f610f 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -19,7 +19,7 @@ - **Tvrdé výkonové limity site/baterie:** `gi ≤ site_grid_connection.max_import_power_w` (breaker); **`bc_pv + bc_gi ≤ asset_battery.max_charge_power_w`**; **`ge ≤ max_export_power_w`** (proměnná `ge`, platí `ge = ge_pv + ge_bat`); **`bd + ge_bat ≤ asset_battery.max_discharge_power_w`** (vybíjení do domu + export z baterie nesmí současně překročit BMS). Dříve LP dovoloval import+nabíjení a dvojnásobné nabíjení; u prodeje hrozilo současné `bd` a `ge_bat` až 2× max discharge — viz `SitePowerCapTests`. - **Hodnota FVE (PV store value):** tvrdé `ge_pv = 0` jen pokud `sell < future_sell_opportunity − degradation` **a** `sell < 0` (spot), nebo u fixního tarifu dle `fixed_pv_b_export_cap`. Při **`sell ≥ 0` (spot home-01, KV1):** `ge_pv` **neblokuje** pv_store — solver volí export vs. `bc_pv` podle `−ge_pv×sell` a degradace; **baterii** na večerní peak drží `ge_bat` (`evening_early` / push), ne curtail FVE. **v31:** při `sell ≥ 0` + PV přebytek **není** plný `ge_bat` push z `pre_neg_buy_discharge` / ranních shortfallů (export cap pro FVE). **Před prvním `sell < 0`:** `allow_pre_neg_pv_export`. Tag `2026-05-28-morning-pv-export-priority-v31`. Testy `Home01PvStoreValueTests`, `PreNegativeSellExportTests`. - **BA81 úsvit + MI (v51):** `fixed_pv_b_export_cap` (`ge_pv ≤ pv_b`) jen pokud **`pv_a_forecast ≥ 1500 W`** (`DAWN_LOW_PV_NO_CURTAIL_W`); při slabším A + přebytku → `fixed_mi_low_pv_surplus_export` (bez pv_store bloku). Exporter: při `forecast < 1500` a bez curtail A → **bez reg 340** (`setpoints.py`). Tag `2026-05-31-ba81-dawn-no-micro-curtail-v51`. Test `test_ba81_dawn_low_pv_no_full_curtail_for_mi_cap`. - - **Fixní tarif — PV export vs. nabíjení (v58–v59, dočasné):** v58: při **`sell > min_sell + 0,20`** a PV přebytku → **`bc_pv = 0`**, export FVE; profitable noc **`sell > buy`** mimo `evening_early`. v59: **`bc_gi = 0`** i bez FVE při **`sell < buy`** nebo **`sell > min_sell + 0,20`**; večerní push bez nabíjení; **`R__063`** grid maska podle nejnižšího sell (`sell ASC`), ne `slot_ord`. Tagy `…-v58`, `…-v59`. **Plánovaná náhrada:** energetický rozpočet Wh + řazení výkupů/nákupů — **[`planning-charge-slot-budget.md`](planning-charge-slot-budget.md)** (zrušení v58 v LP, rozšíření `R__063`). + - **Fixní tarif — charge-slot budget (v1, 2026-06-06):** **`R__063`** vybírá nabíjecí sloty Wh kumulací; PV vrstva A u fixed = **`sell ASC`**. LP **nezakazuje** `bc_pv`/`bc_gi` prahy v58 (`sell > min+0,20`); respektuje jen `allow_charge` / `allow_grid_charge`. Večerní push: **`sell > buy + spread`** (BA81); KV1 navíc v52 morning-peak pravidlo. Debug: `charge_slot_budget` v `solver_params`, sloupce `charge_layer` / `charge_slot_reason` ve `fn_load_planning_slots_full`. Spec: **[`planning-charge-slot-budget.md`](planning-charge-slot-budget.md)**. Tag **`2026-06-06-charge-slot-budget-v1`**. v59 grid maska (min sell) a večerní push `bc=0` v push slotech zůstávají. - **Drahý nákup → vlastní spotřeba z baterie:** mimo `allow_charge` platí `bd + pv_ld ≥ load_baseline + hp[t]` a `gi ≤ EV + hp[t]` (ne `hp_rated`). **Spot:** drahý slot = `buy > min(buy≥0) + degradace`. **Fixní nákup (DB `purchase_pricing_mode=fixed` nebo heuristika rozptylu buy < 0,25):** navíc `buy > charge_acquisition + degradace`. Na spotu **nesmí** `charge_acquisition` (~0,9 Kč) označit všechny sloty jako drahé → Infeasible (home-01). Při **Infeasible** solver jednou opakuje s `relaxed_expensive_import` (síť smí krmit baseload v drahých slotech; v `solver_params.inputs.relaxed_expensive_import=true`). Testy `AutoPassiveSelfConsumptionTests`, `test_spot_low_acquisition_does_not_mark_all_slots_expensive`, `test_negative_buy_in_horizon_does_not_block_all_grid_import`. - **Záporný výkup (`sell < 0`) bez exportu:** `block_export_on_negative_sell` (KV1) **nebo** `purchase_pricing_mode=fixed` (BA81). **Spot (home-01):** `ge_pv=0` dokud není plná baterie; při plné jen ventil pole B (`ge_pv ≤ pv_b`, `w_pv_b_vent_neg`); výboj baterie při `sell<0` jen **12 slotů** před `buy ≤ planner_extreme_buy_threshold` (default −2), pokud spread do budoucna dává smysl — tag `2026-05-26-neg-sell-bat-dump-extreme-buy-v11`. Večerní discharge maska u spotu: denní peak ≥17:00 (ne `sell > ref_buy` v slotu). **v50:** u **KV1** při `sell≥0` a PV přebytku >500 W i **po** 1. `sell<0` → `ge_pv` (PV_SURPLUS), ne tvrdý `ge_bat` z večerního peak/push. - **Pole B při sell<0 (home-01):** pokud `block_export_on_negative_sell = false`, LP nesmí vynutit `ge_pv = 0` (přebytek neriťitelného PV B). KV1 s `block_export = true` jen curtail A / nabíjení. @@ -153,9 +153,9 @@ flowchart TD **Funkce:** … home-01 **v61**; BA81/KV1 fixed **v59** (+ `R__063`). -### Rozpočet nabíjecích slotů (plánováno, 2026-06) +### Rozpočet nabíjecích slotů (charge-slot-budget v1, 2026-06-06) -Náhrada tvrdých prahů v58 a binárního pre-neg cushion (v33): **deficit Wh**, forecast v okně `sell < 0`, fronta nejlevnějších slotů (buy/sell) s `pv_surplus`. Pokrývá BA81/KV1 (slunečný den nad ~60 % SoC) i home-01 (krátké zimní neg okno → nabíjení před oknem). **Specifikace:** [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md). **Stav:** neimplementováno — viz changelog *Plánováno*. +**Branch 3 (BA81/KV1):** `R__063` vrací `charge_target_wh`, `pre_window_wh`, `in_window_wh` a debug sloupce; fixed PV vrstva **`sell ASC`**. LP bez v58 — jen masky SQL. Večerní push fixed: **`sell > buy + spread`**. Tag **`2026-06-06-charge-slot-budget-v1`**. **Zbývá pro home-01:** pre-neg fronta místo v33 cushion, v44 změkčení — [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md) §6. ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 9856e15..8beecfc 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -42,24 +42,34 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- -## Plánováno — rozpočet nabíjecích slotů (charge-slot-budget, neimplementováno) +## 2026-06-06 — charge-slot budget v1 (Branch 3: BA81/KV1) -**Stav:** pouze dokumentace (2026-06); implementace později. +**Problém:** v58 `sell > min_sell + 0,20 → bc_pv = 0` držel denní SoC ~60 % při slunci (konflikt s `R__063` vrstvou A). Fixní lokality neměly večerní push podle `sell > buy + spread`. -**Motivace:** +**Změna:** -- **BA81/KV1:** v58 `sell > min_sell + 0,20 → bc_pv = 0` drží denní SoC ~60 % při slunci — konflikt s `R__063` vrstvou A. -- **home-01:** v33 binární pre-neg cushion exportuje FVE před `sell < 0` i při středním sell; při kratším zimním okně `sell < 0` / slabší FVE chybí nabíjení **před** oknem. +1. **`R__063_fn_load_planning_slots_full.sql`:** nové sloupce `charge_target_wh`, `pre_window_wh`, `in_window_wh`, `charge_slot_wh`, `charge_cum_wh`, `charge_layer`, `charge_slot_reason`; PV vrstva A u **fixed** řazena **`sell ASC`** + Wh kumulace (spot dál `store_score DESC`). +2. **`planning_engine.py`:** odstraněn v58 (`fixed_high_sell_no_pv_charge`, `fixed_grid_charge_unprofitable`); LP respektuje jen `allow_charge` / `allow_grid_charge` ze SQL. Večerní push u fixního tarifu: **`sell > buy + spread`** (`fixed_evening_push_sell_above_buy`); KV1 zachovává v52 morning-peak pravidlo. +3. **`solver_params.charge_slot_budget`** — audit rozpočtu na aktivním runu. -**Záměr:** +Tag **`2026-06-06-charge-slot-budget-v1`**. -1. **`fn_load_planning_slots_full`:** `charge_target_wh`, `pre_window_wh` (deficit − forecast v neg okně), fronta slotů řazená **`sell ASC`** (fixed) / **`buy ASC`** (spot), kumulace `pv_surplus` Wh → `allow_charge`. -2. **Python:** zrušit v58 (a související fixed `bc_pv`/`bc_gi` prahy); pre-neg export jen v pre-neg slotech **bez** `allow_charge`. -3. **Neg den:** změkčit v44 grid před `sell < 0`, pokud `pre_window_wh` > dostupná FVE v okně. +**Ověření:** -**Specifikace:** [`docs/04-modules/planning-charge-slot-budget.md`](04-modules/planning-charge-slot-budget.md). +```bash +pytest backend/tests/test_planning_charge_slot_selection.py backend/tests/test_planning_dispatch_milp.py \ + -k "fixed_high_sell or fixed_tariff_evening or fixed_evening or kv1_evening" -q +``` -**Plánovaný tag:** `…-charge-slot-budget-v1` (po implementaci). +MCP: `planning_run.solver_params->'charge_slot_budget'`; u BA81 večer `evening_push_ts` neprázdné při `sell > buy`. + +**Zbývá (Branch 4–5 spec):** změkčení v44 grid před neg; plná náhrada v33 cushion přes `pre_window_wh` frontu u home-01. + +--- + +## Plánováno — charge-slot-budget home-01 (pre-neg fronta, v44) + +**Stav:** Branch 3 hotový pro fixed; spot pre-neg fronta a v44 změkčení — viz [`planning-charge-slot-budget.md`](04-modules/planning-charge-slot-budget.md) §6. ---