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

@@ -64,6 +64,10 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
11. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`.
### SQL vs Python (read-model)
- **Žádné ad-hoc `SELECT`/`INSERT`/`UPDATE` v `backend/services/*.py` a `backend/app/routers/*.py`** kromě: existence `SELECT 1` / `EXISTS`, volání `select ems.fn_*(…)`, a čtení z **`ems.vw_*`**. IO (Modbus, HTTP), PuLP solver a orchestrace zůstávají v Pythonu.
### Provozní režimy (operating_mode)
- Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**.
@@ -129,7 +133,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
| `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). |
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`.
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`.
---
@@ -177,6 +181,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
| Rolling plán, forecast log | `db/migration/V007__rolling_replanning.sql` |
| Audit 15min | `db/routines/R__fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` |
| Nové sloupce / tabulky | nový `db/migration/V00x__*.sql` + případně `db/routines` / `db/views` |
| JSONB read-model (`fn_*`, `fetch_json`) | `docs/02-architecture.md` sekce Read-model JSONB, `app/db_json.py` |
| Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` |
| Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` |
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |

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,52 +236,20 @@ 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:
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()}",
)
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"],
)
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 [
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=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"]),
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:
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(row["id"]))
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:
raw = await fetch_json(
conn,
"select ems.fn_ev_arrival_prediction_bundle($1::int)",
site_id,
)
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")
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
""",
site_id,
)
if tomorrow is None:
raise HTTPException(status_code=500, detail="Site date resolution failed")
tomorrow_d: date = tomorrow
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=[
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(r["expected_hour"]),
confidence_pct=int(r["confidence_pct"]),
samples=int(r["sample_count"]),
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:
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"
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,
)
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]] = []
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]
raw_iv = bundle.get("planning_intervals") or []
if isinstance(raw_iv, list):
intervals = [x for x in raw_iv if isinstance(x, dict)]
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:
if not isinstance(ctx, dict):
ctx = json.loads(ctx)
if ctx.get("error") == "not_found":
raise HTTPException(status_code=404, detail="Site not found")
tz = site["timezone"] or "Europe/Prague"
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:
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:
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:
def _iso_utc_from_cfg(val: Any) -> str | None:
if val is None:
return None
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:
if raw 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"]
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
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,
},
}
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",
)
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,
site_id,
)
if owner is None:
raise HTTPException(status_code=404, detail="Inverter not found for this site")
sets: list[str] = []
args: list[Any] = []
n = 1
patch: dict[str, Any] = {}
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
patch["deye_register_max_charge_a"] = updates["deye_register_max_charge_a"]
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
patch["deye_register_max_discharge_a"] = updates["deye_register_max_discharge_a"]
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,
async with pool.acquire() as conn:
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),
)
assert row is not 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")
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
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)
""",
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,
)
if hrow is None:
hpj = ctx["heat_pump"]
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"]),
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"]),
)
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] = [
vehicles: list[SimpleNamespace] = []
for v in ctx.get("vehicles") or []:
vehicles.append(
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"]),
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)
# Bulk insert výsledků
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:
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],
)
for r, si in zip(results, slot_inputs)
]
await db.executemany(
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)
return int(
await db.fetchval(
"""
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)
select ems.fn_planning_run_commit(
$1::int, $2::timestamptz, $3::timestamptz,
$4::jsonb, $5::jsonb
)
""",
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
""",
stats = await fetch_json(
conn,
"select ems.fn_ote_day_slot_stats_prague($1::date)",
target_day,
)
or 0
)
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:
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
""",
await db.fetchval(
"select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
site_id,
charger_id,
)
await db.execute(
"SELECT ems.fn_update_ev_arrival_stats($1, $2, $3, $4)",
site_id,
charger_id,
vehicle_id,
str(previous_status),
current_status,
measured_at,
)
if previous_status == "available" and current_status != "available":
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:

View File

@@ -0,0 +1,11 @@
-- volitelné plánovací konstanty per site (horizont, decay, …) čte fn_planning_site_context
create table if not exists ems.planning_config (
site_id int not null references ems.site (id) on delete cascade,
config jsonb not null default '{}'::jsonb,
updated_at timestamptz not null default now(),
primary key (site_id)
);
comment on table ems.planning_config is
'JSON konfigurace pro budoucí přesun konstant z planning_engine.py (slot weights, correction decay, …).';

View File

@@ -0,0 +1,51 @@
-- audit „ekvivalent plných cyklů“ z 1min telemetrie battery_power_w (bez LP constraintu)
create or replace function ems.fn_battery_cycle_audit(
p_site_id int,
p_from timestamptz,
p_to timestamptz
)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_usable numeric;
v_throughput_wh numeric;
v_full_cycles numeric;
begin
select coalesce(sum(ab.usable_capacity_wh), 0)::numeric
into v_usable
from ems.asset_battery ab
where ab.site_id = p_site_id;
if v_usable is null or v_usable <= 0 then
return jsonb_build_object('error', 'no_battery', 'full_cycles', 0);
end if;
select coalesce(
sum(abs(ti.battery_power_w::numeric) / 60.0),
0
)
into v_throughput_wh
from ems.telemetry_inverter ti
where ti.site_id = p_site_id
and ti.measured_at >= p_from
and ti.measured_at < p_to
and ti.battery_power_w is not null;
v_full_cycles := case
when v_usable * 2 > 0 then v_throughput_wh / (v_usable * 2)
else 0
end;
return jsonb_build_object(
'full_cycles', round(v_full_cycles::numeric, 4),
'throughput_wh', round(v_throughput_wh, 2),
'throughput_vs_usable_ratio', round((v_throughput_wh / nullif(v_usable, 0))::numeric, 4),
'usable_capacity_wh', v_usable,
'window_start', p_from,
'window_end', p_to
);
end;
$fn$;

View File

@@ -0,0 +1,16 @@
create or replace function ems.fn_deye_clock_drift_sec(
p_device_ts timestamptz,
p_reference_ts timestamptz
)
returns int
language sql
immutable
as $fn$
select case
when p_device_ts is null or p_reference_ts is null then null::int
else abs(extract(epoch from (p_device_ts - p_reference_ts)))::int
end;
$fn$;
comment on function ems.fn_deye_clock_drift_sec(timestamptz, timestamptz) is
'Absolutní odchylka hodin Deye vs referenční UTC (sekundy).';

View File

@@ -0,0 +1,17 @@
-- pack reg 6264 (Europe/Prague wall time, seconds = 0) stejně jako _deye_system_time_register_rows
create or replace function ems.fn_deye_pack_system_time(p_ts timestamptz)
returns int[]
language sql
stable
as $fn$
with loc as (
select (p_ts at time zone 'Europe/Prague') as t
)
select array[
((extract(year from t)::int - 2000) << 8) | extract(month from t)::int,
(extract(day from t)::int << 8) | extract(hour from t)::int,
(extract(minute from t)::int << 8) | 0
]
from loc;
$fn$;

View File

@@ -0,0 +1,27 @@
-- pole registrů pro jeden TOU time point (čistá logika čísel; zápis řeší control exporter)
create or replace function ems.fn_deye_time_point_regs(
p_slot_index int,
p_hhmm int,
p_power_w int,
p_soc_pct int,
p_grid_charge_bit int
)
returns int[]
language sql
immutable
as $fn$
select array[
148 + p_slot_index * 6,
p_hhmm,
154 + p_slot_index * 6,
p_power_w,
166 + p_slot_index * 6,
p_soc_pct,
172 + p_slot_index * 6,
p_grid_charge_bit
];
$fn$;
comment on function ems.fn_deye_time_point_regs(int, int, int, int, int) is
'Adresy a hodnoty pro jeden Deye TOU blok (reg páry 148/154/166/172 + offset slotu).';

View File

@@ -0,0 +1,21 @@
create or replace function ems.fn_deye_tou_inactive_signature(
p_hhmm_inactive int,
p_min_soc_pct numeric,
p_reserve_soc_pct numeric,
p_tp_discharge_w int
)
returns text
language sql
immutable
as $fn$
select concat_ws(
'|',
p_hhmm_inactive::text,
round(p_min_soc_pct, 2)::text,
round(p_reserve_soc_pct, 2)::text,
p_tp_discharge_w::text
);
$fn$;
comment on function ems.fn_deye_tou_inactive_signature(int, numeric, numeric, int) is
'Podpis neaktivních TOU slotů (shoda s asset_inverter.deye_tou_inactive_signature).';

View File

@@ -0,0 +1,69 @@
create or replace function ems.fn_economics_daily_month(
p_site_id int,
p_month_start date,
p_month_end date
)
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'has_green_bonus',
exists (
select 1
from ems.asset_pv_array apv
where apv.site_id = p_site_id
and apv.green_bonus_czk_kwh is not null
),
'days',
coalesce(
(
select jsonb_agg(sub.day_row order by sub.day_local)
from (
select
r.day_local,
jsonb_build_object(
'day', r.day_local,
'interval_count', r.interval_count,
'import_kwh', r.import_kwh,
'export_kwh', r.export_kwh,
'pv_kwh', r.pv_kwh,
'load_kwh', r.load_kwh,
'pv_self_consumption_kwh', r.pv_self_consumption_kwh,
'ev_kwh', r.ev_kwh,
'hp_kwh', r.hp_kwh,
'import_cost_czk',
case when l.site_id is not null then l.import_cost_czk else r.import_cost_czk end,
'export_revenue_czk',
case when l.site_id is not null then l.export_revenue_czk else r.export_revenue_czk end,
'grid_import_cashflow_czk',
coalesce(l.grid_import_cashflow_czk, r.grid_import_cashflow_czk),
'grid_export_revenue_czk',
coalesce(l.grid_export_revenue_czk, r.grid_export_revenue_czk),
'net_cost_czk',
case when l.site_id is not null then l.net_cost_czk else r.net_cost_czk end,
'green_bonus_czk',
case when l.site_id is not null then l.green_bonus_czk else r.green_bonus_czk end,
'total_balance_czk',
case when l.site_id is not null then l.total_balance_czk else r.total_balance_czk end,
'planned_balance_czk', r.planned_balance_czk,
'deviation_cost_czk', r.deviation_cost_czk,
'is_locked', (l.site_id is not null)
) as day_row
from ems.vw_economics_daily r
left join ems.audit_day_lock l
on l.site_id = r.site_id
and l.day_local = r.day_local
where r.site_id = p_site_id
and r.day_local >= p_month_start
and r.day_local < p_month_end
order by r.day_local
) sub
),
'[]'::jsonb
)
);
$fn$;
comment on function ems.fn_economics_daily_month(int, date, date) is
'Měsíční denní ekonomika + lock merge jako JSON (GET /economics/daily).';

View File

@@ -0,0 +1,74 @@
create or replace function ems.fn_economics_lock_day(p_site_id int, p_day date)
returns jsonb
language plpgsql
as $fn$
declare
v_import_cost numeric;
v_export_rev numeric;
v_net numeric;
v_green numeric;
v_total numeric;
v_gic numeric;
v_ger numeric;
begin
select
r.import_cost_czk,
r.export_revenue_czk,
r.net_cost_czk,
r.green_bonus_czk,
r.total_balance_czk,
r.grid_import_cashflow_czk,
r.grid_export_revenue_czk
into strict
v_import_cost,
v_export_rev,
v_net,
v_green,
v_total,
v_gic,
v_ger
from ems.vw_economics_daily r
where r.site_id = p_site_id
and r.day_local = p_day;
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 (
p_site_id,
p_day,
v_import_cost,
v_export_rev,
v_net,
v_green,
v_total,
v_gic,
v_ger
)
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();
return jsonb_build_object('locked', true, 'day', p_day);
exception
when no_data_found then
return jsonb_build_object('locked', false, 'error', 'no_economics_data');
end;
$fn$;
comment on function ems.fn_economics_lock_day(int, date) is
'Zamkne den ekonomiky podle aktuálního vw_economics_daily (POST lock).';

View File

@@ -0,0 +1,62 @@
create or replace function ems.fn_economics_monthly_chart(
p_site_id int,
p_month_start date,
p_month_end date
)
returns jsonb
language sql
stable
as $fn$
with base as (
select
r.day_local,
case when l.site_id is not null then l.total_balance_czk else r.total_balance_czk end as tb,
case when l.site_id is not null then l.net_cost_czk else r.net_cost_czk end as nc,
case when l.site_id is not null then l.green_bonus_czk else r.green_bonus_czk end as gb,
coalesce(l.grid_import_cashflow_czk, r.grid_import_cashflow_czk) as gic,
coalesce(l.grid_export_revenue_czk, r.grid_export_revenue_czk) as ger
from ems.vw_economics_daily r
left join ems.audit_day_lock l
on l.site_id = r.site_id
and l.day_local = r.day_local
where r.site_id = p_site_id
and r.day_local >= p_month_start
and r.day_local < p_month_end
),
w as (
select
day_local,
round(tb::numeric, 2) as daily_balance_czk,
round((-nc)::numeric, 2) as daily_grid_balance_czk,
round(gb::numeric, 2) as daily_green_bonus_czk,
round(gic::numeric, 2) as daily_import_cost_czk,
round(ger::numeric, 2) as daily_export_revenue_czk,
sum(round(tb::numeric, 2)) over (
order by day_local rows between unbounded preceding and current row
) as cumb,
sum(round((-nc)::numeric, 2)) over (
order by day_local rows between unbounded preceding and current row
) as cumg
from base
)
select coalesce(
jsonb_agg(
jsonb_build_object(
'day', day_local,
'daily_balance_czk', daily_balance_czk,
'daily_grid_balance_czk', daily_grid_balance_czk,
'daily_green_bonus_czk', daily_green_bonus_czk,
'daily_import_cost_czk', daily_import_cost_czk,
'daily_export_revenue_czk', daily_export_revenue_czk,
'cumulative_balance_czk', round(cumb::numeric, 2),
'cumulative_grid_balance_czk', round(cumg::numeric, 2)
)
order by day_local
),
'[]'::jsonb
)
from w;
$fn$;
comment on function ems.fn_economics_monthly_chart(int, date, date) is
'Křivka měsíční bilance s běžícími součty (GET /economics/monthly-chart).';

View File

@@ -0,0 +1,15 @@
create or replace function ems.fn_economics_unlock_day(p_site_id int, p_day date)
returns jsonb
language plpgsql
as $fn$
begin
delete from ems.audit_day_lock
where site_id = p_site_id
and day_local = p_day;
return jsonb_build_object('locked', false, 'day', p_day);
end;
$fn$;
comment on function ems.fn_economics_unlock_day(int, date) is
'Odebere zámek dne ekonomiky (DELETE lock).';

View File

@@ -0,0 +1,82 @@
create or replace function ems.fn_energy_flows_daily_month(
p_site_id int,
p_month_start date,
p_month_end date
)
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'days',
coalesce(
jsonb_agg(t.row_json order by t.day_local),
'[]'::jsonb
)
)
from (
select
(date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date as day_local,
jsonb_build_object(
'day', (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date,
'interval_count', count(*)::int,
'pv_production_kwh', round(sum(coalesce(ai.actual_pv_production_wh, 0)) / 1000, 3),
'grid_import_kwh', round(sum(coalesce(ai.actual_grid_import_wh, 0)) / 1000, 3),
'grid_export_kwh', round(sum(coalesce(ai.actual_grid_export_wh, 0)) / 1000, 3),
'batt_charge_kwh', round(sum(coalesce(ai.actual_batt_charge_wh, 0)) / 1000, 3),
'batt_discharge_kwh', round(sum(coalesce(ai.actual_batt_discharge_wh, 0)) / 1000, 3),
'load_kwh', round(sum(coalesce(ai.actual_load_consumption_wh, 0)) / 1000, 3),
'pv_to_load_kwh', round(sum(coalesce(ai.flow_pv_to_load_wh, 0)) / 1000, 3),
'pv_to_batt_kwh', round(sum(coalesce(ai.flow_pv_to_batt_wh, 0)) / 1000, 3),
'pv_to_grid_kwh', round(sum(coalesce(ai.flow_pv_to_grid_wh, 0)) / 1000, 3),
'batt_to_load_kwh', round(sum(coalesce(ai.flow_batt_to_load_wh, 0)) / 1000, 3),
'batt_to_grid_kwh', round(sum(coalesce(ai.flow_batt_to_grid_wh, 0)) / 1000, 3),
'grid_to_load_kwh', round(sum(coalesce(ai.flow_grid_to_load_wh, 0)) / 1000, 3),
'grid_to_batt_kwh', round(sum(coalesce(ai.flow_grid_to_batt_wh, 0)) / 1000, 3),
'grid_import_cashflow_czk',
round(
sum(
coalesce(ai.actual_grid_import_wh, 0) / 1000.0
* coalesce(ep.effective_buy_price_czk_kwh, 0)
),
2
),
'grid_export_revenue_czk',
round(
sum(
coalesce(ai.actual_grid_export_wh, 0) / 1000.0
* coalesce(ep.effective_sell_price_czk_kwh, 0)
),
2
),
'grid_to_load_cost_czk',
round(
sum(
coalesce(ai.flow_grid_to_load_wh, 0) / 1000.0
* coalesce(ep.effective_buy_price_czk_kwh, 0)
),
2
),
'grid_to_batt_cost_czk',
round(
sum(
coalesce(ai.flow_grid_to_batt_wh, 0) / 1000.0
* coalesce(ep.effective_buy_price_czk_kwh, 0)
),
2
)
) as row_json
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 = p_site_id
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date >= p_month_start
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date < p_month_end
group by 1
order by 1
) t;
$fn$;
comment on function ems.fn_energy_flows_daily_month(int, date, date) is
'Denní agregace energy flows za měsíc jako JSON pole řádků.';

View File

@@ -0,0 +1,86 @@
create or replace function ems.fn_energy_flows_intervals_day(p_site_id int, p_day date)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(
jsonb_build_object(
'interval_start', ai.interval_start,
'pv_production_kwh',
case
when ai.actual_pv_production_wh is null then null
else round(ai.actual_pv_production_wh::numeric / 1000, 4)
end,
'grid_import_kwh',
case
when ai.actual_grid_import_wh is null then null
else round(ai.actual_grid_import_wh::numeric / 1000, 4)
end,
'grid_export_kwh',
case
when ai.actual_grid_export_wh is null then null
else round(ai.actual_grid_export_wh::numeric / 1000, 4)
end,
'batt_charge_kwh',
case
when ai.actual_batt_charge_wh is null then null
else round(ai.actual_batt_charge_wh::numeric / 1000, 4)
end,
'batt_discharge_kwh',
case
when ai.actual_batt_discharge_wh is null then null
else round(ai.actual_batt_discharge_wh::numeric / 1000, 4)
end,
'load_kwh',
case
when ai.actual_load_consumption_wh is null then null
else round(ai.actual_load_consumption_wh::numeric / 1000, 4)
end,
'pv_to_load_kwh',
case
when ai.flow_pv_to_load_wh is null then null
else round(ai.flow_pv_to_load_wh::numeric / 1000, 4)
end,
'pv_to_batt_kwh',
case
when ai.flow_pv_to_batt_wh is null then null
else round(ai.flow_pv_to_batt_wh::numeric / 1000, 4)
end,
'pv_to_grid_kwh',
case
when ai.flow_pv_to_grid_wh is null then null
else round(ai.flow_pv_to_grid_wh::numeric / 1000, 4)
end,
'batt_to_load_kwh',
case
when ai.flow_batt_to_load_wh is null then null
else round(ai.flow_batt_to_load_wh::numeric / 1000, 4)
end,
'batt_to_grid_kwh',
case
when ai.flow_batt_to_grid_wh is null then null
else round(ai.flow_batt_to_grid_wh::numeric / 1000, 4)
end,
'grid_to_load_kwh',
case
when ai.flow_grid_to_load_wh is null then null
else round(ai.flow_grid_to_load_wh::numeric / 1000, 4)
end,
'grid_to_batt_kwh',
case
when ai.flow_grid_to_batt_wh is null then null
else round(ai.flow_grid_to_batt_wh::numeric / 1000, 4)
end
)
order by ai.interval_start
),
'[]'::jsonb
)
from ems.audit_interval ai
where ai.site_id = p_site_id
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date = p_day;
$fn$;
comment on function ems.fn_energy_flows_intervals_day(int, date) is
'15min energy flows pro jeden kalendářní den (Prague) jako JSON pole.';

View File

@@ -0,0 +1,70 @@
create or replace function ems.fn_ev_arrival_prediction_bundle(p_site_id int)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_tz text;
v_tomorrow date;
v_n_sessions int;
v_insufficient boolean;
v_chargers jsonb := '{}'::jsonb;
r record;
v_rows jsonb;
begin
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague')
into v_tz
from ems.site s
where s.id = p_site_id;
if not found then
return jsonb_build_object('error', 'site_not_found');
end if;
v_tomorrow := (
(current_timestamp at time zone v_tz)::date + 1
);
select count(*)::int
into v_n_sessions
from ems.ev_session
where site_id = p_site_id;
v_insufficient := coalesce(v_n_sessions, 0) < 5;
for r in
select id, code
from ems.asset_ev_charger
where site_id = p_site_id
order by id
loop
select coalesce(
jsonb_agg(
jsonb_build_object(
'hour', x.expected_hour,
'confidence_pct', x.confidence_pct,
'samples', x.sample_count
)
order by x.expected_hour
),
'[]'::jsonb
)
into v_rows
from ems.fn_ev_expected_arrival(p_site_id, r.id, v_tomorrow) x;
v_chargers := v_chargers || jsonb_build_object(
r.code::text,
jsonb_build_object('tomorrow', coalesce(v_rows, '[]'::jsonb))
);
end loop;
return jsonb_build_object(
'insufficient_data', v_insufficient,
'tomorrow_date', v_tomorrow,
'chargers', v_chargers
);
end;
$fn$;
comment on function ems.fn_ev_arrival_prediction_bundle(int) is
'Predikce příjezdů pro všechny nabíječky (nahrazuje N+1 volání fn_ev_expected_arrival).';

View File

@@ -0,0 +1,49 @@
create or replace function ems.fn_ev_session_apply_patch(
p_site_id int,
p_session_id int,
p_patch jsonb
)
returns jsonb
language plpgsql
as $fn$
declare
v_id int;
begin
if not (p_patch ? 'target_soc_pct') and not (p_patch ? 'target_deadline') then
return jsonb_build_object('success', false, 'error', 'no_fields');
end if;
update ems.ev_session es
set
target_soc_pct = case
when p_patch ? 'target_soc_pct' then
case
when p_patch->'target_soc_pct' is null
or jsonb_typeof(p_patch->'target_soc_pct') = 'null' then null
else (p_patch->>'target_soc_pct')::double precision
end
else es.target_soc_pct
end,
target_deadline = case
when p_patch ? 'target_deadline' then
case
when p_patch->'target_deadline' is null
or jsonb_typeof(p_patch->'target_deadline') = 'null' then null
else (p_patch->>'target_deadline')::timestamptz
end
else es.target_deadline
end
where es.id = p_session_id
and es.site_id = p_site_id
returning es.id into v_id;
if v_id is null then
return jsonb_build_object('success', false, 'session_id', null);
end if;
return jsonb_build_object('success', true, 'session_id', v_id);
end;
$fn$;
comment on function ems.fn_ev_session_apply_patch(int, int, jsonb) is
'PATCH EV session jen klíče přítomné v JSON (ISO string pro deadline).';

View File

@@ -0,0 +1,87 @@
create or replace function ems.fn_ev_session_transition(
p_site_id int,
p_charger_id int,
p_prev_status text,
p_new_status text,
p_measured_at timestamptz
)
returns jsonb
language plpgsql
as $fn$
declare
v_vehicle_id int;
begin
if p_prev_status is not distinct from p_new_status then
return jsonb_build_object('action', 'none');
end if;
if p_prev_status = 'available' and p_new_status is distinct from 'available' then
select av.id
into v_vehicle_id
from ems.asset_vehicle av
where av.site_id = p_site_id
and av.default_charger_id = p_charger_id
and av.active = true
order by av.id
limit 1;
perform ems.fn_update_ev_arrival_stats(
p_site_id,
p_charger_id,
v_vehicle_id,
p_measured_at
);
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 = p_charger_id
and ac.site_id = p_site_id
on conflict (charger_id) where session_end is null do nothing;
return jsonb_build_object('action', 'arrival');
end if;
if p_prev_status is distinct from 'available' and p_new_status = 'available' then
update ems.ev_session es
set session_end = now()
where es.charger_id = p_charger_id
and es.session_end is null;
return jsonb_build_object('action', 'departure');
end if;
return jsonb_build_object('action', 'none');
end;
$fn$;
comment on function ems.fn_ev_session_transition(int, int, text, text, timestamptz) is
'Detekce příjezdu/odjezdu EV po změně statusu nabíječky (telemetry_collector).';

View File

@@ -0,0 +1,49 @@
create or replace function ems.fn_ev_sessions_active(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(
jsonb_build_object(
'id', es.id,
'charger_id', es.charger_id,
'vehicle_id', es.vehicle_id,
'session_start', es.session_start,
'energy_delivered_wh', es.energy_delivered_wh,
'target_soc_pct', es.target_soc_pct,
'target_deadline', es.target_deadline,
'make', av.make,
'model', av.model,
'battery_capacity_kwh', av.battery_capacity_kwh,
'default_target_soc_pct', av.default_target_soc_pct,
'default_deadline_hour', av.default_deadline_hour,
'charger_code', ac.code,
'charger_name',
coalesce(
nullif(
trim(
concat_ws(
' ',
nullif(trim(ac.manufacturer), ''),
nullif(trim(ac.model), '')
)
),
''
),
ac.code
)
)
order by es.session_start desc
),
'[]'::jsonb
)
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 = p_site_id
and es.session_end is null;
$fn$;
comment on function ems.fn_ev_sessions_active(int) is
'Aktivní EV session pro site (GET /ev/sessions/active).';

View File

@@ -0,0 +1,39 @@
-- doplní chybějící audit_interval za posledních p_hours hodin (15min sloty)
create or replace function ems.fn_fill_audit_for_site_window(
p_site_id int,
p_hours int default 6
)
returns int
language plpgsql
as $fn$
declare
v_last timestamptz;
v_slot timestamptz;
v_cnt int := 0;
begin
v_last := date_trunc('minute', now())
- (extract(minute from now())::int % 15) * interval '1 minute';
v_last := v_last - interval '15 minutes';
for v_slot in
select gs.slot
from generate_series(
v_last - make_interval(hours => p_hours),
v_last,
interval '15 minutes'
) as gs(slot)
where not exists (
select 1 from ems.audit_interval ai
where ai.site_id = p_site_id
and ai.interval_start = gs.slot
)
loop
perform ems.fn_fill_audit_interval(p_site_id, v_slot);
perform ems.fn_fill_baseline_load_forecast_accuracy(p_site_id, v_slot);
v_cnt := v_cnt + 1;
end loop;
return v_cnt;
end;
$fn$;

View File

@@ -0,0 +1,74 @@
create or replace function ems.fn_forecast_pv_split(p_site_id int, p_day date)
returns jsonb
language sql
stable
as $fn$
with latest as (
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 = p_site_id
and (
fpi.interval_start at time zone coalesce(
nullif(trim((select timezone from ems.site s where s.id = p_site_id)), ''),
'Europe/Prague'
)
)::date = p_day
and fpr.status = 'ok'
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
),
rows as (
select
case
when controllable then 'a'
else 'b'
end as pole,
jsonb_build_object(
'run_id', run_id,
'pv_array_id', pv_array_id,
'interval_start', interval_start,
'power_w', power_w,
'irradiance_wm2', irradiance_wm2,
'temp_c', temp_c,
'pv_array_code', pv_array_code
) as j,
controllable,
pv_array_code,
interval_start
from latest
)
select jsonb_build_object(
'pv_a',
coalesce(
(
select jsonb_agg(j order by pv_array_code, interval_start)
from rows
where controllable
),
'[]'::jsonb
),
'pv_b',
coalesce(
(
select jsonb_agg(j order by pv_array_code, interval_start)
from rows
where not controllable
),
'[]'::jsonb
)
);
$fn$;
comment on function ems.fn_forecast_pv_split(int, date) is
'Predikce FVE rozsplitěná na pole A (controllable) a B pro UI.';

View File

@@ -0,0 +1,69 @@
create or replace function ems.fn_inverter_modbus_caps_patch(
p_site_id int,
p_inverter_id int,
p_patch jsonb
)
returns jsonb
language plpgsql
as $fn$
declare
v_charge int;
v_discharge int;
r record;
begin
if not (p_patch ? 'deye_register_max_charge_a')
and not (p_patch ? 'deye_register_max_discharge_a') then
return jsonb_build_object('ok', false, 'error', 'no_fields');
end if;
v_charge := case
when p_patch ? 'deye_register_max_charge_a' then
case
when p_patch->'deye_register_max_charge_a' is null
or jsonb_typeof(p_patch->'deye_register_max_charge_a') = 'null' then null
else (p_patch->>'deye_register_max_charge_a')::int
end
else null
end;
v_discharge := case
when p_patch ? 'deye_register_max_discharge_a' then
case
when p_patch->'deye_register_max_discharge_a' is null
or jsonb_typeof(p_patch->'deye_register_max_discharge_a') = 'null' then null
else (p_patch->>'deye_register_max_discharge_a')::int
end
else null
end;
update ems.asset_inverter ai
set
deye_register_max_charge_a = case
when p_patch ? 'deye_register_max_charge_a' then v_charge
else ai.deye_register_max_charge_a
end,
deye_register_max_discharge_a = case
when p_patch ? 'deye_register_max_discharge_a' then v_discharge
else ai.deye_register_max_discharge_a
end
where ai.id = p_inverter_id
and ai.site_id = p_site_id
returning ai.id, ai.code, ai.deye_register_max_charge_a, ai.deye_register_max_discharge_a
into r;
if r.id is null then
return jsonb_build_object('ok', false, 'error', 'not_found');
end if;
return jsonb_build_object(
'ok', true,
'inverter_id', r.id,
'code', r.code,
'deye_register_max_charge_a', r.deye_register_max_charge_a,
'deye_register_max_discharge_a', r.deye_register_max_discharge_a
);
end;
$fn$;
comment on function ems.fn_inverter_modbus_caps_patch(int, int, jsonb) is
'PATCH stropů proudu reg 108/109 explicitní JSON null maže strop.';

View File

@@ -0,0 +1,23 @@
create or replace function ems.fn_latest_ote_day_stats()
returns jsonb
language sql
stable
as $fn$
select to_jsonb(sub)
from (
select
(mip.interval_start at time zone 'Europe/Prague')::date as latest_date,
count(*)::int as slots,
min(mip.buy_raw_price_czk_kwh)::float as min_price,
max(mip.buy_raw_price_czk_kwh)::float as max_price,
avg(mip.buy_raw_price_czk_kwh)::float as avg_price
from ems.market_interval_price mip
where mip.market_source in ('OTE_CZ', 'OTE_CZ_DAM')
group by (mip.interval_start at time zone 'Europe/Prague')::date
order by latest_date desc
limit 1
) sub;
$fn$;
comment on function ems.fn_latest_ote_day_stats() is
'Agregace posledního kalendářního dne s OTE daty (globální, bez site_id).';

View File

@@ -0,0 +1,285 @@
-- sloty pro LP: ceny, forecast, baseline, EV připojení + masky allow_charge / allow_discharge_export
create or replace function ems.fn_load_planning_slots_full(
p_site_id int,
p_from timestamptz,
p_to timestamptz,
p_current_soc_wh numeric
)
returns table (
slot_ord int,
interval_start timestamptz,
buy_price numeric,
sell_price numeric,
is_predicted_price boolean,
pv_a_forecast_w int,
pv_b_forecast_w int,
load_baseline_w int,
ev1_connected boolean,
ev2_connected boolean,
allow_charge boolean,
allow_discharge_export boolean
)
language plpgsql
stable
as $fn$
declare
v_charge_buf numeric;
v_discharge_buf numeric;
v_usable numeric;
v_min_soc_wh numeric;
v_soc_max_wh numeric;
v_energy_to_fill numeric;
v_exportable numeric;
v_charge_eff numeric;
v_discharge_eff numeric;
v_max_charge_w numeric;
v_max_discharge_w numeric;
v_per_slot_charge_wh numeric;
v_per_slot_discharge_wh numeric;
v_grid_target_wh numeric;
v_discharge_target_wh numeric;
v_cum numeric;
r_slot record;
begin
drop table if exists _ems_plan_slot_wk;
create temp table _ems_plan_slot_wk on commit drop as
with slot_spine as (
select gs as interval_start
from generate_series(
p_from,
(p_to - interval '15 minutes')::timestamptz,
interval '15 minutes'
) as gs
)
select
row_number() over (order by s.interval_start) - 1 as slot_ord,
s.interval_start,
coalesce(
ep.effective_buy_price_czk_kwh,
ems.fn_get_predicted_price(p_site_id, s.interval_start)
) as buy_price,
coalesce(
ep.effective_sell_price_czk_kwh,
ems.fn_get_predicted_price(p_site_id, 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)::int as pv_a_forecast_w,
coalesce(fpi_b.power_w, 0)::int as pv_b_forecast_w,
coalesce(
(
select bs.avg_power_w
from ems.consumption_baseline_stats bs
where bs.site_id = p_site_id
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
)::int 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,
greatest(
0,
coalesce(fpi_a.power_w, 0) + coalesce(fpi_b.power_w, 0)
- coalesce(
(
select bs.avg_power_w
from ems.consumption_baseline_stats bs
where bs.site_id = p_site_id
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
)
)::int as pv_surplus_w,
false::boolean as allow_charge,
false::boolean as allow_discharge_export
from slot_spine s
left join ems.vw_site_effective_price ep
on ep.site_id = p_site_id 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 = p_site_id
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 = p_site_id
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 = p_site_id 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 = p_site_id and ch.code = 'ev-charger-2'
order by t.measured_at desc
limit 1
) ev2 on true;
if not exists (select 1 from _ems_plan_slot_wk) then
raise exception 'No planning slots available check market prices and horizon settings';
end if;
select
coalesce(ab.charge_slot_buffer, 0::numeric),
coalesce(ab.discharge_slot_buffer, 0::numeric),
ab.usable_capacity_wh::numeric,
(ab.min_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
(ab.max_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
greatest(coalesce(ab.charge_efficiency, 1::numeric), 0.0001::numeric),
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)
)
)::numeric,
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)
)
)::numeric,
greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric)
into
v_charge_buf,
v_discharge_buf,
v_usable,
v_min_soc_wh,
v_soc_max_wh,
v_charge_eff,
v_max_charge_w,
v_max_discharge_w,
v_discharge_eff
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 = p_site_id
order by ab.id
limit 1;
if v_usable is null then
raise exception 'No asset_battery for site_id=%', p_site_id;
end if;
v_per_slot_charge_wh := v_max_charge_w * v_charge_eff * 0.25;
v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25;
v_energy_to_fill := v_soc_max_wh - p_current_soc_wh;
v_exportable := v_soc_max_wh - v_min_soc_wh;
v_grid_target_wh := v_energy_to_fill * v_charge_buf;
v_discharge_target_wh := v_exportable * v_discharge_buf;
-- charge mask
if v_charge_buf <= 0 then
update _ems_plan_slot_wk set allow_charge = true;
elsif v_energy_to_fill <= 0 then
update _ems_plan_slot_wk set allow_charge = false;
else
update _ems_plan_slot_wk set allow_charge = (pv_surplus_w > 0);
v_cum := 0;
for r_slot in
select slot_ord
from _ems_plan_slot_wk
where pv_surplus_w <= 0
order by buy_price, slot_ord
loop
exit when v_cum >= v_grid_target_wh;
exit when v_per_slot_charge_wh <= 0;
update _ems_plan_slot_wk set allow_charge = true where slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_charge_wh;
end loop;
end if;
-- discharge-export mask
if v_discharge_buf <= 0 then
update _ems_plan_slot_wk set allow_discharge_export = true;
elsif v_exportable <= 0 then
update _ems_plan_slot_wk set allow_discharge_export = false;
else
update _ems_plan_slot_wk set allow_discharge_export = false;
v_cum := 0;
for r_slot in
select slot_ord
from _ems_plan_slot_wk
order by sell_price desc, slot_ord desc
loop
exit when v_cum >= v_discharge_target_wh;
exit when v_per_slot_discharge_wh <= 0;
update _ems_plan_slot_wk set allow_discharge_export = true where slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_discharge_wh;
end loop;
end if;
return query
select
w.slot_ord,
w.interval_start,
w.buy_price,
w.sell_price,
w.is_predicted_price,
w.pv_a_forecast_w,
w.pv_b_forecast_w,
w.load_baseline_w,
w.ev1_connected,
w.ev2_connected,
w.allow_charge,
w.allow_discharge_export
from _ems_plan_slot_wk w
order by w.slot_ord;
end;
$fn$;
comment on function ems.fn_load_planning_slots_full(int, timestamptz, timestamptz, numeric) is
'15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export).';

View File

@@ -0,0 +1,15 @@
create or replace function ems.fn_modbus_commands_by_ids(p_ids int[])
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(to_jsonb(m.*) order by m.id),
'[]'::jsonb
)
from ems.modbus_command m
where m.id = any(p_ids);
$fn$;
comment on function ems.fn_modbus_commands_by_ids(int[]) is
'Řádky modbus_command pro seznam ID (ověření / journal detail).';

View File

@@ -0,0 +1,42 @@
create or replace function ems.fn_modbus_journal_list(p_site_id int, p_limit int)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(
jsonb_build_object(
'id', q.id,
'register', q.register,
'register_name', q.register_name,
'value_to_write', q.value_to_write,
'value_written', q.value_written,
'value_verified', q.value_verified,
'status', q.status,
'attempt_count', q.attempt_count,
'created_at', q.created_at
)
order by q.created_at desc
),
'[]'::jsonb
)
from (
select
mc.id,
mc.register,
mc.register_name,
mc.value_to_write,
mc.value_written,
mc.value_verified,
mc.status,
mc.attempt_count,
mc.created_at
from ems.modbus_command mc
where mc.site_id = p_site_id
order by mc.created_at desc
limit p_limit
) q;
$fn$;
comment on function ems.fn_modbus_journal_list(int, int) is
'Poslední Modbus příkazy pro site (GET control/journal).';

View File

@@ -0,0 +1,24 @@
-- map register -> value_verified z modbus_command (poslední verified řádek per register)
create or replace function ems.fn_modbus_last_verified_map(
p_site_id int,
p_asset_id int
)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_object_agg(register::text, to_jsonb(value_verified)),
'{}'::jsonb
)
from (
select
v.register,
v.value_verified
from ems.vw_modbus_last_verified v
where v.site_id = p_site_id
and v.asset_type = 'inverter'
and v.asset_id = p_asset_id
) t;
$fn$;

View File

@@ -0,0 +1,20 @@
create or replace function ems.fn_modbus_written_command_ids(
p_site_id int,
p_lookback interval
)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(mc.id order by mc.written_at),
'[]'::jsonb
)
from ems.modbus_command mc
where mc.site_id = p_site_id
and mc.status = 'written'
and mc.written_at >= now() - p_lookback;
$fn$;
comment on function ems.fn_modbus_written_command_ids(int, interval) is
'ID written příkazů k ruční verifikaci (GET control/verify).';

View File

@@ -0,0 +1,59 @@
create or replace function ems.fn_negative_price_predictions(p_site_id int)
returns jsonb
language sql
stable
as $fn$
with hist as (
select count(distinct (mip.interval_start at time zone 'Europe/Prague')::date)::int as ndays
from ems.market_interval_price mip
where mip.market_source in ('OTE_CZ', 'OTE_CZ_DAM')
and mip.interval_start >= now() - interval '400 days'
),
rows as (
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 = p_site_id
and p.predicted_date > (
current_timestamp at time zone coalesce(
nullif(trim((select s.timezone from ems.site s where s.id = p_site_id)), ''),
'Europe/Prague'
)
)::date
and p.predicted_date <= (
current_timestamp at time zone coalesce(
nullif(trim((select s.timezone from ems.site s where s.id = p_site_id)), ''),
'Europe/Prague'
)
)::date + 7
order by p.predicted_date, p.window_start_hour
)
select jsonb_build_object(
'predictions',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'predicted_date', r.predicted_date,
'window_start_hour', r.window_start_hour,
'window_end_hour', r.window_end_hour,
'probability_pct', r.probability_pct,
'expected_min_price', r.expected_min_price,
'reason', coalesce(r.reason, '')
)
)
from rows r
),
'[]'::jsonb
),
'insufficient_history', (select ndays < 28 from hist)
);
$fn$;
comment on function ems.fn_negative_price_predictions(int) is
'Predikovaná okna záporných cen + flag nedostatečné historie OTE.';

View File

@@ -0,0 +1,42 @@
-- statistiky importovaných OTE slotů pro kalendářní den v Europe/Prague
create or replace function ems.fn_ote_day_slot_stats_prague(p_day date)
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'count',
coalesce(
(
select count(*)::int
from ems.market_interval_price mip
where mip.market_source = 'OTE_CZ'
and (mip.interval_start at time zone 'Europe/Prague')::date = p_day
),
0
),
'first_price',
(
select mip.buy_raw_price_czk_kwh
from ems.market_interval_price mip
where mip.market_source = 'OTE_CZ'
and (mip.interval_start at time zone 'Europe/Prague')::date = p_day
order by mip.interval_start
limit 1
),
'is_complete',
coalesce(
(
select count(*)::int
from ems.market_interval_price mip
where mip.market_source = 'OTE_CZ'
and (mip.interval_start at time zone 'Europe/Prague')::date = p_day
),
0
) in (92, 96, 100)
);
$fn$;
comment on function ems.fn_ote_day_slot_stats_prague(date) is
'Počet slotů OTE_CZ, první cena a zda den vypadá kompletně (92/96/100) v TZ Praha.';

View File

@@ -0,0 +1,26 @@
create or replace function ems.fn_ote_list_missing_days(p_from date, p_to date)
returns table(day_local date)
language sql
stable
as $fn$
with days as (
select gs::date as d
from generate_series(p_from::timestamp, p_to::timestamp, interval '1 day') gs
),
counts as (
select
(mip.interval_start at time zone 'Europe/Prague')::date as d,
count(*)::int as n
from ems.market_interval_price mip
where mip.market_source = 'OTE_CZ'
and (mip.interval_start at time zone 'Europe/Prague')::date between p_from and p_to
group by 1
)
select days.d as day_local
from days
left join counts c on c.d = days.d
where coalesce(c.n, 0) not in (92, 96, 100);
$fn$;
comment on function ems.fn_ote_list_missing_days(date, date) is
'Kalendářní dny v rozsahu bez „plného“ počtu OTE slotů (backfill).';

View File

@@ -0,0 +1,177 @@
create or replace function ems.fn_plan_current_bundle(p_site_id int)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_run jsonb;
v_run_id int;
v_batt_wh float;
v_intervals jsonb;
v_total_cost numeric;
v_curtailed numeric;
v_charge bigint;
v_discharge bigint;
v_export bigint;
v_pv_kwh numeric;
v_cap numeric;
v_cov numeric;
v_scarcity numeric;
begin
select to_jsonb(pr)
into v_run
from ems.planning_run pr
where pr.site_id = p_site_id
and pr.status = 'active'
order by pr.created_at desc
limit 1;
if v_run is null then
return jsonb_build_object('error', 'no_active_plan');
end if;
v_run_id := (v_run->>'id')::int;
select coalesce(sum(ab.usable_capacity_wh), 0)::float
into v_batt_wh
from ems.asset_battery ab
where ab.site_id = p_site_id;
with fc_slot as (
select
u.interval_start,
coalesce(sum(u.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 = p_site_id
and fpr.status = 'ok'
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
) u
group by u.interval_start
),
joined as (
select
to_jsonb(pi.*)
|| jsonb_build_object(
'pv_power_w', ai.actual_pv_power_w,
'pv_forecast_total_w', fs.pv_forecast_total_w
) as j,
pi.interval_start,
pi.expected_cost_czk,
pi.pv_a_curtailed_w,
pi.battery_setpoint_w,
pi.grid_setpoint_w,
fs.pv_forecast_total_w
from ems.planning_interval pi
left join ems.audit_interval ai
on ai.site_id = p_site_id
and ai.interval_start = pi.interval_start
left join fc_slot fs on fs.interval_start = pi.interval_start
where pi.run_id = v_run_id
),
agg as (
select
coalesce(jsonb_agg(j order by interval_start), '[]'::jsonb) as intervals,
coalesce(
sum(
case
when expected_cost_czk is not null then expected_cost_czk::numeric
else 0::numeric
end
),
0::numeric
) as total_cost,
coalesce(
sum(coalesce(pv_a_curtailed_w, 0)::numeric * 0.25 / 1000.0),
0::numeric
) as curtailed_kwh,
coalesce(
sum(
case
when battery_setpoint_w is not null and battery_setpoint_w > 0 then 1
else 0
end
),
0::bigint
) as charge_slots,
coalesce(
sum(
case
when battery_setpoint_w is not null and battery_setpoint_w < 0 then 1
else 0
end
),
0::bigint
) as discharge_slots,
coalesce(
sum(
case
when grid_setpoint_w is not null and grid_setpoint_w < 0 then 1
else 0
end
),
0::bigint
) as export_slots
from joined
),
pv96 as (
select coalesce(
sum(
greatest(0::numeric, coalesce(pv_forecast_total_w, 0)::numeric) * 0.25 / 1000.0
),
0::numeric
) as pv_kwh
from (
select pv_forecast_total_w
from joined
order by interval_start
limit 96
) z
)
select
a.intervals,
a.total_cost,
a.curtailed_kwh,
a.charge_slots,
a.discharge_slots,
a.export_slots,
p.pv_kwh
into strict
v_intervals,
v_total_cost,
v_curtailed,
v_charge,
v_discharge,
v_export,
v_pv_kwh
from agg a
cross join pv96 p;
v_cap := greatest(1::numeric, coalesce(v_batt_wh, 0::float)::numeric / 1000.0);
v_cov := least(1::numeric, greatest(0::numeric, coalesce(v_pv_kwh, 0) / v_cap));
v_scarcity := round(0.65::numeric + 0.35 * v_cov, 4);
return jsonb_build_object(
'run', v_run,
'intervals', v_intervals,
'summary', jsonb_build_object(
'total_expected_cost_czk', round(v_total_cost, 4),
'total_pv_curtailed_kwh', round(v_curtailed, 6),
'charge_slots', v_charge,
'discharge_slots', v_discharge,
'export_slots', v_export,
'pv_scarcity_factor', v_scarcity
)
);
end;
$fn$;
comment on function ems.fn_plan_current_bundle(int) is
'Aktivní planning_run + intervaly + souhrn (GET /plan/current).';

View File

@@ -0,0 +1,29 @@
-- aktivní planning_run pro site (rolling horizon)
create or replace function ems.fn_planning_active_run(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select case
when not exists (select 1 from ems.site s where s.id = p_site_id) then
jsonb_build_object('error', 'unknown_site')
when not exists (
select 1 from ems.planning_run pr
where pr.site_id = p_site_id and pr.status = 'active'
) then
jsonb_build_object('error', 'no_active_plan')
else (
select jsonb_build_object(
'id', pr.id,
'horizon_end', pr.horizon_end,
'horizon_start', pr.horizon_start,
'created_at', pr.created_at
)
from ems.planning_run pr
where pr.site_id = p_site_id and pr.status = 'active'
order by pr.created_at desc
limit 1
)
end;
$fn$;

View File

@@ -0,0 +1,14 @@
create or replace function ems.fn_planning_future_price_days()
returns int
language sql
stable
as $fn$
select count(distinct (mip.interval_start at time zone 'Europe/Prague')::date)::int
from ems.market_interval_price mip
where mip.market_source in ('OTE_CZ', 'OTE_CZ_DAM')
and mip.interval_start >= now()
and mip.interval_start < now() + interval '48 hours';
$fn$;
comment on function ems.fn_planning_future_price_days() is
'Počet kalendářních dní s OTE daty v okně now..now+48h (před spuštěním plánu).';

View File

@@ -0,0 +1,45 @@
-- jeden řádek planning_interval jako jsonb pro aktivní plán a slot offset (Prague 15min)
create or replace function ems.fn_planning_interval_at_offset(
p_site_id int,
p_offset_slots int default 0
)
returns jsonb
language sql
stable
as $fn$
select to_jsonb(pi)
from ems.planning_interval pi
join ems.planning_run pr on pr.id = pi.run_id
where pr.site_id = p_site_id
and pr.status = 'active'
and pi.interval_start = ems.fn_planning_slot_boundary_prague(p_offset_slots)
limit 1;
$fn$;
create or replace function ems.fn_planning_max_effective_charge_w(p_site_id int)
returns int
language sql
stable
as $fn$
select coalesce(
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)
)
)::int,
0
)
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 = p_site_id
and ai.controllable is true
and ai.active is true
order by ab.id
limit 1;
$fn$;

View File

@@ -0,0 +1,148 @@
-- uložení planning_run + planning_interval v jedné transakci
create or replace function ems.fn_planning_run_commit(
p_site_id int,
p_horizon_start timestamptz,
p_horizon_end timestamptz,
p_run_meta jsonb,
p_intervals jsonb
)
returns int
language plpgsql
as $fn$
declare
v_run_id int;
r record;
v_has_slot_inputs boolean;
begin
v_has_slot_inputs := coalesce(
(jsonb_typeof(p_intervals) = 'array' and jsonb_array_length(p_intervals) > 0
and (p_intervals->0) ? 'load_baseline_w'),
false
);
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 (
p_site_id,
p_horizon_start,
p_horizon_end,
'draft',
nullif(trim(p_run_meta->>'run_type'), ''),
nullif(trim(p_run_meta->>'triggered_by'), ''),
case
when p_run_meta ? 'replan_from' and (p_run_meta->>'replan_from') is not null
and (p_run_meta->>'replan_from') <> 'null'
then (p_run_meta->>'replan_from')::timestamptz
else null::timestamptz
end,
(p_run_meta->>'soc_at_replan_wh')::numeric,
(p_run_meta->>'solver_duration_ms')::int,
(p_run_meta->>'forecast_correction_factor')::numeric
)
returning id into v_run_id;
for r in select * from jsonb_array_elements(p_intervals) as elem(value)
loop
if v_has_slot_inputs then
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 (
v_run_id,
(r.value->>'interval_start')::timestamptz,
(r.value->>'battery_setpoint_w')::int,
(r.value->>'battery_soc_target_pct')::numeric,
(r.value->>'grid_setpoint_w')::int,
nullif(r.value->>'ev1_setpoint_w', '')::int,
nullif(r.value->>'ev2_setpoint_w', '')::int,
coalesce((r.value->>'ev1_via_bat_w')::int, 0),
coalesce((r.value->>'ev2_via_bat_w')::int, 0),
coalesce((r.value->>'heat_pump_enabled')::boolean, false),
(r.value->>'heat_pump_setpoint_w')::int,
(r.value->>'pv_a_curtailed_w')::int,
(r.value->>'expected_cost_czk')::numeric,
(r.value->>'effective_buy_price')::numeric,
(r.value->>'effective_sell_price')::numeric,
coalesce((r.value->>'is_predicted_price')::boolean, false),
(r.value->>'load_baseline_w')::int,
(r.value->>'pv_a_forecast_raw_w')::int,
(r.value->>'pv_b_forecast_raw_w')::int,
(r.value->>'pv_a_forecast_solver_w')::int,
(r.value->>'pv_b_forecast_solver_w')::int
);
else
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 (
v_run_id,
(r.value->>'interval_start')::timestamptz,
(r.value->>'battery_setpoint_w')::int,
(r.value->>'battery_soc_target_pct')::numeric,
(r.value->>'grid_setpoint_w')::int,
nullif(r.value->>'ev1_setpoint_w', '')::int,
nullif(r.value->>'ev2_setpoint_w', '')::int,
coalesce((r.value->>'ev1_via_bat_w')::int, 0),
coalesce((r.value->>'ev2_via_bat_w')::int, 0),
coalesce((r.value->>'heat_pump_enabled')::boolean, false),
(r.value->>'heat_pump_setpoint_w')::int,
(r.value->>'pv_a_curtailed_w')::int,
(r.value->>'expected_cost_czk')::numeric,
(r.value->>'effective_buy_price')::numeric,
(r.value->>'effective_sell_price')::numeric,
coalesce((r.value->>'is_predicted_price')::boolean, false)
);
end if;
end loop;
update ems.planning_run
set status = 'superseded'
where site_id = p_site_id
and status = 'active'
and id <> v_run_id;
update ems.planning_run
set status = 'active'
where id = v_run_id;
return v_run_id;
end;
$fn$;
create or replace function ems.fn_forecast_correction_log_insert(
p_site_id int,
p_window_start timestamptz,
p_window_end timestamptz,
p_actual_pv_wh numeric,
p_forecast_pv_wh numeric,
p_correction_factor numeric,
p_applied_to_run_id int
)
returns void
language sql
as $fn$
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 (
p_site_id, p_window_start, p_window_end,
p_actual_pv_wh, p_forecast_pv_wh, p_correction_factor, p_applied_to_run_id
);
$fn$;

View File

@@ -0,0 +1,15 @@
create or replace function ems.fn_planning_run_horizon(p_run_id int)
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'horizon_start', pr.horizon_start,
'horizon_end', pr.horizon_end
)
from ems.planning_run pr
where pr.id = p_run_id;
$fn$;
comment on function ems.fn_planning_run_horizon(int) is
'Horizont po úspěšném POST /plan/run (read-model).';

View File

@@ -0,0 +1,263 @@
-- jeden jsonb snapshot pro LP: režim, baterie, síť, EV, TČ, tuv stats
create or replace function ems.fn_planning_site_context(p_site_id int)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_mode text;
v_b jsonb;
v_hp jsonb;
v_grid jsonb;
v_veh jsonb;
v_ev jsonb;
v_soc_pct numeric;
v_soc_wh numeric;
v_tuv numeric;
v_tuv_stats jsonb;
v_uc numeric;
v_min_soc_wh numeric;
v_arb_wh numeric;
v_soc_max_wh numeric;
begin
if not exists (select 1 from ems.site s where s.id = p_site_id) then
return jsonb_build_object('error', 'unknown_site');
end if;
select som.mode_code
into v_mode
from ems.site_operating_mode som
where som.site_id = p_site_id;
select jsonb_build_object(
'usable_capacity_wh', ab.usable_capacity_wh,
'min_soc_wh', (ab.min_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
'arb_floor_wh', (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
'reserve_soc_wh', (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
'soc_max_wh', (ab.max_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
'charge_efficiency', ab.charge_efficiency,
'discharge_efficiency', ab.discharge_efficiency,
'degradation_cost_czk_kwh', ab.degradation_cost_czk_kwh,
'max_charge_power_w', 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)
)
)::int,
'max_discharge_power_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)
)
)::int,
'charge_slot_buffer', ab.charge_slot_buffer,
'discharge_slot_buffer', ab.discharge_slot_buffer
)
into v_b
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 = p_site_id
order by ab.id
limit 1;
if v_b is null then
raise exception 'No asset_battery for site_id=%', p_site_id;
end if;
v_uc := (v_b->>'usable_capacity_wh')::numeric;
v_min_soc_wh := (v_b->>'min_soc_wh')::numeric;
v_soc_max_wh := (v_b->>'soc_max_wh')::numeric;
if (v_b->>'max_charge_power_w')::int <= 0 or (v_b->>'max_discharge_power_w')::int <= 0 then
raise exception 'Invalid battery effective limits for site_id=%', p_site_id;
end if;
select jsonb_build_object(
'rated_heating_power_w', greatest(coalesce(hp.rated_heating_power_w, 8000), 0)::int,
'tuv_min_temp_c', coalesce(hp.tuv_min_temp_c, 45)::numeric,
'tuv_target_temp_c', coalesce(hp.tuv_target_temp_c, 55)::numeric
)
into v_hp
from ems.asset_heat_pump hp
where hp.site_id = p_site_id
order by hp.id
limit 1;
if v_hp is null then
v_hp := jsonb_build_object(
'rated_heating_power_w', 0,
'tuv_min_temp_c', 0,
'tuv_target_temp_c', 55
);
end if;
select jsonb_build_object(
'max_import_power_w', sgc.max_import_power_w,
'max_export_power_w', sgc.max_export_power_w
)
into v_grid
from ems.site_grid_connection sgc
where sgc.site_id = p_site_id
order by sgc.id
limit 1;
if v_grid is null then
raise exception 'No site_grid_connection for site_id=%', p_site_id;
end if;
select coalesce(
jsonb_agg(
jsonb_build_object(
'max_charge_power_w', v.max_charge_power_w,
'battery_capacity_kwh', v.battery_capacity_kwh,
'default_target_soc_pct', v.default_target_soc_pct
)
order by ch.code
),
'[]'::jsonb
)
into v_veh
from ems.asset_vehicle v
join ems.asset_ev_charger ch on ch.id = v.default_charger_id
where v.site_id = p_site_id
and ch.code in ('ev-charger-1', 'ev-charger-2');
v_ev := jsonb_build_array(
(
select case
when es.target_deadline is null then null::jsonb
when v.battery_capacity_kwh is null then null::jsonb
when es.soc_at_connect_pct is null then null::jsonb
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb
when greatest(
0,
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
) <= 0 then null::jsonb
else jsonb_build_object(
'target_deadline', es.target_deadline,
'energy_needed_wh', greatest(
0,
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
)
)
end
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 = p_site_id
and es.session_end is null
and ch.code = 'ev-charger-1'
limit 1
),
(
select case
when es.target_deadline is null then null::jsonb
when v.battery_capacity_kwh is null then null::jsonb
when es.soc_at_connect_pct is null then null::jsonb
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb
when greatest(
0,
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
) <= 0 then null::jsonb
else jsonb_build_object(
'target_deadline', es.target_deadline,
'energy_needed_wh', greatest(
0,
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
)
)
end
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 = p_site_id
and es.session_end is null
and ch.code = 'ev-charger-2'
limit 1
)
);
select ti.battery_soc_percent
into v_soc_pct
from ems.telemetry_inverter ti
where ti.site_id = p_site_id
order by ti.measured_at desc
limit 1;
if v_soc_pct is null then
v_soc_wh := v_uc * 0.5;
else
v_soc_wh := v_soc_pct::numeric / 100.0 * v_uc;
end if;
v_soc_wh := greatest(v_min_soc_wh, least(v_soc_wh, v_soc_max_wh));
select thp.tuv_tank_temp_c
into v_tuv
from ems.telemetry_heat_pump thp
where thp.site_id = p_site_id
order by thp.measured_at desc
limit 1;
v_tuv := coalesce(v_tuv::numeric, 50::numeric);
select coalesce(
jsonb_agg(
jsonb_build_object(
'dow', tu.day_of_week,
'hour', tu.hour_of_day,
'delta', tu.avg_temp_delta_c
)
),
'[]'::jsonb
)
into v_tuv_stats
from ems.tuv_usage_stats tu
where tu.site_id = p_site_id;
return jsonb_build_object(
'operating_mode', v_mode,
'battery', v_b,
'heat_pump', v_hp,
'grid', v_grid,
'vehicles', v_veh,
'ev_sessions', v_ev,
'soc_wh', v_soc_wh,
'tuv_temp', v_tuv,
'tuv_delta_stats', v_tuv_stats,
'planning_config', coalesce(
(
select pc.config
from ems.planning_config pc
where pc.site_id = p_site_id
limit 1
),
'{}'::jsonb
)
);
end;
$fn$;
comment on function ems.fn_planning_site_context(int) is
'Kontext pro planning_engine / LP (bez samotného solveru).';

View File

@@ -0,0 +1,20 @@
-- začátek aktuálního (+offset) 15min slotu v Europe/Prague jako timestamptz (UTC instants)
create or replace function ems.fn_planning_slot_boundary_prague(p_offset_slots int default 0)
returns timestamptz
language sql
stable
as $fn$
select (
(date_trunc('day', loc.ts)
+ make_interval(
hours => extract(hour from loc.ts)::int,
mins => (floor(extract(minute from loc.ts) / 15) * 15)::int
)
)::timestamp at time zone 'Europe/Prague'
) + make_interval(mins => coalesce(p_offset_slots, 0) * 15)
from (select now() at time zone 'Europe/Prague' as ts) loc;
$fn$;
comment on function ems.fn_planning_slot_boundary_prague(int) is
'Začátek 15min slotu v časové zóně site provozu (Europe/Prague floor); offset v násobcích 15 min.';

View File

@@ -0,0 +1,74 @@
-- korekční faktor FVE forecast vs telemetrie (stejná logika jako compute_correction_factor v Pythonu)
create or replace function ems.fn_pv_forecast_correction_factor(
p_site_id int,
p_window_start timestamptz,
p_window_end timestamptz,
p_min_clamp numeric default 0.5,
p_max_clamp numeric default 1.5
)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_actual numeric;
v_forecast numeric;
v_raw numeric;
v_factor numeric := 1.0;
v_clamped boolean := false;
begin
select coalesce(sum(ti.pv_power_w) * 0.25 / 1000.0, 0)
into v_actual
from ems.telemetry_inverter ti
where ti.site_id = p_site_id
and ti.measured_at >= p_window_start
and ti.measured_at < p_window_end;
select coalesce(sum(fpi.power_w) * 0.25 / 1000.0, 0)
into v_forecast
from ems.forecast_pv_interval fpi
join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
where fpr.site_id = p_site_id
and fpi.interval_start >= p_window_start
and fpi.interval_start < p_window_end
and fpr.status = 'ok'
and fpr.id = (
select fpr2.id
from ems.forecast_pv_run fpr2
where fpr2.site_id = p_site_id
and fpr2.status = 'ok'
and fpr2.created_at <= p_window_start
order by fpr2.created_at desc
limit 1
);
if v_forecast < 0.1 or coalesce(v_actual, 0) < 0.05 then
return jsonb_build_object(
'correction_factor', 1.0,
'raw_factor', null,
'clamped', false,
'reason', 'insufficient_data',
'actual_pv_wh', coalesce(v_actual, 0) * 1000,
'forecast_pv_wh', coalesce(v_forecast, 0) * 1000,
'window_start', p_window_start,
'window_end', p_window_end
);
end if;
v_raw := v_actual / nullif(v_forecast, 0);
v_factor := greatest(p_min_clamp, least(p_max_clamp, v_raw));
v_clamped := (v_factor <> v_raw);
return jsonb_build_object(
'correction_factor', v_factor,
'raw_factor', v_raw,
'clamped', v_clamped,
'reason', 'ok',
'actual_pv_wh', v_actual * 1000,
'forecast_pv_wh', v_forecast * 1000,
'window_start', p_window_start,
'window_end', p_window_end
);
end;
$fn$;

View File

@@ -0,0 +1,36 @@
-- jedno volání: předchozí režim, fn_set_mode, nový režim, site.code
create or replace function ems.fn_set_mode_with_context(
p_site_id int,
p_mode_code text,
p_activated_by text default 'system',
p_valid_until timestamptz default null,
p_notes text default null
)
returns jsonb
language plpgsql
as $fn$
declare
v_prev text;
v_out text;
v_code text;
begin
select mode_code into v_prev
from ems.site_operating_mode
where site_id = p_site_id;
v_out := ems.fn_set_mode(
p_site_id, p_mode_code, p_activated_by, p_valid_until, p_notes
);
select s.code into v_code
from ems.site s
where s.id = p_site_id;
return jsonb_build_object(
'previous_mode', v_prev,
'new_mode', v_out,
'site_code', v_code
);
end;
$fn$;

View File

@@ -0,0 +1,265 @@
create or replace function ems.fn_site_configuration(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select
case
when not exists (select 1 from ems.site s0 where s0.id = p_site_id) then null::jsonb
else jsonb_build_object(
'site',
(
select to_jsonb(x)
from (
select
s.id,
s.code,
s.name,
s.timezone,
s.latitude::float8 as latitude,
s.longitude::float8 as longitude,
s.active,
s.notes,
s.created_at
from ems.site s
where s.id = p_site_id
) x
),
'grid_connection',
(
select to_jsonb(g.*)
from ems.site_grid_connection g
where g.site_id = p_site_id
limit 1
),
'market_config',
(
select to_jsonb(m.*)
from ems.site_market_config m
where m.site_id = p_site_id
and m.valid_from <= now()
and (m.valid_to is null or m.valid_to > now())
order by m.valid_from desc
limit 1
),
'market_config_note',
'Zelený bonus za výrobu je u FVE polí (asset_pv_array), ne v obchodní konfiguraci.',
'endpoints',
coalesce(
(
select jsonb_agg(
to_jsonb(e.*)
|| jsonb_build_object(
'auth_reference',
case
when e.auth_reference is null or btrim(e.auth_reference::text) = '' then null::text
when length(btrim(e.auth_reference::text)) <= 4 then 'nastaveno'::text
else concat('', right(btrim(e.auth_reference::text), 2))
end
)
order by e.id
)
from ems.site_endpoint e
where e.site_id = p_site_id
),
'[]'::jsonb
),
'inverters',
coalesce(
(
select jsonb_agg(
(
(
to_jsonb(ai.*)
- 'deye_last_system_time_sync_at'::text
- 'deye_last_system_time_sync_minute'::text
- 'deye_last_tou_inactive_write_prague_date'::text
- 'deye_tou_inactive_signature'::text
)
|| jsonb_build_object(
'endpoint_connection',
(
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
),
'deye_meta',
case
when jsonb_strip_nulls(
jsonb_build_object(
'deye_last_system_time_sync_at', to_jsonb(ai.deye_last_system_time_sync_at),
'deye_last_system_time_sync_minute', to_jsonb(ai.deye_last_system_time_sync_minute),
'deye_last_tou_inactive_write_prague_date',
to_jsonb(ai.deye_last_tou_inactive_write_prague_date),
'deye_tou_inactive_signature', to_jsonb(ai.deye_tou_inactive_signature)
)
) = '{}'::jsonb then null::jsonb
else jsonb_strip_nulls(
jsonb_build_object(
'deye_last_system_time_sync_at', to_jsonb(ai.deye_last_system_time_sync_at),
'deye_last_system_time_sync_minute', to_jsonb(ai.deye_last_system_time_sync_minute),
'deye_last_tou_inactive_write_prague_date',
to_jsonb(ai.deye_last_tou_inactive_write_prague_date),
'deye_tou_inactive_signature', to_jsonb(ai.deye_tou_inactive_signature)
)
)
end
)
)
order by ai.id
)
from ems.asset_inverter ai
where ai.site_id = p_site_id
),
'[]'::jsonb
),
'batteries',
coalesce(
(
select jsonb_agg(to_jsonb(b.*) order by b.id)
from ems.asset_battery b
where b.site_id = p_site_id
),
'[]'::jsonb
),
'pv_arrays',
coalesce(
(
select jsonb_agg(to_jsonb(p.*) order by p.id)
from ems.asset_pv_array p
where p.site_id = p_site_id
),
'[]'::jsonb
),
'ev_chargers',
coalesce(
(
select jsonb_agg(
to_jsonb(ec.*)
|| jsonb_build_object(
'endpoint_connection',
se.host || case
when se.port is not null then ':' || se.port::text
else ''
end
)
order by ec.id
)
from ems.asset_ev_charger ec
left join ems.site_endpoint se on se.id = ec.endpoint_id
where ec.site_id = p_site_id
),
'[]'::jsonb
),
'vehicles',
coalesce(
(
select jsonb_agg(
to_jsonb(v.*)
|| jsonb_build_object(
'api_reference',
case
when v.api_reference is null or btrim(v.api_reference::text) = '' then null::text
when length(btrim(v.api_reference::text)) <= 4 then 'nastaveno'::text
else concat('', right(btrim(v.api_reference::text), 2))
end
)
order by v.code
)
from ems.asset_vehicle v
where v.site_id = p_site_id
),
'[]'::jsonb
),
'heat_pumps',
coalesce(
(
select jsonb_agg(
to_jsonb(hp.*)
|| jsonb_build_object(
'endpoint_connection',
se.host || case
when se.port is not null then ':' || se.port::text
else ''
end
)
order by hp.id
)
from ems.asset_heat_pump hp
left join ems.site_endpoint se on se.id = hp.endpoint_id
where hp.site_id = p_site_id
),
'[]'::jsonb
),
'operating_mode',
(
select to_jsonb(om)
from (
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 = p_site_id
) om
),
'active_overrides',
coalesce(
(
select jsonb_agg(to_jsonb(o.*) order by o.valid_from desc)
from (
select *
from ems.site_override so
where so.site_id = p_site_id
and so.valid_from <= now()
and (so.valid_to is null or so.valid_to > now())
order by so.valid_from desc
limit 50
) o
),
'[]'::jsonb
),
'operational',
jsonb_build_object(
'heartbeat_last_seen',
(select hb.last_seen from ems.site_heartbeat hb where hb.site_id = p_site_id limit 1),
'heartbeat_status',
(select hb.status from ems.site_heartbeat hb where hb.site_id = p_site_id limit 1),
'has_active_plan',
exists (
select 1
from ems.planning_run pr
where pr.site_id = p_site_id
and pr.status = 'active'
),
'active_plan_created_at',
(
select pr.created_at
from ems.planning_run pr
where pr.site_id = p_site_id
and pr.status = 'active'
order by pr.created_at desc
limit 1
)
)
)
end;
$fn$;
comment on function ems.fn_site_configuration(int) is
'GET /configuration jako jeden JSON bundle (maskované reference, deye_meta).';

View File

@@ -0,0 +1,28 @@
create or replace function ems.fn_site_effective_prices_day_prague(
p_site_id int,
p_day date
)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(u.j order by u.interval_start),
'[]'::jsonb
)
from (
select
t.interval_start,
to_jsonb(t) as j
from (
select v.*
from ems.vw_site_effective_price v
where v.site_id = p_site_id
and (v.interval_start at time zone 'Europe/Prague')::date = p_day
order by v.interval_start
) t
) u;
$fn$;
comment on function ems.fn_site_effective_prices_day_prague(int, date) is
'Efektivní ceny pro kalendářní den v TZ Praha jako pole JSON řádků view.';

View File

@@ -0,0 +1,158 @@
create or replace function ems.fn_site_full_status(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select
case
when not exists (select 1 from ems.site s0 where s0.id = p_site_id) then
jsonb_build_object('error', 'not_found')
else jsonb_build_object(
'site',
(
select jsonb_build_object(
'id', s.id,
'code', s.code,
'name', s.name,
'timezone', s.timezone
)
from ems.site s
where s.id = p_site_id
),
'operating_mode',
(
select jsonb_build_object(
'mode_code', m.mode_code,
'mode_name', d.name,
'activated_at', m.activated_at,
'activated_by', 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 = p_site_id
),
'heartbeat',
(
select jsonb_build_object(
'last_seen', hb.last_seen,
'status', hb.status
)
from ems.site_heartbeat hb
where hb.site_id = p_site_id
),
'inverter_latest',
(
select to_jsonb(li.*)
from ems.vw_latest_inverter li
where li.site_id = p_site_id
order by li.measured_at desc nulls last
limit 1
),
'ev_chargers',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'code', v.code,
'status', v.status,
'power_w', v.power_w,
'measured_at', v.measured_at
)
order by v.measured_at desc nulls last
)
from (
select distinct on (evc.charger_id)
evc.charger_code as code,
evc.status,
evc.power_w,
evc.measured_at
from ems.vw_latest_ev_charger evc
where evc.site_id = p_site_id
order by evc.charger_id, evc.measured_at desc nulls last
) v
),
'[]'::jsonb
),
'heat_pump_latest',
(
select jsonb_build_object(
'power_w', hp.power_w,
'tuv_tank_temp_c', hp.tuv_tank_temp_c,
'measured_at', hp.measured_at
)
from ems.vw_latest_heat_pump hp
where hp.site_id = p_site_id
order by hp.measured_at desc nulls last
limit 1
),
'battery_limits',
(
select jsonb_build_object(
'reserve_soc', min(ab.reserve_soc_percent)::float,
'min_soc', min(ab.min_soc_percent)::float
)
from ems.asset_battery ab
where ab.site_id = p_site_id
),
'active_plan',
(
select jsonb_build_object(
'id', pr.id,
'created_at', pr.created_at
)
from ems.planning_run pr
where pr.site_id = p_site_id
and pr.status = 'active'
order by pr.created_at desc
limit 1
),
'planning_intervals',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'interval_start', pi.interval_start,
'battery_setpoint_w', pi.battery_setpoint_w,
'load_baseline_w', pi.load_baseline_w,
'pv_a_forecast_raw_w', pi.pv_a_forecast_raw_w,
'pv_b_forecast_raw_w', pi.pv_b_forecast_raw_w,
'pv_a_forecast_solver_w', pi.pv_a_forecast_solver_w,
'pv_b_forecast_solver_w', pi.pv_b_forecast_solver_w
)
order by pi.interval_start
)
from ems.planning_interval pi
where pi.run_id = (
select pr2.id
from ems.planning_run pr2
where pr2.site_id = p_site_id
and pr2.status = 'active'
order by pr2.created_at desc
limit 1
)
),
'[]'::jsonb
),
'tomorrow_price_slot_count',
(
select count(*)::int
from ems.vw_site_effective_price vep
where vep.site_id = p_site_id
and (vep.interval_start at time zone coalesce(
nullif(trim((select s2.timezone from ems.site s2 where s2.id = p_site_id)), ''),
'Europe/Prague'
))::date = (
(
current_timestamp at time zone coalesce(
nullif(trim((select s3.timezone from ems.site s3 where s3.id = p_site_id)), ''),
'Europe/Prague'
)
)::date + 1
)
)
)
end;
$fn$;
comment on function ems.fn_site_full_status(int) is
'Raw data pro GET /status/full (věk telemetrie a alerty dopočítá Python).';

View File

@@ -0,0 +1,138 @@
create or replace function ems.fn_site_notifications_context(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select
case
when not exists (select 1 from ems.site s0 where s0.id = p_site_id) then
jsonb_build_object('error', 'not_found')
else jsonb_build_object(
'timezone',
coalesce(
nullif(trim((select s.timezone from ems.site s where s.id = p_site_id)), ''),
'Europe/Prague'
),
'mode_code',
(select m.mode_code from ems.site_operating_mode m where m.site_id = p_site_id),
'has_plan',
exists (
select 1
from ems.planning_run pr
where pr.site_id = p_site_id
and pr.status = 'active'
),
'tomorrow_slots',
(
select count(*)::int
from ems.vw_site_effective_price v
where v.site_id = p_site_id
and (v.interval_start at time zone coalesce(
nullif(trim((select s2.timezone from ems.site s2 where s2.id = p_site_id)), ''),
'Europe/Prague'
))::date = (
(
current_timestamp at time zone coalesce(
nullif(trim((select s3.timezone from ems.site s3 where s3.id = p_site_id)), ''),
'Europe/Prague'
)
)::date + 1
)
),
'reserve_soc',
(select min(ab.reserve_soc_percent)::float from ems.asset_battery ab where ab.site_id = p_site_id),
'min_soc',
(select min(ab.min_soc_percent)::float from ems.asset_battery ab where ab.site_id = p_site_id),
'soc_pct',
(select li.battery_soc_percent::float from ems.vw_latest_inverter li where li.site_id = p_site_id order by li.measured_at desc nulls last limit 1),
'inv_measured_at',
(select li.measured_at from ems.vw_latest_inverter li where li.site_id = p_site_id order by li.measured_at desc nulls last limit 1),
'hb_last_seen',
(select hb.last_seen from ems.site_heartbeat hb where hb.site_id = p_site_id limit 1),
'price_slots',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'interval_start', v.interval_start,
'effective_buy_price_czk_kwh', v.effective_buy_price_czk_kwh,
'effective_sell_price_czk_kwh', v.effective_sell_price_czk_kwh
)
order by v.interval_start
)
from ems.vw_site_effective_price v
where v.site_id = p_site_id
and v.interval_start >= now()
and v.interval_start < now() + interval '48 hours'
),
'[]'::jsonb
),
'avg_buy',
(
select avg(v.effective_buy_price_czk_kwh)::float
from ems.vw_site_effective_price v
where v.site_id = p_site_id
and v.interval_start::date in (current_date, current_date + 1)
),
'usable_wh',
(
select coalesce(sum(ab.usable_capacity_wh), 0)::float
from ems.asset_battery ab
join ems.asset_inverter ai on ai.id = ab.inverter_id
where ai.site_id = p_site_id
),
'ev_sessions',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'id', es.id,
'charger_id', es.charger_id,
'energy_delivered_wh', es.energy_delivered_wh,
'target_soc_pct', es.target_soc_pct,
'session_start', es.session_start,
'soc_at_connect_pct', es.soc_at_connect_pct,
'battery_capacity_kwh', coalesce(av_id.battery_capacity_kwh, av_def.battery_capacity_kwh),
'make', coalesce(av_id.make, av_def.make),
'model', coalesce(av_id.model, av_def.model),
'default_target_soc_pct', coalesce(av_id.default_target_soc_pct, av_def.default_target_soc_pct),
'charger_code', ac.code
)
order by es.id
)
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 = p_site_id
and es.session_end is null
),
'[]'::jsonb
),
'neg_windows',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'predicted_date', p.predicted_date,
'window_start_hour', p.window_start_hour,
'window_end_hour', p.window_end_hour,
'probability_pct', p.probability_pct
)
order by p.predicted_date, p.window_start_hour
)
from ems.predicted_negative_price_window p
where p.site_id = p_site_id
and p.predicted_date between current_date and current_date + 2
and p.probability_pct >= 50
),
'[]'::jsonb
)
)
end;
$fn$;
comment on function ems.fn_site_notifications_context(int) is
'Vstupy pro build_smart_notifications + infra pravidla (GET /notifications).';

View File

@@ -0,0 +1,35 @@
create or replace function ems.fn_telemetry_ev_charger_sample(
p_site_id int,
p_charger_id int,
p_measured_at timestamptz,
p_connector_id int,
p_status text,
p_power_w int,
p_energy_kwh double precision
)
returns void
language sql
as $fn$
insert into ems.telemetry_ev_charger (
site_id,
charger_id,
measured_at,
connector_id,
status,
power_w,
energy_kwh
)
values (
p_site_id,
p_charger_id,
p_measured_at,
p_connector_id,
p_status,
p_power_w,
p_energy_kwh
)
on conflict (charger_id, connector_id, measured_at) do nothing;
$fn$;
comment on function ems.fn_telemetry_ev_charger_sample is
'Insert telemetrie nabíječky EV (placeholder Modbus).';

View File

@@ -0,0 +1,38 @@
create or replace function ems.fn_telemetry_heat_pump_sample(
p_site_id int,
p_heat_pump_id int,
p_measured_at timestamptz,
p_power_w int,
p_outdoor_temp_c double precision,
p_water_outlet_temp_c double precision,
p_tuv_tank_temp_c double precision,
p_operating_mode text
)
returns void
language sql
as $fn$
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 (
p_site_id,
p_heat_pump_id,
p_measured_at,
p_power_w,
p_outdoor_temp_c,
p_water_outlet_temp_c,
p_tuv_tank_temp_c,
p_operating_mode
)
on conflict (heat_pump_id, measured_at) do nothing;
$fn$;
comment on function ems.fn_telemetry_heat_pump_sample is
'Insert telemetrie TČ (placeholder Modbus).';

View File

@@ -0,0 +1,62 @@
create or replace function ems.fn_telemetry_inverter_sample(
p_site_id int,
p_inverter_id int,
p_measured_at timestamptz,
p_pv_power_w int,
p_pv1_power_w int,
p_pv2_power_w int,
p_gen_port_power_w int,
p_battery_soc_percent double precision,
p_battery_power_w int,
p_batt_charge_today_wh int,
p_batt_discharge_today_wh int,
p_grid_power_w int,
p_load_power_w int,
p_grid_import_total_wh bigint,
p_grid_export_total_wh bigint,
p_run_state int
)
returns void
language sql
as $fn$
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 (
p_site_id,
p_inverter_id,
p_measured_at,
p_pv_power_w,
p_pv1_power_w,
p_pv2_power_w,
p_gen_port_power_w,
p_battery_soc_percent,
p_battery_power_w,
p_batt_charge_today_wh,
p_batt_discharge_today_wh,
p_grid_power_w,
p_load_power_w,
p_grid_import_total_wh,
p_grid_export_total_wh,
p_run_state
)
on conflict (inverter_id, measured_at) do nothing;
$fn$;
comment on function ems.fn_telemetry_inverter_sample is
'Insert jednoho vzorku telemetrie střídače (telemetry_collector).';

View File

@@ -0,0 +1,17 @@
drop view if exists ems.vw_asset_ev_charger_modbus_poll;
create view ems.vw_asset_ev_charger_modbus_poll as
select
ec.site_id,
ec.id as charger_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 se.enabled = true
and se.endpoint_type = 'modbus_tcp';
comment on view ems.vw_asset_ev_charger_modbus_poll is
'Nabíječky EV s Modbus TCP pro telemetry_collector.';

View File

@@ -0,0 +1,17 @@
drop view if exists ems.vw_asset_heat_pump_modbus_poll;
create view ems.vw_asset_heat_pump_modbus_poll as
select
hp.site_id,
hp.id as heat_pump_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 se.enabled = true
and se.endpoint_type = 'modbus_tcp';
comment on view ems.vw_asset_heat_pump_modbus_poll is
'TČ s Modbus TCP pro telemetry_collector.';

View File

@@ -0,0 +1,18 @@
drop view if exists ems.vw_asset_inverter_modbus_poll;
create view ems.vw_asset_inverter_modbus_poll as
select
ai.site_id,
ai.id as inverter_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.active = true
and se.enabled = true
and se.endpoint_type = 'modbus_tcp';
comment on view ems.vw_asset_inverter_modbus_poll is
'Aktivní střídače s Modbus TCP endpointem pro telemetry_collector.';

View File

@@ -0,0 +1,23 @@
-- denní součet |battery_power_w|/60 Wh → poměr k 2× usable (orientační cykly/den)
create or replace view ems.vw_battery_cycle_daily as
select
ti.site_id,
(ti.measured_at at time zone 'Europe/Prague')::date as day_prague,
sum(abs(ti.battery_power_w::numeric) / 60.0) as throughput_wh,
max(ab.usable_wh) as usable_wh,
round(
(sum(abs(ti.battery_power_w::numeric) / 60.0)
/ nullif(max(ab.usable_wh) * 2, 0))::numeric,
4
) as equiv_full_cycles
from ems.telemetry_inverter ti
cross join lateral (
select usable_capacity_wh::numeric as usable_wh
from ems.asset_battery b
where b.site_id = ti.site_id
order by b.id
limit 1
) ab
where ti.battery_power_w is not null
group by ti.site_id, (ti.measured_at at time zone 'Europe/Prague')::date;

View File

@@ -0,0 +1,21 @@
-- poslední úspěšně ověřený zápis per (site, asset, register)
drop view if exists ems.vw_modbus_last_verified;
create view ems.vw_modbus_last_verified as
select distinct on (mc.site_id, mc.asset_type, mc.asset_id, mc.register)
mc.id,
mc.site_id,
mc.asset_type,
mc.asset_id,
mc.register,
mc.value_verified,
mc.verified_at,
mc.status
from ems.modbus_command mc
where mc.status = 'verified'
and mc.value_verified is not null
order by mc.site_id, mc.asset_type, mc.asset_id, mc.register, mc.verified_at desc nulls last, mc.id desc;
comment on view ems.vw_modbus_last_verified is
'DISTINCT ON (register) poslední verified řádek pro ověření / mapu registrů.';

View File

@@ -0,0 +1,19 @@
-- tenké čtení lokality pro API (Phase 1 místo select z ems.site v routerech)
drop view if exists ems.vw_site_directory;
create view ems.vw_site_directory as
select
s.id,
s.code,
s.name,
s.timezone,
s.latitude,
s.longitude,
s.active,
s.notes,
s.created_at
from ems.site s;
comment on view ems.vw_site_directory is
'Veřejná metadata lokality pro GET seznamů; čtení místo přímého dotazu na ems.site.';

View File

@@ -46,6 +46,10 @@
---
## Read-model JSONB (`ems.fn_*`)
FastAPI endpointy pro dashboard a konfiguraci preferují **jedno volání** `select ems.fn_*(…)` vracející **jsonb** (pole řádků, agregace, merge locků), aby v Pythonu nezůstávaly ad-hoc `SELECT`/`JOIN`/`WITH`. Pomocník `app.db_json.fetch_json` vrací `dict`/`list`. Telemetrie a IO zůstávají v Pythonu; čisté agregace a sjednocení TZ patří do SQL. Opakované migrace: `db/routines/R__fn_*.sql`, `db/views/R__vw_*.sql`.
## Komponenty
| Komponenta | Technologie | Port | Popis |
@@ -77,7 +81,7 @@ ems-platform/
R__fn_cop_estimate.sql
R__fn_baseline_consumption.sql
R__fn_fill_audit_interval.sql
R__fn_plan_day.sql
(historicky) R__fn_plan_day.sql primární plánování je PuLP v Pythonu
R__fn_create_planning_run.sql
views/
R__vw_site_effective_price.sql

View File

@@ -0,0 +1,46 @@
# Refaktor EMS slabá místa a hranice SQL vs Python
Dokument z code review před SQL-first refaktorem. Cíl produktu: maximalizovat ekonomický zisk při rozumném využití zdrojů a **bez** nadměrného cyklování baterie.
## Ekonomický cíl a cyklování
### Penalizace cyklu v LP
- V `docs/04-modules/planning.md` je uvedena symetrická penalizace cyklu (`0.5*(charge+discharge)` v textu účelové funkce).
- V `solve_dispatch` je v kódu **`0.5 * (bc[t] + bd[t]) * degradation_cost_effective * interval_h / 1000`** s dokumentací souhlasí.
### Další páky proti cyklování
- **`degradation_cost_czk_kwh`** na `asset_battery` jediná explicitní ekonomická cena cyklu v objective.
- **Slot pre-selection** (`charge_slot_buffer`, `discharge_slot_buffer`) omezuje množinu slotů pro nabíjení z sítě / vybíjení na export; snižuje mikro-cyklování.
- **Dynamická arbitrážní podlaha** (`_dynamic_arb_floor_wh_series`, `ARB_LOOKAHEAD_SLOTS = 32` = 8 h) posouvá ekonomickou podlahu mezi `min_soc_wh` a rezervou podle očekávané FVE energie vpřed; lookahead je **hard-coded** v Pythonu kandidát na přesun do DB (`planning_config`).
- **`pv_scarcity_factor`** (0.651.0) mění násobek degradace podle poměru očekávané FVE energie k kapacitě baterie; **úzký rozsah** = slabý signál; možné rozšíření v budoucnu.
- **Horizont a váhy slotů** (`SLOT_WEIGHT_FULL/MEDIUM/LOW` = 1.0 / 0.7 / 0.4) hard-coded; vzdálenější sloty mají menší váhu v objective → konzervativnější chování k predikovaným cenám.
### Zelený bonus (pole B)
- Záměrně **není** v účelové funkci LP bonus se účtuje v auditu (`fn_fill_audit_interval`). Solver neomezuje „výtěžek“ bonusu; riziko přeladění LP je větší než přínos.
### Audit cyklování (telemetrie)
- `fn_battery_cycle_audit` + `vw_battery_cycle_daily` ekvivalent plných cyklů z `telemetry_inverter` pro monitoring a ladění `degradation_cost_czk_kwh`, **ne** nový hard constraint v LP.
## SQL vs Python (stav před refaktorem)
| Oblast | Problém |
|--------|---------|
| `planning_engine` | Velké inline `SELECT` (`_load_slots`, `_load_site_context`), `compute_correction_factor`, `_save_planning_run`, f-string CTE pro slot boundary |
| `control_exporter` | `DISTINCT ON` journal, interpolace SQL pro plán slotu, pack hodin/TOU v Pythonu |
| Routery | Mnoho po sobě jdoucích dotazů (`site_configuration`, `full_status`), running sumy v Pythonu (`economics`), split FVE A/B v Pythonu |
| `price_importer` | Mix `::date` vs den v `Europe/Prague` u statistik OTE |
## Cílová hranice po refaktoru
- **Python:** PuLP solver, orchestrace jobů, Modbus/HTTP/Discord, pvlib forecast.
- **PostgreSQL:** čtení/zápis dat přes `ems.fn_*` a `ems.vw_*`; read-modely jako JSONB bundles.
## Odkazy
- Plánování: `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`
- Architektura: `docs/02-architecture.md`
- Pravidla agenta: `CLAUDE.md` (sekce o `fn_*`)