Files
ems/backend/app/routers/economics.py
Dusan Vojacek 806274cf59
Some checks failed
deploy / deploy (push) Failing after 1m15s
test / smoke-test (push) Successful in 2s
uprava adutiu - nacitani dalsich registru, uprava ekonomiky
2026-04-10 21:53:32 +02:00

418 lines
14 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 denní ekonomické vyhodnocení provozu."""
from __future__ import annotations
import logging
from datetime import date, datetime
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}/economics",
tags=["economics"],
)
logger = logging.getLogger(__name__)
class DailyEconomics(BaseModel):
day: date
interval_count: int
import_kwh: float
export_kwh: float
pv_kwh: float
load_kwh: float
pv_self_consumption_kwh: float
ev_kwh: float
hp_kwh: float
import_cost_czk: float
export_revenue_czk: float
grid_import_cashflow_czk: float
grid_export_revenue_czk: float
net_cost_czk: float
green_bonus_czk: float
total_balance_czk: float
planned_balance_czk: float | None
deviation_cost_czk: float | None
is_locked: bool
class DailyEconomicsResponse(BaseModel):
days: list[DailyEconomics]
has_green_bonus: bool
class IntervalEconomics(BaseModel):
interval_start: str
import_kwh: float
export_kwh: float
dynamic_cost_czk: float | None
grid_import_cashflow_czk: float | None
grid_export_revenue_czk: float | None
stored_cost_czk: float | None
green_bonus_czk: float | None
planned_cost_czk: float | None
planned_grid_w: int | None
actual_grid_power_w: int | None
effective_buy_price: float | None
effective_sell_price: float | None
planned_buy_price: float | None
planned_sell_price: float | None
actual_pv_power_w: int | None
actual_load_power_w: int | None
actual_battery_power_w: int | None
actual_battery_soc_pct: float | None
class ChartDayPoint(BaseModel):
day: date
daily_balance_czk: float
daily_grid_balance_czk: float
daily_green_bonus_czk: float
daily_import_cost_czk: float
daily_export_revenue_czk: float
cumulative_balance_czk: float
cumulative_grid_balance_czk: float
class LockResponse(BaseModel):
locked: bool
day: date
def _num(val: Any) -> float:
if val is None:
return 0.0
return float(val)
def _opt(val: Any) -> float | None:
if val is None:
return None
return float(val)
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")
async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool:
return bool(
await conn.fetchval(
"""
SELECT EXISTS(
SELECT 1 FROM ems.asset_pv_array
WHERE site_id = $1
AND green_bonus_czk_kwh IS NOT NULL
)
""",
site_id,
)
)
def _safe_get(record: Any, key: str, fallback: Any = None) -> Any:
"""Safely get a key from asyncpg Record (which supports [] but not .get())."""
try:
return record[key]
except (KeyError, TypeError):
return fallback
def _daily_from_row(r: Any, lock: Any | None, is_locked: bool) -> DailyEconomics:
src = lock if (lock and is_locked) else r
return DailyEconomics(
day=r["day_local"],
interval_count=r["interval_count"],
import_kwh=_num(r["import_kwh"]),
export_kwh=_num(r["export_kwh"]),
pv_kwh=_num(r["pv_kwh"]),
load_kwh=_num(r["load_kwh"]),
pv_self_consumption_kwh=_num(r["pv_self_consumption_kwh"]),
ev_kwh=_num(r["ev_kwh"]),
hp_kwh=_num(r["hp_kwh"]),
import_cost_czk=_num(src["import_cost_czk"]),
export_revenue_czk=_num(src["export_revenue_czk"]),
grid_import_cashflow_czk=_num(
_safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"])
),
grid_export_revenue_czk=_num(
_safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"])
),
net_cost_czk=_num(src["net_cost_czk"]),
green_bonus_czk=_num(src["green_bonus_czk"]),
total_balance_czk=_num(src["total_balance_czk"]),
planned_balance_czk=_opt(r["planned_balance_czk"]),
deviation_cost_czk=_opt(r["deviation_cost_czk"]),
is_locked=is_locked,
)
@router.get("/daily", response_model=DailyEconomicsResponse)
async def get_economics_daily(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
month: str = Query(
...,
description="YYYY-MM (měsíc pro zobrazení)",
pattern=r"^\d{4}-\d{2}$",
),
) -> DailyEconomicsResponse:
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)
has_bonus = await _has_green_bonus(conn, site_id)
dyn_rows = await conn.fetch(
"""
SELECT * FROM ems.vw_economics_daily
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
ORDER BY day_local
""",
site_id,
month_start,
month_end,
)
lock_rows = await conn.fetch(
"""
SELECT * FROM ems.audit_day_lock
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
""",
site_id,
month_start,
month_end,
)
locks = {r["day_local"]: r for r in lock_rows}
days: list[DailyEconomics] = []
for r in dyn_rows:
d = r["day_local"]
lock = locks.get(d)
days.append(_daily_from_row(r, lock, is_locked=lock is not None))
return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus)
@router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics])
async def get_economics_intervals(
site_id: int,
day: date,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[IntervalEconomics]:
async with db.acquire() as conn:
await _check_site(conn, site_id)
rows = await conn.fetch(
"""
SELECT *
FROM ems.vw_economics_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 [
IntervalEconomics(
interval_start=r["interval_start"].isoformat(),
import_kwh=_num(r["import_kwh"]),
export_kwh=_num(r["export_kwh"]),
dynamic_cost_czk=_opt(r["dynamic_cost_czk"]),
grid_import_cashflow_czk=_opt(r["grid_import_cashflow_czk"]),
grid_export_revenue_czk=_opt(r["grid_export_revenue_czk"]),
stored_cost_czk=_opt(r["stored_cost_czk"]),
green_bonus_czk=_opt(r["green_bonus_czk"]),
planned_cost_czk=_opt(r["planned_cost_czk"]),
planned_grid_w=int(r["planned_grid_w"]) if r["planned_grid_w"] is not None else None,
actual_grid_power_w=int(r["actual_grid_power_w"]) if r["actual_grid_power_w"] is not None else None,
effective_buy_price=_opt(r["effective_buy_price_czk_kwh"]),
effective_sell_price=_opt(r["effective_sell_price_czk_kwh"]),
planned_buy_price=_opt(r["planned_buy_price"]),
planned_sell_price=_opt(r["planned_sell_price"]),
actual_pv_power_w=int(r["actual_pv_power_w"]) if r["actual_pv_power_w"] is not None else None,
actual_load_power_w=int(r["actual_load_power_w"]) if r["actual_load_power_w"] is not None else None,
actual_battery_power_w=int(r["actual_battery_power_w"]) if r["actual_battery_power_w"] is not None else None,
actual_battery_soc_pct=_opt(r["actual_battery_soc_pct"]),
)
for r in rows
]
@router.post("/daily/{day}/lock", response_model=LockResponse)
async def lock_day(
site_id: int,
day: date,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> LockResponse:
async with db.acquire() as conn:
await _check_site(conn, site_id)
row = await conn.fetchrow(
"""
SELECT import_cost_czk, export_revenue_czk, net_cost_czk,
green_bonus_czk, total_balance_czk,
grid_import_cashflow_czk, grid_export_revenue_czk
FROM ems.vw_economics_daily
WHERE site_id = $1 AND day_local = $2
""",
site_id,
day,
)
if row is None:
raise HTTPException(
status_code=404,
detail=f"No economics data for {day.isoformat()}",
)
await conn.execute(
"""
INSERT INTO ems.audit_day_lock
(site_id, day_local, import_cost_czk, export_revenue_czk,
net_cost_czk, green_bonus_czk, total_balance_czk,
grid_import_cashflow_czk, grid_export_revenue_czk)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (site_id, day_local) DO UPDATE SET
import_cost_czk = EXCLUDED.import_cost_czk,
export_revenue_czk = EXCLUDED.export_revenue_czk,
net_cost_czk = EXCLUDED.net_cost_czk,
green_bonus_czk = EXCLUDED.green_bonus_czk,
total_balance_czk = EXCLUDED.total_balance_czk,
grid_import_cashflow_czk = EXCLUDED.grid_import_cashflow_czk,
grid_export_revenue_czk = EXCLUDED.grid_export_revenue_czk,
locked_at = now()
""",
site_id,
day,
row["import_cost_czk"],
row["export_revenue_czk"],
row["net_cost_czk"],
row["green_bonus_czk"],
row["total_balance_czk"],
row["grid_import_cashflow_czk"],
row["grid_export_revenue_czk"],
)
return LockResponse(locked=True, day=day)
@router.delete("/daily/{day}/lock", response_model=LockResponse)
async def unlock_day(
site_id: int,
day: date,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> LockResponse:
async with db.acquire() as conn:
await _check_site(conn, site_id)
await conn.execute(
"DELETE FROM ems.audit_day_lock WHERE site_id = $1 AND day_local = $2",
site_id,
day,
)
return LockResponse(locked=False, day=day)
@router.get("/monthly-chart", response_model=list[ChartDayPoint])
async def get_monthly_chart(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
month: str = Query(
...,
description="YYYY-MM",
pattern=r"^\d{4}-\d{2}$",
),
) -> list[ChartDayPoint]:
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 day_local, total_balance_czk, net_cost_czk,
green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk
FROM ems.vw_economics_daily
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
ORDER BY day_local
""",
site_id,
month_start,
month_end,
)
lock_rows = await conn.fetch(
"""
SELECT day_local, total_balance_czk, net_cost_czk,
green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk
FROM ems.audit_day_lock
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
""",
site_id,
month_start,
month_end,
)
locks = {r["day_local"]: r for r in lock_rows}
points: list[ChartDayPoint] = []
cum_balance = 0.0
cum_grid = 0.0
for r in rows:
d = r["day_local"]
src = locks.get(d, r)
balance = _num(src["total_balance_czk"])
grid_balance = -_num(src["net_cost_czk"])
green_bonus = _num(src["green_bonus_czk"])
import_cost = _num(_safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"]))
export_revenue = _num(_safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"]))
cum_balance += balance
cum_grid += grid_balance
points.append(
ChartDayPoint(
day=d,
daily_balance_czk=round(balance, 2),
daily_grid_balance_czk=round(grid_balance, 2),
daily_green_bonus_czk=round(green_bonus, 2),
daily_import_cost_czk=round(import_cost, 2),
daily_export_revenue_czk=round(export_revenue, 2),
cumulative_balance_czk=round(cum_balance, 2),
cumulative_grid_balance_czk=round(cum_grid, 2),
)
)
return points