134 lines
4.3 KiB
Python
134 lines
4.3 KiB
Python
"""REST API – aktivní plán a ruční přepočet."""
|
||
|
||
import json
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
from typing import Annotated, Any, Literal
|
||
|
||
import asyncpg
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
from app.db_json import fetch_json
|
||
from app.deps import get_pg_pool
|
||
from services.control_exporter import export_setpoints
|
||
from services.planning_engine import run_plan_api
|
||
|
||
router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"])
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class RunPlanResponse(BaseModel):
|
||
run_id: int
|
||
solver_duration_ms: int
|
||
horizon_start: datetime
|
||
horizon_end: datetime
|
||
|
||
|
||
class PlanningIntervalDto(BaseModel):
|
||
"""Řádek `ems.planning_interval` v odpovědi aktivního plánu."""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
interval_start: str
|
||
is_predicted_price: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"True pokud solver pro slot použil predikovanou cenu (market_price_stats), "
|
||
"nikoli přesný řádek z vw_site_effective_price / OTE."
|
||
),
|
||
)
|
||
|
||
|
||
class CurrentPlanResponseModel(BaseModel):
|
||
run: dict[str, Any]
|
||
intervals: list[PlanningIntervalDto]
|
||
summary: dict[str, Any]
|
||
|
||
|
||
@router.get("/current", response_model=CurrentPlanResponseModel)
|
||
async def get_current_plan(
|
||
site_id: int,
|
||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||
) -> CurrentPlanResponseModel:
|
||
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")
|
||
|
||
bundle = await fetch_json(
|
||
conn,
|
||
"select ems.fn_plan_current_bundle($1::int)",
|
||
site_id,
|
||
)
|
||
if not isinstance(bundle, dict):
|
||
bundle = json.loads(bundle)
|
||
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)]
|
||
return CurrentPlanResponseModel(
|
||
run=bundle.get("run") or {},
|
||
intervals=intervals,
|
||
summary=bundle.get("summary") or {},
|
||
)
|
||
|
||
|
||
@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:
|
||
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")
|
||
|
||
days_with_prices = await conn.fetchval(
|
||
"select ems.fn_planning_future_price_days()",
|
||
)
|
||
if (days_with_prices or 0) < 1:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Nejsou dostupné tržní ceny",
|
||
)
|
||
|
||
try:
|
||
run_id, solver_duration_ms = await run_plan_api(
|
||
site_id, plan_type, conn, triggered_by="api"
|
||
)
|
||
await export_setpoints(site_id, conn)
|
||
row = await fetch_json(
|
||
conn,
|
||
"select ems.fn_planning_run_horizon($1::int)",
|
||
run_id,
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
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
|
||
except Exception as e:
|
||
logger.error("Plan run failed: %s", e, exc_info=True)
|
||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||
|
||
if not isinstance(row, dict) or row.get("horizon_start") 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"],
|
||
)
|