planner v2 vc. porovnani
This commit is contained in:
@@ -19,6 +19,8 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pulp
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -64,6 +66,140 @@ def _timestamptz_from_db(val: object) -> Optional[datetime]:
|
||||
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||
|
||||
|
||||
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 _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
|
||||
peer_results, peer_ms, peer_snapshot = 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=peer_version,
|
||||
)
|
||||
# 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(
|
||||
@@ -453,6 +589,7 @@ def solve_dispatch(
|
||||
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,
|
||||
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
|
||||
"""
|
||||
LP solver pro dispatch optimalizaci.
|
||||
@@ -462,6 +599,8 @@ def solve_dispatch(
|
||||
if T < 1:
|
||||
raise RuntimeError("solve_dispatch requires at least one slot")
|
||||
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)
|
||||
@@ -577,7 +716,7 @@ def solve_dispatch(
|
||||
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 = 5.0
|
||||
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.
|
||||
@@ -639,7 +778,10 @@ def solve_dispatch(
|
||||
# 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)
|
||||
and (
|
||||
bool(slots[t].is_daytime_pv_surplus_slot)
|
||||
or (planner_v2 and float(slots[t].buy_price) < 0.0)
|
||||
)
|
||||
and not hs
|
||||
)
|
||||
safety_active.append(active)
|
||||
@@ -1063,6 +1205,7 @@ def solve_dispatch(
|
||||
"inputs": {
|
||||
"current_soc_wh": float(current_soc_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),
|
||||
@@ -1093,7 +1236,13 @@ def solve_dispatch(
|
||||
# Denní plán (15:00)
|
||||
# ============================================================
|
||||
|
||||
async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily") -> tuple[int, int]:
|
||||
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).
|
||||
@@ -1115,13 +1264,40 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
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)
|
||||
|
||||
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=operating_mode or "AUTO",
|
||||
planner_version=planner_version_resolved,
|
||||
)
|
||||
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=operating_mode or "AUTO",
|
||||
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(
|
||||
@@ -1139,6 +1315,26 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
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
|
||||
|
||||
@@ -1153,6 +1349,7 @@ async def run_rolling_replan(
|
||||
*,
|
||||
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.
|
||||
@@ -1167,6 +1364,7 @@ async def run_rolling_replan(
|
||||
"""
|
||||
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)",
|
||||
@@ -1175,7 +1373,12 @@ async def run_rolling_replan(
|
||||
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)
|
||||
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:
|
||||
@@ -1189,14 +1392,24 @@ async def run_rolling_replan(
|
||||
"[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)
|
||||
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)
|
||||
return await run_daily_plan(
|
||||
site_id,
|
||||
db,
|
||||
triggered_by=triggered_by,
|
||||
planner_version=planner_version_resolved,
|
||||
)
|
||||
|
||||
logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}")
|
||||
|
||||
@@ -1248,7 +1461,33 @@ async def run_rolling_replan(
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=operating_mode or "AUTO",
|
||||
charge_commitment_prev_w=commitment_prev,
|
||||
planner_version=planner_version_resolved,
|
||||
)
|
||||
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=operating_mode or "AUTO",
|
||||
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(
|
||||
@@ -1266,6 +1505,26 @@ async def run_rolling_replan(
|
||||
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).
|
||||
@@ -1280,14 +1539,25 @@ async def run_plan_api(
|
||||
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)
|
||||
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
|
||||
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")
|
||||
@@ -1583,6 +1853,7 @@ async def _save_planning_run(
|
||||
soc_wh, duration_ms, correction, db,
|
||||
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
||||
*,
|
||||
activate_run: bool = True,
|
||||
solver_snapshot: Optional[dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
|
||||
@@ -1637,7 +1908,7 @@ async def _save_planning_run(
|
||||
"""
|
||||
select ems.fn_planning_run_commit(
|
||||
$1::int, $2::timestamptz, $3::timestamptz,
|
||||
$4::jsonb, $5::jsonb
|
||||
$4::jsonb, $5::jsonb, $6::boolean
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
@@ -1645,5 +1916,6 @@ async def _save_planning_run(
|
||||
horizon_to,
|
||||
json.dumps(run_meta, default=str),
|
||||
json.dumps(intervals, default=str),
|
||||
activate_run,
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user