"""REST API – aktivní plán a ruční přepočet.""" from datetime import datetime from typing import Annotated, Any, Literal import asyncpg from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from app.deps import get_pg_pool from services.planning_engine import run_plan_api router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"]) class PlanningRunOut(BaseModel): id: int created_at: datetime run_type: str horizon_start: datetime horizon_end: datetime forecast_correction_factor: float | None = None solver_duration_ms: int | None = None class PlanningIntervalOut(BaseModel): interval_start: datetime battery_setpoint_w: int | None = None battery_soc_target_pct: float | None = None grid_setpoint_w: int | None = None ev1_setpoint_w: int | None = None ev2_setpoint_w: int | None = None heat_pump_enabled: bool | None = None pv_a_curtailed_w: int | None = None expected_cost_czk: float | None = None effective_buy_price: float | None = None effective_sell_price: float | None = None pv_forecast_total_w: int | None = Field( default=None, description="Součet FVE forecast A+B pro graf (k aktuálnímu slotu z DB).", ) load_baseline_w: int | None = Field( default=None, description="Bazální spotřeba forecast pro graf.", ) class PlanningSummaryOut(BaseModel): total_expected_cost_czk: float total_pv_curtailed_kwh: float charge_slots: int discharge_slots: int export_slots: int class CurrentPlanResponse(BaseModel): run: PlanningRunOut | None intervals: list[PlanningIntervalOut] summary: PlanningSummaryOut | None class RunPlanResponse(BaseModel): run_id: int solver_duration_ms: int def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut: total_cost = 0.0 curtailed_wh = 0.0 charge_slots = 0 discharge_slots = 0 export_slots = 0 for row in intervals: ec = row.get("expected_cost_czk") if ec is not None: total_cost += float(ec) c = row.get("pv_a_curtailed_w") or 0 curtailed_wh += int(c) * 0.25 b = row.get("battery_setpoint_w") if b is not None: if int(b) > 0: charge_slots += 1 elif int(b) < 0: discharge_slots += 1 g = row.get("grid_setpoint_w") if g is not None and int(g) < 0: export_slots += 1 return PlanningSummaryOut( total_expected_cost_czk=round(total_cost, 4), total_pv_curtailed_kwh=round(curtailed_wh / 1000.0, 6), charge_slots=charge_slots, discharge_slots=discharge_slots, export_slots=export_slots, ) @router.get("/current", response_model=CurrentPlanResponse) async def get_current_plan( site_id: int, pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], ) -> CurrentPlanResponse: async with pool.acquire() as conn: exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id) if not exists: raise HTTPException(status_code=404, detail="Site not found") run_row = await conn.fetchrow( """ SELECT id, created_at, run_type, horizon_start, horizon_end, forecast_correction_factor, solver_duration_ms FROM ems.planning_run WHERE site_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 1 """, site_id, ) if not run_row: return CurrentPlanResponse(run=None, intervals=[], summary=None) run_id = run_row["id"] int_rows = await conn.fetch( """ SELECT pi.interval_start, pi.battery_setpoint_w, pi.battery_soc_target_pct, pi.grid_setpoint_w, pi.ev1_setpoint_w, pi.ev2_setpoint_w, pi.heat_pump_enabled, pi.pv_a_curtailed_w, pi.expected_cost_czk, pi.effective_buy_price, pi.effective_sell_price, COALESCE(fa.power_w, 0) + COALESCE(fb.power_w, 0) AS pv_forecast_total_w, COALESCE(cbi.power_w, 500) AS load_baseline_w FROM ems.planning_interval pi LEFT JOIN LATERAL ( SELECT fpi.power_w FROM ems.forecast_pv_interval fpi JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id WHERE fpr.site_id = $2 AND apa.code = 'pv-a' AND fpi.interval_start = pi.interval_start AND fpr.status = 'ok' ORDER BY fpr.created_at DESC LIMIT 1 ) fa ON true LEFT JOIN LATERAL ( SELECT fpi.power_w FROM ems.forecast_pv_interval fpi JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id WHERE fpr.site_id = $2 AND apa.code = 'pv-b' AND fpi.interval_start = pi.interval_start AND fpr.status = 'ok' ORDER BY fpr.created_at DESC LIMIT 1 ) fb ON true LEFT JOIN ems.consumption_baseline_interval cbi ON cbi.site_id = $2 AND cbi.interval_start = pi.interval_start AND cbi.data_type = 'forecast' WHERE pi.run_id = $1 ORDER BY pi.interval_start """, run_id, site_id, ) intervals_dicts = [dict(r) for r in int_rows] summary = _build_summary(intervals_dicts) if intervals_dicts else None run_out = PlanningRunOut( id=run_row["id"], created_at=run_row["created_at"], run_type=run_row["run_type"], horizon_start=run_row["horizon_start"], horizon_end=run_row["horizon_end"], forecast_correction_factor=float(run_row["forecast_correction_factor"]) if run_row["forecast_correction_factor"] is not None else None, solver_duration_ms=run_row["solver_duration_ms"], ) intervals_out = [ PlanningIntervalOut( interval_start=r["interval_start"], battery_setpoint_w=r["battery_setpoint_w"], battery_soc_target_pct=float(r["battery_soc_target_pct"]) if r["battery_soc_target_pct"] is not None else None, grid_setpoint_w=r["grid_setpoint_w"], ev1_setpoint_w=r["ev1_setpoint_w"], ev2_setpoint_w=r["ev2_setpoint_w"], heat_pump_enabled=r["heat_pump_enabled"], pv_a_curtailed_w=r["pv_a_curtailed_w"], expected_cost_czk=float(r["expected_cost_czk"]) if r["expected_cost_czk"] is not None else None, effective_buy_price=float(r["effective_buy_price"]) if r["effective_buy_price"] is not None else None, effective_sell_price=float(r["effective_sell_price"]) if r["effective_sell_price"] is not None else None, pv_forecast_total_w=int(r["pv_forecast_total_w"] or 0), load_baseline_w=int(r["load_baseline_w"] or 0), ) for r in intervals_dicts ] return CurrentPlanResponse(run=run_out, intervals=intervals_out, summary=summary) @router.post("/run", response_model=RunPlanResponse) async def post_run_plan( site_id: int, pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], plan_type: Literal["daily", "rolling"] = Query(..., alias="type"), ) -> RunPlanResponse: async with pool.acquire() as conn: exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id) if not exists: raise HTTPException(status_code=404, detail="Site not found") try: run_id, duration_ms = await run_plan_api( site_id, conn, plan_type=plan_type, triggered_by="api" ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e except RuntimeError as e: raise HTTPException(status_code=500, detail=str(e)) from e return RunPlanResponse(run_id=run_id, solver_duration_ms=duration_ms)