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