second version
This commit is contained in:
@@ -8,6 +8,7 @@ import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
@@ -17,13 +18,29 @@ from app.deps import set_pg_pool
|
||||
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
|
||||
from app.ws_log_handler import WSLogHandler
|
||||
from app.ws_manager import manager
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from services.audit_filler import fill_audit_for_completed_intervals
|
||||
from services.control_exporter import (
|
||||
export_setpoints,
|
||||
read_deye_registers_live,
|
||||
verify_modbus_commands,
|
||||
)
|
||||
from services.heartbeat_service import send_heartbeat
|
||||
from services.forecast_service import fetch_pv_forecast
|
||||
from services.price_importer import import_ote_prices
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
|
||||
from services.audit_filler import fill_audit_for_completed_intervals
|
||||
from services.heartbeat_service import send_heartbeat
|
||||
from services.telemetry_collector import run_telemetry_loop_wrapper
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,7 +64,8 @@ async def get_pool() -> asyncpg.Pool:
|
||||
return pool
|
||||
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
# Cron hodiny/minuty = Europe/Prague (import OTE 13:30 / 14:00, denní plán 15:00, …)
|
||||
scheduler = AsyncIOScheduler(timezone=ZoneInfo("Europe/Prague"))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -57,7 +75,10 @@ async def lifespan(app: FastAPI):
|
||||
set_pg_pool(pool)
|
||||
app.state.pg_pool = pool
|
||||
|
||||
from services.control_exporter import export_setpoints
|
||||
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:
|
||||
@@ -78,6 +99,26 @@ async def lifespan(app: FastAPI):
|
||||
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:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
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:
|
||||
@@ -94,23 +135,200 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.exception("scheduled_control_export site=%s: %s", site["id"], e)
|
||||
|
||||
async def scheduled_daily_plan() -> None:
|
||||
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:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
await run_daily_plan(site["id"], conn)
|
||||
cmd_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id FROM ems.modbus_command
|
||||
WHERE site_id = $1
|
||||
AND status = 'written'
|
||||
AND written_at >= now() - INTERVAL '20 minutes'
|
||||
ORDER BY written_at
|
||||
""",
|
||||
site["id"],
|
||||
)
|
||||
if cmd_rows:
|
||||
await verify_modbus_commands(
|
||||
[int(r["id"]) for r in cmd_rows],
|
||||
conn,
|
||||
int(site["id"]),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("scheduled_daily_plan site=%s failed", site["id"])
|
||||
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:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
await run_daily_plan(site_id, conn)
|
||||
# Aplikuj nový active run okamžitě, nečekej na další 15min tick exportu.
|
||||
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:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
await run_rolling_replan(site["id"], conn)
|
||||
await run_rolling_replan(site_id, conn)
|
||||
# Aplikuj nový active run okamžitě, nečekej na další 15min tick exportu.
|
||||
await export_setpoints(site_id, conn)
|
||||
except Exception:
|
||||
logger.exception("scheduled_rolling_replan site=%s failed", site["id"])
|
||||
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:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
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:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
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:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
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:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
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, site_id: int, 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 _scheduled_ote_import_for_site(
|
||||
conn: asyncpg.Connection, site_id: int
|
||||
) -> None:
|
||||
tz_name = await conn.fetchval(
|
||||
"SELECT timezone FROM ems.site WHERE id = $1",
|
||||
site_id,
|
||||
)
|
||||
tz = ZoneInfo(tz_name or "Europe/Prague")
|
||||
now_loc = datetime.now(tz)
|
||||
today = now_loc.date()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
|
||||
# Zajistit data pro dnešek i zítřek; import jen pokud není kompletních 96 slotů.
|
||||
for day in (today, tomorrow):
|
||||
slots = await _count_ote_slots_for_day(conn, site_id, day)
|
||||
if slots >= 96:
|
||||
continue
|
||||
n, imported_day, _, err = await import_ote_prices(
|
||||
site_id, conn, target_date=day
|
||||
)
|
||||
if n < 0:
|
||||
logger.warning(
|
||||
"scheduled_ote_import site=%s day=%s failed (%s)",
|
||||
site_id,
|
||||
day.isoformat(),
|
||||
err,
|
||||
)
|
||||
continue
|
||||
logger.info(
|
||||
"scheduled_ote_import site=%s day=%s imported=%s",
|
||||
site_id,
|
||||
imported_day,
|
||||
n,
|
||||
)
|
||||
await _refresh_negative_price_predictions(conn, site_id)
|
||||
|
||||
async def scheduled_ote_import() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
await _scheduled_ote_import_for_site(conn, int(site["id"]))
|
||||
except Exception:
|
||||
logger.exception("scheduled_ote_import site=%s failed", site["id"])
|
||||
|
||||
scheduler.add_job(scheduled_heartbeat, "interval", seconds=60, id="heartbeat")
|
||||
scheduler.add_job(
|
||||
@@ -120,6 +338,13 @@ async def lifespan(app: FastAPI):
|
||||
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,
|
||||
@@ -128,6 +353,13 @@ async def lifespan(app: FastAPI):
|
||||
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,
|
||||
@@ -135,6 +367,62 @@ async def lifespan(app: FastAPI):
|
||||
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,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
|
||||
@@ -142,6 +430,11 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
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
|
||||
@@ -230,6 +523,45 @@ class ForecastRunResponse(BaseModel):
|
||||
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]
|
||||
|
||||
|
||||
class NegativePricePredictionItem(BaseModel):
|
||||
id: int
|
||||
predicted_at: datetime
|
||||
predicted_date: date
|
||||
window_start_hour: int
|
||||
window_end_hour: int
|
||||
probability_pct: int
|
||||
expected_min_price: float | None
|
||||
reason: str | None
|
||||
|
||||
|
||||
async def _refresh_negative_price_predictions(conn: asyncpg.Connection, site_id: int) -> None:
|
||||
"""Po importu cen / forecastu obnoví cache predikce záporných cen."""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@sites_router.post("/{site_id}/prices/import", response_model=PricesImportResponse)
|
||||
async def post_import_site_prices(
|
||||
site_id: int,
|
||||
@@ -241,15 +573,18 @@ async def post_import_site_prices(
|
||||
),
|
||||
) -> 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 = await import_ote_prices(site_id, conn, target_date=target)
|
||||
n, day, first_price, import_error = await import_ote_prices(site_id, conn, target_date=target)
|
||||
if n >= 0:
|
||||
await _refresh_negative_price_predictions(conn, site_id)
|
||||
if n < 0:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="OTE API nedostupné nebo nevrátilo data",
|
||||
detail=f"OTE import selhal ({import_error or 'unknown'})",
|
||||
)
|
||||
return PricesImportResponse(
|
||||
slots_imported=n,
|
||||
@@ -258,6 +593,66 @@ async def post_import_site_prices(
|
||||
)
|
||||
|
||||
|
||||
@sites_router.get(
|
||||
"/{site_id}/prices/negative-predictions",
|
||||
response_model=list[NegativePricePredictionItem],
|
||||
)
|
||||
async def get_site_negative_price_predictions(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pool)],
|
||||
) -> list[NegativePricePredictionItem]:
|
||||
"""Záznamy z cache predikce záporných cen na příštích 7 kalendářních dní (v časové zóně lokality)."""
|
||||
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 conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
p.id,
|
||||
p.predicted_at,
|
||||
p.predicted_date,
|
||||
p.window_start_hour,
|
||||
p.window_end_hour,
|
||||
p.probability_pct,
|
||||
p.expected_min_price,
|
||||
p.reason
|
||||
FROM ems.predicted_negative_price_window p
|
||||
WHERE p.site_id = $1
|
||||
AND p.predicted_date > (
|
||||
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
|
||||
NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''),
|
||||
'Europe/Prague'
|
||||
)
|
||||
)::date
|
||||
AND p.predicted_date <= (
|
||||
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
|
||||
NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''),
|
||||
'Europe/Prague'
|
||||
)
|
||||
)::date + 7
|
||||
ORDER BY p.predicted_date, p.window_start_hour
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
out: list[NegativePricePredictionItem] = []
|
||||
for r in rows:
|
||||
em = r["expected_min_price"]
|
||||
out.append(
|
||||
NegativePricePredictionItem(
|
||||
id=int(r["id"]),
|
||||
predicted_at=r["predicted_at"],
|
||||
predicted_date=r["predicted_date"],
|
||||
window_start_hour=int(r["window_start_hour"]),
|
||||
window_end_hour=int(r["window_end_hour"]),
|
||||
probability_pct=int(r["probability_pct"]),
|
||||
expected_min_price=float(em) if em is not None else None,
|
||||
reason=r["reason"],
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@sites_router.get("/{site_id}/prices/latest", response_model=PricesLatestResponse)
|
||||
async def get_site_prices_latest(
|
||||
site_id: int,
|
||||
@@ -293,6 +688,186 @@ async def get_site_prices_latest(
|
||||
)
|
||||
|
||||
|
||||
@sites_router.get("/{site_id}/control/verify", response_model=ModbusVerifyResponse)
|
||||
async def get_verify_modbus_commands(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_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)
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id FROM ems.modbus_command
|
||||
WHERE site_id = $1
|
||||
AND status = 'written'
|
||||
AND written_at >= now() - $2::interval
|
||||
ORDER BY written_at
|
||||
""",
|
||||
site_id,
|
||||
lookback,
|
||||
)
|
||||
ids = [int(r["id"]) for r in rows]
|
||||
checked = len(ids)
|
||||
if ids:
|
||||
await verify_modbus_commands(ids, conn, site_id)
|
||||
|
||||
detail_rows = (
|
||||
await conn.fetch(
|
||||
"""
|
||||
SELECT id, asset_code, register_name, value_to_write, value_verified, status
|
||||
FROM ems.modbus_command
|
||||
WHERE id = ANY($1::int[])
|
||||
ORDER BY id
|
||||
""",
|
||||
ids,
|
||||
)
|
||||
if ids
|
||||
else []
|
||||
)
|
||||
|
||||
commands = [
|
||||
ModbusCommandVerifyItem(
|
||||
id=int(r["id"]),
|
||||
asset_code=r["asset_code"],
|
||||
register_name=r["register_name"],
|
||||
value_to_write=int(r["value_to_write"]),
|
||||
value_verified=int(r["value_verified"])
|
||||
if r["value_verified"] is not None
|
||||
else None,
|
||||
status=r["status"],
|
||||
)
|
||||
for r in detail_rows
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
@sites_router.get(
|
||||
"/{site_id}/control/registers",
|
||||
response_model=DeyeRegistersLiveResponse,
|
||||
)
|
||||
async def get_control_registers_live(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_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]
|
||||
|
||||
|
||||
@sites_router.get(
|
||||
"/{site_id}/control/journal",
|
||||
response_model=ModbusJournalListResponse,
|
||||
)
|
||||
async def get_control_command_journal(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_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 conn.fetch(
|
||||
"""
|
||||
SELECT id, register, register_name, value_to_write, value_written,
|
||||
value_verified, status, attempt_count, created_at
|
||||
FROM ems.modbus_command
|
||||
WHERE site_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
""",
|
||||
site_id,
|
||||
limit,
|
||||
)
|
||||
cmds: list[ModbusJournalCommandRow] = []
|
||||
for r in rows:
|
||||
d = record_to_dict(r)
|
||||
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)
|
||||
|
||||
|
||||
@sites_router.post("/{site_id}/forecast/run", response_model=ForecastRunResponse)
|
||||
async def post_run_site_forecast(
|
||||
site_id: int,
|
||||
@@ -302,7 +877,13 @@ async def post_run_site_forecast(
|
||||
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")
|
||||
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
|
||||
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,
|
||||
@@ -326,14 +907,27 @@ async def get_site_forecast_pv(
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT fpi.*, apa.code AS pv_array_code
|
||||
FROM ems.forecast_pv_interval fpi
|
||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
||||
JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id
|
||||
WHERE fpr.site_id = $1
|
||||
AND fpi.interval_start::date = $2::date
|
||||
AND fpr.status = 'ok'
|
||||
ORDER BY apa.code, fpi.interval_start
|
||||
SELECT run_id, pv_array_id, interval_start, power_w,
|
||||
irradiance_wm2, temp_c, pv_array_code
|
||||
FROM (
|
||||
SELECT DISTINCT ON (fpi.interval_start, fpr.pv_array_id)
|
||||
fpi.run_id,
|
||||
fpi.pv_array_id,
|
||||
fpi.interval_start,
|
||||
fpi.power_w,
|
||||
fpi.irradiance_wm2,
|
||||
fpi.temp_c,
|
||||
apa.code AS pv_array_code
|
||||
FROM ems.forecast_pv_interval fpi
|
||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
||||
JOIN ems.asset_pv_array apa
|
||||
ON apa.id = fpr.pv_array_id AND apa.site_id = fpr.site_id
|
||||
WHERE fpr.site_id = $1
|
||||
AND fpi.interval_start::date = $2::date
|
||||
AND fpr.status = 'ok'
|
||||
ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC
|
||||
) latest
|
||||
ORDER BY pv_array_code, interval_start
|
||||
""",
|
||||
site_id,
|
||||
d,
|
||||
@@ -351,6 +945,45 @@ async def get_site_forecast_pv(
|
||||
return {"pv_a": pv_a, "pv_b": pv_b}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@sites_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_pool)],
|
||||
) -> NegativePredictionsResponse:
|
||||
"""Zástupný endpoint – predikce modelu doplnit později; historii počítáme z OTE dat."""
|
||||
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")
|
||||
ndays = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT (interval_start AT TIME ZONE 'Europe/Prague')::date)::int
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
|
||||
AND interval_start >= now() - INTERVAL '400 days'
|
||||
"""
|
||||
)
|
||||
n = int(ndays or 0)
|
||||
return NegativePredictionsResponse(predictions=[], insufficient_history=n < 28)
|
||||
|
||||
|
||||
app.include_router(sites_router)
|
||||
|
||||
app.add_middleware(
|
||||
@@ -362,6 +995,26 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@app.websocket("/ws/telemetry")
|
||||
async def ws_telemetry(websocket: WebSocket) -> None:
|
||||
await manager.connect_telemetry(websocket)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text() # keepalive
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
@app.websocket("/ws/logs")
|
||||
async def ws_logs(websocket: WebSocket) -> None:
|
||||
await manager.connect_logs(websocket)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
async def _health_payload(db: asyncpg.Pool) -> dict[str, Any]:
|
||||
db_status = "error"
|
||||
active_plan_slots = 0
|
||||
|
||||
Reference in New Issue
Block a user