sql first refactor
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"""asyncpg Record → JSON-serializovatelný dict."""
|
||||
"""asyncpg Record → JSON-serializovatelný dict + helper pro jsonb z fn_*."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
@@ -33,3 +34,17 @@ def record_to_dict(r: asyncpg.Record) -> dict[str, Any]:
|
||||
else:
|
||||
out[k] = str(v)
|
||||
return out
|
||||
|
||||
|
||||
async def fetch_json(conn: asyncpg.Connection, query: str, *args: Any) -> Any:
|
||||
"""fetchval pro dotazy vracející jsonb (např. select ems.fn_*(...))."""
|
||||
v = await conn.fetchval(query, *args)
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (dict, list)):
|
||||
return v
|
||||
if isinstance(v, (bytes, memoryview)):
|
||||
return json.loads(bytes(v).decode("utf-8"))
|
||||
if isinstance(v, str):
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -13,7 +14,7 @@ from zoneinfo import ZoneInfo
|
||||
import asyncpg
|
||||
import httpx
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.db_json import record_to_dict
|
||||
from app.db_json import fetch_json, record_to_dict
|
||||
from app.deps import set_pg_pool
|
||||
from app.routers.economics import router as economics_router
|
||||
from app.routers.energy_flows import router as energy_flows_router
|
||||
@@ -90,7 +91,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_heartbeat() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
await send_heartbeat(site["id"], conn)
|
||||
@@ -99,7 +100,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_audit_filler() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
await fill_audit_for_completed_intervals(site["id"], conn)
|
||||
@@ -108,7 +109,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_forecast_accuracy() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
n = await conn.fetchval(
|
||||
@@ -143,7 +144,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_control_export() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
await export_setpoints(site["id"], conn)
|
||||
@@ -156,7 +157,7 @@ async def lifespan(app: FastAPI):
|
||||
Běží každé 2 minuty, nezávisle na control_exporter (delší okno kvůli zpoždění jobu).
|
||||
"""
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
cmd_rows = await conn.fetch(
|
||||
@@ -182,7 +183,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_daily_plan() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
@@ -194,7 +195,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_rolling_replan() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
@@ -206,7 +207,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_baseline_update() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
n = await conn.fetchval(
|
||||
@@ -225,7 +226,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_market_price_stats() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
n = await conn.fetchval(
|
||||
@@ -244,7 +245,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_tuv_usage_stats() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
try:
|
||||
n = await conn.fetchval(
|
||||
@@ -263,7 +264,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
async def scheduled_forecast_refresh() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
@@ -303,7 +304,7 @@ async def lifespan(app: FastAPI):
|
||||
async def _refresh_negative_price_predictions_all_active(
|
||||
conn: asyncpg.Connection,
|
||||
) -> None:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
await _refresh_negative_price_predictions(conn, int(site["id"]))
|
||||
|
||||
@@ -444,7 +445,7 @@ async def lifespan(app: FastAPI):
|
||||
from services.notification_service import notify_daily_economics
|
||||
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id, code FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id, code from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
site_id = int(site["id"])
|
||||
site_code = site["code"]
|
||||
@@ -546,9 +547,9 @@ async def list_sites(db: Annotated[asyncpg.Pool, Depends(get_pool)]) -> list[dic
|
||||
async with db.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at
|
||||
FROM ems.site
|
||||
ORDER BY id
|
||||
select id, code, name, timezone, latitude, longitude, active, notes, created_at
|
||||
from ems.vw_site_directory
|
||||
order by id
|
||||
"""
|
||||
)
|
||||
return [record_to_dict(r) for r in rows]
|
||||
@@ -567,17 +568,15 @@ async def get_site_prices(
|
||||
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")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM ems.vw_site_effective_price
|
||||
WHERE site_id = $1 AND interval_start::date = $2::date
|
||||
ORDER BY interval_start
|
||||
""",
|
||||
rows = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_effective_prices_day_prague($1::int, $2::date)",
|
||||
site_id,
|
||||
d,
|
||||
)
|
||||
return [record_to_dict(r) for r in rows]
|
||||
if not isinstance(rows, list):
|
||||
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
class PricesImportResponse(BaseModel):
|
||||
@@ -656,7 +655,7 @@ async def post_import_site_prices(
|
||||
conn, site_id=None, target_date=target
|
||||
)
|
||||
if n >= 0:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||
for site in sites:
|
||||
await _refresh_negative_price_predictions(conn, int(site["id"]))
|
||||
if n < 0:
|
||||
@@ -698,59 +697,35 @@ async def get_site_negative_price_predictions(
|
||||
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")
|
||||
ndays = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT (interval_start AT TIME ZONE 'Europe/Prague')::date)::int
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
|
||||
AND interval_start >= now() - INTERVAL '400 days'
|
||||
"""
|
||||
)
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
p.predicted_date,
|
||||
p.window_start_hour,
|
||||
p.window_end_hour,
|
||||
p.probability_pct,
|
||||
p.expected_min_price,
|
||||
p.reason
|
||||
FROM ems.predicted_negative_price_window p
|
||||
WHERE p.site_id = $1
|
||||
AND p.predicted_date > (
|
||||
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
|
||||
NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''),
|
||||
'Europe/Prague'
|
||||
)
|
||||
)::date
|
||||
AND p.predicted_date <= (
|
||||
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
|
||||
NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''),
|
||||
'Europe/Prague'
|
||||
)
|
||||
)::date + 7
|
||||
ORDER BY p.predicted_date, p.window_start_hour
|
||||
""",
|
||||
bundle = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_negative_price_predictions($1::int)",
|
||||
site_id,
|
||||
)
|
||||
n_hist = int(ndays or 0)
|
||||
if not isinstance(bundle, dict):
|
||||
bundle = json.loads(bundle)
|
||||
rows = bundle.get("predictions") or []
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
predictions: list[NegPricePredictionItem] = []
|
||||
for r in rows:
|
||||
em = r["expected_min_price"]
|
||||
pd = r["predicted_date"]
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
em = r.get("expected_min_price")
|
||||
pd = r.get("predicted_date")
|
||||
predictions.append(
|
||||
NegPricePredictionItem(
|
||||
predicted_date=pd.isoformat() if hasattr(pd, "isoformat") else str(pd),
|
||||
window_start_hour=int(r["window_start_hour"]),
|
||||
window_end_hour=int(r["window_end_hour"]),
|
||||
probability_pct=float(r["probability_pct"]),
|
||||
window_start_hour=int(r.get("window_start_hour") or 0),
|
||||
window_end_hour=int(r.get("window_end_hour") or 0),
|
||||
probability_pct=float(r.get("probability_pct") or 0),
|
||||
expected_min_price=float(em) if em is not None else None,
|
||||
reason=r["reason"] if r["reason"] is not None else "",
|
||||
reason=str(r.get("reason") or ""),
|
||||
)
|
||||
)
|
||||
return NegativePredictionsResponse(
|
||||
predictions=predictions,
|
||||
insufficient_history=n_hist < 28,
|
||||
insufficient_history=bool(bundle.get("insufficient_history")),
|
||||
)
|
||||
|
||||
|
||||
@@ -763,29 +738,19 @@ async def get_site_prices_latest(
|
||||
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")
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
(interval_start AT TIME ZONE 'Europe/Prague')::date AS day,
|
||||
COUNT(*)::int AS slots,
|
||||
MIN(buy_raw_price_czk_kwh)::float AS min_price,
|
||||
MAX(buy_raw_price_czk_kwh)::float AS max_price,
|
||||
AVG(buy_raw_price_czk_kwh)::float AS avg_price
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
|
||||
GROUP BY day
|
||||
ORDER BY day DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if row is None or row["day"] is None:
|
||||
row = await fetch_json(conn, "select ems.fn_latest_ote_day_stats()")
|
||||
if not isinstance(row, dict):
|
||||
row = json.loads(row)
|
||||
day = row.get("latest_date")
|
||||
if day is None:
|
||||
raise HTTPException(status_code=404, detail="Žádná tržní data v databázi")
|
||||
latest_date = day.isoformat() if hasattr(day, "isoformat") else str(day)[:10]
|
||||
return PricesLatestResponse(
|
||||
latest_date=row["day"].isoformat(),
|
||||
slots=int(row["slots"] or 0),
|
||||
min_price=float(row["min_price"] or 0.0),
|
||||
max_price=float(row["max_price"] or 0.0),
|
||||
avg_price=float(row["avg_price"] or 0.0),
|
||||
latest_date=latest_date,
|
||||
slots=int(row.get("slots") or 0),
|
||||
min_price=float(row.get("min_price") or 0.0),
|
||||
max_price=float(row.get("max_price") or 0.0),
|
||||
avg_price=float(row.get("avg_price") or 0.0),
|
||||
)
|
||||
|
||||
|
||||
@@ -807,48 +772,45 @@ async def get_verify_modbus_commands(
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
lookback = timedelta(minutes=minutes)
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id FROM ems.modbus_command
|
||||
WHERE site_id = $1
|
||||
AND status = 'written'
|
||||
AND written_at >= now() - $2::interval
|
||||
ORDER BY written_at
|
||||
""",
|
||||
id_json = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_modbus_written_command_ids($1::int, $2::interval)",
|
||||
site_id,
|
||||
lookback,
|
||||
)
|
||||
ids = [int(r["id"]) for r in rows]
|
||||
if not isinstance(id_json, list):
|
||||
id_json = json.loads(id_json) if isinstance(id_json, str) else []
|
||||
ids = [int(x) for x in id_json]
|
||||
checked = len(ids)
|
||||
if ids:
|
||||
await verify_modbus_commands(ids, conn, site_id)
|
||||
|
||||
detail_rows = (
|
||||
await conn.fetch(
|
||||
"""
|
||||
SELECT id, asset_code, register_name, value_to_write, value_verified, status
|
||||
FROM ems.modbus_command
|
||||
WHERE id = ANY($1::int[])
|
||||
ORDER BY id
|
||||
""",
|
||||
detail_json = (
|
||||
await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_modbus_commands_by_ids($1::int[])",
|
||||
ids,
|
||||
)
|
||||
if ids
|
||||
else []
|
||||
)
|
||||
if ids and not isinstance(detail_json, list):
|
||||
detail_json = json.loads(detail_json) if isinstance(detail_json, str) else []
|
||||
detail_rows = detail_json if ids else []
|
||||
|
||||
commands = [
|
||||
ModbusCommandVerifyItem(
|
||||
id=int(r["id"]),
|
||||
asset_code=r["asset_code"],
|
||||
register_name=r["register_name"],
|
||||
asset_code=str(r.get("asset_code") or ""),
|
||||
register_name=r.get("register_name"),
|
||||
value_to_write=int(r["value_to_write"]),
|
||||
value_verified=int(r["value_verified"])
|
||||
if r["value_verified"] is not None
|
||||
if r.get("value_verified") is not None
|
||||
else None,
|
||||
status=r["status"],
|
||||
status=str(r.get("status") or ""),
|
||||
)
|
||||
for r in detail_rows
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
verified = sum(1 for c in commands if c.status == "verified")
|
||||
mismatch = sum(1 for c in commands if c.status == "mismatch")
|
||||
@@ -933,21 +895,17 @@ async def get_control_command_journal(
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, register, register_name, value_to_write, value_written,
|
||||
value_verified, status, attempt_count, created_at
|
||||
FROM ems.modbus_command
|
||||
WHERE site_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
""",
|
||||
rows = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_modbus_journal_list($1::int, $2::int)",
|
||||
site_id,
|
||||
limit,
|
||||
)
|
||||
if not isinstance(rows, list):
|
||||
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||
cmds: list[ModbusJournalCommandRow] = []
|
||||
for r in rows:
|
||||
d = record_to_dict(r)
|
||||
d = r if isinstance(r, dict) else {}
|
||||
ca = d["created_at"]
|
||||
cmds.append(
|
||||
ModbusJournalCommandRow(
|
||||
@@ -1006,51 +964,20 @@ async def get_site_forecast_pv(
|
||||
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")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT run_id, pv_array_id, interval_start, power_w,
|
||||
irradiance_wm2, temp_c, pv_array_code, controllable
|
||||
FROM (
|
||||
SELECT DISTINCT ON (fpi.interval_start, fpr.pv_array_id)
|
||||
fpi.run_id,
|
||||
fpi.pv_array_id,
|
||||
fpi.interval_start,
|
||||
fpi.power_w,
|
||||
fpi.irradiance_wm2,
|
||||
fpi.temp_c,
|
||||
apa.code AS pv_array_code,
|
||||
apa.controllable
|
||||
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 = $1
|
||||
AND (
|
||||
fpi.interval_start
|
||||
AT TIME ZONE COALESCE(
|
||||
(SELECT timezone FROM ems.site WHERE id = $1),
|
||||
'Europe/Prague'
|
||||
)
|
||||
)::date = $2::date
|
||||
AND fpr.status = 'ok'
|
||||
ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC
|
||||
) latest
|
||||
ORDER BY controllable DESC, pv_array_code, interval_start
|
||||
""",
|
||||
split = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_forecast_pv_split($1::int, $2::date)",
|
||||
site_id,
|
||||
d,
|
||||
)
|
||||
|
||||
# pv_a = řiditelná pole (curtailment / Deye), pv_b = neřízená (GEN, …) — sloučí více orientací
|
||||
pv_a: list[dict[str, Any]] = []
|
||||
pv_b: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
item = record_to_dict(r)
|
||||
item.pop("controllable", None)
|
||||
if r["controllable"]:
|
||||
pv_a.append(item)
|
||||
else:
|
||||
pv_b.append(item)
|
||||
if not isinstance(split, dict):
|
||||
split = json.loads(split) if isinstance(split, str) else {}
|
||||
pv_a = split.get("pv_a") or []
|
||||
pv_b = split.get("pv_b") or []
|
||||
if not isinstance(pv_a, list):
|
||||
pv_a = []
|
||||
if not isinstance(pv_b, list):
|
||||
pv_b = []
|
||||
return {"pv_a": pv_a, "pv_b": pv_b}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Any
|
||||
@@ -10,6 +11,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(
|
||||
@@ -105,56 +107,14 @@ async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
|
||||
async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool:
|
||||
return bool(
|
||||
await conn.fetchval(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ems.asset_pv_array
|
||||
WHERE site_id = $1
|
||||
AND green_bonus_czk_kwh IS NOT NULL
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _safe_get(record: Any, key: str, fallback: Any = None) -> Any:
|
||||
"""Safely get a key from asyncpg Record (which supports [] but not .get())."""
|
||||
try:
|
||||
return record[key]
|
||||
except (KeyError, TypeError):
|
||||
return fallback
|
||||
|
||||
|
||||
def _daily_from_row(r: Any, lock: Any | None, is_locked: bool) -> DailyEconomics:
|
||||
src = lock if (lock and is_locked) else r
|
||||
return DailyEconomics(
|
||||
day=r["day_local"],
|
||||
interval_count=r["interval_count"],
|
||||
import_kwh=_num(r["import_kwh"]),
|
||||
export_kwh=_num(r["export_kwh"]),
|
||||
pv_kwh=_num(r["pv_kwh"]),
|
||||
load_kwh=_num(r["load_kwh"]),
|
||||
pv_self_consumption_kwh=_num(r["pv_self_consumption_kwh"]),
|
||||
ev_kwh=_num(r["ev_kwh"]),
|
||||
hp_kwh=_num(r["hp_kwh"]),
|
||||
import_cost_czk=_num(src["import_cost_czk"]),
|
||||
export_revenue_czk=_num(src["export_revenue_czk"]),
|
||||
grid_import_cashflow_czk=_num(
|
||||
_safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"])
|
||||
),
|
||||
grid_export_revenue_czk=_num(
|
||||
_safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"])
|
||||
),
|
||||
net_cost_czk=_num(src["net_cost_czk"]),
|
||||
green_bonus_czk=_num(src["green_bonus_czk"]),
|
||||
total_balance_czk=_num(src["total_balance_czk"]),
|
||||
planned_balance_czk=_opt(r["planned_balance_czk"]),
|
||||
deviation_cost_czk=_opt(r["deviation_cost_czk"]),
|
||||
is_locked=is_locked,
|
||||
)
|
||||
def _parse_day(val: Any) -> date:
|
||||
if isinstance(val, datetime):
|
||||
return val.date()
|
||||
if isinstance(val, date):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return date.fromisoformat(val[:10])
|
||||
raise ValueError(val)
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyEconomicsResponse)
|
||||
@@ -179,41 +139,47 @@ async def get_economics_daily(
|
||||
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
has_bonus = await _has_green_bonus(conn, site_id)
|
||||
|
||||
dyn_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
ORDER BY day_local
|
||||
""",
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_economics_daily_month($1::int, $2::date, $3::date)",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
|
||||
lock_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM ems.audit_day_lock
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
""",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
locks = {r["day_local"]: r for r in lock_rows}
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
days_in: list[Any] = list(raw.get("days") or [])
|
||||
days: list[DailyEconomics] = []
|
||||
for r in dyn_rows:
|
||||
d = r["day_local"]
|
||||
lock = locks.get(d)
|
||||
days.append(_daily_from_row(r, lock, is_locked=lock is not None))
|
||||
|
||||
return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus)
|
||||
for d in days_in:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
days.append(
|
||||
DailyEconomics(
|
||||
day=_parse_day(d.get("day")),
|
||||
interval_count=int(d.get("interval_count") or 0),
|
||||
import_kwh=_num(d.get("import_kwh")),
|
||||
export_kwh=_num(d.get("export_kwh")),
|
||||
pv_kwh=_num(d.get("pv_kwh")),
|
||||
load_kwh=_num(d.get("load_kwh")),
|
||||
pv_self_consumption_kwh=_num(d.get("pv_self_consumption_kwh")),
|
||||
ev_kwh=_num(d.get("ev_kwh")),
|
||||
hp_kwh=_num(d.get("hp_kwh")),
|
||||
import_cost_czk=_num(d.get("import_cost_czk")),
|
||||
export_revenue_czk=_num(d.get("export_revenue_czk")),
|
||||
grid_import_cashflow_czk=_num(d.get("grid_import_cashflow_czk")),
|
||||
grid_export_revenue_czk=_num(d.get("grid_export_revenue_czk")),
|
||||
net_cost_czk=_num(d.get("net_cost_czk")),
|
||||
green_bonus_czk=_num(d.get("green_bonus_czk")),
|
||||
total_balance_czk=_num(d.get("total_balance_czk")),
|
||||
planned_balance_czk=_opt(d.get("planned_balance_czk")),
|
||||
deviation_cost_czk=_opt(d.get("deviation_cost_czk")),
|
||||
is_locked=bool(d.get("is_locked")),
|
||||
)
|
||||
)
|
||||
return DailyEconomicsResponse(
|
||||
days=days,
|
||||
has_green_bonus=bool(raw.get("has_green_bonus")),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics])
|
||||
@@ -270,50 +236,18 @@ async def lock_day(
|
||||
) -> LockResponse:
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT import_cost_czk, export_revenue_czk, net_cost_czk,
|
||||
green_bonus_czk, total_balance_czk,
|
||||
grid_import_cashflow_czk, grid_export_revenue_czk
|
||||
FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1 AND day_local = $2
|
||||
""",
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_economics_lock_day($1::int, $2::date)",
|
||||
site_id,
|
||||
day,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No economics data for {day.isoformat()}",
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.audit_day_lock
|
||||
(site_id, day_local, import_cost_czk, export_revenue_czk,
|
||||
net_cost_czk, green_bonus_czk, total_balance_czk,
|
||||
grid_import_cashflow_czk, grid_export_revenue_czk)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (site_id, day_local) DO UPDATE SET
|
||||
import_cost_czk = EXCLUDED.import_cost_czk,
|
||||
export_revenue_czk = EXCLUDED.export_revenue_czk,
|
||||
net_cost_czk = EXCLUDED.net_cost_czk,
|
||||
green_bonus_czk = EXCLUDED.green_bonus_czk,
|
||||
total_balance_czk = EXCLUDED.total_balance_czk,
|
||||
grid_import_cashflow_czk = EXCLUDED.grid_import_cashflow_czk,
|
||||
grid_export_revenue_czk = EXCLUDED.grid_export_revenue_czk,
|
||||
locked_at = now()
|
||||
""",
|
||||
site_id,
|
||||
day,
|
||||
row["import_cost_czk"],
|
||||
row["export_revenue_czk"],
|
||||
row["net_cost_czk"],
|
||||
row["green_bonus_czk"],
|
||||
row["total_balance_czk"],
|
||||
row["grid_import_cashflow_czk"],
|
||||
row["grid_export_revenue_czk"],
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
if raw.get("locked") is not True:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No economics data for {day.isoformat()}",
|
||||
)
|
||||
|
||||
return LockResponse(locked=True, day=day)
|
||||
@@ -327,8 +261,9 @@ async def unlock_day(
|
||||
) -> LockResponse:
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM ems.audit_day_lock WHERE site_id = $1 AND day_local = $2",
|
||||
await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_economics_unlock_day($1::int, $2::date)",
|
||||
site_id,
|
||||
day,
|
||||
)
|
||||
@@ -357,61 +292,29 @@ async def get_monthly_chart(
|
||||
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT day_local, total_balance_czk, net_cost_czk,
|
||||
green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk
|
||||
FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
ORDER BY day_local
|
||||
""",
|
||||
arr = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_economics_monthly_chart($1::int, $2::date, $3::date)",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
|
||||
lock_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT day_local, total_balance_czk, net_cost_czk,
|
||||
green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk
|
||||
FROM ems.audit_day_lock
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
""",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
locks = {r["day_local"]: r for r in lock_rows}
|
||||
|
||||
if not isinstance(arr, list):
|
||||
arr = json.loads(arr) if isinstance(arr, str) else []
|
||||
points: list[ChartDayPoint] = []
|
||||
cum_balance = 0.0
|
||||
cum_grid = 0.0
|
||||
for r in rows:
|
||||
d = r["day_local"]
|
||||
src = locks.get(d, r)
|
||||
balance = _num(src["total_balance_czk"])
|
||||
grid_balance = -_num(src["net_cost_czk"])
|
||||
green_bonus = _num(src["green_bonus_czk"])
|
||||
import_cost = _num(_safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"]))
|
||||
export_revenue = _num(_safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"]))
|
||||
cum_balance += balance
|
||||
cum_grid += grid_balance
|
||||
for r in arr:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
points.append(
|
||||
ChartDayPoint(
|
||||
day=d,
|
||||
daily_balance_czk=round(balance, 2),
|
||||
daily_grid_balance_czk=round(grid_balance, 2),
|
||||
daily_green_bonus_czk=round(green_bonus, 2),
|
||||
daily_import_cost_czk=round(import_cost, 2),
|
||||
daily_export_revenue_czk=round(export_revenue, 2),
|
||||
cumulative_balance_czk=round(cum_balance, 2),
|
||||
cumulative_grid_balance_czk=round(cum_grid, 2),
|
||||
day=_parse_day(r.get("day")),
|
||||
daily_balance_czk=float(r.get("daily_balance_czk") or 0),
|
||||
daily_grid_balance_czk=float(r.get("daily_grid_balance_czk") or 0),
|
||||
daily_green_bonus_czk=float(r.get("daily_green_bonus_czk") or 0),
|
||||
daily_import_cost_czk=float(r.get("daily_import_cost_czk") or 0),
|
||||
daily_export_revenue_czk=float(r.get("daily_export_revenue_czk") or 0),
|
||||
cumulative_balance_czk=float(r.get("cumulative_balance_czk") or 0),
|
||||
cumulative_grid_balance_czk=float(r.get("cumulative_grid_balance_czk") or 0),
|
||||
)
|
||||
)
|
||||
|
||||
return points
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Annotated, Any
|
||||
|
||||
@@ -9,6 +10,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(
|
||||
@@ -16,6 +18,7 @@ router = APIRouter(
|
||||
tags=["energy-flows"],
|
||||
)
|
||||
|
||||
|
||||
class DailyEnergyFlows(BaseModel):
|
||||
day: date
|
||||
interval_count: int
|
||||
@@ -65,12 +68,6 @@ def _num(val: Any) -> float:
|
||||
return float(val)
|
||||
|
||||
|
||||
def _wh_to_kwh(val: Any) -> float | None:
|
||||
if val is None:
|
||||
return None
|
||||
return round(float(val) / 1000.0, 4)
|
||||
|
||||
|
||||
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
||||
ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
@@ -79,28 +76,16 @@ async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
|
||||
def _row_to_daily(r: Any) -> DailyEnergyFlows:
|
||||
return DailyEnergyFlows(
|
||||
day=r["day_local"],
|
||||
interval_count=int(r["interval_count"] or 0),
|
||||
pv_production_kwh=_num(r["pv_production_kwh"]),
|
||||
grid_import_kwh=_num(r["grid_import_kwh"]),
|
||||
grid_export_kwh=_num(r["grid_export_kwh"]),
|
||||
batt_charge_kwh=_num(r["batt_charge_kwh"]),
|
||||
batt_discharge_kwh=_num(r["batt_discharge_kwh"]),
|
||||
load_kwh=_num(r["load_kwh"]),
|
||||
pv_to_load_kwh=_num(r["pv_to_load_kwh"]),
|
||||
pv_to_batt_kwh=_num(r["pv_to_batt_kwh"]),
|
||||
pv_to_grid_kwh=_num(r["pv_to_grid_kwh"]),
|
||||
batt_to_load_kwh=_num(r["batt_to_load_kwh"]),
|
||||
batt_to_grid_kwh=_num(r["batt_to_grid_kwh"]),
|
||||
grid_to_load_kwh=_num(r["grid_to_load_kwh"]),
|
||||
grid_to_batt_kwh=_num(r["grid_to_batt_kwh"]),
|
||||
grid_import_cashflow_czk=_num(r["grid_import_cashflow_czk"]),
|
||||
grid_export_revenue_czk=_num(r["grid_export_revenue_czk"]),
|
||||
grid_to_load_cost_czk=_num(r["grid_to_load_cost_czk"]),
|
||||
grid_to_batt_cost_czk=_num(r["grid_to_batt_cost_czk"]),
|
||||
)
|
||||
def _parse_day(val: Any) -> date:
|
||||
from datetime import datetime as _dt
|
||||
|
||||
if isinstance(val, _dt):
|
||||
return val.date()
|
||||
if isinstance(val, date):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return date.fromisoformat(val[:10])
|
||||
raise ValueError(val)
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyEnergyFlowsResponse)
|
||||
@@ -125,84 +110,44 @@ async def get_energy_flows_daily(
|
||||
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
(date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date
|
||||
AS day_local,
|
||||
COUNT(*)::int AS interval_count,
|
||||
ROUND(SUM(COALESCE(ai.actual_pv_production_wh, 0)) / 1000, 3)
|
||||
AS pv_production_kwh,
|
||||
ROUND(SUM(COALESCE(ai.actual_grid_import_wh, 0)) / 1000, 3)
|
||||
AS grid_import_kwh,
|
||||
ROUND(SUM(COALESCE(ai.actual_grid_export_wh, 0)) / 1000, 3)
|
||||
AS grid_export_kwh,
|
||||
ROUND(SUM(COALESCE(ai.actual_batt_charge_wh, 0)) / 1000, 3)
|
||||
AS batt_charge_kwh,
|
||||
ROUND(SUM(COALESCE(ai.actual_batt_discharge_wh, 0)) / 1000, 3)
|
||||
AS batt_discharge_kwh,
|
||||
ROUND(SUM(COALESCE(ai.actual_load_consumption_wh, 0)) / 1000, 3)
|
||||
AS load_kwh,
|
||||
ROUND(SUM(COALESCE(ai.flow_pv_to_load_wh, 0)) / 1000, 3)
|
||||
AS pv_to_load_kwh,
|
||||
ROUND(SUM(COALESCE(ai.flow_pv_to_batt_wh, 0)) / 1000, 3)
|
||||
AS pv_to_batt_kwh,
|
||||
ROUND(SUM(COALESCE(ai.flow_pv_to_grid_wh, 0)) / 1000, 3)
|
||||
AS pv_to_grid_kwh,
|
||||
ROUND(SUM(COALESCE(ai.flow_batt_to_load_wh, 0)) / 1000, 3)
|
||||
AS batt_to_load_kwh,
|
||||
ROUND(SUM(COALESCE(ai.flow_batt_to_grid_wh, 0)) / 1000, 3)
|
||||
AS batt_to_grid_kwh,
|
||||
ROUND(SUM(COALESCE(ai.flow_grid_to_load_wh, 0)) / 1000, 3)
|
||||
AS grid_to_load_kwh,
|
||||
ROUND(SUM(COALESCE(ai.flow_grid_to_batt_wh, 0)) / 1000, 3)
|
||||
AS grid_to_batt_kwh,
|
||||
ROUND(
|
||||
SUM(
|
||||
COALESCE(ai.actual_grid_import_wh, 0) / 1000.0
|
||||
* COALESCE(ep.effective_buy_price_czk_kwh, 0)
|
||||
),
|
||||
2
|
||||
) AS grid_import_cashflow_czk,
|
||||
ROUND(
|
||||
SUM(
|
||||
COALESCE(ai.actual_grid_export_wh, 0) / 1000.0
|
||||
* COALESCE(ep.effective_sell_price_czk_kwh, 0)
|
||||
),
|
||||
2
|
||||
) AS grid_export_revenue_czk,
|
||||
ROUND(
|
||||
SUM(
|
||||
COALESCE(ai.flow_grid_to_load_wh, 0) / 1000.0
|
||||
* COALESCE(ep.effective_buy_price_czk_kwh, 0)
|
||||
),
|
||||
2
|
||||
) AS grid_to_load_cost_czk,
|
||||
ROUND(
|
||||
SUM(
|
||||
COALESCE(ai.flow_grid_to_batt_wh, 0) / 1000.0
|
||||
* COALESCE(ep.effective_buy_price_czk_kwh, 0)
|
||||
),
|
||||
2
|
||||
) AS grid_to_batt_cost_czk
|
||||
FROM ems.audit_interval ai
|
||||
LEFT JOIN ems.vw_site_effective_price ep
|
||||
ON ep.site_id = ai.site_id
|
||||
AND ep.interval_start = ai.interval_start
|
||||
WHERE ai.site_id = $1
|
||||
AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date
|
||||
>= $2
|
||||
AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date
|
||||
< $3
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""",
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_energy_flows_daily_month($1::int, $2::date, $3::date)",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
|
||||
return DailyEnergyFlowsResponse(days=[_row_to_daily(r) for r in rows])
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
rows = raw.get("days") or []
|
||||
days: list[DailyEnergyFlows] = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
days.append(
|
||||
DailyEnergyFlows(
|
||||
day=_parse_day(r.get("day")),
|
||||
interval_count=int(r.get("interval_count") or 0),
|
||||
pv_production_kwh=_num(r.get("pv_production_kwh")),
|
||||
grid_import_kwh=_num(r.get("grid_import_kwh")),
|
||||
grid_export_kwh=_num(r.get("grid_export_kwh")),
|
||||
batt_charge_kwh=_num(r.get("batt_charge_kwh")),
|
||||
batt_discharge_kwh=_num(r.get("batt_discharge_kwh")),
|
||||
load_kwh=_num(r.get("load_kwh")),
|
||||
pv_to_load_kwh=_num(r.get("pv_to_load_kwh")),
|
||||
pv_to_batt_kwh=_num(r.get("pv_to_batt_kwh")),
|
||||
pv_to_grid_kwh=_num(r.get("pv_to_grid_kwh")),
|
||||
batt_to_load_kwh=_num(r.get("batt_to_load_kwh")),
|
||||
batt_to_grid_kwh=_num(r.get("batt_to_grid_kwh")),
|
||||
grid_to_load_kwh=_num(r.get("grid_to_load_kwh")),
|
||||
grid_to_batt_kwh=_num(r.get("grid_to_batt_kwh")),
|
||||
grid_import_cashflow_czk=_num(r.get("grid_import_cashflow_czk")),
|
||||
grid_export_revenue_czk=_num(r.get("grid_export_revenue_czk")),
|
||||
grid_to_load_cost_czk=_num(r.get("grid_to_load_cost_czk")),
|
||||
grid_to_batt_cost_czk=_num(r.get("grid_to_batt_cost_czk")),
|
||||
)
|
||||
)
|
||||
return DailyEnergyFlowsResponse(days=days)
|
||||
|
||||
|
||||
@router.get("/daily/{day}/intervals", response_model=list[IntervalEnergyFlows])
|
||||
@@ -213,48 +158,35 @@ async def get_energy_flows_intervals(
|
||||
) -> list[IntervalEnergyFlows]:
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
interval_start,
|
||||
actual_pv_production_wh,
|
||||
actual_grid_import_wh,
|
||||
actual_grid_export_wh,
|
||||
actual_batt_charge_wh,
|
||||
actual_batt_discharge_wh,
|
||||
actual_load_consumption_wh,
|
||||
flow_pv_to_load_wh,
|
||||
flow_pv_to_batt_wh,
|
||||
flow_pv_to_grid_wh,
|
||||
flow_batt_to_load_wh,
|
||||
flow_batt_to_grid_wh,
|
||||
flow_grid_to_load_wh,
|
||||
flow_grid_to_batt_wh
|
||||
FROM ems.audit_interval
|
||||
WHERE site_id = $1
|
||||
AND (date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague'))::date = $2
|
||||
ORDER BY interval_start
|
||||
""",
|
||||
rows = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_energy_flows_intervals_day($1::int, $2::date)",
|
||||
site_id,
|
||||
day,
|
||||
)
|
||||
|
||||
return [
|
||||
IntervalEnergyFlows(
|
||||
interval_start=r["interval_start"].isoformat(),
|
||||
pv_production_kwh=_wh_to_kwh(r["actual_pv_production_wh"]),
|
||||
grid_import_kwh=_wh_to_kwh(r["actual_grid_import_wh"]),
|
||||
grid_export_kwh=_wh_to_kwh(r["actual_grid_export_wh"]),
|
||||
batt_charge_kwh=_wh_to_kwh(r["actual_batt_charge_wh"]),
|
||||
batt_discharge_kwh=_wh_to_kwh(r["actual_batt_discharge_wh"]),
|
||||
load_kwh=_wh_to_kwh(r["actual_load_consumption_wh"]),
|
||||
pv_to_load_kwh=_wh_to_kwh(r["flow_pv_to_load_wh"]),
|
||||
pv_to_batt_kwh=_wh_to_kwh(r["flow_pv_to_batt_wh"]),
|
||||
pv_to_grid_kwh=_wh_to_kwh(r["flow_pv_to_grid_wh"]),
|
||||
batt_to_load_kwh=_wh_to_kwh(r["flow_batt_to_load_wh"]),
|
||||
batt_to_grid_kwh=_wh_to_kwh(r["flow_batt_to_grid_wh"]),
|
||||
grid_to_load_kwh=_wh_to_kwh(r["flow_grid_to_load_wh"]),
|
||||
grid_to_batt_kwh=_wh_to_kwh(r["flow_grid_to_batt_wh"]),
|
||||
if not isinstance(rows, list):
|
||||
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||
out: list[IntervalEnergyFlows] = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
ist = r.get("interval_start")
|
||||
out.append(
|
||||
IntervalEnergyFlows(
|
||||
interval_start=ist if isinstance(ist, str) else str(ist),
|
||||
pv_production_kwh=r.get("pv_production_kwh"),
|
||||
grid_import_kwh=r.get("grid_import_kwh"),
|
||||
grid_export_kwh=r.get("grid_export_kwh"),
|
||||
batt_charge_kwh=r.get("batt_charge_kwh"),
|
||||
batt_discharge_kwh=r.get("batt_discharge_kwh"),
|
||||
load_kwh=r.get("load_kwh"),
|
||||
pv_to_load_kwh=r.get("pv_to_load_kwh"),
|
||||
pv_to_batt_kwh=r.get("pv_to_batt_kwh"),
|
||||
pv_to_grid_kwh=r.get("pv_to_grid_kwh"),
|
||||
batt_to_load_kwh=r.get("batt_to_load_kwh"),
|
||||
batt_to_grid_kwh=r.get("batt_to_grid_kwh"),
|
||||
grid_to_load_kwh=r.get("grid_to_load_kwh"),
|
||||
grid_to_batt_kwh=r.get("grid_to_batt_kwh"),
|
||||
)
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return out
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
@@ -9,7 +10,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}/ev", tags=["ev"])
|
||||
@@ -38,30 +39,19 @@ async def get_active_ev_sessions(
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> list[dict[str, Any]]:
|
||||
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")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT es.id, es.charger_id, es.vehicle_id,
|
||||
es.session_start, es.energy_delivered_wh,
|
||||
es.target_soc_pct, es.target_deadline,
|
||||
av.make, av.model, av.battery_capacity_kwh,
|
||||
av.default_target_soc_pct, av.default_deadline_hour,
|
||||
ac.code AS charger_code,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT_WS(' ', ac.manufacturer, ac.model)), ''),
|
||||
ac.code
|
||||
) AS charger_name
|
||||
FROM ems.ev_session es
|
||||
LEFT JOIN ems.asset_vehicle av ON av.id = es.vehicle_id
|
||||
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
|
||||
WHERE es.site_id = $1 AND es.session_end IS NULL
|
||||
ORDER BY es.session_start DESC
|
||||
""",
|
||||
rows = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ev_sessions_active($1::int)",
|
||||
site_id,
|
||||
)
|
||||
return [record_to_dict(r) for r in rows]
|
||||
if not isinstance(rows, list):
|
||||
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
@router.patch("/sessions/{session_id}", response_model=EvSessionPatchResponse)
|
||||
@@ -72,25 +62,25 @@ async def patch_ev_session(
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> EvSessionPatchResponse:
|
||||
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")
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE ems.ev_session
|
||||
SET target_soc_pct = $1, target_deadline = $2
|
||||
WHERE id = $3 AND site_id = $4
|
||||
RETURNING id
|
||||
""",
|
||||
body.target_soc_pct,
|
||||
body.target_deadline,
|
||||
session_id,
|
||||
patch = body.model_dump(exclude_unset=True)
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
|
||||
site_id,
|
||||
session_id,
|
||||
json.dumps(patch),
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return EvSessionPatchResponse(success=True, session_id=int(row["id"]))
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
if not raw.get("success"):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return EvSessionPatchResponse(success=True, session_id=int(raw["session_id"]))
|
||||
|
||||
|
||||
class ArrivalHourItem(BaseModel):
|
||||
@@ -114,65 +104,48 @@ 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
|
||||
""",
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ev_arrival_prediction_bundle($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if tomorrow is None:
|
||||
raise HTTPException(status_code=500, detail="Site date resolution failed")
|
||||
tomorrow_d: date = tomorrow
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
if raw.get("error") == "site_not_found":
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
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"]),
|
||||
chargers: dict[str, ChargerTomorrowArrival] = {}
|
||||
ch_raw = raw.get("chargers") or {}
|
||||
if isinstance(ch_raw, dict):
|
||||
for code, v in ch_raw.items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
tlist = v.get("tomorrow") or []
|
||||
items: list[ArrivalHourItem] = []
|
||||
if isinstance(tlist, list):
|
||||
for it in tlist:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
items.append(
|
||||
ArrivalHourItem(
|
||||
hour=int(it.get("hour") or 0),
|
||||
confidence_pct=int(it.get("confidence_pct") or 0),
|
||||
samples=int(it.get("samples") or 0),
|
||||
)
|
||||
)
|
||||
for r in preds
|
||||
]
|
||||
)
|
||||
chargers[str(code)] = ChargerTomorrowArrival(tomorrow=items)
|
||||
|
||||
td = raw.get("tomorrow_date")
|
||||
if isinstance(td, date):
|
||||
td_s = td.isoformat()
|
||||
elif isinstance(td, datetime):
|
||||
td_s = td.date().isoformat()
|
||||
else:
|
||||
td_s = str(td or "")
|
||||
|
||||
return EvArrivalPredictionResponse(
|
||||
insufficient_data=insufficient,
|
||||
tomorrow_date=tomorrow_d.isoformat(),
|
||||
insufficient_data=bool(raw.get("insufficient_data")),
|
||||
tomorrow_date=td_s,
|
||||
chargers=chargers,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -10,7 +11,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
from app.notifications_logic import (
|
||||
EvSessionRow,
|
||||
@@ -47,6 +48,16 @@ def _iso_utc(dt: datetime | None) -> str | None:
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_ts(val: Any) -> datetime | None:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
return None
|
||||
|
||||
|
||||
def _age_seconds(at: datetime | None) -> int | None:
|
||||
if at is None:
|
||||
return None
|
||||
@@ -81,174 +92,105 @@ async def get_site_status_full(
|
||||
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
|
||||
""",
|
||||
bundle = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_full_status($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if site is None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if not isinstance(bundle, dict):
|
||||
bundle = json.loads(bundle)
|
||||
if bundle.get("error") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
tz = site["timezone"] or "Europe/Prague"
|
||||
site = bundle.get("site") or {}
|
||||
mode_row = bundle.get("operating_mode") or {}
|
||||
hb_row = bundle.get("heartbeat") or {}
|
||||
inv_row = bundle.get("inverter_latest")
|
||||
if not isinstance(inv_row, dict):
|
||||
inv_row = None
|
||||
ev_rows = bundle.get("ev_chargers") or []
|
||||
if not isinstance(ev_rows, list):
|
||||
ev_rows = []
|
||||
hp_row = bundle.get("heat_pump_latest")
|
||||
if not isinstance(hp_row, dict):
|
||||
hp_row = None
|
||||
reserve_row = bundle.get("battery_limits") or {}
|
||||
run_row = bundle.get("active_plan")
|
||||
if not isinstance(run_row, dict):
|
||||
run_row = None
|
||||
intervals: list[dict[str, Any]] = []
|
||||
raw_iv = bundle.get("planning_intervals") or []
|
||||
if isinstance(raw_iv, list):
|
||||
intervals = [x for x in raw_iv if isinstance(x, dict)]
|
||||
|
||||
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,
|
||||
MIN(min_soc_percent)::float AS min_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,
|
||||
load_baseline_w,
|
||||
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
|
||||
pv_a_forecast_solver_w, pv_b_forecast_solver_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)
|
||||
tomorrow_slots = int(bundle.get("tomorrow_price_slot_count") or 0)
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
hb_last = hb_row["last_seen"] if hb_row else None
|
||||
hb_last = hb_row.get("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_measured = inv_row.get("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:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
ev_list.append(
|
||||
{
|
||||
"code": r["code"],
|
||||
"status": r["status"],
|
||||
"power_w": int(r["power_w"]) if r["power_w"] is not None else None,
|
||||
"code": r.get("code"),
|
||||
"status": r.get("status"),
|
||||
"power_w": int(r["power_w"]) if r.get("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
|
||||
"pv_power_w": int(inv_row["pv_power_w"])
|
||||
if inv_row and inv_row.get("pv_power_w") is not None
|
||||
else None,
|
||||
"battery_soc_pct": float(inv_row["battery_soc_percent"])
|
||||
if inv_row and inv_row.get("battery_soc_percent") is not None
|
||||
else None,
|
||||
"grid_power_w": int(inv_row["grid_power_w"])
|
||||
if inv_row and inv_row.get("grid_power_w") 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,
|
||||
"power_w": int(hp_row["power_w"]) if hp_row and hp_row.get("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
|
||||
if hp_row and hp_row.get("tuv_tank_temp_c") is not None
|
||||
else None,
|
||||
"measured_at": _iso_utc(hp_row["measured_at"]) if hp_row else None,
|
||||
"measured_at": _iso_utc(hp_row.get("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,
|
||||
"plan_created_at": _iso_utc(run_row.get("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
|
||||
min_soc = float(reserve_row["min_soc"]) if reserve_row and reserve_row["min_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
|
||||
mode_code = (mode_row.get("mode_code") if mode_row else None) or ""
|
||||
reserve_soc = (
|
||||
float(reserve_row["reserve_soc"])
|
||||
if reserve_row and reserve_row.get("reserve_soc") is not None
|
||||
else None
|
||||
)
|
||||
min_soc = (
|
||||
float(reserve_row["min_soc"]) if reserve_row and reserve_row.get("min_soc") is not None else None
|
||||
)
|
||||
soc = (
|
||||
float(inv_row["battery_soc_percent"])
|
||||
if inv_row and inv_row.get("battery_soc_percent") is not None
|
||||
else None
|
||||
)
|
||||
|
||||
alerts: list[dict[str, str]] = []
|
||||
|
||||
@@ -281,17 +223,17 @@ async def get_site_status_full(
|
||||
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"]},
|
||||
"site": {"id": site.get("id"), "code": site.get("code"), "name": site.get("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,
|
||||
"mode_code": mode_row.get("mode_code") if mode_row else None,
|
||||
"mode_name": mode_row.get("mode_name") if mode_row else None,
|
||||
"activated_at": _iso_utc(mode_row.get("activated_at")) if mode_row else None,
|
||||
"activated_by": mode_row.get("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,
|
||||
"status": hb_row.get("status") if hb_row else None,
|
||||
},
|
||||
"telemetry": telemetry,
|
||||
"planning": planning,
|
||||
@@ -395,156 +337,39 @@ async def get_site_notifications(
|
||||
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",
|
||||
ctx = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_notifications_context($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if site is None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
tz = site["timezone"] or "Europe/Prague"
|
||||
if not isinstance(ctx, dict):
|
||||
ctx = json.loads(ctx)
|
||||
if ctx.get("error") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
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,
|
||||
MIN(min_soc_percent)::float AS min_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,
|
||||
)
|
||||
has_plan = bool(ctx.get("has_plan"))
|
||||
mode_code = (ctx.get("mode_code") or "") or ""
|
||||
reserve_soc = _float_or_none(ctx.get("reserve_soc"))
|
||||
min_soc = _float_or_none(ctx.get("min_soc"))
|
||||
soc = _float_or_none(ctx.get("soc_pct"))
|
||||
inv_age = _age_seconds(_parse_ts(ctx.get("inv_measured_at")))
|
||||
hb_age = _age_seconds(_parse_ts(ctx.get("hb_last_seen")))
|
||||
tomorrow_slots = int(ctx.get("tomorrow_slots") or 0)
|
||||
|
||||
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,
|
||||
)
|
||||
price_rows = ctx.get("price_slots") or []
|
||||
if not isinstance(price_rows, list):
|
||||
price_rows = []
|
||||
|
||||
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,
|
||||
)
|
||||
avg_buy = _float_or_none(ctx.get("avg_buy"))
|
||||
usable_wh = _float_or_none(ctx.get("usable_wh"))
|
||||
|
||||
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 = ctx.get("ev_sessions") or []
|
||||
if not isinstance(ev_rows, list):
|
||||
ev_rows = []
|
||||
|
||||
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
|
||||
)
|
||||
min_soc = (
|
||||
float(reserve_row["min_soc"])
|
||||
if reserve_row and reserve_row["min_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)
|
||||
neg_rows = ctx.get("neg_windows") or []
|
||||
if not isinstance(neg_rows, list):
|
||||
neg_rows = []
|
||||
|
||||
infra = _infrastructure_notification_items(
|
||||
has_plan=has_plan,
|
||||
@@ -559,11 +384,15 @@ async def get_site_notifications(
|
||||
|
||||
prices: list[PriceSlot] = []
|
||||
for r in price_rows:
|
||||
buy = _float_or_none(r["effective_buy_price_czk_kwh"])
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
buy = _float_or_none(r.get("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"]
|
||||
sell_v = _float_or_none(r.get("effective_sell_price_czk_kwh"))
|
||||
istart = r.get("interval_start")
|
||||
if isinstance(istart, str):
|
||||
istart = datetime.fromisoformat(istart.replace("Z", "+00:00"))
|
||||
prices.append(
|
||||
PriceSlot(
|
||||
interval_start=istart,
|
||||
@@ -572,43 +401,50 @@ async def get_site_notifications(
|
||||
)
|
||||
)
|
||||
|
||||
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:
|
||||
if not isinstance(er, dict):
|
||||
continue
|
||||
ss = er.get("session_start")
|
||||
if isinstance(ss, str):
|
||||
ss = datetime.fromisoformat(ss.replace("Z", "+00:00"))
|
||||
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"]),
|
||||
energy_delivered_wh=float(er.get("energy_delivered_wh") or 0),
|
||||
target_soc_pct=_float_or_none(er.get("target_soc_pct")),
|
||||
session_start=ss,
|
||||
battery_capacity_kwh=_float_or_none(er.get("battery_capacity_kwh")),
|
||||
make=er.get("make"),
|
||||
model=er.get("model"),
|
||||
default_target_soc_pct=_float_or_none(er.get("default_target_soc_pct")),
|
||||
charger_code=str(er.get("charger_code") or ""),
|
||||
soc_at_connect_pct=_float_or_none(er.get("soc_at_connect_pct")),
|
||||
)
|
||||
)
|
||||
|
||||
neg_windows: list[NegWindowRow] = []
|
||||
for nr in neg_rows:
|
||||
dr = nr["predicted_date"]
|
||||
if not isinstance(nr, dict):
|
||||
continue
|
||||
dr = nr.get("predicted_date")
|
||||
if isinstance(dr, datetime):
|
||||
d_conv = dr.date()
|
||||
elif isinstance(dr, date):
|
||||
d_conv = dr
|
||||
elif isinstance(dr, str):
|
||||
d_conv = date.fromisoformat(dr[:10])
|
||||
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"]),
|
||||
window_start_hour=int(nr.get("window_start_hour") or 0),
|
||||
window_end_hour=int(nr.get("window_end_hour") or 0),
|
||||
probability_pct=int(nr.get("probability_pct") or 0),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
@@ -9,7 +10,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
|
||||
@@ -19,39 +20,30 @@ class InverterModbusCurrentCapsBody(BaseModel):
|
||||
"""Tvrdý strop proudu pro zápis Deye reg 108/109 (A); NULL ve JSONu = smaž strop v DB."""
|
||||
|
||||
deye_register_max_charge_a: int | None = Field(
|
||||
default=None, ge=0, le=640, description="None při vynechání klíče = nezměnit; explicitní null = smazat strop"
|
||||
default=None,
|
||||
ge=0,
|
||||
le=640,
|
||||
description="None při vynechání klíče = nezměnit; explicitní null = smazat strop",
|
||||
)
|
||||
deye_register_max_discharge_a: int | None = Field(
|
||||
default=None, ge=0, le=640, description="Jako u nabíjení"
|
||||
default=None,
|
||||
ge=0,
|
||||
le=640,
|
||||
description="Jako u nabíjení",
|
||||
)
|
||||
|
||||
_DEYE_KEYS = frozenset(
|
||||
{
|
||||
"deye_last_system_time_sync_at",
|
||||
"deye_last_system_time_sync_minute",
|
||||
"deye_last_tou_inactive_write_prague_date",
|
||||
"deye_tou_inactive_signature",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _mask_secret_reference(raw: str | None) -> str | None:
|
||||
if raw is None:
|
||||
def _iso_utc_from_cfg(val: Any) -> str | None:
|
||||
if val is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
if len(s) <= 4:
|
||||
return "nastaveno"
|
||||
return f"…{s[-2:]}"
|
||||
|
||||
|
||||
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()
|
||||
if isinstance(val, str):
|
||||
return val
|
||||
if isinstance(val, datetime):
|
||||
dt = val
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
return str(val)
|
||||
|
||||
|
||||
@router.get("/configuration")
|
||||
@@ -60,204 +52,29 @@ async def get_site_configuration(
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> dict[str, Any]:
|
||||
async with pool.acquire() as conn:
|
||||
site_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at
|
||||
FROM ems.site
|
||||
WHERE id = $1
|
||||
""",
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_configuration($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if site_row is None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
grid_row = await conn.fetchrow(
|
||||
"SELECT * FROM ems.site_grid_connection WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
market_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT *
|
||||
FROM ems.site_market_config
|
||||
WHERE site_id = $1
|
||||
AND valid_from <= now()
|
||||
AND (valid_to IS NULL OR valid_to > now())
|
||||
ORDER BY valid_from DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
endpoint_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, site_id, endpoint_type, host, port, protocol, unit_id,
|
||||
auth_reference, enabled, notes
|
||||
FROM ems.site_endpoint
|
||||
WHERE site_id = $1
|
||||
ORDER BY id
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
endpoints: list[dict[str, Any]] = []
|
||||
for er in endpoint_rows:
|
||||
d = record_to_dict(er)
|
||||
d["auth_reference"] = _mask_secret_reference(er["auth_reference"])
|
||||
endpoints.append(d)
|
||||
|
||||
inv_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT ai.*,
|
||||
(SELECT ep.host || CASE
|
||||
WHEN ep.port IS NOT NULL THEN ':' || ep.port::text
|
||||
ELSE ''
|
||||
END
|
||||
FROM ems.site_endpoint ep
|
||||
WHERE ep.id = ai.endpoint_id) AS endpoint_connection
|
||||
FROM ems.asset_inverter ai
|
||||
WHERE ai.site_id = $1
|
||||
ORDER BY ai.id
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
inverters: list[dict[str, Any]] = []
|
||||
for ir in inv_rows:
|
||||
full = record_to_dict(ir)
|
||||
ep_label = full.pop("endpoint_connection", None)
|
||||
core = {k: v for k, v in full.items() if k not in _DEYE_KEYS}
|
||||
deye_meta = {k: full[k] for k in _DEYE_KEYS if full.get(k) is not None}
|
||||
core["endpoint_connection"] = ep_label
|
||||
core["deye_meta"] = deye_meta if deye_meta else None
|
||||
inverters.append(core)
|
||||
|
||||
bat_rows = await conn.fetch(
|
||||
"SELECT * FROM ems.asset_battery WHERE site_id = $1 ORDER BY id",
|
||||
site_id,
|
||||
)
|
||||
pv_rows = await conn.fetch(
|
||||
"SELECT * FROM ems.asset_pv_array WHERE site_id = $1 ORDER BY id",
|
||||
site_id,
|
||||
)
|
||||
ev_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT ec.*,
|
||||
se.host || CASE
|
||||
WHEN se.port IS NOT NULL THEN ':' || se.port::text
|
||||
ELSE ''
|
||||
END AS endpoint_connection
|
||||
FROM ems.asset_ev_charger ec
|
||||
LEFT JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||
WHERE ec.site_id = $1
|
||||
ORDER BY ec.id
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
ev_chargers = [record_to_dict(r) for r in ev_rows]
|
||||
|
||||
veh_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, site_id, code, name, make, model, battery_capacity_kwh,
|
||||
max_charge_power_w, default_charger_id, api_type, api_reference,
|
||||
default_target_soc_pct, default_deadline_hour, active
|
||||
FROM ems.asset_vehicle
|
||||
WHERE site_id = $1
|
||||
ORDER BY code
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
vehicles: list[dict[str, Any]] = []
|
||||
for vr in veh_rows:
|
||||
d = record_to_dict(vr)
|
||||
d["api_reference"] = _mask_secret_reference(vr["api_reference"])
|
||||
vehicles.append(d)
|
||||
|
||||
hp_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT hp.*,
|
||||
se.host || CASE
|
||||
WHEN se.port IS NOT NULL THEN ':' || se.port::text
|
||||
ELSE ''
|
||||
END AS endpoint_connection
|
||||
FROM ems.asset_heat_pump hp
|
||||
LEFT JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||||
WHERE hp.site_id = $1
|
||||
ORDER BY hp.id
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
heat_pumps = [record_to_dict(r) for r in hp_rows]
|
||||
|
||||
mode_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT m.mode_code, m.activated_at, m.activated_by, m.valid_until,
|
||||
m.previous_mode, m.notes,
|
||||
d.name AS mode_name, d.description AS mode_description,
|
||||
d.loxone_mode_value, d.ev_enabled, d.heat_pump_enabled,
|
||||
d.battery_mode, d.grid_mode, d.is_autonomous
|
||||
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,
|
||||
)
|
||||
|
||||
override_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, override_type, value_json, valid_from, valid_to, reason, created_by, created_at
|
||||
FROM ems.site_override
|
||||
WHERE site_id = $1
|
||||
AND valid_from <= now()
|
||||
AND (valid_to IS NULL OR valid_to > now())
|
||||
ORDER BY valid_from DESC
|
||||
LIMIT 50
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
hb_row = await conn.fetchrow(
|
||||
"SELECT last_seen, status FROM ems.site_heartbeat 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,
|
||||
)
|
||||
|
||||
site = record_to_dict(site_row)
|
||||
lat = site_row["latitude"]
|
||||
lon = site_row["longitude"]
|
||||
site["latitude"] = float(lat) if lat is not None else None
|
||||
site["longitude"] = float(lon) if lon is not None else None
|
||||
|
||||
operating_mode = record_to_dict(mode_row) if mode_row else None
|
||||
|
||||
return {
|
||||
"site": site,
|
||||
"grid_connection": record_to_dict(grid_row) if grid_row else None,
|
||||
"market_config": record_to_dict(market_row) if market_row else None,
|
||||
"market_config_note": (
|
||||
"Zelený bonus za výrobu je u FVE polí (asset_pv_array), ne v obchodní konfiguraci."
|
||||
),
|
||||
"endpoints": endpoints,
|
||||
"inverters": inverters,
|
||||
"batteries": [record_to_dict(r) for r in bat_rows],
|
||||
"pv_arrays": [record_to_dict(r) for r in pv_rows],
|
||||
"ev_chargers": ev_chargers,
|
||||
"vehicles": vehicles,
|
||||
"heat_pumps": heat_pumps,
|
||||
"operating_mode": operating_mode,
|
||||
"active_overrides": [record_to_dict(r) for r in override_rows],
|
||||
"operational": {
|
||||
"heartbeat_last_seen": _iso_utc(hb_row["last_seen"]) if hb_row else None,
|
||||
"heartbeat_status": hb_row["status"] if hb_row else None,
|
||||
"has_active_plan": run_row is not None,
|
||||
"active_plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None,
|
||||
},
|
||||
}
|
||||
if raw is None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
op = raw.get("operational")
|
||||
if isinstance(op, dict):
|
||||
op = dict(op)
|
||||
op["heartbeat_last_seen"] = _iso_utc_from_cfg(op.get("heartbeat_last_seen"))
|
||||
op["active_plan_created_at"] = _iso_utc_from_cfg(op.get("active_plan_created_at"))
|
||||
raw["operational"] = op
|
||||
lat = raw.get("site", {}).get("latitude") if isinstance(raw.get("site"), dict) else None
|
||||
lon = raw.get("site", {}).get("longitude") if isinstance(raw.get("site"), dict) else None
|
||||
if isinstance(raw.get("site"), dict):
|
||||
site = dict(raw["site"])
|
||||
site["latitude"] = float(lat) if lat is not None else None
|
||||
site["longitude"] = float(lon) if lon is not None else None
|
||||
raw["site"] = site
|
||||
return raw
|
||||
|
||||
|
||||
@router.patch("/inverters/{inverter_id}/modbus-current-caps")
|
||||
@@ -269,7 +86,6 @@ async def patch_inverter_modbus_current_caps(
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Nastavení `deye_register_max_charge_a` / `deye_register_max_discharge_a` na `ems.asset_inverter`.
|
||||
Hodnoty se uplatní v dotazu `_load_inverter_config` jako `COALESCE(strop_A, FLOOR(…z_kW))` pro reg 108/109.
|
||||
"""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
@@ -277,52 +93,29 @@ async def patch_inverter_modbus_current_caps(
|
||||
status_code=400,
|
||||
detail="Send at least one of: deye_register_max_charge_a, deye_register_max_discharge_a",
|
||||
)
|
||||
patch: dict[str, Any] = {}
|
||||
if "deye_register_max_charge_a" in updates:
|
||||
patch["deye_register_max_charge_a"] = updates["deye_register_max_charge_a"]
|
||||
if "deye_register_max_discharge_a" in updates:
|
||||
patch["deye_register_max_discharge_a"] = updates["deye_register_max_discharge_a"]
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
owner = await conn.fetchval(
|
||||
"""
|
||||
SELECT id FROM ems.asset_inverter
|
||||
WHERE id = $1 AND site_id = $2
|
||||
""",
|
||||
inverter_id,
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_inverter_modbus_caps_patch($1::int, $2::int, $3::jsonb)",
|
||||
site_id,
|
||||
inverter_id,
|
||||
json.dumps(patch),
|
||||
)
|
||||
if owner is None:
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
if not raw.get("ok"):
|
||||
if raw.get("error") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="Inverter not found for this site")
|
||||
|
||||
sets: list[str] = []
|
||||
args: list[Any] = []
|
||||
n = 1
|
||||
if "deye_register_max_charge_a" in updates:
|
||||
sets.append(f"deye_register_max_charge_a = ${n}")
|
||||
args.append(updates["deye_register_max_charge_a"])
|
||||
n += 1
|
||||
if "deye_register_max_discharge_a" in updates:
|
||||
sets.append(f"deye_register_max_discharge_a = ${n}")
|
||||
args.append(updates["deye_register_max_discharge_a"])
|
||||
n += 1
|
||||
|
||||
args.extend([inverter_id, site_id])
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET {", ".join(sets)}
|
||||
WHERE id = ${n} AND site_id = ${n + 1}
|
||||
""",
|
||||
*args,
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, code, deye_register_max_charge_a, deye_register_max_discharge_a
|
||||
FROM ems.asset_inverter
|
||||
WHERE id = $1 AND site_id = $2
|
||||
""",
|
||||
inverter_id,
|
||||
site_id,
|
||||
)
|
||||
assert row is not None
|
||||
raise HTTPException(status_code=400, detail=raw.get("error", "patch_failed"))
|
||||
return {
|
||||
"inverter_id": int(row["id"]),
|
||||
"code": row["code"],
|
||||
"deye_register_max_charge_a": row["deye_register_max_charge_a"],
|
||||
"deye_register_max_discharge_a": row["deye_register_max_discharge_a"],
|
||||
"inverter_id": int(raw["inverter_id"]),
|
||||
"code": raw["code"],
|
||||
"deye_register_max_charge_a": raw.get("deye_register_max_charge_a"),
|
||||
"deye_register_max_discharge_a": raw.get("deye_register_max_discharge_a"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user