From 1ec92bdf79818bba778192935d901b9dca0ff998 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 23 May 2026 00:34:52 +0200 Subject: [PATCH] dalsi --- backend/services/planning_engine.py | 134 ++++++++++++++++-- .../test_planning_charge_slot_selection.py | 64 +++++++-- backend/tests/test_planning_dispatch_milp.py | 58 ++++++-- .../R__063_fn_load_planning_slots_full.sql | 113 +++++++++++---- docs/04-modules/planning.md | 7 +- 5 files changed, 313 insertions(+), 63 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index cb200b0..581c379 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -668,25 +668,85 @@ def _prague_calendar_date(slot: PlanningSlot): return dt.astimezone(ZoneInfo("Europe/Prague")).date() +MORNING_PRENEG_START_HOUR = 5 +MORNING_PRENEG_END_HOUR = 11 +NEGATIVE_BUY_GRID_CHARGE_MIN_W = 8_000.0 +PRENEG_MORNING_EXPORT_MIN_W = 5_000.0 + + +def _prague_hour(slot: PlanningSlot) -> int: + dt = slot.interval_start + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(ZoneInfo("Europe/Prague")).hour + + +def _morning_pre_neg_zone_peak_sell( + slots: list[PlanningSlot], + first_neg_sell_idx: int | None, +) -> float | None: + """Max kladný sell v pásmu 5–11 Prague před prvním sell<0 (shodně s R__063).""" + if first_neg_sell_idx is None or first_neg_sell_idx <= 0: + return None + neg_day = _prague_calendar_date(slots[first_neg_sell_idx]) + sells = [ + float(slots[i].sell_price) + for i in range(first_neg_sell_idx) + if float(slots[i].sell_price) >= 0.0 + and _prague_calendar_date(slots[i]) == neg_day + and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR + ] + if not sells: + return None + return max(sells) + + def _pre_neg_peak_sell_idx( slots: list[PlanningSlot], first_neg_sell_idx: int | None, ) -> int | None: - """Nejvyšší kladný sell před prvním sell<0 ve stejném kalendářním dni (Prague).""" + """Nejvyšší kladný sell v ranním pásmu před prvním sell<0 (ne půlnoc celého dne).""" if first_neg_sell_idx is None or first_neg_sell_idx <= 0: return None + zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx) + if zone_peak is None: + return None neg_day = _prague_calendar_date(slots[first_neg_sell_idx]) positive = [ (i, float(slots[i].sell_price)) for i in range(first_neg_sell_idx) if float(slots[i].sell_price) >= 0.0 and _prague_calendar_date(slots[i]) == neg_day + and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR ] if not positive: return None return max(positive, key=lambda x: (x[1], x[0]))[0] +def _morning_pre_neg_export_indices( + slots: list[PlanningSlot], + first_neg_sell_idx: int | None, + *, + degrad_czk_kwh: float, +) -> list[int]: + """Všechny ranní peak sloty (sell ≥ zónový max − degrad) před prvním sell<0.""" + zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx) + if zone_peak is None or first_neg_sell_idx is None or first_neg_sell_idx <= 0: + return [] + neg_day = _prague_calendar_date(slots[first_neg_sell_idx]) + out: list[int] = [] + for i in range(first_neg_sell_idx): + if ( + float(slots[i].sell_price) >= zone_peak - degrad_czk_kwh + and float(slots[i].sell_price) >= 0.0 + and _prague_calendar_date(slots[i]) == neg_day + and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR + ): + out.append(i) + return out + + def _pv_forced_vent_export_allowed( t: int, *, @@ -972,8 +1032,20 @@ def solve_dispatch( # Slack penalizujeme v objective; samotné omezení přidáme až po definici soc. first_neg_sell_idx, pre_neg_export_last_t = _pre_negative_sell_export_window(slots) t_pre_neg_peak = _pre_neg_peak_sell_idx(slots, first_neg_sell_idx) + morning_pre_neg_export_ts = _morning_pre_neg_export_indices( + slots, + first_neg_sell_idx, + degrad_czk_kwh=float(degradation_cost_effective), + ) if first_neg_sell_idx is not None and first_neg_sell_idx > 0 and floor_pct is not None: - t_anchor = first_neg_sell_idx - 1 + # Kotva na ranním peaku (ne na posledním slotu před sell<0) — jinak dump až v 07:30. + if ( + t_pre_neg_peak is not None + and t_pre_neg_peak < first_neg_sell_idx - 1 + ): + t_anchor = t_pre_neg_peak + else: + t_anchor = first_neg_sell_idx - 1 soc_anchor_slack = pulp.LpVariable("soc_anchor_slack_wh", 0, float(battery.usable_capacity_wh)) daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True)) @@ -1100,12 +1172,16 @@ def solve_dispatch( # --- Omezení --- for _t, sf, cap_w in peak_export_shortfall: prob += sf >= cap_w - ge[_t] - if ( - om == "AUTO" - and t_pre_neg_peak is not None - and t_pre_neg_peak in discharge_export_slots - ): - prob += ge_bat[t_pre_neg_peak] >= 5000.0 * z_export[t_pre_neg_peak] + preneg_export_min_soc_wh = float(min_soc_wh) + max( + float(battery.max_discharge_power_w) + * float(battery.discharge_efficiency) + * INTERVAL_H, + 1000.0, + ) + if om == "AUTO": + for t_peak in morning_pre_neg_export_ts: + if t_peak in discharge_export_slots: + prob += ge_bat[t_peak] >= PRENEG_MORNING_EXPORT_MIN_W * z_export[t_peak] if t_anchor is not None and soc_anchor_slack is not None: target_floor_wh = float(planner_floor_effective_wh) prob += soc[t_anchor] <= target_floor_wh + soc_anchor_slack @@ -1173,12 +1249,12 @@ def solve_dispatch( # Měkký breaker cap: gi_over[t] >= max(0, gi[t] - breaker). prob += gi_over[t] >= gi[t] - float(grid.max_import_power_w) - # SoC kontinuita + # SoC kontinuita (bd do domu i ge_bat do sítě vybíjí baterii) soc_prev = current_soc_wh if t == 0 else soc[t - 1] prob += soc[t] == ( soc_prev + (bc_pv[t] + bc_gi[t]) * battery.charge_efficiency * INTERVAL_H - - bd[t] / battery.discharge_efficiency * INTERVAL_H + - (bd[t] + ge_bat[t]) / battery.discharge_efficiency * INTERVAL_H ) sv = safety_vars[t] @@ -1306,7 +1382,20 @@ def solve_dispatch( prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t] # Bez hluboké relaxace: export končí ≥ rezerva. Při hluboké relaxaci (soc_panel_min pod min_soc) # sladit s LP spodkem — jinak z_export vynutil arb_base a blokoval vývoz k planner floor. - if soc_panel_min[t] < min_soc_wh - 1e-3: + if ( + om == "AUTO" + and first_neg_sell_idx is not None + and t < first_neg_sell_idx + and floor_pct is not None + ): + export_soc_floor_t = float(planner_floor_effective_wh) + elif ( + om == "AUTO" + and t in morning_pre_neg_export_ts + and floor_pct is not None + ): + export_soc_floor_t = float(planner_floor_effective_wh) + elif soc_panel_min[t] < min_soc_wh - 1e-3: export_soc_floor_t = float(soc_panel_min[t]) else: export_soc_floor_t = float(arb_base_wh) @@ -1375,6 +1464,29 @@ def solve_dispatch( prob += ge_bat[t] == 0 prob += z_export[t] == 0 + # Záporný buy: minimální grid import (spot arbitráž), jen pokud není baterie prakticky plná. + for t in range(T): + if float(slots[t].buy_price) >= 0.0: + continue + load_t = float(slots[t].load_baseline_w) + min_gi = min( + gi_upper, + load_t + NEGATIVE_BUY_GRID_CHARGE_MIN_W, + load_t + float(battery.max_charge_power_w) * 0.9, + ) + if min_gi <= load_t + 500.0: + continue + if t == 0: + if current_soc_wh >= float(battery.soc_max_wh) - soc_headroom_wh - 500.0: + continue + prob += gi[t] >= min_gi + else: + z_neg_chg = pulp.LpVariable(f"z_neg_chg_{t}", cat="Binary") + prob += soc[t - 1] <= float(battery.soc_max_wh) - soc_headroom_wh - 500.0 + float( + battery.usable_capacity_wh + ) * (1 - z_neg_chg) + prob += gi[t] >= min_gi * z_neg_chg + # Ekonomické guardy: ceny v objective nestačí proti maskám / terminal SoC. # Referenční buy jen z ne-záporných slotů: jinak jeden buy<0 v horizontu označí # téměř všechny sloty jako „drahé“ (gi=0 pro dům) → Infeasible (home-01). diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 045f4e8..2996027 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -276,6 +276,22 @@ def _select_discharge_export_slots( ] candidates.sort(key=lambda x: (-x[1], -x[0])) + first_neg = next( + (i for i, s in enumerate(slots) if float(s.sell_price) < 0), + None, + ) + neg_day = _prague_date(slots[first_neg]) if first_neg is not None else None + + candidates = [ + (t, sell) + for t, sell in candidates + if not ( + neg_day is not None + and _prague_date(slots[t]) == neg_day + and _prague_hour(slots[t]) < 5 + ) + ] + selected: set[int] = set() cum = 0.0 for t, _sell in candidates: @@ -284,31 +300,49 @@ def _select_discharge_export_slots( selected.add(t) cum += per_slot_wh - max_sell = max((float(s.sell_price) for s in slots), default=0.0) - if max_sell > 0: + if first_neg is not None and neg_day is not None: + evening_by_day: dict = {} for t, s in enumerate(slots): - if float(s.sell_price) >= max_sell - degrad and float(s.sell_price) > sell_min: - selected.add(t) + d = _prague_date(s) + if _prague_hour(s) < 17: + continue + evening_by_day[d] = max(evening_by_day.get(d, 0.0), float(s.sell_price)) + for t, s in enumerate(slots): + d = _prague_date(s) + peak = evening_by_day.get(d, 0.0) + if peak > 0 and _prague_hour(s) >= 17 and float(s.sell_price) >= peak - degrad: + if float(s.sell_price) > sell_min: + selected.add(t) - first_neg = next( - (i for i, s in enumerate(slots) if float(s.sell_price) < 0), - None, - ) preneg_min_soc = min_soc_wh + max(per_slot_wh, 1000.0) if ( first_neg is not None and first_neg > 0 and current_soc_wh >= preneg_min_soc + and neg_day is not None ): - neg_day = _prague_date(slots[first_neg]) - positive = [ - i + morning_sells = [ + float(slots[i].sell_price) for i in range(first_neg) - if float(slots[i].sell_price) >= 0 and _prague_date(slots[i]) == neg_day + if float(slots[i].sell_price) >= 0 + and _prague_date(slots[i]) == neg_day + and 5 <= _prague_hour(slots[i]) <= 11 ] - if positive: - peak_t = max(positive, key=lambda i: (float(slots[i].sell_price), i)) - selected.add(peak_t) + if morning_sells: + zone_peak = max(morning_sells) + for i in range(first_neg): + if ( + _prague_date(slots[i]) == neg_day + and 5 <= _prague_hour(slots[i]) <= 11 + and float(slots[i].sell_price) >= zone_peak - degrad + ): + selected.add(i) + for i in range(first_neg): + if _prague_date(slots[i]) != neg_day: + continue + h = _prague_hour(slots[i]) + if 5 <= h < 17 and float(slots[i].sell_price) < zone_peak - degrad: + selected.discard(i) return selected diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 12a3840..422394f 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -12,6 +12,7 @@ from services.planning_engine import ( _dynamic_arb_floor_wh_series, _dispatch_result_comparison, _pre_neg_peak_sell_idx, + _prague_hour, _prewindow_deferral_slots, _slots_until_buy_le_threshold, _slots_until_sell_lt, @@ -620,8 +621,8 @@ class PlanningDispatchMilpTests(unittest.TestCase): if results[0].grid_setpoint_w < 0: self.assertLess( results[0].battery_soc_target, - 19.0, - msg="with relaxed soc_min, first-slot export should be able to finish below reserve %", + 22.0, + msg="with relaxed soc_min, morning export should finish below reserve %", ) def test_negative_sell_forbids_battery_export_arbitrage(self) -> None: @@ -785,11 +786,12 @@ class PlanningDispatchMilpTests(unittest.TestCase): tuv_delta_stats=None, operating_mode="AUTO", ) - # Slot index 1 je poslední před prvním sell<0 (index 2). + first_neg = 2 + pre_neg_soc = [results[i].battery_soc_target for i in range(first_neg)] self.assertLessEqual( - results[1].battery_soc_target, + min(pre_neg_soc), 6.0, - msg="anchor should drive SoC close to planner floor before first negative sell", + msg="anchor at morning peak should drive SoC near planner floor before first negative sell", ) def test_anchor_uses_planner_floor_even_without_extreme_buy(self) -> None: @@ -802,7 +804,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): PlanningSlot( interval_start=base, buy_price=3.0, - sell_price=1.0, + sell_price=3.06, pv_a_forecast_w=0, pv_b_forecast_w=0, load_baseline_w=0, @@ -814,7 +816,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): PlanningSlot( interval_start=base + timedelta(minutes=15), buy_price=3.0, - sell_price=0.5, + sell_price=2.0, pv_a_forecast_w=0, pv_b_forecast_w=0, load_baseline_w=0, @@ -860,8 +862,11 @@ class PlanningDispatchMilpTests(unittest.TestCase): tuv_delta_stats=None, operating_mode="AUTO", ) - # Slot index 1 je poslední před prvním sell<0 (index 2). - self.assertLessEqual(results[1].battery_soc_target, 6.0) + self.assertLess( + results[0].grid_setpoint_w, + -1_000, + msg="morning peak slot should export before first negative sell", + ) def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None: """ @@ -1562,7 +1567,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): charge_acquisition_cutoff_at=base + timedelta(minutes=30), ) ) - battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.5) + battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0) battery.max_charge_power_w = 17_000 battery.max_discharge_power_w = 17_000 hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0) @@ -2236,6 +2241,39 @@ class PlannerArbitrageImprovementsTests(unittest.TestCase): first_neg = 2 self.assertEqual(_pre_neg_peak_sell_idx(slots, first_neg), 1) + def test_pre_neg_peak_ignores_midnight_on_same_day(self) -> None: + """Půlnoc může mít vyšší sell než ráno — peak musí být v pásmu 5–11, ne 00:00.""" + base = datetime(2026, 5, 22, 22, 0, tzinfo=timezone.utc) + slots = [ + PlanningSlot( + interval_start=base + timedelta(minutes=15 * i), + buy_price=4.0, + sell_price=3.72 if i == 0 else (3.06 if i == 28 else 2.0), + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=1000, + ev1_connected=False, + ev2_connected=False, + ) + for i in range(36) + ] + [ + PlanningSlot( + interval_start=base + timedelta(minutes=15 * 36), + buy_price=0.5, + sell_price=-0.1, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=1000, + ev1_connected=False, + ev2_connected=False, + ), + ] + first_neg = 36 + peak_idx = _pre_neg_peak_sell_idx(slots, first_neg) + self.assertIsNotNone(peak_idx) + self.assertGreater(_prague_hour(slots[peak_idx]), 4) + self.assertLess(_prague_hour(slots[peak_idx]), 12) + def test_pre_neg_peak_idx_is_highest_positive_sell(self) -> None: base = datetime(2026, 5, 23, 4, 0, tzinfo=timezone.utc) 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 1d2ac2d..ba3011c 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -81,7 +81,10 @@ declare v_first_neg_prague_date date; v_pre_neg_peak_sell_ord int; v_preneg_export_min_soc_wh numeric; - v_max_sell_czk_kwh numeric; + v_morning_zone_peak_sell numeric; + v_morning_preneg_start_hour int := 5; + v_morning_preneg_end_hour int := 11; + v_evening_peak_start_hour int := 17; v_charge_acquisition numeric; v_est_grid_wh numeric; v_est_pv_wh numeric; @@ -556,6 +559,14 @@ begin wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh end ) + -- Na dni prvního sell<0 nepočítat noční „šrot“ (00–04) do globálního rozpočtu — + -- jinak vyčerpá Wh před ranní špičkou (home-01: půlnoc 3,7 vs. 07:00 3,06). + and not ( + v_first_neg_prague_date is not null + and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date + and extract(hour from wk.interval_start at time zone 'Europe/Prague') + < v_morning_preneg_start_hour + ) order by wk.sell_price desc, wk.slot_ord desc loop exit when v_cum >= v_discharge_target_wh; @@ -565,46 +576,96 @@ begin end loop; end if; - -- Globální sell špičky (≈ max sell v horizontu): vždy export baterie, i po vyčerpání Wh rozpočtu. - select coalesce(max(wk.sell_price), 0) - into v_max_sell_czk_kwh - from _ems_plan_slot_wk wk; + -- Večerní špičky per kalendářní den (≥17:00 Prague): ne globální max horizontu (jinak půlnoc). + for r_slot in + select + (wk.interval_start at time zone 'Europe/Prague')::date as plan_date, + coalesce(max(wk.sell_price), 0) as evening_peak_sell + from _ems_plan_slot_wk wk + where extract(hour from wk.interval_start at time zone 'Europe/Prague') + >= v_evening_peak_start_hour + group by (wk.interval_start at time zone 'Europe/Prague')::date + loop + if r_slot.evening_peak_sell > 0 then + update _ems_plan_slot_wk wk + set allow_discharge_export = true + where (wk.interval_start at time zone 'Europe/Prague')::date = r_slot.plan_date + and extract(hour from wk.interval_start at time zone 'Europe/Prague') + >= v_evening_peak_start_hour + and wk.sell_price >= r_slot.evening_peak_sell - v_degrad_czk_kwh + and ( + case + when v_purchase_pricing_mode = 'fixed' then + wk.sell_price > v_degrad_czk_kwh + else + wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh + end + ); + end if; + end loop; - if v_max_sell_czk_kwh > 0 then - update _ems_plan_slot_wk wk - set allow_discharge_export = true - where wk.sell_price >= v_max_sell_czk_kwh - v_degrad_czk_kwh - and ( - case - when v_purchase_pricing_mode = 'fixed' then - wk.sell_price > v_degrad_czk_kwh - else - wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh - end - ); - end if; - - -- Před prvním sell<0 téhož dne: export v lokálním max kladného sell (např. 07:00, ne včerejší 20:45). + -- Ranní pásmo před prvním sell<0 (5–11 Prague): lokální peak, ne půlnoc celého dne. if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null and p_current_soc_wh >= v_preneg_export_min_soc_wh then - select wk.slot_ord - into v_pre_neg_peak_sell_ord + select coalesce(max(wk.sell_price), 0) + into v_morning_zone_peak_sell from _ems_plan_slot_wk wk where wk.slot_ord < v_first_neg_sell_ord and wk.sell_price >= 0 and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date - order by wk.sell_price desc, wk.slot_ord - limit 1; + and extract(hour from wk.interval_start at time zone 'Europe/Prague') + between v_morning_preneg_start_hour and v_morning_preneg_end_hour; - if v_pre_neg_peak_sell_ord is not null then + if v_morning_zone_peak_sell > 0 then update _ems_plan_slot_wk wk set allow_discharge_export = true - where wk.slot_ord = v_pre_neg_peak_sell_ord; + where wk.slot_ord < v_first_neg_sell_ord + and wk.sell_price >= 0 + and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date + and extract(hour from wk.interval_start at time zone 'Europe/Prague') + between v_morning_preneg_start_hour and v_morning_preneg_end_hour + and wk.sell_price >= v_morning_zone_peak_sell - v_degrad_czk_kwh; + + select wk.slot_ord + into v_pre_neg_peak_sell_ord + from _ems_plan_slot_wk wk + where wk.slot_ord < v_first_neg_sell_ord + and wk.sell_price >= 0 + and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date + and extract(hour from wk.interval_start at time zone 'Europe/Prague') + between v_morning_preneg_start_hour and v_morning_preneg_end_hour + order by wk.sell_price desc, wk.slot_ord + limit 1; end if; end if; + -- Mezi ranní peak a prvním sell<0: zákaz „pozdního dumpu“ při nízkém sell (07:30 za 2 Kč). + if v_first_neg_sell_ord is not null + and v_first_neg_prague_date is not null + and v_morning_zone_peak_sell is not null + and v_morning_zone_peak_sell > 0 + then + update _ems_plan_slot_wk wk + set allow_discharge_export = false + where wk.slot_ord < v_first_neg_sell_ord + and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date + and extract(hour from wk.interval_start at time zone 'Europe/Prague') + between v_morning_preneg_start_hour and v_morning_preneg_end_hour + and wk.sell_price < v_morning_zone_peak_sell - v_degrad_czk_kwh; + + update _ems_plan_slot_wk wk + set allow_discharge_export = false + where wk.slot_ord < v_first_neg_sell_ord + and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date + and extract(hour from wk.interval_start at time zone 'Europe/Prague') + >= v_morning_preneg_start_hour + and extract(hour from wk.interval_start at time zone 'Europe/Prague') + < v_evening_peak_start_hour + and wk.sell_price < v_morning_zone_peak_sell - v_degrad_czk_kwh; + end if; + -- Acquisition: grid nabíjení před prvním exportem ve STEJNÝ den jako záporné výkupní okno -- (ne dřívější večerní export v horizontu rolling replanu). select min(wk.interval_start) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 7d5466a..8f9661c 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -9,6 +9,7 @@ - **SQL-first:** horizont a sloty z DB funkcí (`fn_planning_horizon_end`, `fn_load_planning_slots_full`, …); viz **`CLAUDE.md`** → sekce *SQL-first a read-model*. - **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** – obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu. - **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). +- **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`. @@ -39,7 +40,11 @@ - měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše. - **Dynamická ekonomická podlaha (fáze 2):** - `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB – silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění. - - **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50), doplněno o: (1) **všechny sloty** s `sell ≥ max(sell) − degradation` (večerní špičky vždy exportovatelná), (2) **lokální maximum kladného `sell` před prvním `sell < 0` ve stejném kalendářním dni (Europe/Prague)** — horizont od `p_from` může zahrnovat víc dní (rolling večer + ráno); peak **není** včerejší večerní špička. Povoleno jen pokud `p_current_soc_wh ≥ min_soc + 1 slot discharge` (SoC). **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před **prvním exportem téhož dne** jako záporné výkupní okno (ne před včerejším exportem v horizontu). **Spot nákup:** `sell_price > ref_buy + degradation_cost_czk_kwh`. V `solve_dispatch` (AUTO): **`ge_pv`** / **`ge_bat`**; v **high-sell** exportních slotech měkká penalizace **`export_shortfall`**. **Kotva před `sell < 0`:** SoC ≤ planner floor v posledním slotu před prvním `sell < 0`; **`ge_bat` push** v peak slotu (Python, shodný den). Mimo exportní sloty: **`ge_bat = 0`**; **`bc_gi = 0`** mimo masku, **výjimka `buy < 0`**. **`deye_physical_mode`** = PASSIVE kromě CHARGE/SELL. + - **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` (`R__063`). Tři vrstvy: + 1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`, ale na **dni prvního `sell < 0`** se **vynechává noc 00–04** (Prague), aby půlnoc nevyčerpala rozpočet před ranní špičkou. + 2. **Večerní špičky per den:** `sell ≥ max(sell) − degradation` jen pro hodiny **≥ 17** (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera). + 3. **Ranní pásmo před prvním `sell < 0`:** hodiny **5–11** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno − degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno. + V `solve_dispatch` (AUTO): **`ge_bat` push** ve všech ranních peak slotech; **kotva SoC** na ranním peaku (ne na posledním slotu před `sell < 0`); **`gi` minimum** při `buy < 0`; **`export_shortfall`** u high-sell. Mimo exportní sloty: **`ge_bat = 0`**; **`bc_gi = 0`** mimo masku, **výjimka `buy < 0`**. - **Záporná nákupní cena:** - horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu). - **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —