Files
ems/backend/app/routers/energy_flows.py
Dusan Vojacek b50041cfc7
All checks were successful
deploy / deploy (push) Successful in 1m21s
test / smoke-test (push) Successful in 5s
do flow pridana ekonomika
2026-04-10 23:06:25 +02:00

261 lines
9.4 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 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
grid_import_cashflow_czk: float
grid_export_revenue_czk: float
grid_to_load_cost_czk: float
grid_to_batt_cost_czk: 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"]),
grid_import_cashflow_czk=_num(r["grid_import_cashflow_czk"]),
grid_export_revenue_czk=_num(r["grid_export_revenue_czk"]),
grid_to_load_cost_czk=_num(r["grid_to_load_cost_czk"]),
grid_to_batt_cost_czk=_num(r["grid_to_batt_cost_czk"]),
)
@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,
ROUND(
SUM(
COALESCE(ai.actual_grid_import_wh, 0) / 1000.0
* COALESCE(ep.effective_buy_price_czk_kwh, 0)
),
2
) AS grid_import_cashflow_czk,
ROUND(
SUM(
COALESCE(ai.actual_grid_export_wh, 0) / 1000.0
* COALESCE(ep.effective_sell_price_czk_kwh, 0)
),
2
) AS grid_export_revenue_czk,
ROUND(
SUM(
COALESCE(ai.flow_grid_to_load_wh, 0) / 1000.0
* COALESCE(ep.effective_buy_price_czk_kwh, 0)
),
2
) AS grid_to_load_cost_czk,
ROUND(
SUM(
COALESCE(ai.flow_grid_to_batt_wh, 0) / 1000.0
* COALESCE(ep.effective_buy_price_czk_kwh, 0)
),
2
) AS grid_to_batt_cost_czk
FROM ems.audit_interval ai
LEFT JOIN ems.vw_site_effective_price ep
ON ep.site_id = ai.site_id
AND ep.interval_start = ai.interval_start
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
]