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,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

View File

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

View File

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

View File

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

View File

@@ -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,
)

View File

@@ -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),
)
)

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(

View File

@@ -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"),
}

View File

@@ -3,51 +3,17 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
"""
Naplní audit_interval pro všechny dokončené 15min intervaly
za posledních 6 hodin které ještě nemají záznam.
Volá PostgreSQL funkci ems.fn_fill_audit_interval().
Naplní audit_interval pro dokončené 15min sloty přes ems.fn_fill_audit_for_site_window.
"""
now = datetime.now(timezone.utc)
last_complete = now.replace(
minute=(now.minute // 15) * 15, second=0, microsecond=0
)
rows = await db.fetch(
"""
SELECT gs.slot
FROM generate_series(
$1::timestamptz - interval '6 hours',
$1::timestamptz - interval '15 minutes',
interval '15 minutes'
) AS gs(slot)
WHERE NOT EXISTS (
SELECT 1 FROM ems.audit_interval ai
WHERE ai.site_id = $2 AND ai.interval_start = gs.slot
)
""",
last_complete,
n = await db.fetchval(
"select ems.fn_fill_audit_for_site_window($1::int, 6)",
site_id,
)
for row in rows:
slot = row["slot"]
await db.execute(
"SELECT ems.fn_fill_audit_interval($1, $2)",
site_id,
slot,
)
await db.execute(
"SELECT ems.fn_fill_baseline_load_forecast_accuracy($1, $2)",
site_id,
slot,
)
if rows:
logger.info("[site=%s] Filled %s missing audit intervals", site_id, len(rows))
if n:
logger.info("[site=%s] Filled %s missing audit intervals", site_id, int(n))

View File

@@ -0,0 +1,3 @@
"""Deye / Modbus control export (monolith v exporter_monolith.py postupný split)."""
from .exporter_monolith import * # noqa: F401,F403

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
@@ -78,31 +79,26 @@ async def run_fn_set_mode_with_discord(
notify_level: str | None = None,
) -> str:
"""
Zavolá ems.fn_set_mode. Při skutečné změně režimu pošle Discord (pokud je webhook).
Zavolá ems.fn_set_mode_with_context. Při skutečné změně režimu pošle Discord (pokud je webhook).
Vrátí aktuální mode_code z DB po volání.
"""
prev = await conn.fetchval(
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
site_id,
)
await conn.execute(
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
raw = await conn.fetchval(
"""
select ems.fn_set_mode_with_context($1::int, $2::text, $3::text, $4::timestamptz, $5::text)
""",
site_id,
mode_code,
activated_by,
valid_until,
notes,
)
new = await conn.fetchval(
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
site_id,
)
ctx = raw if isinstance(raw, dict) else json.loads(raw)
prev = ctx.get("previous_mode")
new = ctx.get("new_mode")
if new is None:
new = mode_code
site_code = ctx.get("site_code")
if prev is not None and prev != new:
site_code = await conn.fetchval(
"SELECT code FROM ems.site WHERE id = $1", site_id
)
await notify_operating_mode_changed(
site_code or str(site_id),
str(prev),

View File

@@ -7,6 +7,7 @@
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
import json
import time
import logging
from dataclasses import dataclass, replace
@@ -149,107 +150,6 @@ def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
return (loc.weekday() + 1) % 7, loc.hour
# ============================================================
# Slot pre-selection (anti-micro-cycling)
# ============================================================
def _select_charge_slots(
slots: list["PlanningSlot"],
battery,
current_soc_wh: float,
) -> set[int]:
"""
Pre-select which slots are eligible for battery charging (anti-micro-cycling).
Logika:
1) Sloty s PV-surplus (pv_a + pv_b > load_baseline) jsou vždy zahrnuty
nabíjení z FVE je „zdarma“, solver ho musí mít povolené. Tyto sloty se
NEzapočítávají do grid rozpočtu (v dlouhém horizontu by přetekly target).
2) Nezávisle na bodu 1 se vybere top-N **grid** slotů seřazených podle
`buy_price` ASC tak, aby pokryly `charge_buf × (soc_max current_soc)`.
Tím dostane solver k dispozici přístup k nejlevnějšímu nákupu ze sítě,
i když PV v daném slotu spotřebu nepokrývá.
3) Per-slot kapacita přírůstku SoC = max_charge_power × η × 15 min (plný
výkon, ne limitovaný aktuálním PV-surplus výkonem).
Vrací množinu indexů povolených pro `bc[t] > 0` v MILP. Prázdná množina = žádné
restrikce. `charge_slot_buffer <= 0` v DB ⇒ všechny sloty povoleny.
"""
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
if charge_buf <= 0:
return set(range(len(slots)))
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
if energy_to_fill <= 0:
return set()
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
per_slot_full_wh = max_p_w * eta * INTERVAL_H
selected: set[int] = set()
for t, s in enumerate(slots):
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
if pv_surplus_w > 0:
selected.add(t)
grid_target_wh = energy_to_fill * charge_buf
if grid_target_wh <= 0 or per_slot_full_wh <= 0:
return selected
grid_candidates = [
(t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected
]
grid_candidates.sort(key=lambda x: x[1])
cumulative = 0.0
for t, _price in grid_candidates:
if cumulative >= grid_target_wh:
break
selected.add(t)
cumulative += per_slot_full_wh
return selected
def _select_discharge_export_slots(
slots: list["PlanningSlot"],
battery,
) -> set[int]:
"""
Pre-select which slots may use battery energy for grid export.
Only the Y most expensive sell-price slots are selected,
enough to empty the exportable portion of the battery with a buffer.
Returns set of slot indices. Empty set = no restriction.
"""
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
if discharge_buf <= 0:
return set(range(len(slots)))
exportable = float(battery.soc_max_wh) - float(battery.min_soc_wh)
if exportable <= 0:
return set()
candidates = [(t, float(s.sell_price)) for t, s in enumerate(slots)]
candidates.sort(key=lambda x: x[1], reverse=True)
energy_per_slot = (
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
* INTERVAL_H
)
target = exportable * discharge_buf
selected: set[int] = set()
cumulative = 0.0
for t, _price in candidates:
if cumulative >= target:
break
selected.add(t)
cumulative += energy_per_slot
return selected
# ============================================================
# Datové třídy (lze nahradit pydantic modely)
# ============================================================
@@ -265,6 +165,8 @@ class PlanningSlot:
ev1_connected: bool
ev2_connected: bool
is_predicted_price: bool = False
allow_charge: bool = True
allow_discharge_export: bool = True
@dataclass
@@ -303,49 +205,31 @@ async def compute_correction_factor(
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
"""
window_start = now - timedelta(hours=window_h)
# Skutečná výroba za okno (z telemetrie)
actual = await db.fetchval("""
SELECT COALESCE(SUM(pv_power_w) * 0.25 / 1000.0, 0) -- kWh
FROM ems.telemetry_inverter
WHERE site_id = $1
AND measured_at >= $2 AND measured_at < $3
""", site_id, window_start, now)
# Předpovídaná výroba za stejné okno (z nejnovějšího forecastu který platil tehdy)
forecast = await db.fetchval("""
SELECT COALESCE(SUM(fpi.power_w) * 0.25 / 1000.0, 0)
FROM ems.forecast_pv_interval fpi
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
WHERE fpr.site_id = $1
AND fpi.interval_start >= $2 AND fpi.interval_start < $3
AND fpr.status = 'ok'
AND fpr.created_at = (
SELECT MAX(fpr2.created_at)
FROM ems.forecast_pv_run fpr2
WHERE fpr2.site_id = $1 AND fpr2.status = 'ok'
AND fpr2.created_at <= $2
)
""", site_id, window_start, now)
raw = await db.fetchval(
"""
select ems.fn_pv_forecast_correction_factor(
$1::int, $2::timestamptz, $3::timestamptz,
$4::numeric, $5::numeric
)
""",
site_id,
window_start,
now,
CORRECTION_MIN_CLAMP,
CORRECTION_MAX_CLAMP,
)
j = raw if isinstance(raw, dict) else json.loads(raw)
factor = float(j.get("correction_factor", 1.0))
log_data = {
"window_start": window_start,
"window_end": now,
"actual_pv_wh": actual * 1000,
"forecast_pv_wh": forecast * 1000,
"window_start": j.get("window_start", window_start),
"window_end": j.get("window_end", now),
"actual_pv_wh": j.get("actual_pv_wh"),
"forecast_pv_wh": j.get("forecast_pv_wh"),
"correction_factor": factor,
"reason": j.get("reason", "ok"),
}
# Pokud forecast nebo actual jsou příliš malé (noc, <0.1 kWh) → žádná korekce
if forecast < 0.1 or actual < 0.05:
log_data["correction_factor"] = 1.0
log_data["reason"] = "insufficient_data"
return 1.0, log_data
raw_factor = actual / forecast
factor = max(CORRECTION_MIN_CLAMP, min(CORRECTION_MAX_CLAMP, raw_factor))
log_data["correction_factor"] = factor
log_data["raw_factor"] = raw_factor
if j.get("raw_factor") is not None:
log_data["raw_factor"] = j["raw_factor"]
return factor, log_data
@@ -559,10 +443,10 @@ def solve_dispatch(
if slots[t].is_predicted_price:
prob += ge[t] == 0
# Slot pre-selection: omezení nabíjení a discharge-exportu na vybrané sloty
# Slot pre-selection (z DB fn_load_planning_slots_full → allow_*)
if om == "AUTO":
charge_slots = _select_charge_slots(slots, battery, current_soc_wh)
discharge_export_slots = _select_discharge_export_slots(slots, battery)
charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
discharge_export_slots = {t for t, s in enumerate(slots) if s.allow_discharge_export}
for t in range(T):
if t not in charge_slots:
prob += bc[t] == 0
@@ -683,7 +567,10 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
logger.info(f"[site={site_id}] Daily plan: {horizon_from}{horizon_to}")
slots = await _load_slots(site_id, horizon_from, horizon_to, db)
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
await _load_site_context(site_id, db)
)
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
critical_slots = int(36 / INTERVAL_H)
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
price_failsafe_active = missing_ote_count > 0
@@ -694,11 +581,6 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
missing_ote_count,
)
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode = (
await _load_site_context(site_id, db)
)
tuv_stats = await _load_tuv_usage_stats(site_id, db)
results, duration_ms = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
@@ -750,17 +632,20 @@ async def run_rolling_replan(
now = datetime.now(timezone.utc)
replan_from = _current_slot_start(now)
active_run = await db.fetchrow("""
SELECT id, horizon_end FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC LIMIT 1
""", site_id)
if not active_run:
ar_raw = await db.fetchval(
"select ems.fn_planning_active_run($1::int)",
site_id,
)
ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw)
if ar.get("error") == "no_active_plan":
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
horizon_to = active_run["horizon_end"]
he = ar["horizon_end"]
if isinstance(he, datetime):
horizon_to = he if he.tzinfo else he.replace(tzinfo=timezone.utc)
else:
horizon_to = datetime.fromisoformat(str(he).replace("Z", "+00:00"))
if (horizon_to - replan_from).total_seconds() < 1800:
if allow_skip:
@@ -771,13 +656,13 @@ async def run_rolling_replan(
logger.info(f"[site={site_id}] Rolling replan from {replan_from}{horizon_to}")
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode = (
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
await _load_site_context(site_id, db)
)
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
slots = await _load_slots(site_id, replan_from, horizon_to, db)
slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh)
slots_before_pv_correction = list(slots)
critical_slots = int(36 / INTERVAL_H)
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
@@ -791,8 +676,6 @@ async def run_rolling_replan(
slots = apply_forecast_correction(slots, now, correction_factor)
tuv_stats = await _load_tuv_usage_stats(site_id, db)
results, duration_ms = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
@@ -818,10 +701,10 @@ async def run_rolling_replan(
await db.execute(
"""
INSERT INTO ems.forecast_correction_log
(site_id, window_start, window_end, actual_pv_wh, forecast_pv_wh,
correction_factor, applied_to_run_id)
VALUES ($1,$2,$3,$4,$5,$6,$7)
select ems.fn_forecast_correction_log_insert(
$1::int, $2::timestamptz, $3::timestamptz,
$4::numeric, $5::numeric, $6::numeric, $7::int
)
""",
site_id,
correction_log["window_start"],
@@ -870,184 +753,86 @@ def _current_slot_start(dt: datetime) -> datetime:
return dt.replace(minute=minute, second=0, microsecond=0)
def _ev_session_ctx(row) -> Optional[SimpleNamespace]:
"""Kontext deadline constraintu pro jedno EV (nebo None)."""
if row is None or row["target_deadline"] is None:
def _parse_json_dt(val: object) -> Optional[datetime]:
if val is None:
return None
cap_kwh = row["veh_cap_kwh"]
if cap_kwh is None:
if isinstance(val, datetime):
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
if obj is None or obj == []:
return None
cap_wh = float(cap_kwh) * 1000.0
tgt = row["target_soc_pct"]
if tgt is None:
tgt = row["default_target_soc_pct"]
if tgt is None:
if isinstance(obj, str):
obj = json.loads(obj)
if not isinstance(obj, dict):
return None
tgt_f = float(tgt)
soc0 = row["soc_at_connect_pct"]
if soc0 is None:
return None
needed_wh = (tgt_f - float(soc0)) / 100.0 * cap_wh
delivered = float(row["energy_delivered_wh"] or 0)
remaining = max(0.0, needed_wh - delivered)
if remaining <= 0:
td = _parse_json_dt(obj.get("target_deadline"))
if td is None:
return None
return SimpleNamespace(
target_deadline=row["target_deadline"],
energy_needed_wh=remaining,
target_deadline=td,
energy_needed_wh=float(obj["energy_needed_wh"]),
)
async def _load_site_context(site_id: int, db):
"""
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV a provozní režim pro solver.
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV, režim a TUV statistiky (SQL).
"""
operating_mode = await db.fetchval(
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
raw = await db.fetchval(
"select ems.fn_planning_site_context($1::int)",
site_id,
)
ctx = raw if isinstance(raw, dict) else json.loads(raw)
if ctx.get("error") == "unknown_site":
raise RuntimeError(f"Site not found: {site_id}")
brow = await db.fetchrow(
"""
SELECT ab.usable_capacity_wh,
ab.min_soc_percent,
ab.reserve_soc_percent,
ab.max_soc_percent,
ab.charge_efficiency,
ab.discharge_efficiency,
ab.degradation_cost_czk_kwh,
ab.charge_slot_buffer,
ab.discharge_slot_buffer,
LEAST(
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w),
COALESCE(
ab.bms_max_charge_w,
CASE WHEN ab.max_charge_c_rate IS NOT NULL
THEN (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
END,
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w)
)
) AS effective_charge_w,
LEAST(
COALESCE(ai.max_battery_discharge_w, ai.max_discharge_power_w),
COALESCE(
ab.bms_max_discharge_w,
CASE WHEN ab.max_discharge_c_rate IS NOT NULL
THEN (ab.max_discharge_c_rate * ab.usable_capacity_wh)::bigint
END,
COALESCE(ai.max_battery_discharge_w, ai.max_discharge_power_w)
)
) AS effective_discharge_w
FROM ems.asset_battery ab
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id AND ai.site_id = ab.site_id
WHERE ab.site_id = $1
ORDER BY ab.id
LIMIT 1
""",
site_id,
)
if brow is None:
raise RuntimeError(f"No asset_battery for site_id={site_id}")
ec_w = brow["effective_charge_w"]
ed_w = brow["effective_discharge_w"]
if ec_w is None or ed_w is None:
raise RuntimeError(
f"Battery effective power limits missing for site_id={site_id} "
"(need max_battery_charge_w/max_discharge or legacy max_charge_power_w / max_discharge_power_w)"
)
ec_i = int(ec_w)
ed_i = int(ed_w)
if ec_i <= 0 or ed_i <= 0:
raise RuntimeError(
f"Invalid battery effective limits for site_id={site_id}: "
f"charge={ec_i}W discharge={ed_i}W"
)
uc = float(brow["usable_capacity_wh"])
min_soc_wh = float(brow["min_soc_percent"]) / 100.0 * uc
arb_floor_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc
soc_max_wh = float(brow["max_soc_percent"]) / 100.0 * uc
b = ctx["battery"]
ec_i = int(b["max_charge_power_w"])
ed_i = int(b["max_discharge_power_w"])
battery = SimpleNamespace(
usable_capacity_wh=uc,
min_soc_wh=min_soc_wh,
arb_floor_wh=arb_floor_wh,
reserve_soc_wh=arb_floor_wh,
soc_max_wh=soc_max_wh,
charge_efficiency=float(brow["charge_efficiency"]),
discharge_efficiency=float(brow["discharge_efficiency"]),
degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]),
usable_capacity_wh=float(b["usable_capacity_wh"]),
min_soc_wh=float(b["min_soc_wh"]),
arb_floor_wh=float(b["arb_floor_wh"]),
reserve_soc_wh=float(b["reserve_soc_wh"]),
soc_max_wh=float(b["soc_max_wh"]),
charge_efficiency=float(b["charge_efficiency"]),
discharge_efficiency=float(b["discharge_efficiency"]),
degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]),
max_charge_power_w=ec_i,
max_discharge_power_w=ed_i,
charge_slot_buffer=float(brow["charge_slot_buffer"]) if brow["charge_slot_buffer"] is not None else 0,
discharge_slot_buffer=float(brow["discharge_slot_buffer"]) if brow["discharge_slot_buffer"] is not None else 0,
charge_slot_buffer=float(b["charge_slot_buffer"])
if b.get("charge_slot_buffer") is not None
else 0,
discharge_slot_buffer=float(b["discharge_slot_buffer"])
if b.get("discharge_slot_buffer") is not None
else 0,
)
hrow = await db.fetchrow(
"""
SELECT COALESCE(rated_heating_power_w, 8000) AS rated_heating_power_w,
COALESCE(tuv_min_temp_c, 45) AS tuv_min_temp_c,
COALESCE(tuv_target_temp_c, 55) AS tuv_target_temp_c
FROM ems.asset_heat_pump
WHERE site_id = $1
ORDER BY id
LIMIT 1
""",
site_id,
hpj = ctx["heat_pump"]
heat_pump = SimpleNamespace(
rated_heating_power_w=int(hpj["rated_heating_power_w"]),
tuv_min_temp_c=float(hpj["tuv_min_temp_c"]),
tuv_target_temp_c=float(hpj["tuv_target_temp_c"]),
)
if hrow is None:
heat_pump = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=0.0,
tuv_target_temp_c=55.0,
)
else:
hp_w = int(hrow["rated_heating_power_w"])
heat_pump = SimpleNamespace(
rated_heating_power_w=max(hp_w, 0),
tuv_min_temp_c=float(hrow["tuv_min_temp_c"]),
tuv_target_temp_c=float(hrow["tuv_target_temp_c"]),
)
grow = await db.fetchrow(
"""
SELECT max_import_power_w, max_export_power_w
FROM ems.site_grid_connection
WHERE site_id = $1
ORDER BY id
LIMIT 1
""",
site_id,
)
if grow is None:
raise RuntimeError(f"No site_grid_connection for site_id={site_id}")
g = ctx["grid"]
grid = SimpleNamespace(
max_import_power_w=int(grow["max_import_power_w"]),
max_export_power_w=int(grow["max_export_power_w"]),
max_import_power_w=int(g["max_import_power_w"]),
max_export_power_w=int(g["max_export_power_w"]),
)
vrows = await db.fetch(
"""
SELECT v.battery_capacity_kwh,
v.max_charge_power_w,
v.default_target_soc_pct,
ch.code AS charger_code
FROM ems.asset_vehicle v
JOIN ems.asset_ev_charger ch ON ch.id = v.default_charger_id
WHERE v.site_id = $1
AND ch.code IN ('ev-charger-1', 'ev-charger-2')
ORDER BY ch.code
""",
site_id,
)
vehicles: list[SimpleNamespace] = [
SimpleNamespace(
max_charge_power_w=int(r["max_charge_power_w"]),
battery_capacity_kwh=float(r["battery_capacity_kwh"]),
default_target_soc_pct=float(r["default_target_soc_pct"]),
vehicles: list[SimpleNamespace] = []
for v in ctx.get("vehicles") or []:
vehicles.append(
SimpleNamespace(
max_charge_power_w=int(v["max_charge_power_w"]),
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
default_target_soc_pct=float(v["default_target_soc_pct"]),
)
)
for r in vrows
]
while len(vehicles) < 2:
vehicles.append(
SimpleNamespace(
@@ -1057,56 +842,19 @@ async def _load_site_context(site_id: int, db):
)
)
srows = await db.fetch(
"""
SELECT es.target_deadline,
es.target_soc_pct,
es.soc_at_connect_pct,
es.energy_delivered_wh,
ch.code AS charger_code,
v.battery_capacity_kwh AS veh_cap_kwh,
v.default_target_soc_pct
FROM ems.ev_session es
JOIN ems.asset_ev_charger ch ON ch.id = es.charger_id
LEFT JOIN ems.asset_vehicle v ON v.id = es.vehicle_id
WHERE es.site_id = $1
AND es.session_end IS NULL
""",
site_id,
)
by_charger = {r["charger_code"]: r for r in srows}
ev_raw = ctx.get("ev_sessions") or []
ev_sessions = [
_ev_session_ctx(by_charger.get("ev-charger-1")),
_ev_session_ctx(by_charger.get("ev-charger-2")),
_ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None,
_ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None,
]
soc_pct = await db.fetchval(
"""
SELECT battery_soc_percent
FROM ems.telemetry_inverter
WHERE site_id = $1
ORDER BY measured_at DESC
LIMIT 1
""",
site_id,
)
if soc_pct is None:
soc_wh = uc * 0.5
else:
soc_wh = float(soc_pct) / 100.0 * uc
soc_wh = max(min_soc_wh, min(soc_wh, soc_max_wh))
soc_wh = float(ctx["soc_wh"])
tuv_temp = float(ctx["tuv_temp"])
operating_mode = ctx.get("operating_mode")
tuv = await db.fetchval(
"""
SELECT tuv_tank_temp_c
FROM ems.telemetry_heat_pump
WHERE site_id = $1
ORDER BY measured_at DESC
LIMIT 1
""",
site_id,
)
tuv_temp = float(tuv) if tuv is not None else 50.0
tuv_stats: dict[tuple[int, int], float] = {}
for row in ctx.get("tuv_delta_stats") or []:
tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"])
return (
battery,
@@ -1117,120 +865,33 @@ async def _load_site_context(site_id: int, db):
soc_wh,
tuv_temp,
operating_mode,
tuv_stats,
)
async def _load_tuv_usage_stats(site_id: int, db) -> dict[tuple[int, int], float]:
"""Průměrná změna teploty TUV zásobníku per (DOW, hodina) v konvenci DB EXTRACT(DOW)."""
async def _load_slots(
site_id: int,
from_dt: datetime,
to_dt: datetime,
db,
*,
soc_wh: float,
) -> list[PlanningSlot]:
"""15min sloty z ems.fn_load_planning_slots_full."""
rows = await db.fetch(
"""
SELECT day_of_week, hour_of_day, avg_temp_delta_c
FROM ems.tuv_usage_stats
WHERE site_id = $1
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
ev1_connected, ev2_connected, allow_charge, allow_discharge_export
from ems.fn_load_planning_slots_full(
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
)
""",
site_id,
from_dt,
to_dt,
soc_wh,
)
return {
(int(r["day_of_week"]), int(r["hour_of_day"])): float(r["avg_temp_delta_c"])
for r in rows
}
async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
"""Načte 15min sloty s cenami (OTE + predikce za horizont), forecasty a stavem EV z DB."""
rows = await db.fetch("""
WITH slot_spine AS (
SELECT gs AS interval_start
FROM generate_series(
$2::timestamptz,
($3::timestamptz - interval '15 minutes')::timestamptz,
interval '15 minutes'
) AS gs
)
SELECT
s.interval_start,
COALESCE(
ep.effective_buy_price_czk_kwh,
ems.fn_get_predicted_price($1, s.interval_start)
) AS buy_price,
COALESCE(
ep.effective_sell_price_czk_kwh,
ems.fn_get_predicted_price($1, s.interval_start) * 0.85
) AS sell_price,
(ep.effective_buy_price_czk_kwh IS NULL) AS is_predicted_price,
COALESCE(fpi_a.power_w, 0) AS pv_a_forecast_w,
COALESCE(fpi_b.power_w, 0) AS pv_b_forecast_w,
COALESCE(
(SELECT bs.avg_power_w
FROM ems.consumption_baseline_stats bs
WHERE bs.site_id = $1
AND bs.day_of_week = EXTRACT(DOW FROM s.interval_start
AT TIME ZONE 'Europe/Prague')::INT
AND bs.hour_of_day = EXTRACT(HOUR FROM s.interval_start
AT TIME ZONE 'Europe/Prague')::INT
LIMIT 1),
500
) AS load_baseline_w,
(COALESCE(ev1.status, 'available') NOT IN ('available', 'unavailable')) AS ev1_connected,
(COALESCE(ev2.status, 'available') NOT IN ('available', 'unavailable')) AS ev2_connected
FROM slot_spine s
LEFT JOIN ems.vw_site_effective_price ep
ON ep.site_id = $1 AND ep.interval_start = s.interval_start
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(u.power_w), 0)::INT AS power_w
FROM (
SELECT DISTINCT ON (apa.id)
fpi.power_w
FROM ems.asset_pv_array apa
JOIN ems.forecast_pv_run fpr
ON fpr.pv_array_id = apa.id
AND fpr.site_id = apa.site_id
AND fpr.status = 'ok'
JOIN ems.forecast_pv_interval fpi
ON fpi.run_id = fpr.id
AND fpi.pv_array_id = apa.id
AND fpi.interval_start = s.interval_start
WHERE apa.site_id = $1
AND apa.controllable IS TRUE
ORDER BY apa.id, fpr.created_at DESC
) u
) fpi_a ON true
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(u.power_w), 0)::INT AS power_w
FROM (
SELECT DISTINCT ON (apa.id)
fpi.power_w
FROM ems.asset_pv_array apa
JOIN ems.forecast_pv_run fpr
ON fpr.pv_array_id = apa.id
AND fpr.site_id = apa.site_id
AND fpr.status = 'ok'
JOIN ems.forecast_pv_interval fpi
ON fpi.run_id = fpr.id
AND fpi.pv_array_id = apa.id
AND fpi.interval_start = s.interval_start
WHERE apa.site_id = $1
AND apa.controllable IS FALSE
ORDER BY apa.id, fpr.created_at DESC
) u
) fpi_b ON true
LEFT JOIN LATERAL (
SELECT t.status
FROM ems.telemetry_ev_charger t
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
WHERE t.site_id = $1 AND ch.code = 'ev-charger-1'
ORDER BY t.measured_at DESC LIMIT 1
) ev1 ON true
LEFT JOIN LATERAL (
SELECT t.status
FROM ems.telemetry_ev_charger t
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
WHERE t.site_id = $1 AND ch.code = 'ev-charger-2'
ORDER BY t.measured_at DESC LIMIT 1
) ev2 ON true
ORDER BY s.interval_start
""", site_id, from_dt, to_dt)
out: list[PlanningSlot] = []
for r in rows:
d = dict(r)
@@ -1245,6 +906,8 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
ev1_connected=bool(d["ev1_connected"]),
ev2_connected=bool(d["ev2_connected"]),
is_predicted_price=bool(d.get("is_predicted_price")),
allow_charge=bool(d.get("allow_charge", True)),
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
)
)
if not out:
@@ -1281,112 +944,59 @@ async def _save_planning_run(
soc_wh, duration_ms, correction, db,
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
) -> int:
"""Uloží výsledky solveru jako nový planning_run, deaktivuje předchozí."""
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
if slot_inputs is not None and len(slot_inputs) != len(results):
raise ValueError("slot_inputs and results length mismatch")
run_id = await db.fetchval("""
INSERT INTO ems.planning_run
(site_id, horizon_start, horizon_end, status,
run_type, triggered_by, replan_from,
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor)
VALUES ($1,$2,$3,'draft',$4,$5,$6,$7,$8,$9)
RETURNING id
""", site_id, horizon_from, horizon_to,
run_type, triggered_by, replan_from,
soc_wh, duration_ms, correction)
run_meta = {
"run_type": run_type,
"triggered_by": triggered_by,
"replan_from": replan_from.isoformat() if replan_from else None,
"soc_at_replan_wh": soc_wh,
"solver_duration_ms": duration_ms,
"forecast_correction_factor": correction,
}
intervals: list[dict] = []
for i, r in enumerate(results):
row: dict = {
"interval_start": r.interval_start.isoformat()
if hasattr(r.interval_start, "isoformat")
else r.interval_start,
"battery_setpoint_w": r.battery_setpoint_w,
"battery_soc_target_pct": r.battery_soc_target,
"grid_setpoint_w": r.grid_setpoint_w,
"ev1_setpoint_w": r.ev1_setpoint_w,
"ev2_setpoint_w": r.ev2_setpoint_w,
"ev1_via_bat_w": r.ev1_via_bat_w,
"ev2_via_bat_w": r.ev2_via_bat_w,
"heat_pump_enabled": r.heat_pump_enabled,
"heat_pump_setpoint_w": r.heat_pump_setpoint_w,
"pv_a_curtailed_w": r.pv_a_curtailed_w,
"expected_cost_czk": float(r.expected_cost_czk),
"effective_buy_price": float(r.effective_buy_price),
"effective_sell_price": float(r.effective_sell_price),
"is_predicted_price": r.is_predicted_price,
}
if slot_inputs is not None:
si = slot_inputs[i]
row["load_baseline_w"] = si[0]
row["pv_a_forecast_raw_w"] = si[1]
row["pv_b_forecast_raw_w"] = si[2]
row["pv_a_forecast_solver_w"] = si[3]
row["pv_b_forecast_solver_w"] = si[4]
intervals.append(row)
# Bulk insert výsledků
if slot_inputs is not None:
rows_pi = [
(
run_id,
r.interval_start,
r.battery_setpoint_w,
r.battery_soc_target,
r.grid_setpoint_w,
r.ev1_setpoint_w,
r.ev2_setpoint_w,
r.ev1_via_bat_w,
r.ev2_via_bat_w,
r.heat_pump_enabled,
r.heat_pump_setpoint_w,
r.pv_a_curtailed_w,
r.expected_cost_czk,
r.effective_buy_price,
r.effective_sell_price,
r.is_predicted_price,
si[0],
si[1],
si[2],
si[3],
si[4],
return int(
await db.fetchval(
"""
select ems.fn_planning_run_commit(
$1::int, $2::timestamptz, $3::timestamptz,
$4::jsonb, $5::jsonb
)
for r, si in zip(results, slot_inputs)
]
await db.executemany(
"""
INSERT INTO ems.planning_interval
(run_id, interval_start,
battery_setpoint_w, battery_soc_target_pct,
grid_setpoint_w,
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
effective_buy_price, effective_sell_price,
is_predicted_price,
load_baseline_w,
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
pv_a_forecast_solver_w, pv_b_forecast_solver_w)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
$17,$18,$19,$20,$21)
""",
rows_pi,
site_id,
horizon_from,
horizon_to,
json.dumps(run_meta, default=str),
json.dumps(intervals, default=str),
)
else:
await db.executemany(
"""
INSERT INTO ems.planning_interval
(run_id, interval_start,
battery_setpoint_w, battery_soc_target_pct,
grid_setpoint_w,
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
effective_buy_price, effective_sell_price,
is_predicted_price)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
""",
[
(
run_id,
r.interval_start,
r.battery_setpoint_w,
r.battery_soc_target,
r.grid_setpoint_w,
r.ev1_setpoint_w,
r.ev2_setpoint_w,
r.ev1_via_bat_w,
r.ev2_via_bat_w,
r.heat_pump_enabled,
r.heat_pump_setpoint_w,
r.pv_a_curtailed_w,
r.expected_cost_czk,
r.effective_buy_price,
r.effective_sell_price,
r.is_predicted_price,
)
for r in results
],
)
# Aktivovat nový plán, supersede předchozí
await db.execute("""
UPDATE ems.planning_run SET status = 'superseded'
WHERE site_id = $1 AND status = 'active' AND id <> $2
""", site_id, run_id)
await db.execute(
"UPDATE ems.planning_run SET status = 'active' WHERE id = $1", run_id
)
return run_id

