238 lines
8.5 KiB
Python
238 lines
8.5 KiB
Python
"""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)
|