156 lines
4.9 KiB
Python
156 lines
4.9 KiB
Python
"""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"],
|
||
)
|