View File

@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
import httpx
from app.config import get_settings
from app.db_json import fetch_json
logger = logging.getLogger(__name__)
@@ -119,18 +120,14 @@ async def _apply_ote_json_to_db(conn, payload: dict) -> int:
async def count_ote_slots_prague_day(conn, target_day: date) -> int:
"""Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100)."""
return int(
await conn.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND (interval_start AT TIME ZONE 'Europe/Prague')::date = $1::date
""",
target_day,
)
or 0
stats = await fetch_json(
conn,
"select ems.fn_ote_day_slot_stats_prague($1::date)",
target_day,
)
if not isinstance(stats, dict):
stats = json.loads(stats)
return int(stats.get("count") or 0)
async def import_ote_prices_for_day(
@@ -147,18 +144,15 @@ async def import_ote_prices_for_day(
return -1, day_str, 0.0, fetch_error or "fetch_failed"
try:
n = await _apply_ote_json_to_db(conn, payload)
first_price = await conn.fetchval(
"""
SELECT buy_raw_price_czk_kwh
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND (interval_start AT TIME ZONE 'Europe/Prague')::date = $1::date
ORDER BY interval_start
LIMIT 1
""",
stats_after = await fetch_json(
conn,
"select ems.fn_ote_day_slot_stats_prague($1::date)",
target_day,
)
n_imported = await count_ote_slots_prague_day(conn, target_day)
if not isinstance(stats_after, dict):
stats_after = json.loads(stats_after)
first_price = stats_after.get("first_price")
n_imported = int(stats_after.get("count") or 0)
if not ote_prague_day_slots_look_complete(n_imported):
logger.warning(
"OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)",
@@ -248,7 +242,7 @@ async def import_ote_prices(
"""
if site_id is not None:
row = await db.fetchrow(
"SELECT timezone FROM ems.site WHERE id = $1", site_id
"select timezone from ems.vw_site_directory where id = $1", site_id
)
if row is None:
logger.error("OTE import: site id=%s nenalezen", site_id)
@@ -290,26 +284,15 @@ async def import_ote_prices(
try:
n = await _apply_ote_json_to_db(db, payload)
first_price = await db.fetchval(
"""
SELECT buy_raw_price_czk_kwh
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND interval_start::date = $1::date
ORDER BY interval_start
LIMIT 1
""",
target_day,
)
n_imported = await db.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND interval_start::date = $1::date
""",
stats_after = await fetch_json(
db,
"select ems.fn_ote_day_slot_stats_prague($1::date)",
target_day,
)
if not isinstance(stats_after, dict):
stats_after = json.loads(stats_after)
first_price = stats_after.get("first_price")
n_imported = int(stats_after.get("count") or 0)
incomplete = not ote_prague_day_slots_look_complete(n_imported or 0)
if incomplete:
now_p = datetime.now(ZoneInfo("Europe/Prague"))

View File

@@ -41,13 +41,9 @@ def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
SELECT ai.id, ai.code, se.host, se.port, se.unit_id
FROM ems.asset_inverter ai
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
WHERE ai.site_id = $1
AND ai.active = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
select inverter_id as id, code, host, port, unit_id
from ems.vw_asset_inverter_modbus_poll
where site_id = $1
""",
site_id,
)
@@ -67,7 +63,7 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER)
load_power = await mb.read_register_signed(DEYE_REG_LOAD_TOTAL_POWER)
pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER)
pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER)
gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER)
@@ -81,27 +77,7 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
await db.execute(
"""
INSERT INTO ems.telemetry_inverter (
site_id, inverter_id, measured_at,
pv_power_w, pv1_power_w, pv2_power_w, gen_port_power_w,
battery_soc_percent, battery_power_w,
batt_charge_today_wh, batt_discharge_today_wh,
grid_power_w, load_power_w,
grid_import_total_wh, grid_export_total_wh,
run_state
)
VALUES (
$1, $2, $3,
$4, $5, $6, $7,
$8, $9,
$10, $11,
$12, $13,
$14, $15,
$16
)
ON CONFLICT (inverter_id, measured_at) DO NOTHING
""",
"select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int)",
site_id,
inv_id,
measured_at,
@@ -141,12 +117,9 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
SELECT ec.id, ec.code, se.host, se.port, se.unit_id
FROM ems.asset_ev_charger ec
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
WHERE ec.site_id = $1
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
select charger_id as id, code, host, port, unit_id
from ems.vw_asset_ev_charger_modbus_poll
where site_id = $1
""",
site_id,
)
@@ -156,117 +129,52 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
code = row["code"]
charger_id = row["id"]
logger.info("TODO: EV charger Modbus registry pending | %s", code)
# Placeholder až do mapování Modbus: status zůstává available (bez falešných přechodů).
current_status = "available"
previous_status = await db.fetchval(
"""
SELECT status
FROM ems.telemetry_ev_charger
WHERE charger_id = $1 AND connector_id = $2
ORDER BY measured_at DESC
LIMIT 1
select status
from ems.telemetry_ev_charger
where charger_id = $1 and connector_id = $2
order by measured_at desc
limit 1
""",
charger_id,
connector_id,
)
await db.execute(
"""
INSERT INTO ems.telemetry_ev_charger (
site_id, charger_id, measured_at, connector_id,
status, power_w, energy_kwh
)
VALUES ($1, $2, $3, $4, $5, 0, 0)
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
""",
"select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8)",
site_id,
charger_id,
measured_at,
connector_id,
current_status,
0,
0.0,
)
if previous_status is not None:
await db.fetchval(
"select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
site_id,
charger_id,
str(previous_status),
current_status,
measured_at,
)
if previous_status == "available" and current_status != "available":
vehicle_id = await db.fetchval(
"""
SELECT av.id
FROM ems.asset_vehicle av
WHERE av.site_id = $1
AND av.default_charger_id = $2
AND av.active = true
ORDER BY av.id
LIMIT 1
""",
site_id,
charger_id,
)
await db.execute(
"SELECT ems.fn_update_ev_arrival_stats($1, $2, $3, $4)",
site_id,
charger_id,
vehicle_id,
measured_at,
)
logger.info("EV arrival detected on charger %s", code)
await db.execute(
"""
INSERT INTO ems.ev_session (
site_id, charger_id, vehicle_id, session_start,
target_soc_pct, target_deadline
)
SELECT
ac.site_id,
ac.id,
av.id,
now(),
av.default_target_soc_pct,
CASE
WHEN av.default_deadline_hour IS NOT NULL THEN
(
(timezone('Europe/Prague', now()))::date + interval '1 day'
+ make_interval(hours => av.default_deadline_hour)
)::timestamp AT TIME ZONE 'Europe/Prague'
END
FROM ems.asset_ev_charger ac
LEFT JOIN LATERAL (
SELECT v.id, v.default_target_soc_pct, v.default_deadline_hour
FROM ems.asset_vehicle v
WHERE v.default_charger_id = ac.id
AND v.site_id = ac.site_id
AND v.active = true
ORDER BY v.id
LIMIT 1
) av ON true
WHERE ac.id = $1 AND ac.site_id = $2
ON CONFLICT (charger_id) WHERE session_end IS NULL DO NOTHING
""",
charger_id,
site_id,
)
if previous_status != "available" and current_status == "available":
await db.execute(
"""
UPDATE ems.ev_session
SET session_end = now()
WHERE charger_id = $1 AND session_end IS NULL
""",
charger_id,
)
elif previous_status != "available" and current_status == "available":
logger.info("EV departure detected on charger %s", code)
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
SELECT hp.id, hp.code, se.host, se.port, se.unit_id
FROM ems.asset_heat_pump hp
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
WHERE hp.site_id = $1
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
select heat_pump_id as id, code, host, port, unit_id
from ems.vw_asset_heat_pump_modbus_poll
where site_id = $1
""",
site_id,
)
@@ -275,18 +183,15 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
code = row["code"]
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
await db.execute(
"""
INSERT INTO ems.telemetry_heat_pump (
site_id, heat_pump_id, measured_at,
power_w, outdoor_temp_c, water_outlet_temp_c, tuv_tank_temp_c,
operating_mode
)
VALUES ($1, $2, $3, 0, 10.0, 45.0, 55.0, 'standby')
ON CONFLICT (heat_pump_id, measured_at) DO NOTHING
""",
"select ems.fn_telemetry_heat_pump_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8, $7::float8, $8::text)",
site_id,
row["id"],
measured_at,
0,
10.0,
45.0,
55.0,
"standby",
)
@@ -297,7 +202,9 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
"""
loop = asyncio.get_running_loop()
start = loop.time()
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:
sid = site["id"]
try:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import unittest
from dataclasses import replace
from services.control_exporter import (
from services.control.exporter_monolith import (
ControlSetpoints,
InverterConfig,
_deye_reg178_verify_with_double_read,

View File

@@ -0,0 +1,28 @@
"""Smoke: fetch_json toleruje dict z asyncpg (bez reálné DB)."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock
from app.db_json import fetch_json
def test_fetch_json_returns_dict() -> None:
async def _run() -> None:
conn = AsyncMock()
conn.fetchval = AsyncMock(return_value={"a": 1})
out = await fetch_json(conn, "select ems.fn_x()", 1)
assert out == {"a": 1}
asyncio.run(_run())
def test_fetch_json_parses_str() -> None:
async def _run() -> None:
conn = AsyncMock()
conn.fetchval = AsyncMock(return_value='{"b": 2}')
out = await fetch_json(conn, "select 1")
assert out == {"b": 2}
asyncio.run(_run())

