second version
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
import asyncpg
|
||||
@@ -91,3 +91,88 @@ async def patch_ev_session(
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return EvSessionPatchResponse(success=True, session_id=int(row["id"]))
|
||||
|
||||
|
||||
class ArrivalHourItem(BaseModel):
|
||||
hour: int
|
||||
confidence_pct: int
|
||||
samples: int
|
||||
|
||||
|
||||
class ChargerTomorrowArrival(BaseModel):
|
||||
tomorrow: list[ArrivalHourItem]
|
||||
|
||||
|
||||
class EvArrivalPredictionResponse(BaseModel):
|
||||
insufficient_data: bool
|
||||
tomorrow_date: str
|
||||
chargers: dict[str, ChargerTomorrowArrival]
|
||||
|
||||
|
||||
@router.get("/arrival-prediction", response_model=EvArrivalPredictionResponse)
|
||||
async def get_ev_arrival_prediction(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> EvArrivalPredictionResponse:
|
||||
"""Top hodiny příjezdu z ems.fn_ev_expected_arrival; při <5 session celkem insufficient_data."""
|
||||
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")
|
||||
|
||||
n_sessions = int(
|
||||
await conn.fetchval(
|
||||
"SELECT COUNT(*)::int FROM ems.ev_session WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
or 0
|
||||
)
|
||||
insufficient = n_sessions < 5
|
||||
|
||||
tomorrow = await conn.fetchval(
|
||||
"""
|
||||
SELECT (
|
||||
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
|
||||
NULLIF(TRIM(timezone), ''),
|
||||
'Europe/Prague'
|
||||
)
|
||||
)::date + 1
|
||||
FROM ems.site
|
||||
WHERE id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if tomorrow is None:
|
||||
raise HTTPException(status_code=500, detail="Site date resolution failed")
|
||||
tomorrow_d: date = tomorrow
|
||||
|
||||
chargers_rows = await conn.fetch(
|
||||
"SELECT id, code FROM ems.asset_ev_charger WHERE site_id = $1 ORDER BY id",
|
||||
site_id,
|
||||
)
|
||||
|
||||
chargers: dict[str, ChargerTomorrowArrival] = {}
|
||||
for ch in chargers_rows:
|
||||
code = str(ch["code"])
|
||||
preds = await conn.fetch(
|
||||
"SELECT * FROM ems.fn_ev_expected_arrival($1, $2, $3::date)",
|
||||
site_id,
|
||||
ch["id"],
|
||||
tomorrow_d,
|
||||
)
|
||||
chargers[code] = ChargerTomorrowArrival(
|
||||
tomorrow=[
|
||||
ArrivalHourItem(
|
||||
hour=int(r["expected_hour"]),
|
||||
confidence_pct=int(r["confidence_pct"]),
|
||||
samples=int(r["sample_count"]),
|
||||
)
|
||||
for r in preds
|
||||
]
|
||||
)
|
||||
|
||||
return EvArrivalPredictionResponse(
|
||||
insufficient_data=insufficient,
|
||||
tomorrow_date=tomorrow_d.isoformat(),
|
||||
chargers=chargers,
|
||||
)
|
||||
|
||||
@@ -2,17 +2,38 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.deps import get_pg_pool
|
||||
from app.notifications_logic import (
|
||||
EvSessionRow,
|
||||
NegWindowRow,
|
||||
PriceSlot,
|
||||
build_smart_notifications,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
|
||||
|
||||
|
||||
class SiteNotificationItem(BaseModel):
|
||||
id: str
|
||||
level: Literal["success", "info", "warning", "error"]
|
||||
title: str
|
||||
body: str
|
||||
eta_minutes: int | None = None
|
||||
action: Literal["connect_ev", "replan", "import_prices", "switch_auto"] | None = None
|
||||
|
||||
|
||||
class SiteNotificationsResponse(BaseModel):
|
||||
notifications: list[SiteNotificationItem] = Field(default_factory=list)
|
||||
|
||||
INV_STALE_SEC = 300
|
||||
HEARTBEAT_STALE_SEC = 300
|
||||
EXPECTED_TOMORROW_PRICE_SLOTS = 90
|
||||
@@ -235,7 +256,10 @@ async def get_site_status_full(
|
||||
if not has_plan:
|
||||
add_alert("warn", "Není aktivní plán – EMS neoptimalizuje")
|
||||
|
||||
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS:
|
||||
# OTE D+1 typicky až po ~14:30 Europe/Prague – před tím nevarovat
|
||||
now_prague = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
prices_expected = (now_prague.hour, now_prague.minute) >= (14, 30)
|
||||
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS and prices_expected:
|
||||
add_alert("warn", "Chybí spotové ceny pro zítřek")
|
||||
|
||||
if mode_code.upper() == "MANUAL":
|
||||
@@ -266,3 +290,326 @@ async def get_site_status_full(
|
||||
"planning": planning,
|
||||
"alerts": alerts,
|
||||
}
|
||||
|
||||
|
||||
_NOTIF_LEVEL_PRIORITY = {"error": 0, "success": 1, "warning": 2, "info": 3}
|
||||
|
||||
|
||||
def _infrastructure_notification_items(
|
||||
*,
|
||||
has_plan: bool,
|
||||
tomorrow_slots: int,
|
||||
mode_code: str,
|
||||
reserve_soc: float | None,
|
||||
soc: float | None,
|
||||
inv_age: int | None,
|
||||
hb_age: int | None,
|
||||
) -> list[SiteNotificationItem]:
|
||||
"""Kritické / provozní notifikace (telemetrie, plán, ceny, režim, heartbeat)."""
|
||||
items: list[SiteNotificationItem] = []
|
||||
|
||||
def push(
|
||||
nid: str,
|
||||
level: Literal["success", "info", "warning", "error"],
|
||||
title: str,
|
||||
body: str,
|
||||
*,
|
||||
eta_minutes: int | None = None,
|
||||
action: Literal["connect_ev", "replan", "import_prices", "switch_auto"] | None = None,
|
||||
) -> None:
|
||||
items.append(
|
||||
SiteNotificationItem(
|
||||
id=nid,
|
||||
level=level,
|
||||
title=title,
|
||||
body=body,
|
||||
eta_minutes=eta_minutes,
|
||||
action=action,
|
||||
)
|
||||
)
|
||||
|
||||
if inv_age is None or inv_age > INV_STALE_SEC:
|
||||
push("telemetry_inverter", "error", "Telemetrie střídače", "Data ze střídače nejsou aktuální.")
|
||||
|
||||
if not has_plan:
|
||||
push(
|
||||
"no_active_plan",
|
||||
"warning",
|
||||
"Chybí aktivní plán",
|
||||
"EMS zatím neoptimalizuje provoz – spusťte plánování.",
|
||||
action="replan",
|
||||
)
|
||||
|
||||
now_prague = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
prices_expected = (now_prague.hour, now_prague.minute) >= (14, 30)
|
||||
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS and prices_expected:
|
||||
push(
|
||||
"prices_tomorrow",
|
||||
"warning",
|
||||
"Ceny na zítřek",
|
||||
"Nejsou kompletní spotové ceny OTE pro následující den.",
|
||||
action="import_prices",
|
||||
)
|
||||
|
||||
if mode_code.upper() == "MANUAL":
|
||||
push("mode_manual", "info", "Manuální režim", "Automatická optimalizace je vypnutá.")
|
||||
|
||||
if reserve_soc is not None and soc is not None and soc < reserve_soc:
|
||||
push("soc_reserve", "error", "SoC pod rezervou", "Nabití baterie je pod nastavenou bezpečnostní rezervou.")
|
||||
|
||||
if hb_age is None or hb_age > HEARTBEAT_STALE_SEC:
|
||||
push("heartbeat", "error", "EMS heartbeat", "Služba EMS nehlásí pravidelný heartbeat.")
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _float_or_none(v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
return float(v)
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=SiteNotificationsResponse)
|
||||
async def get_site_notifications(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> SiteNotificationsResponse:
|
||||
async with pool.acquire() as conn:
|
||||
site = await conn.fetchrow(
|
||||
"SELECT id, 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
|
||||
FROM ems.site_operating_mode m
|
||||
WHERE m.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
run_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
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,
|
||||
)
|
||||
inv_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT battery_soc_percent, measured_at
|
||||
FROM ems.vw_latest_inverter
|
||||
WHERE site_id = $1
|
||||
ORDER BY measured_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
hb_row = await conn.fetchrow(
|
||||
"SELECT last_seen FROM ems.site_heartbeat WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
price_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT interval_start,
|
||||
effective_buy_price_czk_kwh,
|
||||
effective_sell_price_czk_kwh
|
||||
FROM ems.vw_site_effective_price
|
||||
WHERE site_id = $1
|
||||
AND interval_start >= now()
|
||||
AND interval_start < now() + INTERVAL '48 hours'
|
||||
ORDER BY interval_start
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
avg_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT AVG(effective_buy_price_czk_kwh)::float AS avg_buy
|
||||
FROM ems.vw_site_effective_price
|
||||
WHERE site_id = $1
|
||||
AND interval_start::date IN (CURRENT_DATE, CURRENT_DATE + INTERVAL '1 day')
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
bat_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float AS usable_wh
|
||||
FROM ems.asset_battery ab
|
||||
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id
|
||||
WHERE ai.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
ev_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (es.id)
|
||||
es.id,
|
||||
es.charger_id,
|
||||
es.energy_delivered_wh,
|
||||
es.target_soc_pct,
|
||||
es.session_start,
|
||||
es.soc_at_connect_pct,
|
||||
COALESCE(av_id.battery_capacity_kwh, av_def.battery_capacity_kwh) AS battery_capacity_kwh,
|
||||
COALESCE(av_id.make, av_def.make) AS make,
|
||||
COALESCE(av_id.model, av_def.model) AS model,
|
||||
COALESCE(av_id.default_target_soc_pct, av_def.default_target_soc_pct) AS default_target_soc_pct,
|
||||
ac.code AS charger_code
|
||||
FROM ems.ev_session es
|
||||
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
|
||||
LEFT JOIN ems.asset_vehicle av_id ON av_id.id = es.vehicle_id
|
||||
LEFT JOIN ems.asset_vehicle av_def
|
||||
ON av_def.default_charger_id = ac.id AND es.vehicle_id IS NULL
|
||||
WHERE es.site_id = $1 AND es.session_end IS NULL
|
||||
ORDER BY es.id, av_def.id NULLS LAST
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
neg_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT predicted_date, window_start_hour, window_end_hour, probability_pct
|
||||
FROM ems.predicted_negative_price_window
|
||||
WHERE site_id = $1
|
||||
AND predicted_date BETWEEN CURRENT_DATE AND CURRENT_DATE + 2
|
||||
AND probability_pct >= 50
|
||||
ORDER BY predicted_date, window_start_hour
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
has_plan = run_row is not None
|
||||
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
|
||||
)
|
||||
inv_age = _age_seconds(inv_row["measured_at"] if inv_row else None)
|
||||
hb_age = _age_seconds(hb_row["last_seen"] if hb_row else None)
|
||||
|
||||
infra = _infrastructure_notification_items(
|
||||
has_plan=has_plan,
|
||||
tomorrow_slots=int(tomorrow_slots or 0),
|
||||
mode_code=mode_code,
|
||||
reserve_soc=reserve_soc,
|
||||
soc=soc,
|
||||
inv_age=inv_age,
|
||||
hb_age=hb_age,
|
||||
)
|
||||
|
||||
prices: list[PriceSlot] = []
|
||||
for r in price_rows:
|
||||
buy = _float_or_none(r["effective_buy_price_czk_kwh"])
|
||||
if buy is None:
|
||||
continue
|
||||
sell_v = _float_or_none(r["effective_sell_price_czk_kwh"])
|
||||
istart = r["interval_start"]
|
||||
prices.append(
|
||||
PriceSlot(
|
||||
interval_start=istart,
|
||||
buy=buy,
|
||||
sell=sell_v if sell_v is not None else buy,
|
||||
)
|
||||
)
|
||||
|
||||
avg_buy = _float_or_none(avg_row["avg_buy"]) if avg_row else None
|
||||
usable_wh = _float_or_none(bat_row["usable_wh"]) if bat_row else None
|
||||
battery_kwh = (usable_wh / 1000.0) if usable_wh is not None else None
|
||||
|
||||
ev_sessions: list[EvSessionRow] = []
|
||||
for er in ev_rows:
|
||||
ev_sessions.append(
|
||||
EvSessionRow(
|
||||
id=int(er["id"]),
|
||||
charger_id=int(er["charger_id"]),
|
||||
energy_delivered_wh=float(er["energy_delivered_wh"] or 0),
|
||||
target_soc_pct=_float_or_none(er["target_soc_pct"]),
|
||||
session_start=er["session_start"],
|
||||
battery_capacity_kwh=_float_or_none(er["battery_capacity_kwh"]),
|
||||
make=er["make"],
|
||||
model=er["model"],
|
||||
default_target_soc_pct=_float_or_none(er["default_target_soc_pct"]),
|
||||
charger_code=str(er["charger_code"] or ""),
|
||||
soc_at_connect_pct=_float_or_none(er["soc_at_connect_pct"]),
|
||||
)
|
||||
)
|
||||
|
||||
neg_windows: list[NegWindowRow] = []
|
||||
for nr in neg_rows:
|
||||
dr = nr["predicted_date"]
|
||||
if isinstance(dr, datetime):
|
||||
d_conv = dr.date()
|
||||
elif isinstance(dr, date):
|
||||
d_conv = dr
|
||||
else:
|
||||
d_conv = date.today()
|
||||
neg_windows.append(
|
||||
NegWindowRow(
|
||||
predicted_date=d_conv,
|
||||
window_start_hour=int(nr["window_start_hour"]),
|
||||
window_end_hour=int(nr["window_end_hour"]),
|
||||
probability_pct=int(nr["probability_pct"]),
|
||||
)
|
||||
)
|
||||
|
||||
sell_now = prices[0].sell if prices else None
|
||||
|
||||
smart_raw = build_smart_notifications(
|
||||
prices=prices,
|
||||
avg_buy=avg_buy,
|
||||
soc_pct=soc,
|
||||
battery_kwh=battery_kwh,
|
||||
ev_sessions=ev_sessions,
|
||||
neg_windows=neg_windows,
|
||||
mode=mode_code,
|
||||
sell_price_now=sell_now,
|
||||
)
|
||||
|
||||
smart_items = [
|
||||
SiteNotificationItem(
|
||||
id=d["id"],
|
||||
level=d["level"],
|
||||
title=d["title"],
|
||||
body=d["body"],
|
||||
eta_minutes=d.get("eta_minutes"),
|
||||
action=d.get("action"),
|
||||
)
|
||||
for d in smart_raw
|
||||
]
|
||||
|
||||
merged = infra + smart_items
|
||||
merged.sort(key=lambda x: _NOTIF_LEVEL_PRIORITY.get(x.level, 9))
|
||||
return SiteNotificationsResponse(notifications=merged[:5])
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"""REST API – aktivní plán a ruční přepočet."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.deps import get_pg_pool
|
||||
from services.planning_engine import _current_slot_start, run_plan_api
|
||||
from services.control_exporter import export_setpoints
|
||||
from services.planning_engine import run_plan_api
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"])
|
||||
|
||||
PRICE_CHECK_HOURS = 24
|
||||
_SLOTS_PER_HOUR = 4
|
||||
_EXPECTED_PRICE_SLOTS = PRICE_CHECK_HOURS * _SLOTS_PER_HOUR
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RunPlanResponse(BaseModel):
|
||||
@@ -25,6 +25,27 @@ class RunPlanResponse(BaseModel):
|
||||
horizon_end: datetime
|
||||
|
||||
|
||||
class PlanningIntervalDto(BaseModel):
|
||||
"""Řádek `ems.planning_interval` v odpovědi aktivního plánu."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
interval_start: str
|
||||
is_predicted_price: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"True pokud solver pro slot použil predikovanou cenu (market_price_stats), "
|
||||
"nikoli přesný řádek z vw_site_effective_price / OTE."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CurrentPlanResponseModel(BaseModel):
|
||||
run: dict[str, Any]
|
||||
intervals: list[PlanningIntervalDto]
|
||||
summary: dict[str, Any]
|
||||
|
||||
|
||||
def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
total_cost = 0.0
|
||||
total_curtailed_kwh = 0.0
|
||||
@@ -55,11 +76,29 @@ def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
def _pv_scarcity_factor_from_intervals(
|
||||
intervals: list[dict[str, Any]], battery_usable_wh: float | None
|
||||
) -> float:
|
||||
"""Stejná logika jako v solveru: 0.65..1.0 podle očekávané FVE energie na ~24h."""
|
||||
if not intervals:
|
||||
return 1.0
|
||||
batt_kwh = max(1.0, float(battery_usable_wh or 0.0) / 1000.0)
|
||||
horizon_slots = min(len(intervals), int(24 / 0.25))
|
||||
pv_kwh = 0.0
|
||||
for row in intervals[:horizon_slots]:
|
||||
pv = row.get("pv_forecast_total_w")
|
||||
if pv is not None:
|
||||
pv_kwh += max(0.0, float(pv)) * 0.25 / 1000.0
|
||||
coverage = pv_kwh / batt_kwh
|
||||
coverage_clamped = max(0.0, min(1.0, coverage))
|
||||
return round(0.65 + 0.35 * coverage_clamped, 4)
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentPlanResponseModel)
|
||||
async def get_current_plan(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> dict[str, Any]:
|
||||
) -> CurrentPlanResponseModel:
|
||||
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:
|
||||
@@ -81,17 +120,53 @@ async def get_current_plan(
|
||||
run_id = run_row["id"]
|
||||
int_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM ems.planning_interval
|
||||
WHERE run_id = $1
|
||||
ORDER BY interval_start
|
||||
WITH latest_fc AS (
|
||||
SELECT id
|
||||
FROM ems.forecast_pv_run
|
||||
WHERE site_id = $2 AND status = 'ok'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
fc_slot AS (
|
||||
SELECT fpi.interval_start, COALESCE(SUM(fpi.power_w), 0)::BIGINT AS pv_forecast_total_w
|
||||
FROM ems.forecast_pv_interval fpi
|
||||
WHERE fpi.run_id = (SELECT id FROM latest_fc)
|
||||
GROUP BY fpi.interval_start
|
||||
)
|
||||
SELECT
|
||||
pi.*,
|
||||
ai.actual_pv_power_w AS pv_power_w,
|
||||
fs.pv_forecast_total_w AS pv_forecast_total_w
|
||||
FROM ems.planning_interval pi
|
||||
LEFT JOIN ems.audit_interval ai
|
||||
ON ai.site_id = $2 AND ai.interval_start = pi.interval_start
|
||||
LEFT JOIN fc_slot fs ON fs.interval_start = pi.interval_start
|
||||
WHERE pi.run_id = $1
|
||||
ORDER BY pi.interval_start
|
||||
""",
|
||||
run_id,
|
||||
site_id,
|
||||
)
|
||||
battery_usable_wh = await conn.fetchval(
|
||||
"""
|
||||
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float
|
||||
FROM ems.asset_battery ab
|
||||
WHERE ab.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
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}
|
||||
intervals_raw = [record_to_dict(r) for r in int_rows]
|
||||
summary = _build_summary(intervals_raw)
|
||||
summary["pv_scarcity_factor"] = _pv_scarcity_factor_from_intervals(
|
||||
intervals_raw, float(battery_usable_wh or 0.0)
|
||||
)
|
||||
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw]
|
||||
return CurrentPlanResponseModel(
|
||||
run=record_to_dict(run_row),
|
||||
intervals=intervals,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/run", response_model=RunPlanResponse)
|
||||
@@ -100,52 +175,52 @@ async def post_run_plan(
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
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:
|
||||
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(
|
||||
days_with_prices = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT interval_start::date)::int AS days_with_prices
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
|
||||
AND interval_start >= now()
|
||||
AND interval_start < now() + INTERVAL '48 hours'
|
||||
"""
|
||||
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:
|
||||
if (days_with_prices or 0) < 1:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Nejsou dostupné tržní ceny. Spusťte nejdřív import cen.",
|
||||
detail="Nejsou dostupné tržní ceny",
|
||||
)
|
||||
|
||||
try:
|
||||
run_id, solver_duration_ms = await run_plan_api(
|
||||
site_id, plan_type, conn, triggered_by="api"
|
||||
)
|
||||
# Nový active run aplikuj hned; nečekej na periodický control_export job.
|
||||
await export_setpoints(site_id, conn)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT horizon_start, horizon_end
|
||||
FROM ems.planning_run
|
||||
WHERE id = $1
|
||||
""",
|
||||
run_id,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
logger.error("Plan run failed: %s", e, exc_info=True)
|
||||
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")
|
||||
if row is None:
|
||||
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
|
||||
|
||||
return RunPlanResponse(
|
||||
run_id=run_id,
|
||||
|
||||
Reference in New Issue
Block a user