refactor main.py
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-19 20:42:53 +02:00
parent ccb2a41e22
commit 014c6f193b
7 changed files with 1229 additions and 1062 deletions

View File

@@ -67,6 +67,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
### SQL vs Python (read-model)
- **Žádné ad-hoc `SELECT`/`INSERT`/`UPDATE` v `backend/services/*.py` a `backend/app/routers/*.py`** kromě: existence `SELECT 1` / `EXISTS`, volání `select ems.fn_*(…)`, a čtení z **`ems.vw_*`**. IO (Modbus, HTTP), PuLP solver a orchestrace zůstávají v Pythonu.
- **Health a Loxone po změně režimu:** `fn_health_summary`, `fn_health_detailed_db`, `fn_vw_site_directory_active`, `fn_site_economics_yesterday_notification`, `fn_site_mode_loxone_bundle` v repeatable `db/routines/R__073_fn_health_site_jobs_mode_bundle.sql`; FastAPI je v [`app/main.py`](backend/app/main.py) + joby v [`app/lifespan.py`](backend/app/lifespan.py).
### Provozní režimy (operating_mode)

461
backend/app/lifespan.py Normal file
View File

@@ -0,0 +1,461 @@
"""FastAPI lifespan: DB pool, APScheduler joby, telemetrie."""
from __future__ import annotations
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from datetime import date, datetime, timedelta, timezone
from typing import Any
import asyncpg
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI
from zoneinfo import ZoneInfo
from app.db_json import fetch_json
from app.deps import set_pg_pool
from app.refresh_negative_prices import refresh_negative_price_predictions
from app.ws_log_handler import WSLogHandler
from services.audit_filler import fill_audit_for_completed_intervals
from services.control_exporter import export_setpoints, verify_modbus_commands
from services.forecast_service import fetch_pv_forecast
from services.heartbeat_service import send_heartbeat
from services.notification_service import notify_operating_mode_changed
from services.price_importer import import_ote_prices, ote_prague_day_slots_look_complete
from services.telemetry_collector import run_telemetry_loop_wrapper
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler(timezone=ZoneInfo("Europe/Prague"))
def _dsn() -> str:
host = os.getenv("DB_HOST", "localhost")
port = os.getenv("DB_PORT", "5432")
name = os.getenv("DB_NAME", "ems")
user = os.getenv("DB_USER", "ems_user")
password = os.getenv("DB_PASSWORD", "")
return f"postgresql://{user}:{password}@{host}:{port}/{name}"
async def _active_site_rows(conn: asyncpg.Connection) -> list[dict[str, Any]]:
raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()")
if not isinstance(raw, list):
return []
return [x for x in raw if isinstance(x, dict)]
@asynccontextmanager
async def lifespan(app: FastAPI):
pg_pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5)
set_pg_pool(pg_pool)
app.state.pg_pool = pg_pool
app.state.ws_log_handler = WSLogHandler()
app.state.ws_log_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(app.state.ws_log_handler)
from services.planning_engine import run_daily_plan, run_rolling_replan
async def scheduled_heartbeat() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
await send_heartbeat(int(site["id"]), conn)
except Exception:
logger.exception("scheduled_heartbeat site=%s failed", site["id"])
async def scheduled_audit_filler() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
await fill_audit_for_completed_intervals(int(site["id"]), conn)
except Exception:
logger.exception("scheduled_audit_filler site=%s failed", site["id"])
async def scheduled_forecast_accuracy() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
n = await conn.fetchval(
"SELECT ems.fn_fill_forecast_accuracy($1, 48)",
site["id"],
)
if n:
logger.info(
"forecast_accuracy filled %s slots for site %s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_forecast_accuracy site=%s failed", site["id"]
)
async def scheduled_expire_modes() -> None:
async with app.state.pg_pool.acquire() as conn:
try:
rows = await conn.fetch("SELECT * FROM ems.fn_expire_modes()")
for r in rows:
await notify_operating_mode_changed(
str(r["site_code"]),
str(r["old_mode"]),
str(r["new_mode"]),
"system:expiry",
"Automatické vypršení dočasného režimu",
)
except Exception:
logger.exception("scheduled_expire_modes failed")
async def scheduled_control_export() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
await export_setpoints(int(site["id"]), conn)
except Exception as e:
logger.exception(
"scheduled_control_export site=%s: %s", site["id"], e
)
async def scheduled_verify_modbus() -> None:
"""
Ověří příkazy ve stavu written z posledních 20 minut.
Běží každé 2 minuty, nezávisle na control_exporter (delší okno kvůli zpoždění jobu).
"""
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
try:
id_json = await fetch_json(
conn,
"select ems.fn_modbus_written_command_ids($1::int, interval '20 minutes')",
site_id,
)
if not isinstance(id_json, list):
id_json = []
ids = [int(x) for x in id_json]
if ids:
await verify_modbus_commands(ids, conn, site_id)
except Exception:
logger.exception("scheduled_verify_modbus site=%s failed", site_id)
async def scheduled_daily_plan() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
try:
await run_daily_plan(site_id, conn)
await export_setpoints(site_id, conn)
except Exception:
logger.exception("scheduled_daily_plan site=%s failed", site_id)
async def scheduled_rolling_replan() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
try:
await run_rolling_replan(site_id, conn)
await export_setpoints(site_id, conn)
except Exception:
logger.exception("scheduled_rolling_replan site=%s failed", site_id)
async def scheduled_baseline_update() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
n = await conn.fetchval(
"SELECT ems.fn_update_baseline_stats($1, 30)",
site["id"],
)
logger.info(
"baseline_stats updated %s rows for site %s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_baseline_update site=%s failed", site["id"]
)
async def scheduled_market_price_stats() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
n = await conn.fetchval(
"SELECT ems.fn_update_market_price_stats($1, 90)",
site["id"],
)
logger.info(
"market_price_stats updated %s rows site=%s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_market_price_stats site=%s failed", site["id"]
)
async def scheduled_tuv_usage_stats() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
n = await conn.fetchval(
"SELECT ems.fn_update_tuv_usage_stats($1, 30)",
site["id"],
)
logger.info(
"tuv_usage_stats updated %s rows site=%s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_tuv_usage_stats site=%s failed", site["id"]
)
async def scheduled_forecast_refresh() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
try:
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
if intervals >= 0:
logger.info(
"scheduled_forecast_refresh site=%s intervals=%s arrays=%s",
site_id,
intervals,
pv_arrays,
)
await refresh_negative_price_predictions(conn, site_id)
else:
logger.warning(
"scheduled_forecast_refresh site=%s failed",
site_id,
)
except Exception:
logger.exception("scheduled_forecast_refresh site=%s failed", site_id)
async def _count_ote_slots_for_day(
conn: asyncpg.Connection, target_day: date
) -> int:
return int(
await conn.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND interval_start::date = $1::date
""",
target_day,
)
or 0
)
async def _refresh_negative_price_predictions_all_active(
conn: asyncpg.Connection,
) -> None:
for site in await _active_site_rows(conn):
await refresh_negative_price_predictions(conn, int(site["id"]))
async def _scheduled_ote_import_global(conn: asyncpg.Connection) -> None:
"""Jeden OTE fetch na chybějící den; market_interval_price je globální pro všechny site."""
prague_tz = ZoneInfo("Europe/Prague")
now_loc = datetime.now(prague_tz)
today = now_loc.date()
tomorrow = today + timedelta(days=1)
any_import_ok = False
for day in (today, tomorrow):
slots = await _count_ote_slots_for_day(conn, day)
if ote_prague_day_slots_look_complete(slots):
continue
n, imported_day, _, err = await import_ote_prices(
conn, site_id=None, target_date=day
)
if n < 0:
logger.warning(
"scheduled_ote_import_global day=%s failed (%s)",
day.isoformat(),
err,
)
continue
logger.info(
"scheduled_ote_import_global day=%s imported=%s slots",
imported_day,
n,
)
any_import_ok = True
if any_import_ok:
await _refresh_negative_price_predictions_all_active(conn)
async def scheduled_ote_import() -> None:
async with app.state.pg_pool.acquire() as conn:
try:
await _scheduled_ote_import_global(conn)
except Exception:
logger.exception("scheduled_ote_import_global failed")
scheduler.add_job(scheduled_heartbeat, "interval", seconds=60, id="heartbeat")
scheduler.add_job(
scheduled_audit_filler,
"cron",
minute="1,16,31,46",
second=0,
id="audit_filler",
)
scheduler.add_job(
scheduled_forecast_accuracy,
"cron",
minute="2,17,32,47",
id="forecast_accuracy",
replace_existing=True,
)
scheduler.add_job(scheduled_expire_modes, "interval", minutes=1, id="expire_modes")
scheduler.add_job(
scheduled_control_export,
"cron",
minute="14,29,44,59",
second=0,
id="control_export",
)
scheduler.add_job(
scheduled_verify_modbus,
"interval",
minutes=2,
id="verify_modbus",
replace_existing=True,
)
scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan")
scheduler.add_job(
scheduled_rolling_replan,
"cron",
minute="*/15",
id="rolling_replan",
)
scheduler.add_job(
scheduled_baseline_update,
"cron",
hour=0,
minute=30,
id="baseline_update",
replace_existing=True,
)
scheduler.add_job(
scheduled_market_price_stats,
"cron",
hour=14,
minute=45,
id="market_price_stats",
replace_existing=True,
)
scheduler.add_job(
scheduled_tuv_usage_stats,
"cron",
hour=0,
minute=45,
id="tuv_usage_stats",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour=13,
minute=30,
id="ote_import_preopen",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour=14,
minute=0,
id="ote_import_main",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour=0,
minute=5,
id="ote_import_backfill",
replace_existing=True,
)
scheduler.add_job(
scheduled_forecast_refresh,
"cron",
hour="*/2",
minute=5,
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:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
site_code = str(site["code"])
try:
row = await fetch_json(
conn,
"select ems.fn_site_economics_yesterday_notification($1::int)",
site_id,
)
if row is None or not isinstance(row, dict) or not row:
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.get("import_kwh") or 0),
import_cost=float(row.get("import_cost_czk") or 0),
export_kwh=float(row.get("export_kwh") or 0),
export_revenue=float(row.get("export_revenue_czk") or 0),
green_bonus=float(row.get("green_bonus_czk") or 0),
total_balance=float(row.get("total_balance_czk") or 0),
planned_balance=float(row["planned_balance_czk"])
if row.get("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))
app.state.telemetry_task = telemetry_task
yield
ws_h = getattr(app.state, "ws_log_handler", None)
if ws_h is not None:
logging.getLogger().removeHandler(ws_h)
app.state.ws_log_handler = None
telemetry_task.cancel()
try:
await telemetry_task
except asyncio.CancelledError:
pass
scheduler.shutdown(wait=False)
set_pg_pool(None)
app.state.pg_pool = None
await pg_pool.close()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
"""Sdílený hook po importu cen / forecastu obnova cache predikce záporných cen."""
from __future__ import annotations
import logging
import asyncpg
logger = logging.getLogger(__name__)
async def refresh_negative_price_predictions(conn: asyncpg.Connection, site_id: int) -> None:
try:
await conn.fetch(
"SELECT * FROM ems.fn_predict_negative_price_windows($1, 7)", site_id
)
except Exception:
logger.warning(
"fn_predict_negative_price_windows failed for site %s",
site_id,
exc_info=True,
)

33
backend/app/routers/me.py Normal file
View File

@@ -0,0 +1,33 @@
"""REST API /me (fáze bez auth)."""
from __future__ import annotations
from typing import Annotated, Any
import asyncpg
from fastapi import APIRouter, Depends
from app.db_json import record_to_dict
from app.deps import get_pg_pool
router = APIRouter(prefix="/api/v1/me", tags=["me"])
@router.get(
"/sites",
summary="Lokality přihlášeného uživatele (fáze bez auth)",
description="Aktuálně vrací všechny aktivní lokality z vw_site_directory; po zavedení autentizace se odfiltruje podle oprávnění.",
)
async def list_my_sites(
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[dict[str, Any]]:
async with db.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at
FROM ems.vw_site_directory
WHERE active = true
ORDER BY code
"""
)
return [record_to_dict(r) for r in rows]

