second version
This commit is contained in:
@@ -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