implementace Ekonomiky
All checks were successful
test / smoke-test (push) Successful in 5s
deploy / deploy (push) Successful in 11s

This commit is contained in:
Dusan Vojacek
2026-04-05 20:10:43 +02:00
parent caf3f522e2
commit 5fcc47bce2
13 changed files with 1310 additions and 31 deletions

View File

@@ -15,6 +15,7 @@ import httpx
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.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
@@ -423,6 +424,63 @@ async def lifespan(app: FastAPI):
id="forecast_refresh_2h",
replace_existing=True,
)
async def scheduled_daily_economics_notification() -> None:
from services.notification_service import notify_daily_economics
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id, code FROM ems.site WHERE active = true")
for site in sites:
site_id = int(site["id"])
site_code = site["code"]
try:
row = await conn.fetchrow(
"""
SELECT import_kwh, export_kwh,
import_cost_czk, export_revenue_czk,
green_bonus_czk, total_balance_czk,
planned_balance_czk
FROM ems.vw_economics_daily
WHERE site_id = $1
AND day_local = (
CURRENT_DATE AT TIME ZONE 'Europe/Prague' - INTERVAL '1 day'
)::date
""",
site_id,
)
if row is None:
continue
yesterday = (
datetime.now(ZoneInfo("Europe/Prague"))
- timedelta(days=1)
).strftime("%Y-%m-%d")
await notify_daily_economics(
site_code=site_code,
day=yesterday,
import_kwh=float(row["import_kwh"] or 0),
import_cost=float(row["import_cost_czk"] or 0),
export_kwh=float(row["export_kwh"] or 0),
export_revenue=float(row["export_revenue_czk"] or 0),
green_bonus=float(row["green_bonus_czk"] or 0),
total_balance=float(row["total_balance_czk"] or 0),
planned_balance=float(row["planned_balance_czk"])
if row["planned_balance_czk"] is not None
else None,
)
except Exception:
logger.exception(
"scheduled_daily_economics_notification site=%s failed",
site_id,
)
scheduler.add_job(
scheduled_daily_economics_notification,
"cron",
hour=7,
minute=0,
id="daily_economics_notification",
replace_existing=True,
)
scheduler.start()
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
@@ -454,6 +512,7 @@ app = FastAPI(title="EMS Platform", lifespan=lifespan)
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")
sites_router = APIRouter(prefix="/api/v1/sites", tags=["sites"])

View 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