This commit is contained in:
Dusan Vojacek
2026-03-20 14:29:43 +01:00
parent 8b4af663d8
commit 2cc5ccfda7
4 changed files with 603 additions and 11 deletions

View File

@@ -2,17 +2,27 @@
from __future__ import annotations
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Annotated
from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Any, Literal
import asyncpg
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.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 fastapi import Depends, FastAPI, HTTPException
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
@@ -31,13 +41,116 @@ def _dsn() -> str:
pool: asyncpg.Pool | None = None
async def get_pool() -> asyncpg.Pool:
if pool is None:
raise HTTPException(status_code=503, detail="Database pool not ready")
return pool
scheduler = AsyncIOScheduler()
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5)
set_pg_pool(pool)
app.state.pg_pool = pool
from services.control_exporter import export_setpoints
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:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
try:
await send_heartbeat(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:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
try:
await fill_audit_for_completed_intervals(site["id"], conn)
except Exception:
logger.exception("scheduled_audit_filler site=%s failed", site["id"])
async def scheduled_expire_modes() -> None:
async with app.state.pg_pool.acquire() as conn:
try:
await conn.fetchval("SELECT ems.fn_expire_modes()")
except Exception:
logger.exception("scheduled_expire_modes failed")
async def scheduled_control_export() -> 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 export_setpoints(site["id"], conn)
except Exception as e:
logger.exception("scheduled_control_export site=%s: %s", site["id"], e)
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:
try:
await run_daily_plan(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:
try:
await run_rolling_replan(site["id"], conn)
except Exception:
logger.exception("scheduled_rolling_replan site=%s failed", site["id"])
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_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_daily_plan, "cron", hour=15, minute=0, id="daily_plan")
scheduler.add_job(
scheduled_rolling_replan,
"cron",
minute="*/15",
id="rolling_replan",
)
scheduler.start()
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
app.state.telemetry_task = telemetry_task
yield
telemetry_task.cancel()
try:
await telemetry_task
except asyncio.CancelledError:
pass
scheduler.shutdown(wait=False)
set_pg_pool(None)
app.state.pg_pool = None
if pool:
await pool.close()
pool = None
@@ -46,6 +159,199 @@ async def lifespan(app: FastAPI):
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")
sites_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
@sites_router.get("")
async def list_sites(db: Annotated[asyncpg.Pool, Depends(get_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.site
ORDER BY id
"""
)
return [record_to_dict(r) for r in rows]
@sites_router.get("/{site_id}/prices")
async def get_site_prices(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_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 conn.fetch(
"""
SELECT *
FROM ems.vw_site_effective_price
WHERE site_id = $1 AND interval_start::date = $2::date
ORDER BY interval_start
""",
site_id,
d,
)
return [record_to_dict(r) for r in rows]
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
@sites_router.post("/{site_id}/prices/import", response_model=PricesImportResponse)
async def post_import_site_prices(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pool)],
date_str: str | None = Query(
None,
alias="date",
description="YYYY-MM-DD; výchozí = zítřek v časové zóně lokality",
),
) -> PricesImportResponse:
target: date | None = _parse_ymd(date_str) if date_str is not None else 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)
if n < 0:
raise HTTPException(
status_code=422,
detail="OTE API nedostupné nebo nevrátilo data",
)
return PricesImportResponse(
slots_imported=n,
date=day,
first_price_czk_kwh=first_price,
)
@sites_router.get("/{site_id}/prices/latest", response_model=PricesLatestResponse)
async def get_site_prices_latest(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_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 conn.fetchrow(
"""
SELECT
(interval_start AT TIME ZONE 'Europe/Prague')::date AS day,
COUNT(*)::int AS slots,
MIN(buy_raw_price_czk_kwh)::float AS min_price,
MAX(buy_raw_price_czk_kwh)::float AS max_price,
AVG(buy_raw_price_czk_kwh)::float AS avg_price
FROM ems.market_interval_price
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
GROUP BY day
ORDER BY day DESC
LIMIT 1
"""
)
if row is None or row["day"] is None:
raise HTTPException(status_code=404, detail="Žádná tržní data v databázi")
return PricesLatestResponse(
latest_date=row["day"].isoformat(),
slots=int(row["slots"] or 0),
min_price=float(row["min_price"] or 0.0),
max_price=float(row["max_price"] or 0.0),
avg_price=float(row["avg_price"] or 0.0),
)
@sites_router.post("/{site_id}/forecast/run", response_model=ForecastRunResponse)
async def post_run_site_forecast(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_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")
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
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)
@sites_router.get("/{site_id}/forecast/pv")
async def get_site_forecast_pv(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_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")
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
""",
site_id,
d,
)
pv_a: list[dict[str, Any]] = []
pv_b: list[dict[str, Any]] = []
for r in rows:
item = record_to_dict(r)
code = item.get("pv_array_code")
if code == "pv-a":
pv_a.append(item)
elif code == "pv-b":
pv_b.append(item)
return {"pv_a": pv_a, "pv_b": pv_b}
app.include_router(sites_router)
app.add_middleware(
CORSMiddleware,
@@ -56,15 +362,105 @@ app.add_middleware(
)
async def get_pool() -> asyncpg.Pool:
if pool is None:
raise HTTPException(status_code=503, detail="Database pool not ready")
return pool
async def _health_payload(db: asyncpg.Pool) -> dict[str, Any]:
db_status = "error"
active_plan_slots = 0
try:
async with db.acquire() as conn:
await conn.fetchval("SELECT 1")
db_status = "ok"
active_plan_slots = int(
await conn.fetchval(
"""
SELECT COUNT(*)::bigint
FROM ems.planning_interval pi
INNER JOIN ems.planning_run pr ON pr.id = pi.run_id
WHERE pr.status = 'active'
"""
)
or 0
)
except Exception as e:
logger.warning("health DB check failed: %s", e)
db_status = "error"
return {
"status": "ok" if db_status == "ok" else "degraded",
"db": db_status,
"timestamp": datetime.now(timezone.utc).isoformat(),
"active_plan_slots": active_plan_slots,
}
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/v1/health")
async def health(db: Annotated[asyncpg.Pool, Depends(get_pool)]) -> dict[str, Any]:
return await _health_payload(db)
@app.get("/api/v1/health/detailed")
async def health_detailed(
request: Request,
db: Annotated[asyncpg.Pool, Depends(get_pool)],
) -> dict[str, Any]:
db_status: Literal["ok", "error"] = "error"
last_telemetry_age_sec = -1
last_plan_age_sec = -1
try:
async with db.acquire() as conn:
await conn.fetchval("SELECT 1")
db_status = "ok"
tel = await conn.fetchval(
"""
SELECT CASE
WHEN MAX(measured_at) IS NULL THEN -1
ELSE GREATEST(0, EXTRACT(EPOCH FROM (now() - MAX(measured_at)))::int)
END
FROM ems.telemetry_inverter
"""
)
if tel is not None:
last_telemetry_age_sec = int(tel)
plan_age = await conn.fetchval(
"""
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'
"""
)
if plan_age is not None:
last_plan_age_sec = int(plan_age)
except Exception as e:
logger.warning("health detailed DB check failed: %s", e)
db_status = "error"
sched_state: Literal["running", "stopped"] = "running" if scheduler.running else "stopped"
t_task = getattr(request.app.state, "telemetry_task", None)
tel_loop: Literal["running", "stopped"] = (
"running" if t_task is not None and not t_task.done() else "stopped"
)
active_jobs: list[dict[str, Any]] = []
for job in scheduler.get_jobs():
nrt = job.next_run_time
active_jobs.append(
{
"id": str(job.id),
"next_run_time": nrt.isoformat() if nrt is not None else None,
}
)
return {
"db": db_status,
"scheduler": sched_state,
"telemetry_loop": tel_loop,
"last_telemetry_age_sec": last_telemetry_age_sec,
"last_plan_age_sec": last_plan_age_sec,
"active_jobs": active_jobs,
}
class SetSiteModeBody(BaseModel):

93
backend/app/routers/ev.py Normal file
View File

@@ -0,0 +1,93 @@
"""REST API aktivní EV session a úprava deadline / target SoC."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any
import asyncpg
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, field_validator
from app.db_json import record_to_dict
from app.deps import get_pg_pool
router = APIRouter(prefix="/sites/{site_id}/ev", tags=["ev"])
class EvSessionPatchBody(BaseModel):
target_soc_pct: float | None = None
target_deadline: datetime | None = None
@field_validator("target_soc_pct")
@classmethod
def _soc_range(cls, v: float | None) -> float | None:
if v is not None and not (10 <= v <= 100):
raise ValueError("target_soc_pct must be between 10 and 100")
return v
class EvSessionPatchResponse(BaseModel):
success: bool = True
session_id: int
@router.get("/sessions/active")
async def get_active_ev_sessions(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[dict[str, Any]]:
async with pool.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 es.id, es.charger_id, es.vehicle_id,
es.session_start, es.energy_delivered_wh,
es.target_soc_pct, es.target_deadline,
av.make, av.model, av.battery_capacity_kwh,
av.default_target_soc_pct, av.default_deadline_hour,
ac.code AS charger_code,
COALESCE(
NULLIF(TRIM(CONCAT_WS(' ', ac.manufacturer, ac.model)), ''),
ac.code
) AS charger_name
FROM ems.ev_session es
LEFT JOIN ems.asset_vehicle av ON av.id = es.vehicle_id
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
WHERE es.site_id = $1 AND es.session_end IS NULL
ORDER BY es.session_start DESC
""",
site_id,
)
return [record_to_dict(r) for r in rows]
@router.patch("/sessions/{session_id}", response_model=EvSessionPatchResponse)
async def patch_ev_session(
site_id: int,
session_id: int,
body: EvSessionPatchBody,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> EvSessionPatchResponse:
async with pool.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 conn.fetchrow(
"""
UPDATE ems.ev_session
SET target_soc_pct = $1, target_deadline = $2
WHERE id = $3 AND site_id = $4
RETURNING id
""",
body.target_soc_pct,
body.target_deadline,
session_id,
site_id,
)
if row is None:
raise HTTPException(status_code=404, detail="Session not found")
return EvSessionPatchResponse(success=True, session_id=int(row["id"]))

View File

@@ -1,5 +1,5 @@
# Stage 1 build static assets
FROM node:20-alpine AS builder
# Stage 1 build static assets (bookworm: glibc @tailwindcss/oxide má spolehlivé prebuildy; alpine/musl často selže při npm ci)
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./

103
scripts/smoke_test.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/bin/bash
set -euo pipefail
BASE="http://localhost:8000"
FRONT="http://localhost"
POSTGREST="http://localhost:3000"
echo "=== EMS Platform Smoke Test ==="
# 1. Health
echo -n "Health endpoint... "
curl -sf "$BASE/api/v1/health" | python3 -c "
import sys,json; d=json.load(sys.stdin)
assert d['status']=='ok', f'status={d[\"status\"]}'
assert d['db']=='ok', f'db={d[\"db\"]}'
print('OK')
"
# 2. Sites + první site_id (seed nemusí být vždy id=1)
echo -n "Sites endpoint... "
SITE_ID=$(curl -sf "$BASE/api/v1/sites" | python3 -c "
import sys,json; d=json.load(sys.stdin)
assert len(d)>0, 'no sites'
print(d[0]['id'])
")
echo "OK (site_id=$SITE_ID)"
# 3. Prices (dnes)
echo -n "Prices endpoint... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/sites/${SITE_ID}/prices")
[ "$STATUS" = "200" ] && echo "OK" || echo "WARN (HTTP $STATUS ceny možná nejsou importovány)"
# 4. Plan current (může být 404 bez plánu)
echo -n "Plan current... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/sites/${SITE_ID}/plan/current")
[ "$STATUS" = "200" ] && echo "OK (plán existuje)" || echo "OK (HTTP $STATUS zatím žádný plán)"
# 5. Import cen OTE (zítra)
echo -n "OTE price import... "
RESULT=$(curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/prices/import" \
-H "Content-Type: application/json" 2>/dev/null) || RESULT=""
if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('slots_imported',0)>0" 2>/dev/null; then
echo "OK ($(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['slots_imported'])") slotů)"
else
echo "WARN OTE API možná nemá data pro zítřek nebo je nedostupné"
fi
# 6. PV forecast
echo -n "PV forecast... "
RESULT=$(curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/forecast/run" 2>/dev/null) || RESULT=""
if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('intervals_saved',0)>0" 2>/dev/null; then
echo "OK"
else
echo "WARN forecast selhal (zkontroluj GPS souřadnice v seed datech)"
fi
# 7. Spustit plán (potřebuje ceny + forecast)
echo -n "Planning run... "
RESULT=$(curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/plan/run?type=daily" 2>/dev/null) || RESULT=""
if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('run_id') is not None" 2>/dev/null; then
echo "OK (run_id=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['run_id'])"))"
else
echo "WARN planning selhal: $RESULT"
fi
# 8. Přepnutí režimu a zpět (API + ověření přes PostgREST)
echo -n "Operating mode switch... "
curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/mode" \
-H "Content-Type: application/json" \
-d '{"mode":"PRESERVE","notes":"smoke test","valid_until":null}' >/dev/null || true
RESULT=$(curl -sf "${POSTGREST}/vw_site_status?site_id=eq.${SITE_ID}" 2>/dev/null) || RESULT=""
MODE=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['active_mode'])" 2>/dev/null) || MODE=""
if [ "$MODE" = "PRESERVE" ]; then
echo "OK"
else
echo "WARN (mode=$MODE)"
fi
curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/mode" \
-H "Content-Type: application/json" \
-d '{"mode":"AUTO","notes":"smoke test restore","valid_until":null}' >/dev/null || true
# 9. EV sessions endpoint
echo -n "EV sessions endpoint... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"$BASE/api/v1/sites/${SITE_ID}/ev/sessions/active")
[ "$STATUS" = "200" ] && echo "OK (HTTP 200)" || echo "FAIL (HTTP $STATUS)"
# 10. Frontend dostupný
echo -n "Frontend (nginx)... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$FRONT/")
[ "$STATUS" = "200" ] && echo "OK" || echo "FAIL (HTTP $STATUS)"
# 11. PostgREST přes nginx
echo -n "PostgREST přes nginx... "
if curl -sf "$FRONT/rest/vw_site_status?limit=1" | python3 -c "
import sys,json; d=json.load(sys.stdin); print(f'OK ({len(d)} rows)')
" 2>/dev/null; then
:
else
echo "WARN PostgREST možná potřebuje anon roli"
fi
echo ""
echo "=== Smoke test dokončen ==="