x
This commit is contained in:
@@ -2,17 +2,27 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import httpx
|
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.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.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 fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -31,13 +41,116 @@ def _dsn() -> str:
|
|||||||
pool: asyncpg.Pool | None = None
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global pool
|
global pool
|
||||||
pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5)
|
pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5)
|
||||||
set_pg_pool(pool)
|
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
|
yield
|
||||||
|
|
||||||
|
telemetry_task.cancel()
|
||||||
|
try:
|
||||||
|
await telemetry_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
set_pg_pool(None)
|
set_pg_pool(None)
|
||||||
|
app.state.pg_pool = None
|
||||||
if pool:
|
if pool:
|
||||||
await pool.close()
|
await pool.close()
|
||||||
pool = None
|
pool = None
|
||||||
@@ -46,6 +159,199 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="EMS Platform", lifespan=lifespan)
|
app = FastAPI(title="EMS Platform", lifespan=lifespan)
|
||||||
|
|
||||||
app.include_router(plan_router, prefix="/api/v1")
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -56,15 +362,105 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_pool() -> asyncpg.Pool:
|
async def _health_payload(db: asyncpg.Pool) -> dict[str, Any]:
|
||||||
if pool is None:
|
db_status = "error"
|
||||||
raise HTTPException(status_code=503, detail="Database pool not ready")
|
active_plan_slots = 0
|
||||||
return pool
|
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")
|
@app.get("/health")
|
||||||
async def health() -> dict[str, str]:
|
@app.get("/api/v1/health")
|
||||||
return {"status": "ok"}
|
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):
|
class SetSiteModeBody(BaseModel):
|
||||||
|
|||||||
93
backend/app/routers/ev.py
Normal file
93
backend/app/routers/ev.py
Normal 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"]))
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1 – build static assets
|
# Stage 1 – build static assets (bookworm: glibc – @tailwindcss/oxide má spolehlivé prebuildy; alpine/musl často selže při npm ci)
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-bookworm-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
|||||||
103
scripts/smoke_test.sh
Executable file
103
scripts/smoke_test.sh
Executable 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 ==="
|
||||||
Reference in New Issue
Block a user