"""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