nova stranka flow a obsluha
All checks were successful
deploy / deploy (push) Successful in 8m52s
test / smoke-test (push) Successful in 5s

This commit is contained in:
Dusan Vojacek
2026-04-10 22:13:58 +02:00
parent 64221f701a
commit f714cab0ab
14 changed files with 1670 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.db_json import record_to_dict
from app.deps import set_pg_pool
from app.routers.economics import router as economics_router
from app.routers.energy_flows import router as energy_flows_router
from app.routers.ev import router as ev_router
from app.routers.full_status import router as full_status_router
from app.routers.plan import router as plan_router
@@ -526,6 +527,7 @@ app.include_router(plan_router, prefix="/api/v1")
app.include_router(ev_router, prefix="/api/v1")
app.include_router(full_status_router, prefix="/api/v1")
app.include_router(economics_router, prefix="/api/v1")
app.include_router(energy_flows_router, prefix="/api/v1")
sites_router = APIRouter(prefix="/api/v1/sites", tags=["sites"])

View File

@@ -0,0 +1,221 @@
"""REST API analýza energetických toků (modelované toky z audit_interval)."""
from __future__ import annotations
from datetime import date
from typing import Annotated, Any
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.deps import get_pg_pool
router = APIRouter(
prefix="/sites/{site_id}/energy-flows",
tags=["energy-flows"],
)
class DailyEnergyFlows(BaseModel):
day: date
interval_count: int
pv_production_kwh: float
grid_import_kwh: float
grid_export_kwh: float
batt_charge_kwh: float
batt_discharge_kwh: float
load_kwh: float
pv_to_load_kwh: float
pv_to_batt_kwh: float
pv_to_grid_kwh: float
batt_to_load_kwh: float
batt_to_grid_kwh: float
grid_to_load_kwh: float
grid_to_batt_kwh: float
class DailyEnergyFlowsResponse(BaseModel):
days: list[DailyEnergyFlows]
class IntervalEnergyFlows(BaseModel):
interval_start: str
pv_production_kwh: float | None
grid_import_kwh: float | None
grid_export_kwh: float | None
batt_charge_kwh: float | None
batt_discharge_kwh: float | None
load_kwh: float | None
pv_to_load_kwh: float | None
pv_to_batt_kwh: float | None
pv_to_grid_kwh: float | None
batt_to_load_kwh: float | None
batt_to_grid_kwh: float | None
grid_to_load_kwh: float | None
grid_to_batt_kwh: float | None
def _num(val: Any) -> float:
if val is None:
return 0.0
return float(val)
def _wh_to_kwh(val: Any) -> float | None:
if val is None:
return None
return round(float(val) / 1000.0, 4)
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not ok:
raise HTTPException(status_code=404, detail="Site not found")
def _row_to_daily(r: Any) -> DailyEnergyFlows:
return DailyEnergyFlows(
day=r["day_local"],
interval_count=int(r["interval_count"] or 0),
pv_production_kwh=_num(r["pv_production_kwh"]),
grid_import_kwh=_num(r["grid_import_kwh"]),
grid_export_kwh=_num(r["grid_export_kwh"]),
batt_charge_kwh=_num(r["batt_charge_kwh"]),
batt_discharge_kwh=_num(r["batt_discharge_kwh"]),
load_kwh=_num(r["load_kwh"]),
pv_to_load_kwh=_num(r["pv_to_load_kwh"]),
pv_to_batt_kwh=_num(r["pv_to_batt_kwh"]),
pv_to_grid_kwh=_num(r["pv_to_grid_kwh"]),
batt_to_load_kwh=_num(r["batt_to_load_kwh"]),
batt_to_grid_kwh=_num(r["batt_to_grid_kwh"]),
grid_to_load_kwh=_num(r["grid_to_load_kwh"]),
grid_to_batt_kwh=_num(r["grid_to_batt_kwh"]),
)
@router.get("/daily", response_model=DailyEnergyFlowsResponse)
async def get_energy_flows_daily(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
month: str = Query(
...,
description="YYYY-MM",
pattern=r"^\d{4}-\d{2}$",
),
) -> DailyEnergyFlowsResponse:
try:
year, mon = month.split("-")
month_start = date(int(year), int(mon), 1)
if int(mon) == 12:
month_end = date(int(year) + 1, 1, 1)
else:
month_end = date(int(year), int(mon) + 1, 1)
except (ValueError, IndexError):
raise HTTPException(status_code=400, detail="Invalid month, expected YYYY-MM")
async with db.acquire() as conn:
await _check_site(conn, site_id)
rows = await conn.fetch(
"""
SELECT
(date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date
AS day_local,
COUNT(*)::int AS interval_count,
ROUND(SUM(COALESCE(ai.actual_pv_production_wh, 0)) / 1000, 3)
AS pv_production_kwh,
ROUND(SUM(COALESCE(ai.actual_grid_import_wh, 0)) / 1000, 3)
AS grid_import_kwh,
ROUND(SUM(COALESCE(ai.actual_grid_export_wh, 0)) / 1000, 3)
AS grid_export_kwh,
ROUND(SUM(COALESCE(ai.actual_batt_charge_wh, 0)) / 1000, 3)
AS batt_charge_kwh,
ROUND(SUM(COALESCE(ai.actual_batt_discharge_wh, 0)) / 1000, 3)
AS batt_discharge_kwh,
ROUND(SUM(COALESCE(ai.actual_load_consumption_wh, 0)) / 1000, 3)
AS load_kwh,
ROUND(SUM(COALESCE(ai.flow_pv_to_load_wh, 0)) / 1000, 3)
AS pv_to_load_kwh,
ROUND(SUM(COALESCE(ai.flow_pv_to_batt_wh, 0)) / 1000, 3)
AS pv_to_batt_kwh,
ROUND(SUM(COALESCE(ai.flow_pv_to_grid_wh, 0)) / 1000, 3)
AS pv_to_grid_kwh,
ROUND(SUM(COALESCE(ai.flow_batt_to_load_wh, 0)) / 1000, 3)
AS batt_to_load_kwh,
ROUND(SUM(COALESCE(ai.flow_batt_to_grid_wh, 0)) / 1000, 3)
AS batt_to_grid_kwh,
ROUND(SUM(COALESCE(ai.flow_grid_to_load_wh, 0)) / 1000, 3)
AS grid_to_load_kwh,
ROUND(SUM(COALESCE(ai.flow_grid_to_batt_wh, 0)) / 1000, 3)
AS grid_to_batt_kwh
FROM ems.audit_interval ai
WHERE ai.site_id = $1
AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date
>= $2
AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date
< $3
GROUP BY 1
ORDER BY 1
""",
site_id,
month_start,
month_end,
)
return DailyEnergyFlowsResponse(days=[_row_to_daily(r) for r in rows])
@router.get("/daily/{day}/intervals", response_model=list[IntervalEnergyFlows])
async def get_energy_flows_intervals(
site_id: int,
day: date,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[IntervalEnergyFlows]:
async with db.acquire() as conn:
await _check_site(conn, site_id)
rows = await conn.fetch(
"""
SELECT
interval_start,
actual_pv_production_wh,
actual_grid_import_wh,
actual_grid_export_wh,
actual_batt_charge_wh,
actual_batt_discharge_wh,
actual_load_consumption_wh,
flow_pv_to_load_wh,
flow_pv_to_batt_wh,
flow_pv_to_grid_wh,
flow_batt_to_load_wh,
flow_batt_to_grid_wh,
flow_grid_to_load_wh,
flow_grid_to_batt_wh
FROM ems.audit_interval
WHERE site_id = $1
AND (date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague'))::date = $2
ORDER BY interval_start
""",
site_id,
day,
)
return [
IntervalEnergyFlows(
interval_start=r["interval_start"].isoformat(),
pv_production_kwh=_wh_to_kwh(r["actual_pv_production_wh"]),
grid_import_kwh=_wh_to_kwh(r["actual_grid_import_wh"]),
grid_export_kwh=_wh_to_kwh(r["actual_grid_export_wh"]),
batt_charge_kwh=_wh_to_kwh(r["actual_batt_charge_wh"]),
batt_discharge_kwh=_wh_to_kwh(r["actual_batt_discharge_wh"]),
load_kwh=_wh_to_kwh(r["actual_load_consumption_wh"]),
pv_to_load_kwh=_wh_to_kwh(r["flow_pv_to_load_wh"]),
pv_to_batt_kwh=_wh_to_kwh(r["flow_pv_to_batt_wh"]),
pv_to_grid_kwh=_wh_to_kwh(r["flow_pv_to_grid_wh"]),
batt_to_load_kwh=_wh_to_kwh(r["flow_batt_to_load_wh"]),
batt_to_grid_kwh=_wh_to_kwh(r["flow_batt_to_grid_wh"]),
grid_to_load_kwh=_wh_to_kwh(r["flow_grid_to_load_wh"]),
grid_to_batt_kwh=_wh_to_kwh(r["flow_grid_to_batt_wh"]),
)
for r in rows
]