x
This commit is contained in:
@@ -21,7 +21,7 @@ class Settings(BaseSettings):
|
||||
database_url: str | None = Field(default=None)
|
||||
|
||||
postgrest_jwt_secret: str = Field(default="")
|
||||
postgrest_anon_role: str = Field(default="ems_user")
|
||||
postgrest_anon_role: str = Field(default="ems_anon")
|
||||
|
||||
ote_api_url: str = Field(
|
||||
default="https://www.ote-cr.cz/pubapi/v1/market-data/dam",
|
||||
|
||||
35
backend/app/db_json.py
Normal file
35
backend/app/db_json.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""asyncpg Record → JSON-serializovatelný dict."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
def record_to_dict(r: asyncpg.Record) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for k in r.keys():
|
||||
v = r[k]
|
||||
if v is None:
|
||||
out[k] = None
|
||||
elif isinstance(v, datetime):
|
||||
if v.tzinfo is None:
|
||||
v = v.replace(tzinfo=timezone.utc)
|
||||
out[k] = v.isoformat()
|
||||
elif isinstance(v, date):
|
||||
out[k] = v.isoformat()
|
||||
elif isinstance(v, Decimal):
|
||||
out[k] = float(v)
|
||||
elif isinstance(v, UUID):
|
||||
out[k] = str(v)
|
||||
elif isinstance(v, (dict, list, str, int, float, bool)):
|
||||
out[k] = v
|
||||
elif isinstance(v, (bytes, memoryview)):
|
||||
out[k] = bytes(v).decode("utf-8", errors="replace")
|
||||
else:
|
||||
out[k] = str(v)
|
||||
return out
|
||||
268
backend/app/routers/full_status.py
Normal file
268
backend/app/routers/full_status.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""GET /sites/{site_id}/status/full – monitoring snapshot + alert pravidla."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
|
||||
|
||||
INV_STALE_SEC = 300
|
||||
HEARTBEAT_STALE_SEC = 300
|
||||
EXPECTED_TOMORROW_PRICE_SLOTS = 90
|
||||
|
||||
|
||||
def _iso_utc(dt: datetime | None) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _age_seconds(at: datetime | None) -> int | None:
|
||||
if at is None:
|
||||
return None
|
||||
if at.tzinfo is None:
|
||||
at = at.replace(tzinfo=timezone.utc)
|
||||
return max(0, int((datetime.now(timezone.utc) - at).total_seconds()))
|
||||
|
||||
|
||||
def _next_plan_interval(
|
||||
intervals: list[dict[str, Any]], now_utc: datetime
|
||||
) -> tuple[str | None, int | None]:
|
||||
"""Nejbližší 15min slot od aktuálního času včetně probíhajícího."""
|
||||
slot_ms = 15 * 60 * 1000
|
||||
boundary_ms = (int(now_utc.timestamp() * 1000) // slot_ms) * slot_ms
|
||||
boundary = datetime.fromtimestamp(boundary_ms / 1000, tz=timezone.utc)
|
||||
for row in sorted(intervals, key=lambda r: r["interval_start"]):
|
||||
istart = row["interval_start"]
|
||||
if isinstance(istart, str):
|
||||
istart = datetime.fromisoformat(istart.replace("Z", "+00:00"))
|
||||
if istart.tzinfo is None:
|
||||
istart = istart.replace(tzinfo=timezone.utc)
|
||||
if istart >= boundary - timedelta(milliseconds=1):
|
||||
bat = row.get("battery_setpoint_w")
|
||||
bi = int(bat) if bat is not None else None
|
||||
return _iso_utc(istart), bi
|
||||
return None, None
|
||||
|
||||
|
||||
@router.get("/status/full")
|
||||
async def get_site_status_full(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> dict[str, Any]:
|
||||
async with pool.acquire() as conn:
|
||||
site = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, code, name, timezone
|
||||
FROM ems.site
|
||||
WHERE id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if site is None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
tz = site["timezone"] or "Europe/Prague"
|
||||
|
||||
mode_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT m.mode_code, d.name AS mode_name, m.activated_at, m.activated_by
|
||||
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,
|
||||
)
|
||||
|
||||
hb_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT last_seen, status
|
||||
FROM ems.site_heartbeat
|
||||
WHERE site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
inv_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT pv_power_w, battery_soc_percent, grid_power_w, measured_at
|
||||
FROM ems.vw_latest_inverter
|
||||
WHERE site_id = $1
|
||||
ORDER BY measured_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
ev_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (charger_id)
|
||||
charger_code AS code,
|
||||
status,
|
||||
power_w,
|
||||
measured_at
|
||||
FROM ems.vw_latest_ev_charger
|
||||
WHERE site_id = $1
|
||||
ORDER BY charger_id, measured_at DESC NULLS LAST
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
hp_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT power_w, tuv_tank_temp_c, measured_at
|
||||
FROM ems.vw_latest_heat_pump
|
||||
WHERE site_id = $1
|
||||
ORDER BY measured_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
reserve_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT MIN(reserve_soc_percent)::float AS reserve_soc
|
||||
FROM ems.asset_battery
|
||||
WHERE site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
run_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, created_at
|
||||
FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
intervals: list[dict[str, Any]] = []
|
||||
if run_row:
|
||||
int_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT interval_start, battery_setpoint_w
|
||||
FROM ems.planning_interval
|
||||
WHERE run_id = $1
|
||||
ORDER BY interval_start
|
||||
""",
|
||||
run_row["id"],
|
||||
)
|
||||
intervals = [record_to_dict(r) for r in int_rows]
|
||||
|
||||
tomorrow_slots = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*)::int
|
||||
FROM ems.vw_site_effective_price v
|
||||
WHERE v.site_id = $1
|
||||
AND (v.interval_start AT TIME ZONE $2)::date =
|
||||
((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date
|
||||
""",
|
||||
site_id,
|
||||
tz,
|
||||
)
|
||||
tomorrow_slots = int(tomorrow_slots or 0)
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
hb_last = hb_row["last_seen"] if hb_row else None
|
||||
hb_age = _age_seconds(hb_last)
|
||||
inv_measured = inv_row["measured_at"] if inv_row else None
|
||||
inv_age = _age_seconds(inv_measured)
|
||||
|
||||
next_start, next_bat = _next_plan_interval(intervals, now_utc)
|
||||
|
||||
ev_list: list[dict[str, Any]] = []
|
||||
for r in ev_rows:
|
||||
ev_list.append(
|
||||
{
|
||||
"code": r["code"],
|
||||
"status": r["status"],
|
||||
"power_w": int(r["power_w"]) if r["power_w"] is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
telemetry: dict[str, Any] = {
|
||||
"inverter": {
|
||||
"pv_power_w": int(inv_row["pv_power_w"]) if inv_row and inv_row["pv_power_w"] is not None else None,
|
||||
"battery_soc_pct": float(inv_row["battery_soc_percent"])
|
||||
if inv_row and inv_row["battery_soc_percent"] is not None
|
||||
else None,
|
||||
"grid_power_w": int(inv_row["grid_power_w"]) if inv_row and inv_row["grid_power_w"] is not None else None,
|
||||
"measured_at": _iso_utc(inv_measured),
|
||||
"age_seconds": inv_age,
|
||||
},
|
||||
"ev_chargers": ev_list,
|
||||
"heat_pump": {
|
||||
"power_w": int(hp_row["power_w"]) if hp_row and hp_row["power_w"] is not None else None,
|
||||
"tank_temp_c": float(hp_row["tuv_tank_temp_c"])
|
||||
if hp_row and hp_row["tuv_tank_temp_c"] is not None
|
||||
else None,
|
||||
"measured_at": _iso_utc(hp_row["measured_at"]) if hp_row else None,
|
||||
},
|
||||
}
|
||||
|
||||
has_plan = run_row is not None
|
||||
planning = {
|
||||
"has_active_plan": has_plan,
|
||||
"plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None,
|
||||
"next_interval_start": next_start,
|
||||
"next_battery_setpoint_w": next_bat,
|
||||
}
|
||||
|
||||
mode_code = (mode_row["mode_code"] if mode_row else None) or ""
|
||||
reserve_soc = float(reserve_row["reserve_soc"]) if reserve_row and reserve_row["reserve_soc"] is not None else None
|
||||
soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None
|
||||
|
||||
alerts: list[dict[str, str]] = []
|
||||
|
||||
def add_alert(level: Literal["warn", "error"], message: str) -> None:
|
||||
alerts.append({"level": level, "message": message})
|
||||
|
||||
if inv_age is None or inv_age > INV_STALE_SEC:
|
||||
add_alert("error", "Telemetrie střídače nedostupná")
|
||||
|
||||
if not has_plan:
|
||||
add_alert("warn", "Není aktivní plán – EMS neoptimalizuje")
|
||||
|
||||
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS:
|
||||
add_alert("warn", "Chybí spotové ceny pro zítřek")
|
||||
|
||||
if mode_code.upper() == "MANUAL":
|
||||
add_alert("warn", "Systém v manuálním režimu")
|
||||
|
||||
if reserve_soc is not None and soc is not None and soc < reserve_soc:
|
||||
add_alert("error", "SoC baterie pod rezervou")
|
||||
|
||||
if hb_age is None or hb_age > HEARTBEAT_STALE_SEC:
|
||||
add_alert("error", "EMS heartbeat výpadek")
|
||||
|
||||
alerts.sort(key=lambda a: (0 if a["level"] == "error" else 1, a["message"]))
|
||||
|
||||
return {
|
||||
"site": {"id": site["id"], "code": site["code"], "name": site["name"]},
|
||||
"operating_mode": {
|
||||
"mode_code": mode_row["mode_code"] if mode_row else None,
|
||||
"mode_name": mode_row["mode_name"] if mode_row else None,
|
||||
"activated_at": _iso_utc(mode_row["activated_at"]) if mode_row else None,
|
||||
"activated_by": mode_row["activated_by"] if mode_row else None,
|
||||
},
|
||||
"heartbeat": {
|
||||
"last_seen": _iso_utc(hb_last),
|
||||
"age_seconds": hb_age,
|
||||
"status": hb_row["status"] if hb_row else None,
|
||||
},
|
||||
"telemetry": telemetry,
|
||||
"planning": planning,
|
||||
"alerts": alerts,
|
||||
}
|
||||
@@ -1,72 +1,33 @@
|
||||
"""REST API – aktivní plán a ruční přepočet."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.deps import get_pg_pool
|
||||
from services.planning_engine import run_plan_api
|
||||
from services.planning_engine import _current_slot_start, 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
|
||||
PRICE_CHECK_HOURS = 24
|
||||
_SLOTS_PER_HOUR = 4
|
||||
_EXPECTED_PRICE_SLOTS = PRICE_CHECK_HOURS * _SLOTS_PER_HOUR
|
||||
|
||||
|
||||
class RunPlanResponse(BaseModel):
|
||||
run_id: int
|
||||
solver_duration_ms: int
|
||||
horizon_start: datetime
|
||||
horizon_end: datetime
|
||||
|
||||
|
||||
def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
|
||||
def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
total_cost = 0.0
|
||||
curtailed_wh = 0.0
|
||||
total_curtailed_kwh = 0.0
|
||||
charge_slots = 0
|
||||
discharge_slots = 0
|
||||
export_slots = 0
|
||||
@@ -75,7 +36,7 @@ def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
|
||||
if ec is not None:
|
||||
total_cost += float(ec)
|
||||
c = row.get("pv_a_curtailed_w") or 0
|
||||
curtailed_wh += int(c) * 0.25
|
||||
total_curtailed_kwh += int(c) * 0.25 / 1000.0
|
||||
b = row.get("battery_setpoint_w")
|
||||
if b is not None:
|
||||
if int(b) > 0:
|
||||
@@ -85,153 +46,110 @@ def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
|
||||
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,
|
||||
)
|
||||
return {
|
||||
"total_expected_cost_czk": round(total_cost, 4),
|
||||
"total_pv_curtailed_kwh": round(total_curtailed_kwh, 6),
|
||||
"charge_slots": charge_slots,
|
||||
"discharge_slots": discharge_slots,
|
||||
"export_slots": export_slots,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentPlanResponse)
|
||||
@router.get("/current")
|
||||
async def get_current_plan(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> CurrentPlanResponse:
|
||||
) -> dict[str, Any]:
|
||||
async with pool.acquire() as conn:
|
||||
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
|
||||
if not exists:
|
||||
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")
|
||||
|
||||
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
|
||||
SELECT pr.*
|
||||
FROM ems.planning_run pr
|
||||
WHERE pr.site_id = $1 AND pr.status = 'active'
|
||||
ORDER BY pr.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not run_row:
|
||||
return CurrentPlanResponse(run=None, intervals=[], summary=None)
|
||||
raise HTTPException(status_code=404, detail="No active plan")
|
||||
|
||||
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
|
||||
SELECT *
|
||||
FROM ems.planning_interval
|
||||
WHERE run_id = $1
|
||||
ORDER BY 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)
|
||||
intervals = [record_to_dict(r) for r in int_rows]
|
||||
summary = _build_summary(intervals)
|
||||
return {"run": record_to_dict(run_row), "intervals": intervals, "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"),
|
||||
plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
|
||||
) -> RunPlanResponse:
|
||||
window_start = _current_slot_start(datetime.now(timezone.utc))
|
||||
window_end = window_start + timedelta(hours=PRICE_CHECK_HOURS)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
|
||||
if not exists:
|
||||
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")
|
||||
|
||||
price_slots = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT interval_start)::int
|
||||
FROM ems.vw_site_effective_price
|
||||
WHERE site_id = $1
|
||||
AND interval_start >= $2
|
||||
AND interval_start < $3
|
||||
""",
|
||||
site_id,
|
||||
window_start,
|
||||
window_end,
|
||||
)
|
||||
if (price_slots or 0) < _EXPECTED_PRICE_SLOTS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Nejsou dostupné tržní ceny. Spusťte nejdřív import cen.",
|
||||
)
|
||||
|
||||
try:
|
||||
run_id, duration_ms = await run_plan_api(
|
||||
site_id, conn, plan_type=plan_type, triggered_by="api"
|
||||
run_id, solver_duration_ms = await run_plan_api(
|
||||
site_id, plan_type, conn, 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)
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT horizon_start, horizon_end
|
||||
FROM ems.planning_run
|
||||
WHERE id = $1
|
||||
""",
|
||||
run_id,
|
||||
)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
|
||||
|
||||
return RunPlanResponse(
|
||||
run_id=run_id,
|
||||
solver_duration_ms=solver_duration_ms,
|
||||
horizon_start=row["horizon_start"],
|
||||
horizon_end=row["horizon_end"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user