Initial commit
Made-with: Cursor
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# EMS Platform FastAPI application
|
||||
47
backend/app/config.py
Normal file
47
backend/app/config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Application settings loaded from environment (see .env.example)."""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
db_host: str = Field(default="localhost")
|
||||
db_port: int = Field(default=5432)
|
||||
db_name: str = Field(default="ems")
|
||||
db_user: str = Field(default="ems_user")
|
||||
db_password: str = Field(default="")
|
||||
database_url: str | None = Field(default=None)
|
||||
|
||||
postgrest_jwt_secret: str = Field(default="")
|
||||
postgrest_anon_role: str = Field(default="ems_user")
|
||||
|
||||
ote_api_url: str = Field(
|
||||
default="https://www.ote-cr.cz/pubapi/v1/market-data/dam",
|
||||
)
|
||||
eur_czk_rate: float = Field(default=25.0)
|
||||
|
||||
open_meteo_api_url: str = Field(
|
||||
default="https://api.open-meteo.com/v1/forecast",
|
||||
)
|
||||
|
||||
loxone_user: str = Field(default="")
|
||||
loxone_password: str = Field(default="")
|
||||
|
||||
telemetry_poll_interval_sec: int = Field(default=60)
|
||||
planning_horizon_hours: int = Field(default=36)
|
||||
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)
|
||||
planning_cheap_price_threshold: float = Field(default=0.85)
|
||||
planning_expensive_price_threshold: float = Field(default=1.15)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
48
backend/app/database.py
Normal file
48
backend/app/database.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""asyncpg connection pool and DB access helpers."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def init_db_pool() -> None:
|
||||
"""Create global pool (call from FastAPI lifespan)."""
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
return
|
||||
s = get_settings()
|
||||
_pool = await asyncpg.create_pool(
|
||||
host=s.db_host,
|
||||
port=s.db_port,
|
||||
user=s.db_user,
|
||||
password=s.db_password,
|
||||
database=s.db_name,
|
||||
min_size=1,
|
||||
max_size=10,
|
||||
)
|
||||
|
||||
|
||||
async def close_db_pool() -> None:
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
def get_pool() -> asyncpg.Pool:
|
||||
if _pool is None:
|
||||
raise RuntimeError("DB pool not initialized; call init_db_pool() first")
|
||||
return _pool
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db() -> AsyncIterator[asyncpg.Connection]:
|
||||
"""Async context manager yielding a connection from the pool."""
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
yield conn
|
||||
19
backend/app/deps.py
Normal file
19
backend/app/deps.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Sdílené FastAPI závislosti (DB pool)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncpg
|
||||
from fastapi import HTTPException
|
||||
|
||||
_pg_pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
def set_pg_pool(pool: asyncpg.Pool | None) -> None:
|
||||
global _pg_pool
|
||||
_pg_pool = pool
|
||||
|
||||
|
||||
async def get_pg_pool() -> asyncpg.Pool:
|
||||
if _pg_pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database pool not ready")
|
||||
return _pg_pool
|
||||
157
backend/app/main.py
Normal file
157
backend/app/main.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""EMS FastAPI – health, provozní režimy, PostgREST doplňky."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
from app.deps import set_pg_pool
|
||||
from app.routers.plan import router as plan_router
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global pool
|
||||
pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5)
|
||||
set_pg_pool(pool)
|
||||
yield
|
||||
set_pg_pool(None)
|
||||
if pool:
|
||||
await pool.close()
|
||||
pool = None
|
||||
|
||||
|
||||
app = FastAPI(title="EMS Platform", lifespan=lifespan)
|
||||
|
||||
app.include_router(plan_router, prefix="/api/v1")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database pool not ready")
|
||||
return pool
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
class SetSiteModeBody(BaseModel):
|
||||
mode: str = Field(..., min_length=1)
|
||||
notes: str | None = None
|
||||
valid_until: datetime | None = None
|
||||
|
||||
|
||||
class SetSiteModeResponse(BaseModel):
|
||||
success: bool
|
||||
mode: str
|
||||
activated_at: datetime
|
||||
|
||||
|
||||
@app.post("/api/v1/sites/{site_id}/mode", response_model=SetSiteModeResponse)
|
||||
async def set_site_mode(
|
||||
site_id: int,
|
||||
body: SetSiteModeBody,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pool)],
|
||||
) -> SetSiteModeResponse:
|
||||
mode = body.mode.strip().upper()
|
||||
allowed = {"AUTO", "SELF_SUSTAIN", "CHARGE_CHEAP", "PRESERVE", "MANUAL"}
|
||||
if mode not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported mode: {body.mode}")
|
||||
|
||||
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:
|
||||
await conn.execute(
|
||||
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
|
||||
site_id,
|
||||
mode,
|
||||
"user:api",
|
||||
body.valid_until,
|
||||
body.notes,
|
||||
)
|
||||
except asyncpg.PostgresError as e:
|
||||
logger.warning("fn_set_mode failed: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT m.mode_code, m.activated_at, d.loxone_mode_value
|
||||
FROM ems.site_operating_mode m
|
||||
JOIN ems.operating_mode_def d ON d.code = m.mode_code
|
||||
WHERE m.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=500, detail="Mode row missing after set")
|
||||
|
||||
ep = await conn.fetchrow(
|
||||
"""
|
||||
SELECT host, port, protocol
|
||||
FROM ems.site_endpoint
|
||||
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
activated_at: datetime = row["activated_at"]
|
||||
if activated_at.tzinfo is None:
|
||||
activated_at = activated_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
loxone_val: int | None = row["loxone_mode_value"]
|
||||
if ep and loxone_val is not None:
|
||||
proto = (ep["protocol"] or "http").lower()
|
||||
if proto not in ("http", "https"):
|
||||
proto = "http"
|
||||
host = ep["host"]
|
||||
port = int(ep["port"] or (443 if proto == "https" else 80))
|
||||
base = f"{proto}://{host}:{port}"
|
||||
url = f"{base}/dev/sps/io/EMS_Mode/{loxone_val}"
|
||||
user = os.getenv("LOXONE_USER") or ""
|
||||
password = os.getenv("LOXONE_PASSWORD") or ""
|
||||
auth = (user, password) if user else None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.get(url, auth=auth)
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.warning("Loxone EMS_Mode notify failed for site %s: %s", site_id, e)
|
||||
|
||||
return SetSiteModeResponse(success=True, mode=row["mode_code"], activated_at=activated_at)
|
||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""FastAPI routers."""
|
||||
237
backend/app/routers/plan.py
Normal file
237
backend/app/routers/plan.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""REST API – aktivní plán a ruční přepočet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.deps import get_pg_pool
|
||||
from services.planning_engine import run_plan_api
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"])
|
||||
|
||||
|
||||
class PlanningRunOut(BaseModel):
|
||||
id: int
|
||||
created_at: datetime
|
||||
run_type: str
|
||||
horizon_start: datetime
|
||||
horizon_end: datetime
|
||||
forecast_correction_factor: float | None = None
|
||||
solver_duration_ms: int | None = None
|
||||
|
||||
|
||||
class PlanningIntervalOut(BaseModel):
|
||||
interval_start: datetime
|
||||
battery_setpoint_w: int | None = None
|
||||
battery_soc_target_pct: float | None = None
|
||||
grid_setpoint_w: int | None = None
|
||||
ev1_setpoint_w: int | None = None
|
||||
ev2_setpoint_w: int | None = None
|
||||
heat_pump_enabled: bool | None = None
|
||||
pv_a_curtailed_w: int | None = None
|
||||
expected_cost_czk: float | None = None
|
||||
effective_buy_price: float | None = None
|
||||
effective_sell_price: float | None = None
|
||||
pv_forecast_total_w: int | None = Field(
|
||||
default=None,
|
||||
description="Součet FVE forecast A+B pro graf (k aktuálnímu slotu z DB).",
|
||||
)
|
||||
load_baseline_w: int | None = Field(
|
||||
default=None,
|
||||
description="Bazální spotřeba forecast pro graf.",
|
||||
)
|
||||
|
||||
|
||||
class PlanningSummaryOut(BaseModel):
|
||||
total_expected_cost_czk: float
|
||||
total_pv_curtailed_kwh: float
|
||||
charge_slots: int
|
||||
discharge_slots: int
|
||||
export_slots: int
|
||||
|
||||
|
||||
class CurrentPlanResponse(BaseModel):
|
||||
run: PlanningRunOut | None
|
||||
intervals: list[PlanningIntervalOut]
|
||||
summary: PlanningSummaryOut | None
|
||||
|
||||
|
||||
class RunPlanResponse(BaseModel):
|
||||
run_id: int
|
||||
solver_duration_ms: int
|
||||
|
||||
|
||||
def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
|
||||
total_cost = 0.0
|
||||
curtailed_wh = 0.0
|
||||
charge_slots = 0
|
||||
discharge_slots = 0
|
||||
export_slots = 0
|
||||
for row in intervals:
|
||||
ec = row.get("expected_cost_czk")
|
||||
if ec is not None:
|
||||
total_cost += float(ec)
|
||||
c = row.get("pv_a_curtailed_w") or 0
|
||||
curtailed_wh += int(c) * 0.25
|
||||
b = row.get("battery_setpoint_w")
|
||||
if b is not None:
|
||||
if int(b) > 0:
|
||||
charge_slots += 1
|
||||
elif int(b) < 0:
|
||||
discharge_slots += 1
|
||||
g = row.get("grid_setpoint_w")
|
||||
if g is not None and int(g) < 0:
|
||||
export_slots += 1
|
||||
return PlanningSummaryOut(
|
||||
total_expected_cost_czk=round(total_cost, 4),
|
||||
total_pv_curtailed_kwh=round(curtailed_wh / 1000.0, 6),
|
||||
charge_slots=charge_slots,
|
||||
discharge_slots=discharge_slots,
|
||||
export_slots=export_slots,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentPlanResponse)
|
||||
async def get_current_plan(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> CurrentPlanResponse:
|
||||
async with pool.acquire() as conn:
|
||||
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
|
||||
if not exists:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
run_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, created_at, run_type, horizon_start, horizon_end,
|
||||
forecast_correction_factor, solver_duration_ms
|
||||
FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not run_row:
|
||||
return CurrentPlanResponse(run=None, intervals=[], summary=None)
|
||||
|
||||
run_id = run_row["id"]
|
||||
int_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
pi.interval_start,
|
||||
pi.battery_setpoint_w,
|
||||
pi.battery_soc_target_pct,
|
||||
pi.grid_setpoint_w,
|
||||
pi.ev1_setpoint_w,
|
||||
pi.ev2_setpoint_w,
|
||||
pi.heat_pump_enabled,
|
||||
pi.pv_a_curtailed_w,
|
||||
pi.expected_cost_czk,
|
||||
pi.effective_buy_price,
|
||||
pi.effective_sell_price,
|
||||
COALESCE(fa.power_w, 0) + COALESCE(fb.power_w, 0) AS pv_forecast_total_w,
|
||||
COALESCE(cbi.power_w, 500) AS load_baseline_w
|
||||
FROM ems.planning_interval pi
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT fpi.power_w
|
||||
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 = $2
|
||||
AND apa.code = 'pv-a'
|
||||
AND fpi.interval_start = pi.interval_start
|
||||
AND fpr.status = 'ok'
|
||||
ORDER BY fpr.created_at DESC
|
||||
LIMIT 1
|
||||
) fa ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT fpi.power_w
|
||||
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 = $2
|
||||
AND apa.code = 'pv-b'
|
||||
AND fpi.interval_start = pi.interval_start
|
||||
AND fpr.status = 'ok'
|
||||
ORDER BY fpr.created_at DESC
|
||||
LIMIT 1
|
||||
) fb ON true
|
||||
LEFT JOIN ems.consumption_baseline_interval cbi
|
||||
ON cbi.site_id = $2
|
||||
AND cbi.interval_start = pi.interval_start
|
||||
AND cbi.data_type = 'forecast'
|
||||
WHERE pi.run_id = $1
|
||||
ORDER BY pi.interval_start
|
||||
""",
|
||||
run_id,
|
||||
site_id,
|
||||
)
|
||||
|
||||
intervals_dicts = [dict(r) for r in int_rows]
|
||||
summary = _build_summary(intervals_dicts) if intervals_dicts else None
|
||||
|
||||
run_out = PlanningRunOut(
|
||||
id=run_row["id"],
|
||||
created_at=run_row["created_at"],
|
||||
run_type=run_row["run_type"],
|
||||
horizon_start=run_row["horizon_start"],
|
||||
horizon_end=run_row["horizon_end"],
|
||||
forecast_correction_factor=float(run_row["forecast_correction_factor"])
|
||||
if run_row["forecast_correction_factor"] is not None
|
||||
else None,
|
||||
solver_duration_ms=run_row["solver_duration_ms"],
|
||||
)
|
||||
|
||||
intervals_out = [
|
||||
PlanningIntervalOut(
|
||||
interval_start=r["interval_start"],
|
||||
battery_setpoint_w=r["battery_setpoint_w"],
|
||||
battery_soc_target_pct=float(r["battery_soc_target_pct"])
|
||||
if r["battery_soc_target_pct"] is not None
|
||||
else None,
|
||||
grid_setpoint_w=r["grid_setpoint_w"],
|
||||
ev1_setpoint_w=r["ev1_setpoint_w"],
|
||||
ev2_setpoint_w=r["ev2_setpoint_w"],
|
||||
heat_pump_enabled=r["heat_pump_enabled"],
|
||||
pv_a_curtailed_w=r["pv_a_curtailed_w"],
|
||||
expected_cost_czk=float(r["expected_cost_czk"])
|
||||
if r["expected_cost_czk"] is not None
|
||||
else None,
|
||||
effective_buy_price=float(r["effective_buy_price"])
|
||||
if r["effective_buy_price"] is not None
|
||||
else None,
|
||||
effective_sell_price=float(r["effective_sell_price"])
|
||||
if r["effective_sell_price"] is not None
|
||||
else None,
|
||||
pv_forecast_total_w=int(r["pv_forecast_total_w"] or 0),
|
||||
load_baseline_w=int(r["load_baseline_w"] or 0),
|
||||
)
|
||||
for r in intervals_dicts
|
||||
]
|
||||
|
||||
return CurrentPlanResponse(run=run_out, intervals=intervals_out, summary=summary)
|
||||
|
||||
|
||||
@router.post("/run", response_model=RunPlanResponse)
|
||||
async def post_run_plan(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
plan_type: Literal["daily", "rolling"] = Query(..., alias="type"),
|
||||
) -> RunPlanResponse:
|
||||
async with pool.acquire() as conn:
|
||||
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
|
||||
if not exists:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
try:
|
||||
run_id, duration_ms = await run_plan_api(
|
||||
site_id, conn, plan_type=plan_type, triggered_by="api"
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
return RunPlanResponse(run_id=run_id, solver_duration_ms=duration_ms)
|
||||
Reference in New Issue
Block a user