View File

@@ -0,0 +1,483 @@
"""REST API lokality: ceny OTE, forecast, Modbus journal/verify."""
from __future__ import annotations
import json
import logging
from datetime import date, datetime, timedelta
from typing import Annotated, Any
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.db_json import fetch_json, record_to_dict
from app.deps import get_pg_pool
from app.refresh_negative_prices import refresh_negative_price_predictions
from services.control_exporter import read_deye_registers_live, verify_modbus_commands
from services.forecast_service import fetch_pv_forecast
from services.price_importer import import_ote_prices
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/sites", tags=["sites"])
def _parse_ymd(s: str) -> date:
try:
return date.fromisoformat(s)
except ValueError:
raise HTTPException(
status_code=400, detail="Invalid date, expected YYYY-MM-DD"
) from None
@router.get("")
async def list_sites(
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[dict[str, Any]]:
async with db.acquire() as conn:
rows = await conn.fetch(
"""
select id, code, name, timezone, latitude, longitude, active, notes, created_at
from ems.vw_site_directory
order by id
"""
)
return [record_to_dict(r) for r in rows]
@router.get("/{site_id}/prices")
async def get_site_prices(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
date_str: str | None = Query(
None, alias="date", description="YYYY-MM-DD, default today"
),
) -> list[dict[str, Any]]:
if date_str is None:
date_str = date.today().isoformat()
d = _parse_ymd(date_str)
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
rows = await fetch_json(
conn,
"select ems.fn_site_effective_prices_day_prague($1::int, $2::date)",
site_id,
d,
)
if not isinstance(rows, list):
rows = json.loads(rows) if isinstance(rows, str) else []
return [r for r in rows if isinstance(r, dict)]
class PricesImportResponse(BaseModel):
slots_imported: int
date: str
first_price_czk_kwh: float
class PricesLatestResponse(BaseModel):
latest_date: str
slots: int
min_price: float
max_price: float
avg_price: float
class ForecastRunResponse(BaseModel):
intervals_saved: int
pv_arrays: int
class ModbusCommandVerifyItem(BaseModel):
id: int
asset_code: str
register_name: str | None
value_to_write: int
value_verified: int | None
status: str
class ModbusVerifyResponse(BaseModel):
checked: int
verified: int
mismatch: int
commands: list[ModbusCommandVerifyItem]
@router.post(
"/{site_id}/prices/import",
response_model=PricesImportResponse,
summary="Import OTE cen (globální)",
description=(
"Zapíše do sdílené tabulky ems.market_interval_price (jedna sada dat pro všechny lokality). "
"site_id v cestě slouží ke kontrole existence lokality (kompatibilita s UI); po importu se "
"obnoví predikce záporných cen pro všechny aktivní lokality."
),
)
async def post_import_site_prices(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
date_str: str | None = Query(
None,
alias="date",
description="YYYY-MM-DD; výchozí = zítřek/dnes dle logiky OTE (Europe/Prague)",
),
) -> PricesImportResponse:
target: date | None = _parse_ymd(date_str) if date_str is not None else None
import_error: str | None = None
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
n, day, first_price, import_error = await import_ote_prices(
conn, site_id=None, target_date=target
)
if n >= 0:
sites_raw = await fetch_json(
conn, "select ems.fn_vw_site_directory_active()"
)
sites_list = sites_raw if isinstance(sites_raw, list) else []
for site in sites_list:
if isinstance(site, dict):
await refresh_negative_price_predictions(conn, int(site["id"]))
if n < 0:
raise HTTPException(
status_code=422,
detail=f"OTE import selhal ({import_error or 'unknown'})",
)
return PricesImportResponse(
slots_imported=n,
date=day,
first_price_czk_kwh=first_price,
)
class NegPricePredictionItem(BaseModel):
predicted_date: str
window_start_hour: int
window_end_hour: int
probability_pct: float
expected_min_price: float | None
reason: str
class NegativePredictionsResponse(BaseModel):
predictions: list[NegPricePredictionItem]
insufficient_history: bool
@router.get(
"/{site_id}/prices/negative-predictions",
response_model=NegativePredictionsResponse,
)
async def get_site_negative_price_predictions(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> NegativePredictionsResponse:
"""Cache predikce záporných cen (per site) + informace, zda je dost historie OTE."""
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
bundle = await fetch_json(
conn,
"select ems.fn_negative_price_predictions($1::int)",
site_id,
)
if not isinstance(bundle, dict):
bundle = json.loads(bundle)
rows = bundle.get("predictions") or []
if not isinstance(rows, list):
rows = []
predictions: list[NegPricePredictionItem] = []
for r in rows:
if not isinstance(r, dict):
continue
em = r.get("expected_min_price")
pd = r.get("predicted_date")
predictions.append(
NegPricePredictionItem(
predicted_date=pd.isoformat()
if hasattr(pd, "isoformat")
else str(pd),
window_start_hour=int(r.get("window_start_hour") or 0),
window_end_hour=int(r.get("window_end_hour") or 0),
probability_pct=float(r.get("probability_pct") or 0),
expected_min_price=float(em) if em is not None else None,
reason=str(r.get("reason") or ""),
)
)
return NegativePredictionsResponse(
predictions=predictions,
insufficient_history=bool(bundle.get("insufficient_history")),
)
@router.get("/{site_id}/prices/latest", response_model=PricesLatestResponse)
async def get_site_prices_latest(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> PricesLatestResponse:
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
row = await fetch_json(conn, "select ems.fn_latest_ote_day_stats()")
if not isinstance(row, dict):
row = json.loads(row)
day = row.get("latest_date")
if day is None:
raise HTTPException(status_code=404, detail="Žádná tržní data v databázi")
latest_date = day.isoformat() if hasattr(day, "isoformat") else str(day)[:10]
return PricesLatestResponse(
latest_date=latest_date,
slots=int(row.get("slots") or 0),
min_price=float(row.get("min_price") or 0.0),
max_price=float(row.get("max_price") or 0.0),
avg_price=float(row.get("avg_price") or 0.0),
)
@router.get("/{site_id}/control/verify", response_model=ModbusVerifyResponse)
async def get_verify_modbus_commands(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
minutes: int = Query(10, ge=1, le=1440, description="Jak daleko zpět hledat written příkazy"),
) -> ModbusVerifyResponse:
"""
Ruční ověření Modbus zápisů (written) z posledních N minut.
Vhodné hned po manuálním exportu setpointů.
"""
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
lookback = timedelta(minutes=minutes)
id_json = await fetch_json(
conn,
"select ems.fn_modbus_written_command_ids($1::int, $2::interval)",
site_id,
lookback,
)
if not isinstance(id_json, list):
id_json = json.loads(id_json) if isinstance(id_json, str) else []
ids = [int(x) for x in id_json]
checked = len(ids)
if ids:
await verify_modbus_commands(ids, conn, site_id)
detail_json = (
await fetch_json(
conn,
"select ems.fn_modbus_commands_by_ids($1::int[])",
ids,
)
if ids
else []
)
if ids and not isinstance(detail_json, list):
detail_json = json.loads(detail_json) if isinstance(detail_json, str) else []
detail_rows = detail_json if ids else []
commands = [
ModbusCommandVerifyItem(
id=int(r["id"]),
asset_code=str(r.get("asset_code") or ""),
register_name=r.get("register_name"),
value_to_write=int(r["value_to_write"]),
value_verified=int(r["value_verified"])
if r.get("value_verified") is not None
else None,
status=str(r.get("status") or ""),
)
for r in detail_rows
if isinstance(r, dict)
]
verified = sum(1 for c in commands if c.status == "verified")
mismatch = sum(1 for c in commands if c.status == "mismatch")
return ModbusVerifyResponse(
checked=checked,
verified=verified,
mismatch=mismatch,
commands=commands,
)
class DeyeRegistersLiveResponse(BaseModel):
reg108_charge_a: int
reg109_discharge_a: int
reg141_energy_mode: int
reg142_limit_control: int
reg143_export_limit_w: int
reg178_peak_shaving_switch: int
reg191_peak_shaving_w: int
read_at: str
@router.get(
"/{site_id}/control/registers",
response_model=DeyeRegistersLiveResponse,
)
async def get_control_registers_live(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> DeyeRegistersLiveResponse:
"""Živé hodnoty registrů Deye 108/109/141/142/143/178/191 přes sdílený Modbus klient."""
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
try:
payload = await read_deye_registers_live(site_id, conn)
except ValueError:
raise HTTPException(
status_code=404,
detail="No controllable Modbus inverter for this site",
) from None
except Exception as e:
logger.warning("get_control_registers_live site=%s: %s", site_id, e)
raise HTTPException(
status_code=503,
detail=f"Modbus read failed: {e}",
) from e
return DeyeRegistersLiveResponse(**payload)
class ModbusJournalCommandRow(BaseModel):
id: int
register: int
register_name: str | None
value_to_write: int
value_written: int | None
value_verified: int | None
status: str
attempt_count: int
created_at: str
class ModbusJournalListResponse(BaseModel):
commands: list[ModbusJournalCommandRow]
@router.get(
"/{site_id}/control/journal",
response_model=ModbusJournalListResponse,
)
async def get_control_command_journal(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
limit: int = Query(50, ge=1, le=100),
) -> ModbusJournalListResponse:
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
rows = await fetch_json(
conn,
"select ems.fn_modbus_journal_list($1::int, $2::int)",
site_id,
limit,
)
if not isinstance(rows, list):
rows = json.loads(rows) if isinstance(rows, str) else []
cmds: list[ModbusJournalCommandRow] = []
for r in rows:
d = r if isinstance(r, dict) else {}
ca = d["created_at"]
cmds.append(
ModbusJournalCommandRow(
id=int(d["id"]),
register=int(d["register"]),
register_name=d.get("register_name"),
value_to_write=int(d["value_to_write"]),
value_written=int(d["value_written"])
if d.get("value_written") is not None
else None,
value_verified=int(d["value_verified"])
if d.get("value_verified") is not None
else None,
status=str(d["status"]),
attempt_count=int(d["attempt_count"]),
created_at=ca if isinstance(ca, str) else str(ca),
)
)
return ModbusJournalListResponse(commands=cmds)
@router.post("/{site_id}/forecast/run", response_model=ForecastRunResponse)
async def post_run_site_forecast(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> ForecastRunResponse:
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
try:
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
except Exception as e:
logger.error("Forecast failed: %s", e, exc_info=True)
raise HTTPException(status_code=422, detail=str(e)) from e
if intervals >= 0:
await refresh_negative_price_predictions(conn, site_id)
if intervals < 0:
raise HTTPException(
status_code=422,
detail="Forecast se nepodařilo stáhnout nebo zpracovat",
)
return ForecastRunResponse(intervals_saved=intervals, pv_arrays=pv_arrays)
@router.get("/{site_id}/forecast/pv")
async def get_site_forecast_pv(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
date_str: str | None = Query(
None, alias="date", description="YYYY-MM-DD, default tomorrow"
),
) -> dict[str, list[dict[str, Any]]]:
if date_str is None:
date_str = (date.today() + timedelta(days=1)).isoformat()
d = _parse_ymd(date_str)
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
split = await fetch_json(
conn,
"select ems.fn_forecast_pv_split($1::int, $2::date)",
site_id,
d,
)
if not isinstance(split, dict):
split = json.loads(split) if isinstance(split, str) else {}
pv_a = split.get("pv_a") or []
pv_b = split.get("pv_b") or []
if not isinstance(pv_a, list):
pv_a = []
if not isinstance(pv_b, list):
pv_b = []
return {"pv_a": pv_a, "pv_b": pv_b}

View File

@@ -0,0 +1,141 @@
-- Read-modely: health, aktivní lokality pro joby, včerejší ekonomika (Discord), Loxone po změně režimu.
create or replace function ems.fn_health_summary()
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'db', 'ok',
'active_plan_slots', (
select count(*)::bigint
from ems.planning_interval pi
inner join ems.planning_run pr on pr.id = pi.run_id
where pr.status = 'active'
),
'timestamp', to_jsonb(now() at time zone 'utc')
);
$fn$;
comment on function ems.fn_health_summary() is
'Lehký health payload (COUNT aktivních intervalů + čas UTC).';
create or replace function ems.fn_health_detailed_db()
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'last_telemetry_age_sec', (
select case
when max(ti.measured_at) is null then -1
else greatest(
0,
extract(epoch from (now() - max(ti.measured_at)))::int
)
end
from ems.telemetry_inverter ti
),
'last_plan_age_sec', (
select case
when max(pr.created_at) is null then -1
else greatest(
0,
extract(epoch from (now() - max(pr.created_at)))::int
)
end
from ems.planning_run pr
where pr.status = 'active'
)
);
$fn$;
comment on function ems.fn_health_detailed_db() is
'Stáří poslední telemetrie (globální max) a posledního aktivního planning_run.';
create or replace function ems.fn_vw_site_directory_active()
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(
jsonb_build_object(
'id', sd.id,
'code', sd.code,
'name', sd.name,
'timezone', sd.timezone,
'latitude', sd.latitude,
'longitude', sd.longitude,
'active', sd.active,
'notes', sd.notes,
'created_at', sd.created_at
)
order by sd.id
),
'[]'::jsonb
)
from ems.vw_site_directory sd
where sd.active is true;
$fn$;
comment on function ems.fn_vw_site_directory_active() is
'Řádky vw_site_directory pro active=true (joby po lokalitách).';
create or replace function ems.fn_site_economics_yesterday_notification(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select to_jsonb(d)
from (
select
ed.import_kwh,
ed.export_kwh,
ed.import_cost_czk,
ed.export_revenue_czk,
ed.green_bonus_czk,
ed.total_balance_czk,
ed.planned_balance_czk
from ems.vw_economics_daily ed
where ed.site_id = p_site_id
and ed.day_local = (
(current_timestamp at time zone 'Europe/Prague')::date - 1
)
limit 1
) d;
$fn$;
comment on function ems.fn_site_economics_yesterday_notification(int) is
'Včerejší řádek vw_economics_daily (Europe/Prague) pro denní Discord souhrn.';
create or replace function ems.fn_site_mode_loxone_bundle(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'mode_code', m.mode_code,
'activated_at', m.activated_at,
'loxone_mode_value', d.loxone_mode_value,
'loxone_host', ep.host,
'loxone_port', ep.port,
'loxone_protocol', ep.protocol
)
from ems.site_operating_mode m
join ems.operating_mode_def d on d.code = m.mode_code
left join lateral (
select se.host, se.port, se.protocol
from ems.site_endpoint se
where se.site_id = p_site_id
and se.endpoint_type = 'loxone_http'
and se.enabled is true
order by se.id
limit 1
) ep on true
where m.site_id = p_site_id
limit 1;
$fn$;
comment on function ems.fn_site_mode_loxone_bundle(int) is
'Režim + Loxone endpoint po úspěšném zápisu režimu (API odpověď / push).';