sql first refactor
Some checks failed
CI and deploy / migration-check (push) Successful in 5s
CI and deploy / deploy (push) Failing after 20s

This commit is contained in:
Dusan Vojacek
2026-04-19 20:02:20 +02:00
parent a02e11ee13
commit 93f883f5e0
74 changed files with 6022 additions and 4014 deletions

View File

@@ -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(