# backend/services/planning_engine.py # # EMS Platform – plánovací engine # Obsahuje: hlavní denní plán + rolling 15min replan # # Spouštění (APScheduler v lifespan.py): # scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0) # scheduler.add_job(run_rolling_replan, 'cron', minute='*/15') # Horizont: ems.fn_planning_horizon_end (OTE + strop/min v SQL). import json import logging import time from dataclasses import dataclass, replace from datetime import datetime, timezone, timedelta from types import SimpleNamespace from typing import Any, Optional from zoneinfo import ZoneInfo import pulp from app.config import get_settings logger = logging.getLogger(__name__) from services.planning.solver_v2 import solve_dispatch_v2 from services.planning.types import ( PlannerSolverError, _timestamptz_from_db, _slot_float_nullable, _prague_dow_hour, PlanningSlot, SOC_MIN_RELAX_LOOKAHEAD_SLOTS, DispatchResult, _prague_calendar_date, _prague_hour, _parse_json_dt, _current_slot_start, ) from services.planning.forecast import ( compute_correction_factor, apply_forecast_correction, ) from services.planning.db_io import ( _ev_session_from_json, _load_site_context, _load_previous_plan_charge_commitment_prev_w, _load_slots, _build_slot_inputs, _save_planning_run, _save_failed_planning_run, ) from services.planning.heuristics import ( _pv_scarcity_penalty_multiplier, _pv_coverage_ratio, _dynamic_arb_floor_wh_series, _soc_security_profile, _soc_min_wh_series, _slots_until_buy_le_threshold, _slots_until_sell_lt, _prewindow_deferral_slots, _soc_panel_min_wh_series, _recompute_charge_acquisition_from_results, _slots_with_charge_acquisition, _pv_store_value_czk_kwh, _slot_profitable_battery_export, _purchase_pricing_fixed, _horizon_fixed_tariff_like, _future_extreme_buy_from, _neg_sell_bat_dump_slots, _slots_until_buy_le, _pre_negative_sell_export_window, _neg_sell_phases_enabled, _neg_sell_indices_by_prague_day, _neg_sell_t_detach_index, _neg_sell_pv_b_charge_wh, _neg_sell_pv_forecast_charge_wh, _neg_sell_day_pv_b_usable_wh, _neg_sell_e_surplus_after_t_wh, _neg_sell_day_phases, _neg_sell_day_pv_usable_wh, _pre_neg_pv_export_forecast_cushion_ok_for_day, _pre_neg_pv_export_forecast_cushion_ok, _pre_neg_pv_export_slot_indices_for_day, _pre_neg_pv_export_bundle, _pre_neg_pv_export_slot_indices, _discharge_before_first_neg_sell_ts, _evening_discharge_before_neg_day_ts, _night_baseload_buffer_wh_from_slots, _neg_evening_discharge_budget_wh, _first_neg_sell_idx_on_prague_day, _terminal_neg_buy_weight, _future_neg_buy_discharge_enabled, _pos_sell_pre_neg_buy_evening_export_exempt_ts, _neg_evening_before_neg_push_indices, _neg_evening_reserve_soc_anchors, _battery_export_cap_w, _evening_push_battery_export_w, _dispatch_grid_setpoint_w, _morning_pre_neg_zone_peak_sell, _pre_neg_peak_sell_idx, _morning_pre_neg_export_indices, _pre_neg_buy_discharge_indices, _slot_pv_surplus_w, _battery_export_push_defer_to_pv, _in_night_battery_export_window, _in_evening_push_hour_window, _night_export_window_segments, _night_peak_sell_czk_kwh, _evening_peak_export_indices, _planner_discharge_floor_wh, _evening_push_discharge_budget_wh, _kv1_block_export_fixed_evening_push, _slot_evening_push_profitable, _evening_push_segment_candidates, _strict_late_replan_evening_slot_indices, _strict_late_replan_night_self_consume_indices, _degraded_relaxed_night_self_consume_indices, _degraded_relaxed_evening_export_to_reserve_indices, _post_evening_push_night_self_consume_indices, _evening_push_calendar_segments, _primary_night_export_segment_indices, _evening_push_soc_budget_calendar_segments, _night_self_consume_discourage_import_indices, _evening_battery_export_push_indices, _evening_push_peak_fallback_indices, _evening_night_peak_sell_czk, _evening_push_peak_sell_czk, _evening_push_ts_from_iso, _evening_push_hysteresis_active, _evening_early_export_penalty_indices, _last_non_negative_sell_before_neg_buy, _positive_sell_pre_neg_buy_indices, _pre_neg_buy_empty_discharge_indices, _pre_neg_buy_soc_ceiling_wh, _planner_soc_for_solver, _pv_forced_vent_export_allowed, _relax_solver_slot_masks, _unlock_late_replan_evening_slots, _evening_push_override_for_solve, _filter_evening_push_override_indices, ) from services.planning.constants import ( MORNING_PRENEG_START_HOUR, MORNING_PRENEG_END_HOUR, ACQUISITION_TWO_PASS_EPS_KWH, SOLVER_RELAX_STEPS, ARB_FLOOR_E_REF_FRAC, ARB_LOOKAHEAD_SLOTS, CORRECTION_DECAY_SLOTS, CORRECTION_MAX_CLAMP, CORRECTION_MIN_CLAMP, CORRECTION_WINDOW_H, CURTAILMENT_PENALTY, DAWN_LOW_PV_NO_CURTAIL_W, DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS, EVENING_PEAK_SELL_EPS_CZK_KWH, EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH, EVENING_PUSH_HYSTERESIS_SOC_PCT, EVENING_PUSH_Z_EXPORT_BONUS_CZK, EXTREME_BUY_DUMP_PREWINDOW_SLOTS, GE_MIN_EXPORT_W, INTERVAL_H, LOAD_FIRST_INCENTIVE_CZK_KWH, NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH, NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH, NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH, NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH, NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH, NEG_SELL_CURTAIL_PENALTY_CZK_KWH, NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH, NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH, NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH, NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH, NEG_SELL_PV_CHARGE_REWARD_CZK_KWH, NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH, NIGHT_EXPORT_EVENING_START_HOUR, NIGHT_EXPORT_MORNING_END_HOUR, NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W, NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH, PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH, PLANNER_BUILD_TAG, POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH, PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH, PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH, PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH, PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH, PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH, PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH, PRE_NEG_CHARGE_PENALTY_CZK_KWH, PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH, PRE_NEG_PV_EXPORT_FORECAST_MARGIN, PRE_NEG_PV_EXPORT_MIN_NEEDED_WH, PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH, PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH, SOLVER_TIME_LIMIT, TERMINAL_NEG_BUY_MAGNITUDE_FLOOR, TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK, TERMINAL_NEG_BUY_WEIGHT_CAP, TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS, _DAILY_FALLBACK_HORIZON_HOURS, _PRAGUE_TZ, ) def _solver_relax_chain( *, relaxed_expensive_import: bool = False, relaxed_neg_buy_charge: bool = False, relaxed_neg_prep_hold_only: bool = False, relaxed_neg_prep_window: bool = False, neg_sell_phases_fallback: bool = False, relaxed_pos_sell_ge_block: bool = False, relaxed_solver_masks: bool = False, ) -> list[str]: flags = { "relaxed_expensive_import": relaxed_expensive_import, "relaxed_neg_buy_charge": relaxed_neg_buy_charge, "relaxed_neg_prep_hold_only": relaxed_neg_prep_hold_only, "relaxed_neg_prep_window": relaxed_neg_prep_window, "neg_sell_phases_fallback": neg_sell_phases_fallback, "relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block, "relaxed_solver_masks": relaxed_solver_masks, } chain = [SOLVER_RELAX_STEPS[0]] for step in SOLVER_RELAX_STEPS[1:]: if flags.get(step, False): chain.append(step) return chain def _planner_engine_version(explicit: str | None = None) -> str: if explicit is not None and str(explicit).strip(): return str(explicit).strip().lower() return str(get_settings().planning_engine_version or "v1").strip().lower() def _planner_compare_enabled() -> bool: return bool(get_settings().planning_engine_compare_enabled) def _solve_dispatch_for_version( version: str, slots: list["PlanningSlot"], battery, heat_pump, grid, ev_sessions: list, vehicles: list, current_soc_wh: float, current_tuv_temp_c: float, *, tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None, operating_mode: str = "AUTO", charge_commitment_prev_w: Optional[list[Optional[float]]] = None, evening_push_ts_override: Optional[set[int]] = None, ) -> tuple[list["DispatchResult"], int, dict[str, Any]]: """ Router verzí plánovače: "v2" = čisté ekonomické jádro (services.planning.solver_v2, bez heuristických penalt; commitment/evening_push override nemá — koncepty v1), jinak v1 two-pass. Chybu v2 balí do PlannerSolverError kvůli failure pipeline. """ if str(version).strip().lower() == "v2": try: return solve_dispatch_v2( slots, battery, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, planner_version="v2", ) except PlannerSolverError: raise except RuntimeError as exc: raise PlannerSolverError(f"v2: {exc}", relax_chain=["v2"]) from exc return solve_dispatch_two_pass( slots, battery, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=version, evening_push_ts_override=evening_push_ts_override, ) def _planner_peer_version(version: str) -> str: v = str(version).strip().lower() if v == "v1": return "v2" if v == "v2": return "v1" return "v1" def _dispatch_result_summary(results: list["DispatchResult"], duration_ms: int, version: str) -> dict[str, Any]: charge_slots = [r.interval_start.isoformat() for r in results if r.battery_setpoint_w > 500] discharge_slots = [r.interval_start.isoformat() for r in results if r.battery_setpoint_w < -500] export_slots = [r.interval_start.isoformat() for r in results if r.grid_setpoint_w < 0] return { "planner_version": version, "solver_duration_ms": int(duration_ms), "total_expected_cost_czk": round(sum(float(r.expected_cost_czk) for r in results), 4), "charge_slots": len(charge_slots), "discharge_slots": len(discharge_slots), "export_slots": len(export_slots), "first_charge_slot": charge_slots[0] if charge_slots else None, "first_discharge_slot": discharge_slots[0] if discharge_slots else None, "first_export_slot": export_slots[0] if export_slots else None, } def _dispatch_result_comparison( active_results: list["DispatchResult"], active_ms: int, active_version: str, peer_results: list["DispatchResult"], peer_ms: int, peer_version: str, ) -> dict[str, Any]: active_summary = _dispatch_result_summary(active_results, active_ms, active_version) peer_summary = _dispatch_result_summary(peer_results, peer_ms, peer_version) slot_rows: list[dict[str, Any]] = [] for a, b in zip(active_results, peer_results): row = { "interval_start": a.interval_start.isoformat(), "active": { "battery_setpoint_w": a.battery_setpoint_w, "grid_setpoint_w": a.grid_setpoint_w, "export_mode": a.export_mode, "deye_physical_mode": a.deye_physical_mode, "deye_gen_cutoff_enabled": a.deye_gen_cutoff_enabled, "pv_a_curtailed_w": a.pv_a_curtailed_w, "battery_soc_target": a.battery_soc_target, "expected_cost_czk": a.expected_cost_czk, }, "peer": { "battery_setpoint_w": b.battery_setpoint_w, "grid_setpoint_w": b.grid_setpoint_w, "export_mode": b.export_mode, "deye_physical_mode": b.deye_physical_mode, "deye_gen_cutoff_enabled": b.deye_gen_cutoff_enabled, "pv_a_curtailed_w": b.pv_a_curtailed_w, "battery_soc_target": b.battery_soc_target, "expected_cost_czk": b.expected_cost_czk, }, } if row["active"] != row["peer"]: slot_rows.append(row) total_cost_diff = round( float(active_summary["total_expected_cost_czk"]) - float(peer_summary["total_expected_cost_czk"]), 4, ) return { "compare_enabled": True, "active": active_summary, "peer": peer_summary, "diff": { "total_expected_cost_czk": total_cost_diff, "absolute_total_expected_cost_czk": round(abs(total_cost_diff), 4), "changed_slots": len(slot_rows), }, "slot_diffs": slot_rows, } def _maybe_add_planner_comparison( *, slots: list["PlanningSlot"], battery, heat_pump, grid, ev_sessions: list, vehicles: list, current_soc_wh: float, current_tuv_temp_c: float, operating_mode: str, tuv_delta_stats: Optional[dict[tuple[int, int], float]], active_version: str, charge_commitment_prev_w: Optional[list[Optional[float]]] = None, ) -> dict[str, Any] | None: if not _planner_compare_enabled(): return None peer_version = _planner_peer_version(active_version) if peer_version == active_version: return None try: peer_results, peer_ms, peer_snapshot = _solve_dispatch_for_version( peer_version, slots, battery, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, evening_push_ts_override=None, ) except RuntimeError as exc: logger.warning( "Planner comparison peer (%s) failed, skipping compare run: %s", peer_version, exc, ) return None # active_results / active_ms jsou doplněny později v calleru return { "peer_version": peer_version, "peer_results": peer_results, "peer_ms": peer_ms, "peer_snapshot": peer_snapshot, } async def _planning_horizon_end(site_id: int, horizon_from: datetime, db) -> Optional[datetime]: """Konec horizontu z DB (`fn_planning_horizon_end`); NULL = rolling skip / daily fallback.""" raw = await db.fetchval( "select ems.fn_planning_horizon_end($1::int, $2::timestamptz)", site_id, horizon_from, ) return _timestamptz_from_db(raw) # ============================================================ # Datové třídy (lze nahradit pydantic modely) # ============================================================ # Lookahead pro relax spodní meze SoC: až 36 h od indexu slotu (pevné OTE ceny v horizontu). # ============================================================ # Korekce forecastu na základě skutečné výroby # ============================================================ # ============================================================ # LP Solver # ============================================================ def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]: """Pass2 two-pass: přenést nouzové relax flagy z pass1, ať pass2 nezačne od strict.""" inp = snap.get("inputs") if not isinstance(inp, dict): return {} out: dict[str, Any] = {} for key in ( "relaxed_expensive_import", "relaxed_neg_buy_charge", "relaxed_neg_prep_hold_only", "relaxed_neg_prep_window", "neg_sell_phases_fallback", "relaxed_pos_sell_ge_block", "relaxed_solver_masks", ): if inp.get(key): out[key] = True return out def solve_dispatch_two_pass( slots: list[PlanningSlot], battery, heat_pump, grid, ev_sessions: list, vehicles: list, current_soc_wh: float, current_tuv_temp_c: float, *, tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None, operating_mode: str = "AUTO", charge_commitment_prev_w: Optional[list[Optional[float]]] = None, planner_version: str | None = None, evening_push_ts_override: Optional[set[int]] = None, ) -> tuple[list["DispatchResult"], int, dict[str, Any]]: """ Dva průchody solve_dispatch: pass2 používá acquisition z váženého buy nabíjení v pass1. """ results1, ms1, snap1 = solve_dispatch( slots, battery, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, evening_push_ts_override=evening_push_ts_override, ) acq1 = float( snap1.get("inputs", {}).get("charge_acquisition_buy_czk_kwh") or getattr(slots[0], "charge_acquisition_buy_czk_kwh", None) or min(float(s.buy_price) for s in slots) ) acq2 = _recompute_charge_acquisition_from_results(slots, results1, battery) converged = abs(acq2 - acq1) < ACQUISITION_TWO_PASS_EPS_KWH if converged: if isinstance(snap1.get("inputs"), dict): snap1["inputs"]["acquisition_pass1_czk_kwh"] = round(acq1, 6) snap1["inputs"]["acquisition_pass2_czk_kwh"] = round(acq2, 6) snap1["inputs"]["two_pass_enabled"] = True snap1["inputs"]["two_pass_converged"] = True snap1["inputs"]["two_pass_skipped"] = False return results1, ms1, snap1 slots2 = _slots_with_charge_acquisition(slots, acq2) relax_carry = _solve_dispatch_relax_carryover(snap1) try: results2, ms2, snap2 = solve_dispatch( slots2, battery, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, evening_push_ts_override=None, **relax_carry, ) except (RuntimeError, PlannerSolverError) as exc: infeasible = isinstance(exc, PlannerSolverError) or "Infeasible" in str(exc) if infeasible: logger.warning( "two_pass pass2 Infeasible (%s), using pass1 solution", exc, ) if isinstance(snap1.get("inputs"), dict): snap1["inputs"]["two_pass_pass2_infeasible_used_pass1"] = True return results1, ms1, snap1 raise if isinstance(snap2.get("inputs"), dict): snap2["inputs"]["acquisition_pass1_czk_kwh"] = round(acq1, 6) snap2["inputs"]["acquisition_pass2_czk_kwh"] = round(acq2, 6) snap2["inputs"]["two_pass_enabled"] = True snap2["inputs"]["two_pass_converged"] = False snap2["inputs"]["two_pass_skipped"] = False snap2["inputs"]["solver_duration_ms_pass1"] = ms1 return results2, ms1 + ms2, snap2 def solve_dispatch( slots: list[PlanningSlot], battery, heat_pump, grid, ev_sessions: list, # aktivní EV sessions [ev1_session, ev2_session] vehicles: list, # [vehicle1, vehicle2] current_soc_wh: float, current_tuv_temp_c: float, *, tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None, operating_mode: str = "AUTO", charge_commitment_prev_w: Optional[list[Optional[float]]] = None, planner_version: str | None = None, relaxed_expensive_import: bool = False, relaxed_neg_buy_charge: bool = False, relaxed_neg_prep_hold_only: bool = False, relaxed_neg_prep_window: bool = False, neg_sell_phases_fallback: bool = False, relaxed_pos_sell_ge_block: bool = False, relaxed_solver_masks: bool = False, evening_push_ts_override: Optional[set[int]] = None, ) -> tuple[list[DispatchResult], int, dict[str, Any]]: """ LP solver pro dispatch optimalizaci. Vrátí (výsledky, solver_duration_ms, solver_debug_snapshot). relaxed_expensive_import: nouzový režim po Infeasible — síť smí krmit baseload v drahých slotech. relaxed_neg_buy_charge: druhý nouzový retry bez neg_buy charge shortfall. relaxed_neg_prep_hold_only: třetí retry — bez prep_soc_shortfall a prep hold binárek (evening push zůstává). relaxed_neg_prep_window: čtvrtý retry — vypne strict pre-neg bundle; future_neg_buy večerní export zůstává. relaxed_pos_sell_ge_block: retry — neaplikovat ge=0 v pos_sell před buy<0. relaxed_solver_masks: poslední retry — permissivní SQL masky + vypnutí tvrdých neg-večer větví. """ T = len(slots) if T < 1: raise RuntimeError("solve_dispatch requires at least one slot") if relaxed_solver_masks or relaxed_pos_sell_ge_block: slots = _relax_solver_slot_masks(slots) any_relaxed = ( relaxed_expensive_import or relaxed_neg_buy_charge or relaxed_neg_prep_window or neg_sell_phases_fallback or relaxed_pos_sell_ge_block or relaxed_solver_masks ) prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window late_replan_strict_active = False strict_late_replan_evening_ts: set[int] = set() strict_late_replan_night_ts: set[int] = set() EV = len(vehicles) # počet EV (typicky 2) planner_version_resolved = _planner_engine_version(planner_version) planner_v2 = planner_version_resolved == "v2" EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency) cycle_penalty_mult = _pv_scarcity_penalty_multiplier(slots, battery) degradation_cost_effective = battery.degradation_cost_czk_kwh * cycle_penalty_mult soc_buffer_target_wh, soc_deficit_penalty_czk_kwh = _soc_security_profile(slots, battery) prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize) # Penalizace překročení breakeru (Kč/kWh importu nad max_import_power_w). # Záměr: breaker je fyzický strop, ale kvůli chybám forecastu a krátkým „extrémním“ oknům # (např. záporná nákupní cena) umožníme solveru nominálně jít nad breaker, ovšem pouze za cenu. IMPORT_OVER_BREAKER_PENALTY_CZK_KWH = 10.0 min_soc_wh = float(getattr(battery, "min_soc_wh", battery.reserve_soc_wh)) buy_extreme_thr = float(getattr(battery, "planner_extreme_buy_threshold_czk_kwh", -5.0)) floor_pct_raw = getattr(battery, "planner_discharge_floor_percent", None) floor_pct = float(floor_pct_raw) if floor_pct_raw is not None else None prewin = max( 0, int( getattr( battery, "planner_discharge_relax_prewindow_slots", DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS, ) ), ) # Planner floor v Wh (nezávisle na lookahead extrémním buy) – použije se pro kotvu před sell<0. abs_min_wh = max(float(battery.usable_capacity_wh) * 0.05, 1.0) planner_floor_wh = ( min_soc_wh if floor_pct is None else max(abs_min_wh, float(floor_pct) / 100.0 * float(battery.usable_capacity_wh)) ) planner_floor_effective_wh = min(min_soc_wh, float(planner_floor_wh)) soc_min_series = _soc_min_wh_series( slots, float(battery.usable_capacity_wh), min_soc_wh, buy_extreme_thr, floor_pct, ) # Pokud se blíží první sell<0, dovol hluboký planner floor i bez extrémního buy. # Záměr: „dovylít“ baterii před záporným prodejem a pak už baterii v sell<0 okně nevybíjet. if floor_pct is not None: dist_to_neg_sell = _slots_until_sell_lt(slots, 0.0) soc_min_series = [ min(float(sm), float(planner_floor_effective_wh)) if dist_to_neg_sell[i] <= prewin else float(sm) for i, sm in enumerate(soc_min_series) ] observed_soc_wh = max( float(battery.min_soc_wh), min(float(current_soc_wh), float(battery.soc_max_wh)), ) soc_headroom_applied_wh: float | None = None current_soc_wh, soc_headroom_applied_wh = _planner_soc_for_solver( current_soc_wh, battery ) current_soc_wh = max(soc_min_series[0], min(current_soc_wh, float(battery.soc_max_wh))) arb_base_wh = max( float(getattr(battery, "arb_floor_wh", battery.reserve_soc_wh)), min_soc_wh, ) if getattr(battery, "disable_dynamic_arb_floor", False): arb_floor_series = [arb_base_wh] * T else: arb_floor_series = _dynamic_arb_floor_wh_series( slots, min_soc_wh, arb_base_wh, float(battery.usable_capacity_wh) ) deferral_slots = _prewindow_deferral_slots(slots, buy_extreme_thr) soc_panel_min = _soc_panel_min_wh_series( soc_min_series, deferral_slots, min_soc_wh, arb_base_wh, prewin, ) # --- Proměnné --- # Import ze sítě: tvrdý strop = site breaker (max_import_power_w). gi_upper = float(grid.max_import_power_w) gi = [pulp.LpVariable(f"gi_{t}", 0, gi_upper) for t in range(T)] gi_over = [ pulp.LpVariable(f"gi_over_{t}", 0, max(0.0, gi_upper - float(grid.max_import_power_w))) for t in range(T) ] ge = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)] ge_pv = [pulp.LpVariable(f"ge_pv_{t}", 0, grid.max_export_power_w) for t in range(T)] ge_bat = [pulp.LpVariable(f"ge_bat_{t}", 0, grid.max_export_power_w) for t in range(T)] bc_pv = [pulp.LpVariable(f"bc_pv_{t}", 0, battery.max_charge_power_w) for t in range(T)] bc_gi = [pulp.LpVariable(f"bc_gi_{t}", 0, battery.max_charge_power_w) for t in range(T)] bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)] pv_ld = [pulp.LpVariable(f"pv_ld_{t}", 0) for t in range(T)] pv_sp = [pulp.LpVariable(f"pv_sp_{t}", 0) for t in range(T)] soc = [ pulp.LpVariable(f"soc_{t}", soc_panel_min[t], battery.soc_max_wh) for t in range(T) ] w_arb = [pulp.LpVariable(f"w_arb_{t}", cat=pulp.LpBinary) for t in range(T)] z_export = [pulp.LpVariable(f"z_export_{t}", cat=pulp.LpBinary) for t in range(T)] ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)] hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)] soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh) # GEN port cut-off (BA81): binární proměnná pouze pokud je feature povolená v konfiguraci site/invertoru. gen_cutoff_enabled = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False)) z_gen_cutoff = ( [pulp.LpVariable(f"z_gen_cutoff_{t}", cat=pulp.LpBinary) for t in range(T)] if gen_cutoff_enabled else None ) om = (operating_mode or "AUTO").strip().upper() charge_slots: set[int] = set() discharge_export_slots: set[int] = set() if om == "AUTO": charge_slots = {t for t, s in enumerate(slots) if s.allow_charge} charge_slots |= { t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 } # Stejně jako R__063 (sell<0 + PV přebytek): shortfall/curtail penalizace i bez block_export. charge_slots |= { t for t, s in enumerate(slots) if float(s.sell_price) < 0.0 and max( 0, int(s.pv_a_forecast_w) + int(s.pv_b_forecast_w) - int(s.load_baseline_w), ) > 500 } discharge_export_slots = { t for t, s in enumerate(slots) if s.allow_discharge_export } # SELF_SUSTAIN dřív vynucoval ge[t] == 0, což umí udělat MILP infeasible v okamžiku, kdy: # - baterie je na max SoC (nelze nabíjet), # - PV pole B není curtailable, # - a pv_b_forecast_w > load_baseline_w (typicky po ručním snížení baseline). # Export v SELF_SUSTAIN proto povolíme jako nouzový ventil, ale silně penalizujeme, # aby k němu docházelo jen když už neexistuje jiné fyzikálně možné řešení. SELF_SUSTAIN_EXPORT_PENALTY_CZK_KWH = 100.0 # Penalizace vypnutí GEN portu (mikroinvertory): preferujeme nechat zapnuto a vypnout jen když # by to jinak vedlo k nežádoucímu exportu / infeasible řešení. GEN_CUTOFF_PENALTY_CZK_KWH = 2.0 if planner_v2 else 5.0 # Heuristika: pokud existuje necurtailable PV B a v budoucnu v horizontu nastane buy < 0, # chceme mít motivaci držet baterii „prázdnější“ pro pozdější výhodný import / bonusové PV B okno. # V okně sell < 0 pak preferujeme curtail PV A (místo placeného exportu), a to tak, # že dočasně snížíme penalizaci ca[t] (curtailment) na 0. has_pv_b = any(float(s.pv_b_forecast_w) > 0.0 for s in slots) future_neg_buy_from: list[bool] = [False] * T seen_neg_buy = False for i in range(T - 1, -1, -1): if float(slots[i].buy_price) < 0.0: seen_neg_buy = True future_neg_buy_from[i] = seen_neg_buy future_extreme_buy_from = _future_extreme_buy_from(slots, buy_extreme_thr) dist_to_extreme_buy = _slots_until_buy_le(slots, buy_extreme_thr) # EV proměnné per vozidlo ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0, min(vehicles[e].max_charge_power_w, grid.max_import_power_w)) for t in range(T)] for e in range(EV)] ev_via_bat = [[pulp.LpVariable(f"evb_{e}_{t}", 0, vehicles[e].max_charge_power_w) for t in range(T)] for e in range(EV)] horizon_slots_h24 = min(T, int(24 / INTERVAL_H)) avg_buy_terminal = ( sum(float(slots[t].buy_price) for t in range(horizon_slots_h24)) / horizon_slots_h24 if horizon_slots_h24 > 0 else 4.0 ) terminal_factor_base = float(battery.planner_terminal_soc_value_factor) charge_acq_raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None) charge_acquisition_czk_kwh = ( float(charge_acq_raw) if charge_acq_raw is not None else min(float(s.buy_price) for s in slots) ) soc_headroom_wh = max(2000.0, 0.05 * float(battery.soc_max_wh)) # Kotva: poslední slot před prvním sell<0 by měl končit u planner floor (pokud relaxace existuje). # 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) first_neg_buy_idx = next( (t for t, s in enumerate(slots) if float(s.buy_price) < 0.0), None, ) neg_buy_slot_indices_pre = [ t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 ] last_neg_sell_by_prague_date: dict[object, int] = {} for t_ln, st_ln in enumerate(slots): if float(st_ln.sell_price) < 0: last_neg_sell_by_prague_date[_prague_calendar_date(st_ln)] = t_ln 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), ) evening_peak_export_ts = _evening_peak_export_indices( slots, degrad_czk_kwh=float(degradation_cost_effective), ) purchase_fixed_pre = _purchase_pricing_fixed(grid) fixed_horizon_min_sell_pre: float | None = None if purchase_fixed_pre: _pos_sell_prices = [ float(s.sell_price) for s in slots if float(s.sell_price) >= 0.0 ] if _pos_sell_prices: fixed_horizon_min_sell_pre = min(_pos_sell_prices) block_export_neg_sell_pre = bool( getattr(grid, "block_export_on_negative_sell", False) ) if purchase_fixed_pre and block_export_neg_sell_pre: evening_peak_export_ts = sorted( set(evening_peak_export_ts) | { t for t, st in enumerate(slots) if _in_night_battery_export_window(st) and float(st.sell_price) > 0.0 } ) non_negative_buys_pre = [ float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0 ] ref_buy_horizon_pre = ( min(non_negative_buys_pre) if non_negative_buys_pre else min(float(s.buy_price) for s in slots) ) min_spread_pre = float(degradation_cost_effective) fixed_tariff_like_pre = purchase_fixed_pre or _horizon_fixed_tariff_like(slots) reserve_soc_wh_solver = float( getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) ) if om == "AUTO" and not purchase_fixed_pre and not relaxed_solver_masks: strict_late_replan_evening_ts = _strict_late_replan_evening_slot_indices( slots, first_neg_buy_idx=first_neg_buy_idx, observed_soc_wh=observed_soc_wh, reserve_soc_wh=reserve_soc_wh_solver, ) late_replan_strict_active = bool(strict_late_replan_evening_ts) if late_replan_strict_active: slots = [ replace( s, allow_charge=True, allow_discharge_export=True, ) if i in strict_late_replan_evening_ts else s for i, s in enumerate(slots) ] charge_slots |= strict_late_replan_evening_ts discharge_export_slots |= strict_late_replan_evening_ts if not relaxed_pos_sell_ge_block and not relaxed_solver_masks: slots = _relax_solver_slot_masks(slots) charge_slots = {t for t, s in enumerate(slots) if s.allow_charge} charge_slots |= { t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 } charge_slots |= { t for t, s in enumerate(slots) if float(s.sell_price) < 0.0 and max( 0, int(s.pv_a_forecast_w) + int(s.pv_b_forecast_w) - int(s.load_baseline_w), ) > 500 } discharge_export_slots = { t for t, s in enumerate(slots) if s.allow_discharge_export } late_replan_solver_relax = ( late_replan_strict_active and not relaxed_solver_masks ) neg_sell_phases_en = ( om == "AUTO" and not purchase_fixed_pre and _neg_sell_phases_enabled(battery) and not late_replan_strict_active ) neg_sell_phase_by_t: list[str] = ["none"] * T neg_sell_soc_target_by_t: list[Optional[float]] = [None] * T neg_sell_shortfall_weight_by_t: list[float] = [0.0] * T neg_sell_day_meta: dict[str, Any] = {} neg_sell_post_detach_prep_ts: set[int] = set() if neg_sell_phases_en: ( neg_sell_phase_by_t, neg_sell_soc_target_by_t, neg_sell_shortfall_weight_by_t, neg_sell_day_meta, ) = _neg_sell_day_phases(slots, battery) neg_sell_post_detach_prep_ts = set( neg_sell_day_meta.get("post_detach_prep_ts") or [] ) prep_soc_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] prep_hold_bcpv_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] prep_hold_curtail_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] prep_hold_met_binary: dict[int, pulp.LpVariable] = {} pre_neg_cushion_by_day: dict[str, bool] = {} pre_neg_pv_export_ts: set[int] = set() neg_evening_before_neg_ts: set[int] = set() neg_evening_push_ts: set[int] = set() neg_evening_export_budget_wh: float | None = None neg_evening_reserve_anchors: list[tuple[int, float]] = [] future_neg_buy_discharge_en = False if ( om == "AUTO" and not purchase_fixed_pre and first_neg_buy_idx is not None and first_neg_buy_idx > 0 ): future_neg_buy_discharge_en = _future_neg_buy_discharge_enabled( slots, battery, first_neg_buy_idx=first_neg_buy_idx, first_neg_sell_idx=first_neg_sell_idx, observed_soc_wh=observed_soc_wh, neg_sell_phases_en=neg_sell_phases_en, neg_sell_soc_target_by_t=( neg_sell_soc_target_by_t if neg_sell_phases_en else None ), ) terminal_neg_buy_weight = _terminal_neg_buy_weight( slots, first_neg_buy_idx=first_neg_buy_idx, ) terminal_factor = terminal_factor_base * (1.0 - terminal_neg_buy_weight) # Kč/Wh: ocenění energie ponechané v baterii na konci horizontu (receding horizon kotva). terminal_soc_kcz_per_wh = avg_buy_terminal * terminal_factor / 1000.0 neg_evening_bundle_strict = ( om == "AUTO" and not purchase_fixed_pre and neg_sell_phases_en and not relaxed_neg_prep_window and not late_replan_strict_active ) neg_evening_discharge_active = neg_evening_bundle_strict or future_neg_buy_discharge_en if neg_evening_bundle_strict: pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle( slots, battery, observed_soc_wh, first_neg_buy_idx, neg_sell_phases_en=True, soc_target_by_t=neg_sell_soc_target_by_t, ) if neg_evening_discharge_active: meta_for_evening = neg_sell_day_meta if not (meta_for_evening.get("days")) and first_neg_sell_idx is not None: meta_for_evening = {"days": [{"first_neg_idx": first_neg_sell_idx}]} neg_evening_before_neg_ts = _evening_discharge_before_neg_day_ts( slots, meta_for_evening, ) neg_evening_before_neg_ts |= _discharge_before_first_neg_sell_ts( slots, first_neg_sell_idx, ) neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors( slots, meta_for_evening, battery, ) reserve_wh = float( getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) ) night_buf_wh = _night_baseload_buffer_wh_from_slots(slots, battery) neg_evening_export_budget_wh = _neg_evening_discharge_budget_wh( observed_soc_wh=observed_soc_wh, reserve_soc_wh=reserve_wh, night_baseload_buffer_wh=night_buf_wh, ) per_slot_neg_eve_wh = max( float(battery.max_discharge_power_w) * float(battery.discharge_efficiency) * INTERVAL_H, 0.0, ) neg_evening_push_ts = _neg_evening_before_neg_push_indices( slots, neg_evening_before_neg_ts, export_budget_wh=float(neg_evening_export_budget_wh), per_slot_discharge_wh=per_slot_neg_eve_wh, discharge_export_ok=discharge_export_slots, ) elif om == "AUTO" and not purchase_fixed_pre: legacy_ok = bool( first_neg_sell_idx is not None and pre_neg_export_last_t is not None and _pre_neg_pv_export_forecast_cushion_ok( slots, battery, observed_soc_wh, first_neg_sell_idx, neg_sell_phases_en=False, ) ) if legacy_ok: pre_neg_pv_export_ts = _pre_neg_pv_export_slot_indices( slots, first_neg_sell_idx, pre_neg_export_last_t, first_neg_buy_idx, ) pre_neg_pv_export_forecast_ok = bool(pre_neg_pv_export_ts) pre_neg_buy_discharge_ts: set[int] = set() if om == "AUTO" and first_neg_buy_idx is not None and first_neg_buy_idx > 0: pre_neg_buy_discharge_ts = _pre_neg_buy_discharge_indices( slots, first_neg_buy_idx, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=min_spread_pre, fixed_tariff=fixed_tariff_like_pre, ) neg_sell_bat_dump_slots = _neg_sell_bat_dump_slots( slots, operating_mode=om, purchase_fixed=purchase_fixed_pre, grid=grid, buy_extreme_thr=buy_extreme_thr, degrad_czk_kwh=float(degradation_cost_effective), ) profitable_export_ts_pre: set[int] = set() if om == "AUTO": for _t in range(T): if _t not in discharge_export_slots: continue if _slot_profitable_battery_export( slots[_t], charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=min_spread_pre, fixed_tariff=fixed_tariff_like_pre, ): profitable_export_ts_pre.add(_t) elif ( purchase_fixed_pre and block_export_neg_sell_pre and _t in evening_peak_export_ts and float(slots[_t].sell_price) > 0.0 ): # KV1: večerní sell může být < fixní buy; peak sloty stejně vývoz bat. profitable_export_ts_pre.add(_t) evening_push_ts: set[int] = set() evening_early_export_penalty_ts: set[int] = set() night_self_consume_discourage_ts: set[int] = set() post_evening_push_night_ts: set[int] = set() degraded_relaxed_night_ts: set[int] = set() degraded_evening_export_ts: set[int] = set() evening_push_hysteresis_retained = False push_override_raw: Optional[set[int]] = None push_override_eff: Optional[set[int]] = None computed_evening_push_ts: set[int] = set() evening_push_hard_suppressed = False if om == "AUTO": per_slot_discharge_wh_pre = max( float(battery.max_discharge_power_w) * float(battery.discharge_efficiency) * INTERVAL_H, 0.0, ) export_cap_push_w = _battery_export_cap_w(battery, grid) per_slot_push_wh_pre = min( per_slot_discharge_wh_pre, export_cap_push_w * float(battery.discharge_efficiency) * INTERVAL_H, ) discharge_buf_pre = float(getattr(battery, "discharge_slot_buffer", 0) or 0) discharge_floor_wh = _planner_discharge_floor_wh(battery) kv1_evening_push_pre = _kv1_block_export_fixed_evening_push( grid, purchase_fixed=purchase_fixed_pre, ) computed_evening_push_ts = set( _evening_battery_export_push_indices( slots, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, degrad_czk_kwh=float(degradation_cost_effective), current_soc_wh=float(current_soc_wh), min_soc_wh=float(discharge_floor_wh), soc_max_wh=float(battery.soc_max_wh), per_slot_discharge_wh=per_slot_push_wh_pre, discharge_slot_buffer=discharge_buf_pre, 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( evening_push_ts_override, relaxed_expensive_import=relaxed_expensive_import, relaxed_neg_buy_charge=relaxed_neg_buy_charge, relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only, relaxed_neg_prep_window=relaxed_neg_prep_window, neg_sell_phases_fallback=neg_sell_phases_fallback, relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block, relaxed_solver_masks=relaxed_solver_masks, ) push_override_eff = None if push_override_raw: push_override_eff = _filter_evening_push_override_indices( slots, push_override_raw, battery=battery, grid=grid, discharge_export_ok=discharge_export_slots, ) evening_push_hysteresis_retained = False if push_override_eff: evening_push_ts = push_override_eff evening_push_hysteresis_retained = True else: evening_push_ts = computed_evening_push_ts if not evening_push_ts: evening_push_ts = _evening_push_peak_fallback_indices( slots, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=float(degradation_cost_effective), 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 or late_replan_strict_active ) else: evening_push_hard_suppressed = False last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy( slots, first_neg_buy_idx ) pos_sell_pre_neg_buy_ts = _positive_sell_pre_neg_buy_indices( slots, first_neg_buy_idx ) pos_sell_pre_neg_buy_ge_exempt_ts = _pos_sell_pre_neg_buy_evening_export_exempt_ts( slots, pos_sell_pre_neg_buy_ts, evening_peak_export_ts, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=float(degradation_cost_effective), fixed_tariff=fixed_tariff_like_pre, future_neg_buy_discharge_en=future_neg_buy_discharge_en, ) if strict_late_replan_evening_ts: pos_sell_pre_neg_buy_ge_exempt_ts |= strict_late_replan_evening_ts pre_neg_buy_empty_ts = _pre_neg_buy_empty_discharge_indices( slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy ) if om == "AUTO": evening_export_exempt_ts = ( set(morning_pre_neg_export_ts) | set(pre_neg_buy_discharge_ts) | set(pre_neg_buy_empty_ts) | set(neg_evening_push_ts) ) if purchase_fixed_pre: # Fixní tarif: sell>buy v noci nesmí ge_bat=0 přes evening_early (BA81 úsvit). evening_export_exempt_ts |= profitable_export_ts_pre evening_early_export_penalty_ts = _evening_early_export_penalty_indices( slots, discharge_export_slots=discharge_export_slots, evening_push_ts=evening_push_ts, exempt_ts=evening_export_exempt_ts, ) night_self_consume_discourage_ts = _night_self_consume_discourage_import_indices( slots, evening_push_ts=evening_push_ts, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=float(degradation_cost_effective), purchase_fixed=purchase_fixed_pre, ) post_evening_push_night_ts = _post_evening_push_night_self_consume_indices( slots, evening_push_ts ) night_self_consume_discourage_ts |= post_evening_push_night_ts if not relaxed_solver_masks and strict_late_replan_evening_ts: strict_late_replan_night_ts = _strict_late_replan_night_self_consume_indices( slots, evening_export_ts=strict_late_replan_evening_ts, ) night_self_consume_discourage_ts |= strict_late_replan_night_ts post_evening_push_night_ts |= strict_late_replan_night_ts battery_export_defer_pv_ts = { t for t in range(T) if _battery_export_push_defer_to_pv(slots[t]) } # Pozdní replan večer: SQL allow_charge může být false (drahý buy), ale večerní vývoz # k reserve před neg dnem vyžaduje souběžně grid import pro load (ne jen bd). if neg_evening_discharge_active or evening_push_ts: replan_day = _prague_calendar_date(slots[0]) for t in range(T): if _prague_calendar_date(slots[t]) != replan_day: continue if float(slots[t].sell_price) < 0.0: continue if ( t in evening_push_ts or t in neg_evening_push_ts or ( _in_evening_push_hour_window(slots[t]) and t in discharge_export_slots ) ): charge_slots.add(t) if neg_evening_discharge_active: for t in discharge_export_slots: if _prague_calendar_date(slots[t]) == replan_day: charge_slots.add(t) if relaxed_pos_sell_ge_block or relaxed_solver_masks: # Poslední retry: SQL allow_charge / drahý import nesmí zablokovat fyzicky dosažitelný plán. charge_slots = set(range(T)) discharge_export_slots = { t for t, s in enumerate(slots) if s.allow_discharge_export or float(s.sell_price) >= 0.0 } else: battery_export_defer_pv_ts = set() if relaxed_solver_masks and om == "AUTO": future_neg_buy_discharge_en = False neg_evening_discharge_active = False neg_evening_push_ts = set() neg_evening_before_neg_ts = set() neg_evening_reserve_anchors = [] evening_push_ts = set() evening_early_export_penalty_ts = set() battery_export_defer_pv_ts = set() evening_push_hard_suppressed = True degraded_relaxed_night_ts = _degraded_relaxed_night_self_consume_indices(slots) reserve_wh_degraded = float( getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) ) degraded_evening_export_ts = _degraded_relaxed_evening_export_to_reserve_indices( slots, observed_soc_wh=observed_soc_wh, reserve_soc_wh=reserve_wh_degraded, first_neg_buy_idx=first_neg_buy_idx, ) night_self_consume_discourage_ts |= degraded_relaxed_night_ts post_evening_push_night_ts |= degraded_relaxed_night_ts pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh( slots, first_neg_buy_idx=first_neg_buy_idx, min_soc_wh=float(min_soc_wh), soc_max_wh=float(battery.soc_max_wh), max_charge_w=float(battery.max_charge_power_w), charge_eff=float(battery.charge_efficiency), ) t_pre_neg_buy_anchor: int | None = ( first_neg_buy_idx - 1 if first_neg_buy_idx is not None and first_neg_buy_idx > 0 else None ) soc_pre_neg_buy_ceiling_slack: pulp.LpVariable | None = None if ( t_pre_neg_buy_anchor is not None and pre_neg_buy_soc_ceiling_wh is not None ): soc_pre_neg_buy_ceiling_slack = pulp.LpVariable( "soc_pre_neg_buy_ceiling_slack_wh", 0, float(battery.usable_capacity_wh), ) pos_sell_soc_shortfall: pulp.LpVariable | None = None if last_pos_sell_pre_neg_buy is not None: pos_sell_soc_shortfall = pulp.LpVariable( "pos_sell_pre_neg_soc_shortfall_wh", 0, float(battery.usable_capacity_wh), ) daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True)) safety_pen_czk_per_wh: list[float] = [] safety_vars: list[Optional[pulp.LpVariable]] = [] safety_active: list[bool] = [] post_neg_pv_topup: list[bool] = [] high_sell_slot: list[bool] = [] for t in range(T): sft = slots[t].safety_soc_target_wh if daytime_en else None # High-sell slot: typicky lokální maximum v SQL lookaheadu (future_sell_opportunity_czk_kwh). # V těchto slotech safety floor nepoužijeme, aby se zachovala arbitráž na špičkách. fso = slots[t].future_sell_opportunity_czk_kwh hs = bool(fso is not None and float(slots[t].sell_price) >= float(fso) - 1e-6) high_sell_slot.append(hs) fb = float(slots[t].future_avoided_buy_czk_kwh or slots[t].buy_price) fs = float(slots[t].future_sell_opportunity_czk_kwh or slots[t].sell_price) bv = max(fb, fs) - float(degradation_cost_effective) bv = max(0.0, min(5.0, bv)) st_d = _prague_calendar_date(slots[t]) ln_neg = last_neg_sell_by_prague_date.get(st_d) pv_topup_after_neg = bool( om == "AUTO" and ln_neg is not None and t > ln_neg and float(slots[t].sell_price) >= 0.0 and bool(slots[t].is_daytime_pv_surplus_slot) and not hs ) post_neg_pv_topup.append(pv_topup_after_neg) # Safety deficit penalizujeme jen v PV surplus slotech, a ne ve high-sell špičce. # Záměr: safety není obecná „nabij co nejdřív“ motivace; je to preference využít přebytek PV. active = bool( ( sft is not None and ( bool(slots[t].is_daytime_pv_surplus_slot) or (planner_v2 and float(slots[t].buy_price) < 0.0) ) and not hs ) or pv_topup_after_neg ) safety_active.append(active) safety_pen_czk_per_wh.append(bv / 1000.0 if active else 0.0) if active: safety_vars.append( pulp.LpVariable(f"safety_def_{t}", 0, float(battery.usable_capacity_wh)) ) else: safety_vars.append(None) commit_pen = float(getattr(battery, "planner_charge_commitment_penalty_czk_kwh", 0.2)) commit_lp: list[tuple[int, pulp.LpVariable, float]] = [] commitment_for_solve = charge_commitment_prev_w if ( relaxed_neg_buy_charge or relaxed_neg_prep_window or neg_sell_phases_fallback or late_replan_solver_relax ): commitment_for_solve = None if commitment_for_solve is not None and len(commitment_for_solve) == T: for t in range(T): prev = commitment_for_solve[t] if prev is not None and prev > 500: cap_prev = float(prev) cv = pulp.LpVariable(f"ccommit_{t}", 0, cap_prev) commit_lp.append((t, cv, cap_prev)) peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pre_neg_pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pre_neg_pv_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] neg_evening_before_neg_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] neg_evening_reserve_soc_slack: list[tuple[int, pulp.LpVariable, float]] = [] neg_sell_bat_dump_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] neg_sell_soc_underfill: list[tuple[int, pulp.LpVariable, float]] = [] neg_buy_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pre_neg_batt_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pre_neg_buy_empty_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] degraded_evening_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] fixed_tariff_like = fixed_tariff_like_pre block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False)) if om == "AUTO": for t in range(T): if relaxed_solver_masks and not purchase_fixed_pre: continue if t not in discharge_export_slots: continue if t in evening_push_ts: continue if _in_night_battery_export_window(slots[t]): # Spot: večerní export jen v tvrdém push. Fixní: i profitable sell>buy v noci. if not ( purchase_fixed_pre and _slot_profitable_battery_export( slots[t], charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=float(degradation_cost_effective), fixed_tariff=True, ) ): continue if _battery_export_push_defer_to_pv(slots[t]): continue if not _slot_profitable_battery_export( slots[t], charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=float(degradation_cost_effective), fixed_tariff=fixed_tariff_like, ): continue cap_w = float(min( grid.max_export_power_w, battery.max_discharge_power_w, )) sf = pulp.LpVariable(f"export_shortfall_{t}", 0, cap_w) peak_export_shortfall.append((t, sf, cap_w)) export_cap_w = _battery_export_cap_w(battery, grid) for t_pnd in sorted(pre_neg_buy_discharge_ts): if _battery_export_push_defer_to_pv(slots[t_pnd]): continue sf_pnd = pulp.LpVariable(f"pre_neg_bat_export_sf_{t_pnd}", 0, export_cap_w) pre_neg_batt_export_shortfall.append((t_pnd, sf_pnd, export_cap_w)) for t_empty in pre_neg_buy_empty_ts: if _battery_export_push_defer_to_pv(slots[t_empty]): continue sf_e = pulp.LpVariable(f"pre_neg_buy_empty_sf_{t_empty}", 0, export_cap_w) pre_neg_buy_empty_shortfall.append((t_empty, sf_e, export_cap_w)) if relaxed_solver_masks and degraded_evening_export_ts: deg_cap = _battery_export_cap_w(battery, grid) for t_deg in sorted(degraded_evening_export_ts): sf_deg = pulp.LpVariable( f"deg_eve_reserve_export_{t_deg}", 0, deg_cap, ) degraded_evening_export_shortfall.append((t_deg, sf_deg, deg_cap)) if not (relaxed_neg_buy_charge or late_replan_solver_relax): neg_buy_slot_indices = [ t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 ] if neg_buy_slot_indices: t_nb_last = max(neg_buy_slot_indices_pre) cap_w = float(battery.max_charge_power_w) sf_nb = pulp.LpVariable(f"neg_buy_charge_sf_{t_nb_last}", 0, cap_w) neg_buy_charge_shortfall.append((t_nb_last, sf_nb, cap_w)) for t in range(T): if float(slots[t].sell_price) >= 0: continue if float(slots[t].buy_price) < 0.0: continue if t not in charge_slots: continue # Před buy<0: nepenalizovat / netlačit PV→bat (jinak 98 % v 09:15 a export v sell<0). if first_neg_buy_idx is not None and t < first_neg_buy_idx: continue pv_surplus_w = max( 0.0, float(slots[t].pv_a_forecast_w) + float(slots[t].pv_b_forecast_w) - float(slots[t].load_baseline_w), ) if pv_surplus_w <= 500: continue cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) sf_pv = pulp.LpVariable(f"pv_charge_shortfall_{t}", 0, cap_w) pv_charge_shortfall.append((t, sf_pv, cap_w)) if neg_sell_phases_en: pv_charge_taken = {t_sf for t_sf, _sf, _c in pv_charge_shortfall} for t_ns in range(T): if neg_sell_phase_by_t[t_ns] not in ("prep", "tail"): continue if t_ns in pv_charge_taken: continue if float(slots[t_ns].sell_price) >= 0.0: continue pv_surplus_ns = max( 0.0, float(slots[t_ns].pv_a_forecast_w) + float(slots[t_ns].pv_b_forecast_w) - float(slots[t_ns].load_baseline_w), ) if pv_surplus_ns <= 500: continue cap_ns = float(min(pv_surplus_ns, battery.max_charge_power_w)) sf_ns = pulp.LpVariable(f"neg_phase_pv_charge_{t_ns}", 0, cap_ns) pv_charge_shortfall.append((t_ns, sf_ns, cap_ns)) for t_pe in sorted(pre_neg_pv_export_ts): s_pe = slots[t_pe] pv_surplus_pe = max( 0.0, float(s_pe.pv_a_forecast_w) + float(s_pe.pv_b_forecast_w) - float(s_pe.load_baseline_w), ) cap_pe = float( min( pv_surplus_pe, float(grid.max_export_power_w), ) ) if cap_pe <= 500.0: continue sf_pe = pulp.LpVariable(f"pre_neg_pv_export_sf_{t_pe}", 0, cap_pe) pre_neg_pv_export_shortfall.append((t_pe, sf_pe, cap_pe)) export_cap_evening = _battery_export_cap_w(battery, grid) for t_ev in sorted(neg_evening_push_ts): if t_ev not in discharge_export_slots: continue sf_ev = pulp.LpVariable( f"neg_eve_prep_discharge_{t_ev}", 0, export_cap_evening, ) neg_evening_before_neg_shortfall.append((t_ev, sf_ev, export_cap_evening)) for t_anchor, reserve_tgt in neg_evening_reserve_anchors: sl = pulp.LpVariable( f"neg_eve_reserve_soc_slack_{t_anchor}", 0, float(NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH), ) neg_evening_reserve_soc_slack.append((t_anchor, sl, float(reserve_tgt))) if t_anchor in discharge_export_slots and t_anchor not in { t for t, _sf, _c in neg_evening_before_neg_shortfall }: cap_ev = _battery_export_cap_w(battery, grid) sf_ra = pulp.LpVariable( f"neg_eve_reserve_ge_{t_anchor}", 0, cap_ev, ) neg_evening_before_neg_shortfall.append((t_anchor, sf_ra, cap_ev)) for t in range(T): if not post_neg_pv_topup[t]: continue if float(slots[t].sell_price) < 0: continue pv_surplus_w = max( 0.0, float(slots[t].pv_a_forecast_w) + float(slots[t].pv_b_forecast_w) - float(slots[t].load_baseline_w), ) if pv_surplus_w <= 500: continue cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w) pv_charge_shortfall.append((t, sf_pv, cap_w)) if neg_sell_phases_en and not (prep_hold_relaxed or late_replan_strict_active): for t_ns in range(T): phase_ns = neg_sell_phase_by_t[t_ns] tgt_ns = neg_sell_soc_target_by_t[t_ns] if phase_ns == "none" or tgt_ns is None: continue us_prep = pulp.LpVariable( f"neg_sell_prep_soc_{t_ns}", 0, float(battery.usable_capacity_wh), ) w_sf = float(neg_sell_shortfall_weight_by_t[t_ns]) prep_soc_shortfall.append((t_ns, us_prep, w_sf)) tail_last_by_day: dict[object, int] = {} for t_ln, st_ln in enumerate(slots): if neg_sell_phase_by_t[t_ln] != "tail": continue tail_last_by_day[_prague_calendar_date(st_ln)] = t_ln for t_tail_last in tail_last_by_day.values(): if t_tail_last in charge_slots or relaxed_neg_buy_charge or late_replan_solver_relax: us_tail = pulp.LpVariable( f"neg_sell_tail_soc_{t_tail_last}", 0, float(battery.usable_capacity_wh), ) neg_sell_soc_underfill.append( (t_tail_last, us_tail, float(battery.soc_max_wh)) ) if not (prep_hold_relaxed or late_replan_strict_active): for t_ph in range(T): if neg_sell_phase_by_t[t_ph] != "prep": continue cap_bc = float(battery.max_charge_power_w) prep_hold_met_binary[t_ph] = pulp.LpVariable( f"prep_hold_met_{t_ph}", cat=pulp.LpBinary, ) sf_hold = pulp.LpVariable(f"prep_hold_bcpv_{t_ph}", 0, cap_bc) prep_hold_bcpv_shortfall.append((t_ph, sf_hold, cap_bc)) cap_ca = float(max(0, slots[t_ph].pv_a_forecast_w)) sf_ca = pulp.LpVariable(f"prep_hold_curtail_{t_ph}", 0, cap_ca) prep_hold_curtail_shortfall.append((t_ph, sf_ca, cap_ca)) elif len(neg_buy_slot_indices_pre) >= 2: t_nb_last = max(neg_buy_slot_indices_pre) if t_nb_last in charge_slots or relaxed_neg_buy_charge or late_replan_solver_relax: us = pulp.LpVariable( f"neg_buy_soc_under_{t_nb_last}", 0, float(battery.usable_capacity_wh), ) neg_sell_soc_underfill.append( (t_nb_last, us, float(battery.soc_max_wh)) ) for t in range(T): if first_neg_buy_idx is None or t >= first_neg_buy_idx: continue if float(slots[t].sell_price) >= 0.0: continue if float(slots[t].buy_price) < 0.0: continue pv_surplus_w = max( 0.0, float(slots[t].pv_a_forecast_w) + float(slots[t].pv_b_forecast_w) - float(slots[t].load_baseline_w), ) if pv_surplus_w <= 500: continue cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) sf_m = pulp.LpVariable(f"pre_neg_pv_charge_sf_{t}", 0, cap_w) pre_neg_pv_charge_shortfall.append((t, sf_m, cap_w)) for t in neg_sell_bat_dump_slots: dump_target_w = _battery_export_cap_w(battery, grid) sf_dump = pulp.LpVariable(f"neg_bat_dump_shortfall_{t}", 0, dump_target_w) neg_sell_bat_dump_shortfall.append((t, sf_dump, dump_target_w)) # --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) --- # Kanály: gi×buy, −ge_pv×sell, −ge_bat×sell, +ge_bat×acquisition (export bat. jen v discharge slotách). # Viz docs/04-modules/planning-arbitrage-accounting.md — mezi-slotová arbitráž, ne sell vs buy v jednom slotu. prob += ( pulp.lpSum( gi[t] * slots[t].buy_price * INTERVAL_H / 1000 - ge_pv[t] * slots[t].sell_price * INTERVAL_H / 1000 - ge_bat[t] * slots[t].sell_price * INTERVAL_H / 1000 + ( ge_pv[t] * SELF_SUSTAIN_EXPORT_PENALTY_CZK_KWH * INTERVAL_H / 1000 if om == "SELF_SUSTAIN" else 0 ) + ( (slots[t].pv_b_forecast_w * z_gen_cutoff[t]) * GEN_CUTOFF_PENALTY_CZK_KWH * INTERVAL_H / 1000 if z_gen_cutoff is not None else 0 ) + gi_over[t] * IMPORT_OVER_BREAKER_PENALTY_CZK_KWH * INTERVAL_H / 1000 + 0.5 * (bc_pv[t] + bc_gi[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000 - ( pv_ld[t] * LOAD_FIRST_INCENTIVE_CZK_KWH * INTERVAL_H / 1000 if om == "AUTO" else 0 ) + ( ge_bat[t] * charge_acquisition_czk_kwh * INTERVAL_H / 1000 if om == "AUTO" and t in discharge_export_slots else 0 ) - ( bc_pv[t] * NEG_SELL_PV_CHARGE_REWARD_CZK_KWH * INTERVAL_H / 1000 if ( om == "AUTO" and float(slots[t].sell_price) < 0.0 and t in charge_slots ) else 0 ) + ( ge_pv[t] * ( max( 0.05, -float(slots[t].sell_price), ) if ( neg_sell_phases_en and neg_sell_phase_by_t[t] == "tail" ) else NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH ) * INTERVAL_H / 1000 if ( om == "AUTO" and float(slots[t].sell_price) < 0.0 and not purchase_fixed_pre ) else 0 ) + ( gi[t] * max( NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH, max( 0.0, float(slots[t].buy_price) - charge_acquisition_czk_kwh, ), ) * INTERVAL_H / 1000 if om == "AUTO" and t in night_self_consume_discourage_ts else 0 ) + pulp.lpSum( ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000 + ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000 for e in range(EV) ) + ca[t] * ( NEG_SELL_CURTAIL_PENALTY_CZK_KWH if ( om == "AUTO" and float(slots[t].buy_price) < 0.0 and t in charge_slots and not ( neg_sell_phases_en and neg_sell_phase_by_t[t] == "prep" ) ) else CURTAILMENT_PENALTY ) for t in range(T) ) + soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000 - terminal_soc_kcz_per_wh * soc[T - 1] + ( pos_sell_soc_shortfall * POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH if pos_sell_soc_shortfall is not None else 0 ) + ( soc_pre_neg_buy_ceiling_slack * PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH if soc_pre_neg_buy_ceiling_slack is not None else 0 ) + pulp.lpSum( safety_vars[t] * safety_pen_czk_per_wh[t] for t in range(T) if safety_vars[t] is not None ) + pulp.lpSum(cv * INTERVAL_H / 1000.0 * commit_pen for _t, cv, _p in commit_lp) + pulp.lpSum( sf * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in peak_export_shortfall ) + pulp.lpSum( sf * NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in degraded_evening_export_shortfall ) + pulp.lpSum( sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in pv_charge_shortfall ) + pulp.lpSum( us * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH for _t, us, _tgt in neg_sell_soc_underfill ) + pulp.lpSum( us * w_sf * NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH for _t, us, w_sf in prep_soc_shortfall ) + pulp.lpSum( sf * NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in prep_hold_bcpv_shortfall ) + pulp.lpSum( sf * NEG_SELL_CURTAIL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in prep_hold_curtail_shortfall ) + pulp.lpSum( sf * PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in pre_neg_pv_export_shortfall ) + pulp.lpSum( sf * NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in neg_evening_before_neg_shortfall ) + pulp.lpSum( sl * NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH for _t, sl, _tgt in neg_evening_reserve_soc_slack ) + pulp.lpSum( bc_pv[t] * PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH * INTERVAL_H / 1000.0 for t in pre_neg_pv_export_ts ) + pulp.lpSum( bc_pv[t] * NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH * INTERVAL_H / 1000.0 for t in neg_sell_post_detach_prep_ts ) + pulp.lpSum( sf * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in neg_sell_bat_dump_shortfall ) + pulp.lpSum( sf * NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in neg_buy_charge_shortfall ) + pulp.lpSum( sf * PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in pre_neg_batt_export_shortfall ) + pulp.lpSum( bc_gi[t] * PRE_NEG_CHARGE_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for t in range(T) if ( first_neg_buy_idx is not None and t < first_neg_buy_idx and float(slots[t].buy_price) >= 0.0 ) ) + pulp.lpSum( bc_pv[t] * PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for t in range(T) if float(slots[t].buy_price) < 0.0 ) + pulp.lpSum( sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in pre_neg_pv_charge_shortfall ) + pulp.lpSum( sf * PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in pre_neg_buy_empty_shortfall ) + pulp.lpSum( -25.0 * z_export[t] for t in range(T) if t in discharge_export_slots and t in profitable_export_ts_pre ) + pulp.lpSum( -EVENING_PUSH_Z_EXPORT_BONUS_CZK * z_export[t] for t in evening_push_ts ) ) # --- Omezení --- for t_sf, sf, cap_w in peak_export_shortfall: prob += sf >= cap_w - ge_bat[t_sf] for t_sf, sf, cap_w in pv_charge_shortfall: prob += sf >= cap_w - bc_pv[t_sf] for t_sf, sf, cap_w in neg_sell_bat_dump_shortfall: prob += sf >= cap_w - ge_bat[t_sf] for t_us, us, _w_sf in prep_soc_shortfall: tgt_prep = neg_sell_soc_target_by_t[t_us] if tgt_prep is not None: prob += us >= float(tgt_prep) - soc[t_us] for t_us, us, tgt_wh in neg_sell_soc_underfill: prob += us >= float(tgt_wh) - soc[t_us] m_hold_soc = float(battery.soc_max_wh) for t_h, sf_h, cap_h in prep_hold_bcpv_shortfall: w_h = prep_hold_met_binary[t_h] soc_prev_h = current_soc_wh if t_h == 0 else soc[t_h - 1] tgt_hold = neg_sell_soc_target_by_t[t_h] hold_thr = float(tgt_hold) if tgt_hold is not None else float(battery.soc_max_wh) prob += soc_prev_h >= hold_thr - m_hold_soc * (1 - w_h) prob += sf_h >= bc_pv[t_h] - cap_h * w_h for t_c, sf_c, cap_c in prep_hold_curtail_shortfall: w_c = prep_hold_met_binary[t_c] prob += sf_c >= ca[t_c] - cap_c * (1 - w_c) for t_sf, sf, cap_w in neg_buy_charge_shortfall: # buy<0: bc_pv=0 (import arbitráž); shortfall jen na grid→bat. prob += sf >= cap_w - bc_gi[t_sf] for t_sf, sf, cap_w in pre_neg_batt_export_shortfall: prob += sf >= cap_w - ge_bat[t_sf] for t_sf, sf, cap_w in pre_neg_buy_empty_shortfall: prob += sf >= cap_w - ge_bat[t_sf] for t_sf, sf, cap_w in pre_neg_pv_charge_shortfall: prob += sf >= cap_w - bc_pv[t_sf] for t_sf, sf, cap_w in pre_neg_pv_export_shortfall: prob += sf >= cap_w - ge_pv[t_sf] for t_sf, sf, cap_w in neg_evening_before_neg_shortfall: prob += sf >= cap_w - ge_bat[t_sf] for t_sf, sf, cap_w in degraded_evening_export_shortfall: prob += sf >= cap_w - ge_bat[t_sf] for t_sl, sl, reserve_tgt in neg_evening_reserve_soc_slack: prob += soc[t_sl] <= float(reserve_tgt) + sl preneg_export_min_soc_wh = float(min_soc_wh) + max( float(battery.max_discharge_power_w) * float(battery.discharge_efficiency) * INTERVAL_H, 1000.0, ) per_slot_discharge_wh = max( float(battery.max_discharge_power_w) * float(battery.discharge_efficiency) * INTERVAL_H, 0.0, ) if om == "AUTO": profitable_export_ts = profitable_export_ts_pre export_push_w = _battery_export_cap_w(battery, grid) discharge_floor_wh = _planner_discharge_floor_wh(battery) # Tvrdý ranní/pre-neg export jen ve strict režimu (jinak ~25 % SoC + neg den → Infeasible). if not any_relaxed: for t_peak in morning_pre_neg_export_ts: if t_peak in profitable_export_ts: if _battery_export_push_defer_to_pv(slots[t_peak]): continue prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] prob += soc[t_peak] >= float(discharge_floor_wh) for t_pnd in pre_neg_buy_discharge_ts: if _battery_export_push_defer_to_pv(slots[t_pnd]): continue prob += ge_bat[t_pnd] >= export_push_w * z_export[t_pnd] for t_empty in pre_neg_buy_empty_ts: if t_empty in discharge_export_slots: if _battery_export_push_defer_to_pv(slots[t_empty]): continue prob += ge_bat[t_empty] >= export_push_w * z_export[t_empty] for t_early in sorted(evening_early_export_penalty_ts): prob += ge_bat[t_early] == 0 if not evening_push_hard_suppressed: for t_peak in sorted(evening_push_ts): if t_peak not in discharge_export_slots: continue if t_peak in battery_export_defer_pv_ts: continue push_floor_w = _evening_push_battery_export_w( slots[t_peak], battery, grid ) if push_floor_w >= GE_MIN_EXPORT_W: prob += z_export[t_peak] == 1 prob += ge_bat[t_peak] >= push_floor_w prob += soc[t_peak] >= float(discharge_floor_wh) for t_pv in sorted(battery_export_defer_pv_ts): if t_pv in evening_push_ts: continue if t_pv in morning_pre_neg_export_ts: continue if t_pv in pre_neg_buy_discharge_ts: continue if t_pv in pre_neg_buy_empty_ts: continue prob += ge_bat[t_pv] == 0 prob += z_export[t_pv] == 0 # Nouzový relax: v noci jen vývoz k reserve večer D0; jinak ge_bat=0. if relaxed_solver_masks and not purchase_fixed_pre: reserve_wh_blk = float( getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0)) ) for t_blk in range(T): if t_blk in degraded_evening_export_ts: continue if not _in_night_battery_export_window(slots[t_blk]): continue prob += ge_bat[t_blk] == 0 prob += z_export[t_blk] == 0 for t_ev in sorted(degraded_evening_export_ts): m_soc_deg = float(battery.usable_capacity_wh) prob += soc[t_ev] >= float(reserve_wh_blk) - m_soc_deg * ( 1 - z_export[t_ev] ) # Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push). if ( last_pos_sell_pre_neg_buy is not None and pos_sell_soc_shortfall is not None ): prob += ( soc[last_pos_sell_pre_neg_buy] >= float(battery.soc_max_wh) - pos_sell_soc_shortfall ) if ( t_pre_neg_buy_anchor is not None and pre_neg_buy_soc_ceiling_wh is not None and soc_pre_neg_buy_ceiling_slack is not None and last_pos_sell_pre_neg_buy is not None ): prob += ( soc[t_pre_neg_buy_anchor] <= float(pre_neg_buy_soc_ceiling_wh) + soc_pre_neg_buy_ceiling_slack ) for t in range(T): s = slots[t] pv_a_net = s.pv_a_forecast_w - ca[t] ev_total_t = pulp.lpSum(ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV)) # Energetická bilance pv_b_effective = ( float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t]) if z_gen_cutoff is not None else float(s.pv_b_forecast_w) ) pv_total_ub = float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) # Součet nabíjení z FVE + ze sítě nesmí překročit max_charge_power_w baterie. prob += bc_pv[t] + bc_gi[t] <= battery.max_charge_power_w # Breaker: import ze site je tvrdě omezen (gi_over jen numerická pojistka). prob += gi[t] <= gi_upper if om == "AUTO": load_site_expr = float(s.load_baseline_w) + ev_total_t + hp[t] ev_cap_slot_w = sum( float(vehicles[e].max_charge_power_w) for e in range(EV) if (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected) ) max_load_site_w = ( float(s.load_baseline_w) + ev_cap_slot_w + float(heat_pump.rated_heating_power_w) ) # BMS: jedno vybíjení — bilance při gi≈0 dá bd≈load+ge_bat; bd+ge_bat≤max by export # započítalo dvakrát ((max−load)/2). Exportní sloty: load+ge_bat; jinak bd≤max. prob += bd[t] <= battery.max_discharge_power_w if t in discharge_export_slots: prob += load_site_expr + ge_bat[t] <= battery.max_discharge_power_w prob += pv_ld[t] + pv_sp[t] == pv_a_net + pv_b_effective prob += pv_ld[t] <= load_site_expr prob += pv_ld[t] <= pv_a_net + pv_b_effective prob += pv_sp[t] <= pv_total_ub prob += pv_sp[t] >= pv_a_net + pv_b_effective - load_site_expr prob += bc_pv[t] <= pv_sp[t] prob += bc_gi[t] <= gi[t] prob += ge_pv[t] <= pv_sp[t] prob += bc_pv[t] + ge_pv[t] <= pv_sp[t] # Tvrdý load-first (Deye): při dostatečné FVE jen grid-nabíjení (bc_gi); jinak gi smí # krmit deficit domu (noc / nízká FVE), ne fiktivně paralelně s plným PV→bc_pv. house_grid_import_cap_w = max( 0.0, max_load_site_w - pv_total_ub, ) prob += gi[t] <= bc_gi[t] + house_grid_import_cap_w pv_covers_load_site = ( pv_total_ub >= max_load_site_w + NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W ) if pv_covers_load_site: prob += pv_ld[t] >= load_site_expr # Vybíjení do domu až po pv_ld; v exportních slotech smí bd→síť. if t not in discharge_export_slots: prob += bd[t] <= load_site_expr - pv_ld[t] if pv_covers_load_site: prob += pv_ld[t] >= load_site_expr - bd[t] else: prob += pv_ld[t] >= load_site_expr - gi[t] - bd[t] # Plná bilance (pv_ld+pv_sp rozpad je ortogonální k tokům přebytku). prob += ( pv_a_net + pv_b_effective + gi[t] + bd[t] == float(s.load_baseline_w) + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] + ge[t] ) else: prob += pv_ld[t] == 0 prob += pv_sp[t] == pv_a_net + pv_b_effective prob += bc_pv[t] <= pv_sp[t] prob += bc_gi[t] <= gi[t] prob += ( pv_a_net + pv_b_effective + gi[t] + bd[t] == s.load_baseline_w + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] + ge[t] ) prob += bd[t] + ge_bat[t] <= battery.max_discharge_power_w prob += ge[t] == ge_pv[t] + ge_bat[t] # Baterie nesmí „přestrojit“ FVE export: jen z pv_sp (po load-first). if om == "AUTO": prob += ge_bat[t] >= ge[t] - pv_sp[t] else: prob += ge_bat[t] >= ge[t] - (pv_a_net + pv_b_effective) # 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: bd je v bilanci zdroj na AC sběrnici; při exportu z baterie už # obsahuje load + ge_bat (ge = ge_pv + ge_bat). ge_bat znovu neodečítat. 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 ) sv = safety_vars[t] tgt_s = slots[t].safety_soc_target_wh if daytime_en else None if sv is not None: eff_tgt_s = float(tgt_s) if tgt_s is not None else float(min_soc_wh) if ( neg_sell_phases_en and float(s.sell_price) < 0.0 and neg_sell_soc_target_by_t[t] is not None ): eff_tgt_s = max(eff_tgt_s, float(neg_sell_soc_target_by_t[t])) elif ( om == "AUTO" and float(s.buy_price) < 0.0 and t in charge_slots and len(neg_buy_slot_indices_pre) >= 2 and not neg_sell_phases_en ): # buy<0: cíl soc_max jen při víceslotovém okně (jinak fyzicky neřešitelné). eff_tgt_s = max(eff_tgt_s, float(battery.soc_max_wh)) elif post_neg_pv_topup[t]: # Po konci sell<0: dobit z FVE na plno, pak teprve export (kladný sell, ne večerní peak). eff_tgt_s = max(eff_tgt_s, float(battery.soc_max_wh)) prob += sv >= eff_tgt_s - soc[t] # ev_via_bat kryto z discharge prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t] # GEN port cut-off chceme vůbec připustit jen v režimech/politikách, kde má smysl: # - SELF_SUSTAIN (no-export intent; typicky ge=0, takže cut-off je bezpečnostní ventil), # - BLOCK_EXPORT okna (v projektu reprezentované sloty se sell_price < 0), # - případně explicitní no_export politika (pokud bude v kontextu dostupná). allow_gen_cutoff = ( om == "SELF_SUSTAIN" or float(s.sell_price) < 0 or bool(getattr(grid, "no_export", False)) ) if z_gen_cutoff is not None and not allow_gen_cutoff: prob += z_gen_cutoff[t] == 0 # Záporná nákupní cena → import jen na load + nabíjení + EV + TČ (stále ≤ breaker). if s.buy_price < 0: prob += gi[t] <= min( gi_upper, float(s.load_baseline_w) + battery.max_charge_power_w + sum(v.max_charge_power_w for v in vehicles) + heat_pump.rated_heating_power_w, ) prob += ge[t] == 0 prob += ge_pv[t] == 0 prob += ge_bat[t] == 0 if z_gen_cutoff is not None and float(s.sell_price) < 0.0: prob += z_gen_cutoff[t] == 1 # PV A: měkký tlak curtail (NEG_SELL_CURTAIL při buy<0), ne tvrdé bc_pv=0 # (s polem B a bilancí může být bc_pv=0 nutné pro řešitelnost krátkých okének). # Záporný prodej (sell < 0): výboj baterie jen před extrémně záporným buy (v11). # Export FVE při sell<0: spot = nabíjení/curtail A; ventil jen pole B při plné baterii. if s.sell_price < 0: prob += w_arb[t] == 0 prob += bd[t] <= pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) # buy<0: export už zakázán výše; neaplikovat sell<0 ventil (bilance / infeasible). if float(s.buy_price) < 0.0: continue block_neg_sell_export_t = bool( getattr(grid, "block_export_on_negative_sell", False) ) if t not in neg_sell_bat_dump_slots: prob += ge_bat[t] == 0 ev_cap_neg = sum( float(vehicles[e].max_charge_power_w) for e in range(EV) if (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected) ) load_neg = ( float(s.load_baseline_w) + ev_cap_neg + float(heat_pump.rated_heating_power_w) ) pv_surplus_neg_w = max( 0.0, float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_neg, ) # FVE→síť při záporném výkupu: u KV1 (block_export) jen bc/curtail A; # u home-01 s polem B musí přebytek jít do sítě (ge_pv), jinak infeasible. block_pv_export_neg_sell = bool( getattr(grid, "block_export_on_negative_sell", False) ) or ( float(s.pv_b_forecast_w) <= 0 and not _pv_forced_vent_export_allowed( t, current_soc_wh=current_soc_wh, battery=battery, soc_headroom_wh=soc_headroom_wh, pv_surplus_w=pv_surplus_neg_w, ) ) if block_pv_export_neg_sell: prob += ge_pv[t] == 0 # Tvrdý zákaz vývozu jen při block_export_on_negative_sell (KV1). if block_neg_sell_export_t: prob += ge[t] == 0 prob += ge_pv[t] == 0 prob += ge_bat[t] == 0 elif purchase_fixed_pre: # Fixní nákup + spot výkup (BA81, KV1 bez block_export): sell<0 = platíš za vývoz. prob += ge[t] == 0 prob += ge_pv[t] == 0 elif not purchase_fixed_pre: # Spot: sell<0 před buy<0 — PV (A) do baterie, B může jít do sítě (ge_pv≤pv_b). # Po buy<0 / mimo ranní pásmo: ventil B jen při plné baterii (nebo tail + sell práh). before_first_neg_buy = ( first_neg_buy_idx is not None and t < first_neg_buy_idx ) vent_min_sell = getattr( battery, "planner_neg_sell_vent_min_sell_czk_kwh", None ) tail_free_vent = bool( neg_sell_phases_en and neg_sell_phase_by_t[t] == "tail" and vent_min_sell is not None and float(s.sell_price) >= float(vent_min_sell) ) if tail_free_vent and float(s.pv_b_forecast_w) > 0: prob += ge_pv[t] <= float(s.pv_b_forecast_w) elif before_first_neg_buy: if float(s.pv_b_forecast_w) > 0: prob += ge_pv[t] <= float(s.pv_b_forecast_w) else: soc_prev_neg = current_soc_wh if t == 0 else soc[t - 1] w_pv_b_vent = pulp.LpVariable( f"w_pv_b_vent_neg_{t}", cat=pulp.LpBinary ) m_soc_neg = float(battery.soc_max_wh) prob += soc_prev_neg >= ( m_soc_neg - soc_headroom_wh - m_soc_neg * (1 - w_pv_b_vent) ) prob += ge_pv[t] <= float(s.pv_b_forecast_w) * w_pv_b_vent # GEN/MI cut-off ON když LP zakazuje vývoz — bez cut-off únik PV B na GEN portu do sítě. if z_gen_cutoff is not None: if block_neg_sell_export_t or purchase_fixed_pre: prob += z_gen_cutoff[t] == 1 elif block_pv_export_neg_sell: prob += z_gen_cutoff[t] == 1 soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1] arb_t = arb_floor_series[t] soc_low_t = soc_panel_min[t] # Při relaxovaném dnu (soc_low pod DB min_soc Wh) nesmí větev w_arb=1 znovu vynutit arb_t # (typicky ~rezerva 20 %) — jinak nejde „vypustit“ baterku k planner floor 5 %. if soc_low_t < min_soc_wh - 1e-3: arb_cap_t = min(arb_t, soc_low_t) else: arb_cap_t = arb_t if om == "AUTO" and t in discharge_export_slots: prob += soc_prev_expr >= ( arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t]) ) prob += bd[t] <= ( battery.max_discharge_power_w * w_arb[t] + pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) ) elif om == "AUTO": # PASSIVE: vlastní spotřeba (bd); export baterie jen ge_bat (ge_bat=0 níže). prob += soc_prev_expr >= ( arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t]) ) prob += bd[t] <= ( s.load_baseline_w + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] ) else: prob += soc_prev_expr >= ( arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t]) ) prob += bd[t] <= ( s.load_baseline_w + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] + battery.max_discharge_power_w * w_arb[t] ) # Významný export z baterie ⇒ koncové SoC ≥ podlaha (FVE export ge_pv bez této podlahy). m_ge = float(grid.max_export_power_w) m_soc_bigm = float(battery.usable_capacity_wh) if t in neg_sell_bat_dump_slots: prob += ge_bat[t] <= m_ge else: prob += ge_bat[t] <= m_ge * z_export[t] 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 ( 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 pre_neg_buy_discharge_ts: export_soc_floor_t = float(min_soc_wh) elif om == "AUTO" and t in pre_neg_buy_empty_ts: export_soc_floor_t = float(min_soc_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) # Večerní exportní slot: podlaha jen min_soc (ne safety ramp), aby šlo vybít při z_export=1. # Nouzový relaxed_solver_masks: export nikdy pod reserve_soc (ekonomická podlaha). if ( om == "AUTO" and t in discharge_export_slots and ( t in evening_peak_export_ts or t in neg_evening_push_ts ) and not relaxed_solver_masks ): export_soc_floor_t = float(min_soc_wh) elif relaxed_solver_masks and om == "AUTO": export_soc_floor_t = max( export_soc_floor_t, float(getattr(battery, "reserve_soc_wh", arb_base_wh)), ) # Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro # robustnost/noční baseload. Použije se pouze pokud je safety target v SQL vyplněný. tgt_s = slots[t].safety_soc_target_wh if daytime_en else None if ( tgt_s is not None and not high_sell_slot[t] and t not in profitable_export_ts_pre and not ( om == "AUTO" and t in discharge_export_slots and t in evening_peak_export_ts ) ): export_soc_floor_t = max( export_soc_floor_t, min( float(battery.soc_max_wh), max(min_soc_wh, float(tgt_s)), ), ) prob += soc[t] >= export_soc_floor_t - m_soc_bigm * (1 - z_export[t]) # EV – limity a připojení for e in range(EV): connected = ( (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected) ) if not connected: prob += ev_direct[e][t] == 0 prob += ev_via_bat[e][t] == 0 else: prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w for tt, cv, prev in commit_lp: prob += cv >= prev - (bc_pv[tt] + bc_gi[tt]) if om == "SELF_SUSTAIN": for t in range(T): prob += gi[t] <= slots[t].load_baseline_w elif om == "PRESERVE": for t in range(T): prob += bc_pv[t] == 0 prob += bc_gi[t] == 0 prob += bd[t] == 0 elif om == "CHARGE_CHEAP": for t in range(T): prob += ge[t] == 0 prob += ge_pv[t] == 0 prob += ge_bat[t] == 0 prob += bd[t] == 0 # Slot pre-selection (z DB fn_load_planning_slots_full → allow_*) if om == "AUTO": for t in range(T): s = slots[t] sell_t_pre = float(s.sell_price) pv_surplus_w = max( 0, int(s.pv_a_forecast_w) + int(s.pv_b_forecast_w) - int(s.load_baseline_w), ) pv_surplus_for_gi = pv_surplus_w if ( t in charge_slots and sell_t_pre < 0 and pv_surplus_for_gi > 0 and float(s.buy_price) >= 0.0 ): prob += bc_gi[t] == 0 if float(s.buy_price) < 0.0: pass elif ( first_neg_buy_idx is not None and first_neg_buy_idx > 0 and t in pos_sell_pre_neg_buy_ts and t not in pos_sell_pre_neg_buy_ge_exempt_ts and not relaxed_pos_sell_ge_block ): prob += ge[t] == 0 prob += ge_pv[t] == 0 prob += ge_bat[t] == 0 elif t not in charge_slots: if float(s.buy_price) >= 0.0: prob += bc_gi[t] == 0 if float(s.buy_price) >= 0.0: if pv_surplus_w <= 0: prob += bc_pv[t] == 0 else: prob += bc_pv[t] <= float(pv_surplus_w) if ( t not in discharge_export_slots and t not in neg_sell_bat_dump_slots and t not in pre_neg_buy_discharge_ts and t not in pre_neg_buy_empty_ts ): prob += ge_bat[t] == 0 prob += z_export[t] == 0 for t_pne in pre_neg_pv_export_ts: # v33: při dostatečné FVE v sell<0 okně neukládat ranní PV do baterie — export. prob += bc_pv[t_pne] == 0 # v44: neg den — před 1. sell<0 žádné grid→bat (AM sloty za ~3 Kč vs FVE v okně). if neg_sell_phases_en and first_neg_sell_idx is not None: neg_day = _prague_calendar_date(slots[first_neg_sell_idx]) for t_blk in range(first_neg_sell_idx): if _prague_calendar_date(slots[t_blk]) != neg_day: continue prob += bc_gi[t_blk] == 0 # 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). non_negative_buys = [ float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0 ] ref_buy_horizon = ( min(non_negative_buys) if non_negative_buys else min(float(s.buy_price) for s in slots) ) min_spread = float(degradation_cost_effective) for t in range(T): s = slots[t] buy_t = float(s.buy_price) sell_t = float(s.sell_price) load_t = float(s.load_baseline_w) ev_cap_t = sum( float(vehicles[e].max_charge_power_w) for e in range(EV) if (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected) ) pv_surplus_w = max( 0.0, float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_t, ) # FVE export před sell<0 jen pokud forecast v sell<0 okně pokryje dobítí (v33). allow_pre_neg_pv_export = t in pre_neg_pv_export_ts pv_store_val = _pv_store_value_czk_kwh(s, min_spread) fixed_pre_neg_pv_export = ( purchase_fixed_pre and sell_t >= 0.0 and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W and ( first_neg_sell_idx is None or t < first_neg_sell_idx ) ) fixed_block_pv_surplus_export = ( purchase_fixed_pre and bool(getattr(grid, "block_export_on_negative_sell", False)) and sell_t >= 0.0 and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W ) # BA81: ge_pv≤pv_b jen při významném poli A — při úsvitu nechat Deye bez plného curtail A. fixed_mi_low_pv_surplus_export = ( purchase_fixed_pre and float(s.pv_b_forecast_w) > 0 and not getattr(grid, "block_export_on_negative_sell", False) and sell_t >= 0.0 and int(s.pv_a_forecast_w) < DAWN_LOW_PV_NO_CURTAIL_W and pv_surplus_w > 0.0 ) skip_pv_store_block = ( ( float(s.pv_b_forecast_w) > 0 and not getattr(grid, "block_export_on_negative_sell", False) and sell_t < 0 and buy_t >= 0.0 and not purchase_fixed_pre and ( first_neg_buy_idx is None or t < first_neg_buy_idx ) ) or ( # Spot: při sell>=0 neblokovat ge_pv (export vs bc_pv; večerní peak = ge_bat). not purchase_fixed_pre and sell_t >= 0 and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W ) or fixed_pre_neg_pv_export or fixed_block_pv_surplus_export or fixed_mi_low_pv_surplus_export ) # Spot: mezi-slotová arbitráž — grid→bat jen když buy ≤ charge_acquisition (v61). spot_grid_charge_not_cheap_buy = ( not purchase_fixed_pre and buy_t >= 0.0 and buy_t > charge_acquisition_czk_kwh + min_spread ) fixed_pv_b_export_cap = ( purchase_fixed_pre and float(s.pv_b_forecast_w) > 0 and not getattr(grid, "block_export_on_negative_sell", False) and sell_t >= 0 and not fixed_pre_neg_pv_export and int(s.pv_a_forecast_w) >= DAWN_LOW_PV_NO_CURTAIL_W ) if spot_grid_charge_not_cheap_buy: prob += bc_gi[t] == 0 if ( purchase_fixed_pre and t in evening_push_ts and sell_t > buy_t + min_spread ): prob += bc_pv[t] == 0 prob += bc_gi[t] == 0 if fixed_pre_neg_pv_export: prob += ge_pv[t] <= max(0.0, pv_surplus_w) elif fixed_pv_b_export_cap: if z_gen_cutoff is not None: prob += ge_pv[t] <= float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t]) else: prob += ge_pv[t] <= max(0.0, float(s.pv_b_forecast_w)) if ( not allow_pre_neg_pv_export and not skip_pv_store_block and not fixed_pv_b_export_cap and sell_t < pv_store_val and not ( sell_t >= 0.0 and int(s.pv_a_forecast_w) < DAWN_LOW_PV_NO_CURTAIL_W ) and not _pv_forced_vent_export_allowed( t, current_soc_wh=current_soc_wh, battery=battery, soc_headroom_wh=soc_headroom_wh, pv_surplus_w=pv_surplus_w, ) ): prob += ge_pv[t] == 0 # Při `sell < 0` exportovat MAX pole B (má green bonus 7+ Kč/kWh → čistá hodnota # i při sell=-1 = +6 Kč). Pole A green bonus nemá → export A za sell<0 je čistá ztráta. # Constraint: ge_pv ≤ pv_b_forecast_w (pole A jde do baterie / curtail). # Aplikuje se jen u sites bez block_export_on_negative_sell (home-01 áno; KV1 ne) # A jen pokud reálně existuje pole B (pv_b_forecast_w > 0 — jinak by ge_pv ≤ 0 # zablokovalo legitimní pre-neg-pv export pole A z testů). if ( sell_t < 0 and buy_t >= 0.0 and float(s.pv_b_forecast_w) > 0 and not getattr(grid, "block_export_on_negative_sell", False) ): prob += ge_pv[t] <= float(s.pv_b_forecast_w) # Drahý nákup: dům + TČ z baterie (ne import ze sítě); síť jen EV (+ případně TČ). # Spot (home-01): buy > min ne-záporného buy v horizontu. # Fixní tarif (KV1): navíc buy > charge_acquisition (konstantní buy ≈ ref). expensive_import_slot = buy_t > ref_buy_horizon + min_spread if purchase_fixed_pre and buy_t >= 0.0: # KV1/BA81: buy skoro konstantní — buy > acq nikdy neplatí, jinak v noci import za 6 Kč. expensive_import_slot = True elif fixed_tariff_like_pre: expensive_import_slot = expensive_import_slot or ( buy_t > charge_acquisition_czk_kwh + min_spread ) if expensive_import_slot and buy_t >= 0.0: force_night_self_consume = ( relaxed_solver_masks and t in degraded_relaxed_night_ts and t not in degraded_evening_export_ts ) if force_night_self_consume or ( expensive_import_slot and t not in charge_slots ): # Strict: síť jen EV+TČ; baseload z baterie/FVE. # Relaxed: síť smí baseload jen mimo night_self_consume (v46). night_self_consume_slot = ( om == "AUTO" and ( t in night_self_consume_discourage_ts or t in post_evening_push_night_ts or force_night_self_consume ) ) if (relaxed_expensive_import or late_replan_solver_relax) and not night_self_consume_slot: prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w) else: prob += gi[t] <= ev_cap_t + hp[t] if ( force_night_self_consume or (not (relaxed_expensive_import or late_replan_solver_relax) or night_self_consume_slot) ) and om == "AUTO": prob += ( bd[t] + pv_ld[t] >= float(s.load_baseline_w) + hp[t] ) # Anti souběžný vývoz FVE + významný import (mikrocyklus). if buy_t > sell_t + min_spread and pv_surplus_w > 0: prob += ge_pv[t] <= pv_surplus_w # Deadline constraints pro EV for e, session in enumerate(ev_sessions): if session and session.target_deadline and session.energy_needed_wh > 0: t_dl = next( (t for t, s in enumerate(slots) if s.interval_start >= session.target_deadline), T - 1 ) prob += pulp.lpSum( (ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H for t in range(t_dl + 1) if (e == 0 and slots[t].ev1_connected) or (e == 1 and slots[t].ev2_connected) ) >= session.energy_needed_wh # TUV look-ahead podle tuv_usage_stats (DOW+hodina, konvence jako v DB) if ( tuv_delta_stats and heat_pump.rated_heating_power_w > 0 and getattr(heat_pump, "tuv_min_temp_c", 0) is not None ): tuv_pred = float(current_tuv_temp_c) tgt = float(getattr(heat_pump, "tuv_target_temp_c", 55.0) or 55.0) thr = float(heat_pump.tuv_min_temp_c) + 5.0 for t in range(T): dow, hour = _prague_dow_hour(slots[t].interval_start) delta = tuv_delta_stats.get((dow, hour), -0.1) tuv_pred += float(delta) * INTERVAL_H if tuv_pred < thr: prob += ( pulp.lpSum(hp[s] for s in range(max(0, t - 8), t + 1)) >= heat_pump.rated_heating_power_w * 0.5 ) tuv_pred = tgt # Nouzový ohřev TUV if current_tuv_temp_c < heat_pump.tuv_min_temp_c: prob += hp[0] >= heat_pump.rated_heating_power_w * 0.8 # SoC bezpečnostní buffer vyhodnocený až na konci 24h horizontu eod_idx = min(T - 1, int(24 / INTERVAL_H) - 1) prob += soc_deficit_24h >= soc_buffer_target_wh - soc[eod_idx] # --- Řešení (HiGHS přes highspy / PuLP API; bez externí binárky HiGHS_CMD) --- t_start = time.monotonic() try: solver = pulp.getSolver( "HiGHS", msg=False, timeLimit=SOLVER_TIME_LIMIT ) except Exception: logger.warning("HiGHS nedostupný, používám CBC fallback") solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT) status = prob.solve(solver) duration_ms = int((time.monotonic() - t_start) * 1000) if pulp.LpStatus[status] != "Optimal": if not relaxed_expensive_import: logger.warning( "solve_dispatch Infeasible, retry with relaxed_expensive_import " "(grid may supply baseload in expensive slots)" ) return solve_dispatch( slots, battery, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, relaxed_expensive_import=True, evening_push_ts_override=evening_push_ts_override, ) if not relaxed_neg_buy_charge: logger.warning( "solve_dispatch still Infeasible, retry without neg_buy_charge_shortfall" ) return solve_dispatch( slots, battery, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, relaxed_expensive_import=True, relaxed_neg_buy_charge=True, evening_push_ts_override=evening_push_ts_override, ) if not relaxed_neg_prep_hold_only: logger.warning( "solve_dispatch still Infeasible, retry with relaxed_neg_prep_hold_only " "(skip prep_soc_shortfall and prep hold binárek; evening push unchanged)" ) return solve_dispatch( slots, battery, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, relaxed_expensive_import=True, relaxed_neg_buy_charge=True, relaxed_neg_prep_hold_only=True, evening_push_ts_override=evening_push_ts_override, ) if not relaxed_neg_prep_window: logger.warning( "solve_dispatch still Infeasible, retry with relaxed_neg_prep_window " "(skip strict pre-neg bundle; future_neg_buy evening export kept)" ) return solve_dispatch( slots, battery, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, relaxed_expensive_import=True, relaxed_neg_buy_charge=True, relaxed_neg_prep_hold_only=True, relaxed_neg_prep_window=True, neg_sell_phases_fallback=neg_sell_phases_fallback, evening_push_ts_override=evening_push_ts_override, ) if not neg_sell_phases_fallback: logger.warning( "solve_dispatch still Infeasible, retry with neg_sell phases disabled " "(prep_soc_percent=100)" ) battery_no_phases = SimpleNamespace( **{ **vars(battery), "planner_neg_sell_prep_soc_percent": 100.0, } ) return solve_dispatch( slots, battery_no_phases, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, relaxed_expensive_import=True, relaxed_neg_buy_charge=True, relaxed_neg_prep_hold_only=True, relaxed_neg_prep_window=True, neg_sell_phases_fallback=True, evening_push_ts_override=evening_push_ts_override, ) if not relaxed_pos_sell_ge_block: logger.warning( "solve_dispatch still Infeasible, retry with relaxed_pos_sell_ge_block " "(no ge=0 on pos_sell before buy<0)" ) battery_no_phases = SimpleNamespace( **{ **vars(battery), "planner_neg_sell_prep_soc_percent": 100.0, } ) return solve_dispatch( slots, battery_no_phases, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, relaxed_expensive_import=True, relaxed_neg_buy_charge=True, relaxed_neg_prep_hold_only=True, relaxed_neg_prep_window=True, neg_sell_phases_fallback=True, relaxed_pos_sell_ge_block=True, evening_push_ts_override=evening_push_ts_override, ) if not relaxed_solver_masks: logger.warning( "solve_dispatch still Infeasible, retry with relaxed_solver_masks " "(permissive slot masks; neg-evening hard bundle off)" ) battery_no_phases = SimpleNamespace( **{ **vars(battery), "planner_neg_sell_prep_soc_percent": 100.0, } ) return solve_dispatch( slots, battery_no_phases, heat_pump, grid, ev_sessions, vehicles, current_soc_wh, current_tuv_temp_c, tuv_delta_stats=tuv_delta_stats, operating_mode=operating_mode, charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, relaxed_expensive_import=True, relaxed_neg_buy_charge=True, relaxed_neg_prep_hold_only=True, relaxed_neg_prep_window=True, neg_sell_phases_fallback=True, relaxed_pos_sell_ge_block=True, relaxed_solver_masks=True, evening_push_ts_override=evening_push_ts_override, ) raise PlannerSolverError( pulp.LpStatus[status], relax_chain=_solver_relax_chain( relaxed_expensive_import=relaxed_expensive_import, relaxed_neg_buy_charge=relaxed_neg_buy_charge, relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only, relaxed_neg_prep_window=relaxed_neg_prep_window, neg_sell_phases_fallback=neg_sell_phases_fallback, relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block, relaxed_solver_masks=relaxed_solver_masks, ), ) # --- Post-processing --- results = [] for t in range(T): hp_raw = pulp.value(hp[t]) hp_on = hp_raw > heat_pump.rated_heating_power_w * 0.3 bc_tot = float(pulp.value(bc_pv[t]) or 0) + float(pulp.value(bc_gi[t]) or 0) batt_w = round(bc_tot - float(pulp.value(bd[t]) or 0)) ge_bat_w = round(float(pulp.value(ge_bat[t]) or 0)) ge_pv_w = round(float(pulp.value(ge_pv[t]) or 0)) grid_w, export_mode = _dispatch_grid_setpoint_w( gi_w=float(pulp.value(gi[t]) or 0), ge_w=float(pulp.value(ge[t]) or 0), ge_bat_w=float(ge_bat_w), ge_pv_w=float(ge_pv_w), max_export_power_w=int(grid.max_export_power_w), ) soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1) export_limit_w = int(grid.max_export_power_w) if grid_w < 0 else 0 # Deye: default PASSIVE (střídač pokryje load). CHARGE/SELL jen v maskovaných AUTO slotech. deye_mode = "PASSIVE" if om == "AUTO": if ( slots[t].allow_discharge_export and ge_bat_w >= GE_MIN_EXPORT_W ): deye_mode = "SELL" elif slots[t].allow_charge and batt_w > 0 and grid_w > 0: deye_mode = "CHARGE" elif batt_w < 0 and grid_w < 0: deye_mode = "SELL" elif batt_w > 0 and grid_w > 0: deye_mode = "CHARGE" deye_gen_cutoff = None if z_gen_cutoff is not None: deye_gen_cutoff = bool(round(float(pulp.value(z_gen_cutoff[t]) or 0))) cashflow_czk_t = ( pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000 - pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000 ) ge_bat_value = float(pulp.value(ge_bat[t]) or 0) battery_arbitrage_czk_t = ( ge_bat_value * (float(slots[t].sell_price) - float(charge_acquisition_czk_kwh)) * INTERVAL_H / 1000.0 ) penalty_terms_t = 0.0 for _tt, _sf, _cap in peak_export_shortfall: if _tt == t: penalty_terms_t += ( float(pulp.value(_sf) or 0.0) * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 ) for _tt, _sf, _cap in pv_charge_shortfall: if _tt == t: penalty_terms_t += ( float(pulp.value(_sf) or 0.0) * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 ) for _tt, _sf, _cap in neg_sell_bat_dump_shortfall: if _tt == t: penalty_terms_t += ( float(pulp.value(_sf) or 0.0) * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 ) for _tt, _us, _tgt in neg_sell_soc_underfill: if _tt == t: penalty_terms_t += ( float(pulp.value(_us) or 0.0) * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH ) for _tt, _us, _w in prep_soc_shortfall: if _tt == t: penalty_terms_t += ( float(pulp.value(_us) or 0.0) * float(_w) * NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH ) sv_t = safety_vars[t] if sv_t is not None: penalty_terms_t += float(pulp.value(sv_t) or 0.0) * safety_pen_czk_per_wh[t] for _tt, _cv, _prev in commit_lp: if _tt == t: penalty_terms_t += float(pulp.value(_cv) or 0.0) * INTERVAL_H / 1000.0 * commit_pen penalty_terms_t += float(pulp.value(ca[t]) or 0.0) * CURTAILMENT_PENALTY green_bonus_czk_t = float( getattr(slots[t], "green_bonus_czk_per_slot", 0.0) or 0.0 ) cost = cashflow_czk_t results.append(DispatchResult( interval_start = slots[t].interval_start, battery_setpoint_w = batt_w, battery_soc_target = soc_pct, grid_setpoint_w = grid_w, export_limit_w = export_limit_w, export_mode = export_mode, deye_physical_mode = deye_mode, deye_gen_cutoff_enabled = deye_gen_cutoff, ev1_setpoint_w = round(pulp.value(ev_direct[0][t]) + pulp.value(ev_via_bat[0][t])) if slots[t].ev1_connected else None, ev2_setpoint_w = round(pulp.value(ev_direct[1][t]) + pulp.value(ev_via_bat[1][t])) if slots[t].ev2_connected else None, ev1_via_bat_w = round(pulp.value(ev_via_bat[0][t])), ev2_via_bat_w = round(pulp.value(ev_via_bat[1][t])), heat_pump_enabled = hp_on, heat_pump_setpoint_w = heat_pump.rated_heating_power_w if hp_on else 0, pv_a_curtailed_w = round(pulp.value(ca[t])), expected_cost_czk = round(cost, 4), effective_buy_price = slots[t].buy_price, effective_sell_price = slots[t].sell_price, is_predicted_price = bool(slots[t].is_predicted_price), cashflow_czk = round(cashflow_czk_t, 4), battery_arbitrage_czk = round(battery_arbitrage_czk_t, 4), penalty_czk = round(penalty_terms_t, 4), green_bonus_czk = round(green_bonus_czk_t, 4), )) sell_rank = sorted(range(T), key=lambda i: float(slots[i].sell_price), reverse=True)[: min(3, T)] charge_commit_snapshot = [ { "slot": slots[tt].interval_start.isoformat(), "previous_charge_w": prev, "shortfall_w": float(pulp.value(cv) or 0.0), } for tt, cv, prev in commit_lp ] masks_snap: list[dict[str, Any]] = [] soc_bounds_snap: list[dict[str, Any]] = [] objective_terms_snap: list[dict[str, Any]] = [] for t in range(T): st = slots[t] masks_snap.append( { "slot": st.interval_start.isoformat(), "allow_charge": bool(st.allow_charge), "allow_discharge_export": bool(st.allow_discharge_export), "neg_sell_phase": neg_sell_phase_by_t[t] if neg_sell_phases_en else None, "neg_sell_soc_target_wh": ( float(neg_sell_soc_target_by_t[t]) if neg_sell_soc_target_by_t[t] is not None else None ), "neg_sell_post_detach_prep": ( t in neg_sell_post_detach_prep_ts if neg_sell_phases_en else None ), "pre_neg_pv_export": ( t in pre_neg_pv_export_ts if neg_sell_phases_en else None ), "neg_evening_before_neg": ( t in neg_evening_push_ts if neg_sell_phases_en else None ), "neg_evening_reserve_anchor": ( any(t == ta for ta, _ in neg_evening_reserve_anchors) if neg_sell_phases_en else None ), "evening_push": ( t in evening_push_ts if om == "AUTO" else None ), "evening_early_export_ban": ( t in evening_early_export_penalty_ts if om == "AUTO" else None ), "night_self_consume_discourage_import": ( t in night_self_consume_discourage_ts if om == "AUTO" else None ), } ) tgt_s = st.safety_soc_target_wh if daytime_en else None # Export floor pro debug snapshot (kopie logiky z constraintů výše). if soc_panel_min[t] < min_soc_wh - 1e-3: export_floor_wh = float(soc_panel_min[t]) export_floor_reason = "deep_relax" else: export_floor_wh = float(arb_base_wh) export_floor_reason = "arb_base" if tgt_s is not None and not high_sell_slot[t]: export_floor_wh = max( export_floor_wh, min( float(battery.soc_max_wh), max(min_soc_wh, float(tgt_s)), ), ) export_floor_reason = "safety_export_floor" soc_bounds_snap.append( { "slot": st.interval_start.isoformat(), "soc_min_wh": float(soc_panel_min[t]), "arb_floor_wh": float(arb_floor_series[t]), "soc_panel_min_wh": float(soc_panel_min[t]), "safety_soc_target_wh": float(tgt_s) if tgt_s is not None else None, "export_soc_floor_wh": float(export_floor_wh), "export_floor_reason": export_floor_reason, "high_sell_slot": bool(high_sell_slot[t]), } ) fb = float(st.future_avoided_buy_czk_kwh or st.buy_price) fs = float(st.future_sell_opportunity_czk_kwh or st.sell_price) bv = max(fb, fs) - float(degradation_cost_effective) bv = max(0.0, min(5.0, bv)) pen_wh = bv / 1000.0 if tgt_s is not None else 0.0 sv = safety_vars[t] sdv = float(pulp.value(sv) or 0.0) if sv is not None else None cshort = next((float(pulp.value(cv) or 0.0) for tt, cv, _p in commit_lp if tt == t), None) objective_terms_snap.append( { "slot": st.interval_start.isoformat(), "buy_price": float(st.buy_price), "sell_price": float(st.sell_price), "future_avoided_buy_czk_kwh": float(st.future_avoided_buy_czk_kwh or st.buy_price), "future_sell_opportunity_czk_kwh": float( st.future_sell_opportunity_czk_kwh or st.sell_price ), "battery_value_czk_kwh": float(bv), "safety_deficit_penalty_czk_per_wh": float(pen_wh) if safety_active[t] else 0.0, "safety_penalty_active": bool(safety_active[t]), "safety_deficit_wh": sdv, "commitment_shortfall_w": cshort, "commitment_penalty_czk_kwh": float(commit_pen) if cshort is not None else None, "acquisition_used_czk_kwh": float(charge_acquisition_czk_kwh), "grid_charge_suppressed_reason": getattr( st, "grid_charge_suppressed_reason", None ), "pv_charge_wh_ahead": float( getattr(st, "pv_charge_wh_ahead", 0.0) or 0.0 ), "min_buy_before_cutoff_czk_kwh": ( float(st.min_buy_before_cutoff_czk_kwh) if getattr(st, "min_buy_before_cutoff_czk_kwh", None) is not None else None ), } ) night0 = slots[0] 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), "soc_headroom_applied_wh": soc_headroom_applied_wh, "operating_mode": operating_mode, "planner_version": planner_version_resolved, "battery": { "usable_capacity_wh": float(battery.usable_capacity_wh), "min_soc_wh": float(battery.min_soc_wh), "reserve_soc_wh": float(getattr(battery, "reserve_soc_wh", 0.0)), "degradation_cost_czk_kwh": float(battery.degradation_cost_czk_kwh), "planner_terminal_soc_value_factor": float(battery.planner_terminal_soc_value_factor), "planner_daytime_charge_target_enabled": daytime_en, "planner_charge_commitment_penalty_czk_kwh": float(commit_pen), "planner_neg_sell_prep_soc_percent": float( getattr(battery, "planner_neg_sell_prep_soc_percent", 80.0) ), "planner_neg_sell_full_soc_tail_slots": int( getattr(battery, "planner_neg_sell_full_soc_tail_slots", 4) ), "planner_neg_sell_vent_min_sell_czk_kwh": getattr( battery, "planner_neg_sell_vent_min_sell_czk_kwh", None ), }, "neg_sell_phases_enabled": bool(neg_sell_phases_en), "neg_sell_b_ramp_v35": bool(neg_sell_phases_en), "neg_sell_day_meta": neg_sell_day_meta if neg_sell_phases_en else None, "t_detach_idx": ( neg_sell_day_meta.get("t_detach_idx") if neg_sell_phases_en else None ), "e_surplus_after_t_wh": ( neg_sell_day_meta.get("e_surplus_after_t_wh") if neg_sell_phases_en else None ), "neg_sell_day_pv_b_usable_wh": ( _neg_sell_day_pv_b_usable_wh(slots, first_neg_sell_idx, battery) if first_neg_sell_idx is not None and neg_sell_phases_en else None ), "pre_neg_pv_export_forecast_ok": bool(pre_neg_pv_export_forecast_ok), "pre_neg_cushion_by_day": pre_neg_cushion_by_day or None, "pre_neg_pv_export_slots": [ slots[i].interval_start.isoformat() for i in sorted(pre_neg_pv_export_ts) ], "neg_evening_before_neg_slots": [ slots[i].interval_start.isoformat() for i in sorted(neg_evening_before_neg_ts) ], "neg_evening_push_slots": [ slots[i].interval_start.isoformat() for i in sorted(neg_evening_push_ts) ], "neg_evening_export_budget_wh": ( float(neg_evening_export_budget_wh) if neg_evening_export_budget_wh is not None else None ), "neg_evening_reserve_soc_anchors": [ { "slot": slots[t_a].interval_start.isoformat(), "target_reserve_soc_wh": float(tgt_wh), } for t_a, tgt_wh in neg_evening_reserve_anchors ], "neg_sell_prep_window_v36": bool(neg_sell_phases_en), "neg_sell_day_pv_usable_wh": ( _neg_sell_day_pv_usable_wh( slots, first_neg_sell_idx, max_charge_power_w=float(battery.max_charge_power_w), charge_efficiency=float(battery.charge_efficiency), ) if first_neg_sell_idx is not None else None ), "load_first_enabled": om == "AUTO", "relaxed_expensive_import": relaxed_expensive_import, "relaxed_neg_buy_charge": relaxed_neg_buy_charge, "relaxed_neg_prep_hold_only": relaxed_neg_prep_hold_only, "relaxed_neg_prep_window": relaxed_neg_prep_window, "neg_sell_phases_fallback": neg_sell_phases_fallback, "relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block, "relaxed_solver_masks": relaxed_solver_masks, "relax_chain": _solver_relax_chain( relaxed_expensive_import=relaxed_expensive_import, relaxed_neg_buy_charge=relaxed_neg_buy_charge, relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only, relaxed_neg_prep_window=relaxed_neg_prep_window, neg_sell_phases_fallback=neg_sell_phases_fallback, relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block, relaxed_solver_masks=relaxed_solver_masks, ), "charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh, "charge_acquisition_cutoff_at": ( slots[0].charge_acquisition_cutoff_at.isoformat() if slots[0].charge_acquisition_cutoff_at is not None else None ), "evening_push_ts": [ slots[i].interval_start.isoformat() for i in sorted(evening_push_ts) ], "evening_push_peak_sell_czk_kwh": ( _evening_push_peak_sell_czk(slots, evening_push_ts) if evening_push_ts else _evening_night_peak_sell_czk(slots) ), "evening_push_hysteresis_retained": bool(evening_push_hysteresis_retained), "evening_push_override_dropped_on_retry": bool( evening_push_ts_override is not None and push_override_raw is None ), "evening_push_override_filtered_empty": bool( push_override_raw and not push_override_eff ), "evening_push_hard_suppressed": bool(evening_push_hard_suppressed), "future_neg_buy_discharge": bool(future_neg_buy_discharge_en), "terminal_neg_buy_weight": float(terminal_neg_buy_weight), "terminal_soc_factor_effective": float(terminal_factor), "pos_sell_pre_neg_buy_ge_exempt_slots": [ slots[i].interval_start.isoformat() for i in sorted(pos_sell_pre_neg_buy_ge_exempt_ts) ], "evening_push_peak_fallback_used": bool( om == "AUTO" and not computed_evening_push_ts and bool(evening_push_ts) and not push_override_eff ), "fixed_horizon_min_sell_czk_kwh": fixed_horizon_min_sell_pre, "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 ), "morning_pre_neg_export_hard": bool( om == "AUTO" and not any_relaxed and bool(morning_pre_neg_export_ts) ), "any_relaxed_solve": bool(any_relaxed), "kv1_evening_push_morning_peak_rule": _kv1_block_export_fixed_evening_push( grid, purchase_fixed=purchase_fixed_pre, ), "night_self_consume_discourage_ts": [ slots[i].interval_start.isoformat() for i in sorted(night_self_consume_discourage_ts) ], "degraded_relaxed_night_ts": [ slots[i].interval_start.isoformat() for i in sorted(degraded_relaxed_night_ts) ], "degraded_evening_export_ts": [ slots[i].interval_start.isoformat() for i in sorted(degraded_evening_export_ts) ], "strict_late_replan_evening_ts": [ slots[i].interval_start.isoformat() for i in sorted(strict_late_replan_evening_ts) ], "strict_late_replan_night_ts": [ slots[i].interval_start.isoformat() for i in sorted(strict_late_replan_night_ts) ], "late_replan_strict_active": bool(late_replan_strict_active), "late_replan_solver_relax": bool(late_replan_solver_relax), }, "masks": masks_snap, "soc_bounds": soc_bounds_snap, "objective_terms": objective_terms_snap, "chosen_slots": { "charge_commitment": charge_commit_snapshot, "high_sell_windows": [slots[i].interval_start.isoformat() for i in sell_rank], "night_window": { "definition": "Europe/Prague 20:00–06:00 projected baseload Wh (fn_load_planning_slots_full)", "target_wh": night0.night_baseload_target_wh, "buffer_wh": night0.night_baseload_buffer_wh, }, }, } return results, duration_ms, solver_snapshot # ============================================================ # Denní plán (15:00) # ============================================================ async def run_daily_plan( site_id: int, db, triggered_by: str = "scheduler:daily", *, planner_version: str | None = None, ) -> tuple[int, int]: """ Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00) a aktualizaci forecastu (14:30). Horizont: `ems.fn_planning_horizon_end` (OTE, strop a práh v SQL). """ now = datetime.now(timezone.utc) horizon_from = _current_slot_start(now) horizon_to = await _planning_horizon_end(site_id, horizon_from, db) if horizon_to is None: horizon_to = horizon_from + timedelta(hours=_DAILY_FALLBACK_HORIZON_HOURS) logger.warning( "[site=%s] Daily plan: fn_planning_horizon_end NULL, fallback %.1fh", site_id, _DAILY_FALLBACK_HORIZON_HOURS, ) logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}") battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = ( await _load_site_context(site_id, db) ) planner_version_resolved = _planner_engine_version(planner_version) slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh) _unlock_late_replan_evening_slots( slots, current_soc_wh=float(soc_wh), reserve_soc_wh=float( getattr(battery, "reserve_soc_wh", getattr(battery, "arb_floor_wh", 0.0)) ), ) om = operating_mode or "AUTO" try: if planner_version_resolved == "v2": results, duration_ms, solver_snapshot = _solve_dispatch_for_version( "v2", slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=om, ) elif om == "AUTO": results, duration_ms, solver_snapshot = solve_dispatch_two_pass( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=om, planner_version=planner_version_resolved, ) else: results, duration_ms, solver_snapshot = solve_dispatch( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=om, planner_version=planner_version_resolved, ) except PlannerSolverError as exc: await _save_failed_planning_run( site_id, horizon_from, horizon_to, run_type="daily", triggered_by=triggered_by, replan_from=None, soc_wh=soc_wh, correction=1.0, db=db, error=exc, slot_count=len(slots), ) raise comparison_ctx = _maybe_add_planner_comparison( slots=slots, battery=battery, heat_pump=hp, grid=grid, ev_sessions=ev_sessions, vehicles=vehicles, current_soc_wh=soc_wh, current_tuv_temp_c=tuv_temp, operating_mode=om, tuv_delta_stats=tuv_stats, active_version=planner_version_resolved, ) if comparison_ctx is not None: peer_results = comparison_ctx["peer_results"] peer_ms = comparison_ctx["peer_ms"] peer_snapshot = comparison_ctx["peer_snapshot"] solver_snapshot["comparison"] = _dispatch_result_comparison( results, duration_ms, planner_version_resolved, peer_results, peer_ms, comparison_ctx["peer_version"], ) slot_inputs = _build_slot_inputs(slots, slots) run_id = await _save_planning_run( site_id, results, horizon_from, horizon_to, run_type="daily", triggered_by=triggered_by, replan_from=None, soc_wh=soc_wh, duration_ms=duration_ms, correction=1.0, db=db, slot_inputs=slot_inputs, solver_snapshot=solver_snapshot, ) if comparison_ctx is not None: compare_snapshot = dict(peer_snapshot) compare_snapshot["comparison_of_run_id"] = run_id compare_snapshot["compare_peer_version"] = comparison_ctx["peer_version"] await _save_planning_run( site_id, comparison_ctx["peer_results"], horizon_from, horizon_to, run_type="daily", triggered_by=f"{triggered_by}:compare", replan_from=None, soc_wh=soc_wh, duration_ms=comparison_ctx["peer_ms"], correction=1.0, db=db, slot_inputs=slot_inputs, activate_run=False, solver_snapshot=compare_snapshot, ) logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms") return run_id, duration_ms # ============================================================ # Rolling replan (každých 15min) # ============================================================ async def run_rolling_replan( site_id: int, db, *, triggered_by: str = "scheduler:rolling", allow_skip: bool = True, planner_version: str | None = None, ) -> tuple[Optional[int], Optional[int]]: """ Rolling replan každých 15 minut. 1. Zjistí aktuální SoC baterie z telemetrie 2. Spočítá korekční faktor FVE forecastu z poslední hodiny 3. Aplikuje korekci na forecast zbytku dne (s útlumem) 4. Spustí solver pro zbývající horizont aktivního plánu 5. Uloží jako nový planning_run (aktivní plán se stane superseded) Pokud allow_skip=True (scheduler) a horizont je vyčerpaný → vrátí (None, None). Pokud allow_skip=False (API) → spustí denní plán jako náhradu. """ now = datetime.now(timezone.utc) replan_from = _current_slot_start(now) planner_version_resolved = _planner_engine_version(planner_version) ar_raw = await db.fetchval( "select ems.fn_planning_active_run($1::int)", site_id, ) ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw) if ar.get("error") == "no_active_plan": logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan") return await run_daily_plan( site_id, db, triggered_by=triggered_by, planner_version=planner_version_resolved, ) horizon_to = await _planning_horizon_end(site_id, replan_from, db) if horizon_to is None: if allow_skip: logger.info( "[site=%s] Rolling replan: fn_planning_horizon_end NULL (krátký OTE horizont), skipping", site_id, ) return None, None logger.warning( "[site=%s] Rolling replan: fn_planning_horizon_end NULL, running daily plan", site_id, ) return await run_daily_plan( site_id, db, triggered_by=triggered_by, planner_version=planner_version_resolved, ) if (horizon_to - replan_from).total_seconds() < 1800: if allow_skip: logger.info(f"[site={site_id}] Rolling replan: horizon almost exhausted, skipping") return None, None logger.info(f"[site={site_id}] Rolling replan: horizon exhausted, running daily plan") return await run_daily_plan( site_id, db, triggered_by=triggered_by, planner_version=planner_version_resolved, ) logger.info( "[site=%s] Rolling replan from %s → %s (tag=%s)", site_id, replan_from, horizon_to, PLANNER_BUILD_TAG, ) battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = ( await _load_site_context(site_id, db) ) if operating_mode != "AUTO": logger.info( "[site=%s] Rolling replan skipped: operating_mode=%s (not AUTO)", site_id, operating_mode, ) return None, None slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh) _unlock_late_replan_evening_slots( slots, current_soc_wh=float(soc_wh), reserve_soc_wh=float( getattr(battery, "reserve_soc_wh", getattr(battery, "arb_floor_wh", 0.0)) ), ) # PV forecast korekce je kanonicky v DB (delta + rolling faktor + decay) a do LP vstupuje přes # ems.fn_load_planning_slots_full. Pro audit/debug ale chceme ukládat i RAW (bez korekcí). correction_factor, correction_log = 1.0, { "window_start": None, "window_end": None, "actual_pv_wh": None, "forecast_pv_wh": None, "correction_factor": None, "reason": "canonical_db", } # RAW PV pro slot_inputs: přímý součet nejnovějších forecast_pv_interval per array/slot (bez delta/rolling). raw_pv_rows = await db.fetchval( "select ems.fn_forecast_pv_slots_range_raw_ab($1::int, $2::timestamptz, $3::timestamptz)", site_id, replan_from, horizon_to, ) raw_pv = raw_pv_rows if isinstance(raw_pv_rows, list) else json.loads(raw_pv_rows) raw_by_ts: dict[str, tuple[int, int]] = {} if isinstance(raw_pv, list): for r in raw_pv: if not isinstance(r, dict): continue ts = r.get("interval_start") if isinstance(ts, str): raw_by_ts[ts] = ( int(r.get("pv_a_forecast_raw_w") or 0), int(r.get("pv_b_forecast_raw_w") or 0), ) slots_raw_pv: list[PlanningSlot] = [] for s in slots: key = s.interval_start.isoformat() pva, pvb = raw_by_ts.get(key, (s.pv_a_forecast_w, s.pv_b_forecast_w)) slots_raw_pv.append(replace(s, pv_a_forecast_w=pva, pv_b_forecast_w=pvb)) commitment_prev = await _load_previous_plan_charge_commitment_prev_w(site_id, slots, db) evening_push_override = await _rolling_evening_push_override( site_id, slots, battery, soc_wh, db ) om = operating_mode or "AUTO" try: if planner_version_resolved == "v2": results, duration_ms, solver_snapshot = _solve_dispatch_for_version( "v2", slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=om, ) elif om == "AUTO": results, duration_ms, solver_snapshot = solve_dispatch_two_pass( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=om, charge_commitment_prev_w=commitment_prev, planner_version=planner_version_resolved, evening_push_ts_override=evening_push_override, ) else: results, duration_ms, solver_snapshot = solve_dispatch( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=om, charge_commitment_prev_w=commitment_prev, planner_version=planner_version_resolved, ) except PlannerSolverError as exc: await _save_failed_planning_run( site_id, replan_from, horizon_to, run_type="rolling", triggered_by=triggered_by, replan_from=replan_from, soc_wh=soc_wh, correction=correction_factor, db=db, error=exc, slot_count=len(slots), ) raise comparison_ctx = _maybe_add_planner_comparison( slots=slots, battery=battery, heat_pump=hp, grid=grid, ev_sessions=ev_sessions, vehicles=vehicles, current_soc_wh=soc_wh, current_tuv_temp_c=tuv_temp, operating_mode=om, tuv_delta_stats=tuv_stats, active_version=planner_version_resolved, charge_commitment_prev_w=commitment_prev, ) if comparison_ctx is not None: peer_results = comparison_ctx["peer_results"] peer_ms = comparison_ctx["peer_ms"] solver_snapshot["comparison"] = _dispatch_result_comparison( results, duration_ms, planner_version_resolved, peer_results, peer_ms, comparison_ctx["peer_version"], ) slot_inputs = _build_slot_inputs(slots_raw_pv, slots) run_id = await _save_planning_run( site_id, results, replan_from, horizon_to, run_type="rolling", triggered_by=triggered_by, replan_from=replan_from, soc_wh=soc_wh, duration_ms=duration_ms, correction=correction_factor, db=db, slot_inputs=slot_inputs, solver_snapshot=solver_snapshot, ) if comparison_ctx is not None: compare_snapshot = dict(comparison_ctx["peer_snapshot"]) compare_snapshot["comparison_of_run_id"] = run_id compare_snapshot["compare_peer_version"] = comparison_ctx["peer_version"] await _save_planning_run( site_id, comparison_ctx["peer_results"], replan_from, horizon_to, run_type="rolling", triggered_by=f"{triggered_by}:compare", replan_from=replan_from, soc_wh=soc_wh, duration_ms=comparison_ctx["peer_ms"], correction=correction_factor, db=db, slot_inputs=slot_inputs, activate_run=False, solver_snapshot=compare_snapshot, ) # Historický log rolling korekce: dřív se psal z Pythonu. Nově se rolling faktor počítá v DB # v kanonické PV řadě; log se případně přesune do DB (todo). logger.info(f"[site={site_id}] Rolling replan done in {duration_ms} ms (pv=canonical_db)") return run_id, duration_ms async def run_plan_api( site_id: int, plan_type: str, db, *, triggered_by: str = "api", planner_version: str | None = None, ) -> tuple[int, int]: """Ruční / UI spuštění plánu. Vždy vrátí (run_id, solver_duration_ms).""" pt = plan_type.lower().strip() planner_version_resolved = _planner_engine_version(planner_version) if pt == "daily": return await run_daily_plan( site_id, db, triggered_by=triggered_by, planner_version=planner_version_resolved, ) if pt == "rolling": rid, ms = await run_rolling_replan( site_id, db, triggered_by=triggered_by, allow_skip=False, planner_version=planner_version_resolved, ) if rid is None or ms is None: raise RuntimeError("Rolling replan did not return a run") return rid, ms raise ValueError(f"Unknown plan_type: {plan_type!r} (use daily or rolling)") # ============================================================ # Pomocné funkce # ============================================================ async def _rolling_evening_push_override( site_id: int, slots: list[PlanningSlot], battery, current_soc_wh: float, db, ) -> set[int] | None: """Rolling: držet evening_push_ts z aktivního runu při malé změně peak sell / SoC.""" if not slots: return None row = await db.fetchrow( """ select solver_params from ems.planning_run where site_id = $1::int and status = 'active' limit 1 """, site_id, ) if row is None or row["solver_params"] is None: return None sp = row["solver_params"] if isinstance(sp, str): sp = json.loads(sp) if not isinstance(sp, dict): return None inputs = sp.get("inputs") if not isinstance(inputs, dict): return None prev_iso = inputs.get("evening_push_ts") if not isinstance(prev_iso, list) or not prev_iso: return None prev_push = _evening_push_ts_from_iso(slots, [str(x) for x in prev_iso]) if not prev_push: return None budget_eligible = { t for seg in _evening_push_soc_budget_calendar_segments(slots, None) for t in seg } if budget_eligible: prev_push = {t for t in prev_push if t in budget_eligible} if not prev_push: return None prev_peak = inputs.get("evening_push_peak_sell_czk_kwh") prev_soc = inputs.get("current_soc_wh") new_peak = _evening_night_peak_sell_czk(slots) if not _evening_push_hysteresis_active( prev_peak_sell_czk=float(prev_peak) if prev_peak is not None else None, new_peak_sell_czk=new_peak, prev_soc_wh=float(prev_soc) if prev_soc is not None else None, current_soc_wh=float(current_soc_wh), usable_capacity_wh=float(battery.usable_capacity_wh), ): return None logger.info( "[site=%s] evening_push hysteresis: retaining %d slot(s), peak_sell=%.3f", site_id, len(prev_push), new_peak, ) return prev_push