planner v2 vc. porovnani
Some checks failed
CI and deploy / migration-check (push) Failing after 20s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-15 23:03:32 +02:00
parent d89d8b1e3a
commit 7490ac3d70
11 changed files with 900 additions and 29 deletions

View File

@@ -45,6 +45,8 @@ class Settings(BaseSettings):
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)
planning_cheap_price_threshold: float = Field(default=0.85)
planning_expensive_price_threshold: float = Field(default=1.15)
planning_engine_version: str = Field(default="v1")
planning_engine_compare_enabled: bool = Field(default=False)
@lru_cache

View File

@@ -41,12 +41,117 @@ class PlanningIntervalDto(BaseModel):
)
class CurrentPlanResponseModel(BaseModel):
class PlanningBundleDto(BaseModel):
run: dict[str, Any]
intervals: list[PlanningIntervalDto]
summary: dict[str, Any]
class CurrentPlanResponseModel(PlanningBundleDto):
pass
class ComparisonSlotDiffDto(BaseModel):
interval_start: str
active: dict[str, Any]
comparison: dict[str, Any]
class PlanningCompareResponseModel(BaseModel):
active: PlanningBundleDto
comparison: PlanningBundleDto
diff: dict[str, Any]
slot_diffs: list[ComparisonSlotDiffDto]
def _bundle_from_payload(payload: dict[str, Any], *, run_key: str) -> PlanningBundleDto:
run_raw = payload.get(run_key) or {}
if not isinstance(run_raw, dict):
run_raw = {}
intervals_raw = payload.get("intervals") or []
if not isinstance(intervals_raw, list):
intervals_raw = []
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
summary = payload.get("summary") or {}
if not isinstance(summary, dict):
summary = {}
return PlanningBundleDto(run=run_raw, intervals=intervals, summary=summary)
def _bundle_from_current(payload: dict[str, Any]) -> PlanningBundleDto:
return _bundle_from_payload(payload, run_key="run")
def _bundle_from_debug(payload: dict[str, Any]) -> PlanningBundleDto:
return _bundle_from_payload(payload, run_key="planning_run")
def _extract_run_id(bundle: PlanningBundleDto) -> int | None:
raw = bundle.run.get("id")
try:
return int(raw)
except (TypeError, ValueError):
return None
def _build_plan_diff(
active: PlanningBundleDto,
comparison: PlanningBundleDto,
) -> tuple[dict[str, Any], list[ComparisonSlotDiffDto]]:
active_by_ts = {i.interval_start: i for i in active.intervals}
compare_by_ts = {i.interval_start: i for i in comparison.intervals}
diffs: list[ComparisonSlotDiffDto] = []
interesting_keys = (
"battery_setpoint_w",
"battery_soc_target_pct",
"grid_setpoint_w",
"export_limit_w",
"export_mode",
"deye_physical_mode",
"deye_gen_cutoff_enabled",
"pv_a_curtailed_w",
"expected_cost_czk",
)
for ts, a in active_by_ts.items():
b = compare_by_ts.get(ts)
if b is None:
continue
active_payload = a.model_dump()
comparison_payload = b.model_dump()
if any(active_payload.get(k) != comparison_payload.get(k) for k in interesting_keys):
diffs.append(
ComparisonSlotDiffDto(
interval_start=ts,
active={k: active_payload.get(k) for k in interesting_keys},
comparison={k: comparison_payload.get(k) for k in interesting_keys},
)
)
def _summary_num(bundle: PlanningBundleDto, key: str) -> float:
raw = bundle.summary.get(key)
try:
return float(raw) if raw is not None else 0.0
except (TypeError, ValueError):
return 0.0
active_cost = _summary_num(active, "total_expected_cost_czk")
compare_cost = _summary_num(comparison, "total_expected_cost_czk")
diff = {
"active_total_expected_cost_czk": active_cost,
"comparison_total_expected_cost_czk": compare_cost,
"total_expected_cost_czk": round(active_cost - compare_cost, 4),
"absolute_total_expected_cost_czk": round(abs(active_cost - compare_cost), 4),
"active_charge_slots": int(_summary_num(active, "charge_slots")),
"comparison_charge_slots": int(_summary_num(comparison, "charge_slots")),
"active_discharge_slots": int(_summary_num(active, "discharge_slots")),
"comparison_discharge_slots": int(_summary_num(comparison, "discharge_slots")),
"active_export_slots": int(_summary_num(active, "export_slots")),
"comparison_export_slots": int(_summary_num(comparison, "export_slots")),
"changed_slots": len(diffs),
}
return diff, diffs
@router.get("/current", response_model=CurrentPlanResponseModel)
async def get_current_plan(
site_id: int,
@@ -69,14 +174,85 @@ async def get_current_plan(
if bundle.get("error") == "no_active_plan":
raise HTTPException(status_code=404, detail="No active plan")
intervals_raw = bundle.get("intervals") or []
if not isinstance(intervals_raw, list):
intervals_raw = []
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
plan = _bundle_from_current(bundle)
return CurrentPlanResponseModel(
run=bundle.get("run") or {},
intervals=intervals,
summary=bundle.get("summary") or {},
run=plan.run,
intervals=plan.intervals,
summary=plan.summary,
)
@router.get("/compare", response_model=PlanningCompareResponseModel)
async def get_plan_compare(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> PlanningCompareResponseModel:
async with pool.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
active_raw = await fetch_json(
conn,
"select ems.fn_plan_current_bundle($1::int)",
site_id,
)
if not isinstance(active_raw, dict):
active_raw = json.loads(active_raw)
if active_raw.get("error") == "no_active_plan":
raise HTTPException(status_code=404, detail="No active plan")
active = _bundle_from_current(active_raw)
active_run_id = _extract_run_id(active)
if active_run_id is None:
raise HTTPException(status_code=404, detail="No active plan")
compare_run_id = await conn.fetchval(
"""
select pr.id
from ems.planning_run pr
where pr.site_id = $1::int
and pr.status = 'comparison'
and (pr.solver_params->>'comparison_of_run_id')::int = $2::int
order by pr.created_at desc
limit 1
""",
site_id,
active_run_id,
)
if compare_run_id is None:
compare_run_id = await conn.fetchval(
"""
select pr.id
from ems.planning_run pr
where pr.site_id = $1::int
and pr.status = 'comparison'
order by pr.created_at desc
limit 1
""",
site_id,
)
if compare_run_id is None:
raise HTTPException(status_code=404, detail="No comparison plan")
compare_raw = await fetch_json(
conn,
"select ems.fn_planning_run_debug($1::int)",
int(compare_run_id),
)
if not isinstance(compare_raw, dict):
compare_raw = json.loads(compare_raw)
if compare_raw is None:
raise HTTPException(status_code=404, detail="No comparison plan")
comparison = _bundle_from_debug(compare_raw)
diff, slot_diffs = _build_plan_diff(active, comparison)
return PlanningCompareResponseModel(
active=active,
comparison=comparison,
diff=diff,
slot_diffs=slot_diffs,
)

View File

@@ -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,
)
)

View File

@@ -7,8 +7,10 @@ from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from services.planning_engine import (
DispatchResult,
PlanningSlot,
_dynamic_arb_floor_wh_series,
_dispatch_result_comparison,
_prewindow_deferral_slots,
_slots_until_buy_le_threshold,
_slots_until_sell_lt,
@@ -205,6 +207,83 @@ def replace_slot(
class PlanningDispatchMilpTests(unittest.TestCase):
def test_dispatch_result_comparison_marks_changed_slots(self) -> None:
dt = datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc)
active = [
DispatchResult(
interval_start=dt,
battery_setpoint_w=1000,
battery_soc_target=50.0,
grid_setpoint_w=0,
export_limit_w=0,
export_mode="NONE",
deye_physical_mode="PASSIVE",
deye_gen_cutoff_enabled=False,
ev1_setpoint_w=None,
ev2_setpoint_w=None,
ev1_via_bat_w=0,
ev2_via_bat_w=0,
heat_pump_enabled=False,
heat_pump_setpoint_w=0,
pv_a_curtailed_w=0,
expected_cost_czk=1.0,
effective_buy_price=1.0,
effective_sell_price=1.0,
is_predicted_price=False,
)
]
peer = [
DispatchResult(
interval_start=dt,
battery_setpoint_w=2000,
battery_soc_target=55.0,
grid_setpoint_w=-1000,
export_limit_w=1000,
export_mode="PV_SURPLUS",
deye_physical_mode="SELL",
deye_gen_cutoff_enabled=True,
ev1_setpoint_w=None,
ev2_setpoint_w=None,
ev1_via_bat_w=0,
ev2_via_bat_w=0,
heat_pump_enabled=False,
heat_pump_setpoint_w=0,
pv_a_curtailed_w=200,
expected_cost_czk=2.0,
effective_buy_price=1.0,
effective_sell_price=1.0,
is_predicted_price=False,
)
]
cmp = _dispatch_result_comparison(active, 10, "v1", peer, 12, "v2")
self.assertEqual(cmp["active"]["planner_version"], "v1")
self.assertEqual(cmp["peer"]["planner_version"], "v2")
self.assertEqual(cmp["diff"]["changed_slots"], 1)
self.assertEqual(len(cmp["slot_diffs"]), 1)
def test_planner_version_is_recorded_in_snapshot(self) -> None:
slots = [_slot(load=500, buy=1.0, sell=1.0, pv_a=0, pv_b=0) for _ in range(2)]
battery = _battery()
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[],
vehicles,
current_soc_wh=0.5 * battery.usable_capacity_wh,
current_tuv_temp_c=50.0,
planner_version="v2",
)
self.assertEqual(len(results), 2)
self.assertEqual(snap["inputs"]["planner_version"], "v2")
def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None:
"""
Když: