diff --git a/.env.example b/.env.example index 4bb42f9..fdb96da 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,5 @@ TELEMETRY_POLL_INTERVAL_SEC=60 PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ PLANNING_CHEAP_PRICE_THRESHOLD=0.85 PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15 +PLANNING_ENGINE_VERSION=v1 # v1 = původní planner, v2 = nová policy větev +PLANNING_ENGINE_COMPARE_ENABLED=false # true = spočítat i druhou verzi a uložit comparison do solver_params diff --git a/backend/app/config.py b/backend/app/config.py index 15f3029..669a720 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/routers/plan.py b/backend/app/routers/plan.py index 83df72a..8e7dca6 100644 --- a/backend/app/routers/plan.py +++ b/backend/app/routers/plan.py @@ -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, ) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 38baf2a..6d8f42f 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -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, ) ) diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index e1e2402..4502a0e 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -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ž: diff --git a/db/routines/R__037_fn_planning_run_commit.sql b/db/routines/R__037_fn_planning_run_commit.sql index c4ea29f..438c536 100644 --- a/db/routines/R__037_fn_planning_run_commit.sql +++ b/db/routines/R__037_fn_planning_run_commit.sql @@ -5,7 +5,8 @@ create or replace function ems.fn_planning_run_commit( p_horizon_start timestamptz, p_horizon_end timestamptz, p_run_meta jsonb, - p_intervals jsonb + p_intervals jsonb, + p_activate_run boolean default true ) returns int language plpgsql @@ -29,7 +30,7 @@ begin p_site_id, p_horizon_start, p_horizon_end, - 'draft', + case when p_activate_run then 'draft' else 'comparison' end, nullif(trim(p_run_meta->>'run_type'), ''), nullif(trim(p_run_meta->>'triggered_by'), ''), case @@ -128,13 +129,16 @@ begin update ems.planning_run set status = 'superseded' - where site_id = p_site_id + where p_activate_run + and site_id = p_site_id and status = 'active' and id <> v_run_id; - update ems.planning_run - set status = 'active' - where id = v_run_id; + if p_activate_run then + update ems.planning_run + set status = 'active' + where id = v_run_id; + end if; return v_run_id; end; diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index b602474..905b65d 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -541,6 +541,8 @@ a nechal si kapacitu na nabití v oknech záporných cen. PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu +PLANNING_ENGINE_VERSION=v1 # v1 = původní planner, v2 = nová policy větev +PLANNING_ENGINE_COMPARE_ENABLED=false # true = spočítat i druhou verzi a uložit comparison do solver_params ``` > **Zelený bonus:** Sazba a platnost jsou v `ems.asset_pv_array` (`green_bonus_*`). Bonus **není** v objective function LP solveru – jako aditivní konstanta k nákladům by optimalizaci stejně neměnil. Příjem z bonusu se počítá v **`fn_fill_audit_interval`** přes `ems.fn_green_bonus_revenue()` a ukládá se do `audit_interval.green_bonus_czk`; v přehledech (např. `vw_audit_daily`) je samostatná položka příjmů vedle nákladů ze sítě. Viz `docs/04-modules/market-prices.md` → sekce Zelený bonus. @@ -565,3 +567,103 @@ highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD) - [ ] EV rozdělení výkonu mezi 2 nabíječky – zatím řešeno jako agregát - [ ] Curtailment pole A – ověřit Modbus registr pro Output Power Limit na Deye SUN-20K - [ ] Testovat solver na reálných datech – ověřit čas výpočtu pro 36h horizont (144 slotů) + +--- + +## Planner v2 + +Tahle sekce popisuje návrh druhé verze planneru. Cíl je mít samostatný solver, který bude vycházet ze stejného vstupu a bude zapisovat do stejného `planning_interval`, ale provozní pravidla budou čitelné a striktně dané zadáním. + +### Význam hranic SoC + +- `reserve_soc_percent` = ranní cílová hranice, na kterou se má baterie dobít, pokud to denní forecast a ceny umožňují +- `min_soc_percent` = fyzická / TOU podlaha, pod kterou baterie nesmí klesnout +- `reserve_soc_percent` je tedy provozní kotva pro den, zatímco `min_soc_percent` je tvrdé minimum +- `reserve_soc_percent` není predikce noční spotřeby; jen znamená „než začne export z FVE do sítě, drž baterii aspoň sem“ + +### Základní pravidla v2 + +#### Ráno + +- pokud denní forecast dává dostatek výroby nebo levných hodin, planner dobije baterii minimálně na `reserve_soc_percent` +- tato rezerva slouží jako ochrana proti neplánované spotřebě během dne +- `min_soc_percent` se v ranní fázi nepoužívá jako cíl, ale jen jako spodní limit + +#### Záporná nákupní cena + +- při `buy_price < 0` má prioritu nabíjení ze sítě +- cílem je uložit levnou energii pro pozdější dražší prodej +- to ale neznamená, že se má baterie dobít hned v první záporné hodině; pokud jsou v horizontu ještě zápornější ceny, může být lepší nabíjet později +- nabíjení ze sítě je omezené jen fyzickými limity baterie a připojení + +#### Záporná prodejní cena + +- při `sell_price < 0` je export do sítě zakázán +- řiditelná FVE A se může škrtit +- neřiditelná FVE B se neškrtí, pouze se povinně zohlední v bilanci +- baterie se nejdřív nabíjí z přebytku FVE, potom se využije flexibilní spotřeba +- pokud je potřeba uvolnit místo pro pozdější extrémně záporné ceny, může planner baterii předem záměrně mírně vybít až na bezpečnou ekonomickou podlahu + +#### Nezáporná prodejní cena + +- věta „prodám vše“ v tomto návrhu neznamená povinné okamžité vybití baterie +- znamená pouze to, že pokud je baterie už plná z levných nebo záporných hodin, přebytek FVE A jde do sítě +- pokud ještě dává větší smysl uložit energii pro pozdější dražší prodej, má přednost uložení do baterie +- dynamické zátěže jako TUV a wallbox zůstávají plně součástí bilance; jejich spotřeba může být využita jako další „úložiště“ levné energie + +#### Prodej z baterie + +- při cenové špičce má baterie prodávat do sítě +- v2 má využít baterii jako arbitrážní zásobník mezi levnými a drahými okny +- vybíjení nesmí klesnout pod `min_soc_percent` + +#### PV A a PV B + +- PV A je řiditelná a může být curtailovaná +- PV B je neřiditelná a nikdy se neplánuje jako curtailovaná výroba +- PV B je vždy pevný vstup do bilance + +#### BA81 / GEN cutoff + +- v lokalitě BA81 může být zapnutý `deye_gen_microinverter_cutoff_enabled` +- pokud by při záporné prodejní ceně nebo no-export politice vznikal nežádoucí export z GEN portu, planner v2 musí umět aktivovat cutoff mikroinvertoru +- cutoff má být součást rozhodnutí planneru, ne dodatečná heuristika v exporteru + +### Co má být v plánu zapsané + +Planner v2 má do `planning_interval` zapisovat stejné základní položky jako dosavadní verze: + +- `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` +- `effective_buy_price` +- `effective_sell_price` + +### Implementační oddělení od v1 + +- v1 zůstává beze změny +- v2 bude samostatný modul planneru +- přepnutí mezi v1 a v2 bude na úrovni orchestrace nebo konfigurace lokality +- exportér i control pipeline mají dál číst standardní výstup z `planning_interval` +- pokud je zapnuté `PLANNING_ENGINE_COMPARE_ENABLED`, backend spočítá obě verze nad stejným vstupem, aktivní verzi zapíše do plánu a druhou uloží i jako samostatný read-only `planning_run` se stavem `comparison` +- compare čtení jde přes `GET /api/v1/sites/{site_id}/plan/compare` a endpoint páruje compare run na aktivní run přes `comparison_of_run_id` +- FE stránka `frontend/src/pages/Planning.tsx` ukazuje souhrn aktivní verze, compare verze, slotové rozdíly a compare křivku baterie v grafu +- fyzicky se na střídač aplikuje jen aktivní plán; compare běh slouží jen pro audit a vizualizaci + +### Shrnutí v jedné větě + +Planner v2 má dělat přesně toto: + +- ráno držet baterii na `reserve_soc_percent` +- při záporných nákupních cenách nabíjet ze sítě +- při záporných prodejních cenách zakázat export +- při cenových špičkách prodávat z baterie +- PV A škrtit jen když je to nutné +- PV B nikdy neškrtit +- BA81 řešit přes GEN cutoff diff --git a/docs/04-modules/provozni-rezimy-checklist.md b/docs/04-modules/provozni-rezimy-checklist.md index e8dec73..155e96c 100644 --- a/docs/04-modules/provozni-rezimy-checklist.md +++ b/docs/04-modules/provozni-rezimy-checklist.md @@ -290,3 +290,16 @@ To znamená: - live registry: `backend/app/routers/sites.py` - FE plánování: `frontend/src/pages/Planning.tsx` - FE live registry: `frontend/src/components/ControlPanel.tsx` + +## 10. Planner v2 + +Pro přesné zadání nové verze planneru se řiď sekcí **Planner v2** v [`docs/04-modules/planning.md`](/home/dusan.vojacek@triglav.local/Documents/AI-projekty/ems-cursor/docs/04-modules/planning.md). + +Krátké shrnutí: + +- `reserve_soc_percent` = ranní cílová rezerva +- `min_soc_percent` = tvrdá TOU / fyzická podlaha +- PV A je řiditelná, PV B je neřiditelná +- při záporné prodejní ceně se zakazuje export +- v BA81 se cutoff mikroinvertoru řeší přímo v planneru +- pokud je zapnuté `PLANNING_ENGINE_COMPARE_ENABLED`, backend spočítá i druhou verzi nad stejným vstupem, uloží ji jako read-only `planning_run` se stavem `comparison`, napáruje ji na aktivní run přes `comparison_of_run_id` a FE ji ukáže v `/plan/compare`; fyzicky se aplikuje jen aktivní plán diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index c891c2f..aa1a6c0 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -6,7 +6,7 @@ import type { SitePvForecastCalibrationRow, } from '../types/siteConfiguration' import type { Notification } from '../types/dashboard' -import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan' +import type { CurrentPlanResponse, PlanningCompareResponse, RunPlanResponse } from '../types/plan' const client: AxiosInstance = axios.create({ baseURL: '/api/v1', @@ -124,6 +124,13 @@ export async function getCurrentPlan(siteId: number): Promise { + const { data } = await client.get(`/sites/${siteId}/plan/compare`, { + timeout: 60_000, + }) + return data +} + /** Řada FVE předpovědi (součet polí) po 15 min — doplnění grafu za horizont uloženého plánu. */ export type ForecastPvSlotRow = { interval_start: string diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index f84c57e..017cdfc 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -25,13 +25,18 @@ import { import { getCurrentPlan, + getPlanCompare, postImportSitePrices, postRunForecast, postRunPlan, } from '../api/backend' import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants' import { useSiteStatus } from '../hooks/useSiteStatus' -import type { CurrentPlanResponse, PlanningIntervalDto } from '../types/plan' +import type { + CurrentPlanResponse, + PlanningCompareResponse, + PlanningIntervalDto, +} from '../types/plan' const TZ = 'Europe/Prague' @@ -389,10 +394,21 @@ type ChartRow = { pv_a_w: number battery_soc_target_pct: number | null battery_setpoint_w: number + compare_battery_setpoint_w?: number | null effective_buy_price: number | null raw: PlanningIntervalDto } +function recordNumber(value: unknown): number | null { + if (value == null) return null + const n = Number(value) + return Number.isFinite(n) ? n : null +} + +function recordString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + type PlanPrepActionsProps = { prepAction: null | 'import' | 'forecast' | 'init' replanning: boolean @@ -494,6 +510,7 @@ function PlanTooltip({ const soc = p.battery_soc_target_pct const exportLimit = i.export_limit_w const exportMode = i.export_mode ?? 'NONE' + const compareBattery = p.compare_battery_setpoint_w return (
{formatLocal(i.interval_start)}
@@ -520,6 +537,7 @@ function PlanTooltip({
SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}
Dům: {i.load_baseline_w ?? '—'} W
Baterie: {i.battery_setpoint_w ?? '—'} W
+ {compareBattery != null ?
Compare baterie: {compareBattery} W
: null}
Síť (čistý EM): {i.grid_setpoint_w ?? '—'} W
TČ: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}
@@ -592,8 +610,11 @@ export default function Planning() { const siteId = site?.site_id ?? null const [data, setData] = useState(null) + const [compareData, setCompareData] = useState(null) const [loading, setLoading] = useState(true) + const [compareLoading, setCompareLoading] = useState(true) const [error, setError] = useState(null) + const [compareError, setCompareError] = useState(null) const [replanning, setReplanning] = useState(false) const [prepAction, setPrepAction] = useState(null) const [importDate, setImportDate] = useState<'today' | 'tomorrow'>('tomorrow') @@ -604,10 +625,32 @@ export default function Planning() { const load = useCallback(async () => { if (siteId == null) return setLoading(true) + setCompareLoading(true) setError(null) + setCompareError(null) try { - const res = await getCurrentPlan(siteId) - setData(res) + const [planRes, compareRes] = await Promise.allSettled([ + getCurrentPlan(siteId), + getPlanCompare(siteId), + ]) + + if (planRes.status === 'fulfilled') { + setData(planRes.value) + } else if (axios.isAxiosError(planRes.reason) && planRes.reason.response?.status === 404) { + setData({ run: null, intervals: [], summary: null }) + setError(null) + } else { + throw planRes.reason + } + + if (compareRes.status === 'fulfilled') { + setCompareData(compareRes.value) + } else if (axios.isAxiosError(compareRes.reason) && compareRes.reason.response?.status === 404) { + setCompareData(null) + } else { + setCompareError(axiosDetail(compareRes.reason)) + setCompareData(null) + } } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 404) { setData({ run: null, intervals: [], summary: null }) @@ -618,6 +661,7 @@ export default function Planning() { } } finally { setLoading(false) + setCompareLoading(false) } }, [siteId]) @@ -679,16 +723,19 @@ export default function Planning() { }, [chartIntervals, chartHorizonH]) const chartRows: ChartRow[] = useMemo(() => { + const compareIntervals = compareData?.comparison?.intervals ?? [] + const compareMap = new Map(compareIntervals.map((i) => [i.interval_start, i])) return chartIntervals.map((i) => ({ label: formatLocalTime(i.interval_start), ts: slotStartUtcMs(i.interval_start), pv_a_w: pvChartFveW(i, nowMs), battery_soc_target_pct: i.battery_soc_target_pct, battery_setpoint_w: i.battery_setpoint_w ?? 0, + compare_battery_setpoint_w: compareMap.get(i.interval_start)?.battery_setpoint_w ?? null, effective_buy_price: i.effective_buy_price, raw: i, })) - }, [chartIntervals, nowMs]) + }, [chartIntervals, nowMs, compareData?.comparison?.intervals]) async function onReplan() { if (siteId == null) return @@ -795,6 +842,10 @@ export default function Planning() { const correctionPct = run?.forecast_correction_factor != null ? run.forecast_correction_factor * 100 : null const correctionUp = (run?.forecast_correction_factor ?? 1) >= 1 + const compareActiveSummary = compareData?.active?.summary ?? null + const comparePeerSummary = compareData?.comparison?.summary ?? null + const compareDiff = compareData?.diff ?? null + const compareSlotDiffs = compareData?.slot_diffs ?? [] return (
@@ -956,6 +1007,139 @@ export default function Planning() { {/* Sekce 2 */} +
+

Porovnání v1 / v2

+ {compareLoading ? ( +
+ Načítám compare… +
+ ) : compareError && !compareData ? ( +
+ {compareError} +
+ ) : compareData ? ( +
+ {compareError && ( +
+ {compareError} +
+ )} +
+
+

Aktivní verze

+

+ {recordString(compareData.active.run?.run_type) ?? '—'} +

+

+ Solver: {recordNumber(compareData.active.run?.solver_duration_ms) != null + ? `${recordNumber(compareData.active.run?.solver_duration_ms)} ms` + : '—'} +

+

+ Náklady: {recordNumber(compareActiveSummary?.total_expected_cost_czk) != null + ? `${recordNumber(compareActiveSummary?.total_expected_cost_czk)?.toFixed(2)} Kč` + : '—'} +

+
+
+

Compare verze

+

+ {recordString(compareData.comparison.run?.run_type) ?? '—'} +

+

+ Solver: {recordNumber(compareData.comparison.run?.solver_duration_ms) != null + ? `${recordNumber(compareData.comparison.run?.solver_duration_ms)} ms` + : '—'} +

+

+ Náklady: {recordNumber(comparePeerSummary?.total_expected_cost_czk) != null + ? `${recordNumber(comparePeerSummary?.total_expected_cost_czk)?.toFixed(2)} Kč` + : '—'} +

+
+
+

Rozdíl

+

+ {recordNumber(compareDiff?.total_expected_cost_czk) != null + ? `${recordNumber(compareDiff?.total_expected_cost_czk)?.toFixed(2)} Kč` + : '—'} +

+

+ Změněných slotů:{' '} + {recordNumber(compareDiff?.changed_slots) != null ? recordNumber(compareDiff?.changed_slots) : '—'} +

+

+ Aktivní / compare export sloty:{' '} + {recordNumber(compareDiff?.active_export_slots) != null + ? `${recordNumber(compareDiff?.active_export_slots)} / ${recordNumber(compareDiff?.comparison_export_slots)}` + : '—'} +

+
+
+ + {compareSlotDiffs.length > 0 ? ( +
+ + + + + + + + + + + + + + {compareSlotDiffs.slice(0, 48).map((row) => ( + + + + + + + + + + ))} + +
SlotAktivní bat. WCompare bat. WAktivní grid WCompare grid WAktivní exportCompare export
+ {formatLocalTime(row.interval_start)} + + {recordNumber(row.active.battery_setpoint_w) != null + ? recordNumber(row.active.battery_setpoint_w) + : '—'} + + {recordNumber(row.comparison.battery_setpoint_w) != null + ? recordNumber(row.comparison.battery_setpoint_w) + : '—'} + + {recordNumber(row.active.grid_setpoint_w) != null + ? recordNumber(row.active.grid_setpoint_w) + : '—'} + + {recordNumber(row.comparison.grid_setpoint_w) != null + ? recordNumber(row.comparison.grid_setpoint_w) + : '—'} + + {recordString(row.active.export_mode) ?? '—'} + + {recordString(row.comparison.export_mode) ?? '—'} +
+
+ ) : ( +

Compare běh je uložen, ale nemá slotové rozdíly k zobrazení.

+ )} +
+ ) : ( +

+ Compare plán zatím není k dispozici. Spusťte plánování s aktivním režimem v1/v2 compare. +

+ )} +
+ + {/* Sekce 3 */}

Graf plánu

))} + - {/* Sekce 3 */} + {/* Sekce 4 */}

Tabulka slotů

diff --git a/frontend/src/types/plan.ts b/frontend/src/types/plan.ts index e3acac5..a670466 100644 --- a/frontend/src/types/plan.ts +++ b/frontend/src/types/plan.ts @@ -59,6 +59,25 @@ export type CurrentPlanResponse = { summary: PlanningSummaryDto | null } +export type PlanningBundleDto = { + run: Record + intervals: PlanningIntervalDto[] + summary: Record +} + +export type PlanningSlotDiffDto = { + interval_start: string + active: Record + comparison: Record +} + +export type PlanningCompareResponse = { + active: PlanningBundleDto + comparison: PlanningBundleDto + diff: Record + slot_diffs: PlanningSlotDiffDto[] +} + export type RunPlanResponse = { run_id: number solver_duration_ms: number