Files
ems/backend/services/planning_engine.py
Dusan Vojacek a8b4342099 Fáze 3.4: router verzí plánovače — v2 zapojeno do shadow porovnání
_solve_dispatch_for_version: 'v2' → services.planning.solver_v2 (čisté jádro),
jinak v1 two-pass; chyby v2 balené do PlannerSolverError (failure pipeline).
Zapojeno do _maybe_add_planner_comparison (peer) i aktivních běhů
run_daily_plan / run_rolling_replan (gated PLANNING_ENGINE_VERSION).

Aktivace shadow: env PLANNING_ENGINE_COMPARE_ENABLED=true (aktivní zůstává v1,
v2 se počítá paralelně, srovnání v planning_run.solver_params.comparison).
Přepnutí: PLANNING_ENGINE_VERSION=v2. Default beze změny — golden 7/7,
plná sada 245 passed (1 předexistující reg340 fail), 4 xfailed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:23:24 +02:00

4033 lines
157 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ((maxload)/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:0006: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