View File

@@ -6,7 +6,7 @@ import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from services.control_exporter import (
from services.control.exporter_monolith import (
DEYE_CLOCK_DRIFT_OK_SEC,
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC,

View File

@@ -1,10 +1,7 @@
"""`_select_charge_slots`: pre-selection nabíjecích slotů (anti-micro-cycling).
"""Pre-selection nabíjecích slotů (anti-micro-cycling) referenční Python.
Ověřuje novou logiku podle varianty B:
- PV-surplus sloty jsou vždy zahrnuty.
- Zbytek rozpočtu doplnit nejlevnějšími sloty podle `buy_price` (ne `sell_price`).
- Žádné sloty nesmí být vyloučeny kvůli tomu, že nemají PV-surplus, když
`charge_slot_buffer` > 0 a ještě chybí energie do `soc_max`.
Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu
pro rychlé unit testy bez PostgreSQL.
"""
from __future__ import annotations
@@ -13,7 +10,50 @@ import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from services.planning_engine import INTERVAL_H, PlanningSlot, _select_charge_slots
from services.planning_engine import INTERVAL_H, PlanningSlot
def _select_charge_slots(
slots: list[PlanningSlot],
battery: SimpleNamespace,
current_soc_wh: float,
) -> set[int]:
"""Kopie logiky z ems.fn_load_planning_slots_full (charge mask)."""
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
if charge_buf <= 0:
return set(range(len(slots)))
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
if energy_to_fill <= 0:
return set()
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
per_slot_full_wh = max_p_w * eta * INTERVAL_H
selected: set[int] = set()
for t, s in enumerate(slots):
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
if pv_surplus_w > 0:
selected.add(t)
grid_target_wh = energy_to_fill * charge_buf
if grid_target_wh <= 0 or per_slot_full_wh <= 0:
return selected
grid_candidates = [
(t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected
]
grid_candidates.sort(key=lambda x: x[1])
cumulative = 0.0
for t, _price in grid_candidates:
if cumulative >= grid_target_wh:
break
selected.add(t)
cumulative += per_slot_full_wh
return selected
def _slot(*, buy: float, sell: float = 1.0, pv: int = 0, load: int = 2_000) -> PlanningSlot: