"""REST API – aktivní plán a ruční přepočet.""" from datetime import datetime, timedelta, timezone from typing import Annotated, Any, Literal import asyncpg from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from app.db_json import record_to_dict from app.deps import get_pg_pool from services.planning_engine import _current_slot_start, run_plan_api router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"]) PRICE_CHECK_HOURS = 24 _SLOTS_PER_HOUR = 4 _EXPECTED_PRICE_SLOTS = PRICE_CHECK_HOURS * _SLOTS_PER_HOUR class RunPlanResponse(BaseModel): run_id: int solver_duration_ms: int horizon_start: datetime horizon_end: datetime def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]: total_cost = 0.0 total_curtailed_kwh = 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 total_curtailed_kwh += int(c) * 0.25 / 1000.0 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 { "total_expected_cost_czk": round(total_cost, 4), "total_pv_curtailed_kwh": round(total_curtailed_kwh, 6), "charge_slots": charge_slots, "discharge_slots": discharge_slots, "export_slots": export_slots, } @router.get("/current") async def get_current_plan( site_id: int, pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], ) -> dict[str, Any]: 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") run_row = await conn.fetchrow( """ SELECT pr.* FROM ems.planning_run pr WHERE pr.site_id = $1 AND pr.status = 'active' ORDER BY pr.created_at DESC LIMIT 1 """, site_id, ) if not run_row: raise HTTPException(status_code=404, detail="No active plan") run_id = run_row["id"] int_rows = await conn.fetch( """ SELECT * FROM ems.planning_interval WHERE run_id = $1 ORDER BY interval_start """, run_id, ) intervals = [record_to_dict(r) for r in int_rows] summary = _build_summary(intervals) return {"run": record_to_dict(run_row), "intervals": intervals, "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("rolling", alias="type"), ) -> RunPlanResponse: window_start = _current_slot_start(datetime.now(timezone.utc)) window_end = window_start + timedelta(hours=PRICE_CHECK_HOURS) 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") price_slots = await conn.fetchval( """ SELECT COUNT(DISTINCT interval_start)::int FROM ems.vw_site_effective_price WHERE site_id = $1 AND interval_start >= $2 AND interval_start < $3 """, site_id, window_start, window_end, ) if (price_slots or 0) < _EXPECTED_PRICE_SLOTS: raise HTTPException( status_code=422, detail="Nejsou dostupné tržní ceny. Spusťte nejdřív import cen.", ) try: run_id, solver_duration_ms = await run_plan_api( site_id, plan_type, conn, 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=422, detail=str(e)) from e row = await conn.fetchrow( """ SELECT horizon_start, horizon_end FROM ems.planning_run WHERE id = $1 """, run_id, ) if row is None: raise HTTPException(status_code=500, detail="Planning run row missing after insert") return RunPlanResponse( run_id=run_id, solver_duration_ms=solver_duration_ms, horizon_start=row["horizon_start"], horizon_end=row["horizon_end"], )