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`. 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) ### Provozní režimy (operating_mode)
- Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**. - 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). | | `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. | | `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` | | 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` | | 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` | | 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` | | 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` | | 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) | | 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 from __future__ import annotations
import json
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
@@ -33,3 +34,17 @@ def record_to_dict(r: asyncpg.Record) -> dict[str, Any]:
else: else:
out[k] = str(v) out[k] = str(v)
return out 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 from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -13,7 +14,7 @@ from zoneinfo import ZoneInfo
import asyncpg import asyncpg
import httpx import httpx
from apscheduler.schedulers.asyncio import AsyncIOScheduler 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.deps import set_pg_pool
from app.routers.economics import router as economics_router from app.routers.economics import router as economics_router
from app.routers.energy_flows import router as energy_flows_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 def scheduled_heartbeat() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
try: try:
await send_heartbeat(site["id"], conn) await send_heartbeat(site["id"], conn)
@@ -99,7 +100,7 @@ async def lifespan(app: FastAPI):
async def scheduled_audit_filler() -> None: async def scheduled_audit_filler() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
try: try:
await fill_audit_for_completed_intervals(site["id"], conn) 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 def scheduled_forecast_accuracy() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
try: try:
n = await conn.fetchval( n = await conn.fetchval(
@@ -143,7 +144,7 @@ async def lifespan(app: FastAPI):
async def scheduled_control_export() -> None: async def scheduled_control_export() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
try: try:
await export_setpoints(site["id"], conn) 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). 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: 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: for site in sites:
try: try:
cmd_rows = await conn.fetch( cmd_rows = await conn.fetch(
@@ -182,7 +183,7 @@ async def lifespan(app: FastAPI):
async def scheduled_daily_plan() -> None: async def scheduled_daily_plan() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
site_id = int(site["id"]) site_id = int(site["id"])
try: try:
@@ -194,7 +195,7 @@ async def lifespan(app: FastAPI):
async def scheduled_rolling_replan() -> None: async def scheduled_rolling_replan() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
site_id = int(site["id"]) site_id = int(site["id"])
try: try:
@@ -206,7 +207,7 @@ async def lifespan(app: FastAPI):
async def scheduled_baseline_update() -> None: async def scheduled_baseline_update() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
try: try:
n = await conn.fetchval( n = await conn.fetchval(
@@ -225,7 +226,7 @@ async def lifespan(app: FastAPI):
async def scheduled_market_price_stats() -> None: async def scheduled_market_price_stats() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
try: try:
n = await conn.fetchval( n = await conn.fetchval(
@@ -244,7 +245,7 @@ async def lifespan(app: FastAPI):
async def scheduled_tuv_usage_stats() -> None: async def scheduled_tuv_usage_stats() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
try: try:
n = await conn.fetchval( n = await conn.fetchval(
@@ -263,7 +264,7 @@ async def lifespan(app: FastAPI):
async def scheduled_forecast_refresh() -> None: async def scheduled_forecast_refresh() -> None:
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
site_id = int(site["id"]) site_id = int(site["id"])
try: try:
@@ -303,7 +304,7 @@ async def lifespan(app: FastAPI):
async def _refresh_negative_price_predictions_all_active( async def _refresh_negative_price_predictions_all_active(
conn: asyncpg.Connection, conn: asyncpg.Connection,
) -> None: ) -> 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: for site in sites:
await _refresh_negative_price_predictions(conn, int(site["id"])) 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 from services.notification_service import notify_daily_economics
async with app.state.pg_pool.acquire() as conn: 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: for site in sites:
site_id = int(site["id"]) site_id = int(site["id"])
site_code = site["code"] 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: async with db.acquire() as conn:
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at select id, code, name, timezone, latitude, longitude, active, notes, created_at
FROM ems.site from ems.vw_site_directory
ORDER BY id order by id
""" """
) )
return [record_to_dict(r) for r in rows] 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) site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok: if not site_ok:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
rows = await conn.fetch( rows = await fetch_json(
""" conn,
SELECT * "select ems.fn_site_effective_prices_day_prague($1::int, $2::date)",
FROM ems.vw_site_effective_price
WHERE site_id = $1 AND interval_start::date = $2::date
ORDER BY interval_start
""",
site_id, site_id,
d, 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): class PricesImportResponse(BaseModel):
@@ -656,7 +655,7 @@ async def post_import_site_prices(
conn, site_id=None, target_date=target conn, site_id=None, target_date=target
) )
if n >= 0: 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: for site in sites:
await _refresh_negative_price_predictions(conn, int(site["id"])) await _refresh_negative_price_predictions(conn, int(site["id"]))
if n < 0: 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) site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok: if not site_ok:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
ndays = await conn.fetchval( bundle = await fetch_json(
""" conn,
SELECT COUNT(DISTINCT (interval_start AT TIME ZONE 'Europe/Prague')::date)::int "select ems.fn_negative_price_predictions($1::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
""",
site_id, 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] = [] predictions: list[NegPricePredictionItem] = []
for r in rows: for r in rows:
em = r["expected_min_price"] if not isinstance(r, dict):
pd = r["predicted_date"] continue
em = r.get("expected_min_price")
pd = r.get("predicted_date")
predictions.append( predictions.append(
NegPricePredictionItem( NegPricePredictionItem(
predicted_date=pd.isoformat() if hasattr(pd, "isoformat") else str(pd), predicted_date=pd.isoformat() if hasattr(pd, "isoformat") else str(pd),
window_start_hour=int(r["window_start_hour"]), window_start_hour=int(r.get("window_start_hour") or 0),
window_end_hour=int(r["window_end_hour"]), window_end_hour=int(r.get("window_end_hour") or 0),
probability_pct=float(r["probability_pct"]), probability_pct=float(r.get("probability_pct") or 0),
expected_min_price=float(em) if em is not None else None, 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( return NegativePredictionsResponse(
predictions=predictions, 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) site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok: if not site_ok:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
row = await conn.fetchrow( row = await fetch_json(conn, "select ems.fn_latest_ote_day_stats()")
""" if not isinstance(row, dict):
SELECT row = json.loads(row)
(interval_start AT TIME ZONE 'Europe/Prague')::date AS day, day = row.get("latest_date")
COUNT(*)::int AS slots, if day is None:
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:
raise HTTPException(status_code=404, detail="Žádná tržní data v databázi") 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( return PricesLatestResponse(
latest_date=row["day"].isoformat(), latest_date=latest_date,
slots=int(row["slots"] or 0), slots=int(row.get("slots") or 0),
min_price=float(row["min_price"] or 0.0), min_price=float(row.get("min_price") or 0.0),
max_price=float(row["max_price"] or 0.0), max_price=float(row.get("max_price") or 0.0),
avg_price=float(row["avg_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") raise HTTPException(status_code=404, detail="Site not found")
lookback = timedelta(minutes=minutes) lookback = timedelta(minutes=minutes)
rows = await conn.fetch( id_json = await fetch_json(
""" conn,
SELECT id FROM ems.modbus_command "select ems.fn_modbus_written_command_ids($1::int, $2::interval)",
WHERE site_id = $1
AND status = 'written'
AND written_at >= now() - $2::interval
ORDER BY written_at
""",
site_id, site_id,
lookback, 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) checked = len(ids)
if ids: if ids:
await verify_modbus_commands(ids, conn, site_id) await verify_modbus_commands(ids, conn, site_id)
detail_rows = ( detail_json = (
await conn.fetch( await fetch_json(
""" conn,
SELECT id, asset_code, register_name, value_to_write, value_verified, status "select ems.fn_modbus_commands_by_ids($1::int[])",
FROM ems.modbus_command
WHERE id = ANY($1::int[])
ORDER BY id
""",
ids, ids,
) )
if ids if ids
else [] 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 = [ commands = [
ModbusCommandVerifyItem( ModbusCommandVerifyItem(
id=int(r["id"]), id=int(r["id"]),
asset_code=r["asset_code"], asset_code=str(r.get("asset_code") or ""),
register_name=r["register_name"], register_name=r.get("register_name"),
value_to_write=int(r["value_to_write"]), value_to_write=int(r["value_to_write"]),
value_verified=int(r["value_verified"]) value_verified=int(r["value_verified"])
if r["value_verified"] is not None if r.get("value_verified") is not None
else None, else None,
status=r["status"], status=str(r.get("status") or ""),
) )
for r in detail_rows for r in detail_rows
if isinstance(r, dict)
] ]
verified = sum(1 for c in commands if c.status == "verified") verified = sum(1 for c in commands if c.status == "verified")
mismatch = sum(1 for c in commands if c.status == "mismatch") 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: if not site_ok:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
rows = await conn.fetch( rows = await fetch_json(
""" conn,
SELECT id, register, register_name, value_to_write, value_written, "select ems.fn_modbus_journal_list($1::int, $2::int)",
value_verified, status, attempt_count, created_at
FROM ems.modbus_command
WHERE site_id = $1
ORDER BY created_at DESC
LIMIT $2
""",
site_id, site_id,
limit, limit,
) )
if not isinstance(rows, list):
rows = json.loads(rows) if isinstance(rows, str) else []
cmds: list[ModbusJournalCommandRow] = [] cmds: list[ModbusJournalCommandRow] = []
for r in rows: for r in rows:
d = record_to_dict(r) d = r if isinstance(r, dict) else {}
ca = d["created_at"] ca = d["created_at"]
cmds.append( cmds.append(
ModbusJournalCommandRow( 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) site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok: if not site_ok:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
rows = await conn.fetch( split = await fetch_json(
""" conn,
SELECT run_id, pv_array_id, interval_start, power_w, "select ems.fn_forecast_pv_split($1::int, $2::date)",
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
""",
site_id, site_id,
d, d,
) )
if not isinstance(split, dict):
# pv_a = řiditelná pole (curtailment / Deye), pv_b = neřízená (GEN, …) — sloučí více orientací split = json.loads(split) if isinstance(split, str) else {}
pv_a: list[dict[str, Any]] = [] pv_a = split.get("pv_a") or []
pv_b: list[dict[str, Any]] = [] pv_b = split.get("pv_b") or []
for r in rows: if not isinstance(pv_a, list):
item = record_to_dict(r) pv_a = []
item.pop("controllable", None) if not isinstance(pv_b, list):
if r["controllable"]: pv_b = []
pv_a.append(item)
else:
pv_b.append(item)
return {"pv_a": pv_a, "pv_b": pv_b} return {"pv_a": pv_a, "pv_b": pv_b}

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from datetime import date, datetime from datetime import date, datetime
from typing import Annotated, Any from typing import Annotated, Any
@@ -10,6 +11,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from app.db_json import fetch_json
from app.deps import get_pg_pool from app.deps import get_pg_pool
router = APIRouter( 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") raise HTTPException(status_code=404, detail="Site not found")
async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool: def _parse_day(val: Any) -> date:
return bool( if isinstance(val, datetime):
await conn.fetchval( return val.date()
""" if isinstance(val, date):
SELECT EXISTS( return val
SELECT 1 FROM ems.asset_pv_array if isinstance(val, str):
WHERE site_id = $1 return date.fromisoformat(val[:10])
AND green_bonus_czk_kwh IS NOT NULL raise ValueError(val)
)
""",
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,
)
@router.get("/daily", response_model=DailyEconomicsResponse) @router.get("/daily", response_model=DailyEconomicsResponse)
@@ -179,41 +139,47 @@ async def get_economics_daily(
async with db.acquire() as conn: async with db.acquire() as conn:
await _check_site(conn, site_id) await _check_site(conn, site_id)
has_bonus = await _has_green_bonus(conn, site_id) raw = await fetch_json(
conn,
dyn_rows = await conn.fetch( "select ems.fn_economics_daily_month($1::int, $2::date, $3::date)",
"""
SELECT * FROM ems.vw_economics_daily
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
ORDER BY day_local
""",
site_id, site_id,
month_start, month_start,
month_end, month_end,
) )
if not isinstance(raw, dict):
lock_rows = await conn.fetch( raw = json.loads(raw)
""" days_in: list[Any] = list(raw.get("days") or [])
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}
days: list[DailyEconomics] = [] days: list[DailyEconomics] = []
for r in dyn_rows: for d in days_in:
d = r["day_local"] if not isinstance(d, dict):
lock = locks.get(d) continue
days.append(_daily_from_row(r, lock, is_locked=lock is not None)) days.append(
DailyEconomics(
return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus) 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]) @router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics])
@@ -270,52 +236,20 @@ async def lock_day(
) -> LockResponse: ) -> LockResponse:
async with db.acquire() as conn: async with db.acquire() as conn:
await _check_site(conn, site_id) await _check_site(conn, site_id)
raw = await fetch_json(
row = await conn.fetchrow( conn,
""" "select ems.fn_economics_lock_day($1::int, $2::date)",
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
""",
site_id, site_id,
day, day,
) )
if row is None: if not isinstance(raw, dict):
raw = json.loads(raw)
if raw.get("locked") is not True:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"No economics data for {day.isoformat()}", 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) return LockResponse(locked=True, day=day)
@@ -327,8 +261,9 @@ async def unlock_day(
) -> LockResponse: ) -> LockResponse:
async with db.acquire() as conn: async with db.acquire() as conn:
await _check_site(conn, site_id) await _check_site(conn, site_id)
await conn.execute( await fetch_json(
"DELETE FROM ems.audit_day_lock WHERE site_id = $1 AND day_local = $2", conn,
"select ems.fn_economics_unlock_day($1::int, $2::date)",
site_id, site_id,
day, day,
) )
@@ -357,61 +292,29 @@ async def get_monthly_chart(
async with db.acquire() as conn: async with db.acquire() as conn:
await _check_site(conn, site_id) await _check_site(conn, site_id)
arr = await fetch_json(
rows = await conn.fetch( conn,
""" "select ems.fn_economics_monthly_chart($1::int, $2::date, $3::date)",
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
""",
site_id, site_id,
month_start, month_start,
month_end, month_end,
) )
if not isinstance(arr, list):
lock_rows = await conn.fetch( arr = json.loads(arr) if isinstance(arr, str) else []
"""
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}
points: list[ChartDayPoint] = [] points: list[ChartDayPoint] = []
cum_balance = 0.0 for r in arr:
cum_grid = 0.0 if not isinstance(r, dict):
for r in rows: continue
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
points.append( points.append(
ChartDayPoint( ChartDayPoint(
day=d, day=_parse_day(r.get("day")),
daily_balance_czk=round(balance, 2), daily_balance_czk=float(r.get("daily_balance_czk") or 0),
daily_grid_balance_czk=round(grid_balance, 2), daily_grid_balance_czk=float(r.get("daily_grid_balance_czk") or 0),
daily_green_bonus_czk=round(green_bonus, 2), daily_green_bonus_czk=float(r.get("daily_green_bonus_czk") or 0),
daily_import_cost_czk=round(import_cost, 2), daily_import_cost_czk=float(r.get("daily_import_cost_czk") or 0),
daily_export_revenue_czk=round(export_revenue, 2), daily_export_revenue_czk=float(r.get("daily_export_revenue_czk") or 0),
cumulative_balance_czk=round(cum_balance, 2), cumulative_balance_czk=float(r.get("cumulative_balance_czk") or 0),
cumulative_grid_balance_czk=round(cum_grid, 2), cumulative_grid_balance_czk=float(r.get("cumulative_grid_balance_czk") or 0),
) )
) )
return points return points

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
from datetime import date from datetime import date
from typing import Annotated, Any from typing import Annotated, Any
@@ -9,6 +10,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from app.db_json import fetch_json
from app.deps import get_pg_pool from app.deps import get_pg_pool
router = APIRouter( router = APIRouter(
@@ -16,6 +18,7 @@ router = APIRouter(
tags=["energy-flows"], tags=["energy-flows"],
) )
class DailyEnergyFlows(BaseModel): class DailyEnergyFlows(BaseModel):
day: date day: date
interval_count: int interval_count: int
@@ -65,12 +68,6 @@ def _num(val: Any) -> float:
return float(val) 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: async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
ok = await conn.fetchval( ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id "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") raise HTTPException(status_code=404, detail="Site not found")
def _row_to_daily(r: Any) -> DailyEnergyFlows: def _parse_day(val: Any) -> date:
return DailyEnergyFlows( from datetime import datetime as _dt
day=r["day_local"],
interval_count=int(r["interval_count"] or 0), if isinstance(val, _dt):
pv_production_kwh=_num(r["pv_production_kwh"]), return val.date()
grid_import_kwh=_num(r["grid_import_kwh"]), if isinstance(val, date):
grid_export_kwh=_num(r["grid_export_kwh"]), return val
batt_charge_kwh=_num(r["batt_charge_kwh"]), if isinstance(val, str):
batt_discharge_kwh=_num(r["batt_discharge_kwh"]), return date.fromisoformat(val[:10])
load_kwh=_num(r["load_kwh"]), raise ValueError(val)
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"]),
)
@router.get("/daily", response_model=DailyEnergyFlowsResponse) @router.get("/daily", response_model=DailyEnergyFlowsResponse)
@@ -125,84 +110,44 @@ async def get_energy_flows_daily(
async with db.acquire() as conn: async with db.acquire() as conn:
await _check_site(conn, site_id) await _check_site(conn, site_id)
rows = await conn.fetch( raw = await fetch_json(
""" conn,
SELECT "select ems.fn_energy_flows_daily_month($1::int, $2::date, $3::date)",
(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
""",
site_id, site_id,
month_start, month_start,
month_end, month_end,
) )
if not isinstance(raw, dict):
return DailyEnergyFlowsResponse(days=[_row_to_daily(r) for r in rows]) 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]) @router.get("/daily/{day}/intervals", response_model=list[IntervalEnergyFlows])
@@ -213,48 +158,35 @@ async def get_energy_flows_intervals(
) -> list[IntervalEnergyFlows]: ) -> list[IntervalEnergyFlows]:
async with db.acquire() as conn: async with db.acquire() as conn:
await _check_site(conn, site_id) await _check_site(conn, site_id)
rows = await conn.fetch( rows = await fetch_json(
""" conn,
SELECT "select ems.fn_energy_flows_intervals_day($1::int, $2::date)",
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
""",
site_id, site_id,
day, day,
) )
if not isinstance(rows, list):
return [ 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( IntervalEnergyFlows(
interval_start=r["interval_start"].isoformat(), interval_start=ist if isinstance(ist, str) else str(ist),
pv_production_kwh=_wh_to_kwh(r["actual_pv_production_wh"]), pv_production_kwh=r.get("pv_production_kwh"),
grid_import_kwh=_wh_to_kwh(r["actual_grid_import_wh"]), grid_import_kwh=r.get("grid_import_kwh"),
grid_export_kwh=_wh_to_kwh(r["actual_grid_export_wh"]), grid_export_kwh=r.get("grid_export_kwh"),
batt_charge_kwh=_wh_to_kwh(r["actual_batt_charge_wh"]), batt_charge_kwh=r.get("batt_charge_kwh"),
batt_discharge_kwh=_wh_to_kwh(r["actual_batt_discharge_wh"]), batt_discharge_kwh=r.get("batt_discharge_kwh"),
load_kwh=_wh_to_kwh(r["actual_load_consumption_wh"]), load_kwh=r.get("load_kwh"),
pv_to_load_kwh=_wh_to_kwh(r["flow_pv_to_load_wh"]), pv_to_load_kwh=r.get("pv_to_load_kwh"),
pv_to_batt_kwh=_wh_to_kwh(r["flow_pv_to_batt_wh"]), pv_to_batt_kwh=r.get("pv_to_batt_kwh"),
pv_to_grid_kwh=_wh_to_kwh(r["flow_pv_to_grid_wh"]), pv_to_grid_kwh=r.get("pv_to_grid_kwh"),
batt_to_load_kwh=_wh_to_kwh(r["flow_batt_to_load_wh"]), batt_to_load_kwh=r.get("batt_to_load_kwh"),
batt_to_grid_kwh=_wh_to_kwh(r["flow_batt_to_grid_wh"]), batt_to_grid_kwh=r.get("batt_to_grid_kwh"),
grid_to_load_kwh=_wh_to_kwh(r["flow_grid_to_load_wh"]), grid_to_load_kwh=r.get("grid_to_load_kwh"),
grid_to_batt_kwh=_wh_to_kwh(r["flow_grid_to_batt_wh"]), 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 from __future__ import annotations
import json
from datetime import date, datetime from datetime import date, datetime
from typing import Annotated, Any from typing import Annotated, Any
@@ -9,7 +10,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, field_validator 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 from app.deps import get_pg_pool
router = APIRouter(prefix="/sites/{site_id}/ev", tags=["ev"]) 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)], pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
async with pool.acquire() as conn: 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: if not site_ok:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
rows = await conn.fetch( rows = await fetch_json(
""" conn,
SELECT es.id, es.charger_id, es.vehicle_id, "select ems.fn_ev_sessions_active($1::int)",
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
""",
site_id, 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) @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)], pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> EvSessionPatchResponse: ) -> EvSessionPatchResponse:
async with pool.acquire() as conn: 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: if not site_ok:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
row = await conn.fetchrow( patch = body.model_dump(exclude_unset=True)
""" raw = await fetch_json(
UPDATE ems.ev_session conn,
SET target_soc_pct = $1, target_deadline = $2 "select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
WHERE id = $3 AND site_id = $4
RETURNING id
""",
body.target_soc_pct,
body.target_deadline,
session_id,
site_id, 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") 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): class ArrivalHourItem(BaseModel):
@@ -114,65 +104,48 @@ async def get_ev_arrival_prediction(
site_id: int, site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> EvArrivalPredictionResponse: ) -> EvArrivalPredictionResponse:
"""Top hodiny příjezdu z ems.fn_ev_expected_arrival; při <5 session celkem insufficient_data."""
async with pool.acquire() as conn: async with pool.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) raw = await fetch_json(
if not site_ok: 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") 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] = {} chargers: dict[str, ChargerTomorrowArrival] = {}
for ch in chargers_rows: ch_raw = raw.get("chargers") or {}
code = str(ch["code"]) if isinstance(ch_raw, dict):
preds = await conn.fetch( for code, v in ch_raw.items():
"SELECT * FROM ems.fn_ev_expected_arrival($1, $2, $3::date)", if not isinstance(v, dict):
site_id, continue
ch["id"], tlist = v.get("tomorrow") or []
tomorrow_d, items: list[ArrivalHourItem] = []
) if isinstance(tlist, list):
chargers[code] = ChargerTomorrowArrival( for it in tlist:
tomorrow=[ if not isinstance(it, dict):
continue
items.append(
ArrivalHourItem( ArrivalHourItem(
hour=int(r["expected_hour"]), hour=int(it.get("hour") or 0),
confidence_pct=int(r["confidence_pct"]), confidence_pct=int(it.get("confidence_pct") or 0),
samples=int(r["sample_count"]), 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( return EvArrivalPredictionResponse(
insufficient_data=insufficient, insufficient_data=bool(raw.get("insufficient_data")),
tomorrow_date=tomorrow_d.isoformat(), tomorrow_date=td_s,
chargers=chargers, chargers=chargers,
) )

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -10,7 +11,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field 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.deps import get_pg_pool
from app.notifications_logic import ( from app.notifications_logic import (
EvSessionRow, EvSessionRow,
@@ -47,6 +48,16 @@ def _iso_utc(dt: datetime | None) -> str | None:
return dt.astimezone(timezone.utc).isoformat() 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: def _age_seconds(at: datetime | None) -> int | None:
if at is None: if at is None:
return None return None
@@ -81,174 +92,105 @@ async def get_site_status_full(
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> dict[str, Any]: ) -> dict[str, Any]:
async with pool.acquire() as conn: async with pool.acquire() as conn:
site = await conn.fetchrow( bundle = await fetch_json(
""" conn,
SELECT id, code, name, timezone "select ems.fn_site_full_status($1::int)",
FROM ems.site
WHERE id = $1
""",
site_id, 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") raise HTTPException(status_code=404, detail="Site not found")
tz = site["timezone"] or "Europe/Prague" site = bundle.get("site") or {}
mode_row = bundle.get("operating_mode") or {}
mode_row = await conn.fetchrow( hb_row = bundle.get("heartbeat") or {}
""" inv_row = bundle.get("inverter_latest")
SELECT m.mode_code, d.name AS mode_name, m.activated_at, m.activated_by if not isinstance(inv_row, dict):
FROM ems.site_operating_mode m inv_row = None
JOIN ems.operating_mode_def d ON d.code = m.mode_code ev_rows = bundle.get("ev_chargers") or []
WHERE m.site_id = $1 if not isinstance(ev_rows, list):
""", ev_rows = []
site_id, hp_row = bundle.get("heat_pump_latest")
) if not isinstance(hp_row, dict):
hp_row = None
hb_row = await conn.fetchrow( reserve_row = bundle.get("battery_limits") or {}
""" run_row = bundle.get("active_plan")
SELECT last_seen, status if not isinstance(run_row, dict):
FROM ems.site_heartbeat run_row = None
WHERE site_id = $1
""",
site_id,
)
inv_row = await conn.fetchrow(
"""
SELECT pv_power_w, battery_soc_percent, grid_power_w, measured_at
FROM ems.vw_latest_inverter
WHERE site_id = $1
ORDER BY measured_at DESC NULLS LAST
LIMIT 1
""",
site_id,
)
ev_rows = await conn.fetch(
"""
SELECT DISTINCT ON (charger_id)
charger_code AS code,
status,
power_w,
measured_at
FROM ems.vw_latest_ev_charger
WHERE site_id = $1
ORDER BY charger_id, measured_at DESC NULLS LAST
""",
site_id,
)
hp_row = await conn.fetchrow(
"""
SELECT power_w, tuv_tank_temp_c, measured_at
FROM ems.vw_latest_heat_pump
WHERE site_id = $1
ORDER BY measured_at DESC NULLS LAST
LIMIT 1
""",
site_id,
)
reserve_row = await conn.fetchrow(
"""
SELECT MIN(reserve_soc_percent)::float AS reserve_soc,
MIN(min_soc_percent)::float AS min_soc
FROM ems.asset_battery
WHERE site_id = $1
""",
site_id,
)
run_row = await conn.fetchrow(
"""
SELECT id, created_at
FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
intervals: list[dict[str, Any]] = [] intervals: list[dict[str, Any]] = []
if run_row: raw_iv = bundle.get("planning_intervals") or []
int_rows = await conn.fetch( if isinstance(raw_iv, list):
""" intervals = [x for x in raw_iv if isinstance(x, dict)]
SELECT interval_start, battery_setpoint_w,
load_baseline_w,
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
pv_a_forecast_solver_w, pv_b_forecast_solver_w
FROM ems.planning_interval
WHERE run_id = $1
ORDER BY interval_start
""",
run_row["id"],
)
intervals = [record_to_dict(r) for r in int_rows]
tomorrow_slots = await conn.fetchval( tomorrow_slots = int(bundle.get("tomorrow_price_slot_count") or 0)
"""
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)
now_utc = datetime.now(timezone.utc) 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) 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) inv_age = _age_seconds(inv_measured)
next_start, next_bat = _next_plan_interval(intervals, now_utc) next_start, next_bat = _next_plan_interval(intervals, now_utc)
ev_list: list[dict[str, Any]] = [] ev_list: list[dict[str, Any]] = []
for r in ev_rows: for r in ev_rows:
if not isinstance(r, dict):
continue
ev_list.append( ev_list.append(
{ {
"code": r["code"], "code": r.get("code"),
"status": r["status"], "status": r.get("status"),
"power_w": int(r["power_w"]) if r["power_w"] is not None else None, "power_w": int(r["power_w"]) if r.get("power_w") is not None else None,
} }
) )
telemetry: dict[str, Any] = { telemetry: dict[str, Any] = {
"inverter": { "inverter": {
"pv_power_w": int(inv_row["pv_power_w"]) if inv_row and inv_row["pv_power_w"] is not None else None, "pv_power_w": int(inv_row["pv_power_w"])
"battery_soc_pct": float(inv_row["battery_soc_percent"]) if inv_row and inv_row.get("pv_power_w") is not None
if inv_row and inv_row["battery_soc_percent"] 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, 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), "measured_at": _iso_utc(inv_measured),
"age_seconds": inv_age, "age_seconds": inv_age,
}, },
"ev_chargers": ev_list, "ev_chargers": ev_list,
"heat_pump": { "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"]) "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, 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 has_plan = run_row is not None
planning = { planning = {
"has_active_plan": has_plan, "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_interval_start": next_start,
"next_battery_setpoint_w": next_bat, "next_battery_setpoint_w": next_bat,
} }
mode_code = (mode_row["mode_code"] if mode_row else None) or "" 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["reserve_soc"] is not None else None reserve_soc = (
min_soc = float(reserve_row["min_soc"]) if reserve_row and reserve_row["min_soc"] is not None else None float(reserve_row["reserve_soc"])
soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None 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]] = [] 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"])) alerts.sort(key=lambda a: (0 if a["level"] == "error" else 1, a["message"]))
return { 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": { "operating_mode": {
"mode_code": mode_row["mode_code"] if mode_row else None, "mode_code": mode_row.get("mode_code") if mode_row else None,
"mode_name": mode_row["mode_name"] if mode_row else None, "mode_name": mode_row.get("mode_name") if mode_row else None,
"activated_at": _iso_utc(mode_row["activated_at"]) if mode_row else None, "activated_at": _iso_utc(mode_row.get("activated_at")) if mode_row else None,
"activated_by": mode_row["activated_by"] if mode_row else None, "activated_by": mode_row.get("activated_by") if mode_row else None,
}, },
"heartbeat": { "heartbeat": {
"last_seen": _iso_utc(hb_last), "last_seen": _iso_utc(hb_last),
"age_seconds": hb_age, "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, "telemetry": telemetry,
"planning": planning, "planning": planning,
@@ -395,156 +337,39 @@ async def get_site_notifications(
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> SiteNotificationsResponse: ) -> SiteNotificationsResponse:
async with pool.acquire() as conn: async with pool.acquire() as conn:
site = await conn.fetchrow( ctx = await fetch_json(
"SELECT id, timezone FROM ems.site WHERE id = $1", conn,
"select ems.fn_site_notifications_context($1::int)",
site_id, 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") raise HTTPException(status_code=404, detail="Site not found")
tz = site["timezone"] or "Europe/Prague"
mode_row = await conn.fetchrow( has_plan = bool(ctx.get("has_plan"))
""" mode_code = (ctx.get("mode_code") or "") or ""
SELECT m.mode_code reserve_soc = _float_or_none(ctx.get("reserve_soc"))
FROM ems.site_operating_mode m min_soc = _float_or_none(ctx.get("min_soc"))
WHERE m.site_id = $1 soc = _float_or_none(ctx.get("soc_pct"))
""", inv_age = _age_seconds(_parse_ts(ctx.get("inv_measured_at")))
site_id, hb_age = _age_seconds(_parse_ts(ctx.get("hb_last_seen")))
) tomorrow_slots = int(ctx.get("tomorrow_slots") or 0)
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,
)
price_rows = await conn.fetch( price_rows = ctx.get("price_slots") or []
""" if not isinstance(price_rows, list):
SELECT interval_start, price_rows = []
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,
)
avg_row = await conn.fetchrow( avg_buy = _float_or_none(ctx.get("avg_buy"))
""" usable_wh = _float_or_none(ctx.get("usable_wh"))
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,
)
bat_row = await conn.fetchrow( ev_rows = ctx.get("ev_sessions") or []
""" if not isinstance(ev_rows, list):
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float AS usable_wh ev_rows = []
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 = await conn.fetch( neg_rows = ctx.get("neg_windows") or []
""" if not isinstance(neg_rows, list):
SELECT DISTINCT ON (es.id) neg_rows = []
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)
infra = _infrastructure_notification_items( infra = _infrastructure_notification_items(
has_plan=has_plan, has_plan=has_plan,
@@ -559,11 +384,15 @@ async def get_site_notifications(
prices: list[PriceSlot] = [] prices: list[PriceSlot] = []
for r in price_rows: 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: if buy is None:
continue continue
sell_v = _float_or_none(r["effective_sell_price_czk_kwh"]) sell_v = _float_or_none(r.get("effective_sell_price_czk_kwh"))
istart = r["interval_start"] istart = r.get("interval_start")
if isinstance(istart, str):
istart = datetime.fromisoformat(istart.replace("Z", "+00:00"))
prices.append( prices.append(
PriceSlot( PriceSlot(
interval_start=istart, 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 battery_kwh = (usable_wh / 1000.0) if usable_wh is not None else None
ev_sessions: list[EvSessionRow] = [] ev_sessions: list[EvSessionRow] = []
for er in ev_rows: 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( ev_sessions.append(
EvSessionRow( EvSessionRow(
id=int(er["id"]), id=int(er["id"]),
charger_id=int(er["charger_id"]), charger_id=int(er["charger_id"]),
energy_delivered_wh=float(er["energy_delivered_wh"] or 0), energy_delivered_wh=float(er.get("energy_delivered_wh") or 0),
target_soc_pct=_float_or_none(er["target_soc_pct"]), target_soc_pct=_float_or_none(er.get("target_soc_pct")),
session_start=er["session_start"], session_start=ss,
battery_capacity_kwh=_float_or_none(er["battery_capacity_kwh"]), battery_capacity_kwh=_float_or_none(er.get("battery_capacity_kwh")),
make=er["make"], make=er.get("make"),
model=er["model"], model=er.get("model"),
default_target_soc_pct=_float_or_none(er["default_target_soc_pct"]), default_target_soc_pct=_float_or_none(er.get("default_target_soc_pct")),
charger_code=str(er["charger_code"] or ""), charger_code=str(er.get("charger_code") or ""),
soc_at_connect_pct=_float_or_none(er["soc_at_connect_pct"]), soc_at_connect_pct=_float_or_none(er.get("soc_at_connect_pct")),
) )
) )
neg_windows: list[NegWindowRow] = [] neg_windows: list[NegWindowRow] = []
for nr in neg_rows: for nr in neg_rows:
dr = nr["predicted_date"] if not isinstance(nr, dict):
continue
dr = nr.get("predicted_date")
if isinstance(dr, datetime): if isinstance(dr, datetime):
d_conv = dr.date() d_conv = dr.date()
elif isinstance(dr, date): elif isinstance(dr, date):
d_conv = dr d_conv = dr
elif isinstance(dr, str):
d_conv = date.fromisoformat(dr[:10])
else: else:
d_conv = date.today() d_conv = date.today()
neg_windows.append( neg_windows.append(
NegWindowRow( NegWindowRow(
predicted_date=d_conv, predicted_date=d_conv,
window_start_hour=int(nr["window_start_hour"]), window_start_hour=int(nr.get("window_start_hour") or 0),
window_end_hour=int(nr["window_end_hour"]), window_end_hour=int(nr.get("window_end_hour") or 0),
probability_pct=int(nr["probability_pct"]), 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.""" """REST API aktivní plán a ruční přepočet."""
import json
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
@@ -8,7 +9,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field 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 app.deps import get_pg_pool
from services.control_exporter import export_setpoints from services.control_exporter import export_setpoints
from services.planning_engine import run_plan_api from services.planning_engine import run_plan_api
@@ -46,131 +47,36 @@ class CurrentPlanResponseModel(BaseModel):
summary: dict[str, Any] 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) @router.get("/current", response_model=CurrentPlanResponseModel)
async def get_current_plan( async def get_current_plan(
site_id: int, site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> CurrentPlanResponseModel: ) -> CurrentPlanResponseModel:
async with pool.acquire() as conn: 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: if not site_ok:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
run_row = await conn.fetchrow( bundle = await fetch_json(
""" conn,
SELECT pr.* "select ems.fn_plan_current_bundle($1::int)",
FROM ems.planning_run pr
WHERE pr.site_id = $1 AND pr.status = 'active'
ORDER BY pr.created_at DESC
LIMIT 1
""",
site_id, 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") raise HTTPException(status_code=404, detail="No active plan")
run_id = run_row["id"] intervals_raw = bundle.get("intervals") or []
int_rows = await conn.fetch( if not isinstance(intervals_raw, list):
""" intervals_raw = []
WITH fc_slot AS ( intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
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]
return CurrentPlanResponseModel( return CurrentPlanResponseModel(
run=record_to_dict(run_row), run=bundle.get("run") or {},
intervals=intervals, 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"), plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
) -> RunPlanResponse: ) -> RunPlanResponse:
async with pool.acquire() as conn: 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: if not site_ok:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
days_with_prices = await conn.fetchval( days_with_prices = await conn.fetchval(
""" "select ems.fn_planning_future_price_days()",
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'
"""
) )
if (days_with_prices or 0) < 1: if (days_with_prices or 0) < 1:
raise HTTPException( raise HTTPException(
@@ -204,14 +106,10 @@ async def post_run_plan(
run_id, solver_duration_ms = await run_plan_api( run_id, solver_duration_ms = await run_plan_api(
site_id, plan_type, conn, triggered_by="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) await export_setpoints(site_id, conn)
row = await conn.fetchrow( row = await fetch_json(
""" conn,
SELECT horizon_start, horizon_end "select ems.fn_planning_run_horizon($1::int)",
FROM ems.planning_run
WHERE id = $1
""",
run_id, run_id,
) )
except HTTPException: except HTTPException:
@@ -224,7 +122,7 @@ async def post_run_plan(
logger.error("Plan run failed: %s", e, exc_info=True) logger.error("Plan run failed: %s", e, exc_info=True)
raise HTTPException(status_code=422, detail=str(e)) from e 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") raise HTTPException(status_code=500, detail="Planning run row missing after insert")
return RunPlanResponse( return RunPlanResponse(

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated, Any from typing import Annotated, Any
@@ -9,7 +10,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field 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.deps import get_pg_pool
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"]) 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.""" """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( 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( 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,
_DEYE_KEYS = frozenset( description="Jako u nabíjení",
{
"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: def _iso_utc_from_cfg(val: Any) -> str | None:
if raw is None: if val is None:
return None
s = str(raw).strip()
if not s:
return None
if len(s) <= 4:
return "nastaveno"
return f"{s[-2:]}"
def _iso_utc(dt: datetime | None) -> str | None:
if dt is None:
return None return None
if isinstance(val, str):
return val
if isinstance(val, datetime):
dt = val
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat() return dt.astimezone(timezone.utc).isoformat()
return str(val)
@router.get("/configuration") @router.get("/configuration")
@@ -60,204 +52,29 @@ async def get_site_configuration(
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> dict[str, Any]: ) -> dict[str, Any]:
async with pool.acquire() as conn: async with pool.acquire() as conn:
site_row = await conn.fetchrow( raw = await fetch_json(
""" conn,
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at "select ems.fn_site_configuration($1::int)",
FROM ems.site
WHERE id = $1
""",
site_id, site_id,
) )
if site_row is None: if raw is None:
raise HTTPException(status_code=404, detail="Site not found") raise HTTPException(status_code=404, detail="Site not found")
if not isinstance(raw, dict):
grid_row = await conn.fetchrow( raw = json.loads(raw)
"SELECT * FROM ems.site_grid_connection WHERE site_id = $1", op = raw.get("operational")
site_id, if isinstance(op, dict):
) op = dict(op)
market_row = await conn.fetchrow( 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"))
SELECT * raw["operational"] = op
FROM ems.site_market_config lat = raw.get("site", {}).get("latitude") if isinstance(raw.get("site"), dict) else None
WHERE site_id = $1 lon = raw.get("site", {}).get("longitude") if isinstance(raw.get("site"), dict) else None
AND valid_from <= now() if isinstance(raw.get("site"), dict):
AND (valid_to IS NULL OR valid_to > now()) site = dict(raw["site"])
ORDER BY valid_from DESC
LIMIT 1
""",
site_id,
)
endpoint_rows = await conn.fetch(
"""
SELECT id, site_id, endpoint_type, host, port, protocol, unit_id,
auth_reference, enabled, notes
FROM ems.site_endpoint
WHERE site_id = $1
ORDER BY id
""",
site_id,
)
endpoints: list[dict[str, Any]] = []
for er in endpoint_rows:
d = record_to_dict(er)
d["auth_reference"] = _mask_secret_reference(er["auth_reference"])
endpoints.append(d)
inv_rows = await conn.fetch(
"""
SELECT ai.*,
(SELECT ep.host || CASE
WHEN ep.port IS NOT NULL THEN ':' || ep.port::text
ELSE ''
END
FROM ems.site_endpoint ep
WHERE ep.id = ai.endpoint_id) AS endpoint_connection
FROM ems.asset_inverter ai
WHERE ai.site_id = $1
ORDER BY ai.id
""",
site_id,
)
inverters: list[dict[str, Any]] = []
for ir in inv_rows:
full = record_to_dict(ir)
ep_label = full.pop("endpoint_connection", None)
core = {k: v for k, v in full.items() if k not in _DEYE_KEYS}
deye_meta = {k: full[k] for k in _DEYE_KEYS if full.get(k) is not None}
core["endpoint_connection"] = ep_label
core["deye_meta"] = deye_meta if deye_meta else None
inverters.append(core)
bat_rows = await conn.fetch(
"SELECT * FROM ems.asset_battery WHERE site_id = $1 ORDER BY id",
site_id,
)
pv_rows = await conn.fetch(
"SELECT * FROM ems.asset_pv_array WHERE site_id = $1 ORDER BY id",
site_id,
)
ev_rows = await conn.fetch(
"""
SELECT ec.*,
se.host || CASE
WHEN se.port IS NOT NULL THEN ':' || se.port::text
ELSE ''
END AS endpoint_connection
FROM ems.asset_ev_charger ec
LEFT JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
WHERE ec.site_id = $1
ORDER BY ec.id
""",
site_id,
)
ev_chargers = [record_to_dict(r) for r in ev_rows]
veh_rows = await conn.fetch(
"""
SELECT id, site_id, code, name, make, model, battery_capacity_kwh,
max_charge_power_w, default_charger_id, api_type, api_reference,
default_target_soc_pct, default_deadline_hour, active
FROM ems.asset_vehicle
WHERE site_id = $1
ORDER BY code
""",
site_id,
)
vehicles: list[dict[str, Any]] = []
for vr in veh_rows:
d = record_to_dict(vr)
d["api_reference"] = _mask_secret_reference(vr["api_reference"])
vehicles.append(d)
hp_rows = await conn.fetch(
"""
SELECT hp.*,
se.host || CASE
WHEN se.port IS NOT NULL THEN ':' || se.port::text
ELSE ''
END AS endpoint_connection
FROM ems.asset_heat_pump hp
LEFT JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
WHERE hp.site_id = $1
ORDER BY hp.id
""",
site_id,
)
heat_pumps = [record_to_dict(r) for r in hp_rows]
mode_row = await conn.fetchrow(
"""
SELECT m.mode_code, m.activated_at, m.activated_by, m.valid_until,
m.previous_mode, m.notes,
d.name AS mode_name, d.description AS mode_description,
d.loxone_mode_value, d.ev_enabled, d.heat_pump_enabled,
d.battery_mode, d.grid_mode, d.is_autonomous
FROM ems.site_operating_mode m
JOIN ems.operating_mode_def d ON d.code = m.mode_code
WHERE m.site_id = $1
""",
site_id,
)
override_rows = await conn.fetch(
"""
SELECT id, override_type, value_json, valid_from, valid_to, reason, created_by, created_at
FROM ems.site_override
WHERE site_id = $1
AND valid_from <= now()
AND (valid_to IS NULL OR valid_to > now())
ORDER BY valid_from DESC
LIMIT 50
""",
site_id,
)
hb_row = await conn.fetchrow(
"SELECT last_seen, status FROM ems.site_heartbeat WHERE site_id = $1",
site_id,
)
run_row = await conn.fetchrow(
"""
SELECT id, created_at
FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
site = record_to_dict(site_row)
lat = site_row["latitude"]
lon = site_row["longitude"]
site["latitude"] = float(lat) if lat is not None else None site["latitude"] = float(lat) if lat is not None else None
site["longitude"] = float(lon) if lon is not None else None site["longitude"] = float(lon) if lon is not None else None
raw["site"] = site
operating_mode = record_to_dict(mode_row) if mode_row else None return raw
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,
},
}
@router.patch("/inverters/{inverter_id}/modbus-current-caps") @router.patch("/inverters/{inverter_id}/modbus-current-caps")
@@ -269,7 +86,6 @@ async def patch_inverter_modbus_current_caps(
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Nastavení `deye_register_max_charge_a` / `deye_register_max_discharge_a` na `ems.asset_inverter`. 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) updates = body.model_dump(exclude_unset=True)
if not updates: if not updates:
@@ -277,52 +93,29 @@ async def patch_inverter_modbus_current_caps(
status_code=400, status_code=400,
detail="Send at least one of: deye_register_max_charge_a, deye_register_max_discharge_a", detail="Send at least one of: deye_register_max_charge_a, deye_register_max_discharge_a",
) )
async with pool.acquire() as conn: patch: dict[str, Any] = {}
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
if "deye_register_max_charge_a" in updates: if "deye_register_max_charge_a" in updates:
sets.append(f"deye_register_max_charge_a = ${n}") patch["deye_register_max_charge_a"] = updates["deye_register_max_charge_a"]
args.append(updates["deye_register_max_charge_a"])
n += 1
if "deye_register_max_discharge_a" in updates: if "deye_register_max_discharge_a" in updates:
sets.append(f"deye_register_max_discharge_a = ${n}") patch["deye_register_max_discharge_a"] = updates["deye_register_max_discharge_a"]
args.append(updates["deye_register_max_discharge_a"])
n += 1
args.extend([inverter_id, site_id]) async with pool.acquire() as conn:
await conn.execute( raw = await fetch_json(
f""" conn,
UPDATE ems.asset_inverter "select ems.fn_inverter_modbus_caps_patch($1::int, $2::int, $3::jsonb)",
SET {", ".join(sets)}
WHERE id = ${n} AND site_id = ${n + 1}
""",
*args,
)
row = await conn.fetchrow(
"""
SELECT id, code, deye_register_max_charge_a, deye_register_max_discharge_a
FROM ems.asset_inverter
WHERE id = $1 AND site_id = $2
""",
inverter_id,
site_id, 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 { return {
"inverter_id": int(row["id"]), "inverter_id": int(raw["inverter_id"]),
"code": row["code"], "code": raw["code"],
"deye_register_max_charge_a": row["deye_register_max_charge_a"], "deye_register_max_charge_a": raw.get("deye_register_max_charge_a"),
"deye_register_max_discharge_a": row["deye_register_max_discharge_a"], "deye_register_max_discharge_a": raw.get("deye_register_max_discharge_a"),
} }

View File

@@ -3,51 +3,17 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def fill_audit_for_completed_intervals(site_id: int, db) -> None: async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
""" """
Naplní audit_interval pro všechny dokončené 15min intervaly Naplní audit_interval pro dokončené 15min sloty přes ems.fn_fill_audit_for_site_window.
za posledních 6 hodin které ještě nemají záznam.
Volá PostgreSQL funkci ems.fn_fill_audit_interval().
""" """
now = datetime.now(timezone.utc) n = await db.fetchval(
last_complete = now.replace( "select ems.fn_fill_audit_for_site_window($1::int, 6)",
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,
site_id, site_id,
) )
if n:
for row in rows: logger.info("[site=%s] Filled %s missing audit intervals", site_id, int(n))
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))

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 from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
from datetime import datetime from datetime import datetime
@@ -78,31 +79,26 @@ async def run_fn_set_mode_with_discord(
notify_level: str | None = None, notify_level: str | None = None,
) -> str: ) -> 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í. Vrátí aktuální mode_code z DB po volání.
""" """
prev = await conn.fetchval( raw = await conn.fetchval(
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1", """
site_id, select ems.fn_set_mode_with_context($1::int, $2::text, $3::text, $4::timestamptz, $5::text)
) """,
await conn.execute(
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
site_id, site_id,
mode_code, mode_code,
activated_by, activated_by,
valid_until, valid_until,
notes, notes,
) )
new = await conn.fetchval( ctx = raw if isinstance(raw, dict) else json.loads(raw)
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1", prev = ctx.get("previous_mode")
site_id, new = ctx.get("new_mode")
)
if new is None: if new is None:
new = mode_code new = mode_code
site_code = ctx.get("site_code")
if prev is not None and prev != new: 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( await notify_operating_mode_changed(
site_code or str(site_id), site_code or str(site_id),
str(prev), str(prev),

View File

@@ -7,6 +7,7 @@
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0) # scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15') # scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
import json
import time import time
import logging import logging
from dataclasses import dataclass, replace 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 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) # Datové třídy (lze nahradit pydantic modely)
# ============================================================ # ============================================================
@@ -265,6 +165,8 @@ class PlanningSlot:
ev1_connected: bool ev1_connected: bool
ev2_connected: bool ev2_connected: bool
is_predicted_price: bool = False is_predicted_price: bool = False
allow_charge: bool = True
allow_discharge_export: bool = True
@dataclass @dataclass
@@ -303,49 +205,31 @@ async def compute_correction_factor(
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný. factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
""" """
window_start = now - timedelta(hours=window_h) window_start = now - timedelta(hours=window_h)
raw = await db.fetchval(
# Skutečná výroba za okno (z telemetrie) """
actual = await db.fetchval(""" select ems.fn_pv_forecast_correction_factor(
SELECT COALESCE(SUM(pv_power_w) * 0.25 / 1000.0, 0) -- kWh $1::int, $2::timestamptz, $3::timestamptz,
FROM ems.telemetry_inverter $4::numeric, $5::numeric
WHERE site_id = $1
AND measured_at >= $2 AND measured_at < $3
""", site_id, window_start, now)
# Předpovídaná výroba za stejné okno (z nejnovějšího forecastu který platil tehdy)
forecast = await db.fetchval("""
SELECT COALESCE(SUM(fpi.power_w) * 0.25 / 1000.0, 0)
FROM ems.forecast_pv_interval fpi
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
WHERE fpr.site_id = $1
AND fpi.interval_start >= $2 AND fpi.interval_start < $3
AND fpr.status = 'ok'
AND fpr.created_at = (
SELECT MAX(fpr2.created_at)
FROM ems.forecast_pv_run fpr2
WHERE fpr2.site_id = $1 AND fpr2.status = 'ok'
AND fpr2.created_at <= $2
) )
""", site_id, window_start, now) """,
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 = { log_data = {
"window_start": window_start, "window_start": j.get("window_start", window_start),
"window_end": now, "window_end": j.get("window_end", now),
"actual_pv_wh": actual * 1000, "actual_pv_wh": j.get("actual_pv_wh"),
"forecast_pv_wh": forecast * 1000, "forecast_pv_wh": j.get("forecast_pv_wh"),
"correction_factor": factor,
"reason": j.get("reason", "ok"),
} }
if j.get("raw_factor") is not None:
# Pokud forecast nebo actual jsou příliš malé (noc, <0.1 kWh) → žádná korekce log_data["raw_factor"] = j["raw_factor"]
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
return factor, log_data return factor, log_data
@@ -559,10 +443,10 @@ def solve_dispatch(
if slots[t].is_predicted_price: if slots[t].is_predicted_price:
prob += ge[t] == 0 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": if om == "AUTO":
charge_slots = _select_charge_slots(slots, battery, current_soc_wh) charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
discharge_export_slots = _select_discharge_export_slots(slots, battery) discharge_export_slots = {t for t, s in enumerate(slots) if s.allow_discharge_export}
for t in range(T): for t in range(T):
if t not in charge_slots: if t not in charge_slots:
prob += bc[t] == 0 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}") 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) critical_slots = int(36 / INTERVAL_H)
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price) missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
price_failsafe_active = missing_ote_count > 0 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, 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( results, duration_ms = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats, tuv_delta_stats=tuv_stats,
@@ -750,17 +632,20 @@ async def run_rolling_replan(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
replan_from = _current_slot_start(now) replan_from = _current_slot_start(now)
active_run = await db.fetchrow(""" ar_raw = await db.fetchval(
SELECT id, horizon_end FROM ems.planning_run "select ems.fn_planning_active_run($1::int)",
WHERE site_id = $1 AND status = 'active' site_id,
ORDER BY created_at DESC LIMIT 1 )
""", site_id) ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw)
if ar.get("error") == "no_active_plan":
if not active_run:
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily 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) 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 (horizon_to - replan_from).total_seconds() < 1800:
if allow_skip: 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}") 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) await _load_site_context(site_id, db)
) )
correction_factor, correction_log = await compute_correction_factor(site_id, now, 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) slots_before_pv_correction = list(slots)
critical_slots = int(36 / INTERVAL_H) critical_slots = int(36 / INTERVAL_H)
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price) 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) slots = apply_forecast_correction(slots, now, correction_factor)
tuv_stats = await _load_tuv_usage_stats(site_id, db)
results, duration_ms = solve_dispatch( results, duration_ms = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats, tuv_delta_stats=tuv_stats,
@@ -818,10 +701,10 @@ async def run_rolling_replan(
await db.execute( await db.execute(
""" """
INSERT INTO ems.forecast_correction_log select ems.fn_forecast_correction_log_insert(
(site_id, window_start, window_end, actual_pv_wh, forecast_pv_wh, $1::int, $2::timestamptz, $3::timestamptz,
correction_factor, applied_to_run_id) $4::numeric, $5::numeric, $6::numeric, $7::int
VALUES ($1,$2,$3,$4,$5,$6,$7) )
""", """,
site_id, site_id,
correction_log["window_start"], correction_log["window_start"],
@@ -870,184 +753,86 @@ def _current_slot_start(dt: datetime) -> datetime:
return dt.replace(minute=minute, second=0, microsecond=0) return dt.replace(minute=minute, second=0, microsecond=0)
def _ev_session_ctx(row) -> Optional[SimpleNamespace]: def _parse_json_dt(val: object) -> Optional[datetime]:
"""Kontext deadline constraintu pro jedno EV (nebo None).""" if val is None:
if row is None or row["target_deadline"] is None:
return None return None
cap_kwh = row["veh_cap_kwh"] if isinstance(val, datetime):
if cap_kwh is None: 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 return None
cap_wh = float(cap_kwh) * 1000.0 if isinstance(obj, str):
tgt = row["target_soc_pct"] obj = json.loads(obj)
if tgt is None: if not isinstance(obj, dict):
tgt = row["default_target_soc_pct"]
if tgt is None:
return None return None
tgt_f = float(tgt) td = _parse_json_dt(obj.get("target_deadline"))
soc0 = row["soc_at_connect_pct"] if td is None:
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:
return None return None
return SimpleNamespace( return SimpleNamespace(
target_deadline=row["target_deadline"], target_deadline=td,
energy_needed_wh=remaining, energy_needed_wh=float(obj["energy_needed_wh"]),
) )
async def _load_site_context(site_id: int, db): 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( raw = await db.fetchval(
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1", "select ems.fn_planning_site_context($1::int)",
site_id, 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( b = ctx["battery"]
""" ec_i = int(b["max_charge_power_w"])
SELECT ab.usable_capacity_wh, ed_i = int(b["max_discharge_power_w"])
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
battery = SimpleNamespace( battery = SimpleNamespace(
usable_capacity_wh=uc, usable_capacity_wh=float(b["usable_capacity_wh"]),
min_soc_wh=min_soc_wh, min_soc_wh=float(b["min_soc_wh"]),
arb_floor_wh=arb_floor_wh, arb_floor_wh=float(b["arb_floor_wh"]),
reserve_soc_wh=arb_floor_wh, reserve_soc_wh=float(b["reserve_soc_wh"]),
soc_max_wh=soc_max_wh, soc_max_wh=float(b["soc_max_wh"]),
charge_efficiency=float(brow["charge_efficiency"]), charge_efficiency=float(b["charge_efficiency"]),
discharge_efficiency=float(brow["discharge_efficiency"]), discharge_efficiency=float(b["discharge_efficiency"]),
degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]), degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]),
max_charge_power_w=ec_i, max_charge_power_w=ec_i,
max_discharge_power_w=ed_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, charge_slot_buffer=float(b["charge_slot_buffer"])
discharge_slot_buffer=float(brow["discharge_slot_buffer"]) if brow["discharge_slot_buffer"] is not None else 0, 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( hpj = ctx["heat_pump"]
"""
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:
heat_pump = SimpleNamespace( heat_pump = SimpleNamespace(
rated_heating_power_w=0, rated_heating_power_w=int(hpj["rated_heating_power_w"]),
tuv_min_temp_c=0.0, tuv_min_temp_c=float(hpj["tuv_min_temp_c"]),
tuv_target_temp_c=55.0, tuv_target_temp_c=float(hpj["tuv_target_temp_c"]),
)
else:
hp_w = int(hrow["rated_heating_power_w"])
heat_pump = SimpleNamespace(
rated_heating_power_w=max(hp_w, 0),
tuv_min_temp_c=float(hrow["tuv_min_temp_c"]),
tuv_target_temp_c=float(hrow["tuv_target_temp_c"]),
) )
grow = await db.fetchrow( g = ctx["grid"]
"""
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}")
grid = SimpleNamespace( grid = SimpleNamespace(
max_import_power_w=int(grow["max_import_power_w"]), max_import_power_w=int(g["max_import_power_w"]),
max_export_power_w=int(grow["max_export_power_w"]), max_export_power_w=int(g["max_export_power_w"]),
) )
vrows = await db.fetch( vehicles: list[SimpleNamespace] = []
""" for v in ctx.get("vehicles") or []:
SELECT v.battery_capacity_kwh, vehicles.append(
v.max_charge_power_w,
v.default_target_soc_pct,
ch.code AS charger_code
FROM ems.asset_vehicle v
JOIN ems.asset_ev_charger ch ON ch.id = v.default_charger_id
WHERE v.site_id = $1
AND ch.code IN ('ev-charger-1', 'ev-charger-2')
ORDER BY ch.code
""",
site_id,
)
vehicles: list[SimpleNamespace] = [
SimpleNamespace( SimpleNamespace(
max_charge_power_w=int(r["max_charge_power_w"]), max_charge_power_w=int(v["max_charge_power_w"]),
battery_capacity_kwh=float(r["battery_capacity_kwh"]), battery_capacity_kwh=float(v["battery_capacity_kwh"]),
default_target_soc_pct=float(r["default_target_soc_pct"]), default_target_soc_pct=float(v["default_target_soc_pct"]),
)
) )
for r in vrows
]
while len(vehicles) < 2: while len(vehicles) < 2:
vehicles.append( vehicles.append(
SimpleNamespace( SimpleNamespace(
@@ -1057,56 +842,19 @@ async def _load_site_context(site_id: int, db):
) )
) )
srows = await db.fetch( ev_raw = ctx.get("ev_sessions") or []
"""
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_sessions = [ ev_sessions = [
_ev_session_ctx(by_charger.get("ev-charger-1")), _ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None,
_ev_session_ctx(by_charger.get("ev-charger-2")), _ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None,
] ]
soc_pct = await db.fetchval( soc_wh = float(ctx["soc_wh"])
""" tuv_temp = float(ctx["tuv_temp"])
SELECT battery_soc_percent operating_mode = ctx.get("operating_mode")
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))
tuv = await db.fetchval( tuv_stats: dict[tuple[int, int], float] = {}
""" for row in ctx.get("tuv_delta_stats") or []:
SELECT tuv_tank_temp_c tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"])
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
return ( return (
battery, battery,
@@ -1117,120 +865,33 @@ async def _load_site_context(site_id: int, db):
soc_wh, soc_wh,
tuv_temp, tuv_temp,
operating_mode, operating_mode,
tuv_stats,
) )
async def _load_tuv_usage_stats(site_id: int, db) -> dict[tuple[int, int], float]: async def _load_slots(
"""Průměrná změna teploty TUV zásobníku per (DOW, hodina) v konvenci DB EXTRACT(DOW).""" 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( rows = await db.fetch(
""" """
SELECT day_of_week, hour_of_day, avg_temp_delta_c select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
FROM ems.tuv_usage_stats pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
WHERE site_id = $1 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, 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] = [] out: list[PlanningSlot] = []
for r in rows: for r in rows:
d = dict(r) 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"]), ev1_connected=bool(d["ev1_connected"]),
ev2_connected=bool(d["ev2_connected"]), ev2_connected=bool(d["ev2_connected"]),
is_predicted_price=bool(d.get("is_predicted_price")), 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: if not out:
@@ -1281,112 +944,59 @@ async def _save_planning_run(
soc_wh, duration_ms, correction, db, soc_wh, duration_ms, correction, db,
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None, slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
) -> int: ) -> 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): if slot_inputs is not None and len(slot_inputs) != len(results):
raise ValueError("slot_inputs and results length mismatch") raise ValueError("slot_inputs and results length mismatch")
run_id = await db.fetchval(""" run_meta = {
INSERT INTO ems.planning_run "run_type": run_type,
(site_id, horizon_start, horizon_end, status, "triggered_by": triggered_by,
run_type, triggered_by, replan_from, "replan_from": replan_from.isoformat() if replan_from else None,
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor) "soc_at_replan_wh": soc_wh,
VALUES ($1,$2,$3,'draft',$4,$5,$6,$7,$8,$9) "solver_duration_ms": duration_ms,
RETURNING id "forecast_correction_factor": correction,
""", site_id, horizon_from, horizon_to, }
run_type, triggered_by, replan_from, intervals: list[dict] = []
soc_wh, duration_ms, correction) for i, r in enumerate(results):
row: dict = {
# Bulk insert výsledků "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: if slot_inputs is not None:
rows_pi = [ si = slot_inputs[i]
( row["load_baseline_w"] = si[0]
run_id, row["pv_a_forecast_raw_w"] = si[1]
r.interval_start, row["pv_b_forecast_raw_w"] = si[2]
r.battery_setpoint_w, row["pv_a_forecast_solver_w"] = si[3]
r.battery_soc_target, row["pv_b_forecast_solver_w"] = si[4]
r.grid_setpoint_w, intervals.append(row)
r.ev1_setpoint_w,
r.ev2_setpoint_w, return int(
r.ev1_via_bat_w, await db.fetchval(
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(
""" """
INSERT INTO ems.planning_interval select ems.fn_planning_run_commit(
(run_id, interval_start, $1::int, $2::timestamptz, $3::timestamptz,
battery_setpoint_w, battery_soc_target_pct, $4::jsonb, $5::jsonb
grid_setpoint_w, )
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
effective_buy_price, effective_sell_price,
is_predicted_price,
load_baseline_w,
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
pv_a_forecast_solver_w, pv_b_forecast_solver_w)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
$17,$18,$19,$20,$21)
""", """,
rows_pi, site_id,
horizon_from,
horizon_to,
json.dumps(run_meta, default=str),
json.dumps(intervals, default=str),
) )
else:
await db.executemany(
"""
INSERT INTO ems.planning_interval
(run_id, interval_start,
battery_setpoint_w, battery_soc_target_pct,
grid_setpoint_w,
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
effective_buy_price, effective_sell_price,
is_predicted_price)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
""",
[
(
run_id,
r.interval_start,
r.battery_setpoint_w,
r.battery_soc_target,
r.grid_setpoint_w,
r.ev1_setpoint_w,
r.ev2_setpoint_w,
r.ev1_via_bat_w,
r.ev2_via_bat_w,
r.heat_pump_enabled,
r.heat_pump_setpoint_w,
r.pv_a_curtailed_w,
r.expected_cost_czk,
r.effective_buy_price,
r.effective_sell_price,
r.is_predicted_price,
) )
for r in results
],
)
# Aktivovat nový plán, supersede předchozí
await db.execute("""
UPDATE ems.planning_run SET status = 'superseded'
WHERE site_id = $1 AND status = 'active' AND id <> $2
""", site_id, run_id)
await db.execute(
"UPDATE ems.planning_run SET status = 'active' WHERE id = $1", run_id
)
return run_id

View File

@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
import httpx import httpx
from app.config import get_settings from app.config import get_settings
from app.db_json import fetch_json
logger = logging.getLogger(__name__) 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: 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).""" """Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100)."""
return int( stats = await fetch_json(
await conn.fetchval( conn,
""" "select ems.fn_ote_day_slot_stats_prague($1::date)",
SELECT COUNT(*)::int
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND (interval_start AT TIME ZONE 'Europe/Prague')::date = $1::date
""",
target_day, 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( 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" return -1, day_str, 0.0, fetch_error or "fetch_failed"
try: try:
n = await _apply_ote_json_to_db(conn, payload) n = await _apply_ote_json_to_db(conn, payload)
first_price = await conn.fetchval( stats_after = await fetch_json(
""" conn,
SELECT buy_raw_price_czk_kwh "select ems.fn_ote_day_slot_stats_prague($1::date)",
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
""",
target_day, 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): if not ote_prague_day_slots_look_complete(n_imported):
logger.warning( logger.warning(
"OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)", "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: if site_id is not None:
row = await db.fetchrow( 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: if row is None:
logger.error("OTE import: site id=%s nenalezen", site_id) logger.error("OTE import: site id=%s nenalezen", site_id)
@@ -290,26 +284,15 @@ async def import_ote_prices(
try: try:
n = await _apply_ote_json_to_db(db, payload) n = await _apply_ote_json_to_db(db, payload)
first_price = await db.fetchval( stats_after = await fetch_json(
""" db,
SELECT buy_raw_price_czk_kwh "select ems.fn_ote_day_slot_stats_prague($1::date)",
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
""",
target_day, 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) incomplete = not ote_prague_day_slots_look_complete(n_imported or 0)
if incomplete: if incomplete:
now_p = datetime.now(ZoneInfo("Europe/Prague")) 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: async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch( rows = await db.fetch(
""" """
SELECT ai.id, ai.code, se.host, se.port, se.unit_id select inverter_id as id, code, host, port, unit_id
FROM ems.asset_inverter ai from ems.vw_asset_inverter_modbus_poll
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id where site_id = $1
WHERE ai.site_id = $1
AND ai.active = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
""", """,
site_id, 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_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_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) 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) pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER)
pv2_power = await mb.read_register_signed(DEYE_REG_PV2_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) 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) logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
await db.execute( await db.execute(
""" "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)",
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
""",
site_id, site_id,
inv_id, inv_id,
measured_at, 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: async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch( rows = await db.fetch(
""" """
SELECT ec.id, ec.code, se.host, se.port, se.unit_id select charger_id as id, code, host, port, unit_id
FROM ems.asset_ev_charger ec from ems.vw_asset_ev_charger_modbus_poll
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id where site_id = $1
WHERE ec.site_id = $1
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
""", """,
site_id, site_id,
) )
@@ -156,117 +129,52 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
code = row["code"] code = row["code"]
charger_id = row["id"] charger_id = row["id"]
logger.info("TODO: EV charger Modbus registry pending | %s", code) 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" current_status = "available"
previous_status = await db.fetchval( previous_status = await db.fetchval(
""" """
SELECT status select status
FROM ems.telemetry_ev_charger from ems.telemetry_ev_charger
WHERE charger_id = $1 AND connector_id = $2 where charger_id = $1 and connector_id = $2
ORDER BY measured_at DESC order by measured_at desc
LIMIT 1 limit 1
""", """,
charger_id, charger_id,
connector_id, connector_id,
) )
await db.execute( await db.execute(
""" "select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8)",
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
""",
site_id, site_id,
charger_id, charger_id,
measured_at, measured_at,
connector_id, connector_id,
current_status, current_status,
0,
0.0,
) )
if previous_status is not None: if previous_status is not None:
if previous_status == "available" and current_status != "available": await db.fetchval(
vehicle_id = await db.fetchval( "select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
"""
SELECT av.id
FROM ems.asset_vehicle av
WHERE av.site_id = $1
AND av.default_charger_id = $2
AND av.active = true
ORDER BY av.id
LIMIT 1
""",
site_id, site_id,
charger_id, charger_id,
) str(previous_status),
await db.execute( current_status,
"SELECT ems.fn_update_ev_arrival_stats($1, $2, $3, $4)",
site_id,
charger_id,
vehicle_id,
measured_at, measured_at,
) )
if previous_status == "available" and current_status != "available":
logger.info("EV arrival detected on charger %s", code) logger.info("EV arrival detected on charger %s", code)
await db.execute( elif previous_status != "available" and current_status == "available":
"""
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,
)
logger.info("EV departure detected on charger %s", code) logger.info("EV departure detected on charger %s", code)
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None: async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch( rows = await db.fetch(
""" """
SELECT hp.id, hp.code, se.host, se.port, se.unit_id select heat_pump_id as id, code, host, port, unit_id
FROM ems.asset_heat_pump hp from ems.vw_asset_heat_pump_modbus_poll
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id where site_id = $1
WHERE hp.site_id = $1
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
""", """,
site_id, site_id,
) )
@@ -275,18 +183,15 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
code = row["code"] code = row["code"]
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code) logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
await db.execute( await db.execute(
""" "select ems.fn_telemetry_heat_pump_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8, $7::float8, $8::text)",
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
""",
site_id, site_id,
row["id"], row["id"],
measured_at, 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() loop = asyncio.get_running_loop()
start = loop.time() 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: for site in sites:
sid = site["id"] sid = site["id"]
try: try:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import unittest import unittest
from dataclasses import replace from dataclasses import replace
from services.control_exporter import ( from services.control.exporter_monolith import (
ControlSetpoints, ControlSetpoints,
InverterConfig, InverterConfig,
_deye_reg178_verify_with_double_read, _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 datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from services.control_exporter import ( from services.control.exporter_monolith import (
DEYE_CLOCK_DRIFT_OK_SEC, DEYE_CLOCK_DRIFT_OK_SEC,
DEYE_CLOCK_RESYNC_INTERVAL_HOURS, DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC, 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: Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu
- PV-surplus sloty jsou vždy zahrnuty. pro rychlé unit testy bez PostgreSQL.
- 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`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,7 +10,50 @@ import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace 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: 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 ## Komponenty
| Komponenta | Technologie | Port | Popis | | Komponenta | Technologie | Port | Popis |
@@ -77,7 +81,7 @@ ems-platform/
R__fn_cop_estimate.sql R__fn_cop_estimate.sql
R__fn_baseline_consumption.sql R__fn_baseline_consumption.sql
R__fn_fill_audit_interval.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 R__fn_create_planning_run.sql
views/ views/
R__vw_site_effective_price.sql 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_*`)