# backend/services/planning/heuristics.py # # EMS plánovač – pre-solver heuristiky (SoC série, neg-sell fáze, evening push, # pre-neg okna). Fáze 1 dekompozice: čistý přesun z planning_engine.py. # # POZOR (Fáze 2/3): tyto funkce jsou hlavní kandidáti na zjednodušení/odstranění — # ~35 % ekonomické logiky obchází solver (viz scripts/harness/README.md, baseline # GAP 27 %). Neměnit chování bez golden gate + economics_report. import logging from dataclasses import replace from datetime import datetime, timedelta, timezone from types import SimpleNamespace from typing import Any, Optional from services.planning.constants import ( MORNING_PRENEG_START_HOUR, MORNING_PRENEG_END_HOUR, ARB_FLOOR_E_REF_FRAC, ARB_LOOKAHEAD_SLOTS, EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH, EVENING_PUSH_HYSTERESIS_SOC_PCT, EXTREME_BUY_DUMP_PREWINDOW_SLOTS, GE_MIN_EXPORT_W, INTERVAL_H, NIGHT_EXPORT_EVENING_START_HOUR, NIGHT_EXPORT_MORNING_END_HOUR, NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W, PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH, PRE_NEG_PV_EXPORT_FORECAST_MARGIN, PRE_NEG_PV_EXPORT_MIN_NEEDED_WH, TERMINAL_NEG_BUY_MAGNITUDE_FLOOR, TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK, TERMINAL_NEG_BUY_WEIGHT_CAP, TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS, ) from services.planning.types import ( PlanningSlot, DispatchResult, SOC_MIN_RELAX_LOOKAHEAD_SLOTS, _prague_calendar_date, _prague_hour, ) logger = logging.getLogger(__name__) def _pv_scarcity_penalty_multiplier(slots: list["PlanningSlot"], battery) -> float: """ Měkká úprava ekonomiky cyklu podle očekávaného slunečního zisku. - málo očekávané FVE energie -> nižší penalizace cyklu (podpora precharge ze sítě), - hodně očekávané FVE energie -> standardní penalizace. """ horizon_slots = min(len(slots), int(24 / INTERVAL_H)) # konzervativní 1 den dopředu if horizon_slots <= 0: return 1.0 pv_kwh = 0.0 for s in slots[:horizon_slots]: pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0 batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0) # coverage = kolikanásobek baterie očekáváme ze slunce v horizontu. coverage = pv_kwh / batt_kwh coverage_clamped = max(0.0, min(1.0, coverage)) # 0.65 při nízkém slunci, 1.0 při vysokém slunci. return 0.65 + 0.35 * coverage_clamped def _pv_coverage_ratio(slots: list["PlanningSlot"], battery, hours: int = 24) -> float: horizon_slots = min(len(slots), int(hours / INTERVAL_H)) if horizon_slots <= 0: return 1.0 pv_kwh = 0.0 for s in slots[:horizon_slots]: pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0 batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0) return max(0.0, min(1.0, pv_kwh / batt_kwh)) def _dynamic_arb_floor_wh_series( slots: list["PlanningSlot"], min_soc_wh: float, arb_base_wh: float, usable_wh: float, ) -> list[float]: """ Časově proměnná ekonomická podlaha Wh pro MILP (nad min_soc_wh). Hodně očekávané FVE energie v dalších ARB_LOOKAHEAD_SLOTS → podlaha klesá k min_soc_wh; málo slunce → zůstává u arb_base_wh (typicky reserve z DB). """ T = len(slots) if T == 0: return [] e_ref = max(1.0, ARB_FLOOR_E_REF_FRAC * float(usable_wh)) spread = max(0.0, float(arb_base_wh) - float(min_soc_wh)) out: list[float] = [] for t in range(T): e_pv_wh = 0.0 for k in range(t, min(T, t + ARB_LOOKAHEAD_SLOTS)): s = slots[k] e_pv_wh += max(0, s.pv_a_forecast_w + s.pv_b_forecast_w) * INTERVAL_H f = min(1.0, e_pv_wh / e_ref) if e_ref > 1e-9 else 1.0 arb_t = float(min_soc_wh) + (1.0 - f) * spread out.append(arb_t) return out def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float, float]: """ Při nízkém očekávaném slunci drží solver vyšší SoC buffer: - cílový buffer: reserve + až 20 % usable capacity, - ekonomická penalizace deficitu vůči bufferu z průměrné ceny. """ coverage = _pv_coverage_ratio(slots, battery, hours=24) scarcity = 1.0 - coverage usable_wh = float(getattr(battery, "usable_capacity_wh", 0.0)) reserve_wh = float(getattr(battery, "reserve_soc_wh", 0.0)) soc_max_wh = float(getattr(battery, "soc_max_wh", usable_wh)) extra_buffer_wh = 0.35 * usable_wh * scarcity target_wh = min(soc_max_wh, reserve_wh + extra_buffer_wh) h24 = min(len(slots), int(24 / INTERVAL_H)) avg_buy = ( sum(float(s.buy_price) for s in slots[:h24]) / h24 if h24 > 0 else 4.0 ) penalty_czk_kwh = max(0.1, avg_buy * 1.00 * scarcity) return target_wh, penalty_czk_kwh def _soc_min_wh_series( slots: list[PlanningSlot], usable_wh: float, base_min_wh: float, buy_extreme_threshold: float, planner_discharge_floor_pct: float | None, ) -> list[float]: """ Spodní mez SoC (Wh) pro každý slot: při extrémně záporném buy v lookahead povolit hlubší vybíjení až na planner_discharge_floor_percent (jinak min_soc z DB). Absolutní minimum 5 % usable. """ t_len = len(slots) abs_min_wh = max(usable_wh * 0.05, 1.0) if planner_discharge_floor_pct is None: relaxed_wh = base_min_wh else: relaxed_wh = max(abs_min_wh, float(planner_discharge_floor_pct) / 100.0 * usable_wh) effective_relaxed = min(base_min_wh, relaxed_wh) out: list[float] = [] for t in range(t_len): j_end = min(t_len, t + SOC_MIN_RELAX_LOOKAHEAD_SLOTS) min_buy_fwd = min(float(slots[k].buy_price) for k in range(t, j_end)) if min_buy_fwd <= buy_extreme_threshold: out.append(float(effective_relaxed)) else: out.append(float(base_min_wh)) return out def _slots_until_buy_le_threshold( slots: list[PlanningSlot], buy_threshold: float ) -> list[int]: """ Pro slot t: kolik slotů (0 = tento slot) do nejbližšího k>=t s buy_price <= buy_threshold. Pokud v [t, T) žádný takový není, vrátí T + 1 (větší než jakýkoli rozumný prewindow). """ t_len = len(slots) sentinel = t_len + 1 next_le = sentinel next_at_or_after: list[int] = [sentinel] * t_len for t in range(t_len - 1, -1, -1): if float(slots[t].buy_price) <= buy_threshold: next_le = t next_at_or_after[t] = next_le out: list[int] = [] for t in range(t_len): nxt = next_at_or_after[t] if nxt >= t_len: out.append(sentinel) else: out.append(nxt - t) return out def _slots_until_sell_lt(slots: list[PlanningSlot], sell_upper: float) -> list[int]: """ Pro slot t: kolik slotů (0 = tento slot) do nejbližšího k>=t s sell_price < sell_upper. Typicky sell_upper=0 (první záporný / „ztrátový“ prodej z pohledu OTE). Pokud v [t, T) žádný takový není, vrátí T + 1. """ t_len = len(slots) sentinel = t_len + 1 next_lt = sentinel next_at_or_after: list[int] = [sentinel] * t_len for t in range(t_len - 1, -1, -1): if float(slots[t].sell_price) < sell_upper: next_lt = t next_at_or_after[t] = next_lt out: list[int] = [] for t in range(t_len): nxt = next_at_or_after[t] if nxt >= t_len: out.append(sentinel) else: out.append(nxt - t) return out def _prewindow_deferral_slots( slots: list[PlanningSlot], buy_extreme_threshold: float, sell_upper: float = 0.0 ) -> list[int]: """ Vzdálenost (v 15min slotech) pro zpoždění hlubokého planner flooru: primárně do prvního sell < sell_upper (poslední „bez ztráty na prodeji“ je k-1), pokud v horizontu není záporný prodej, fallback na první buy <= buy_extreme_threshold. """ t_len = len(slots) sell_d = _slots_until_sell_lt(slots, sell_upper) buy_d = _slots_until_buy_le_threshold(slots, buy_extreme_threshold) sentinel = t_len + 1 out: list[int] = [] for t in range(t_len): if sell_d[t] < sentinel: out.append(sell_d[t]) else: out.append(buy_d[t]) return out def _soc_panel_min_wh_series( soc_min_series: list[float], slots_until_relax_anchor: list[int], min_soc_wh: float, arb_base_wh: float, prewindow_slots: int, ) -> list[float]: """ Zpoždění hluboké relaxace: pokud je lookahead extrémní (soc_min pod min_soc), ale kotva (záporný prodej / fallback extrémní buy) je dál než prewindow_slots, drž spodek na max(relax_wh, arb_base_wh) — prakticky na rezervě. """ t_len = len(soc_min_series) out: list[float] = [] for t in range(t_len): sm = float(soc_min_series[t]) if sm < min_soc_wh - 1e-3 and slots_until_relax_anchor[t] > prewindow_slots: out.append(max(sm, float(arb_base_wh))) else: out.append(sm) return out def _recompute_charge_acquisition_from_results( slots: list[PlanningSlot], results: list["DispatchResult"], battery, ) -> float: """Vážený buy z nabíjecích slotů (grid import + bat charge) z prvního solve.""" wh_total = 0.0 cost = 0.0 for s, r in zip(slots, results): if not s.allow_charge: continue # Zaporne buy sloty (OTE) nejsou grid acquisition pro arbitraz exportu baterie. if float(s.buy_price) < 0: continue gi_w = max(0, int(r.grid_setpoint_w or 0)) bc_w = max(0, int(r.battery_setpoint_w or 0)) wh = (gi_w + bc_w) * INTERVAL_H if wh <= 0: continue wh_total += wh cost += float(s.buy_price) * wh if wh_total <= 0: raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None) if raw is not None: return float(raw) return min(float(s.buy_price) for s in slots) return cost / wh_total def _slots_with_charge_acquisition( slots: list[PlanningSlot], acquisition_czk_kwh: float, ) -> list[PlanningSlot]: return [ replace(s, charge_acquisition_buy_czk_kwh=acquisition_czk_kwh) for s in slots ] def _pv_store_value_czk_kwh(slot: PlanningSlot, min_spread: float) -> float: """ Práh pro tvrdý zákaz ge_pv (sell pod budoucím max sell v horizontu). U spotu při sell >= 0 se neaplikuje — export vs. nabíjení řeší LP; baterii na večerní peak drží ge_bat (evening_early / push), ne ge_pv == 0. """ future = float( slot.future_sell_opportunity_czk_kwh if slot.future_sell_opportunity_czk_kwh is not None else slot.sell_price ) return future - min_spread def _slot_profitable_battery_export( slot: PlanningSlot, *, charge_acquisition_czk_kwh: float, min_spread: float, fixed_tariff: bool, ) -> bool: """ Export z baterie do sítě má kladnou marži. Spot: sell > charge_acquisition + spread (energie ze sítě / vážený nákup). Fixní tarif (BA81/KV1): stejně jako R__063 discharge maska — sell > buy + spread; acquisition může být nafouknutá grid nabíjením a blokovat večerní špičku (3,7 < 3,9). """ sell_t = float(slot.sell_price) acq = float(charge_acquisition_czk_kwh) if fixed_tariff: buy_t = float(slot.buy_price) if buy_t >= 0.0: return sell_t > buy_t + min_spread return sell_t > acq + min_spread def _purchase_pricing_fixed(grid: Any) -> bool: """Režim nákupu z DB (`site_market_config.purchase_pricing_mode`), ne odhad z rozptylu buy.""" return ( str(getattr(grid, "purchase_pricing_mode", "spot") or "spot").strip().lower() == "fixed" ) def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool: """ Heuristika pro drahý import / charge_acquisition: buy v horizontu je prakticky konstantní. U spotu (home-01) nesmí expensive_import používat charge_acquisition — jinak buy > ~1 Kč označí téměř všechny sloty jako drahé (gi=0 pro dům) → Infeasible. BA81 má fixní nákup v DB, ale NT/VT → buy skáče; proto neg-sell export řídí _purchase_pricing_fixed. """ buys = [float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0] if not buys: return False if len(buys) == 1: return True return max(buys) - min(buys) < 0.25 def _future_extreme_buy_from( slots: list[PlanningSlot], buy_thr: float, ) -> list[bool]: """True v t, pokud v některém budoucím slotu buy <= buy_thr.""" t_len = len(slots) out = [False] * t_len seen = False for i in range(t_len - 1, -1, -1): if float(slots[i].buy_price) <= buy_thr: seen = True out[i] = seen return out def _neg_sell_bat_dump_slots( slots: list[PlanningSlot], *, operating_mode: str, purchase_fixed: bool, grid: Any, buy_extreme_thr: float, degrad_czk_kwh: float, ) -> set[int]: """Sloty, kde smí ge_bat>0 při sell<0 (výboj před extrémně záporným buy).""" if operating_mode != "AUTO" or purchase_fixed: return set() if bool(getattr(grid, "block_export_on_negative_sell", False)): return set() t_len = len(slots) future_extreme = _future_extreme_buy_from(slots, buy_extreme_thr) dist = _slots_until_buy_le(slots, buy_extreme_thr) out: set[int] = set() for t, s in enumerate(slots): if float(s.sell_price) >= 0.0: continue future_min = min( (float(slots[j].buy_price) for j in range(t + 1, t_len)), default=float(s.buy_price), ) if ( future_extreme[t] and 0 < dist[t] <= EXTREME_BUY_DUMP_PREWINDOW_SLOTS and future_min < float(s.sell_price) - degrad_czk_kwh ): out.add(t) return out def _slots_until_buy_le( slots: list[PlanningSlot], buy_thr: float, ) -> list[int]: """Počet slotů do nejbližšího buy <= thr (0 = v tomto slotu, T = nikdy).""" t_len = len(slots) dist = [t_len] * t_len next_idx = t_len for i in range(t_len - 1, -1, -1): if float(slots[i].buy_price) <= buy_thr: next_idx = i dist[i] = (next_idx - i) if next_idx < t_len else t_len return dist def _pre_negative_sell_export_window( slots: list[PlanningSlot], ) -> tuple[int | None, int | None]: """Index prvního sell<0 a posledního slotu před ním (pro strategii „vyvézt dřív“).""" first_neg = next( (i for i, s in enumerate(slots) if float(s.sell_price) < 0), None, ) if first_neg is None or first_neg <= 0: return first_neg, None return first_neg, first_neg - 1 def _neg_sell_phases_enabled(battery: Any) -> bool: # Bez atributů z DB (unit testy) = legacy; z DB default 80 % / 4 sloty (V083). prep_pct = float(getattr(battery, "planner_neg_sell_prep_soc_percent", 100.0)) tail_slots = int(getattr(battery, "planner_neg_sell_full_soc_tail_slots", 0)) return prep_pct < 100.0 - 1e-6 and tail_slots > 0 def _neg_sell_indices_by_prague_day( slots: list[PlanningSlot], ) -> dict[object, list[int]]: by_day: dict[object, list[int]] = {} for t, st in enumerate(slots): if float(st.sell_price) < 0.0: by_day.setdefault(_prague_calendar_date(st), []).append(t) for day in by_day: by_day[day].sort() return by_day def _neg_sell_t_detach_index( indices: list[int], charge_b: dict[int, float], soc_need: dict[int, float], tail_start: int, soc_max: float, *, margin: float = 1.05, min_gap_wh: float = 500.0, detach_soc_frac: float = 0.85, ) -> int: """ Bod T: první prep slot, kde (1) soc_need[t] ≥ detach_soc_frac × soc_max a (2) zbývající B-nabití od t do konce pokryje mezeru do 100 %. Dřívější chyba: soc_need[t] ≤ soc_need[tail_start] platilo hned na začátku okna. """ if not indices: return 0 suffix_from: dict[int, float] = {} run = 0.0 for t in reversed(indices): run += float(charge_b.get(t, 0.0)) suffix_from[t] = run thresh_wh = max( soc_max * detach_soc_frac, float(soc_need.get(tail_start, soc_max)) * 0.92, ) for t in indices: if t >= tail_start: continue need_t = float(soc_need.get(t, soc_max)) if need_t < thresh_wh: continue gap_rem = soc_max - need_t if gap_rem <= min_gap_wh: return t if suffix_from.get(t, 0.0) >= gap_rem * margin: return t return tail_start def _neg_sell_pv_b_charge_wh(slot: PlanningSlot, battery: Any) -> float: """Odhad Wh nabitelné jen z PV B v jednom sell<0 slotu (surplus nad load, cap výkonu).""" pv_surplus_b = max(0.0, float(slot.pv_b_forecast_w) - float(slot.load_baseline_w)) if pv_surplus_b <= 500.0: return 0.0 cap_w = min(pv_surplus_b, float(battery.max_charge_power_w)) return cap_w * INTERVAL_H * float(battery.charge_efficiency) def _neg_sell_pv_forecast_charge_wh(slot: PlanningSlot, battery: Any) -> float: """Odhad Wh z FVE A+B v sell<0 slotu pro zpětnou projekci soc_need (v44).""" pv_surplus = max( 0.0, float(slot.pv_a_forecast_w) + float(slot.pv_b_forecast_w) - float(slot.load_baseline_w), ) if pv_surplus <= 500.0: return 0.0 cap_w = min(pv_surplus, float(battery.max_charge_power_w)) return cap_w * INTERVAL_H * float(battery.charge_efficiency) def _neg_sell_day_pv_b_usable_wh( slots: list[PlanningSlot], first_neg_sell_idx: int | None, battery: Any, ) -> float: """Součet B-nabíjení ve všech sell<0 slotech téhož pražského dne.""" if first_neg_sell_idx is None: return 0.0 neg_day = _prague_calendar_date(slots[first_neg_sell_idx]) total = 0.0 for s in slots: if _prague_calendar_date(s) != neg_day: continue if float(s.sell_price) >= 0.0: continue total += _neg_sell_pv_b_charge_wh(s, battery) return total def _neg_sell_e_surplus_after_t_wh( slots: list[PlanningSlot], t_detach: int, last_neg: int, battery: Any, ) -> float: """Integrál přebytku FVE nad load+bat cap od t_detach do last_neg (Wh).""" total = 0.0 for t in range(t_detach, last_neg + 1): if t < 0 or t >= len(slots): continue st = slots[t] if float(st.sell_price) >= 0.0: continue pv_surplus = max( 0.0, float(st.pv_a_forecast_w) + float(st.pv_b_forecast_w) - float(st.load_baseline_w), ) if pv_surplus <= 500.0: continue cap_charge_wh = ( min(pv_surplus, float(battery.max_charge_power_w)) * INTERVAL_H * float(battery.charge_efficiency) ) total += max(0.0, pv_surplus * INTERVAL_H - cap_charge_wh) return total def _neg_sell_day_phases( slots: list[PlanningSlot], battery: Any, ) -> tuple[list[str], list[Optional[float]], list[float], dict[str, Any]]: """ Per slot: phase (none|prep|tail), soc_target_wh (rampa z PV B, ne fixní %), shortfall váha. V35: zpětná projekce soc_need z B od tail. V36: t_detach = první prep slot kde suffix B-nabití pokryje (soc_max − soc_need[t]). """ t_len = len(slots) phases: list[str] = ["none"] * t_len soc_targets: list[Optional[float]] = [None] * t_len shortfall_weights: list[float] = [0.0] * t_len tail_n = int(getattr(battery, "planner_neg_sell_full_soc_tail_slots", 0)) soc_max = float(battery.soc_max_wh) min_soc = float(battery.min_soc_wh) post_detach_prep_ts: set[int] = set() day_meta: list[dict[str, Any]] = [] by_day: dict[object, list[int]] = {} for t, st in enumerate(slots): if float(st.sell_price) < 0.0: by_day.setdefault(_prague_calendar_date(st), []).append(t) for day, indices in by_day.items(): if not indices: continue indices.sort() last_t = indices[-1] tail_start = max(indices[0], last_t - tail_n + 1) if tail_n > 0 else last_t + 1 charge_b = { t: _neg_sell_pv_forecast_charge_wh(slots[t], battery) for t in indices } soc_need: dict[int, float] = {last_t: soc_max} for i in range(len(indices) - 1, 0, -1): t_cur = indices[i] t_prev = indices[i - 1] soc_need[t_prev] = max(min_soc, soc_need[t_cur] - charge_b[t_cur]) t_detach = _neg_sell_t_detach_index( indices, charge_b, soc_need, tail_start, soc_max, ) soc_detach_wh = float(soc_need.get(t_detach, soc_max)) e_surplus = _neg_sell_e_surplus_after_t_wh(slots, t_detach, last_t, battery) for t in indices: if t >= tail_start: phases[t] = "tail" if tail_n <= 1: soc_targets[t] = soc_max else: pos = t - tail_start frac = pos / float(max(1, tail_n - 1)) lo = float(soc_need.get(tail_start, soc_max)) soc_targets[t] = lo + frac * (soc_max - lo) else: phases[t] = "prep" soc_targets[t] = float(soc_need[t]) if t >= t_detach: post_detach_prep_ts.add(t) shortfall_weights[t] = float(last_t - t + 1) / float(len(indices)) day_meta.append( { "prague_date": str(day), "first_neg_idx": indices[0], "last_neg_idx": last_t, "tail_start_idx": tail_start, "t_detach_idx": t_detach, "soc_detach_wh": soc_detach_wh, "e_surplus_after_t_wh": e_surplus, "soc_ramp_wh": [ { "slot": slots[t].interval_start.isoformat(), "soc_need_wh": float(soc_need[t]), "phase": phases[t], "soc_target_wh": float(soc_targets[t] or 0.0), } for t in indices ], } ) meta: dict[str, Any] = { "neg_sell_b_ramp_v35": True, "neg_sell_prep_window_v36": True, "days": day_meta, "post_detach_prep_ts": sorted(post_detach_prep_ts), } if day_meta: meta["t_detach_idx"] = day_meta[0]["t_detach_idx"] meta["e_surplus_after_t_wh"] = day_meta[0]["e_surplus_after_t_wh"] return phases, soc_targets, shortfall_weights, meta def _neg_sell_day_pv_usable_wh( slots: list[PlanningSlot], first_neg_sell_idx: int | None, *, max_charge_power_w: float, charge_efficiency: float, ) -> float: """ Odhad Wh nabitelné z FVE v sell<0 slotech téhož pražského dne (forecast surplus × cap nabíjení). """ if first_neg_sell_idx is None: return 0.0 neg_day = _prague_calendar_date(slots[first_neg_sell_idx]) total_wh = 0.0 for s in slots: if _prague_calendar_date(s) != neg_day: continue if float(s.sell_price) >= 0.0: continue pv_surplus_w = max( 0.0, float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - float(s.load_baseline_w), ) if pv_surplus_w <= 500.0: continue cap_w = min(pv_surplus_w, float(max_charge_power_w)) total_wh += cap_w * INTERVAL_H * float(charge_efficiency) return total_wh def _pre_neg_pv_export_forecast_cushion_ok_for_day( slots: list[PlanningSlot], battery: Any, first_neg_t: int, observed_soc_wh: float, *, neg_sell_phases_en: bool, soc_target_by_t: list[Optional[float]] | None = None, ) -> bool: """ Cushion pro jeden pražský den: usable A+B v sell<0 okně pokryje dobítí na soc_need[first_neg]. Vstup SoC = pozorovaná telemetrie (ne trajektorie z předchozího solve). """ if first_neg_t < 0 or first_neg_t >= len(slots): return False if neg_sell_phases_en and soc_target_by_t is not None: tgt = soc_target_by_t[first_neg_t] target_wh = float(tgt) if tgt is not None else float(battery.soc_max_wh) else: target_wh = float(battery.soc_max_wh) soc_obs = max( float(battery.min_soc_wh), min(float(observed_soc_wh), float(battery.soc_max_wh)), ) if soc_obs >= target_wh - 1e-3: return True usable_wh = _neg_sell_day_pv_usable_wh( slots, first_neg_t, max_charge_power_w=float(battery.max_charge_power_w), charge_efficiency=float(battery.charge_efficiency), ) needed_wh = max(0.0, target_wh - soc_obs) if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH: return True return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN def _pre_neg_pv_export_forecast_cushion_ok( slots: list[PlanningSlot], battery: Any, observed_soc_wh: float, first_neg_sell_idx: int | None, *, neg_sell_phases_en: bool, soc_target_by_t: list[Optional[float]] | None = None, ) -> bool: """Zpětná kompatibilita: cushion pro první sell<0 v horizontu (pozorované SoC).""" if first_neg_sell_idx is None or first_neg_sell_idx <= 0: return False targets = soc_target_by_t if neg_sell_phases_en and targets is None: _ph, targets, _w, _meta = _neg_sell_day_phases(slots, battery) return _pre_neg_pv_export_forecast_cushion_ok_for_day( slots, battery, first_neg_sell_idx, observed_soc_wh, neg_sell_phases_en=neg_sell_phases_en, soc_target_by_t=targets, ) def _pre_neg_pv_export_slot_indices_for_day( slots: list[PlanningSlot], first_neg_t: int, first_neg_buy_idx: int | None, ) -> set[int]: """Kladný sell téhož dne před prvním sell<0, PV přebytek.""" if first_neg_t <= 0: return set() neg_day = _prague_calendar_date(slots[first_neg_t]) out: set[int] = set() for t in range(first_neg_t): if _prague_calendar_date(slots[t]) != neg_day: continue if float(slots[t].sell_price) < 0.0: continue if first_neg_buy_idx is not None and t >= first_neg_buy_idx: continue if _slot_pv_surplus_w(slots[t]) <= NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W: continue out.add(t) return out def _pre_neg_pv_export_bundle( slots: list[PlanningSlot], battery: Any, observed_soc_wh: float, first_neg_buy_idx: int | None, *, neg_sell_phases_en: bool, soc_target_by_t: list[Optional[float]] | None = None, ) -> tuple[set[int], dict[str, bool]]: """ v36: pre-neg export per pražský den s vlastním cushion (A+B v neg okně dne). v40: cushion vždy z pozorovaného SoC (telemetrie), bez řetězení modelových cílů mezi dny. """ by_day = _neg_sell_indices_by_prague_day(slots) export_ts: set[int] = set() cushion_by_day: dict[str, bool] = {} for day in sorted(by_day.keys()): indices = by_day[day] if not indices: continue first_t = indices[0] ok = _pre_neg_pv_export_forecast_cushion_ok_for_day( slots, battery, first_t, observed_soc_wh, neg_sell_phases_en=neg_sell_phases_en, soc_target_by_t=soc_target_by_t, ) cushion_by_day[str(day)] = ok if ok: export_ts |= _pre_neg_pv_export_slot_indices_for_day( slots, first_t, first_neg_buy_idx, ) return export_ts, cushion_by_day def _pre_neg_pv_export_slot_indices( slots: list[PlanningSlot], first_neg_sell_idx: int | None, pre_neg_export_last_t: int | None, first_neg_buy_idx: int | None, ) -> set[int]: """Legacy: jen před globálním prvním sell<0 (v36 preferuj _pre_neg_pv_export_bundle).""" if first_neg_sell_idx is None or pre_neg_export_last_t is None: return set() out: set[int] = set() for t in range(pre_neg_export_last_t + 1): if float(slots[t].sell_price) < 0.0: continue if first_neg_buy_idx is not None and t >= first_neg_buy_idx: continue if _slot_pv_surplus_w(slots[t]) <= NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W: continue out.add(t) return out def _discharge_before_first_neg_sell_ts( slots: list[PlanningSlot], first_neg_sell_idx: int | None, ) -> set[int]: """Všechny kladné-sell sloty před 1. sell<0 (funguje i v rolling bez D−1 večera v horizontu).""" if first_neg_sell_idx is None or first_neg_sell_idx <= 0: return set() return { t for t in range(first_neg_sell_idx) if float(slots[t].sell_price) >= 0.0 } def _evening_discharge_before_neg_day_ts( slots: list[PlanningSlot], neg_sell_day_meta: dict[str, Any], ) -> set[int]: """ Večer/noc kalendářního dne D−1 před pražským dnem D s sell<0: příprava headroomu. """ from datetime import timedelta out: set[int] = set() for day_info in neg_sell_day_meta.get("days") or []: first_neg = int(day_info.get("first_neg_idx", -1)) if first_neg < 0 or first_neg >= len(slots): continue neg_date = _prague_calendar_date(slots[first_neg]) prev_date = neg_date - timedelta(days=1) for t, st in enumerate(slots): if _prague_calendar_date(st) != prev_date: continue if float(st.sell_price) < 0.0: continue h = _prague_hour(st) if not (17 <= h <= 23 or _in_night_battery_export_window(st)): continue if float(st.sell_price) < 0.0: continue out.add(t) return out def _night_baseload_buffer_wh_from_slots( slots: list[PlanningSlot], battery: Any, ) -> float: """Buffer Wh nad reserve pro noc (R__063 nebo % z asset_battery).""" if not slots: return 0.0 slot0 = slots[0] buf = getattr(slot0, "night_baseload_buffer_wh", None) if buf is not None: return max(0.0, float(buf)) target = getattr(slot0, "night_baseload_target_wh", None) if target is not None: pct = float(getattr(battery, "planner_night_baseload_buffer_percent", 20.0) or 20.0) return max(0.0, float(target) * pct / 100.0) return 0.0 def _neg_evening_discharge_budget_wh( *, observed_soc_wh: float, reserve_soc_wh: float, night_baseload_buffer_wh: float, ) -> float: """Wh k výboji nad reserve + noční buffer — z telemetrie, ne z LP trajektorie.""" return max( 0.0, float(observed_soc_wh) - float(reserve_soc_wh) - float(night_baseload_buffer_wh), ) def _first_neg_sell_idx_on_prague_day( slots: list[PlanningSlot], prague_day: object, ) -> int | None: for t, st in enumerate(slots): if _prague_calendar_date(st) != prague_day: continue if float(st.sell_price) < 0.0: return t return None def _terminal_neg_buy_weight( slots: list[PlanningSlot], *, first_neg_buy_idx: int | None, ) -> float: """ w_neg ∈ [0, TERMINAL_NEG_BUY_WEIGHT_CAP]: snížení terminal SoC shadow price při buy<0 v horizontu. Blížší a zápornější okno → vyšší váha; effective_factor = planner_terminal_soc_value_factor × (1 − w_neg). """ if first_neg_buy_idx is None or first_neg_buy_idx <= 0: return 0.0 slots_ahead = first_neg_buy_idx prox = max( 0.0, 1.0 - slots_ahead / TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS, ) if prox <= 0.0: return 0.0 window_end = min(len(slots), first_neg_buy_idx + int(24 / INTERVAL_H)) neg_buys = [ float(slots[t].buy_price) for t in range(first_neg_buy_idx, window_end) if float(slots[t].buy_price) < 0.0 ] if not neg_buys: return 0.0 min_neg_buy = min(neg_buys) mag_raw = min(1.0, abs(min_neg_buy) / TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK) mag = TERMINAL_NEG_BUY_MAGNITUDE_FLOOR + (1.0 - TERMINAL_NEG_BUY_MAGNITUDE_FLOOR) * mag_raw return min(TERMINAL_NEG_BUY_WEIGHT_CAP, prox * mag) def _future_neg_buy_discharge_enabled( slots: list[PlanningSlot], battery: Any, *, first_neg_buy_idx: int, first_neg_sell_idx: int | None, observed_soc_wh: float, neg_sell_phases_en: bool, neg_sell_soc_target_by_t: list[Optional[float]] | None = None, ) -> bool: """ Večerní vývoz k reserve_soc před dnem s buy<0: aktivní i při relaxed_neg_prep_window, pokud FVE v sell<0 okně pokryje deficit do prep rampy (× PRE_NEG_PV_EXPORT_FORECAST_MARGIN). """ if first_neg_buy_idx <= 0: return False neg_buy_day = _prague_calendar_date(slots[first_neg_buy_idx]) neg_sell_t = first_neg_sell_idx if ( neg_sell_t is None or _prague_calendar_date(slots[neg_sell_t]) != neg_buy_day ): neg_sell_t = _first_neg_sell_idx_on_prague_day(slots, neg_buy_day) if neg_sell_t is None: return False if neg_sell_phases_en and neg_sell_soc_target_by_t is not None: tgt = neg_sell_soc_target_by_t[neg_sell_t] target_wh = float(tgt) if tgt is not None else float(battery.soc_max_wh) else: target_wh = float(battery.soc_max_wh) reserve_wh = float( getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) ) soc_obs = max( float(battery.min_soc_wh), min(float(observed_soc_wh), float(battery.soc_max_wh)), ) if soc_obs <= reserve_wh + 1e-3: return False if soc_obs >= target_wh - 1e-3: return True usable_wh = _neg_sell_day_pv_usable_wh( slots, neg_sell_t, max_charge_power_w=float(battery.max_charge_power_w), charge_efficiency=float(battery.charge_efficiency), ) needed_wh = max(0.0, target_wh - soc_obs) if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH: return True return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN def _pos_sell_pre_neg_buy_evening_export_exempt_ts( slots: list[PlanningSlot], pos_sell_pre_neg_buy_ts: list[int], evening_peak_export_ts: list[int], *, charge_acquisition_czk_kwh: float, min_spread: float, fixed_tariff: bool, future_neg_buy_discharge_en: bool, ) -> set[int]: """Večerní peak před buy<0: neaplikovat ge=0, pokud je vývoz ekonomicky výhodný.""" if not future_neg_buy_discharge_en: return set() evening_peak_set = set(evening_peak_export_ts) out: set[int] = set() for t in pos_sell_pre_neg_buy_ts: if t not in evening_peak_set and not _in_evening_push_hour_window(slots[t]): continue if not _slot_profitable_battery_export( slots[t], charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=min_spread, fixed_tariff=fixed_tariff, ): continue out.add(t) return out def _neg_evening_before_neg_push_indices( slots: list[PlanningSlot], candidate_ts: set[int], *, export_budget_wh: float, per_slot_discharge_wh: float, discharge_export_ok: set[int] | None = None, ) -> set[int]: """Nejdražší kladné-sell sloty v kandidátech, dokud budget z pozorovaného SoC.""" if export_budget_wh < per_slot_discharge_wh * 0.5 or not candidate_ts: return set() eligible = { t for t in candidate_ts if discharge_export_ok is None or t in discharge_export_ok } if not eligible: return set() ranked = sorted( eligible, key=lambda t: (float(slots[t].sell_price), -t), reverse=True, ) out: set[int] = set() cum_wh = 0.0 for t in ranked: if float(slots[t].sell_price) < 0.0: continue if cum_wh + per_slot_discharge_wh > export_budget_wh + 1e-6: break out.add(t) cum_wh += per_slot_discharge_wh return out def _neg_evening_reserve_soc_anchors( slots: list[PlanningSlot], neg_sell_day_meta: dict[str, Any], battery: Any, ) -> list[tuple[int, float]]: """ Kotvy SoC ≤ reserve_soc před neg oknem: - večer D−1 (23:45) pokud je v horizontu, - slot těsně před 1. sell<0 (rolling: ráno bez včerejška v okně). """ from datetime import timedelta reserve_wh = float( getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) ) out: list[tuple[int, float]] = [] seen: set[int] = set() for day_info in neg_sell_day_meta.get("days") or []: first_neg = int(day_info.get("first_neg_idx", -1)) if first_neg < 0 or first_neg >= len(slots): continue neg_date = _prague_calendar_date(slots[first_neg]) prev_date = neg_date - timedelta(days=1) eve_slots = [ t for t, st in enumerate(slots) if _prague_calendar_date(st) == prev_date and ( 17 <= _prague_hour(st) <= 23 or _in_night_battery_export_window(st) ) ] if eve_slots: t_eve = max(eve_slots) if t_eve not in seen: out.append((t_eve, reserve_wh)) seen.add(t_eve) if first_neg > 0: t_pre = first_neg - 1 if ( t_pre not in seen and float(slots[t_pre].sell_price) >= 0.0 ): out.append((t_pre, reserve_wh)) seen.add(t_pre) return out def _battery_export_cap_w(battery: Any, grid: Any) -> float: """Max výkon vývozu baterie do sítě [W] — z DB, ne hardcoded konstanta.""" return min( float(battery.max_discharge_power_w), float(grid.max_export_power_w), ) def _evening_push_battery_export_w( slot: PlanningSlot, battery: Any, grid: Any, ) -> float: """ Tvrdý push ge_bat: min(site/inverter export cap, BMS − load). Stejná fyzika jako Deye SELL — load pokryje baterie, zbytek výkonu jde do sítě (ne (max−load)/2 z dvojího započtení bd+ge_bat v LP). """ cap = _battery_export_cap_w(battery, grid) load_w = max(0.0, float(slot.load_baseline_w)) discharge_headroom = max( 0.0, float(battery.max_discharge_power_w) - load_w, ) return min(cap, discharge_headroom) def _dispatch_grid_setpoint_w( *, gi_w: float, ge_w: float, ge_bat_w: float, ge_pv_w: float, max_export_power_w: int, ) -> tuple[int, str]: """ grid_setpoint pro export do sítě (záporný W) a export_mode. gi−ge může být ~0 při load-first, i když ge_bat exportuje — Deye reg 143 potřebuje |grid_setpoint|. """ ge_total = max(0.0, float(ge_w)) ge_bat_v = max(0.0, float(ge_bat_w)) cap = float(max_export_power_w) if ge_bat_v >= GE_MIN_EXPORT_W and ge_bat_v >= max(ge_total * 0.5, 500.0): export_w = min(cap, max(ge_total, ge_bat_v + max(0.0, float(ge_pv_w)))) return -int(round(export_w)), "BATTERY_SELL" if ge_total >= GE_MIN_EXPORT_W: return -int(round(min(cap, ge_total))), "PV_SURPLUS" return round(float(gi_w) - ge_total), "NONE" 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 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 _pre_neg_buy_discharge_indices( slots: list[PlanningSlot], first_neg_buy_idx: int | None, *, charge_acquisition_czk_kwh: float, min_spread: float, fixed_tariff: bool, ) -> set[int]: """ Sloty před prvním buy<0: výboj baterie do sítě při kladném sell (včetně noci). Bez rozšíření discharge_export_slots (v19b — jinak w_arb → Infeasible). """ if first_neg_buy_idx is None or first_neg_buy_idx <= 0: return set() out: set[int] = set() for i in range(first_neg_buy_idx): s = slots[i] if float(s.buy_price) < 0.0: continue if float(s.sell_price) < PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH: continue if not _slot_profitable_battery_export( s, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=min_spread, fixed_tariff=fixed_tariff, ): continue out.add(i) return out def _slot_pv_surplus_w(slot: PlanningSlot) -> float: load_w = float(slot.load_baseline_w) pv_w = float(slot.pv_a_forecast_w) + float(slot.pv_b_forecast_w) return max(0.0, pv_w - load_w) def _battery_export_push_defer_to_pv(slot: PlanningSlot) -> bool: """ Při kladném sell a PV přebytku nevnucovat vývoz z baterie (ge_bat / z_export). Platí pro ranní pre-neg, večerní push i KV1 odpoledne (block_export + fixní tarif): přetok řeší ge_pv / Deye PASSIVE, ne BATTERY_SELL. """ if float(slot.sell_price) < 0.0: return False return _slot_pv_surplus_w(slot) > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W def _in_night_battery_export_window(slot: PlanningSlot) -> bool: """ Noční okno pro večerní push / peak sell: >=17h Prague, nebo 0–5h (přes půlnoc). Končí prvním slotem s významným PV přebytkem (východ FVE), ne kalendářním dnem. """ if _slot_pv_surplus_w(slot) > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W: return False h = _prague_hour(slot) if h >= NIGHT_EXPORT_EVENING_START_HOUR: return True return h <= NIGHT_EXPORT_MORNING_END_HOUR def _in_evening_push_hour_window(slot: PlanningSlot) -> bool: """Tvrdý večerní push jen ≥17h Prague — ne noční vývoz ve 02–06h (sell < buy).""" return _prague_hour(slot) >= NIGHT_EXPORT_EVENING_START_HOUR def _night_export_window_segments(slots: list[PlanningSlot]) -> list[list[int]]: """Souvislé úseky nočního okna v horizontu (oddělené denní pauzou / východem FVE).""" segments: list[list[int]] = [] current: list[int] = [] for t, s in enumerate(slots): if _in_night_battery_export_window(s): current.append(t) else: if current: segments.append(current) current = [] if current: segments.append(current) return segments def _night_peak_sell_czk_kwh(slots: list[PlanningSlot], slot_index: int) -> float: """Max sell v nočním úseku, do kterého slot patří (pro evening_early).""" for seg in _night_export_window_segments(slots): if slot_index in seg: return max(float(slots[t].sell_price) for t in seg) return 0.0 def _evening_peak_export_indices( slots: list[PlanningSlot], *, degrad_czk_kwh: float, evening_start_hour: int = 17, ) -> list[int]: """ Noční špičky sell: jeden peak na souvislý úsek (17h → půlnoc → ráno do východu FVE), ne per kalendářní den (oprava 23:30 vs 00:00). """ _ = evening_start_hour # kompatibilita volání; okno řídí NIGHT_EXPORT_* konstanty out: list[int] = [] for seg in _night_export_window_segments(slots): if not seg: continue peak = max(float(slots[t].sell_price) for t in seg) if peak <= 0.0: continue for t in seg: if float(slots[t].sell_price) >= peak - degrad_czk_kwh: out.append(t) return sorted(out) def _planner_discharge_floor_wh(battery: Any) -> float: """Provozní podlaha vývoje: reserve_soc (domluva), ne jen min_soc.""" return max( float(getattr(battery, "min_soc_wh", 0.0)), float(getattr(battery, "reserve_soc_wh", 0.0)), ) def _evening_push_discharge_budget_wh( *, current_soc_wh: float, discharge_floor_wh: float, soc_max_wh: float, discharge_slot_buffer: float, ) -> float: """ Rozpočet Wh pro tvrdý večerní push — stejný princip jako R__063 (discharge_slot_buffer). Podlaha = reserve_soc (typ. 20 %), ne min_soc (10 %). """ exportable_full_wh = max(0.0, float(soc_max_wh) - float(discharge_floor_wh)) available_wh = max(0.0, float(current_soc_wh) - float(discharge_floor_wh)) buf = float(discharge_slot_buffer) if buf <= 0.0: return available_wh return min(available_wh, exportable_full_wh * buf) def _kv1_block_export_fixed_evening_push( grid: Any, *, purchase_fixed: bool, ) -> bool: """KV1: fixní buy + block_export — večerní push jiná profitabilita než acq+spread.""" return purchase_fixed and bool( getattr(grid, "block_export_on_negative_sell", False) ) def _slot_evening_push_profitable( slot: PlanningSlot, *, charge_acquisition_czk_kwh: float, min_spread: float, 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: 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: if sell_t < PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH: return False if slots is not None: zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx) 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) def _evening_push_segment_candidates( slots: list[PlanningSlot], seg: list[int], *, charge_acquisition_czk_kwh: float, min_spread: float, 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: return [] out: list[int] = [] for t in seg: if discharge_export_ok is not None and t not in discharge_export_ok: continue if not _in_evening_push_hour_window(slots[t]): continue if not _slot_evening_push_profitable( slots[t], charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=min_spread, slots=slots, first_neg_sell_idx=first_neg_sell_idx, kv1_evening_push=kv1_evening_push, purchase_fixed=purchase_fixed, ): continue out.append(t) return out def _strict_late_replan_evening_slot_indices( slots: list[PlanningSlot], *, first_neg_buy_idx: int | None, observed_soc_wh: float, reserve_soc_wh: float, ) -> set[int]: """ Strict solve: večer D0 (17–22h) před dnem s buy<0 — vývoz k reserve, výjimka z pos_sell ge=0. """ if first_neg_buy_idx is None or first_neg_buy_idx <= 0: return set() if not any(float(s.buy_price) < 0.0 for s in slots): return set() if observed_soc_wh <= float(reserve_soc_wh) + 500.0: return set() replan_day = _prague_calendar_date(slots[0]) out: set[int] = set() for t, s in enumerate(slots): if _prague_calendar_date(s) != replan_day: continue h = _prague_hour(s) if h < NIGHT_EXPORT_EVENING_START_HOUR or h > 22: continue if float(s.sell_price) < 0.0: continue out.add(t) return out def _strict_late_replan_night_self_consume_indices( slots: list[PlanningSlot], *, evening_export_ts: set[int], ) -> set[int]: """Strict: noc po 22h — dům z baterie, ne drahý import (mimo večerní export sloty).""" out: set[int] = set() for t, s in enumerate(slots): if t in evening_export_ts: continue if not _in_night_battery_export_window(s): continue if float(s.load_baseline_w) <= 0: continue if float(s.buy_price) < 0.0: continue out.add(t) return out def _degraded_relaxed_night_self_consume_indices( slots: list[PlanningSlot], ) -> set[int]: """ relaxed_solver_masks: celé noční okno — dům z baterie (až min_soc), ne import za spot buy. """ out: set[int] = set() for t, s in enumerate(slots): if not _in_night_battery_export_window(s): continue if float(s.load_baseline_w) <= 0: continue if float(s.buy_price) < 0.0: continue out.add(t) return out def _degraded_relaxed_evening_export_to_reserve_indices( slots: list[PlanningSlot], *, observed_soc_wh: float, reserve_soc_wh: float, first_neg_buy_idx: int | None, ) -> set[int]: """ Nouzový solve: večer D0 smí vývoz bat k reserve_soc před dnem s buy<0 (headroom na zítra). Jen kalendářní večer 17–22h — po 22h už noc (dům z baterie, ne držet kvůli exportu). """ if first_neg_buy_idx is None or first_neg_buy_idx <= 0: return set() if observed_soc_wh <= float(reserve_soc_wh) + 500.0: return set() replan_day = _prague_calendar_date(slots[0]) out: set[int] = set() for t, s in enumerate(slots): if _prague_calendar_date(s) != replan_day: continue h = _prague_hour(s) if h < NIGHT_EXPORT_EVENING_START_HOUR or h > 22: continue if float(s.sell_price) < 0.0: continue out.add(t) return out def _post_evening_push_night_self_consume_indices( slots: list[PlanningSlot], evening_push_ts: set[int], ) -> set[int]: """ Po posledním evening_push daného večera až do rána: dům z baterie, ne import za ~5 Kč. """ if not evening_push_ts: return set() last_push_by_day: dict[object, int] = {} for t in evening_push_ts: last_push_by_day[_prague_calendar_date(slots[t])] = max( last_push_by_day.get(_prague_calendar_date(slots[t]), -1), t, ) out: set[int] = set() for t, s in enumerate(slots): day = _prague_calendar_date(s) t_last = last_push_by_day.get(day) if t_last is None or t <= t_last: continue if t in evening_push_ts: continue if not _in_night_battery_export_window(s): continue if float(s.buy_price) <= 0.0: continue if float(s.load_baseline_w) <= 0: continue out.add(t) return out def _evening_push_calendar_segments( slots: list[PlanningSlot], discharge_export_ok: set[int] | None = None, ) -> list[list[int]]: """Kalendářní večery (≥17h) v nočním okně — každý den vlastní push rozpočet.""" by_date: dict[object, list[int]] = {} for t, s in enumerate(slots): if not _in_evening_push_hour_window(s): continue if not _in_night_battery_export_window(s): continue if discharge_export_ok is not None and t not in discharge_export_ok: continue by_date.setdefault(_prague_calendar_date(s), []).append(t) return [sorted(v) for v in by_date.values() if v] def _primary_night_export_segment_indices(slots: list[PlanningSlot]) -> set[int]: """ První noční epizoda v horizontu (17h → půlnoc → do východu FVE), která platí pro rozpočet Wh z aktuální SoC. Další večery v horizontu (po dni FVE / nabíjení) se plánují až vlastním rolling replanem — nesdílí dnešní baterii. """ segs = _night_export_window_segments(slots) if not segs: return set() for seg in segs: if 0 in seg: return set(seg) return set(segs[0]) def _evening_push_soc_budget_calendar_segments( slots: list[PlanningSlot], discharge_export_ok: set[int] | None = None, ) -> list[list[int]]: """Kalendářní večery jen v primární noční epizodě — vhodné pro push_budget z current_soc.""" primary = _primary_night_export_segment_indices(slots) if not primary: return [] return [ seg for seg in _evening_push_calendar_segments(slots, discharge_export_ok) if seg and all(t in primary for t in seg) ] def _night_self_consume_discourage_import_indices( slots: list[PlanningSlot], *, evening_push_ts: set[int], charge_acquisition_czk_kwh: float, min_spread: float, purchase_fixed: bool = False, ) -> set[int]: """ Noční sloty mimo evening_push: penalizace importu pro dům (preferovat bd). v45: celé noční okno, ne jen evening_early_export_ban subset. KV1: buy ≈ acq (konstantní ~6,35) — jinak prázdná množina, síť místo baterie v noci. """ out: set[int] = set() for t, s in enumerate(slots): if t in evening_push_ts: continue if not _in_night_battery_export_window(s): continue if float(s.load_baseline_w) <= 0: continue buy_t = float(s.buy_price) if purchase_fixed: if buy_t >= 0.0: out.add(t) continue if buy_t <= float(charge_acquisition_czk_kwh) + float(min_spread): continue out.add(t) return out def _evening_battery_export_push_indices( slots: list[PlanningSlot], *, charge_acquisition_czk_kwh: float, degrad_czk_kwh: float, current_soc_wh: float, min_soc_wh: float, soc_max_wh: float, per_slot_discharge_wh: float, discharge_slot_buffer: float, discharge_export_ok: set[int] | None = None, 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 z aktuální SoC jen pro **primární noční epizodu** (dnešní večer → ráno). Zítřejší večer v horizontu se nekrade polovinou budgetu (v43 split) — nabije se přes den / neg okno; push přidá zítřejší rolling replan. per_slot_discharge_wh: min(BMS, export cap) × účinnost × 0,25 h. """ _ = evening_start_hour # kompatibilita volání if per_slot_discharge_wh <= 0.0: return [] push_budget_wh = _evening_push_discharge_budget_wh( current_soc_wh=current_soc_wh, discharge_floor_wh=min_soc_wh, soc_max_wh=soc_max_wh, discharge_slot_buffer=discharge_slot_buffer, ) if push_budget_wh < per_slot_discharge_wh * 0.5: return [] evening_segments = _evening_push_soc_budget_calendar_segments( slots, discharge_export_ok=discharge_export_ok, ) if not evening_segments: return [] candidates: list[int] = [] seen: set[int] = set() for seg in evening_segments: for t in _evening_push_segment_candidates( slots, seg, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=degrad_czk_kwh, 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) candidates.append(t) if not candidates: return [] ranked = sorted( candidates, key=lambda i: (float(slots[i].sell_price), -i), reverse=True, ) remaining_wh = float(push_budget_wh) out: list[int] = [] for t in ranked: if remaining_wh + 1e-6 < per_slot_discharge_wh: break out.append(t) remaining_wh -= per_slot_discharge_wh return sorted(out) def _evening_push_peak_fallback_indices( slots: list[PlanningSlot], *, charge_acquisition_czk_kwh: float, min_spread: float, 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 best_sell = -1.0 for t, s in enumerate(slots): if discharge_export_ok is not None and t not in discharge_export_ok: continue if not _in_evening_push_hour_window(s): continue if not _slot_evening_push_profitable( s, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=min_spread, 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) if sell_t > best_sell: best_sell = sell_t best_t = t return {best_t} if best_t is not None else set() def _evening_night_peak_sell_czk(slots: list[PlanningSlot]) -> float: sells = [ float(s.sell_price) for s in slots if _in_night_battery_export_window(s) and float(s.sell_price) >= 0.0 ] return max(sells) if sells else 0.0 def _evening_push_peak_sell_czk(slots: list[PlanningSlot], push_ts: set[int]) -> float: if not push_ts: return 0.0 return max(float(slots[t].sell_price) for t in push_ts) def _evening_push_ts_from_iso(slots: list[PlanningSlot], iso_slots: list[str]) -> set[int]: by_iso = {s.interval_start.isoformat(): t for t, s in enumerate(slots)} return {by_iso[iso] for iso in iso_slots if iso in by_iso} def _evening_push_hysteresis_active( *, prev_peak_sell_czk: float | None, new_peak_sell_czk: float, prev_soc_wh: float | None, current_soc_wh: float, usable_capacity_wh: float, ) -> bool: if prev_peak_sell_czk is None: return False if abs(new_peak_sell_czk - float(prev_peak_sell_czk)) >= ( EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH ): return False if prev_soc_wh is not None and usable_capacity_wh > 1e-6: delta_pct = ( abs(float(current_soc_wh) - float(prev_soc_wh)) / float(usable_capacity_wh) * 100.0 ) if delta_pct >= EVENING_PUSH_HYSTERESIS_SOC_PCT: return False return True def _evening_early_export_penalty_indices( slots: list[PlanningSlot], *, discharge_export_slots: set[int], evening_push_ts: set[int], exempt_ts: set[int] | None = None, ) -> set[int]: """ ge_bat=0 v nočním okně mimo tvrdý evening_push (a mimo pre-neg / neg-evening větve). """ exempt = exempt_ts or set() out: set[int] = set() for t_ev, s_ev in enumerate(slots): if not _in_night_battery_export_window(s_ev): continue if t_ev not in discharge_export_slots: continue if t_ev in evening_push_ts or t_ev in exempt: continue out.add(t_ev) return out def _last_non_negative_sell_before_neg_buy( slots: list[PlanningSlot], first_neg_buy_idx: int | None, ) -> int | None: if first_neg_buy_idx is None or first_neg_buy_idx <= 0: return None candidates = [ i for i in range(first_neg_buy_idx) if float(slots[i].sell_price) >= 0.0 ] return max(candidates) if candidates else None def _positive_sell_pre_neg_buy_indices( slots: list[PlanningSlot], first_neg_buy_idx: int | None, ) -> list[int]: if first_neg_buy_idx is None or first_neg_buy_idx <= 0: return [] return [ t for t in range(first_neg_buy_idx) if float(slots[t].sell_price) >= 0.0 ] def _pre_neg_buy_empty_discharge_indices( slots: list[PlanningSlot], first_neg_buy_idx: int | None, last_pos_sell_idx: int | None, ) -> list[int]: """Sloty mezi posledním sell≥0 a prvním buy<0 — vyprázdnit před levným importem.""" if first_neg_buy_idx is None or first_neg_buy_idx <= 0: return [] if last_pos_sell_idx is None: return [] start = last_pos_sell_idx + 1 end = first_neg_buy_idx - 1 if start > end: return [] return list(range(start, end + 1)) def _pre_neg_buy_soc_ceiling_wh( slots: list[PlanningSlot], *, first_neg_buy_idx: int | None, min_soc_wh: float, soc_max_wh: float, max_charge_w: float, charge_eff: float, evening_start_hour: int = 17, ) -> float | None: """ Horní SoC těsně před prvním buy<0: pod soc_max musí vejít import v buy<0, PV B v tom okně a rezerva na odpolední sell<0 (stejný den, před večerem). """ if first_neg_buy_idx is None or first_neg_buy_idx <= 0: return None per_slot_chg = max(0.0, float(max_charge_w) * float(charge_eff) * INTERVAL_H) neg_buy_ts = [t for t, s in enumerate(slots) if float(s.buy_price) < 0.0] if not neg_buy_ts: return None last_neg_buy = max(neg_buy_ts) neg_day = _prague_calendar_date(slots[first_neg_buy_idx]) grid_wh = len(neg_buy_ts) * per_slot_chg pv_b_wh = 0.0 for t in neg_buy_ts: s = slots[t] sur = max( 0.0, float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - float(s.load_baseline_w), ) pv_b_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H post_wh = 0.0 for t in range(last_neg_buy + 1, len(slots)): s = slots[t] if _prague_calendar_date(s) != neg_day: continue if float(s.buy_price) < 0.0: continue if float(s.sell_price) >= 0.0: break if _prague_hour(s) >= evening_start_hour: break sur = max(0.0, float(s.pv_b_forecast_w) - float(s.load_baseline_w) * 0.25) post_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H buffer_wh = max(per_slot_chg * 2.0, 3000.0) needed = grid_wh + pv_b_wh + post_wh + buffer_wh ceiling = float(soc_max_wh) - needed floor = float(min_soc_wh) + max(per_slot_chg, 1000.0) return max(floor, min(float(soc_max_wh) - per_slot_chg, ceiling)) def _planner_soc_for_solver( current_soc_wh: float, battery, ) -> tuple[float, float | None]: """ SoC pro MILP. Při telemetrii na soc_max a dlouhém sell<0 s vysokou FVE bez rezervy pod stropem je model neřešitelný (nelze nabít / odvést přebytek). Necháme min. ~650 Wh pod soc_max. """ soc_max = float(battery.soc_max_wh) soc_min = float(battery.min_soc_wh) soc = max(soc_min, min(float(current_soc_wh), soc_max)) charge_slot_wh = ( float(battery.max_charge_power_w) * INTERVAL_H / max(float(battery.charge_efficiency), 1e-6) ) headroom = max(650.0, 0.382 * charge_slot_wh) if soc > soc_max - headroom: return max(soc_min, soc_max - headroom), headroom return soc, None def _pv_forced_vent_export_allowed( t: int, *, current_soc_wh: float, battery, soc_headroom_wh: float, pv_surplus_w: float, ) -> bool: """Přebytek FVE do sítě jen když baterie na konci předchozího slotu nemá kapacitu.""" if pv_surplus_w <= 0: return False if t == 0: return current_soc_wh >= float(battery.soc_max_wh) - soc_headroom_wh return False def _relax_solver_slot_masks(slots: list[PlanningSlot]) -> list[PlanningSlot]: """Nouzově permissivní allow_* — SQL masky nesmí učinit LP neřešitelným.""" return [ replace( s, allow_charge=True, allow_discharge_export=float(s.sell_price) >= 0.0, ) for s in slots ] def _unlock_late_replan_evening_slots( slots: list[PlanningSlot], *, current_soc_wh: float, reserve_soc_wh: float, ) -> None: """Pozdní replan: večer D0 povolit grid import + export (SQL allow_charge často false).""" if not slots or current_soc_wh <= float(reserve_soc_wh) + 500.0: return if not any(float(s.buy_price) < 0.0 for s in slots): return replan_day = _prague_calendar_date(slots[0]) unlocked = 0 for i, s in enumerate(slots): if _prague_calendar_date(s) != replan_day: continue if float(s.sell_price) < 0.0: continue if not _in_evening_push_hour_window(s): continue if s.allow_charge and s.allow_discharge_export: continue slots[i] = replace(s, allow_charge=True, allow_discharge_export=True) unlocked += 1 if unlocked: logger.info( "Late replan: unlocked evening slot masks on %d slot(s) (soc=%.0f Wh)", unlocked, float(current_soc_wh), ) def _evening_push_override_for_solve( evening_push_ts_override: Optional[set[int]], *, relaxed_expensive_import: bool, relaxed_neg_buy_charge: bool, relaxed_neg_prep_hold_only: bool, relaxed_neg_prep_window: bool, neg_sell_phases_fallback: bool, relaxed_pos_sell_ge_block: bool = False, relaxed_solver_masks: bool = False, ) -> Optional[set[int]]: """Po Infeasible nesmí retry držet hysterézní push z minulého běhu.""" if evening_push_ts_override is None: return None if ( relaxed_expensive_import or relaxed_neg_buy_charge or relaxed_neg_prep_hold_only or relaxed_neg_prep_window or neg_sell_phases_fallback or relaxed_pos_sell_ge_block or relaxed_solver_masks ): return None return set(evening_push_ts_override) def _filter_evening_push_override_indices( slots: list[PlanningSlot], override_ts: set[int], *, battery: Any, grid: Any, discharge_export_ok: set[int] | None, ) -> set[int]: """Hysterézní push jen na sloty, kde dnes smí a dává smysl tvrdý ge_bat push.""" out: set[int] = set() for t in override_ts: if t < 0 or t >= len(slots): continue if discharge_export_ok is not None and t not in discharge_export_ok: continue if _battery_export_push_defer_to_pv(slots[t]): continue push_floor_w = _evening_push_battery_export_w(slots[t], battery, grid) if push_floor_w < GE_MIN_EXPORT_W: continue out.add(t) return out