sql first refactor
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""REST API – aktivní plán a ruční přepočet."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
@@ -8,7 +9,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
from services.control_exporter import export_setpoints
|
||||
from services.planning_engine import run_plan_api
|
||||
@@ -46,131 +47,36 @@ class CurrentPlanResponseModel(BaseModel):
|
||||
summary: dict[str, Any]
|
||||
|
||||
|
||||
def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
total_cost = 0.0
|
||||
total_curtailed_kwh = 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
|
||||
total_curtailed_kwh += int(c) * 0.25 / 1000.0
|
||||
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 {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
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)],
|
||||
) -> CurrentPlanResponseModel:
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||
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 pr.*
|
||||
FROM ems.planning_run pr
|
||||
WHERE pr.site_id = $1 AND pr.status = 'active'
|
||||
ORDER BY pr.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
bundle = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_plan_current_bundle($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if not run_row:
|
||||
raise HTTPException(status_code=404, detail="No active plan")
|
||||
if not isinstance(bundle, dict):
|
||||
bundle = json.loads(bundle)
|
||||
if bundle.get("error") == "no_active_plan":
|
||||
raise HTTPException(status_code=404, detail="No active plan")
|
||||
|
||||
run_id = run_row["id"]
|
||||
int_rows = await conn.fetch(
|
||||
"""
|
||||
WITH fc_slot AS (
|
||||
SELECT
|
||||
interval_start,
|
||||
COALESCE(SUM(power_w), 0)::BIGINT AS pv_forecast_total_w
|
||||
FROM (
|
||||
SELECT DISTINCT ON (fpi.interval_start, fpr.pv_array_id)
|
||||
fpi.interval_start,
|
||||
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 = fpr.pv_array_id AND apa.site_id = fpr.site_id
|
||||
WHERE fpr.site_id = $2
|
||||
AND fpr.status = 'ok'
|
||||
ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC
|
||||
) latest_per_array
|
||||
GROUP BY 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_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]
|
||||
intervals_raw = bundle.get("intervals") or []
|
||||
if not isinstance(intervals_raw, list):
|
||||
intervals_raw = []
|
||||
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
|
||||
return CurrentPlanResponseModel(
|
||||
run=record_to_dict(run_row),
|
||||
run=bundle.get("run") or {},
|
||||
intervals=intervals,
|
||||
summary=summary,
|
||||
summary=bundle.get("summary") or {},
|
||||
)
|
||||
|
||||
|
||||
@@ -181,18 +87,14 @@ async def post_run_plan(
|
||||
plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
|
||||
) -> RunPlanResponse:
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||
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")
|
||||
|
||||
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 ems.fn_planning_future_price_days()",
|
||||
)
|
||||
if (days_with_prices or 0) < 1:
|
||||
raise HTTPException(
|
||||
@@ -204,14 +106,10 @@ async def post_run_plan(
|
||||
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
|
||||
""",
|
||||
row = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_planning_run_horizon($1::int)",
|
||||
run_id,
|
||||
)
|
||||
except HTTPException:
|
||||
@@ -224,7 +122,7 @@ async def post_run_plan(
|
||||
logger.error("Plan run failed: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
|
||||
if row is None:
|
||||
if not isinstance(row, dict) or row.get("horizon_start") is None:
|
||||
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
|
||||
|
||||
return RunPlanResponse(
|
||||
|
||||
Reference in New Issue
Block a user