Files
ems/backend/app/routers/plan.py
Dusan Vojacek 8b4af663d8 Initial commit
Made-with: Cursor
2026-03-20 13:27:44 +01:00

238 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)