implementace Ekonomiky
This commit is contained in:
386
backend/app/routers/economics.py
Normal file
386
backend/app/routers/economics.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""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
|
||||
self_consumption_kwh: float
|
||||
ev_kwh: float
|
||||
hp_kwh: float
|
||||
import_cost_czk: float
|
||||
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
|
||||
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
|
||||
cumulative_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)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
if lock:
|
||||
days.append(
|
||||
DailyEconomics(
|
||||
day=d,
|
||||
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"]),
|
||||
self_consumption_kwh=_num(r["self_consumption_kwh"]),
|
||||
ev_kwh=_num(r["ev_kwh"]),
|
||||
hp_kwh=_num(r["hp_kwh"]),
|
||||
import_cost_czk=_num(lock["import_cost_czk"]),
|
||||
export_revenue_czk=_num(lock["export_revenue_czk"]),
|
||||
net_cost_czk=_num(lock["net_cost_czk"]),
|
||||
green_bonus_czk=_num(lock["green_bonus_czk"]),
|
||||
total_balance_czk=_num(lock["total_balance_czk"]),
|
||||
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
|
||||
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
|
||||
is_locked=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
days.append(
|
||||
DailyEconomics(
|
||||
day=d,
|
||||
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"]),
|
||||
self_consumption_kwh=_num(r["self_consumption_kwh"]),
|
||||
ev_kwh=_num(r["ev_kwh"]),
|
||||
hp_kwh=_num(r["hp_kwh"]),
|
||||
import_cost_czk=_num(r["import_cost_czk"]),
|
||||
export_revenue_czk=_num(r["export_revenue_czk"]),
|
||||
net_cost_czk=_num(r["net_cost_czk"]),
|
||||
green_bonus_czk=_num(r["green_bonus_czk"]),
|
||||
total_balance_czk=_num(r["total_balance_czk"]),
|
||||
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
|
||||
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
|
||||
is_locked=False,
|
||||
)
|
||||
)
|
||||
|
||||
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=float(r["dynamic_cost_czk"]) if r["dynamic_cost_czk"] is not None else None,
|
||||
stored_cost_czk=float(r["stored_cost_czk"]) if r["stored_cost_czk"] is not None else None,
|
||||
green_bonus_czk=float(r["green_bonus_czk"]) if r["green_bonus_czk"] is not None else None,
|
||||
planned_cost_czk=float(r["planned_cost_czk"]) if r["planned_cost_czk"] is not None else None,
|
||||
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=float(r["effective_buy_price_czk_kwh"]) if r["effective_buy_price_czk_kwh"] is not None else None,
|
||||
effective_sell_price=float(r["effective_sell_price_czk_kwh"]) if r["effective_sell_price_czk_kwh"] is not None else None,
|
||||
planned_buy_price=float(r["planned_buy_price"]) if r["planned_buy_price"] is not None else None,
|
||||
planned_sell_price=float(r["planned_sell_price"]) if r["planned_sell_price"] is not None else None,
|
||||
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=float(r["actual_battery_soc_pct"]) if r["actual_battery_soc_pct"] is not None else None,
|
||||
)
|
||||
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
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
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,
|
||||
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"],
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
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"]: _num(r["total_balance_czk"]) for r in lock_rows}
|
||||
|
||||
points: list[ChartDayPoint] = []
|
||||
cumulative = 0.0
|
||||
for r in rows:
|
||||
d = r["day_local"]
|
||||
balance = locks.get(d, _num(r["total_balance_czk"]))
|
||||
cumulative += balance
|
||||
points.append(
|
||||
ChartDayPoint(
|
||||
day=d,
|
||||
daily_balance_czk=round(balance, 2),
|
||||
cumulative_balance_czk=round(cumulative, 2),
|
||||
)
|
||||
)
|
||||
|
||||
return points
|
||||
Reference in New Issue
Block a user