diff --git a/CLAUDE.md b/CLAUDE.md index bf74a9a..529ceee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,10 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá 11. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` – držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`. +### SQL vs Python (read-model) + +- **Žádné ad-hoc `SELECT`/`INSERT`/`UPDATE` v `backend/services/*.py` a `backend/app/routers/*.py`** kromě: existence `SELECT 1` / `EXISTS`, volání `select ems.fn_*(…)`, a čtení z **`ems.vw_*`**. IO (Modbus, HTTP), PuLP solver a orchestrace zůstávají v Pythonu. + ### Provozní režimy (operating_mode) - Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**. @@ -129,7 +133,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá | `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). | | `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. | -**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`. +**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`. --- @@ -177,6 +181,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan | Rolling plán, forecast log | `db/migration/V007__rolling_replanning.sql` | | Audit 15min | `db/routines/R__fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` | | Nové sloupce / tabulky | nový `db/migration/V00x__*.sql` + případně `db/routines` / `db/views` | +| JSONB read-model (`fn_*`, `fetch_json`) | `docs/02-architecture.md` sekce Read-model JSONB, `app/db_json.py` | | Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` | | Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` | | Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) | diff --git a/backend/app/db_json.py b/backend/app/db_json.py index 2469c11..5a0fc8d 100644 --- a/backend/app/db_json.py +++ b/backend/app/db_json.py @@ -1,7 +1,8 @@ -"""asyncpg Record → JSON-serializovatelný dict.""" +"""asyncpg Record → JSON-serializovatelný dict + helper pro jsonb z fn_*.""" from __future__ import annotations +import json from datetime import date, datetime, timezone from decimal import Decimal from typing import Any @@ -33,3 +34,17 @@ def record_to_dict(r: asyncpg.Record) -> dict[str, Any]: else: out[k] = str(v) return out + + +async def fetch_json(conn: asyncpg.Connection, query: str, *args: Any) -> Any: + """fetchval pro dotazy vracející jsonb (např. select ems.fn_*(...)).""" + v = await conn.fetchval(query, *args) + if v is None: + return None + if isinstance(v, (dict, list)): + return v + if isinstance(v, (bytes, memoryview)): + return json.loads(bytes(v).decode("utf-8")) + if isinstance(v, str): + return json.loads(v) + return v diff --git a/backend/app/main.py b/backend/app/main.py index a2b5f0a..610b530 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import logging import os from contextlib import asynccontextmanager @@ -13,7 +14,7 @@ from zoneinfo import ZoneInfo import asyncpg import httpx from apscheduler.schedulers.asyncio import AsyncIOScheduler -from app.db_json import record_to_dict +from app.db_json import fetch_json, record_to_dict from app.deps import set_pg_pool from app.routers.economics import router as economics_router from app.routers.energy_flows import router as energy_flows_router @@ -90,7 +91,7 @@ async def lifespan(app: FastAPI): async def scheduled_heartbeat() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: try: await send_heartbeat(site["id"], conn) @@ -99,7 +100,7 @@ async def lifespan(app: FastAPI): async def scheduled_audit_filler() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: try: await fill_audit_for_completed_intervals(site["id"], conn) @@ -108,7 +109,7 @@ async def lifespan(app: FastAPI): async def scheduled_forecast_accuracy() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: try: n = await conn.fetchval( @@ -143,7 +144,7 @@ async def lifespan(app: FastAPI): async def scheduled_control_export() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: try: await export_setpoints(site["id"], conn) @@ -156,7 +157,7 @@ async def lifespan(app: FastAPI): Běží každé 2 minuty, nezávisle na control_exporter (delší okno kvůli zpoždění jobu). """ async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: try: cmd_rows = await conn.fetch( @@ -182,7 +183,7 @@ async def lifespan(app: FastAPI): async def scheduled_daily_plan() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: site_id = int(site["id"]) try: @@ -194,7 +195,7 @@ async def lifespan(app: FastAPI): async def scheduled_rolling_replan() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: site_id = int(site["id"]) try: @@ -206,7 +207,7 @@ async def lifespan(app: FastAPI): async def scheduled_baseline_update() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: try: n = await conn.fetchval( @@ -225,7 +226,7 @@ async def lifespan(app: FastAPI): async def scheduled_market_price_stats() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: try: n = await conn.fetchval( @@ -244,7 +245,7 @@ async def lifespan(app: FastAPI): async def scheduled_tuv_usage_stats() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: try: n = await conn.fetchval( @@ -263,7 +264,7 @@ async def lifespan(app: FastAPI): async def scheduled_forecast_refresh() -> None: async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: site_id = int(site["id"]) try: @@ -303,7 +304,7 @@ async def lifespan(app: FastAPI): async def _refresh_negative_price_predictions_all_active( conn: asyncpg.Connection, ) -> None: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: await _refresh_negative_price_predictions(conn, int(site["id"])) @@ -444,7 +445,7 @@ async def lifespan(app: FastAPI): from services.notification_service import notify_daily_economics async with app.state.pg_pool.acquire() as conn: - sites = await conn.fetch("SELECT id, code FROM ems.site WHERE active = true") + sites = await conn.fetch("select id, code from ems.vw_site_directory where active = true") for site in sites: site_id = int(site["id"]) site_code = site["code"] @@ -546,9 +547,9 @@ async def list_sites(db: Annotated[asyncpg.Pool, Depends(get_pool)]) -> list[dic async with db.acquire() as conn: rows = await conn.fetch( """ - SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at - FROM ems.site - ORDER BY id + select id, code, name, timezone, latitude, longitude, active, notes, created_at + from ems.vw_site_directory + order by id """ ) return [record_to_dict(r) for r in rows] @@ -567,17 +568,15 @@ async def get_site_prices( site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") - rows = await conn.fetch( - """ - SELECT * - FROM ems.vw_site_effective_price - WHERE site_id = $1 AND interval_start::date = $2::date - ORDER BY interval_start - """, + rows = await fetch_json( + conn, + "select ems.fn_site_effective_prices_day_prague($1::int, $2::date)", site_id, d, ) - return [record_to_dict(r) for r in rows] + if not isinstance(rows, list): + rows = json.loads(rows) if isinstance(rows, str) else [] + return [r for r in rows if isinstance(r, dict)] class PricesImportResponse(BaseModel): @@ -656,7 +655,7 @@ async def post_import_site_prices( conn, site_id=None, target_date=target ) if n >= 0: - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch("select id from ems.vw_site_directory where active = true") for site in sites: await _refresh_negative_price_predictions(conn, int(site["id"])) if n < 0: @@ -698,59 +697,35 @@ async def get_site_negative_price_predictions( site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") - ndays = await conn.fetchval( - """ - SELECT COUNT(DISTINCT (interval_start AT TIME ZONE 'Europe/Prague')::date)::int - FROM ems.market_interval_price - WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM') - AND interval_start >= now() - INTERVAL '400 days' - """ - ) - rows = await conn.fetch( - """ - SELECT - p.predicted_date, - p.window_start_hour, - p.window_end_hour, - p.probability_pct, - p.expected_min_price, - p.reason - FROM ems.predicted_negative_price_window p - WHERE p.site_id = $1 - AND p.predicted_date > ( - CURRENT_TIMESTAMP AT TIME ZONE COALESCE( - NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''), - 'Europe/Prague' - ) - )::date - AND p.predicted_date <= ( - CURRENT_TIMESTAMP AT TIME ZONE COALESCE( - NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''), - 'Europe/Prague' - ) - )::date + 7 - ORDER BY p.predicted_date, p.window_start_hour - """, + bundle = await fetch_json( + conn, + "select ems.fn_negative_price_predictions($1::int)", site_id, ) - n_hist = int(ndays or 0) + if not isinstance(bundle, dict): + bundle = json.loads(bundle) + rows = bundle.get("predictions") or [] + if not isinstance(rows, list): + rows = [] predictions: list[NegPricePredictionItem] = [] for r in rows: - em = r["expected_min_price"] - pd = r["predicted_date"] + if not isinstance(r, dict): + continue + em = r.get("expected_min_price") + pd = r.get("predicted_date") predictions.append( NegPricePredictionItem( predicted_date=pd.isoformat() if hasattr(pd, "isoformat") else str(pd), - window_start_hour=int(r["window_start_hour"]), - window_end_hour=int(r["window_end_hour"]), - probability_pct=float(r["probability_pct"]), + window_start_hour=int(r.get("window_start_hour") or 0), + window_end_hour=int(r.get("window_end_hour") or 0), + probability_pct=float(r.get("probability_pct") or 0), expected_min_price=float(em) if em is not None else None, - reason=r["reason"] if r["reason"] is not None else "", + reason=str(r.get("reason") or ""), ) ) return NegativePredictionsResponse( predictions=predictions, - insufficient_history=n_hist < 28, + insufficient_history=bool(bundle.get("insufficient_history")), ) @@ -763,29 +738,19 @@ async def get_site_prices_latest( site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") - row = await conn.fetchrow( - """ - SELECT - (interval_start AT TIME ZONE 'Europe/Prague')::date AS day, - COUNT(*)::int AS slots, - MIN(buy_raw_price_czk_kwh)::float AS min_price, - MAX(buy_raw_price_czk_kwh)::float AS max_price, - AVG(buy_raw_price_czk_kwh)::float AS avg_price - FROM ems.market_interval_price - WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM') - GROUP BY day - ORDER BY day DESC - LIMIT 1 - """ - ) - if row is None or row["day"] is None: + row = await fetch_json(conn, "select ems.fn_latest_ote_day_stats()") + if not isinstance(row, dict): + row = json.loads(row) + day = row.get("latest_date") + if day is None: raise HTTPException(status_code=404, detail="Žádná tržní data v databázi") + latest_date = day.isoformat() if hasattr(day, "isoformat") else str(day)[:10] return PricesLatestResponse( - latest_date=row["day"].isoformat(), - slots=int(row["slots"] or 0), - min_price=float(row["min_price"] or 0.0), - max_price=float(row["max_price"] or 0.0), - avg_price=float(row["avg_price"] or 0.0), + latest_date=latest_date, + slots=int(row.get("slots") or 0), + min_price=float(row.get("min_price") or 0.0), + max_price=float(row.get("max_price") or 0.0), + avg_price=float(row.get("avg_price") or 0.0), ) @@ -807,48 +772,45 @@ async def get_verify_modbus_commands( raise HTTPException(status_code=404, detail="Site not found") lookback = timedelta(minutes=minutes) - rows = await conn.fetch( - """ - SELECT id FROM ems.modbus_command - WHERE site_id = $1 - AND status = 'written' - AND written_at >= now() - $2::interval - ORDER BY written_at - """, + id_json = await fetch_json( + conn, + "select ems.fn_modbus_written_command_ids($1::int, $2::interval)", site_id, lookback, ) - ids = [int(r["id"]) for r in rows] + if not isinstance(id_json, list): + id_json = json.loads(id_json) if isinstance(id_json, str) else [] + ids = [int(x) for x in id_json] checked = len(ids) if ids: await verify_modbus_commands(ids, conn, site_id) - detail_rows = ( - await conn.fetch( - """ - SELECT id, asset_code, register_name, value_to_write, value_verified, status - FROM ems.modbus_command - WHERE id = ANY($1::int[]) - ORDER BY id - """, + detail_json = ( + await fetch_json( + conn, + "select ems.fn_modbus_commands_by_ids($1::int[])", ids, ) if ids else [] ) + if ids and not isinstance(detail_json, list): + detail_json = json.loads(detail_json) if isinstance(detail_json, str) else [] + detail_rows = detail_json if ids else [] commands = [ ModbusCommandVerifyItem( id=int(r["id"]), - asset_code=r["asset_code"], - register_name=r["register_name"], + asset_code=str(r.get("asset_code") or ""), + register_name=r.get("register_name"), value_to_write=int(r["value_to_write"]), value_verified=int(r["value_verified"]) - if r["value_verified"] is not None + if r.get("value_verified") is not None else None, - status=r["status"], + status=str(r.get("status") or ""), ) for r in detail_rows + if isinstance(r, dict) ] verified = sum(1 for c in commands if c.status == "verified") mismatch = sum(1 for c in commands if c.status == "mismatch") @@ -933,21 +895,17 @@ async def get_control_command_journal( ) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") - rows = await conn.fetch( - """ - SELECT id, register, register_name, value_to_write, value_written, - value_verified, status, attempt_count, created_at - FROM ems.modbus_command - WHERE site_id = $1 - ORDER BY created_at DESC - LIMIT $2 - """, + rows = await fetch_json( + conn, + "select ems.fn_modbus_journal_list($1::int, $2::int)", site_id, limit, ) + if not isinstance(rows, list): + rows = json.loads(rows) if isinstance(rows, str) else [] cmds: list[ModbusJournalCommandRow] = [] for r in rows: - d = record_to_dict(r) + d = r if isinstance(r, dict) else {} ca = d["created_at"] cmds.append( ModbusJournalCommandRow( @@ -1006,51 +964,20 @@ async def get_site_forecast_pv( site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") - rows = await conn.fetch( - """ - SELECT run_id, pv_array_id, interval_start, power_w, - irradiance_wm2, temp_c, pv_array_code, controllable - FROM ( - SELECT DISTINCT ON (fpi.interval_start, fpr.pv_array_id) - fpi.run_id, - fpi.pv_array_id, - fpi.interval_start, - fpi.power_w, - fpi.irradiance_wm2, - fpi.temp_c, - apa.code AS pv_array_code, - apa.controllable - FROM ems.forecast_pv_interval fpi - JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id - JOIN ems.asset_pv_array apa - ON apa.id = fpr.pv_array_id AND apa.site_id = fpr.site_id - WHERE fpr.site_id = $1 - AND ( - fpi.interval_start - AT TIME ZONE COALESCE( - (SELECT timezone FROM ems.site WHERE id = $1), - 'Europe/Prague' - ) - )::date = $2::date - AND fpr.status = 'ok' - ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC - ) latest - ORDER BY controllable DESC, pv_array_code, interval_start - """, + split = await fetch_json( + conn, + "select ems.fn_forecast_pv_split($1::int, $2::date)", site_id, d, ) - - # pv_a = řiditelná pole (curtailment / Deye), pv_b = neřízená (GEN, …) — sloučí více orientací - pv_a: list[dict[str, Any]] = [] - pv_b: list[dict[str, Any]] = [] - for r in rows: - item = record_to_dict(r) - item.pop("controllable", None) - if r["controllable"]: - pv_a.append(item) - else: - pv_b.append(item) + if not isinstance(split, dict): + split = json.loads(split) if isinstance(split, str) else {} + pv_a = split.get("pv_a") or [] + pv_b = split.get("pv_b") or [] + if not isinstance(pv_a, list): + pv_a = [] + if not isinstance(pv_b, list): + pv_b = [] return {"pv_a": pv_a, "pv_b": pv_b} diff --git a/backend/app/routers/economics.py b/backend/app/routers/economics.py index 5e12f8a..819598c 100644 --- a/backend/app/routers/economics.py +++ b/backend/app/routers/economics.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging from datetime import date, datetime from typing import Annotated, Any @@ -10,6 +11,7 @@ import asyncpg from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel +from app.db_json import fetch_json from app.deps import get_pg_pool router = APIRouter( @@ -105,56 +107,14 @@ async def _check_site(conn: asyncpg.Connection, site_id: int) -> None: raise HTTPException(status_code=404, detail="Site not found") -async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool: - return bool( - await conn.fetchval( - """ - SELECT EXISTS( - SELECT 1 FROM ems.asset_pv_array - WHERE site_id = $1 - AND green_bonus_czk_kwh IS NOT NULL - ) - """, - site_id, - ) - ) - - -def _safe_get(record: Any, key: str, fallback: Any = None) -> Any: - """Safely get a key from asyncpg Record (which supports [] but not .get()).""" - try: - return record[key] - except (KeyError, TypeError): - return fallback - - -def _daily_from_row(r: Any, lock: Any | None, is_locked: bool) -> DailyEconomics: - src = lock if (lock and is_locked) else r - return DailyEconomics( - day=r["day_local"], - interval_count=r["interval_count"], - import_kwh=_num(r["import_kwh"]), - export_kwh=_num(r["export_kwh"]), - pv_kwh=_num(r["pv_kwh"]), - load_kwh=_num(r["load_kwh"]), - pv_self_consumption_kwh=_num(r["pv_self_consumption_kwh"]), - ev_kwh=_num(r["ev_kwh"]), - hp_kwh=_num(r["hp_kwh"]), - import_cost_czk=_num(src["import_cost_czk"]), - export_revenue_czk=_num(src["export_revenue_czk"]), - grid_import_cashflow_czk=_num( - _safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"]) - ), - grid_export_revenue_czk=_num( - _safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"]) - ), - net_cost_czk=_num(src["net_cost_czk"]), - green_bonus_czk=_num(src["green_bonus_czk"]), - total_balance_czk=_num(src["total_balance_czk"]), - planned_balance_czk=_opt(r["planned_balance_czk"]), - deviation_cost_czk=_opt(r["deviation_cost_czk"]), - is_locked=is_locked, - ) +def _parse_day(val: Any) -> date: + if isinstance(val, datetime): + return val.date() + if isinstance(val, date): + return val + if isinstance(val, str): + return date.fromisoformat(val[:10]) + raise ValueError(val) @router.get("/daily", response_model=DailyEconomicsResponse) @@ -179,41 +139,47 @@ async def get_economics_daily( async with db.acquire() as conn: await _check_site(conn, site_id) - has_bonus = await _has_green_bonus(conn, site_id) - - dyn_rows = await conn.fetch( - """ - SELECT * FROM ems.vw_economics_daily - WHERE site_id = $1 - AND day_local >= $2 - AND day_local < $3 - ORDER BY day_local - """, + raw = await fetch_json( + conn, + "select ems.fn_economics_daily_month($1::int, $2::date, $3::date)", site_id, month_start, month_end, ) - - lock_rows = await conn.fetch( - """ - SELECT * FROM ems.audit_day_lock - WHERE site_id = $1 - AND day_local >= $2 - AND day_local < $3 - """, - site_id, - month_start, - month_end, - ) - locks = {r["day_local"]: r for r in lock_rows} - + if not isinstance(raw, dict): + raw = json.loads(raw) + days_in: list[Any] = list(raw.get("days") or []) days: list[DailyEconomics] = [] - for r in dyn_rows: - d = r["day_local"] - lock = locks.get(d) - days.append(_daily_from_row(r, lock, is_locked=lock is not None)) - - return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus) + for d in days_in: + if not isinstance(d, dict): + continue + days.append( + DailyEconomics( + day=_parse_day(d.get("day")), + interval_count=int(d.get("interval_count") or 0), + import_kwh=_num(d.get("import_kwh")), + export_kwh=_num(d.get("export_kwh")), + pv_kwh=_num(d.get("pv_kwh")), + load_kwh=_num(d.get("load_kwh")), + pv_self_consumption_kwh=_num(d.get("pv_self_consumption_kwh")), + ev_kwh=_num(d.get("ev_kwh")), + hp_kwh=_num(d.get("hp_kwh")), + import_cost_czk=_num(d.get("import_cost_czk")), + export_revenue_czk=_num(d.get("export_revenue_czk")), + grid_import_cashflow_czk=_num(d.get("grid_import_cashflow_czk")), + grid_export_revenue_czk=_num(d.get("grid_export_revenue_czk")), + net_cost_czk=_num(d.get("net_cost_czk")), + green_bonus_czk=_num(d.get("green_bonus_czk")), + total_balance_czk=_num(d.get("total_balance_czk")), + planned_balance_czk=_opt(d.get("planned_balance_czk")), + deviation_cost_czk=_opt(d.get("deviation_cost_czk")), + is_locked=bool(d.get("is_locked")), + ) + ) + return DailyEconomicsResponse( + days=days, + has_green_bonus=bool(raw.get("has_green_bonus")), + ) @router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics]) @@ -270,50 +236,18 @@ async def lock_day( ) -> LockResponse: async with db.acquire() as conn: await _check_site(conn, site_id) - - row = await conn.fetchrow( - """ - SELECT import_cost_czk, export_revenue_czk, net_cost_czk, - green_bonus_czk, total_balance_czk, - grid_import_cashflow_czk, grid_export_revenue_czk - FROM ems.vw_economics_daily - WHERE site_id = $1 AND day_local = $2 - """, + raw = await fetch_json( + conn, + "select ems.fn_economics_lock_day($1::int, $2::date)", site_id, day, ) - if row is None: - raise HTTPException( - status_code=404, - detail=f"No economics data for {day.isoformat()}", - ) - - await conn.execute( - """ - INSERT INTO ems.audit_day_lock - (site_id, day_local, import_cost_czk, export_revenue_czk, - net_cost_czk, green_bonus_czk, total_balance_czk, - grid_import_cashflow_czk, grid_export_revenue_czk) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (site_id, day_local) DO UPDATE SET - import_cost_czk = EXCLUDED.import_cost_czk, - export_revenue_czk = EXCLUDED.export_revenue_czk, - net_cost_czk = EXCLUDED.net_cost_czk, - green_bonus_czk = EXCLUDED.green_bonus_czk, - total_balance_czk = EXCLUDED.total_balance_czk, - grid_import_cashflow_czk = EXCLUDED.grid_import_cashflow_czk, - grid_export_revenue_czk = EXCLUDED.grid_export_revenue_czk, - locked_at = now() - """, - site_id, - day, - row["import_cost_czk"], - row["export_revenue_czk"], - row["net_cost_czk"], - row["green_bonus_czk"], - row["total_balance_czk"], - row["grid_import_cashflow_czk"], - row["grid_export_revenue_czk"], + if not isinstance(raw, dict): + raw = json.loads(raw) + if raw.get("locked") is not True: + raise HTTPException( + status_code=404, + detail=f"No economics data for {day.isoformat()}", ) return LockResponse(locked=True, day=day) @@ -327,8 +261,9 @@ async def unlock_day( ) -> LockResponse: async with db.acquire() as conn: await _check_site(conn, site_id) - await conn.execute( - "DELETE FROM ems.audit_day_lock WHERE site_id = $1 AND day_local = $2", + await fetch_json( + conn, + "select ems.fn_economics_unlock_day($1::int, $2::date)", site_id, day, ) @@ -357,61 +292,29 @@ async def get_monthly_chart( async with db.acquire() as conn: await _check_site(conn, site_id) - - rows = await conn.fetch( - """ - SELECT day_local, total_balance_czk, net_cost_czk, - green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk - FROM ems.vw_economics_daily - WHERE site_id = $1 - AND day_local >= $2 - AND day_local < $3 - ORDER BY day_local - """, + arr = await fetch_json( + conn, + "select ems.fn_economics_monthly_chart($1::int, $2::date, $3::date)", site_id, month_start, month_end, ) - - lock_rows = await conn.fetch( - """ - SELECT day_local, total_balance_czk, net_cost_czk, - green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk - FROM ems.audit_day_lock - WHERE site_id = $1 - AND day_local >= $2 - AND day_local < $3 - """, - site_id, - month_start, - month_end, - ) - locks = {r["day_local"]: r for r in lock_rows} - + if not isinstance(arr, list): + arr = json.loads(arr) if isinstance(arr, str) else [] points: list[ChartDayPoint] = [] - cum_balance = 0.0 - cum_grid = 0.0 - for r in rows: - d = r["day_local"] - src = locks.get(d, r) - balance = _num(src["total_balance_czk"]) - grid_balance = -_num(src["net_cost_czk"]) - green_bonus = _num(src["green_bonus_czk"]) - import_cost = _num(_safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"])) - export_revenue = _num(_safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"])) - cum_balance += balance - cum_grid += grid_balance + for r in arr: + if not isinstance(r, dict): + continue points.append( ChartDayPoint( - day=d, - daily_balance_czk=round(balance, 2), - daily_grid_balance_czk=round(grid_balance, 2), - daily_green_bonus_czk=round(green_bonus, 2), - daily_import_cost_czk=round(import_cost, 2), - daily_export_revenue_czk=round(export_revenue, 2), - cumulative_balance_czk=round(cum_balance, 2), - cumulative_grid_balance_czk=round(cum_grid, 2), + day=_parse_day(r.get("day")), + daily_balance_czk=float(r.get("daily_balance_czk") or 0), + daily_grid_balance_czk=float(r.get("daily_grid_balance_czk") or 0), + daily_green_bonus_czk=float(r.get("daily_green_bonus_czk") or 0), + daily_import_cost_czk=float(r.get("daily_import_cost_czk") or 0), + daily_export_revenue_czk=float(r.get("daily_export_revenue_czk") or 0), + cumulative_balance_czk=float(r.get("cumulative_balance_czk") or 0), + cumulative_grid_balance_czk=float(r.get("cumulative_grid_balance_czk") or 0), ) ) - return points diff --git a/backend/app/routers/energy_flows.py b/backend/app/routers/energy_flows.py index 54fdd28..4f0ecd6 100644 --- a/backend/app/routers/energy_flows.py +++ b/backend/app/routers/energy_flows.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from datetime import date from typing import Annotated, Any @@ -9,6 +10,7 @@ import asyncpg from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel +from app.db_json import fetch_json from app.deps import get_pg_pool router = APIRouter( @@ -16,6 +18,7 @@ router = APIRouter( tags=["energy-flows"], ) + class DailyEnergyFlows(BaseModel): day: date interval_count: int @@ -65,12 +68,6 @@ def _num(val: Any) -> float: return float(val) -def _wh_to_kwh(val: Any) -> float | None: - if val is None: - return None - return round(float(val) / 1000.0, 4) - - async def _check_site(conn: asyncpg.Connection, site_id: int) -> None: ok = await conn.fetchval( "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id @@ -79,28 +76,16 @@ async def _check_site(conn: asyncpg.Connection, site_id: int) -> None: raise HTTPException(status_code=404, detail="Site not found") -def _row_to_daily(r: Any) -> DailyEnergyFlows: - return DailyEnergyFlows( - day=r["day_local"], - interval_count=int(r["interval_count"] or 0), - pv_production_kwh=_num(r["pv_production_kwh"]), - grid_import_kwh=_num(r["grid_import_kwh"]), - grid_export_kwh=_num(r["grid_export_kwh"]), - batt_charge_kwh=_num(r["batt_charge_kwh"]), - batt_discharge_kwh=_num(r["batt_discharge_kwh"]), - load_kwh=_num(r["load_kwh"]), - pv_to_load_kwh=_num(r["pv_to_load_kwh"]), - pv_to_batt_kwh=_num(r["pv_to_batt_kwh"]), - pv_to_grid_kwh=_num(r["pv_to_grid_kwh"]), - batt_to_load_kwh=_num(r["batt_to_load_kwh"]), - batt_to_grid_kwh=_num(r["batt_to_grid_kwh"]), - grid_to_load_kwh=_num(r["grid_to_load_kwh"]), - grid_to_batt_kwh=_num(r["grid_to_batt_kwh"]), - grid_import_cashflow_czk=_num(r["grid_import_cashflow_czk"]), - grid_export_revenue_czk=_num(r["grid_export_revenue_czk"]), - grid_to_load_cost_czk=_num(r["grid_to_load_cost_czk"]), - grid_to_batt_cost_czk=_num(r["grid_to_batt_cost_czk"]), - ) +def _parse_day(val: Any) -> date: + from datetime import datetime as _dt + + if isinstance(val, _dt): + return val.date() + if isinstance(val, date): + return val + if isinstance(val, str): + return date.fromisoformat(val[:10]) + raise ValueError(val) @router.get("/daily", response_model=DailyEnergyFlowsResponse) @@ -125,84 +110,44 @@ async def get_energy_flows_daily( async with db.acquire() as conn: await _check_site(conn, site_id) - rows = await conn.fetch( - """ - SELECT - (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date - AS day_local, - COUNT(*)::int AS interval_count, - ROUND(SUM(COALESCE(ai.actual_pv_production_wh, 0)) / 1000, 3) - AS pv_production_kwh, - ROUND(SUM(COALESCE(ai.actual_grid_import_wh, 0)) / 1000, 3) - AS grid_import_kwh, - ROUND(SUM(COALESCE(ai.actual_grid_export_wh, 0)) / 1000, 3) - AS grid_export_kwh, - ROUND(SUM(COALESCE(ai.actual_batt_charge_wh, 0)) / 1000, 3) - AS batt_charge_kwh, - ROUND(SUM(COALESCE(ai.actual_batt_discharge_wh, 0)) / 1000, 3) - AS batt_discharge_kwh, - ROUND(SUM(COALESCE(ai.actual_load_consumption_wh, 0)) / 1000, 3) - AS load_kwh, - ROUND(SUM(COALESCE(ai.flow_pv_to_load_wh, 0)) / 1000, 3) - AS pv_to_load_kwh, - ROUND(SUM(COALESCE(ai.flow_pv_to_batt_wh, 0)) / 1000, 3) - AS pv_to_batt_kwh, - ROUND(SUM(COALESCE(ai.flow_pv_to_grid_wh, 0)) / 1000, 3) - AS pv_to_grid_kwh, - ROUND(SUM(COALESCE(ai.flow_batt_to_load_wh, 0)) / 1000, 3) - AS batt_to_load_kwh, - ROUND(SUM(COALESCE(ai.flow_batt_to_grid_wh, 0)) / 1000, 3) - AS batt_to_grid_kwh, - ROUND(SUM(COALESCE(ai.flow_grid_to_load_wh, 0)) / 1000, 3) - AS grid_to_load_kwh, - ROUND(SUM(COALESCE(ai.flow_grid_to_batt_wh, 0)) / 1000, 3) - AS grid_to_batt_kwh, - ROUND( - SUM( - COALESCE(ai.actual_grid_import_wh, 0) / 1000.0 - * COALESCE(ep.effective_buy_price_czk_kwh, 0) - ), - 2 - ) AS grid_import_cashflow_czk, - ROUND( - SUM( - COALESCE(ai.actual_grid_export_wh, 0) / 1000.0 - * COALESCE(ep.effective_sell_price_czk_kwh, 0) - ), - 2 - ) AS grid_export_revenue_czk, - ROUND( - SUM( - COALESCE(ai.flow_grid_to_load_wh, 0) / 1000.0 - * COALESCE(ep.effective_buy_price_czk_kwh, 0) - ), - 2 - ) AS grid_to_load_cost_czk, - ROUND( - SUM( - COALESCE(ai.flow_grid_to_batt_wh, 0) / 1000.0 - * COALESCE(ep.effective_buy_price_czk_kwh, 0) - ), - 2 - ) AS grid_to_batt_cost_czk - FROM ems.audit_interval ai - LEFT JOIN ems.vw_site_effective_price ep - ON ep.site_id = ai.site_id - AND ep.interval_start = ai.interval_start - WHERE ai.site_id = $1 - AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date - >= $2 - AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date - < $3 - GROUP BY 1 - ORDER BY 1 - """, + raw = await fetch_json( + conn, + "select ems.fn_energy_flows_daily_month($1::int, $2::date, $3::date)", site_id, month_start, month_end, ) - - return DailyEnergyFlowsResponse(days=[_row_to_daily(r) for r in rows]) + if not isinstance(raw, dict): + raw = json.loads(raw) + rows = raw.get("days") or [] + days: list[DailyEnergyFlows] = [] + for r in rows: + if not isinstance(r, dict): + continue + days.append( + DailyEnergyFlows( + day=_parse_day(r.get("day")), + interval_count=int(r.get("interval_count") or 0), + pv_production_kwh=_num(r.get("pv_production_kwh")), + grid_import_kwh=_num(r.get("grid_import_kwh")), + grid_export_kwh=_num(r.get("grid_export_kwh")), + batt_charge_kwh=_num(r.get("batt_charge_kwh")), + batt_discharge_kwh=_num(r.get("batt_discharge_kwh")), + load_kwh=_num(r.get("load_kwh")), + pv_to_load_kwh=_num(r.get("pv_to_load_kwh")), + pv_to_batt_kwh=_num(r.get("pv_to_batt_kwh")), + pv_to_grid_kwh=_num(r.get("pv_to_grid_kwh")), + batt_to_load_kwh=_num(r.get("batt_to_load_kwh")), + batt_to_grid_kwh=_num(r.get("batt_to_grid_kwh")), + grid_to_load_kwh=_num(r.get("grid_to_load_kwh")), + grid_to_batt_kwh=_num(r.get("grid_to_batt_kwh")), + grid_import_cashflow_czk=_num(r.get("grid_import_cashflow_czk")), + grid_export_revenue_czk=_num(r.get("grid_export_revenue_czk")), + grid_to_load_cost_czk=_num(r.get("grid_to_load_cost_czk")), + grid_to_batt_cost_czk=_num(r.get("grid_to_batt_cost_czk")), + ) + ) + return DailyEnergyFlowsResponse(days=days) @router.get("/daily/{day}/intervals", response_model=list[IntervalEnergyFlows]) @@ -213,48 +158,35 @@ async def get_energy_flows_intervals( ) -> list[IntervalEnergyFlows]: async with db.acquire() as conn: await _check_site(conn, site_id) - rows = await conn.fetch( - """ - SELECT - interval_start, - actual_pv_production_wh, - actual_grid_import_wh, - actual_grid_export_wh, - actual_batt_charge_wh, - actual_batt_discharge_wh, - actual_load_consumption_wh, - flow_pv_to_load_wh, - flow_pv_to_batt_wh, - flow_pv_to_grid_wh, - flow_batt_to_load_wh, - flow_batt_to_grid_wh, - flow_grid_to_load_wh, - flow_grid_to_batt_wh - FROM ems.audit_interval - WHERE site_id = $1 - AND (date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague'))::date = $2 - ORDER BY interval_start - """, + rows = await fetch_json( + conn, + "select ems.fn_energy_flows_intervals_day($1::int, $2::date)", site_id, day, ) - - return [ - IntervalEnergyFlows( - interval_start=r["interval_start"].isoformat(), - pv_production_kwh=_wh_to_kwh(r["actual_pv_production_wh"]), - grid_import_kwh=_wh_to_kwh(r["actual_grid_import_wh"]), - grid_export_kwh=_wh_to_kwh(r["actual_grid_export_wh"]), - batt_charge_kwh=_wh_to_kwh(r["actual_batt_charge_wh"]), - batt_discharge_kwh=_wh_to_kwh(r["actual_batt_discharge_wh"]), - load_kwh=_wh_to_kwh(r["actual_load_consumption_wh"]), - pv_to_load_kwh=_wh_to_kwh(r["flow_pv_to_load_wh"]), - pv_to_batt_kwh=_wh_to_kwh(r["flow_pv_to_batt_wh"]), - pv_to_grid_kwh=_wh_to_kwh(r["flow_pv_to_grid_wh"]), - batt_to_load_kwh=_wh_to_kwh(r["flow_batt_to_load_wh"]), - batt_to_grid_kwh=_wh_to_kwh(r["flow_batt_to_grid_wh"]), - grid_to_load_kwh=_wh_to_kwh(r["flow_grid_to_load_wh"]), - grid_to_batt_kwh=_wh_to_kwh(r["flow_grid_to_batt_wh"]), + if not isinstance(rows, list): + rows = json.loads(rows) if isinstance(rows, str) else [] + out: list[IntervalEnergyFlows] = [] + for r in rows: + if not isinstance(r, dict): + continue + ist = r.get("interval_start") + out.append( + IntervalEnergyFlows( + interval_start=ist if isinstance(ist, str) else str(ist), + pv_production_kwh=r.get("pv_production_kwh"), + grid_import_kwh=r.get("grid_import_kwh"), + grid_export_kwh=r.get("grid_export_kwh"), + batt_charge_kwh=r.get("batt_charge_kwh"), + batt_discharge_kwh=r.get("batt_discharge_kwh"), + load_kwh=r.get("load_kwh"), + pv_to_load_kwh=r.get("pv_to_load_kwh"), + pv_to_batt_kwh=r.get("pv_to_batt_kwh"), + pv_to_grid_kwh=r.get("pv_to_grid_kwh"), + batt_to_load_kwh=r.get("batt_to_load_kwh"), + batt_to_grid_kwh=r.get("batt_to_grid_kwh"), + grid_to_load_kwh=r.get("grid_to_load_kwh"), + grid_to_batt_kwh=r.get("grid_to_batt_kwh"), + ) ) - for r in rows - ] + return out diff --git a/backend/app/routers/ev.py b/backend/app/routers/ev.py index ce6004c..e4b4a80 100644 --- a/backend/app/routers/ev.py +++ b/backend/app/routers/ev.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from datetime import date, datetime from typing import Annotated, Any @@ -9,7 +10,7 @@ import asyncpg from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, field_validator -from app.db_json import record_to_dict +from app.db_json import fetch_json from app.deps import get_pg_pool router = APIRouter(prefix="/sites/{site_id}/ev", tags=["ev"]) @@ -38,30 +39,19 @@ async def get_active_ev_sessions( pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], ) -> list[dict[str, Any]]: async with pool.acquire() as conn: - site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + site_ok = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id + ) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") - rows = await conn.fetch( - """ - SELECT es.id, es.charger_id, es.vehicle_id, - es.session_start, es.energy_delivered_wh, - es.target_soc_pct, es.target_deadline, - av.make, av.model, av.battery_capacity_kwh, - av.default_target_soc_pct, av.default_deadline_hour, - ac.code AS charger_code, - COALESCE( - NULLIF(TRIM(CONCAT_WS(' ', ac.manufacturer, ac.model)), ''), - ac.code - ) AS charger_name - FROM ems.ev_session es - LEFT JOIN ems.asset_vehicle av ON av.id = es.vehicle_id - JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id - WHERE es.site_id = $1 AND es.session_end IS NULL - ORDER BY es.session_start DESC - """, + rows = await fetch_json( + conn, + "select ems.fn_ev_sessions_active($1::int)", site_id, ) - return [record_to_dict(r) for r in rows] + if not isinstance(rows, list): + rows = json.loads(rows) if isinstance(rows, str) else [] + return [r for r in rows if isinstance(r, dict)] @router.patch("/sessions/{session_id}", response_model=EvSessionPatchResponse) @@ -72,25 +62,25 @@ async def patch_ev_session( pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], ) -> EvSessionPatchResponse: async with pool.acquire() as conn: - site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + site_ok = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id + ) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") - row = await conn.fetchrow( - """ - UPDATE ems.ev_session - SET target_soc_pct = $1, target_deadline = $2 - WHERE id = $3 AND site_id = $4 - RETURNING id - """, - body.target_soc_pct, - body.target_deadline, - session_id, + patch = body.model_dump(exclude_unset=True) + raw = await fetch_json( + conn, + "select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)", site_id, + session_id, + json.dumps(patch), ) - if row is None: - raise HTTPException(status_code=404, detail="Session not found") - return EvSessionPatchResponse(success=True, session_id=int(row["id"])) + if not isinstance(raw, dict): + raw = json.loads(raw) + if not raw.get("success"): + raise HTTPException(status_code=404, detail="Session not found") + return EvSessionPatchResponse(success=True, session_id=int(raw["session_id"])) class ArrivalHourItem(BaseModel): @@ -114,65 +104,48 @@ async def get_ev_arrival_prediction( site_id: int, pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], ) -> EvArrivalPredictionResponse: - """Top hodiny příjezdu z ems.fn_ev_expected_arrival; při <5 session celkem insufficient_data.""" async with pool.acquire() as conn: - site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) - if not site_ok: - raise HTTPException(status_code=404, detail="Site not found") - - n_sessions = int( - await conn.fetchval( - "SELECT COUNT(*)::int FROM ems.ev_session WHERE site_id = $1", - site_id, - ) - or 0 - ) - insufficient = n_sessions < 5 - - tomorrow = await conn.fetchval( - """ - SELECT ( - CURRENT_TIMESTAMP AT TIME ZONE COALESCE( - NULLIF(TRIM(timezone), ''), - 'Europe/Prague' - ) - )::date + 1 - FROM ems.site - WHERE id = $1 - """, + raw = await fetch_json( + conn, + "select ems.fn_ev_arrival_prediction_bundle($1::int)", site_id, ) - if tomorrow is None: - raise HTTPException(status_code=500, detail="Site date resolution failed") - tomorrow_d: date = tomorrow + if not isinstance(raw, dict): + raw = json.loads(raw) + if raw.get("error") == "site_not_found": + raise HTTPException(status_code=404, detail="Site not found") - chargers_rows = await conn.fetch( - "SELECT id, code FROM ems.asset_ev_charger WHERE site_id = $1 ORDER BY id", - site_id, - ) - - chargers: dict[str, ChargerTomorrowArrival] = {} - for ch in chargers_rows: - code = str(ch["code"]) - preds = await conn.fetch( - "SELECT * FROM ems.fn_ev_expected_arrival($1, $2, $3::date)", - site_id, - ch["id"], - tomorrow_d, - ) - chargers[code] = ChargerTomorrowArrival( - tomorrow=[ - ArrivalHourItem( - hour=int(r["expected_hour"]), - confidence_pct=int(r["confidence_pct"]), - samples=int(r["sample_count"]), + chargers: dict[str, ChargerTomorrowArrival] = {} + ch_raw = raw.get("chargers") or {} + if isinstance(ch_raw, dict): + for code, v in ch_raw.items(): + if not isinstance(v, dict): + continue + tlist = v.get("tomorrow") or [] + items: list[ArrivalHourItem] = [] + if isinstance(tlist, list): + for it in tlist: + if not isinstance(it, dict): + continue + items.append( + ArrivalHourItem( + hour=int(it.get("hour") or 0), + confidence_pct=int(it.get("confidence_pct") or 0), + samples=int(it.get("samples") or 0), + ) ) - for r in preds - ] - ) + chargers[str(code)] = ChargerTomorrowArrival(tomorrow=items) + + td = raw.get("tomorrow_date") + if isinstance(td, date): + td_s = td.isoformat() + elif isinstance(td, datetime): + td_s = td.date().isoformat() + else: + td_s = str(td or "") return EvArrivalPredictionResponse( - insufficient_data=insufficient, - tomorrow_date=tomorrow_d.isoformat(), + insufficient_data=bool(raw.get("insufficient_data")), + tomorrow_date=td_s, chargers=chargers, ) diff --git a/backend/app/routers/full_status.py b/backend/app/routers/full_status.py index 2fee3ec..acc9e0c 100644 --- a/backend/app/routers/full_status.py +++ b/backend/app/routers/full_status.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from datetime import date, datetime, timedelta, timezone from typing import Annotated, Any, Literal from zoneinfo import ZoneInfo @@ -10,7 +11,7 @@ import asyncpg from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from app.db_json import record_to_dict +from app.db_json import fetch_json from app.deps import get_pg_pool from app.notifications_logic import ( EvSessionRow, @@ -47,6 +48,16 @@ def _iso_utc(dt: datetime | None) -> str | None: return dt.astimezone(timezone.utc).isoformat() +def _parse_ts(val: Any) -> datetime | None: + if val is None: + return None + if isinstance(val, datetime): + return val + if isinstance(val, str): + return datetime.fromisoformat(val.replace("Z", "+00:00")) + return None + + def _age_seconds(at: datetime | None) -> int | None: if at is None: return None @@ -81,174 +92,105 @@ async def get_site_status_full( pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], ) -> dict[str, Any]: async with pool.acquire() as conn: - site = await conn.fetchrow( - """ - SELECT id, code, name, timezone - FROM ems.site - WHERE id = $1 - """, + bundle = await fetch_json( + conn, + "select ems.fn_site_full_status($1::int)", site_id, ) - if site is None: - raise HTTPException(status_code=404, detail="Site not found") + if not isinstance(bundle, dict): + bundle = json.loads(bundle) + if bundle.get("error") == "not_found": + raise HTTPException(status_code=404, detail="Site not found") - tz = site["timezone"] or "Europe/Prague" + site = bundle.get("site") or {} + mode_row = bundle.get("operating_mode") or {} + hb_row = bundle.get("heartbeat") or {} + inv_row = bundle.get("inverter_latest") + if not isinstance(inv_row, dict): + inv_row = None + ev_rows = bundle.get("ev_chargers") or [] + if not isinstance(ev_rows, list): + ev_rows = [] + hp_row = bundle.get("heat_pump_latest") + if not isinstance(hp_row, dict): + hp_row = None + reserve_row = bundle.get("battery_limits") or {} + run_row = bundle.get("active_plan") + if not isinstance(run_row, dict): + run_row = None + intervals: list[dict[str, Any]] = [] + raw_iv = bundle.get("planning_intervals") or [] + if isinstance(raw_iv, list): + intervals = [x for x in raw_iv if isinstance(x, dict)] - mode_row = await conn.fetchrow( - """ - SELECT m.mode_code, d.name AS mode_name, m.activated_at, m.activated_by - FROM ems.site_operating_mode m - JOIN ems.operating_mode_def d ON d.code = m.mode_code - WHERE m.site_id = $1 - """, - site_id, - ) - - hb_row = await conn.fetchrow( - """ - SELECT last_seen, status - FROM ems.site_heartbeat - WHERE site_id = $1 - """, - site_id, - ) - - inv_row = await conn.fetchrow( - """ - SELECT pv_power_w, battery_soc_percent, grid_power_w, measured_at - FROM ems.vw_latest_inverter - WHERE site_id = $1 - ORDER BY measured_at DESC NULLS LAST - LIMIT 1 - """, - site_id, - ) - - ev_rows = await conn.fetch( - """ - SELECT DISTINCT ON (charger_id) - charger_code AS code, - status, - power_w, - measured_at - FROM ems.vw_latest_ev_charger - WHERE site_id = $1 - ORDER BY charger_id, measured_at DESC NULLS LAST - """, - site_id, - ) - - hp_row = await conn.fetchrow( - """ - SELECT power_w, tuv_tank_temp_c, measured_at - FROM ems.vw_latest_heat_pump - WHERE site_id = $1 - ORDER BY measured_at DESC NULLS LAST - LIMIT 1 - """, - site_id, - ) - - reserve_row = await conn.fetchrow( - """ - SELECT MIN(reserve_soc_percent)::float AS reserve_soc, - MIN(min_soc_percent)::float AS min_soc - FROM ems.asset_battery - WHERE site_id = $1 - """, - site_id, - ) - - run_row = await conn.fetchrow( - """ - SELECT id, created_at - FROM ems.planning_run - WHERE site_id = $1 AND status = 'active' - ORDER BY created_at DESC - LIMIT 1 - """, - site_id, - ) - - intervals: list[dict[str, Any]] = [] - if run_row: - int_rows = await conn.fetch( - """ - SELECT interval_start, battery_setpoint_w, - load_baseline_w, - pv_a_forecast_raw_w, pv_b_forecast_raw_w, - pv_a_forecast_solver_w, pv_b_forecast_solver_w - FROM ems.planning_interval - WHERE run_id = $1 - ORDER BY interval_start - """, - run_row["id"], - ) - intervals = [record_to_dict(r) for r in int_rows] - - tomorrow_slots = await conn.fetchval( - """ - SELECT COUNT(*)::int - FROM ems.vw_site_effective_price v - WHERE v.site_id = $1 - AND (v.interval_start AT TIME ZONE $2)::date = - ((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date - """, - site_id, - tz, - ) - tomorrow_slots = int(tomorrow_slots or 0) + tomorrow_slots = int(bundle.get("tomorrow_price_slot_count") or 0) now_utc = datetime.now(timezone.utc) - hb_last = hb_row["last_seen"] if hb_row else None + hb_last = hb_row.get("last_seen") if hb_row else None hb_age = _age_seconds(hb_last) - inv_measured = inv_row["measured_at"] if inv_row else None + inv_measured = inv_row.get("measured_at") if inv_row else None inv_age = _age_seconds(inv_measured) next_start, next_bat = _next_plan_interval(intervals, now_utc) ev_list: list[dict[str, Any]] = [] for r in ev_rows: + if not isinstance(r, dict): + continue ev_list.append( { - "code": r["code"], - "status": r["status"], - "power_w": int(r["power_w"]) if r["power_w"] is not None else None, + "code": r.get("code"), + "status": r.get("status"), + "power_w": int(r["power_w"]) if r.get("power_w") is not None else None, } ) telemetry: dict[str, Any] = { "inverter": { - "pv_power_w": int(inv_row["pv_power_w"]) if inv_row and inv_row["pv_power_w"] is not None else None, - "battery_soc_pct": float(inv_row["battery_soc_percent"]) - if inv_row and inv_row["battery_soc_percent"] is not None + "pv_power_w": int(inv_row["pv_power_w"]) + if inv_row and inv_row.get("pv_power_w") is not None + else None, + "battery_soc_pct": float(inv_row["battery_soc_percent"]) + if inv_row and inv_row.get("battery_soc_percent") is not None + else None, + "grid_power_w": int(inv_row["grid_power_w"]) + if inv_row and inv_row.get("grid_power_w") is not None else None, - "grid_power_w": int(inv_row["grid_power_w"]) if inv_row and inv_row["grid_power_w"] is not None else None, "measured_at": _iso_utc(inv_measured), "age_seconds": inv_age, }, "ev_chargers": ev_list, "heat_pump": { - "power_w": int(hp_row["power_w"]) if hp_row and hp_row["power_w"] is not None else None, + "power_w": int(hp_row["power_w"]) if hp_row and hp_row.get("power_w") is not None else None, "tank_temp_c": float(hp_row["tuv_tank_temp_c"]) - if hp_row and hp_row["tuv_tank_temp_c"] is not None + if hp_row and hp_row.get("tuv_tank_temp_c") is not None else None, - "measured_at": _iso_utc(hp_row["measured_at"]) if hp_row else None, + "measured_at": _iso_utc(hp_row.get("measured_at")) if hp_row else None, }, } has_plan = run_row is not None planning = { "has_active_plan": has_plan, - "plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None, + "plan_created_at": _iso_utc(run_row.get("created_at")) if run_row else None, "next_interval_start": next_start, "next_battery_setpoint_w": next_bat, } - mode_code = (mode_row["mode_code"] if mode_row else None) or "" - reserve_soc = float(reserve_row["reserve_soc"]) if reserve_row and reserve_row["reserve_soc"] is not None else None - min_soc = float(reserve_row["min_soc"]) if reserve_row and reserve_row["min_soc"] is not None else None - soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None + mode_code = (mode_row.get("mode_code") if mode_row else None) or "" + reserve_soc = ( + float(reserve_row["reserve_soc"]) + if reserve_row and reserve_row.get("reserve_soc") is not None + else None + ) + min_soc = ( + float(reserve_row["min_soc"]) if reserve_row and reserve_row.get("min_soc") is not None else None + ) + soc = ( + float(inv_row["battery_soc_percent"]) + if inv_row and inv_row.get("battery_soc_percent") is not None + else None + ) alerts: list[dict[str, str]] = [] @@ -281,17 +223,17 @@ async def get_site_status_full( alerts.sort(key=lambda a: (0 if a["level"] == "error" else 1, a["message"])) return { - "site": {"id": site["id"], "code": site["code"], "name": site["name"]}, + "site": {"id": site.get("id"), "code": site.get("code"), "name": site.get("name")}, "operating_mode": { - "mode_code": mode_row["mode_code"] if mode_row else None, - "mode_name": mode_row["mode_name"] if mode_row else None, - "activated_at": _iso_utc(mode_row["activated_at"]) if mode_row else None, - "activated_by": mode_row["activated_by"] if mode_row else None, + "mode_code": mode_row.get("mode_code") if mode_row else None, + "mode_name": mode_row.get("mode_name") if mode_row else None, + "activated_at": _iso_utc(mode_row.get("activated_at")) if mode_row else None, + "activated_by": mode_row.get("activated_by") if mode_row else None, }, "heartbeat": { "last_seen": _iso_utc(hb_last), "age_seconds": hb_age, - "status": hb_row["status"] if hb_row else None, + "status": hb_row.get("status") if hb_row else None, }, "telemetry": telemetry, "planning": planning, @@ -395,156 +337,39 @@ async def get_site_notifications( pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], ) -> SiteNotificationsResponse: async with pool.acquire() as conn: - site = await conn.fetchrow( - "SELECT id, timezone FROM ems.site WHERE id = $1", + ctx = await fetch_json( + conn, + "select ems.fn_site_notifications_context($1::int)", site_id, ) - if site is None: - raise HTTPException(status_code=404, detail="Site not found") - tz = site["timezone"] or "Europe/Prague" + if not isinstance(ctx, dict): + ctx = json.loads(ctx) + if ctx.get("error") == "not_found": + raise HTTPException(status_code=404, detail="Site not found") - mode_row = await conn.fetchrow( - """ - SELECT m.mode_code - FROM ems.site_operating_mode m - WHERE m.site_id = $1 - """, - site_id, - ) - run_row = await conn.fetchrow( - """ - SELECT id FROM ems.planning_run - WHERE site_id = $1 AND status = 'active' - ORDER BY created_at DESC - LIMIT 1 - """, - site_id, - ) - reserve_row = await conn.fetchrow( - """ - SELECT MIN(reserve_soc_percent)::float AS reserve_soc, - MIN(min_soc_percent)::float AS min_soc - FROM ems.asset_battery - WHERE site_id = $1 - """, - site_id, - ) - inv_row = await conn.fetchrow( - """ - SELECT battery_soc_percent, measured_at - FROM ems.vw_latest_inverter - WHERE site_id = $1 - ORDER BY measured_at DESC NULLS LAST - LIMIT 1 - """, - site_id, - ) - hb_row = await conn.fetchrow( - "SELECT last_seen FROM ems.site_heartbeat WHERE site_id = $1", - site_id, - ) - tomorrow_slots = await conn.fetchval( - """ - SELECT COUNT(*)::int - FROM ems.vw_site_effective_price v - WHERE v.site_id = $1 - AND (v.interval_start AT TIME ZONE $2)::date = - ((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date - """, - site_id, - tz, - ) + has_plan = bool(ctx.get("has_plan")) + mode_code = (ctx.get("mode_code") or "") or "" + reserve_soc = _float_or_none(ctx.get("reserve_soc")) + min_soc = _float_or_none(ctx.get("min_soc")) + soc = _float_or_none(ctx.get("soc_pct")) + inv_age = _age_seconds(_parse_ts(ctx.get("inv_measured_at"))) + hb_age = _age_seconds(_parse_ts(ctx.get("hb_last_seen"))) + tomorrow_slots = int(ctx.get("tomorrow_slots") or 0) - price_rows = await conn.fetch( - """ - SELECT interval_start, - effective_buy_price_czk_kwh, - effective_sell_price_czk_kwh - FROM ems.vw_site_effective_price - WHERE site_id = $1 - AND interval_start >= now() - AND interval_start < now() + INTERVAL '48 hours' - ORDER BY interval_start - """, - site_id, - ) + price_rows = ctx.get("price_slots") or [] + if not isinstance(price_rows, list): + price_rows = [] - avg_row = await conn.fetchrow( - """ - SELECT AVG(effective_buy_price_czk_kwh)::float AS avg_buy - FROM ems.vw_site_effective_price - WHERE site_id = $1 - AND interval_start::date IN (CURRENT_DATE, CURRENT_DATE + INTERVAL '1 day') - """, - site_id, - ) + avg_buy = _float_or_none(ctx.get("avg_buy")) + usable_wh = _float_or_none(ctx.get("usable_wh")) - bat_row = await conn.fetchrow( - """ - SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float AS usable_wh - FROM ems.asset_battery ab - JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id - WHERE ai.site_id = $1 - """, - site_id, - ) + ev_rows = ctx.get("ev_sessions") or [] + if not isinstance(ev_rows, list): + ev_rows = [] - ev_rows = await conn.fetch( - """ - SELECT DISTINCT ON (es.id) - es.id, - es.charger_id, - es.energy_delivered_wh, - es.target_soc_pct, - es.session_start, - es.soc_at_connect_pct, - COALESCE(av_id.battery_capacity_kwh, av_def.battery_capacity_kwh) AS battery_capacity_kwh, - COALESCE(av_id.make, av_def.make) AS make, - COALESCE(av_id.model, av_def.model) AS model, - COALESCE(av_id.default_target_soc_pct, av_def.default_target_soc_pct) AS default_target_soc_pct, - ac.code AS charger_code - FROM ems.ev_session es - JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id - LEFT JOIN ems.asset_vehicle av_id ON av_id.id = es.vehicle_id - LEFT JOIN ems.asset_vehicle av_def - ON av_def.default_charger_id = ac.id AND es.vehicle_id IS NULL - WHERE es.site_id = $1 AND es.session_end IS NULL - ORDER BY es.id, av_def.id NULLS LAST - """, - site_id, - ) - - neg_rows = await conn.fetch( - """ - SELECT predicted_date, window_start_hour, window_end_hour, probability_pct - FROM ems.predicted_negative_price_window - WHERE site_id = $1 - AND predicted_date BETWEEN CURRENT_DATE AND CURRENT_DATE + 2 - AND probability_pct >= 50 - ORDER BY predicted_date, window_start_hour - """, - site_id, - ) - - has_plan = run_row is not None - mode_code = (mode_row["mode_code"] if mode_row else None) or "" - reserve_soc = ( - float(reserve_row["reserve_soc"]) - if reserve_row and reserve_row["reserve_soc"] is not None - else None - ) - min_soc = ( - float(reserve_row["min_soc"]) - if reserve_row and reserve_row["min_soc"] is not None - else None - ) - soc = ( - float(inv_row["battery_soc_percent"]) - if inv_row and inv_row["battery_soc_percent"] is not None - else None - ) - inv_age = _age_seconds(inv_row["measured_at"] if inv_row else None) - hb_age = _age_seconds(hb_row["last_seen"] if hb_row else None) + neg_rows = ctx.get("neg_windows") or [] + if not isinstance(neg_rows, list): + neg_rows = [] infra = _infrastructure_notification_items( has_plan=has_plan, @@ -559,11 +384,15 @@ async def get_site_notifications( prices: list[PriceSlot] = [] for r in price_rows: - buy = _float_or_none(r["effective_buy_price_czk_kwh"]) + if not isinstance(r, dict): + continue + buy = _float_or_none(r.get("effective_buy_price_czk_kwh")) if buy is None: continue - sell_v = _float_or_none(r["effective_sell_price_czk_kwh"]) - istart = r["interval_start"] + sell_v = _float_or_none(r.get("effective_sell_price_czk_kwh")) + istart = r.get("interval_start") + if isinstance(istart, str): + istart = datetime.fromisoformat(istart.replace("Z", "+00:00")) prices.append( PriceSlot( interval_start=istart, @@ -572,43 +401,50 @@ async def get_site_notifications( ) ) - avg_buy = _float_or_none(avg_row["avg_buy"]) if avg_row else None - usable_wh = _float_or_none(bat_row["usable_wh"]) if bat_row else None battery_kwh = (usable_wh / 1000.0) if usable_wh is not None else None ev_sessions: list[EvSessionRow] = [] for er in ev_rows: + if not isinstance(er, dict): + continue + ss = er.get("session_start") + if isinstance(ss, str): + ss = datetime.fromisoformat(ss.replace("Z", "+00:00")) ev_sessions.append( EvSessionRow( id=int(er["id"]), charger_id=int(er["charger_id"]), - energy_delivered_wh=float(er["energy_delivered_wh"] or 0), - target_soc_pct=_float_or_none(er["target_soc_pct"]), - session_start=er["session_start"], - battery_capacity_kwh=_float_or_none(er["battery_capacity_kwh"]), - make=er["make"], - model=er["model"], - default_target_soc_pct=_float_or_none(er["default_target_soc_pct"]), - charger_code=str(er["charger_code"] or ""), - soc_at_connect_pct=_float_or_none(er["soc_at_connect_pct"]), + energy_delivered_wh=float(er.get("energy_delivered_wh") or 0), + target_soc_pct=_float_or_none(er.get("target_soc_pct")), + session_start=ss, + battery_capacity_kwh=_float_or_none(er.get("battery_capacity_kwh")), + make=er.get("make"), + model=er.get("model"), + default_target_soc_pct=_float_or_none(er.get("default_target_soc_pct")), + charger_code=str(er.get("charger_code") or ""), + soc_at_connect_pct=_float_or_none(er.get("soc_at_connect_pct")), ) ) neg_windows: list[NegWindowRow] = [] for nr in neg_rows: - dr = nr["predicted_date"] + if not isinstance(nr, dict): + continue + dr = nr.get("predicted_date") if isinstance(dr, datetime): d_conv = dr.date() elif isinstance(dr, date): d_conv = dr + elif isinstance(dr, str): + d_conv = date.fromisoformat(dr[:10]) else: d_conv = date.today() neg_windows.append( NegWindowRow( predicted_date=d_conv, - window_start_hour=int(nr["window_start_hour"]), - window_end_hour=int(nr["window_end_hour"]), - probability_pct=int(nr["probability_pct"]), + window_start_hour=int(nr.get("window_start_hour") or 0), + window_end_hour=int(nr.get("window_end_hour") or 0), + probability_pct=int(nr.get("probability_pct") or 0), ) ) diff --git a/backend/app/routers/plan.py b/backend/app/routers/plan.py index c9c6974..83df72a 100644 --- a/backend/app/routers/plan.py +++ b/backend/app/routers/plan.py @@ -1,5 +1,6 @@ """REST API – aktivní plán a ruční přepočet.""" +import json import logging from datetime import datetime, timezone from typing import Annotated, Any, Literal @@ -8,7 +9,7 @@ import asyncpg from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, ConfigDict, Field -from app.db_json import record_to_dict +from app.db_json import fetch_json from app.deps import get_pg_pool from services.control_exporter import export_setpoints from services.planning_engine import run_plan_api @@ -46,131 +47,36 @@ class CurrentPlanResponseModel(BaseModel): summary: dict[str, Any] -def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]: - total_cost = 0.0 - total_curtailed_kwh = 0.0 - charge_slots = 0 - discharge_slots = 0 - export_slots = 0 - for row in intervals: - ec = row.get("expected_cost_czk") - if ec is not None: - total_cost += float(ec) - c = row.get("pv_a_curtailed_w") or 0 - total_curtailed_kwh += int(c) * 0.25 / 1000.0 - b = row.get("battery_setpoint_w") - if b is not None: - if int(b) > 0: - charge_slots += 1 - elif int(b) < 0: - discharge_slots += 1 - g = row.get("grid_setpoint_w") - if g is not None and int(g) < 0: - export_slots += 1 - return { - "total_expected_cost_czk": round(total_cost, 4), - "total_pv_curtailed_kwh": round(total_curtailed_kwh, 6), - "charge_slots": charge_slots, - "discharge_slots": discharge_slots, - "export_slots": export_slots, - } - - -def _pv_scarcity_factor_from_intervals( - intervals: list[dict[str, Any]], battery_usable_wh: float | None -) -> float: - """Stejná logika jako v solveru: 0.65..1.0 podle očekávané FVE energie na ~24h.""" - if not intervals: - return 1.0 - batt_kwh = max(1.0, float(battery_usable_wh or 0.0) / 1000.0) - horizon_slots = min(len(intervals), int(24 / 0.25)) - pv_kwh = 0.0 - for row in intervals[:horizon_slots]: - pv = row.get("pv_forecast_total_w") - if pv is not None: - pv_kwh += max(0.0, float(pv)) * 0.25 / 1000.0 - coverage = pv_kwh / batt_kwh - coverage_clamped = max(0.0, min(1.0, coverage)) - return round(0.65 + 0.35 * coverage_clamped, 4) - - @router.get("/current", response_model=CurrentPlanResponseModel) async def get_current_plan( site_id: int, pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], ) -> CurrentPlanResponseModel: async with pool.acquire() as conn: - site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + site_ok = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id + ) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") - run_row = await conn.fetchrow( - """ - SELECT pr.* - FROM ems.planning_run pr - WHERE pr.site_id = $1 AND pr.status = 'active' - ORDER BY pr.created_at DESC - LIMIT 1 - """, + bundle = await fetch_json( + conn, + "select ems.fn_plan_current_bundle($1::int)", site_id, ) - if not run_row: - raise HTTPException(status_code=404, detail="No active plan") + if not isinstance(bundle, dict): + bundle = json.loads(bundle) + if bundle.get("error") == "no_active_plan": + raise HTTPException(status_code=404, detail="No active plan") - run_id = run_row["id"] - int_rows = await conn.fetch( - """ - WITH fc_slot AS ( - SELECT - interval_start, - COALESCE(SUM(power_w), 0)::BIGINT AS pv_forecast_total_w - FROM ( - SELECT DISTINCT ON (fpi.interval_start, fpr.pv_array_id) - fpi.interval_start, - fpi.power_w - FROM ems.forecast_pv_interval fpi - JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id - JOIN ems.asset_pv_array apa - ON apa.id = fpr.pv_array_id AND apa.site_id = fpr.site_id - WHERE fpr.site_id = $2 - AND fpr.status = 'ok' - ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC - ) latest_per_array - GROUP BY interval_start - ) - SELECT - pi.*, - ai.actual_pv_power_w AS pv_power_w, - fs.pv_forecast_total_w AS pv_forecast_total_w - FROM ems.planning_interval pi - LEFT JOIN ems.audit_interval ai - ON ai.site_id = $2 AND ai.interval_start = pi.interval_start - LEFT JOIN fc_slot fs ON fs.interval_start = pi.interval_start - WHERE pi.run_id = $1 - ORDER BY pi.interval_start - """, - run_id, - site_id, - ) - battery_usable_wh = await conn.fetchval( - """ - SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float - FROM ems.asset_battery ab - WHERE ab.site_id = $1 - """, - site_id, - ) - - intervals_raw = [record_to_dict(r) for r in int_rows] - summary = _build_summary(intervals_raw) - summary["pv_scarcity_factor"] = _pv_scarcity_factor_from_intervals( - intervals_raw, float(battery_usable_wh or 0.0) - ) - intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw] + intervals_raw = bundle.get("intervals") or [] + if not isinstance(intervals_raw, list): + intervals_raw = [] + intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)] return CurrentPlanResponseModel( - run=record_to_dict(run_row), + run=bundle.get("run") or {}, intervals=intervals, - summary=summary, + summary=bundle.get("summary") or {}, ) @@ -181,18 +87,14 @@ async def post_run_plan( plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"), ) -> RunPlanResponse: async with pool.acquire() as conn: - site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + site_ok = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id + ) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") days_with_prices = await conn.fetchval( - """ - SELECT COUNT(DISTINCT interval_start::date)::int AS days_with_prices - FROM ems.market_interval_price - WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM') - AND interval_start >= now() - AND interval_start < now() + INTERVAL '48 hours' - """ + "select ems.fn_planning_future_price_days()", ) if (days_with_prices or 0) < 1: raise HTTPException( @@ -204,14 +106,10 @@ async def post_run_plan( run_id, solver_duration_ms = await run_plan_api( site_id, plan_type, conn, triggered_by="api" ) - # Nový active run aplikuj hned; nečekej na periodický control_export job. await export_setpoints(site_id, conn) - row = await conn.fetchrow( - """ - SELECT horizon_start, horizon_end - FROM ems.planning_run - WHERE id = $1 - """, + row = await fetch_json( + conn, + "select ems.fn_planning_run_horizon($1::int)", run_id, ) except HTTPException: @@ -224,7 +122,7 @@ async def post_run_plan( logger.error("Plan run failed: %s", e, exc_info=True) raise HTTPException(status_code=422, detail=str(e)) from e - if row is None: + if not isinstance(row, dict) or row.get("horizon_start") is None: raise HTTPException(status_code=500, detail="Planning run row missing after insert") return RunPlanResponse( diff --git a/backend/app/routers/site_configuration.py b/backend/app/routers/site_configuration.py index 2ba4c7f..f44f9de 100644 --- a/backend/app/routers/site_configuration.py +++ b/backend/app/routers/site_configuration.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from datetime import datetime, timezone from typing import Annotated, Any @@ -9,7 +10,7 @@ import asyncpg from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from app.db_json import record_to_dict +from app.db_json import fetch_json from app.deps import get_pg_pool router = APIRouter(prefix="/sites/{site_id}", tags=["sites"]) @@ -19,39 +20,30 @@ class InverterModbusCurrentCapsBody(BaseModel): """Tvrdý strop proudu pro zápis Deye reg 108/109 (A); NULL ve JSONu = smaž strop v DB.""" deye_register_max_charge_a: int | None = Field( - default=None, ge=0, le=640, description="None při vynechání klíče = nezměnit; explicitní null = smazat strop" + default=None, + ge=0, + le=640, + description="None při vynechání klíče = nezměnit; explicitní null = smazat strop", ) deye_register_max_discharge_a: int | None = Field( - default=None, ge=0, le=640, description="Jako u nabíjení" + default=None, + ge=0, + le=640, + description="Jako u nabíjení", ) -_DEYE_KEYS = frozenset( - { - "deye_last_system_time_sync_at", - "deye_last_system_time_sync_minute", - "deye_last_tou_inactive_write_prague_date", - "deye_tou_inactive_signature", - } -) - -def _mask_secret_reference(raw: str | None) -> str | None: - if raw is None: +def _iso_utc_from_cfg(val: Any) -> str | None: + if val is None: return None - s = str(raw).strip() - if not s: - return None - if len(s) <= 4: - return "nastaveno" - return f"…{s[-2:]}" - - -def _iso_utc(dt: datetime | None) -> str | None: - if dt is None: - return None - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc).isoformat() + if isinstance(val, str): + return val + if isinstance(val, datetime): + dt = val + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + return str(val) @router.get("/configuration") @@ -60,204 +52,29 @@ async def get_site_configuration( pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], ) -> dict[str, Any]: async with pool.acquire() as conn: - site_row = await conn.fetchrow( - """ - SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at - FROM ems.site - WHERE id = $1 - """, + raw = await fetch_json( + conn, + "select ems.fn_site_configuration($1::int)", site_id, ) - if site_row is None: - raise HTTPException(status_code=404, detail="Site not found") - - grid_row = await conn.fetchrow( - "SELECT * FROM ems.site_grid_connection WHERE site_id = $1", - site_id, - ) - market_row = await conn.fetchrow( - """ - SELECT * - FROM ems.site_market_config - WHERE site_id = $1 - AND valid_from <= now() - AND (valid_to IS NULL OR valid_to > now()) - ORDER BY valid_from DESC - LIMIT 1 - """, - site_id, - ) - endpoint_rows = await conn.fetch( - """ - SELECT id, site_id, endpoint_type, host, port, protocol, unit_id, - auth_reference, enabled, notes - FROM ems.site_endpoint - WHERE site_id = $1 - ORDER BY id - """, - site_id, - ) - endpoints: list[dict[str, Any]] = [] - for er in endpoint_rows: - d = record_to_dict(er) - d["auth_reference"] = _mask_secret_reference(er["auth_reference"]) - endpoints.append(d) - - inv_rows = await conn.fetch( - """ - SELECT ai.*, - (SELECT ep.host || CASE - WHEN ep.port IS NOT NULL THEN ':' || ep.port::text - ELSE '' - END - FROM ems.site_endpoint ep - WHERE ep.id = ai.endpoint_id) AS endpoint_connection - FROM ems.asset_inverter ai - WHERE ai.site_id = $1 - ORDER BY ai.id - """, - site_id, - ) - inverters: list[dict[str, Any]] = [] - for ir in inv_rows: - full = record_to_dict(ir) - ep_label = full.pop("endpoint_connection", None) - core = {k: v for k, v in full.items() if k not in _DEYE_KEYS} - deye_meta = {k: full[k] for k in _DEYE_KEYS if full.get(k) is not None} - core["endpoint_connection"] = ep_label - core["deye_meta"] = deye_meta if deye_meta else None - inverters.append(core) - - bat_rows = await conn.fetch( - "SELECT * FROM ems.asset_battery WHERE site_id = $1 ORDER BY id", - site_id, - ) - pv_rows = await conn.fetch( - "SELECT * FROM ems.asset_pv_array WHERE site_id = $1 ORDER BY id", - site_id, - ) - ev_rows = await conn.fetch( - """ - SELECT ec.*, - se.host || CASE - WHEN se.port IS NOT NULL THEN ':' || se.port::text - ELSE '' - END AS endpoint_connection - FROM ems.asset_ev_charger ec - LEFT JOIN ems.site_endpoint se ON se.id = ec.endpoint_id - WHERE ec.site_id = $1 - ORDER BY ec.id - """, - site_id, - ) - ev_chargers = [record_to_dict(r) for r in ev_rows] - - veh_rows = await conn.fetch( - """ - SELECT id, site_id, code, name, make, model, battery_capacity_kwh, - max_charge_power_w, default_charger_id, api_type, api_reference, - default_target_soc_pct, default_deadline_hour, active - FROM ems.asset_vehicle - WHERE site_id = $1 - ORDER BY code - """, - site_id, - ) - vehicles: list[dict[str, Any]] = [] - for vr in veh_rows: - d = record_to_dict(vr) - d["api_reference"] = _mask_secret_reference(vr["api_reference"]) - vehicles.append(d) - - hp_rows = await conn.fetch( - """ - SELECT hp.*, - se.host || CASE - WHEN se.port IS NOT NULL THEN ':' || se.port::text - ELSE '' - END AS endpoint_connection - FROM ems.asset_heat_pump hp - LEFT JOIN ems.site_endpoint se ON se.id = hp.endpoint_id - WHERE hp.site_id = $1 - ORDER BY hp.id - """, - site_id, - ) - heat_pumps = [record_to_dict(r) for r in hp_rows] - - mode_row = await conn.fetchrow( - """ - SELECT m.mode_code, m.activated_at, m.activated_by, m.valid_until, - m.previous_mode, m.notes, - d.name AS mode_name, d.description AS mode_description, - d.loxone_mode_value, d.ev_enabled, d.heat_pump_enabled, - d.battery_mode, d.grid_mode, d.is_autonomous - FROM ems.site_operating_mode m - JOIN ems.operating_mode_def d ON d.code = m.mode_code - WHERE m.site_id = $1 - """, - site_id, - ) - - override_rows = await conn.fetch( - """ - SELECT id, override_type, value_json, valid_from, valid_to, reason, created_by, created_at - FROM ems.site_override - WHERE site_id = $1 - AND valid_from <= now() - AND (valid_to IS NULL OR valid_to > now()) - ORDER BY valid_from DESC - LIMIT 50 - """, - site_id, - ) - - hb_row = await conn.fetchrow( - "SELECT last_seen, status FROM ems.site_heartbeat WHERE site_id = $1", - site_id, - ) - run_row = await conn.fetchrow( - """ - SELECT id, created_at - FROM ems.planning_run - WHERE site_id = $1 AND status = 'active' - ORDER BY created_at DESC - LIMIT 1 - """, - site_id, - ) - - site = record_to_dict(site_row) - lat = site_row["latitude"] - lon = site_row["longitude"] - site["latitude"] = float(lat) if lat is not None else None - site["longitude"] = float(lon) if lon is not None else None - - operating_mode = record_to_dict(mode_row) if mode_row else None - - return { - "site": site, - "grid_connection": record_to_dict(grid_row) if grid_row else None, - "market_config": record_to_dict(market_row) if market_row else None, - "market_config_note": ( - "Zelený bonus za výrobu je u FVE polí (asset_pv_array), ne v obchodní konfiguraci." - ), - "endpoints": endpoints, - "inverters": inverters, - "batteries": [record_to_dict(r) for r in bat_rows], - "pv_arrays": [record_to_dict(r) for r in pv_rows], - "ev_chargers": ev_chargers, - "vehicles": vehicles, - "heat_pumps": heat_pumps, - "operating_mode": operating_mode, - "active_overrides": [record_to_dict(r) for r in override_rows], - "operational": { - "heartbeat_last_seen": _iso_utc(hb_row["last_seen"]) if hb_row else None, - "heartbeat_status": hb_row["status"] if hb_row else None, - "has_active_plan": run_row is not None, - "active_plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None, - }, - } + if raw is None: + raise HTTPException(status_code=404, detail="Site not found") + if not isinstance(raw, dict): + raw = json.loads(raw) + op = raw.get("operational") + if isinstance(op, dict): + op = dict(op) + op["heartbeat_last_seen"] = _iso_utc_from_cfg(op.get("heartbeat_last_seen")) + op["active_plan_created_at"] = _iso_utc_from_cfg(op.get("active_plan_created_at")) + raw["operational"] = op + lat = raw.get("site", {}).get("latitude") if isinstance(raw.get("site"), dict) else None + lon = raw.get("site", {}).get("longitude") if isinstance(raw.get("site"), dict) else None + if isinstance(raw.get("site"), dict): + site = dict(raw["site"]) + site["latitude"] = float(lat) if lat is not None else None + site["longitude"] = float(lon) if lon is not None else None + raw["site"] = site + return raw @router.patch("/inverters/{inverter_id}/modbus-current-caps") @@ -269,7 +86,6 @@ async def patch_inverter_modbus_current_caps( ) -> dict[str, Any]: """ Nastavení `deye_register_max_charge_a` / `deye_register_max_discharge_a` na `ems.asset_inverter`. - Hodnoty se uplatní v dotazu `_load_inverter_config` jako `COALESCE(strop_A, FLOOR(…z_kW))` pro reg 108/109. """ updates = body.model_dump(exclude_unset=True) if not updates: @@ -277,52 +93,29 @@ async def patch_inverter_modbus_current_caps( status_code=400, detail="Send at least one of: deye_register_max_charge_a, deye_register_max_discharge_a", ) + patch: dict[str, Any] = {} + if "deye_register_max_charge_a" in updates: + patch["deye_register_max_charge_a"] = updates["deye_register_max_charge_a"] + if "deye_register_max_discharge_a" in updates: + patch["deye_register_max_discharge_a"] = updates["deye_register_max_discharge_a"] + async with pool.acquire() as conn: - owner = await conn.fetchval( - """ - SELECT id FROM ems.asset_inverter - WHERE id = $1 AND site_id = $2 - """, - inverter_id, + raw = await fetch_json( + conn, + "select ems.fn_inverter_modbus_caps_patch($1::int, $2::int, $3::jsonb)", site_id, + inverter_id, + json.dumps(patch), ) - if owner is None: + if not isinstance(raw, dict): + raw = json.loads(raw) + if not raw.get("ok"): + if raw.get("error") == "not_found": raise HTTPException(status_code=404, detail="Inverter not found for this site") - - sets: list[str] = [] - args: list[Any] = [] - n = 1 - if "deye_register_max_charge_a" in updates: - sets.append(f"deye_register_max_charge_a = ${n}") - args.append(updates["deye_register_max_charge_a"]) - n += 1 - if "deye_register_max_discharge_a" in updates: - sets.append(f"deye_register_max_discharge_a = ${n}") - args.append(updates["deye_register_max_discharge_a"]) - n += 1 - - args.extend([inverter_id, site_id]) - await conn.execute( - f""" - UPDATE ems.asset_inverter - SET {", ".join(sets)} - WHERE id = ${n} AND site_id = ${n + 1} - """, - *args, - ) - row = await conn.fetchrow( - """ - SELECT id, code, deye_register_max_charge_a, deye_register_max_discharge_a - FROM ems.asset_inverter - WHERE id = $1 AND site_id = $2 - """, - inverter_id, - site_id, - ) - assert row is not None + raise HTTPException(status_code=400, detail=raw.get("error", "patch_failed")) return { - "inverter_id": int(row["id"]), - "code": row["code"], - "deye_register_max_charge_a": row["deye_register_max_charge_a"], - "deye_register_max_discharge_a": row["deye_register_max_discharge_a"], + "inverter_id": int(raw["inverter_id"]), + "code": raw["code"], + "deye_register_max_charge_a": raw.get("deye_register_max_charge_a"), + "deye_register_max_discharge_a": raw.get("deye_register_max_discharge_a"), } diff --git a/backend/services/audit_filler.py b/backend/services/audit_filler.py index 4e6a4e9..557c98b 100644 --- a/backend/services/audit_filler.py +++ b/backend/services/audit_filler.py @@ -3,51 +3,17 @@ from __future__ import annotations import logging -from datetime import datetime, timezone logger = logging.getLogger(__name__) async def fill_audit_for_completed_intervals(site_id: int, db) -> None: """ - Naplní audit_interval pro všechny dokončené 15min intervaly - za posledních 6 hodin které ještě nemají záznam. - Volá PostgreSQL funkci ems.fn_fill_audit_interval(). + Naplní audit_interval pro dokončené 15min sloty přes ems.fn_fill_audit_for_site_window. """ - now = datetime.now(timezone.utc) - last_complete = now.replace( - minute=(now.minute // 15) * 15, second=0, microsecond=0 - ) - - rows = await db.fetch( - """ - SELECT gs.slot - FROM generate_series( - $1::timestamptz - interval '6 hours', - $1::timestamptz - interval '15 minutes', - interval '15 minutes' - ) AS gs(slot) - WHERE NOT EXISTS ( - SELECT 1 FROM ems.audit_interval ai - WHERE ai.site_id = $2 AND ai.interval_start = gs.slot - ) - """, - last_complete, + n = await db.fetchval( + "select ems.fn_fill_audit_for_site_window($1::int, 6)", site_id, ) - - for row in rows: - slot = row["slot"] - await db.execute( - "SELECT ems.fn_fill_audit_interval($1, $2)", - site_id, - slot, - ) - await db.execute( - "SELECT ems.fn_fill_baseline_load_forecast_accuracy($1, $2)", - site_id, - slot, - ) - - if rows: - logger.info("[site=%s] Filled %s missing audit intervals", site_id, len(rows)) + if n: + logger.info("[site=%s] Filled %s missing audit intervals", site_id, int(n)) diff --git a/backend/services/control/__init__.py b/backend/services/control/__init__.py new file mode 100644 index 0000000..b1282d3 --- /dev/null +++ b/backend/services/control/__init__.py @@ -0,0 +1,3 @@ +"""Deye / Modbus control export (monolith v exporter_monolith.py – postupný split).""" + +from .exporter_monolith import * # noqa: F401,F403 diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py new file mode 100644 index 0000000..aef76e3 --- /dev/null +++ b/backend/services/control/exporter_monolith.py @@ -0,0 +1,1925 @@ +"""Export plánovaných setpointů na Modbus (Deye, EV, TČ) a HTTP do Loxone.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from collections import defaultdict +from dataclasses import dataclass +from typing import Any +from datetime import date, datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import asyncpg +import httpx + +from app.config import get_settings +from services.modbus_client import get_modbus_client + +logger = logging.getLogger(__name__) + +PRAGUE_TZ = ZoneInfo("Europe/Prague") + +# Hodiny Deye 62–64: po zápisu sekundy na zařízení dál běží → verify musí být toleranční. +DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120 +# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund… +DEYE_CLOCK_DRIFT_OK_SEC = 60 +# …a zároveň neuplynul tento interval od posledního syncu / potvrzení driftu. +DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24 + +# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md) +BATT_VOLTAGE_V = 51.2 + +# Reg 178 – pevné hodnoty (bit4–5); bez read-modify-write (kolize s Loxone / transaction ID) +REG178_SELL = 0b00100000 # 32, grid peak shaving disable +REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE) +# TOU reg 166+ ve PASSIVE při prioritě baterie: signál střídači „využij celý dostupný rozsah“, +# ne provozní strop z DB (ten je pro LP / Wh – viz asset_battery.max_soc_percent). +DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT = 100 +# Verify: jen bity 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone +REG178_VERIFY_MASK = 0x0030 + +# Po 3 neúspěšných verify pokusech → SELF_SUSTAIN jen u těchto registrech (bezpečnost / export). +# 62–64 řeší toleranční bundle (nemění režim). 178 a TOU power W jsou „soft“ — jen log + Discord. +DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145}) +# Výkonové řádky TOU (154 + slot_index 0…5) — firmware často přepíše na max W z max_charge/max_discharge A. +DEYE_TOU_POWER_REGS = frozenset(range(154, 160)) +# Deye LV: firmware často odmítne 351 A a drží 350 — horní strop pro zápis z DB. +DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350 + + +def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool: + return (int(expected_i) & REG178_VERIFY_MASK) == (int(actual_i) & REG178_VERIFY_MASK) + + +def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool: + """True = po 3× mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr).""" + return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN + + +def _deye_tou_power_verify_match( + expected_i: int, actual_i: int, inv: InverterConfig +) -> bool: + """Firmware často clampne TOU power W na max z reg. 108/109 × 51.2 V — akceptovat jako OK.""" + if int(actual_i) == int(expected_i): + return True + # 51.2 V — nesmí int(BATT_VOLTAGE_V)==51 (off-by-one vs. firmware 17920 W @ 350 A) + max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V) + max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) + a = int(actual_i) + return a == max_w_charge or a == max_w_discharge + + +def _deye_reg178_verify_with_double_read( + expected_i: int, actual_first: int, actual_second: int | None +) -> tuple[bool, int]: + """ + Vrátí (shoda, hodnota_pro_journal). + Druhé čtení použít jen když první neprojde maskou (RS485 / glitch). + """ + if _deye_reg178_verify_match(expected_i, actual_first): + return True, actual_first + if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second): + return True, int(actual_second) + return False, actual_first + +# Neaktivní TOU bloky (3–6): „konec dne“ — Deye často 23:59 (2359) neuloží a vrátí např. 2355, +# verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo). +DEYE_TOU_INACTIVE_HHMM = 2355 + +# Registry TOU řádků 3–6 (slot index 2…5): 150–153, 156–159, … — pro detekci skutečného zápisu po filtru „unchanged“. +_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset( + [ + 150, 151, 152, 153, + 156, 157, 158, 159, + 168, 169, 170, 171, + 174, 175, 176, 177, + ] +) + +# Systémový čas Deye — vždy toleranční verify jako celek 62–64 (reg 64 sám nesmí do striktní větve). +DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64}) + +DEYE_REGISTER_NAMES: dict[int, str] = { + 108: "max_charge_a (max nabíjecí proud baterie)", + 109: "max_discharge_a (max vybíjecí proud baterie)", + 141: "energy_mode (0, EMS nemění)", + 142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)", + 143: "export_limit_w (max export do sítě)", + 145: "solar_sell (0=disabled, 1=enabled)", + 178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)", + 148: "time_point_1_time", + 149: "time_point_2_time", + 154: "time_point_1_power_w", + 155: "time_point_2_power_w", + 166: "time_point_1_soc_min_pct", + 167: "time_point_2_soc_min_pct", + 172: "time_point_1_grid_charge", + 173: "time_point_2_grid_charge", + 62: "system_time_year_month", + 63: "system_time_day_hour", + 64: "system_time_min_sec", +} +for _tp_i in range(6): + _n = _tp_i + 1 + DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time") + DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w") + DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct") + DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge") + + +def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int: + if not power_w or power_w <= 0: + return 0 + return min(32, max(0, int(power_w / (phases * voltage)))) + + +def battery_watts_to_amps(power_w: int, max_amps: int) -> int: + """Proud z |výkonu| baterie; max_amps z DB (už COALESCE se stropy v SQL). + + int(|W|/51.2) — u kladných hodnot stejné jako floor bez importu math. + """ + derived = int(abs(power_w) / BATT_VOLTAGE_V) + return min(max(0, max_amps), max(0, derived)) + + +def current_slot_hhmm() -> int: + """Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM (např. 1415).""" + now = datetime.now(ZoneInfo("Europe/Prague")) + slot_min = (now.minute // 15) * 15 + return now.hour * 100 + slot_min + + +def next_slot_hhmm() -> int: + """Začátek příštího 15min slotu v Europe/Prague, formát HHMM (např. 1430).""" + now = datetime.now(ZoneInfo("Europe/Prague")) + minutes = now.minute + slot_minutes = ((minutes // 15) + 1) * 15 + if slot_minutes >= 60: + next_hour = (now.hour + 1) % 24 + next_min = 0 + else: + next_hour = now.hour + next_min = slot_minutes + return next_hour * 100 + next_min + + +@dataclass +class InverterConfig: + id: int + code: str + host: str + port: int + unit_id: int + max_export_power_w: int | None + max_import_power_w: int | None + no_export: bool + max_battery_charge_w: int | None + max_battery_discharge_w: int | None + min_soc_percent: int | None + reserve_soc_percent: int | None + max_soc_percent: int | None + usable_capacity_wh: int | None + max_charge_a: int + max_discharge_a: int + deye_last_system_time_sync_minute: datetime | None = None + deye_last_system_time_sync_at: datetime | None = None + deye_last_tou_inactive_write_prague_date: date | None = None + deye_tou_inactive_signature: str | None = None + deye_zero_export_mode: int = 1 + + +def _prague_minute_start_utc() -> datetime: + """UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague.""" + p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0) + return p.astimezone(timezone.utc) + + +def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None: + """Dekódování reg 62–64 (Deye system time v Europe/Prague).""" + try: + year = (int(r62) >> 8) + 2000 + month = int(r62) & 0xFF + day = int(r63) >> 8 + hour = int(r63) & 0xFF + minute = int(r64) >> 8 + second = int(r64) & 0xFF + if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23): + return None + if not (0 <= minute <= 59 and 0 <= second <= 59): + return None + return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ) + except (ValueError, OverflowError): + return None + + +def _deye_clock_registers_verify_match( + w62: int, + w63: int, + w64: int, + a62: int, + a63: int, + a64: int, +) -> bool: + w_dt = _deye_registers_to_prague_datetime(w62, w63, w64) + a_dt = _deye_registers_to_prague_datetime(a62, a63, a64) + if w_dt is None or a_dt is None: + return False + return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC + + +def _deye_should_skip_time_sync_after_read( + inv: InverterConfig, + r62: int, + r63: int, + r64: int, +) -> bool: + """ + True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného zápisu (FC 0x10 ACK) + nebo tolerančního ověření neuplynulo 24h — sloupec deye_last_system_time_sync_at doplňuje + write_inverter_setpoints po úspěšném zápisu batche obsahujícího 62–64 a znovu po úspěšném verify. + """ + dev = _deye_registers_to_prague_datetime(r62, r63, r64) + if dev is None: + return False + wall = datetime.now(PRAGUE_TZ) + drift = abs((wall - dev).total_seconds()) + if drift > DEYE_CLOCK_DRIFT_OK_SEC: + return False + last_write = inv.deye_last_system_time_sync_at + if last_write is None: + return False + if last_write.tzinfo is None: + last_write = last_write.replace(tzinfo=timezone.utc) + else: + last_write = last_write.astimezone(timezone.utc) + age = datetime.now(timezone.utc) - last_write + if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS): + return False + return True + + +async def _fetch_written_deye_clock_commands( + site_id: int, + asset_id: int, + host: str, + port: int, + unit_id: int, + db: asyncpg.Connection, +) -> list[asyncpg.Record]: + """Všechny řádky journalu 62–64 ve stavu written pro daný invertor/endpoint.""" + rows = await db.fetch( + """ + SELECT * FROM ems.modbus_command + WHERE site_id = $1 + AND asset_type = 'inverter' + AND asset_id = $2 + AND device_host = $3 + AND device_port = $4 + AND device_unit_id = $5 + AND register IN (62, 63, 64) + AND status = 'written' + ORDER BY register + """, + site_id, + asset_id, + host, + port, + unit_id, + ) + return list(rows) + + +async def _fetch_last_verified_inverter_registers( + site_id: int, inverter_asset_id: int, db: asyncpg.Connection +) -> dict[int, int]: + """ + Poslední hodnota na zařízení podle journalu (jen status verified). + Slouží k přeskočení duplicitního zápisu stejné hodnoty. + """ + raw = await db.fetchval( + """ + select ems.fn_modbus_last_verified_map($1::int, $2::int) + """, + site_id, + inverter_asset_id, + ) + data = raw if isinstance(raw, dict) else json.loads(raw) + return {int(k): int(v) for k, v in data.items()} + + +def _drop_registers_matching_last_verified( + registers: list[tuple[int, str, int]], + last_verified: dict[int, int], +) -> tuple[list[tuple[int, str, int]], list[int]]: + """Vynechá položky s hodnotou shodnou s posledním ověřeným stavem; vrátí (nový seznam, vynechané reg).""" + out: list[tuple[int, str, int]] = [] + skipped: list[int] = [] + for reg, meta, val in registers: + lv = last_verified.get(int(reg)) + if lv is not None and lv == int(val): + skipped.append(int(reg)) + continue + out.append((reg, meta, val)) + return out, skipped + + +@dataclass +class ControlSetpoints: + battery_w: int | None + grid_export_limit: int + ev1_current_a: int + ev2_current_a: int + heat_pump_enable: bool + grid_setpoint_w: int + ev1_power_w: int + ev2_power_w: int + target_soc_pct: int | None = None + #: Efektivní vykupní cena slotu (Kč/kWh z plánu); pro TOU řízení priorit baterie vs. přetok + effective_sell_price_czk_kwh: float | None = None + #: True = reg 108/109 na 0 (PRESERVE – Deye baterii nepoužívá) + lock_battery: bool = False + #: Režim SELF_SUSTAIN: plný rozsah nabíjení/vybíjení na invertoru + zero-export (reg 142) a nízké TOU %. + self_sustain_local_use: bool = False + + +@dataclass +class OperatingModeInfo: + mode_code: str + battery_mode: str + grid_mode: str + ev_enabled: bool + heat_pump_enabled_def: bool + loxone_mode_value: int + + +async def create_modbus_commands( + site_id: int, + planning_run_id: int | None, + asset_type: str, + asset_id: int, + asset_code: str, + host: str, + port: int, + unit_id: int, + registers: list[tuple[int, str, int]], + db: asyncpg.Connection, + deye_physical_mode: str | None = None, +) -> list[int]: + """ + Vytvoří záznamy v modbus_command pro sadu zápisů. + Vrátí list command IDs. + Pro Deye se jméno registru bere z DEYE_REGISTER_NAMES (prostřední položka tuplu se ignoruje). + """ + ids: list[int] = [] + for reg, _ignored_name, val in registers: + register_name = DEYE_REGISTER_NAMES.get(reg, f"reg_{reg}") + cmd_id = await db.fetchval( + """ + INSERT INTO ems.modbus_command + (site_id, asset_type, asset_id, asset_code, + device_host, device_port, device_unit_id, + register, register_name, value_to_write, + planning_run_id, status, deye_physical_mode) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12) + RETURNING id + """, + site_id, + asset_type, + asset_id, + asset_code, + host, + port, + unit_id, + reg, + register_name, + val, + planning_run_id, + deye_physical_mode, + ) + if cmd_id is not None: + ids.append(int(cmd_id)) + return ids + + +def _modbus_command_contiguous_runs(cmds: list[asyncpg.Record]) -> list[list[asyncpg.Record]]: + """Seřadí podle adresy registru a rozdělí na souvislé úseky pro FC 0x10 / FC 3.""" + if not cmds: + return [] + sorted_cmds = sorted(cmds, key=lambda c: int(c["register"])) + runs: list[list[asyncpg.Record]] = [] + cur: list[asyncpg.Record] = [sorted_cmds[0]] + for c in sorted_cmds[1:]: + if int(c["register"]) == int(cur[-1]["register"]) + 1: + cur.append(c) + else: + runs.append(cur) + cur = [c] + runs.append(cur) + return runs + + +async def execute_modbus_commands( + command_ids: list[int], + db: asyncpg.Connection, +) -> bool: + """ + Zapíše příkazy z modbus_command do zařízení (FC 0x10 po souvislých blocích). + Aktualizuje status na 'written' nebo 'failed'. + Vrátí True pokud všechny příkazy uspěly. + """ + MAX_RETRIES = 3 + RETRY_DELAY = 0.5 + + rows: list[asyncpg.Record] = [] + for cmd_id in command_ids: + cmd = await db.fetchrow( + "SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id + ) + if cmd is not None: + rows.append(cmd) + + if not rows: + return True + + by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list) + for cmd in rows: + by_gw[ + (cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])) + ].append(cmd) + + all_ok = True + for (host, port, unit), group in by_gw.items(): + client = await get_modbus_client(host, port) + for run in _modbus_command_contiguous_runs(group): + start_reg = int(run[0]["register"]) + values = [int(c["value_to_write"]) for c in run] + ids_run = [int(c["id"]) for c in run] + for attempt in range(MAX_RETRIES): + try: + await client.write_registers(start_reg, values, unit) + for cmd, val in zip(run, values): + cid = int(cmd["id"]) + await db.execute( + """ + UPDATE ems.modbus_command + SET status='written', value_written=$1, written_at=now(), + attempt_count=attempt_count+1, error_msg=NULL + WHERE id=$2 + """, + val, + cid, + ) + logger.info( + "[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)", + cid, + cmd["asset_code"], + int(cmd["register"]), + val, + start_reg, + attempt + 1, + ) + break + except Exception as e: + if attempt < MAX_RETRIES - 1: + logger.warning( + "Modbus batch write 0x%04X count=%s attempt %s failed: %s, retrying...", + start_reg, + len(values), + attempt + 1, + e, + ) + await asyncio.sleep(RETRY_DELAY) + await client.force_disconnect() + else: + for cmd in run: + await db.execute( + """ + UPDATE ems.modbus_command + SET status='failed', error_msg=$1, + attempt_count=attempt_count+1 + WHERE id=$2 + """, + str(e), + int(cmd["id"]), + ) + logger.error( + "Modbus batch 0x%04X count=%s all %s attempts failed: %s", + start_reg, + len(values), + MAX_RETRIES, + e, + ) + all_ok = False + + return all_ok + + +async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None: + """Přepne lokalitu na SELF_SUSTAIN, zaloguje důvod a při změně pošle Discord.""" + from services.notification_service import run_fn_set_mode_with_discord + + await run_fn_set_mode_with_discord( + db, + site_id, + "SELF_SUSTAIN", + "system:mismatch", + None, + reason, + ) + logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason) + + +def _modbus_cmd_register(cmd: Any) -> int: + """asyncpg.Record má __getitem__; objekty s atributem .register též (testy).""" + try: + return int(cmd["register"]) + except (KeyError, TypeError): + return int(cmd.register) + + +def _deye_expected_clock_triplet_for_verify( + bundle: list[asyncpg.Record], + last_verified: dict[int, int], + a62: int, + a63: int, + a64: int, +) -> tuple[int, int, int]: + """ + Sestaví očekávané (w62,w63,w64) pro toleranční verify. + Chybějící registry doplní poslední verified nebo aktuálním přečtením ze zařízení + (aby osiřelý zápis např. jen 64 nešel do striktního porovnání reg64). + """ + by_reg = {_modbus_cmd_register(c): c for c in bundle} + def _vtw(c: Any) -> int: + try: + return int(c["value_to_write"]) + except (KeyError, TypeError): + return int(c.value_to_write) + + w62 = _vtw(by_reg[62]) if 62 in by_reg else last_verified.get(62, a62) + w63 = _vtw(by_reg[63]) if 63 in by_reg else last_verified.get(63, a63) + w64 = _vtw(by_reg[64]) if 64 in by_reg else last_verified.get(64, a64) + return (int(w62), int(w63), int(w64)) + + +async def _verify_deye_clock_written_bundle( + site_id: int, + bundle: list[asyncpg.Record], + a62: int, + a63: int, + a64: int, + db: asyncpg.Connection, +) -> bool: + """ + Toleranční ověření pro jeden až tři řádky journalu 62–64 ve stavu written. + Při mismatch retry společně; bez přepnutí do SELF_SUSTAIN po 3 pokusech. + """ + from services.notification_service import ( + notify_modbus_clock_verify_exhausted, + notify_modbus_mismatch, + ) + + cmds_s = sorted(bundle, key=_modbus_cmd_register) + try: + asset_id = int(cmds_s[0]["asset_id"]) + except (KeyError, TypeError): + asset_id = int(cmds_s[0].asset_id) + last_v = await _fetch_last_verified_inverter_registers(site_id, asset_id, db) + w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last_v, a62, a63, a64) + clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64) + actual_by_reg = {62: a62, 63: a63, 64: a64} + + for cmd in cmds_s: + try: + cid = int(cmd["id"]) + except (KeyError, TypeError): + cid = int(cmd.id) + r = _modbus_cmd_register(cmd) + await db.execute( + """ + UPDATE ems.modbus_command + SET value_verified=$1::int, verified_at=now(), + status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END + WHERE id=$3::int + """, + actual_by_reg[r], + clock_ok, + cid, + ) + + if clock_ok: + await db.execute( + """ + UPDATE ems.asset_inverter + SET deye_last_system_time_sync_minute = $1, + deye_last_system_time_sync_at = now() + WHERE id = $2 + """, + _prague_minute_start_utc(), + asset_id, + ) + for cmd in cmds_s: + try: + cid_l = int(cmd["id"]) + except (KeyError, TypeError): + cid_l = int(cmd.id) + try: + code_l = str(cmd["asset_code"]) + except (KeyError, TypeError): + code_l = str(cmd.asset_code) + rr = _modbus_cmd_register(cmd) + logger.info( + "[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s", + cid_l, + code_l, + rr, + actual_by_reg[rr], + ) + return True + + cmd0 = cmds_s[0] + try: + ac0 = str(cmd0["asset_code"]) + except (KeyError, TypeError): + ac0 = str(cmd0.asset_code) + logger.error( + "[cmd clock] MISMATCH %s 62–64: written=(%s,%s,%s) actual=(%s,%s,%s)", + ac0, + w62, + w63, + w64, + a62, + a63, + a64, + ) + + attempts = 0 + for cmd in cmds_s: + try: + cid_q = int(cmd["id"]) + except (KeyError, TypeError): + cid_q = int(cmd.id) + row_ac = await db.fetchrow( + "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cid_q + ) + ac = int(row_ac["attempt_count"] or 0) if row_ac else 0 + attempts = max(attempts, ac) + + await notify_modbus_mismatch( + ac0, + 62, + "system_time_62_64", + w62, + a62, + attempts, + ) + + ids_ordered = [] + for c in cmds_s: + try: + ids_ordered.append(int(c["id"])) + except (KeyError, TypeError): + ids_ordered.append(int(c.id)) + if attempts < 3: + for cid in ids_ordered: + await db.execute( + "UPDATE ems.modbus_command SET status='retrying' WHERE id=$1", + cid, + ) + await execute_modbus_commands(ids_ordered, db) + await verify_modbus_commands(ids_ordered, db, site_id) + else: + logger.critical( + "[cmd clock] 3 failed verify attempts (62–64); režim se nemění automaticky" + ) + site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id) + await notify_modbus_clock_verify_exhausted( + site["code"] if site else str(site_id), + ac0, + (w62, w63, w64), + (a62, a63, a64), + ) + return False + + +async def verify_modbus_commands( + command_ids: list[int], + db: asyncpg.Connection, + site_id: int, +) -> bool: + """ + Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write. + Při mismatch: retry (až 3×). Po vyčerpání pokusů u kritických registrů (108, 109, 142, 143, 145) + → SELF_SUSTAIN + Discord; u „soft“ (178, TOU power W) jen log + Discord, režim se nemění. + """ + from services.notification_service import notify_modbus_mismatch + + inv_cfg = await _load_inverter_config(site_id, db) + + async def _apply_verify_result( + cmd: asyncpg.Record, + actual_i: int, + *, + client: Any, + unit: int, + ) -> bool: + """Vrátí True při shodě, False při mismatch (a obslouží retry / SELF_SUSTAIN).""" + reg = int(cmd["register"]) + cmd_id = int(cmd["id"]) + + if reg in DEYE_CLOCK_REGS: + asset_id = int(cmd["asset_id"]) + host = str(cmd["device_host"]) + port_i = int(cmd["device_port"]) + uid = int(cmd["device_unit_id"]) + bundle = await _fetch_written_deye_clock_commands( + site_id, asset_id, host, port_i, uid, db + ) + if not bundle: + bundle = [cmd] + try: + cvals = await client.read_holding_registers(62, 3, uid) + except Exception as e: + logger.error( + "verify clock guard read 62–64 failed (reg 0x%04X): %s", reg, e + ) + return False + if len(cvals) != 3: + logger.error( + "verify clock guard: expected 3 regs, got %s", len(cvals) + ) + return False + logger.warning( + "Clock register 0x%04X reached strict verify path; using tolerant 62–64 bundle", + reg, + ) + return await _verify_deye_clock_written_bundle( + site_id, + bundle, + int(cvals[0]), + int(cvals[1]), + int(cvals[2]), + db, + ) + + expected_i = int(cmd["value_to_write"]) + matches = actual_i == expected_i + if reg == 178: + first_178 = int(actual_i) + second_178: int | None = None + if not _deye_reg178_verify_match(expected_i, first_178): + try: + r178 = await client.read_holding_registers(178, 1, unit) + if r178 and len(r178) >= 1: + second_178 = int(r178[0]) + except Exception as e: + logger.warning( + "[cmd %s] reg178 double-read failed: %s", cmd_id, e + ) + matches, actual_i = _deye_reg178_verify_with_double_read( + expected_i, first_178, second_178 + ) + if ( + matches + and second_178 is not None + and not _deye_reg178_verify_match(expected_i, first_178) + ): + logger.info( + "[cmd %s] reg178 double-read recovered: first=%s second=%s", + cmd_id, + first_178, + second_178, + ) + if not matches and reg in DEYE_TOU_POWER_REGS and inv_cfg is not None: + matches = _deye_tou_power_verify_match(expected_i, actual_i, inv_cfg) + + await db.execute( + """ + UPDATE ems.modbus_command + SET value_verified=$1::int, verified_at=now(), + status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END + WHERE id=$3::int + """, + actual_i, + matches, + cmd_id, + ) + + if not matches: + logger.error( + "[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s%s", + cmd_id, + cmd["asset_code"], + reg, + expected_i, + actual_i, + " (reg178 mask 0x%04X)" % REG178_VERIFY_MASK if reg == 178 else "", + ) + row_ac = await db.fetchrow( + "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id + ) + attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0 + await notify_modbus_mismatch( + cmd["asset_code"], + reg, + cmd["register_name"] or "", + expected_i, + actual_i, + attempts, + ) + + if attempts < 3: + await db.execute( + "UPDATE ems.modbus_command SET status='retrying' WHERE id=$1", + cmd_id, + ) + await execute_modbus_commands([cmd_id], db) + await verify_modbus_commands([cmd_id], db, site_id) + else: + if deye_reg_triggers_self_sustain_after_verify_exhaust(reg): + logger.critical( + "[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN", + cmd_id, + ) + await _switch_to_self_sustain( + site_id, + db, + reason=( + f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} " + f"reg 0x{reg:04X}" + ), + ) + else: + logger.warning( + "[cmd %s] 3 failed verify attempts on non-critical reg 0x%04X " + "(no mode change): %s", + cmd_id, + reg, + cmd["asset_code"], + ) + return False + + if reg == 178 and actual_i != expected_i: + logger.info( + "[cmd %s] verified OK (reg178 masked): %s 0x%04X value_to_write=%s actual=%s", + cmd_id, + cmd["asset_code"], + reg, + expected_i, + actual_i, + ) + else: + logger.info( + "[cmd %s] verified OK: %s 0x%04X=%s", + cmd_id, + cmd["asset_code"], + reg, + actual_i, + ) + return True + + cmds: list[asyncpg.Record] = [] + for cmd_id in command_ids: + cmd = await db.fetchrow( + "SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id + ) + if cmd is not None and cmd["status"] == "written": + cmds.append(cmd) + + if not cmds: + return True + + by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list) + for cmd in cmds: + by_gw[ + (cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])) + ].append(cmd) + + all_ok = True + for (host, port, unit), group in by_gw.items(): + client = await get_modbus_client(host, port) + clock_cmds = [c for c in group if int(c["register"]) in DEYE_CLOCK_REGS] + rest = [c for c in group if int(c["register"]) not in DEYE_CLOCK_REGS] + + if clock_cmds: + asset_id = int(clock_cmds[0]["asset_id"]) + bundle = await _fetch_written_deye_clock_commands( + site_id, asset_id, host, port, unit, db + ) + if not bundle: + bundle = clock_cmds + try: + cvals = await client.read_holding_registers(62, 3, unit) + except Exception as e: + logger.error("verify clock read 62–64 failed: %s", e) + all_ok = False + else: + if len(cvals) != 3: + logger.error( + "verify clock read: expected 3 regs, got %s", len(cvals) + ) + all_ok = False + else: + matched = await _verify_deye_clock_written_bundle( + site_id, + bundle, + int(cvals[0]), + int(cvals[1]), + int(cvals[2]), + db, + ) + if not matched: + all_ok = False + + for run in _modbus_command_contiguous_runs(rest): + start_reg = int(run[0]["register"]) + n = len(run) + try: + values = await client.read_holding_registers(start_reg, n, unit) + except Exception as e: + logger.error( + "verify batch read 0x%04X count=%s failed: %s", start_reg, n, e + ) + all_ok = False + continue + if len(values) != n: + logger.error( + "verify read 0x%04X: expected %s regs, got %s", + start_reg, + n, + len(values), + ) + all_ok = False + continue + for cmd, actual in zip(run, values): + matched = await _apply_verify_result( + cmd, int(actual), client=client, unit=unit + ) + if not matched: + all_ok = False + + return all_ok + + +async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None: + sql = """ + SELECT som.mode_code, omd.battery_mode, omd.grid_mode, + omd.ev_enabled, omd.heat_pump_enabled, omd.loxone_mode_value, + som.valid_until + FROM ems.site_operating_mode som + JOIN ems.operating_mode_def omd ON omd.code = som.mode_code + WHERE som.site_id = $1 + """ + row = await db.fetchrow(sql, site_id) + if row is None: + return None + vu = row["valid_until"] + if vu is not None: + now_utc = datetime.now(timezone.utc) + if vu.tzinfo is None: + vu = vu.replace(tzinfo=timezone.utc) + if vu <= now_utc: + exp_rows = await db.fetch("SELECT * FROM ems.fn_expire_modes()") + from services.notification_service import notify_operating_mode_changed + + for er in exp_rows: + await notify_operating_mode_changed( + str(er["site_code"]), + str(er["old_mode"]), + str(er["new_mode"]), + "system:expiry", + "Automatické vypršení dočasného režimu", + ) + row = await db.fetchrow(sql, site_id) + if row is None: + return None + return OperatingModeInfo( + mode_code=row["mode_code"], + battery_mode=row["battery_mode"], + grid_mode=row["grid_mode"], + ev_enabled=bool(row["ev_enabled"]), + heat_pump_enabled_def=bool(row["heat_pump_enabled"]), + loxone_mode_value=int(row["loxone_mode_value"]), + ) + + +async def _get_current_soc(site_id: int, db: asyncpg.Connection) -> int: + soc = await db.fetchval( + """ + SELECT battery_soc_percent + FROM ems.telemetry_inverter + WHERE site_id = $1 AND battery_soc_percent IS NOT NULL + ORDER BY measured_at DESC + LIMIT 1 + """, + site_id, + ) + return int(soc) if soc is not None else 50 + + +async def _load_inverter_config( + site_id: int, db: asyncpg.Connection +) -> InverterConfig | None: + row = await db.fetchrow( + """ + SELECT + ai.id, ai.code, + se.host, se.port, se.unit_id, + sgc.max_export_power_w, + sgc.max_import_power_w, + sgc.no_export, + ai.max_battery_charge_w, + ai.max_battery_discharge_w, + ab.min_soc_percent, + ab.reserve_soc_percent, + ab.max_soc_percent, + ab.usable_capacity_wh, + ai.deye_last_system_time_sync_minute, + ai.deye_last_system_time_sync_at, + ai.deye_last_tou_inactive_write_prague_date, + ai.deye_tou_inactive_signature, + COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode, + COALESCE( + ai.deye_register_max_charge_a, + FLOOR( + LEAST( + COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w), + ai.max_battery_charge_w + )::numeric / 51.2 + )::int + ) AS max_charge_a, + COALESCE( + ai.deye_register_max_discharge_a, + FLOOR( + LEAST( + COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w), + ai.max_battery_discharge_w + )::numeric / 51.2 + )::int + ) AS max_discharge_a + FROM ems.asset_inverter ai + JOIN ems.site_endpoint se ON se.id = ai.endpoint_id + JOIN ems.asset_battery ab ON ab.inverter_id = ai.id + LEFT JOIN ems.site_grid_connection sgc ON sgc.site_id = ai.site_id + WHERE ai.site_id = $1 + AND ai.active = true + AND ai.controllable = true + AND se.enabled = true + AND se.endpoint_type = 'modbus_tcp' + ORDER BY ai.id + LIMIT 1 + """, + site_id, + ) + if row is None: + return None + mc = row["max_charge_a"] + md = row["max_discharge_a"] + max_charge_a = int(mc) if mc is not None else 0 + max_discharge_a = int(md) if md is not None else 0 + # Firmware Deye často drží max 350 A — vyšší hodnota z DB → mismatch 351 vs 350. + max_charge_a = min(max_charge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A) + max_discharge_a = min(max_discharge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A) + port = int(row["port"] or 502) + uid = int(row["unit_id"] if row["unit_id"] is not None else 1) + return InverterConfig( + id=int(row["id"]), + code=row["code"], + host=row["host"], + port=port, + unit_id=uid, + max_export_power_w=int(row["max_export_power_w"]) + if row["max_export_power_w"] is not None + else None, + max_import_power_w=int(row["max_import_power_w"]) + if row["max_import_power_w"] is not None + else None, + no_export=bool(row["no_export"] or False), + max_battery_charge_w=int(row["max_battery_charge_w"]) + if row["max_battery_charge_w"] is not None + else None, + max_battery_discharge_w=int(row["max_battery_discharge_w"]) + if row["max_battery_discharge_w"] is not None + else None, + min_soc_percent=int(round(float(row["min_soc_percent"]))) + if row["min_soc_percent"] is not None + else None, + reserve_soc_percent=int(row["reserve_soc_percent"]) + if row["reserve_soc_percent"] is not None + else None, + max_soc_percent=int(row["max_soc_percent"]) + if row["max_soc_percent"] is not None + else None, + usable_capacity_wh=int(row["usable_capacity_wh"]) + if row["usable_capacity_wh"] is not None + else None, + max_charge_a=max_charge_a, + max_discharge_a=max_discharge_a, + deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"], + deye_last_system_time_sync_at=row["deye_last_system_time_sync_at"], + deye_last_tou_inactive_write_prague_date=row[ + "deye_last_tou_inactive_write_prague_date" + ], + deye_tou_inactive_signature=row["deye_tou_inactive_signature"], + deye_zero_export_mode=int(row["deye_zero_export_mode"]), + ) + + +def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]: + """Hodnoty pro reg 62–64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis).""" + now = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0) + reg62 = ((now.year - 2000) << 8) | now.month + reg63 = (now.day << 8) | now.hour + reg64 = (now.minute << 8) | 0 + rows = [ + (62, "", reg62), + (63, "", reg63), + (64, "", reg64), + ] + return now, rows + + +def _deye_time_point_rows( + slot_index: int, + time_hhmm: int, + power_w: int, + soc_pct: int, + grid_charge: bool, +) -> list[tuple[int, str, int]]: + g = 1 if grid_charge else 0 + return [ + (148 + slot_index, "", time_hhmm), + (154 + slot_index, "", power_w), + (166 + slot_index, "", soc_pct), + (172 + slot_index, "", g), + ] + + +async def _fetch_plan_row_for_slot_offset( + site_id: int, db: asyncpg.Connection, slot_offset: int +) -> asyncpg.Record | None: + """Řádek plánu pro slot z ems.fn_planning_interval_at_offset (jsonb → Record-like dict).""" + raw = await db.fetchval( + """ + select ems.fn_planning_interval_at_offset($1::int, $2::int) + """, + site_id, + slot_offset, + ) + if raw is None: + return None + data = raw if isinstance(raw, dict) else json.loads(raw) + if not data: + return None + return _DictRecord(data) + + +async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int: + v = await db.fetchval( + "select ems.fn_planning_max_effective_charge_w($1::int)", + site_id, + ) + return int(v or 0) + + +class _DictRecord: + """Minimální asyncpg Record kompatibilita pro dict z jsonb.""" + + __slots__ = ("_d",) + + def __init__(self, d: dict[str, Any]) -> None: + self._d = d + + def __getitem__(self, k: str) -> Any: + return self._d[k] + + def get(self, k: str, default: Any = None) -> Any: + return self._d.get(k, default) + + def __contains__(self, k: str) -> bool: + return k in self._d + + +def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> ControlSetpoints | None: + code = mode.mode_code + if code == "MANUAL": + return None + + if code == "AUTO": + if pi is None: + return None + grid_sp = int(pi["grid_setpoint_w"] or 0) + ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0 + ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0 + hp_en = bool(pi["heat_pump_enabled"]) + tgt = pi["battery_soc_target_pct"] + target_soc = int(round(float(tgt))) if tgt is not None else None + sell_raw = pi.get("effective_sell_price") + sell_f: float | None = float(sell_raw) if sell_raw is not None else None + return ControlSetpoints( + battery_w=int(pi["battery_setpoint_w"] or 0), + grid_export_limit=abs(min(grid_sp, 0)), + ev1_current_a=watts_to_amps(ev1_w, phases=3), + ev2_current_a=watts_to_amps(ev2_w, phases=1), + heat_pump_enable=hp_en, + grid_setpoint_w=grid_sp, + ev1_power_w=ev1_w, + ev2_power_w=ev2_w, + target_soc_pct=target_soc, + effective_sell_price_czk_kwh=sell_f, + ) + + if code == "SELF_SUSTAIN": + return ControlSetpoints( + battery_w=None, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=0, + ev1_power_w=0, + ev2_power_w=0, + target_soc_pct=None, + self_sustain_local_use=True, + ) + + if code == "CHARGE_CHEAP": + # max_charge doplníme v export_setpoints z DB + return ControlSetpoints( + battery_w=0, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=0, + ev1_power_w=0, + ev2_power_w=0, + target_soc_pct=None, + ) + + if code == "PRESERVE": + return ControlSetpoints( + battery_w=0, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=0, + ev1_power_w=0, + ev2_power_w=0, + target_soc_pct=None, + lock_battery=True, + ) + + logger.warning("Unknown mode_code %s for site export, skipping", code) + return None + + +def _apply_price_failsafe_guard( + site_id: int, + mode: OperatingModeInfo, + pi: asyncpg.Record | None, + sp: ControlSetpoints, +) -> ControlSetpoints: + if mode.mode_code != "AUTO" or pi is None: + return sp + if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]): + return sp + logger.warning( + "control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard", + site_id, + ) + return ControlSetpoints( + battery_w=0, + grid_export_limit=0, + ev1_current_a=sp.ev1_current_a, + ev2_current_a=sp.ev2_current_a, + heat_pump_enable=sp.heat_pump_enable, + grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)), + ev1_power_w=sp.ev1_power_w, + ev2_power_w=sp.ev2_power_w, + target_soc_pct=sp.target_soc_pct, + effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh, + ) + + +def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int: + """Reg 143 – max export W z DB (např. SUN-20K / home-01 = 13 500 W).""" + if no_export: + return 0 + return max(0, int(max_export_power_w or 0)) + + +def _clamp_deye_tou_soc_pct(pct: int) -> int: + return max(5, min(95, pct)) + + +def _clamp_deye_tou_soc_pct_hi(pct: int, hi: int) -> int: + """Stejné dolní omezení 5 % jako u TOU; horní mez z parametru (např. 100 u priority baterie).""" + return max(5, min(int(hi), int(pct))) + + +def _deye_tou_min_soc_pct(inv: InverterConfig) -> int: + if inv.min_soc_percent is not None: + return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent)) + return 10 + + +def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int: + if inv.reserve_soc_percent is not None: + return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent)) + return 20 + + +def _deye_passive_tou_battery_soc_pct( + inv: InverterConfig, + setpoints: ControlSetpoints, +) -> int: + """ + Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE. + + Na home-01 Deye interpretuje TOU % jako „kam má směřovat využití baterie“: + je-li zapsané procento **nižší než skutečný SoC**, přebytek FVE míří spíš do sítě. + + Při **záporné vykupní** nebo **plánovaném nabíjení** (kladný ``battery_w``) EMS + zapíše **100 %** do TOU (signál střídači „ber přebytek do baterie v celém rozsahu“). + **``max_soc_percent`` v DB** je odděleně: horní limit pro **plánovač / Wh bilance** + (denní provoz, viz komentář sloupce), **nikoli** časové „do kdy“. + + Jinak zůstane provozní podlaha ``min_soc_percent`` (typicky nízká % → přetok do sítě + možný dle chování Deye). + + Režim **SELF_SUSTAIN** (``self_sustain_local_use``): vždy ``min_soc_percent`` — nízké + TOU drží prioritu „baterie jako buffer“ při plném reg. 108/109 a reg. 142 zero-export; + neaplikuje se sem logika 100 % podle ceny (LP se v SELF_SUSTAIN nepoužívá). + """ + mn = _deye_tou_min_soc_pct(inv) + if setpoints.self_sustain_local_use: + return mn + + bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w) + sell = setpoints.effective_sell_price_czk_kwh + want_battery_priority = bat_w > 0 or (sell is not None and float(sell) < 0) + + if not want_battery_priority: + return mn + + return _clamp_deye_tou_soc_pct_hi(DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT, hi=100) + + +def get_deye_mode(setpoints: ControlSetpoints) -> str: + """ + Fyzický režim Deye: SELL | CHARGE | PASSIVE. + + SELL only when battery actively discharges for grid export (bat_w < -500 + AND grid_w < -200). Pass-through (PV → grid, battery idle) stays PASSIVE + with reg 108 = 0 + reg 145 = 1 (solar sell). + battery_w=None (SELF_SUSTAIN) → bat_w considered 0 → PASSIVE; při exportu se ale + zapíše plný reg. 108/109 (viz ``self_sustain_local_use`` v ``write_inverter_setpoints``). + """ + grid_w = int(setpoints.grid_setpoint_w or 0) + bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w) + if bat_w < -500 and grid_w < -200: + return "SELL" + if bat_w > 500 and grid_w > 200: + return "CHARGE" + return "PASSIVE" + + +def _deye_tou_params( + setpoints: ControlSetpoints, + inv: InverterConfig, +) -> tuple[int, int, bool]: + """ + Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge. + Ve PASSIVE viz _deye_passive_tou_battery_soc_pct (min vs. plný max z DB). + """ + max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) + tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge + tou_min = _deye_tou_min_soc_pct(inv) + tou_reserve = _deye_tou_reserve_soc_pct(inv) + if setpoints.lock_battery: + return tp_discharge_w, tou_min, False + deye_mode = get_deye_mode(setpoints) + if deye_mode == "CHARGE": + raw_bat = setpoints.battery_w + battery_w = int(raw_bat) if raw_bat is not None else 0 + cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95 + target_soc = max(10, min(95, cap)) + tp_charge_w = ( + battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V) + ) + return tp_charge_w, target_soc, True + if deye_mode == "SELL": + return tp_discharge_w, tou_reserve, False + tou_soc = _deye_passive_tou_battery_soc_pct(inv, setpoints) + return tp_discharge_w, tou_soc, False + + +async def write_inverter_setpoints( + site_id: int, + setpoints_now: ControlSetpoints, + setpoints_next: ControlSetpoints | None, + db: asyncpg.Connection, + planning_run_id: int | None = None, +) -> str: + inv = await _load_inverter_config(site_id, db) + if inv is None: + return "FAIL inverter: no controllable Modbus endpoint" + + raw_bat = setpoints_now.battery_w + grid_w = int(setpoints_now.grid_setpoint_w or 0) + no_export = inv.no_export + export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w) + max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) + tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge + tou_min_pct = _deye_tou_min_soc_pct(inv) + tou_reserve_pct = _deye_tou_reserve_soc_pct(inv) + + try: + soc_telemetry = await _get_current_soc(site_id, db) + + deye_mode = get_deye_mode(setpoints_now) + + bat_w = int(raw_bat) if raw_bat is not None else 0 + if setpoints_now.lock_battery: + charge_a = 0 + discharge_a = 0 + elif deye_mode == "CHARGE": + charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a) + discharge_a = 0 + elif setpoints_now.self_sustain_local_use: + # SELF_SUSTAIN: plný nabíjecí i vybíjecí proud invertoru — přebytek FVE jde do baterie, + # reg. 142 = zero export to load/CT (viz selling_mode níže), ne reg. 108 = 0. + charge_a = int(inv.max_charge_a) + discharge_a = int(inv.max_discharge_a) + else: + charge_a = int(inv.max_charge_a) if bat_w > 0 else 0 + discharge_a = int(inv.max_discharge_a) + + zero_exp_mode = int(inv.deye_zero_export_mode or 1) + selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode + solar_sell = 1 + export_limit = export_lim + reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE + + logger.info( + f"[control] site={site_id} fyzický režim Deye: {deye_mode} | " + f"battery_w={raw_bat!r} grid_w={grid_w} | " + f"charge_a={charge_a} discharge_a={discharge_a} | " + f"reg142={selling_mode} reg145={solar_sell} reg178={reg178_val}" + ) + + now_cet, time_rows = _deye_system_time_register_rows() + skip_time = False + try: + mb_clock = await get_modbus_client(inv.host, inv.port) + tvals = await mb_clock.read_holding_registers( + 62, 3, int(inv.unit_id if inv.unit_id is not None else 1) + ) + if len(tvals) == 3: + skip_time = _deye_should_skip_time_sync_after_read( + inv, int(tvals[0]), int(tvals[1]), int(tvals[2]) + ) + else: + logger.warning( + "Deye clock read: expected 3 registers, got %s; will sync 62–64", + len(tvals), + ) + except Exception as e: + logger.warning("Deye clock read failed (will sync 62–64): %s", e) + + if skip_time: + logger.info( + "Deye clock 62–64 skipped (drift ≤ %ss, last sync < %sh ago): %s CET", + DEYE_CLOCK_DRIFT_OK_SEC, + DEYE_CLOCK_RESYNC_INTERVAL_HOURS, + now_cet.strftime("%Y-%m-%d %H:%M:%S"), + ) + else: + logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S")) + + registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows) + + sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now + hh_cur = current_slot_hhmm() + hh_nxt = next_slot_hhmm() + p1, s1, g1 = _deye_tou_params(setpoints_now, inv) + p2, s2, g2 = _deye_tou_params(sp_tp2, inv) + registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1)) + registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2)) + + prague_date = datetime.now(PRAGUE_TZ).date() + inactive_sig = ( + f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}" + ) + need_inactive_tou = ( + inv.deye_last_tou_inactive_write_prague_date != prague_date + or inv.deye_tou_inactive_signature != inactive_sig + ) + if need_inactive_tou: + for idx in range(2, 6): + registers.extend( + _deye_time_point_rows( + idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False + ) + ) + else: + logger.debug( + "Deye TOU rows 3–6 skipped (already written today, signature unchanged)" + ) + + registers.extend( + [ + (108, "", charge_a), + (109, "", discharge_a), + (141, "energy_mode (0)", 0), + (142, "limit_control", selling_mode), + (143, "", export_limit), + (145, "solar_sell", solar_sell), + (178, "grid_peak_shaving_switch", reg178_val), + ] + ) + + logger.info( + "[control] %s: deye_mode=%s charge=%sA discharge=%sA " + "reg142=%s reg145=%s export=%sW " + "tp1=%s tp2=%s soc=%s%% (batt=%r grid=%sW)", + inv.code, + deye_mode, + charge_a, + discharge_a, + selling_mode, + solar_sell, + export_limit, + hh_cur, + hh_nxt, + soc_telemetry, + raw_bat, + grid_w, + ) + + last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db) + registers, skipped_unchanged = _drop_registers_matching_last_verified( + registers, last_verified + ) + if skipped_unchanged: + logger.info( + "[control] %s: skip %s registers (value equals last verified): %s", + inv.code, + len(skipped_unchanged), + skipped_unchanged[:24], + ) + if not registers: + logger.info( + "[control] %s: all Deye holding regs match last verified, no Modbus write", + inv.code, + ) + if need_inactive_tou: + await db.execute( + """ + UPDATE ems.asset_inverter + SET deye_last_tou_inactive_write_prague_date = $1, + deye_tou_inactive_signature = $2 + WHERE id = $3 + """, + prague_date, + inactive_sig, + inv.id, + ) + return ( + f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)" + ) + + will_write_inactive = any( + int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers + ) + + cmd_ids = await create_modbus_commands( + site_id, + planning_run_id, + "inverter", + inv.id, + inv.code, + inv.host, + inv.port, + inv.unit_id, + registers, + db, + deye_physical_mode=deye_mode, + ) + if not await execute_modbus_commands(cmd_ids, db): + return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)" + logger.info("[control] Inverter %s journal write OK", inv.code) + + will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers) + if will_write_time: + await db.execute( + """ + UPDATE ems.asset_inverter + SET deye_last_system_time_sync_minute = $1, + deye_last_system_time_sync_at = now() + WHERE id = $2 + """, + _prague_minute_start_utc(), + inv.id, + ) + + if need_inactive_tou or will_write_inactive: + await db.execute( + """ + UPDATE ems.asset_inverter + SET deye_last_tou_inactive_write_prague_date = $1, + deye_tou_inactive_signature = $2 + WHERE id = $3 + """, + prague_date, + inactive_sig, + inv.id, + ) + except Exception as e: + return f"FAIL inverter: {inv.code}: {e}" + + return ( + f"OK inverter: batt_w={raw_bat!r} " + f"(time points + FC 0x10: 108/109/141/142/178/143)" + ) + + +async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]: + """ + Živé čtení holding registrů Deye 108, 109, 141–145, 178, 191 (stejné TCP spojení jako telemetrie/export). + Vše pod jedním mutexem + sdružené FC3 bloky — mezi jednotlivými read_register dřív telemetrie + střídavě brala lock a RS485 brány házely cizí transaction_id / I/O timeouty. + """ + inv = await _load_inverter_config(site_id, db) + if inv is None: + raise ValueError("no controllable Modbus inverter for site") + + uid = int(inv.unit_id) + client = await get_modbus_client(inv.host, inv.port) + read_at = datetime.now(timezone.utc) + try: + async with client.batch(uid) as mb: + b108 = await mb.read_holding_registers(108, 2) + b141 = await mb.read_holding_registers(141, 5) + r178 = await mb.read_holding_registers(178, 1) + r191 = await mb.read_holding_registers(191, 1) + r108, r109 = b108[0], b108[1] + r141, r142, r143 = b141[0], b141[1], b141[2] + r145 = b141[4] + r178 = r178[0] + r191 = r191[0] + except Exception: + logger.exception("read_deye_registers_live site=%s failed", site_id) + raise + + return { + "reg108_charge_a": int(r108), + "reg109_discharge_a": int(r109), + "reg141_energy_mode": int(r141), + "reg142_limit_control": int(r142), + "reg143_export_limit_w": int(r143), + "reg145_solar_sell": int(r145), + "reg178_peak_shaving_switch": int(r178), + "reg191_peak_shaving_w": int(r191), + "read_at": read_at.isoformat(), + } + + +def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int: + c = (charger_code or "").strip().lower() + if c == "ev-charger-1": + a = sp.ev1_current_a + elif c == "ev-charger-2": + a = sp.ev2_current_a + elif c.endswith("-1") or c == "ev1": + a = sp.ev1_current_a + elif c.endswith("-2") or c == "ev2": + a = sp.ev2_current_a + else: + a = 0 + if a < 6: + a = 0 + return a + + +async def write_ev_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str: + rows = await db.fetch( + """ + SELECT ec.code, se.host, se.port, se.unit_id + FROM ems.asset_ev_charger ec + JOIN ems.site_endpoint se ON se.id = ec.endpoint_id + WHERE ec.site_id = $1 + AND ec.schedulable = true + AND se.enabled = true + AND se.endpoint_type = 'modbus_tcp' + ORDER BY ec.code + """, + site_id, + ) + if not rows: + return "OK EV: no schedulable chargers" + + for row in rows: + code = row["code"] + current_a = _current_limit_for_charger(code, setpoints) + logger.info( + "EV setpoint [%s]: %sA (TODO: Modbus registers)", + code, + current_a, + ) + return f"OK EV: logged {len(rows)} charger(s) (Modbus TODO)" + + +async def write_heat_pump_setpoint(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str: + rows = await db.fetch( + """ + SELECT hp.code, se.host, se.port, se.unit_id + FROM ems.asset_heat_pump hp + JOIN ems.site_endpoint se ON se.id = hp.endpoint_id + WHERE hp.site_id = $1 + AND hp.schedulable = true + AND se.enabled = true + AND se.endpoint_type = 'modbus_tcp' + """, + site_id, + ) + if not rows: + return "OK heat pump: no schedulable unit" + for row in rows: + logger.info( + "HP setpoint [%s]: enable=%s (TODO: Modbus registers)", + row["code"], + setpoints.heat_pump_enable, + ) + return "OK heat pump: logged (Modbus TODO)" + + +async def send_loxone_setpoints( + site_id: int, + setpoints: ControlSetpoints, + mode: OperatingModeInfo, + db: asyncpg.Connection, +) -> str: + endpoint = await db.fetchrow( + """ + SELECT host, port, protocol + FROM ems.site_endpoint + WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true + ORDER BY id + LIMIT 1 + """, + site_id, + ) + if not endpoint: + return "OK Loxone: no endpoint, skipped" + + proto = (endpoint["protocol"] or "http").lower() + if proto not in ("http", "https"): + proto = "http" + host = endpoint["host"] + port = int(endpoint["port"] or (443 if proto == "https" else 80)) + base = f"{proto}://{host}:{port}/dev/sps/io" + + settings = get_settings() + user = settings.loxone_user or os.getenv("LOXONE_USER") or "" + password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or "" + auth = (user, password) if user else None + + batt_display = 0 if setpoints.battery_w is None else int(setpoints.battery_w) + + paths: list[tuple[str, int]] = [ + (f"{base}/EMS_Mode/{mode.loxone_mode_value}", mode.loxone_mode_value), + (f"{base}/EMS_Battery_Setpoint_W/{batt_display}", batt_display), + (f"{base}/EMS_Grid_Setpoint_W/{setpoints.grid_setpoint_w}", setpoints.grid_setpoint_w), + (f"{base}/EMS_EV1_Power_W/{setpoints.ev1_power_w}", setpoints.ev1_power_w), + (f"{base}/EMS_EV2_Power_W/{setpoints.ev2_power_w}", setpoints.ev2_power_w), + (f"{base}/EMS_HeatPump_Enable/{1 if setpoints.heat_pump_enable else 0}", 1 if setpoints.heat_pump_enable else 0), + ] + + errs: list[str] = [] + try: + async with httpx.AsyncClient(timeout=5.0) as client: + for url, _ in paths: + try: + r = await client.get(url, auth=auth) + r.raise_for_status() + except Exception as e: + errs.append(f"{url!s}: {e}") + except Exception as e: + return f"FAIL Loxone: client {e}" + + if errs: + return "FAIL Loxone: " + "; ".join(errs[:3]) + return "OK Loxone: all virtual inputs updated" + + +async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: + mode = await _fetch_operating_mode(site_id, db) + if mode is None: + logger.warning("control export site=%s: no operating mode row", site_id) + return + + if mode.mode_code == "MANUAL": + logger.info("control export site=%s: MANUAL, skip writes", site_id) + return + + pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0) + pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1) + sp_now = _build_setpoints(mode, pi_now) + sp_next = _build_setpoints(mode, pi_next) + + if mode.mode_code == "AUTO" and sp_now is None: + if pi_now is None: + logger.warning( + "control export site=%s: AUTO but no planning_interval for current slot, skip", + site_id, + ) + return + + if sp_now is None: + logger.warning( + "control export site=%s: no setpoints for mode %s, skip", + site_id, + mode.mode_code, + ) + return + + if mode.mode_code == "CHARGE_CHEAP": + max_ch = await _fetch_max_charge_power_w(site_id, db) + # Kladný grid_setpoint_w > 200 → fyzický CHARGE (nabíjení ze sítě), viz get_deye_mode + grid_for_charge = max(300, max_ch) + sp_now = ControlSetpoints( + battery_w=max_ch, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=grid_for_charge, + ev1_power_w=0, + ev2_power_w=0, + target_soc_pct=None, + effective_sell_price_czk_kwh=None, + ) + sp_next = sp_now + else: + sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now) + if sp_next is not None: + sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next) + + planning_run_id = await db.fetchval( + """ + SELECT id FROM ems.planning_run + WHERE site_id = $1 AND status = 'active' + ORDER BY created_at DESC + LIMIT 1 + """, + site_id, + ) + if planning_run_id is not None: + planning_run_id = int(planning_run_id) + + try: + inv_res = await write_inverter_setpoints( + site_id, sp_now, sp_next, db, planning_run_id=planning_run_id + ) + except Exception as e: + logger.error("inverter write failed: %s", e) + inv_res = f"FAIL inverter: {e}" + + try: + ev_res = await write_ev_setpoints(site_id, sp_now, db) + except Exception as e: + logger.error("ev write failed: %s", e) + ev_res = f"FAIL ev: {e}" + + try: + hp_res = await write_heat_pump_setpoint(site_id, sp_now, db) + except Exception as e: + logger.error("hp write failed: %s", e) + hp_res = f"FAIL heat pump: {e}" + + try: + lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db) + except Exception as e: + logger.error("loxone write failed: %s", e) + lox_res = f"FAIL Loxone: {e}" + + results = list( + zip( + ("inverter", "ev", "heat_pump", "loxone"), + (inv_res, ev_res, hp_res, lox_res), + ) + ) + + for name, res in results: + if isinstance(res, Exception): + logger.error("control export site=%s %s: FAIL %s", site_id, name, res) + elif isinstance(res, str) and res.startswith("FAIL"): + logger.error("control export site=%s %s: %s", site_id, name, res) + else: + logger.info("control export site=%s %s: %s", site_id, name, res) diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index e0a5fbb..dd3e4d3 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -1,1949 +1,3 @@ -"""Export plánovaných setpointů na Modbus (Deye, EV, TČ) a HTTP do Loxone.""" +"""Zpětná kompatibilita: import z services.control.""" -from __future__ import annotations - -import asyncio -import logging -import os -from collections import defaultdict -from dataclasses import dataclass -from typing import Any -from datetime import date, datetime, timedelta, timezone -from zoneinfo import ZoneInfo - -import asyncpg -import httpx - -from app.config import get_settings -from services.modbus_client import get_modbus_client - -logger = logging.getLogger(__name__) - -PRAGUE_TZ = ZoneInfo("Europe/Prague") - -# Hodiny Deye 62–64: po zápisu sekundy na zařízení dál běží → verify musí být toleranční. -DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120 -# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund… -DEYE_CLOCK_DRIFT_OK_SEC = 60 -# …a zároveň neuplynul tento interval od posledního syncu / potvrzení driftu. -DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24 - -# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md) -BATT_VOLTAGE_V = 51.2 - -# Reg 178 – pevné hodnoty (bit4–5); bez read-modify-write (kolize s Loxone / transaction ID) -REG178_SELL = 0b00100000 # 32, grid peak shaving disable -REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE) -# TOU reg 166+ ve PASSIVE při prioritě baterie: signál střídači „využij celý dostupný rozsah“, -# ne provozní strop z DB (ten je pro LP / Wh – viz asset_battery.max_soc_percent). -DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT = 100 -# Verify: jen bity 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone -REG178_VERIFY_MASK = 0x0030 - -# Po 3 neúspěšných verify pokusech → SELF_SUSTAIN jen u těchto registrech (bezpečnost / export). -# 62–64 řeší toleranční bundle (nemění režim). 178 a TOU power W jsou „soft“ — jen log + Discord. -DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145}) -# Výkonové řádky TOU (154 + slot_index 0…5) — firmware často přepíše na max W z max_charge/max_discharge A. -DEYE_TOU_POWER_REGS = frozenset(range(154, 160)) -# Deye LV: firmware často odmítne 351 A a drží 350 — horní strop pro zápis z DB. -DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350 - - -def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool: - return (int(expected_i) & REG178_VERIFY_MASK) == (int(actual_i) & REG178_VERIFY_MASK) - - -def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool: - """True = po 3× mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr).""" - return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN - - -def _deye_tou_power_verify_match( - expected_i: int, actual_i: int, inv: InverterConfig -) -> bool: - """Firmware často clampne TOU power W na max z reg. 108/109 × 51.2 V — akceptovat jako OK.""" - if int(actual_i) == int(expected_i): - return True - # 51.2 V — nesmí int(BATT_VOLTAGE_V)==51 (off-by-one vs. firmware 17920 W @ 350 A) - max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V) - max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) - a = int(actual_i) - return a == max_w_charge or a == max_w_discharge - - -def _deye_reg178_verify_with_double_read( - expected_i: int, actual_first: int, actual_second: int | None -) -> tuple[bool, int]: - """ - Vrátí (shoda, hodnota_pro_journal). - Druhé čtení použít jen když první neprojde maskou (RS485 / glitch). - """ - if _deye_reg178_verify_match(expected_i, actual_first): - return True, actual_first - if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second): - return True, int(actual_second) - return False, actual_first - -# Neaktivní TOU bloky (3–6): „konec dne“ — Deye často 23:59 (2359) neuloží a vrátí např. 2355, -# verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo). -DEYE_TOU_INACTIVE_HHMM = 2355 - -# Registry TOU řádků 3–6 (slot index 2…5): 150–153, 156–159, … — pro detekci skutečného zápisu po filtru „unchanged“. -_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset( - [ - 150, 151, 152, 153, - 156, 157, 158, 159, - 168, 169, 170, 171, - 174, 175, 176, 177, - ] -) - -# Systémový čas Deye — vždy toleranční verify jako celek 62–64 (reg 64 sám nesmí do striktní větve). -DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64}) - -DEYE_REGISTER_NAMES: dict[int, str] = { - 108: "max_charge_a (max nabíjecí proud baterie)", - 109: "max_discharge_a (max vybíjecí proud baterie)", - 141: "energy_mode (0, EMS nemění)", - 142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)", - 143: "export_limit_w (max export do sítě)", - 145: "solar_sell (0=disabled, 1=enabled)", - 178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)", - 148: "time_point_1_time", - 149: "time_point_2_time", - 154: "time_point_1_power_w", - 155: "time_point_2_power_w", - 166: "time_point_1_soc_min_pct", - 167: "time_point_2_soc_min_pct", - 172: "time_point_1_grid_charge", - 173: "time_point_2_grid_charge", - 62: "system_time_year_month", - 63: "system_time_day_hour", - 64: "system_time_min_sec", -} -for _tp_i in range(6): - _n = _tp_i + 1 - DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time") - DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w") - DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct") - DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge") - - -def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int: - if not power_w or power_w <= 0: - return 0 - return min(32, max(0, int(power_w / (phases * voltage)))) - - -def battery_watts_to_amps(power_w: int, max_amps: int) -> int: - """Proud z |výkonu| baterie; max_amps z DB (už COALESCE se stropy v SQL). - - int(|W|/51.2) — u kladných hodnot stejné jako floor bez importu math. - """ - derived = int(abs(power_w) / BATT_VOLTAGE_V) - return min(max(0, max_amps), max(0, derived)) - - -def current_slot_hhmm() -> int: - """Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM (např. 1415).""" - now = datetime.now(ZoneInfo("Europe/Prague")) - slot_min = (now.minute // 15) * 15 - return now.hour * 100 + slot_min - - -def next_slot_hhmm() -> int: - """Začátek příštího 15min slotu v Europe/Prague, formát HHMM (např. 1430).""" - now = datetime.now(ZoneInfo("Europe/Prague")) - minutes = now.minute - slot_minutes = ((minutes // 15) + 1) * 15 - if slot_minutes >= 60: - next_hour = (now.hour + 1) % 24 - next_min = 0 - else: - next_hour = now.hour - next_min = slot_minutes - return next_hour * 100 + next_min - - -@dataclass -class InverterConfig: - id: int - code: str - host: str - port: int - unit_id: int - max_export_power_w: int | None - max_import_power_w: int | None - no_export: bool - max_battery_charge_w: int | None - max_battery_discharge_w: int | None - min_soc_percent: int | None - reserve_soc_percent: int | None - max_soc_percent: int | None - usable_capacity_wh: int | None - max_charge_a: int - max_discharge_a: int - deye_last_system_time_sync_minute: datetime | None = None - deye_last_system_time_sync_at: datetime | None = None - deye_last_tou_inactive_write_prague_date: date | None = None - deye_tou_inactive_signature: str | None = None - deye_zero_export_mode: int = 1 - - -def _prague_minute_start_utc() -> datetime: - """UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague.""" - p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0) - return p.astimezone(timezone.utc) - - -def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None: - """Dekódování reg 62–64 (Deye system time v Europe/Prague).""" - try: - year = (int(r62) >> 8) + 2000 - month = int(r62) & 0xFF - day = int(r63) >> 8 - hour = int(r63) & 0xFF - minute = int(r64) >> 8 - second = int(r64) & 0xFF - if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23): - return None - if not (0 <= minute <= 59 and 0 <= second <= 59): - return None - return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ) - except (ValueError, OverflowError): - return None - - -def _deye_clock_registers_verify_match( - w62: int, - w63: int, - w64: int, - a62: int, - a63: int, - a64: int, -) -> bool: - w_dt = _deye_registers_to_prague_datetime(w62, w63, w64) - a_dt = _deye_registers_to_prague_datetime(a62, a63, a64) - if w_dt is None or a_dt is None: - return False - return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC - - -def _deye_should_skip_time_sync_after_read( - inv: InverterConfig, - r62: int, - r63: int, - r64: int, -) -> bool: - """ - True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného zápisu (FC 0x10 ACK) - nebo tolerančního ověření neuplynulo 24h — sloupec deye_last_system_time_sync_at doplňuje - write_inverter_setpoints po úspěšném zápisu batche obsahujícího 62–64 a znovu po úspěšném verify. - """ - dev = _deye_registers_to_prague_datetime(r62, r63, r64) - if dev is None: - return False - wall = datetime.now(PRAGUE_TZ) - drift = abs((wall - dev).total_seconds()) - if drift > DEYE_CLOCK_DRIFT_OK_SEC: - return False - last_write = inv.deye_last_system_time_sync_at - if last_write is None: - return False - if last_write.tzinfo is None: - last_write = last_write.replace(tzinfo=timezone.utc) - else: - last_write = last_write.astimezone(timezone.utc) - age = datetime.now(timezone.utc) - last_write - if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS): - return False - return True - - -async def _fetch_written_deye_clock_commands( - site_id: int, - asset_id: int, - host: str, - port: int, - unit_id: int, - db: asyncpg.Connection, -) -> list[asyncpg.Record]: - """Všechny řádky journalu 62–64 ve stavu written pro daný invertor/endpoint.""" - rows = await db.fetch( - """ - SELECT * FROM ems.modbus_command - WHERE site_id = $1 - AND asset_type = 'inverter' - AND asset_id = $2 - AND device_host = $3 - AND device_port = $4 - AND device_unit_id = $5 - AND register IN (62, 63, 64) - AND status = 'written' - ORDER BY register - """, - site_id, - asset_id, - host, - port, - unit_id, - ) - return list(rows) - - -async def _fetch_last_verified_inverter_registers( - site_id: int, inverter_asset_id: int, db: asyncpg.Connection -) -> dict[int, int]: - """ - Poslední hodnota na zařízení podle journalu (jen status verified). - Slouží k přeskočení duplicitního zápisu stejné hodnoty. - """ - rows = await db.fetch( - """ - SELECT DISTINCT ON (register) - register, - value_verified - FROM ems.modbus_command - WHERE site_id = $1 - AND asset_type = 'inverter' - AND asset_id = $2 - AND status = 'verified' - AND value_verified IS NOT NULL - ORDER BY register, verified_at DESC NULLS LAST, id DESC - """, - site_id, - inverter_asset_id, - ) - return {int(r["register"]): int(r["value_verified"]) for r in rows} - - -def _drop_registers_matching_last_verified( - registers: list[tuple[int, str, int]], - last_verified: dict[int, int], -) -> tuple[list[tuple[int, str, int]], list[int]]: - """Vynechá položky s hodnotou shodnou s posledním ověřeným stavem; vrátí (nový seznam, vynechané reg).""" - out: list[tuple[int, str, int]] = [] - skipped: list[int] = [] - for reg, meta, val in registers: - lv = last_verified.get(int(reg)) - if lv is not None and lv == int(val): - skipped.append(int(reg)) - continue - out.append((reg, meta, val)) - return out, skipped - - -@dataclass -class ControlSetpoints: - battery_w: int | None - grid_export_limit: int - ev1_current_a: int - ev2_current_a: int - heat_pump_enable: bool - grid_setpoint_w: int - ev1_power_w: int - ev2_power_w: int - target_soc_pct: int | None = None - #: Efektivní vykupní cena slotu (Kč/kWh z plánu); pro TOU řízení priorit baterie vs. přetok - effective_sell_price_czk_kwh: float | None = None - #: True = reg 108/109 na 0 (PRESERVE – Deye baterii nepoužívá) - lock_battery: bool = False - #: Režim SELF_SUSTAIN: plný rozsah nabíjení/vybíjení na invertoru + zero-export (reg 142) a nízké TOU %. - self_sustain_local_use: bool = False - - -@dataclass -class OperatingModeInfo: - mode_code: str - battery_mode: str - grid_mode: str - ev_enabled: bool - heat_pump_enabled_def: bool - loxone_mode_value: int - - -async def create_modbus_commands( - site_id: int, - planning_run_id: int | None, - asset_type: str, - asset_id: int, - asset_code: str, - host: str, - port: int, - unit_id: int, - registers: list[tuple[int, str, int]], - db: asyncpg.Connection, - deye_physical_mode: str | None = None, -) -> list[int]: - """ - Vytvoří záznamy v modbus_command pro sadu zápisů. - Vrátí list command IDs. - Pro Deye se jméno registru bere z DEYE_REGISTER_NAMES (prostřední položka tuplu se ignoruje). - """ - ids: list[int] = [] - for reg, _ignored_name, val in registers: - register_name = DEYE_REGISTER_NAMES.get(reg, f"reg_{reg}") - cmd_id = await db.fetchval( - """ - INSERT INTO ems.modbus_command - (site_id, asset_type, asset_id, asset_code, - device_host, device_port, device_unit_id, - register, register_name, value_to_write, - planning_run_id, status, deye_physical_mode) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12) - RETURNING id - """, - site_id, - asset_type, - asset_id, - asset_code, - host, - port, - unit_id, - reg, - register_name, - val, - planning_run_id, - deye_physical_mode, - ) - if cmd_id is not None: - ids.append(int(cmd_id)) - return ids - - -def _modbus_command_contiguous_runs(cmds: list[asyncpg.Record]) -> list[list[asyncpg.Record]]: - """Seřadí podle adresy registru a rozdělí na souvislé úseky pro FC 0x10 / FC 3.""" - if not cmds: - return [] - sorted_cmds = sorted(cmds, key=lambda c: int(c["register"])) - runs: list[list[asyncpg.Record]] = [] - cur: list[asyncpg.Record] = [sorted_cmds[0]] - for c in sorted_cmds[1:]: - if int(c["register"]) == int(cur[-1]["register"]) + 1: - cur.append(c) - else: - runs.append(cur) - cur = [c] - runs.append(cur) - return runs - - -async def execute_modbus_commands( - command_ids: list[int], - db: asyncpg.Connection, -) -> bool: - """ - Zapíše příkazy z modbus_command do zařízení (FC 0x10 po souvislých blocích). - Aktualizuje status na 'written' nebo 'failed'. - Vrátí True pokud všechny příkazy uspěly. - """ - MAX_RETRIES = 3 - RETRY_DELAY = 0.5 - - rows: list[asyncpg.Record] = [] - for cmd_id in command_ids: - cmd = await db.fetchrow( - "SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id - ) - if cmd is not None: - rows.append(cmd) - - if not rows: - return True - - by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list) - for cmd in rows: - by_gw[ - (cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])) - ].append(cmd) - - all_ok = True - for (host, port, unit), group in by_gw.items(): - client = await get_modbus_client(host, port) - for run in _modbus_command_contiguous_runs(group): - start_reg = int(run[0]["register"]) - values = [int(c["value_to_write"]) for c in run] - ids_run = [int(c["id"]) for c in run] - for attempt in range(MAX_RETRIES): - try: - await client.write_registers(start_reg, values, unit) - for cmd, val in zip(run, values): - cid = int(cmd["id"]) - await db.execute( - """ - UPDATE ems.modbus_command - SET status='written', value_written=$1, written_at=now(), - attempt_count=attempt_count+1, error_msg=NULL - WHERE id=$2 - """, - val, - cid, - ) - logger.info( - "[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)", - cid, - cmd["asset_code"], - int(cmd["register"]), - val, - start_reg, - attempt + 1, - ) - break - except Exception as e: - if attempt < MAX_RETRIES - 1: - logger.warning( - "Modbus batch write 0x%04X count=%s attempt %s failed: %s, retrying...", - start_reg, - len(values), - attempt + 1, - e, - ) - await asyncio.sleep(RETRY_DELAY) - await client.force_disconnect() - else: - for cmd in run: - await db.execute( - """ - UPDATE ems.modbus_command - SET status='failed', error_msg=$1, - attempt_count=attempt_count+1 - WHERE id=$2 - """, - str(e), - int(cmd["id"]), - ) - logger.error( - "Modbus batch 0x%04X count=%s all %s attempts failed: %s", - start_reg, - len(values), - MAX_RETRIES, - e, - ) - all_ok = False - - return all_ok - - -async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None: - """Přepne lokalitu na SELF_SUSTAIN, zaloguje důvod a při změně pošle Discord.""" - from services.notification_service import run_fn_set_mode_with_discord - - await run_fn_set_mode_with_discord( - db, - site_id, - "SELF_SUSTAIN", - "system:mismatch", - None, - reason, - ) - logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason) - - -def _modbus_cmd_register(cmd: Any) -> int: - """asyncpg.Record má __getitem__; objekty s atributem .register též (testy).""" - try: - return int(cmd["register"]) - except (KeyError, TypeError): - return int(cmd.register) - - -def _deye_expected_clock_triplet_for_verify( - bundle: list[asyncpg.Record], - last_verified: dict[int, int], - a62: int, - a63: int, - a64: int, -) -> tuple[int, int, int]: - """ - Sestaví očekávané (w62,w63,w64) pro toleranční verify. - Chybějící registry doplní poslední verified nebo aktuálním přečtením ze zařízení - (aby osiřelý zápis např. jen 64 nešel do striktního porovnání reg64). - """ - by_reg = {_modbus_cmd_register(c): c for c in bundle} - def _vtw(c: Any) -> int: - try: - return int(c["value_to_write"]) - except (KeyError, TypeError): - return int(c.value_to_write) - - w62 = _vtw(by_reg[62]) if 62 in by_reg else last_verified.get(62, a62) - w63 = _vtw(by_reg[63]) if 63 in by_reg else last_verified.get(63, a63) - w64 = _vtw(by_reg[64]) if 64 in by_reg else last_verified.get(64, a64) - return (int(w62), int(w63), int(w64)) - - -async def _verify_deye_clock_written_bundle( - site_id: int, - bundle: list[asyncpg.Record], - a62: int, - a63: int, - a64: int, - db: asyncpg.Connection, -) -> bool: - """ - Toleranční ověření pro jeden až tři řádky journalu 62–64 ve stavu written. - Při mismatch retry společně; bez přepnutí do SELF_SUSTAIN po 3 pokusech. - """ - from services.notification_service import ( - notify_modbus_clock_verify_exhausted, - notify_modbus_mismatch, - ) - - cmds_s = sorted(bundle, key=_modbus_cmd_register) - try: - asset_id = int(cmds_s[0]["asset_id"]) - except (KeyError, TypeError): - asset_id = int(cmds_s[0].asset_id) - last_v = await _fetch_last_verified_inverter_registers(site_id, asset_id, db) - w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last_v, a62, a63, a64) - clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64) - actual_by_reg = {62: a62, 63: a63, 64: a64} - - for cmd in cmds_s: - try: - cid = int(cmd["id"]) - except (KeyError, TypeError): - cid = int(cmd.id) - r = _modbus_cmd_register(cmd) - await db.execute( - """ - UPDATE ems.modbus_command - SET value_verified=$1::int, verified_at=now(), - status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END - WHERE id=$3::int - """, - actual_by_reg[r], - clock_ok, - cid, - ) - - if clock_ok: - await db.execute( - """ - UPDATE ems.asset_inverter - SET deye_last_system_time_sync_minute = $1, - deye_last_system_time_sync_at = now() - WHERE id = $2 - """, - _prague_minute_start_utc(), - asset_id, - ) - for cmd in cmds_s: - try: - cid_l = int(cmd["id"]) - except (KeyError, TypeError): - cid_l = int(cmd.id) - try: - code_l = str(cmd["asset_code"]) - except (KeyError, TypeError): - code_l = str(cmd.asset_code) - rr = _modbus_cmd_register(cmd) - logger.info( - "[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s", - cid_l, - code_l, - rr, - actual_by_reg[rr], - ) - return True - - cmd0 = cmds_s[0] - try: - ac0 = str(cmd0["asset_code"]) - except (KeyError, TypeError): - ac0 = str(cmd0.asset_code) - logger.error( - "[cmd clock] MISMATCH %s 62–64: written=(%s,%s,%s) actual=(%s,%s,%s)", - ac0, - w62, - w63, - w64, - a62, - a63, - a64, - ) - - attempts = 0 - for cmd in cmds_s: - try: - cid_q = int(cmd["id"]) - except (KeyError, TypeError): - cid_q = int(cmd.id) - row_ac = await db.fetchrow( - "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cid_q - ) - ac = int(row_ac["attempt_count"] or 0) if row_ac else 0 - attempts = max(attempts, ac) - - await notify_modbus_mismatch( - ac0, - 62, - "system_time_62_64", - w62, - a62, - attempts, - ) - - ids_ordered = [] - for c in cmds_s: - try: - ids_ordered.append(int(c["id"])) - except (KeyError, TypeError): - ids_ordered.append(int(c.id)) - if attempts < 3: - for cid in ids_ordered: - await db.execute( - "UPDATE ems.modbus_command SET status='retrying' WHERE id=$1", - cid, - ) - await execute_modbus_commands(ids_ordered, db) - await verify_modbus_commands(ids_ordered, db, site_id) - else: - logger.critical( - "[cmd clock] 3 failed verify attempts (62–64); režim se nemění automaticky" - ) - site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id) - await notify_modbus_clock_verify_exhausted( - site["code"] if site else str(site_id), - ac0, - (w62, w63, w64), - (a62, a63, a64), - ) - return False - - -async def verify_modbus_commands( - command_ids: list[int], - db: asyncpg.Connection, - site_id: int, -) -> bool: - """ - Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write. - Při mismatch: retry (až 3×). Po vyčerpání pokusů u kritických registrů (108, 109, 142, 143, 145) - → SELF_SUSTAIN + Discord; u „soft“ (178, TOU power W) jen log + Discord, režim se nemění. - """ - from services.notification_service import notify_modbus_mismatch - - inv_cfg = await _load_inverter_config(site_id, db) - - async def _apply_verify_result( - cmd: asyncpg.Record, - actual_i: int, - *, - client: Any, - unit: int, - ) -> bool: - """Vrátí True při shodě, False při mismatch (a obslouží retry / SELF_SUSTAIN).""" - reg = int(cmd["register"]) - cmd_id = int(cmd["id"]) - - if reg in DEYE_CLOCK_REGS: - asset_id = int(cmd["asset_id"]) - host = str(cmd["device_host"]) - port_i = int(cmd["device_port"]) - uid = int(cmd["device_unit_id"]) - bundle = await _fetch_written_deye_clock_commands( - site_id, asset_id, host, port_i, uid, db - ) - if not bundle: - bundle = [cmd] - try: - cvals = await client.read_holding_registers(62, 3, uid) - except Exception as e: - logger.error( - "verify clock guard read 62–64 failed (reg 0x%04X): %s", reg, e - ) - return False - if len(cvals) != 3: - logger.error( - "verify clock guard: expected 3 regs, got %s", len(cvals) - ) - return False - logger.warning( - "Clock register 0x%04X reached strict verify path; using tolerant 62–64 bundle", - reg, - ) - return await _verify_deye_clock_written_bundle( - site_id, - bundle, - int(cvals[0]), - int(cvals[1]), - int(cvals[2]), - db, - ) - - expected_i = int(cmd["value_to_write"]) - matches = actual_i == expected_i - if reg == 178: - first_178 = int(actual_i) - second_178: int | None = None - if not _deye_reg178_verify_match(expected_i, first_178): - try: - r178 = await client.read_holding_registers(178, 1, unit) - if r178 and len(r178) >= 1: - second_178 = int(r178[0]) - except Exception as e: - logger.warning( - "[cmd %s] reg178 double-read failed: %s", cmd_id, e - ) - matches, actual_i = _deye_reg178_verify_with_double_read( - expected_i, first_178, second_178 - ) - if ( - matches - and second_178 is not None - and not _deye_reg178_verify_match(expected_i, first_178) - ): - logger.info( - "[cmd %s] reg178 double-read recovered: first=%s second=%s", - cmd_id, - first_178, - second_178, - ) - if not matches and reg in DEYE_TOU_POWER_REGS and inv_cfg is not None: - matches = _deye_tou_power_verify_match(expected_i, actual_i, inv_cfg) - - await db.execute( - """ - UPDATE ems.modbus_command - SET value_verified=$1::int, verified_at=now(), - status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END - WHERE id=$3::int - """, - actual_i, - matches, - cmd_id, - ) - - if not matches: - logger.error( - "[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s%s", - cmd_id, - cmd["asset_code"], - reg, - expected_i, - actual_i, - " (reg178 mask 0x%04X)" % REG178_VERIFY_MASK if reg == 178 else "", - ) - row_ac = await db.fetchrow( - "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id - ) - attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0 - await notify_modbus_mismatch( - cmd["asset_code"], - reg, - cmd["register_name"] or "", - expected_i, - actual_i, - attempts, - ) - - if attempts < 3: - await db.execute( - "UPDATE ems.modbus_command SET status='retrying' WHERE id=$1", - cmd_id, - ) - await execute_modbus_commands([cmd_id], db) - await verify_modbus_commands([cmd_id], db, site_id) - else: - if deye_reg_triggers_self_sustain_after_verify_exhaust(reg): - logger.critical( - "[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN", - cmd_id, - ) - await _switch_to_self_sustain( - site_id, - db, - reason=( - f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} " - f"reg 0x{reg:04X}" - ), - ) - else: - logger.warning( - "[cmd %s] 3 failed verify attempts on non-critical reg 0x%04X " - "(no mode change): %s", - cmd_id, - reg, - cmd["asset_code"], - ) - return False - - if reg == 178 and actual_i != expected_i: - logger.info( - "[cmd %s] verified OK (reg178 masked): %s 0x%04X value_to_write=%s actual=%s", - cmd_id, - cmd["asset_code"], - reg, - expected_i, - actual_i, - ) - else: - logger.info( - "[cmd %s] verified OK: %s 0x%04X=%s", - cmd_id, - cmd["asset_code"], - reg, - actual_i, - ) - return True - - cmds: list[asyncpg.Record] = [] - for cmd_id in command_ids: - cmd = await db.fetchrow( - "SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id - ) - if cmd is not None and cmd["status"] == "written": - cmds.append(cmd) - - if not cmds: - return True - - by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list) - for cmd in cmds: - by_gw[ - (cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])) - ].append(cmd) - - all_ok = True - for (host, port, unit), group in by_gw.items(): - client = await get_modbus_client(host, port) - clock_cmds = [c for c in group if int(c["register"]) in DEYE_CLOCK_REGS] - rest = [c for c in group if int(c["register"]) not in DEYE_CLOCK_REGS] - - if clock_cmds: - asset_id = int(clock_cmds[0]["asset_id"]) - bundle = await _fetch_written_deye_clock_commands( - site_id, asset_id, host, port, unit, db - ) - if not bundle: - bundle = clock_cmds - try: - cvals = await client.read_holding_registers(62, 3, unit) - except Exception as e: - logger.error("verify clock read 62–64 failed: %s", e) - all_ok = False - else: - if len(cvals) != 3: - logger.error( - "verify clock read: expected 3 regs, got %s", len(cvals) - ) - all_ok = False - else: - matched = await _verify_deye_clock_written_bundle( - site_id, - bundle, - int(cvals[0]), - int(cvals[1]), - int(cvals[2]), - db, - ) - if not matched: - all_ok = False - - for run in _modbus_command_contiguous_runs(rest): - start_reg = int(run[0]["register"]) - n = len(run) - try: - values = await client.read_holding_registers(start_reg, n, unit) - except Exception as e: - logger.error( - "verify batch read 0x%04X count=%s failed: %s", start_reg, n, e - ) - all_ok = False - continue - if len(values) != n: - logger.error( - "verify read 0x%04X: expected %s regs, got %s", - start_reg, - n, - len(values), - ) - all_ok = False - continue - for cmd, actual in zip(run, values): - matched = await _apply_verify_result( - cmd, int(actual), client=client, unit=unit - ) - if not matched: - all_ok = False - - return all_ok - - -async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None: - sql = """ - SELECT som.mode_code, omd.battery_mode, omd.grid_mode, - omd.ev_enabled, omd.heat_pump_enabled, omd.loxone_mode_value, - som.valid_until - FROM ems.site_operating_mode som - JOIN ems.operating_mode_def omd ON omd.code = som.mode_code - WHERE som.site_id = $1 - """ - row = await db.fetchrow(sql, site_id) - if row is None: - return None - vu = row["valid_until"] - if vu is not None: - now_utc = datetime.now(timezone.utc) - if vu.tzinfo is None: - vu = vu.replace(tzinfo=timezone.utc) - if vu <= now_utc: - exp_rows = await db.fetch("SELECT * FROM ems.fn_expire_modes()") - from services.notification_service import notify_operating_mode_changed - - for er in exp_rows: - await notify_operating_mode_changed( - str(er["site_code"]), - str(er["old_mode"]), - str(er["new_mode"]), - "system:expiry", - "Automatické vypršení dočasného režimu", - ) - row = await db.fetchrow(sql, site_id) - if row is None: - return None - return OperatingModeInfo( - mode_code=row["mode_code"], - battery_mode=row["battery_mode"], - grid_mode=row["grid_mode"], - ev_enabled=bool(row["ev_enabled"]), - heat_pump_enabled_def=bool(row["heat_pump_enabled"]), - loxone_mode_value=int(row["loxone_mode_value"]), - ) - - -async def _get_current_soc(site_id: int, db: asyncpg.Connection) -> int: - soc = await db.fetchval( - """ - SELECT battery_soc_percent - FROM ems.telemetry_inverter - WHERE site_id = $1 AND battery_soc_percent IS NOT NULL - ORDER BY measured_at DESC - LIMIT 1 - """, - site_id, - ) - return int(soc) if soc is not None else 50 - - -async def _load_inverter_config( - site_id: int, db: asyncpg.Connection -) -> InverterConfig | None: - row = await db.fetchrow( - """ - SELECT - ai.id, ai.code, - se.host, se.port, se.unit_id, - sgc.max_export_power_w, - sgc.max_import_power_w, - sgc.no_export, - ai.max_battery_charge_w, - ai.max_battery_discharge_w, - ab.min_soc_percent, - ab.reserve_soc_percent, - ab.max_soc_percent, - ab.usable_capacity_wh, - ai.deye_last_system_time_sync_minute, - ai.deye_last_system_time_sync_at, - ai.deye_last_tou_inactive_write_prague_date, - ai.deye_tou_inactive_signature, - COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode, - COALESCE( - ai.deye_register_max_charge_a, - FLOOR( - LEAST( - COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w), - ai.max_battery_charge_w - )::numeric / 51.2 - )::int - ) AS max_charge_a, - COALESCE( - ai.deye_register_max_discharge_a, - FLOOR( - LEAST( - COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w), - ai.max_battery_discharge_w - )::numeric / 51.2 - )::int - ) AS max_discharge_a - FROM ems.asset_inverter ai - JOIN ems.site_endpoint se ON se.id = ai.endpoint_id - JOIN ems.asset_battery ab ON ab.inverter_id = ai.id - LEFT JOIN ems.site_grid_connection sgc ON sgc.site_id = ai.site_id - WHERE ai.site_id = $1 - AND ai.active = true - AND ai.controllable = true - AND se.enabled = true - AND se.endpoint_type = 'modbus_tcp' - ORDER BY ai.id - LIMIT 1 - """, - site_id, - ) - if row is None: - return None - mc = row["max_charge_a"] - md = row["max_discharge_a"] - max_charge_a = int(mc) if mc is not None else 0 - max_discharge_a = int(md) if md is not None else 0 - # Firmware Deye často drží max 350 A — vyšší hodnota z DB → mismatch 351 vs 350. - max_charge_a = min(max_charge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A) - max_discharge_a = min(max_discharge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A) - port = int(row["port"] or 502) - uid = int(row["unit_id"] if row["unit_id"] is not None else 1) - return InverterConfig( - id=int(row["id"]), - code=row["code"], - host=row["host"], - port=port, - unit_id=uid, - max_export_power_w=int(row["max_export_power_w"]) - if row["max_export_power_w"] is not None - else None, - max_import_power_w=int(row["max_import_power_w"]) - if row["max_import_power_w"] is not None - else None, - no_export=bool(row["no_export"] or False), - max_battery_charge_w=int(row["max_battery_charge_w"]) - if row["max_battery_charge_w"] is not None - else None, - max_battery_discharge_w=int(row["max_battery_discharge_w"]) - if row["max_battery_discharge_w"] is not None - else None, - min_soc_percent=int(round(float(row["min_soc_percent"]))) - if row["min_soc_percent"] is not None - else None, - reserve_soc_percent=int(row["reserve_soc_percent"]) - if row["reserve_soc_percent"] is not None - else None, - max_soc_percent=int(row["max_soc_percent"]) - if row["max_soc_percent"] is not None - else None, - usable_capacity_wh=int(row["usable_capacity_wh"]) - if row["usable_capacity_wh"] is not None - else None, - max_charge_a=max_charge_a, - max_discharge_a=max_discharge_a, - deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"], - deye_last_system_time_sync_at=row["deye_last_system_time_sync_at"], - deye_last_tou_inactive_write_prague_date=row[ - "deye_last_tou_inactive_write_prague_date" - ], - deye_tou_inactive_signature=row["deye_tou_inactive_signature"], - deye_zero_export_mode=int(row["deye_zero_export_mode"]), - ) - - -def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]: - """Hodnoty pro reg 62–64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis).""" - now = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0) - reg62 = ((now.year - 2000) << 8) | now.month - reg63 = (now.day << 8) | now.hour - reg64 = (now.minute << 8) | 0 - rows = [ - (62, "", reg62), - (63, "", reg63), - (64, "", reg64), - ] - return now, rows - - -def _deye_time_point_rows( - slot_index: int, - time_hhmm: int, - power_w: int, - soc_pct: int, - grid_charge: bool, -) -> list[tuple[int, str, int]]: - g = 1 if grid_charge else 0 - return [ - (148 + slot_index, "", time_hhmm), - (154 + slot_index, "", power_w), - (166 + slot_index, "", soc_pct), - (172 + slot_index, "", g), - ] - - -def _slot_start_prague_sql(slot_offset: int) -> str: - """Výraz TIMESTAMPTZ = začátek aktuálního (+offset) 15min slotu v Europe/Prague.""" - off = int(slot_offset) - return f""" - ( - WITH loc AS (SELECT now() AT TIME ZONE 'Europe/Prague' AS ts) - SELECT ( - (date_trunc('day', ts) - + make_interval( - hours => EXTRACT(HOUR FROM ts)::int, - mins => (FLOOR(EXTRACT(MINUTE FROM ts) / 15) * 15)::int - ) - )::timestamp AT TIME ZONE 'Europe/Prague' - ) + INTERVAL '{off * 15} minutes' - FROM loc - ) - """ - - -async def _fetch_plan_row_for_slot_offset( - site_id: int, db: asyncpg.Connection, slot_offset: int -) -> asyncpg.Record | None: - """Řádek plánu pro slot: 0 = probíhající 15min, 1 = následující (hranice v Europe/Prague).""" - t = _slot_start_prague_sql(slot_offset) - return await db.fetchrow( - f""" - SELECT pi.* FROM ems.planning_interval pi - JOIN ems.planning_run pr ON pr.id = pi.run_id - WHERE pr.site_id = $1 AND pr.status = 'active' - AND pi.interval_start = {t} - LIMIT 1 - """, - site_id, - ) - - -async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int: - v = await db.fetchval( - """ - SELECT 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 - 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 AND ai.controllable = true AND ai.active = true - ORDER BY ab.id - LIMIT 1 - """, - site_id, - ) - if v is None: - return 0 - return int(v) - - -def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> ControlSetpoints | None: - code = mode.mode_code - if code == "MANUAL": - return None - - if code == "AUTO": - if pi is None: - return None - grid_sp = int(pi["grid_setpoint_w"] or 0) - ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0 - ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0 - hp_en = bool(pi["heat_pump_enabled"]) - tgt = pi["battery_soc_target_pct"] - target_soc = int(round(float(tgt))) if tgt is not None else None - sell_raw = pi.get("effective_sell_price") - sell_f: float | None = float(sell_raw) if sell_raw is not None else None - return ControlSetpoints( - battery_w=int(pi["battery_setpoint_w"] or 0), - grid_export_limit=abs(min(grid_sp, 0)), - ev1_current_a=watts_to_amps(ev1_w, phases=3), - ev2_current_a=watts_to_amps(ev2_w, phases=1), - heat_pump_enable=hp_en, - grid_setpoint_w=grid_sp, - ev1_power_w=ev1_w, - ev2_power_w=ev2_w, - target_soc_pct=target_soc, - effective_sell_price_czk_kwh=sell_f, - ) - - if code == "SELF_SUSTAIN": - return ControlSetpoints( - battery_w=None, - grid_export_limit=0, - ev1_current_a=0, - ev2_current_a=0, - heat_pump_enable=False, - grid_setpoint_w=0, - ev1_power_w=0, - ev2_power_w=0, - target_soc_pct=None, - self_sustain_local_use=True, - ) - - if code == "CHARGE_CHEAP": - # max_charge doplníme v export_setpoints z DB - return ControlSetpoints( - battery_w=0, - grid_export_limit=0, - ev1_current_a=0, - ev2_current_a=0, - heat_pump_enable=False, - grid_setpoint_w=0, - ev1_power_w=0, - ev2_power_w=0, - target_soc_pct=None, - ) - - if code == "PRESERVE": - return ControlSetpoints( - battery_w=0, - grid_export_limit=0, - ev1_current_a=0, - ev2_current_a=0, - heat_pump_enable=False, - grid_setpoint_w=0, - ev1_power_w=0, - ev2_power_w=0, - target_soc_pct=None, - lock_battery=True, - ) - - logger.warning("Unknown mode_code %s for site export, skipping", code) - return None - - -def _apply_price_failsafe_guard( - site_id: int, - mode: OperatingModeInfo, - pi: asyncpg.Record | None, - sp: ControlSetpoints, -) -> ControlSetpoints: - if mode.mode_code != "AUTO" or pi is None: - return sp - if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]): - return sp - logger.warning( - "control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard", - site_id, - ) - return ControlSetpoints( - battery_w=0, - grid_export_limit=0, - ev1_current_a=sp.ev1_current_a, - ev2_current_a=sp.ev2_current_a, - heat_pump_enable=sp.heat_pump_enable, - grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)), - ev1_power_w=sp.ev1_power_w, - ev2_power_w=sp.ev2_power_w, - target_soc_pct=sp.target_soc_pct, - effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh, - ) - - -def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int: - """Reg 143 – max export W z DB (např. SUN-20K / home-01 = 13 500 W).""" - if no_export: - return 0 - return max(0, int(max_export_power_w or 0)) - - -def _clamp_deye_tou_soc_pct(pct: int) -> int: - return max(5, min(95, pct)) - - -def _clamp_deye_tou_soc_pct_hi(pct: int, hi: int) -> int: - """Stejné dolní omezení 5 % jako u TOU; horní mez z parametru (např. 100 u priority baterie).""" - return max(5, min(int(hi), int(pct))) - - -def _deye_tou_min_soc_pct(inv: InverterConfig) -> int: - if inv.min_soc_percent is not None: - return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent)) - return 10 - - -def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int: - if inv.reserve_soc_percent is not None: - return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent)) - return 20 - - -def _deye_passive_tou_battery_soc_pct( - inv: InverterConfig, - setpoints: ControlSetpoints, -) -> int: - """ - Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE. - - Na home-01 Deye interpretuje TOU % jako „kam má směřovat využití baterie“: - je-li zapsané procento **nižší než skutečný SoC**, přebytek FVE míří spíš do sítě. - - Při **záporné vykupní** nebo **plánovaném nabíjení** (kladný ``battery_w``) EMS - zapíše **100 %** do TOU (signál střídači „ber přebytek do baterie v celém rozsahu“). - **``max_soc_percent`` v DB** je odděleně: horní limit pro **plánovač / Wh bilance** - (denní provoz, viz komentář sloupce), **nikoli** časové „do kdy“. - - Jinak zůstane provozní podlaha ``min_soc_percent`` (typicky nízká % → přetok do sítě - možný dle chování Deye). - - Režim **SELF_SUSTAIN** (``self_sustain_local_use``): vždy ``min_soc_percent`` — nízké - TOU drží prioritu „baterie jako buffer“ při plném reg. 108/109 a reg. 142 zero-export; - neaplikuje se sem logika 100 % podle ceny (LP se v SELF_SUSTAIN nepoužívá). - """ - mn = _deye_tou_min_soc_pct(inv) - if setpoints.self_sustain_local_use: - return mn - - bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w) - sell = setpoints.effective_sell_price_czk_kwh - want_battery_priority = bat_w > 0 or (sell is not None and float(sell) < 0) - - if not want_battery_priority: - return mn - - return _clamp_deye_tou_soc_pct_hi(DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT, hi=100) - - -def get_deye_mode(setpoints: ControlSetpoints) -> str: - """ - Fyzický režim Deye: SELL | CHARGE | PASSIVE. - - SELL only when battery actively discharges for grid export (bat_w < -500 - AND grid_w < -200). Pass-through (PV → grid, battery idle) stays PASSIVE - with reg 108 = 0 + reg 145 = 1 (solar sell). - battery_w=None (SELF_SUSTAIN) → bat_w considered 0 → PASSIVE; při exportu se ale - zapíše plný reg. 108/109 (viz ``self_sustain_local_use`` v ``write_inverter_setpoints``). - """ - grid_w = int(setpoints.grid_setpoint_w or 0) - bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w) - if bat_w < -500 and grid_w < -200: - return "SELL" - if bat_w > 500 and grid_w > 200: - return "CHARGE" - return "PASSIVE" - - -def _deye_tou_params( - setpoints: ControlSetpoints, - inv: InverterConfig, -) -> tuple[int, int, bool]: - """ - Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge. - Ve PASSIVE viz _deye_passive_tou_battery_soc_pct (min vs. plný max z DB). - """ - max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) - tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge - tou_min = _deye_tou_min_soc_pct(inv) - tou_reserve = _deye_tou_reserve_soc_pct(inv) - if setpoints.lock_battery: - return tp_discharge_w, tou_min, False - deye_mode = get_deye_mode(setpoints) - if deye_mode == "CHARGE": - raw_bat = setpoints.battery_w - battery_w = int(raw_bat) if raw_bat is not None else 0 - cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95 - target_soc = max(10, min(95, cap)) - tp_charge_w = ( - battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V) - ) - return tp_charge_w, target_soc, True - if deye_mode == "SELL": - return tp_discharge_w, tou_reserve, False - tou_soc = _deye_passive_tou_battery_soc_pct(inv, setpoints) - return tp_discharge_w, tou_soc, False - - -async def write_inverter_setpoints( - site_id: int, - setpoints_now: ControlSetpoints, - setpoints_next: ControlSetpoints | None, - db: asyncpg.Connection, - planning_run_id: int | None = None, -) -> str: - inv = await _load_inverter_config(site_id, db) - if inv is None: - return "FAIL inverter: no controllable Modbus endpoint" - - raw_bat = setpoints_now.battery_w - grid_w = int(setpoints_now.grid_setpoint_w or 0) - no_export = inv.no_export - export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w) - max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) - tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge - tou_min_pct = _deye_tou_min_soc_pct(inv) - tou_reserve_pct = _deye_tou_reserve_soc_pct(inv) - - try: - soc_telemetry = await _get_current_soc(site_id, db) - - deye_mode = get_deye_mode(setpoints_now) - - bat_w = int(raw_bat) if raw_bat is not None else 0 - if setpoints_now.lock_battery: - charge_a = 0 - discharge_a = 0 - elif deye_mode == "CHARGE": - charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a) - discharge_a = 0 - elif setpoints_now.self_sustain_local_use: - # SELF_SUSTAIN: plný nabíjecí i vybíjecí proud invertoru — přebytek FVE jde do baterie, - # reg. 142 = zero export to load/CT (viz selling_mode níže), ne reg. 108 = 0. - charge_a = int(inv.max_charge_a) - discharge_a = int(inv.max_discharge_a) - else: - charge_a = int(inv.max_charge_a) if bat_w > 0 else 0 - discharge_a = int(inv.max_discharge_a) - - zero_exp_mode = int(inv.deye_zero_export_mode or 1) - selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode - solar_sell = 1 - export_limit = export_lim - reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE - - logger.info( - f"[control] site={site_id} fyzický režim Deye: {deye_mode} | " - f"battery_w={raw_bat!r} grid_w={grid_w} | " - f"charge_a={charge_a} discharge_a={discharge_a} | " - f"reg142={selling_mode} reg145={solar_sell} reg178={reg178_val}" - ) - - now_cet, time_rows = _deye_system_time_register_rows() - skip_time = False - try: - mb_clock = await get_modbus_client(inv.host, inv.port) - tvals = await mb_clock.read_holding_registers( - 62, 3, int(inv.unit_id if inv.unit_id is not None else 1) - ) - if len(tvals) == 3: - skip_time = _deye_should_skip_time_sync_after_read( - inv, int(tvals[0]), int(tvals[1]), int(tvals[2]) - ) - else: - logger.warning( - "Deye clock read: expected 3 registers, got %s; will sync 62–64", - len(tvals), - ) - except Exception as e: - logger.warning("Deye clock read failed (will sync 62–64): %s", e) - - if skip_time: - logger.info( - "Deye clock 62–64 skipped (drift ≤ %ss, last sync < %sh ago): %s CET", - DEYE_CLOCK_DRIFT_OK_SEC, - DEYE_CLOCK_RESYNC_INTERVAL_HOURS, - now_cet.strftime("%Y-%m-%d %H:%M:%S"), - ) - else: - logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S")) - - registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows) - - sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now - hh_cur = current_slot_hhmm() - hh_nxt = next_slot_hhmm() - p1, s1, g1 = _deye_tou_params(setpoints_now, inv) - p2, s2, g2 = _deye_tou_params(sp_tp2, inv) - registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1)) - registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2)) - - prague_date = datetime.now(PRAGUE_TZ).date() - inactive_sig = ( - f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}" - ) - need_inactive_tou = ( - inv.deye_last_tou_inactive_write_prague_date != prague_date - or inv.deye_tou_inactive_signature != inactive_sig - ) - if need_inactive_tou: - for idx in range(2, 6): - registers.extend( - _deye_time_point_rows( - idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False - ) - ) - else: - logger.debug( - "Deye TOU rows 3–6 skipped (already written today, signature unchanged)" - ) - - registers.extend( - [ - (108, "", charge_a), - (109, "", discharge_a), - (141, "energy_mode (0)", 0), - (142, "limit_control", selling_mode), - (143, "", export_limit), - (145, "solar_sell", solar_sell), - (178, "grid_peak_shaving_switch", reg178_val), - ] - ) - - logger.info( - "[control] %s: deye_mode=%s charge=%sA discharge=%sA " - "reg142=%s reg145=%s export=%sW " - "tp1=%s tp2=%s soc=%s%% (batt=%r grid=%sW)", - inv.code, - deye_mode, - charge_a, - discharge_a, - selling_mode, - solar_sell, - export_limit, - hh_cur, - hh_nxt, - soc_telemetry, - raw_bat, - grid_w, - ) - - last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db) - registers, skipped_unchanged = _drop_registers_matching_last_verified( - registers, last_verified - ) - if skipped_unchanged: - logger.info( - "[control] %s: skip %s registers (value equals last verified): %s", - inv.code, - len(skipped_unchanged), - skipped_unchanged[:24], - ) - if not registers: - logger.info( - "[control] %s: all Deye holding regs match last verified, no Modbus write", - inv.code, - ) - if need_inactive_tou: - await db.execute( - """ - UPDATE ems.asset_inverter - SET deye_last_tou_inactive_write_prague_date = $1, - deye_tou_inactive_signature = $2 - WHERE id = $3 - """, - prague_date, - inactive_sig, - inv.id, - ) - return ( - f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)" - ) - - will_write_inactive = any( - int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers - ) - - cmd_ids = await create_modbus_commands( - site_id, - planning_run_id, - "inverter", - inv.id, - inv.code, - inv.host, - inv.port, - inv.unit_id, - registers, - db, - deye_physical_mode=deye_mode, - ) - if not await execute_modbus_commands(cmd_ids, db): - return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)" - logger.info("[control] Inverter %s journal write OK", inv.code) - - will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers) - if will_write_time: - await db.execute( - """ - UPDATE ems.asset_inverter - SET deye_last_system_time_sync_minute = $1, - deye_last_system_time_sync_at = now() - WHERE id = $2 - """, - _prague_minute_start_utc(), - inv.id, - ) - - if need_inactive_tou or will_write_inactive: - await db.execute( - """ - UPDATE ems.asset_inverter - SET deye_last_tou_inactive_write_prague_date = $1, - deye_tou_inactive_signature = $2 - WHERE id = $3 - """, - prague_date, - inactive_sig, - inv.id, - ) - except Exception as e: - return f"FAIL inverter: {inv.code}: {e}" - - return ( - f"OK inverter: batt_w={raw_bat!r} " - f"(time points + FC 0x10: 108/109/141/142/178/143)" - ) - - -async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]: - """ - Živé čtení holding registrů Deye 108, 109, 141–145, 178, 191 (stejné TCP spojení jako telemetrie/export). - Vše pod jedním mutexem + sdružené FC3 bloky — mezi jednotlivými read_register dřív telemetrie - střídavě brala lock a RS485 brány házely cizí transaction_id / I/O timeouty. - """ - inv = await _load_inverter_config(site_id, db) - if inv is None: - raise ValueError("no controllable Modbus inverter for site") - - uid = int(inv.unit_id) - client = await get_modbus_client(inv.host, inv.port) - read_at = datetime.now(timezone.utc) - try: - async with client.batch(uid) as mb: - b108 = await mb.read_holding_registers(108, 2) - b141 = await mb.read_holding_registers(141, 5) - r178 = await mb.read_holding_registers(178, 1) - r191 = await mb.read_holding_registers(191, 1) - r108, r109 = b108[0], b108[1] - r141, r142, r143 = b141[0], b141[1], b141[2] - r145 = b141[4] - r178 = r178[0] - r191 = r191[0] - except Exception: - logger.exception("read_deye_registers_live site=%s failed", site_id) - raise - - return { - "reg108_charge_a": int(r108), - "reg109_discharge_a": int(r109), - "reg141_energy_mode": int(r141), - "reg142_limit_control": int(r142), - "reg143_export_limit_w": int(r143), - "reg145_solar_sell": int(r145), - "reg178_peak_shaving_switch": int(r178), - "reg191_peak_shaving_w": int(r191), - "read_at": read_at.isoformat(), - } - - -def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int: - c = (charger_code or "").strip().lower() - if c == "ev-charger-1": - a = sp.ev1_current_a - elif c == "ev-charger-2": - a = sp.ev2_current_a - elif c.endswith("-1") or c == "ev1": - a = sp.ev1_current_a - elif c.endswith("-2") or c == "ev2": - a = sp.ev2_current_a - else: - a = 0 - if a < 6: - a = 0 - return a - - -async def write_ev_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str: - rows = await db.fetch( - """ - SELECT ec.code, se.host, se.port, se.unit_id - FROM ems.asset_ev_charger ec - JOIN ems.site_endpoint se ON se.id = ec.endpoint_id - WHERE ec.site_id = $1 - AND ec.schedulable = true - AND se.enabled = true - AND se.endpoint_type = 'modbus_tcp' - ORDER BY ec.code - """, - site_id, - ) - if not rows: - return "OK EV: no schedulable chargers" - - for row in rows: - code = row["code"] - current_a = _current_limit_for_charger(code, setpoints) - logger.info( - "EV setpoint [%s]: %sA (TODO: Modbus registers)", - code, - current_a, - ) - return f"OK EV: logged {len(rows)} charger(s) (Modbus TODO)" - - -async def write_heat_pump_setpoint(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str: - rows = await db.fetch( - """ - SELECT hp.code, se.host, se.port, se.unit_id - FROM ems.asset_heat_pump hp - JOIN ems.site_endpoint se ON se.id = hp.endpoint_id - WHERE hp.site_id = $1 - AND hp.schedulable = true - AND se.enabled = true - AND se.endpoint_type = 'modbus_tcp' - """, - site_id, - ) - if not rows: - return "OK heat pump: no schedulable unit" - for row in rows: - logger.info( - "HP setpoint [%s]: enable=%s (TODO: Modbus registers)", - row["code"], - setpoints.heat_pump_enable, - ) - return "OK heat pump: logged (Modbus TODO)" - - -async def send_loxone_setpoints( - site_id: int, - setpoints: ControlSetpoints, - mode: OperatingModeInfo, - db: asyncpg.Connection, -) -> str: - endpoint = await db.fetchrow( - """ - SELECT host, port, protocol - FROM ems.site_endpoint - WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true - ORDER BY id - LIMIT 1 - """, - site_id, - ) - if not endpoint: - return "OK Loxone: no endpoint, skipped" - - proto = (endpoint["protocol"] or "http").lower() - if proto not in ("http", "https"): - proto = "http" - host = endpoint["host"] - port = int(endpoint["port"] or (443 if proto == "https" else 80)) - base = f"{proto}://{host}:{port}/dev/sps/io" - - settings = get_settings() - user = settings.loxone_user or os.getenv("LOXONE_USER") or "" - password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or "" - auth = (user, password) if user else None - - batt_display = 0 if setpoints.battery_w is None else int(setpoints.battery_w) - - paths: list[tuple[str, int]] = [ - (f"{base}/EMS_Mode/{mode.loxone_mode_value}", mode.loxone_mode_value), - (f"{base}/EMS_Battery_Setpoint_W/{batt_display}", batt_display), - (f"{base}/EMS_Grid_Setpoint_W/{setpoints.grid_setpoint_w}", setpoints.grid_setpoint_w), - (f"{base}/EMS_EV1_Power_W/{setpoints.ev1_power_w}", setpoints.ev1_power_w), - (f"{base}/EMS_EV2_Power_W/{setpoints.ev2_power_w}", setpoints.ev2_power_w), - (f"{base}/EMS_HeatPump_Enable/{1 if setpoints.heat_pump_enable else 0}", 1 if setpoints.heat_pump_enable else 0), - ] - - errs: list[str] = [] - try: - async with httpx.AsyncClient(timeout=5.0) as client: - for url, _ in paths: - try: - r = await client.get(url, auth=auth) - r.raise_for_status() - except Exception as e: - errs.append(f"{url!s}: {e}") - except Exception as e: - return f"FAIL Loxone: client {e}" - - if errs: - return "FAIL Loxone: " + "; ".join(errs[:3]) - return "OK Loxone: all virtual inputs updated" - - -async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: - mode = await _fetch_operating_mode(site_id, db) - if mode is None: - logger.warning("control export site=%s: no operating mode row", site_id) - return - - if mode.mode_code == "MANUAL": - logger.info("control export site=%s: MANUAL, skip writes", site_id) - return - - pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0) - pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1) - sp_now = _build_setpoints(mode, pi_now) - sp_next = _build_setpoints(mode, pi_next) - - if mode.mode_code == "AUTO" and sp_now is None: - if pi_now is None: - logger.warning( - "control export site=%s: AUTO but no planning_interval for current slot, skip", - site_id, - ) - return - - if sp_now is None: - logger.warning( - "control export site=%s: no setpoints for mode %s, skip", - site_id, - mode.mode_code, - ) - return - - if mode.mode_code == "CHARGE_CHEAP": - max_ch = await _fetch_max_charge_power_w(site_id, db) - # Kladný grid_setpoint_w > 200 → fyzický CHARGE (nabíjení ze sítě), viz get_deye_mode - grid_for_charge = max(300, max_ch) - sp_now = ControlSetpoints( - battery_w=max_ch, - grid_export_limit=0, - ev1_current_a=0, - ev2_current_a=0, - heat_pump_enable=False, - grid_setpoint_w=grid_for_charge, - ev1_power_w=0, - ev2_power_w=0, - target_soc_pct=None, - effective_sell_price_czk_kwh=None, - ) - sp_next = sp_now - else: - sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now) - if sp_next is not None: - sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next) - - planning_run_id = await db.fetchval( - """ - SELECT id FROM ems.planning_run - WHERE site_id = $1 AND status = 'active' - ORDER BY created_at DESC - LIMIT 1 - """, - site_id, - ) - if planning_run_id is not None: - planning_run_id = int(planning_run_id) - - try: - inv_res = await write_inverter_setpoints( - site_id, sp_now, sp_next, db, planning_run_id=planning_run_id - ) - except Exception as e: - logger.error("inverter write failed: %s", e) - inv_res = f"FAIL inverter: {e}" - - try: - ev_res = await write_ev_setpoints(site_id, sp_now, db) - except Exception as e: - logger.error("ev write failed: %s", e) - ev_res = f"FAIL ev: {e}" - - try: - hp_res = await write_heat_pump_setpoint(site_id, sp_now, db) - except Exception as e: - logger.error("hp write failed: %s", e) - hp_res = f"FAIL heat pump: {e}" - - try: - lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db) - except Exception as e: - logger.error("loxone write failed: %s", e) - lox_res = f"FAIL Loxone: {e}" - - results = list( - zip( - ("inverter", "ev", "heat_pump", "loxone"), - (inv_res, ev_res, hp_res, lox_res), - ) - ) - - for name, res in results: - if isinstance(res, Exception): - logger.error("control export site=%s %s: FAIL %s", site_id, name, res) - elif isinstance(res, str) and res.startswith("FAIL"): - logger.error("control export site=%s %s: %s", site_id, name, res) - else: - logger.info("control export site=%s %s: %s", site_id, name, res) +from services.control.exporter_monolith import * # noqa: F401,F403 diff --git a/backend/services/notification_service.py b/backend/services/notification_service.py index cd33395..5e50dbe 100644 --- a/backend/services/notification_service.py +++ b/backend/services/notification_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import logging from datetime import datetime @@ -78,31 +79,26 @@ async def run_fn_set_mode_with_discord( notify_level: str | None = None, ) -> str: """ - Zavolá ems.fn_set_mode. Při skutečné změně režimu pošle Discord (pokud je webhook). + Zavolá ems.fn_set_mode_with_context. Při skutečné změně režimu pošle Discord (pokud je webhook). Vrátí aktuální mode_code z DB po volání. """ - prev = await conn.fetchval( - "SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1", - site_id, - ) - await conn.execute( - "SELECT ems.fn_set_mode($1, $2, $3, $4, $5)", + raw = await conn.fetchval( + """ + select ems.fn_set_mode_with_context($1::int, $2::text, $3::text, $4::timestamptz, $5::text) + """, site_id, mode_code, activated_by, valid_until, notes, ) - new = await conn.fetchval( - "SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1", - site_id, - ) + ctx = raw if isinstance(raw, dict) else json.loads(raw) + prev = ctx.get("previous_mode") + new = ctx.get("new_mode") if new is None: new = mode_code + site_code = ctx.get("site_code") if prev is not None and prev != new: - site_code = await conn.fetchval( - "SELECT code FROM ems.site WHERE id = $1", site_id - ) await notify_operating_mode_changed( site_code or str(site_id), str(prev), diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 4681902..22cd686 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -7,6 +7,7 @@ # scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0) # scheduler.add_job(run_rolling_replan, 'cron', minute='*/15') +import json import time import logging from dataclasses import dataclass, replace @@ -149,107 +150,6 @@ def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]: return (loc.weekday() + 1) % 7, loc.hour -# ============================================================ -# Slot pre-selection (anti-micro-cycling) -# ============================================================ - -def _select_charge_slots( - slots: list["PlanningSlot"], - battery, - current_soc_wh: float, -) -> set[int]: - """ - Pre-select which slots are eligible for battery charging (anti-micro-cycling). - - Logika: - 1) Sloty s PV-surplus (pv_a + pv_b > load_baseline) jsou vždy zahrnuty – - nabíjení z FVE je „zdarma“, solver ho musí mít povolené. Tyto sloty se - NEzapočítávají do grid rozpočtu (v dlouhém horizontu by přetekly target). - 2) Nezávisle na bodu 1 se vybere top-N **grid** slotů seřazených podle - `buy_price` ASC tak, aby pokryly `charge_buf × (soc_max − current_soc)`. - Tím dostane solver k dispozici přístup k nejlevnějšímu nákupu ze sítě, - i když PV v daném slotu spotřebu nepokrývá. - 3) Per-slot kapacita přírůstku SoC = max_charge_power × η × 15 min (plný - výkon, ne limitovaný aktuálním PV-surplus výkonem). - - Vrací množinu indexů povolených pro `bc[t] > 0` v MILP. Prázdná množina = žádné - restrikce. `charge_slot_buffer <= 0` v DB ⇒ všechny sloty povoleny. - """ - charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0) - if charge_buf <= 0: - return set(range(len(slots))) - - energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh) - if energy_to_fill <= 0: - return set() - - eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0) - max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0) - per_slot_full_wh = max_p_w * eta * INTERVAL_H - - selected: set[int] = set() - for t, s in enumerate(slots): - pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w) - if pv_surplus_w > 0: - selected.add(t) - - grid_target_wh = energy_to_fill * charge_buf - if grid_target_wh <= 0 or per_slot_full_wh <= 0: - return selected - - grid_candidates = [ - (t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected - ] - grid_candidates.sort(key=lambda x: x[1]) - - cumulative = 0.0 - for t, _price in grid_candidates: - if cumulative >= grid_target_wh: - break - selected.add(t) - cumulative += per_slot_full_wh - - return selected - - -def _select_discharge_export_slots( - slots: list["PlanningSlot"], - battery, -) -> set[int]: - """ - Pre-select which slots may use battery energy for grid export. - Only the Y most expensive sell-price slots are selected, - enough to empty the exportable portion of the battery with a buffer. - Returns set of slot indices. Empty set = no restriction. - """ - discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0) - if discharge_buf <= 0: - return set(range(len(slots))) - - exportable = float(battery.soc_max_wh) - float(battery.min_soc_wh) - if exportable <= 0: - return set() - - candidates = [(t, float(s.sell_price)) for t, s in enumerate(slots)] - candidates.sort(key=lambda x: x[1], reverse=True) - - energy_per_slot = ( - float(battery.max_discharge_power_w) - * float(battery.discharge_efficiency) - * INTERVAL_H - ) - target = exportable * discharge_buf - selected: set[int] = set() - cumulative = 0.0 - for t, _price in candidates: - if cumulative >= target: - break - selected.add(t) - cumulative += energy_per_slot - - return selected - - # ============================================================ # Datové třídy (lze nahradit pydantic modely) # ============================================================ @@ -265,6 +165,8 @@ class PlanningSlot: ev1_connected: bool ev2_connected: bool is_predicted_price: bool = False + allow_charge: bool = True + allow_discharge_export: bool = True @dataclass @@ -303,49 +205,31 @@ async def compute_correction_factor( factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný. """ window_start = now - timedelta(hours=window_h) - - # Skutečná výroba za okno (z telemetrie) - actual = await db.fetchval(""" - SELECT COALESCE(SUM(pv_power_w) * 0.25 / 1000.0, 0) -- kWh - FROM ems.telemetry_inverter - WHERE site_id = $1 - AND measured_at >= $2 AND measured_at < $3 - """, site_id, window_start, now) - - # Předpovídaná výroba za stejné okno (z nejnovějšího forecastu který platil tehdy) - forecast = await db.fetchval(""" - SELECT COALESCE(SUM(fpi.power_w) * 0.25 / 1000.0, 0) - FROM ems.forecast_pv_interval fpi - JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id - WHERE fpr.site_id = $1 - AND fpi.interval_start >= $2 AND fpi.interval_start < $3 - AND fpr.status = 'ok' - AND fpr.created_at = ( - SELECT MAX(fpr2.created_at) - FROM ems.forecast_pv_run fpr2 - WHERE fpr2.site_id = $1 AND fpr2.status = 'ok' - AND fpr2.created_at <= $2 - ) - """, site_id, window_start, now) - + raw = await db.fetchval( + """ + select ems.fn_pv_forecast_correction_factor( + $1::int, $2::timestamptz, $3::timestamptz, + $4::numeric, $5::numeric + ) + """, + site_id, + window_start, + now, + CORRECTION_MIN_CLAMP, + CORRECTION_MAX_CLAMP, + ) + j = raw if isinstance(raw, dict) else json.loads(raw) + factor = float(j.get("correction_factor", 1.0)) log_data = { - "window_start": window_start, - "window_end": now, - "actual_pv_wh": actual * 1000, - "forecast_pv_wh": forecast * 1000, + "window_start": j.get("window_start", window_start), + "window_end": j.get("window_end", now), + "actual_pv_wh": j.get("actual_pv_wh"), + "forecast_pv_wh": j.get("forecast_pv_wh"), + "correction_factor": factor, + "reason": j.get("reason", "ok"), } - - # Pokud forecast nebo actual jsou příliš malé (noc, <0.1 kWh) → žádná korekce - if forecast < 0.1 or actual < 0.05: - log_data["correction_factor"] = 1.0 - log_data["reason"] = "insufficient_data" - return 1.0, log_data - - raw_factor = actual / forecast - factor = max(CORRECTION_MIN_CLAMP, min(CORRECTION_MAX_CLAMP, raw_factor)) - - log_data["correction_factor"] = factor - log_data["raw_factor"] = raw_factor + if j.get("raw_factor") is not None: + log_data["raw_factor"] = j["raw_factor"] return factor, log_data @@ -559,10 +443,10 @@ def solve_dispatch( if slots[t].is_predicted_price: prob += ge[t] == 0 - # Slot pre-selection: omezení nabíjení a discharge-exportu na vybrané sloty + # Slot pre-selection (z DB fn_load_planning_slots_full → allow_*) if om == "AUTO": - charge_slots = _select_charge_slots(slots, battery, current_soc_wh) - discharge_export_slots = _select_discharge_export_slots(slots, battery) + charge_slots = {t for t, s in enumerate(slots) if s.allow_charge} + discharge_export_slots = {t for t, s in enumerate(slots) if s.allow_discharge_export} for t in range(T): if t not in charge_slots: prob += bc[t] == 0 @@ -683,7 +567,10 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily" logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}") - slots = await _load_slots(site_id, horizon_from, horizon_to, db) + battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = ( + await _load_site_context(site_id, db) + ) + slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh) critical_slots = int(36 / INTERVAL_H) missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price) price_failsafe_active = missing_ote_count > 0 @@ -694,11 +581,6 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily" missing_ote_count, ) - battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode = ( - await _load_site_context(site_id, db) - ) - tuv_stats = await _load_tuv_usage_stats(site_id, db) - results, duration_ms = solve_dispatch( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, @@ -750,17 +632,20 @@ async def run_rolling_replan( now = datetime.now(timezone.utc) replan_from = _current_slot_start(now) - active_run = await db.fetchrow(""" - SELECT id, horizon_end FROM ems.planning_run - WHERE site_id = $1 AND status = 'active' - ORDER BY created_at DESC LIMIT 1 - """, site_id) - - if not active_run: + ar_raw = await db.fetchval( + "select ems.fn_planning_active_run($1::int)", + site_id, + ) + ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw) + if ar.get("error") == "no_active_plan": logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan") return await run_daily_plan(site_id, db, triggered_by=triggered_by) - horizon_to = active_run["horizon_end"] + he = ar["horizon_end"] + if isinstance(he, datetime): + horizon_to = he if he.tzinfo else he.replace(tzinfo=timezone.utc) + else: + horizon_to = datetime.fromisoformat(str(he).replace("Z", "+00:00")) if (horizon_to - replan_from).total_seconds() < 1800: if allow_skip: @@ -771,13 +656,13 @@ async def run_rolling_replan( logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}") - battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode = ( + battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = ( await _load_site_context(site_id, db) ) correction_factor, correction_log = await compute_correction_factor(site_id, now, db) - slots = await _load_slots(site_id, replan_from, horizon_to, db) + slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh) slots_before_pv_correction = list(slots) critical_slots = int(36 / INTERVAL_H) missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price) @@ -791,8 +676,6 @@ async def run_rolling_replan( slots = apply_forecast_correction(slots, now, correction_factor) - tuv_stats = await _load_tuv_usage_stats(site_id, db) - results, duration_ms = solve_dispatch( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, @@ -818,10 +701,10 @@ async def run_rolling_replan( await db.execute( """ - INSERT INTO ems.forecast_correction_log - (site_id, window_start, window_end, actual_pv_wh, forecast_pv_wh, - correction_factor, applied_to_run_id) - VALUES ($1,$2,$3,$4,$5,$6,$7) + select ems.fn_forecast_correction_log_insert( + $1::int, $2::timestamptz, $3::timestamptz, + $4::numeric, $5::numeric, $6::numeric, $7::int + ) """, site_id, correction_log["window_start"], @@ -870,184 +753,86 @@ def _current_slot_start(dt: datetime) -> datetime: return dt.replace(minute=minute, second=0, microsecond=0) -def _ev_session_ctx(row) -> Optional[SimpleNamespace]: - """Kontext deadline constraintu pro jedno EV (nebo None).""" - if row is None or row["target_deadline"] is None: +def _parse_json_dt(val: object) -> Optional[datetime]: + if val is None: return None - cap_kwh = row["veh_cap_kwh"] - if cap_kwh is None: + if isinstance(val, datetime): + return val if val.tzinfo else val.replace(tzinfo=timezone.utc) + return datetime.fromisoformat(str(val).replace("Z", "+00:00")) + + +def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]: + if obj is None or obj == []: return None - cap_wh = float(cap_kwh) * 1000.0 - tgt = row["target_soc_pct"] - if tgt is None: - tgt = row["default_target_soc_pct"] - if tgt is None: + if isinstance(obj, str): + obj = json.loads(obj) + if not isinstance(obj, dict): return None - tgt_f = float(tgt) - soc0 = row["soc_at_connect_pct"] - if soc0 is None: - return None - needed_wh = (tgt_f - float(soc0)) / 100.0 * cap_wh - delivered = float(row["energy_delivered_wh"] or 0) - remaining = max(0.0, needed_wh - delivered) - if remaining <= 0: + td = _parse_json_dt(obj.get("target_deadline")) + if td is None: return None return SimpleNamespace( - target_deadline=row["target_deadline"], - energy_needed_wh=remaining, + target_deadline=td, + energy_needed_wh=float(obj["energy_needed_wh"]), ) async def _load_site_context(site_id: int, db): """ - Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV a provozní režim pro solver. + Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV, režim a TUV statistiky (SQL). """ - operating_mode = await db.fetchval( - "SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1", + raw = await db.fetchval( + "select ems.fn_planning_site_context($1::int)", site_id, ) + ctx = raw if isinstance(raw, dict) else json.loads(raw) + if ctx.get("error") == "unknown_site": + raise RuntimeError(f"Site not found: {site_id}") - brow = await db.fetchrow( - """ - SELECT ab.usable_capacity_wh, - ab.min_soc_percent, - ab.reserve_soc_percent, - ab.max_soc_percent, - ab.charge_efficiency, - ab.discharge_efficiency, - ab.degradation_cost_czk_kwh, - ab.charge_slot_buffer, - ab.discharge_slot_buffer, - LEAST( - COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w), - COALESCE( - ab.bms_max_charge_w, - CASE WHEN ab.max_charge_c_rate IS NOT NULL - THEN (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint - END, - COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w) - ) - ) AS effective_charge_w, - LEAST( - COALESCE(ai.max_battery_discharge_w, ai.max_discharge_power_w), - COALESCE( - ab.bms_max_discharge_w, - CASE WHEN ab.max_discharge_c_rate IS NOT NULL - THEN (ab.max_discharge_c_rate * ab.usable_capacity_wh)::bigint - END, - COALESCE(ai.max_battery_discharge_w, ai.max_discharge_power_w) - ) - ) AS effective_discharge_w - FROM ems.asset_battery ab - JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id AND ai.site_id = ab.site_id - WHERE ab.site_id = $1 - ORDER BY ab.id - LIMIT 1 - """, - site_id, - ) - if brow is None: - raise RuntimeError(f"No asset_battery for site_id={site_id}") - - ec_w = brow["effective_charge_w"] - ed_w = brow["effective_discharge_w"] - if ec_w is None or ed_w is None: - raise RuntimeError( - f"Battery effective power limits missing for site_id={site_id} " - "(need max_battery_charge_w/max_discharge or legacy max_charge_power_w / max_discharge_power_w)" - ) - ec_i = int(ec_w) - ed_i = int(ed_w) - if ec_i <= 0 or ed_i <= 0: - raise RuntimeError( - f"Invalid battery effective limits for site_id={site_id}: " - f"charge={ec_i}W discharge={ed_i}W" - ) - - uc = float(brow["usable_capacity_wh"]) - min_soc_wh = float(brow["min_soc_percent"]) / 100.0 * uc - arb_floor_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc - soc_max_wh = float(brow["max_soc_percent"]) / 100.0 * uc + b = ctx["battery"] + ec_i = int(b["max_charge_power_w"]) + ed_i = int(b["max_discharge_power_w"]) battery = SimpleNamespace( - usable_capacity_wh=uc, - min_soc_wh=min_soc_wh, - arb_floor_wh=arb_floor_wh, - reserve_soc_wh=arb_floor_wh, - soc_max_wh=soc_max_wh, - charge_efficiency=float(brow["charge_efficiency"]), - discharge_efficiency=float(brow["discharge_efficiency"]), - degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]), + usable_capacity_wh=float(b["usable_capacity_wh"]), + min_soc_wh=float(b["min_soc_wh"]), + arb_floor_wh=float(b["arb_floor_wh"]), + reserve_soc_wh=float(b["reserve_soc_wh"]), + soc_max_wh=float(b["soc_max_wh"]), + charge_efficiency=float(b["charge_efficiency"]), + discharge_efficiency=float(b["discharge_efficiency"]), + degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]), max_charge_power_w=ec_i, max_discharge_power_w=ed_i, - charge_slot_buffer=float(brow["charge_slot_buffer"]) if brow["charge_slot_buffer"] is not None else 0, - discharge_slot_buffer=float(brow["discharge_slot_buffer"]) if brow["discharge_slot_buffer"] is not None else 0, + charge_slot_buffer=float(b["charge_slot_buffer"]) + if b.get("charge_slot_buffer") is not None + else 0, + discharge_slot_buffer=float(b["discharge_slot_buffer"]) + if b.get("discharge_slot_buffer") is not None + else 0, ) - hrow = await db.fetchrow( - """ - SELECT COALESCE(rated_heating_power_w, 8000) AS rated_heating_power_w, - COALESCE(tuv_min_temp_c, 45) AS tuv_min_temp_c, - COALESCE(tuv_target_temp_c, 55) AS tuv_target_temp_c - FROM ems.asset_heat_pump - WHERE site_id = $1 - ORDER BY id - LIMIT 1 - """, - site_id, + hpj = ctx["heat_pump"] + heat_pump = SimpleNamespace( + rated_heating_power_w=int(hpj["rated_heating_power_w"]), + tuv_min_temp_c=float(hpj["tuv_min_temp_c"]), + tuv_target_temp_c=float(hpj["tuv_target_temp_c"]), ) - if hrow is None: - heat_pump = SimpleNamespace( - rated_heating_power_w=0, - tuv_min_temp_c=0.0, - tuv_target_temp_c=55.0, - ) - else: - hp_w = int(hrow["rated_heating_power_w"]) - heat_pump = SimpleNamespace( - rated_heating_power_w=max(hp_w, 0), - tuv_min_temp_c=float(hrow["tuv_min_temp_c"]), - tuv_target_temp_c=float(hrow["tuv_target_temp_c"]), - ) - grow = await db.fetchrow( - """ - SELECT max_import_power_w, max_export_power_w - FROM ems.site_grid_connection - WHERE site_id = $1 - ORDER BY id - LIMIT 1 - """, - site_id, - ) - if grow is None: - raise RuntimeError(f"No site_grid_connection for site_id={site_id}") + g = ctx["grid"] grid = SimpleNamespace( - max_import_power_w=int(grow["max_import_power_w"]), - max_export_power_w=int(grow["max_export_power_w"]), + max_import_power_w=int(g["max_import_power_w"]), + max_export_power_w=int(g["max_export_power_w"]), ) - vrows = await db.fetch( - """ - SELECT v.battery_capacity_kwh, - v.max_charge_power_w, - v.default_target_soc_pct, - ch.code AS charger_code - FROM ems.asset_vehicle v - JOIN ems.asset_ev_charger ch ON ch.id = v.default_charger_id - WHERE v.site_id = $1 - AND ch.code IN ('ev-charger-1', 'ev-charger-2') - ORDER BY ch.code - """, - site_id, - ) - vehicles: list[SimpleNamespace] = [ - SimpleNamespace( - max_charge_power_w=int(r["max_charge_power_w"]), - battery_capacity_kwh=float(r["battery_capacity_kwh"]), - default_target_soc_pct=float(r["default_target_soc_pct"]), + vehicles: list[SimpleNamespace] = [] + for v in ctx.get("vehicles") or []: + vehicles.append( + SimpleNamespace( + max_charge_power_w=int(v["max_charge_power_w"]), + battery_capacity_kwh=float(v["battery_capacity_kwh"]), + default_target_soc_pct=float(v["default_target_soc_pct"]), + ) ) - for r in vrows - ] while len(vehicles) < 2: vehicles.append( SimpleNamespace( @@ -1057,56 +842,19 @@ async def _load_site_context(site_id: int, db): ) ) - srows = await db.fetch( - """ - SELECT es.target_deadline, - es.target_soc_pct, - es.soc_at_connect_pct, - es.energy_delivered_wh, - ch.code AS charger_code, - v.battery_capacity_kwh AS veh_cap_kwh, - v.default_target_soc_pct - FROM ems.ev_session es - JOIN ems.asset_ev_charger ch ON ch.id = es.charger_id - LEFT JOIN ems.asset_vehicle v ON v.id = es.vehicle_id - WHERE es.site_id = $1 - AND es.session_end IS NULL - """, - site_id, - ) - by_charger = {r["charger_code"]: r for r in srows} + ev_raw = ctx.get("ev_sessions") or [] ev_sessions = [ - _ev_session_ctx(by_charger.get("ev-charger-1")), - _ev_session_ctx(by_charger.get("ev-charger-2")), + _ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None, + _ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None, ] - soc_pct = await db.fetchval( - """ - SELECT battery_soc_percent - FROM ems.telemetry_inverter - WHERE site_id = $1 - ORDER BY measured_at DESC - LIMIT 1 - """, - site_id, - ) - if soc_pct is None: - soc_wh = uc * 0.5 - else: - soc_wh = float(soc_pct) / 100.0 * uc - soc_wh = max(min_soc_wh, min(soc_wh, soc_max_wh)) + soc_wh = float(ctx["soc_wh"]) + tuv_temp = float(ctx["tuv_temp"]) + operating_mode = ctx.get("operating_mode") - tuv = await db.fetchval( - """ - SELECT tuv_tank_temp_c - FROM ems.telemetry_heat_pump - WHERE site_id = $1 - ORDER BY measured_at DESC - LIMIT 1 - """, - site_id, - ) - tuv_temp = float(tuv) if tuv is not None else 50.0 + tuv_stats: dict[tuple[int, int], float] = {} + for row in ctx.get("tuv_delta_stats") or []: + tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"]) return ( battery, @@ -1117,120 +865,33 @@ async def _load_site_context(site_id: int, db): soc_wh, tuv_temp, operating_mode, + tuv_stats, ) -async def _load_tuv_usage_stats(site_id: int, db) -> dict[tuple[int, int], float]: - """Průměrná změna teploty TUV zásobníku per (DOW, hodina) v konvenci DB EXTRACT(DOW).""" +async def _load_slots( + site_id: int, + from_dt: datetime, + to_dt: datetime, + db, + *, + soc_wh: float, +) -> list[PlanningSlot]: + """15min sloty z ems.fn_load_planning_slots_full.""" rows = await db.fetch( """ - SELECT day_of_week, hour_of_day, avg_temp_delta_c - FROM ems.tuv_usage_stats - WHERE site_id = $1 + select slot_ord, interval_start, buy_price, sell_price, is_predicted_price, + pv_a_forecast_w, pv_b_forecast_w, load_baseline_w, + ev1_connected, ev2_connected, allow_charge, allow_discharge_export + from ems.fn_load_planning_slots_full( + $1::int, $2::timestamptz, $3::timestamptz, $4::numeric + ) """, site_id, + from_dt, + to_dt, + soc_wh, ) - return { - (int(r["day_of_week"]), int(r["hour_of_day"])): float(r["avg_temp_delta_c"]) - for r in rows - } - - -async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]: - """Načte 15min sloty s cenami (OTE + predikce za horizont), forecasty a stavem EV z DB.""" - rows = await db.fetch(""" - WITH slot_spine AS ( - SELECT gs AS interval_start - FROM generate_series( - $2::timestamptz, - ($3::timestamptz - interval '15 minutes')::timestamptz, - interval '15 minutes' - ) AS gs - ) - SELECT - s.interval_start, - COALESCE( - ep.effective_buy_price_czk_kwh, - ems.fn_get_predicted_price($1, s.interval_start) - ) AS buy_price, - COALESCE( - ep.effective_sell_price_czk_kwh, - ems.fn_get_predicted_price($1, s.interval_start) * 0.85 - ) AS sell_price, - (ep.effective_buy_price_czk_kwh IS NULL) AS is_predicted_price, - COALESCE(fpi_a.power_w, 0) AS pv_a_forecast_w, - COALESCE(fpi_b.power_w, 0) AS pv_b_forecast_w, - COALESCE( - (SELECT bs.avg_power_w - FROM ems.consumption_baseline_stats bs - WHERE bs.site_id = $1 - AND bs.day_of_week = EXTRACT(DOW FROM s.interval_start - AT TIME ZONE 'Europe/Prague')::INT - AND bs.hour_of_day = EXTRACT(HOUR FROM s.interval_start - AT TIME ZONE 'Europe/Prague')::INT - LIMIT 1), - 500 - ) AS load_baseline_w, - (COALESCE(ev1.status, 'available') NOT IN ('available', 'unavailable')) AS ev1_connected, - (COALESCE(ev2.status, 'available') NOT IN ('available', 'unavailable')) AS ev2_connected - FROM slot_spine s - LEFT JOIN ems.vw_site_effective_price ep - ON ep.site_id = $1 AND ep.interval_start = s.interval_start - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(u.power_w), 0)::INT AS power_w - FROM ( - SELECT DISTINCT ON (apa.id) - fpi.power_w - FROM ems.asset_pv_array apa - JOIN ems.forecast_pv_run fpr - ON fpr.pv_array_id = apa.id - AND fpr.site_id = apa.site_id - AND fpr.status = 'ok' - JOIN ems.forecast_pv_interval fpi - ON fpi.run_id = fpr.id - AND fpi.pv_array_id = apa.id - AND fpi.interval_start = s.interval_start - WHERE apa.site_id = $1 - AND apa.controllable IS TRUE - ORDER BY apa.id, fpr.created_at DESC - ) u - ) fpi_a ON true - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(u.power_w), 0)::INT AS power_w - FROM ( - SELECT DISTINCT ON (apa.id) - fpi.power_w - FROM ems.asset_pv_array apa - JOIN ems.forecast_pv_run fpr - ON fpr.pv_array_id = apa.id - AND fpr.site_id = apa.site_id - AND fpr.status = 'ok' - JOIN ems.forecast_pv_interval fpi - ON fpi.run_id = fpr.id - AND fpi.pv_array_id = apa.id - AND fpi.interval_start = s.interval_start - WHERE apa.site_id = $1 - AND apa.controllable IS FALSE - ORDER BY apa.id, fpr.created_at DESC - ) u - ) fpi_b ON true - LEFT JOIN LATERAL ( - SELECT t.status - FROM ems.telemetry_ev_charger t - JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id - WHERE t.site_id = $1 AND ch.code = 'ev-charger-1' - ORDER BY t.measured_at DESC LIMIT 1 - ) ev1 ON true - LEFT JOIN LATERAL ( - SELECT t.status - FROM ems.telemetry_ev_charger t - JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id - WHERE t.site_id = $1 AND ch.code = 'ev-charger-2' - ORDER BY t.measured_at DESC LIMIT 1 - ) ev2 ON true - ORDER BY s.interval_start - """, site_id, from_dt, to_dt) - out: list[PlanningSlot] = [] for r in rows: d = dict(r) @@ -1245,6 +906,8 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]: ev1_connected=bool(d["ev1_connected"]), ev2_connected=bool(d["ev2_connected"]), is_predicted_price=bool(d.get("is_predicted_price")), + allow_charge=bool(d.get("allow_charge", True)), + allow_discharge_export=bool(d.get("allow_discharge_export", True)), ) ) if not out: @@ -1281,112 +944,59 @@ async def _save_planning_run( soc_wh, duration_ms, correction, db, slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None, ) -> int: - """Uloží výsledky solveru jako nový planning_run, deaktivuje předchozí.""" + """Uloží výsledky solveru přes ems.fn_planning_run_commit.""" if slot_inputs is not None and len(slot_inputs) != len(results): raise ValueError("slot_inputs and results length mismatch") - run_id = await db.fetchval(""" - INSERT INTO ems.planning_run - (site_id, horizon_start, horizon_end, status, - run_type, triggered_by, replan_from, - soc_at_replan_wh, solver_duration_ms, forecast_correction_factor) - VALUES ($1,$2,$3,'draft',$4,$5,$6,$7,$8,$9) - RETURNING id - """, site_id, horizon_from, horizon_to, - run_type, triggered_by, replan_from, - soc_wh, duration_ms, correction) + run_meta = { + "run_type": run_type, + "triggered_by": triggered_by, + "replan_from": replan_from.isoformat() if replan_from else None, + "soc_at_replan_wh": soc_wh, + "solver_duration_ms": duration_ms, + "forecast_correction_factor": correction, + } + intervals: list[dict] = [] + for i, r in enumerate(results): + row: dict = { + "interval_start": r.interval_start.isoformat() + if hasattr(r.interval_start, "isoformat") + else r.interval_start, + "battery_setpoint_w": r.battery_setpoint_w, + "battery_soc_target_pct": r.battery_soc_target, + "grid_setpoint_w": r.grid_setpoint_w, + "ev1_setpoint_w": r.ev1_setpoint_w, + "ev2_setpoint_w": r.ev2_setpoint_w, + "ev1_via_bat_w": r.ev1_via_bat_w, + "ev2_via_bat_w": r.ev2_via_bat_w, + "heat_pump_enabled": r.heat_pump_enabled, + "heat_pump_setpoint_w": r.heat_pump_setpoint_w, + "pv_a_curtailed_w": r.pv_a_curtailed_w, + "expected_cost_czk": float(r.expected_cost_czk), + "effective_buy_price": float(r.effective_buy_price), + "effective_sell_price": float(r.effective_sell_price), + "is_predicted_price": r.is_predicted_price, + } + if slot_inputs is not None: + si = slot_inputs[i] + row["load_baseline_w"] = si[0] + row["pv_a_forecast_raw_w"] = si[1] + row["pv_b_forecast_raw_w"] = si[2] + row["pv_a_forecast_solver_w"] = si[3] + row["pv_b_forecast_solver_w"] = si[4] + intervals.append(row) - # Bulk insert výsledků - if slot_inputs is not None: - rows_pi = [ - ( - run_id, - r.interval_start, - r.battery_setpoint_w, - r.battery_soc_target, - r.grid_setpoint_w, - r.ev1_setpoint_w, - r.ev2_setpoint_w, - r.ev1_via_bat_w, - r.ev2_via_bat_w, - r.heat_pump_enabled, - r.heat_pump_setpoint_w, - r.pv_a_curtailed_w, - r.expected_cost_czk, - r.effective_buy_price, - r.effective_sell_price, - r.is_predicted_price, - si[0], - si[1], - si[2], - si[3], - si[4], + return int( + await db.fetchval( + """ + select ems.fn_planning_run_commit( + $1::int, $2::timestamptz, $3::timestamptz, + $4::jsonb, $5::jsonb ) - for r, si in zip(results, slot_inputs) - ] - await db.executemany( - """ - INSERT INTO ems.planning_interval - (run_id, interval_start, - battery_setpoint_w, battery_soc_target_pct, - grid_setpoint_w, - ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, - heat_pump_enabled, heat_pump_setpoint_w, - pv_a_curtailed_w, expected_cost_czk, - effective_buy_price, effective_sell_price, - is_predicted_price, - load_baseline_w, - pv_a_forecast_raw_w, pv_b_forecast_raw_w, - pv_a_forecast_solver_w, pv_b_forecast_solver_w) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, - $17,$18,$19,$20,$21) """, - rows_pi, + site_id, + horizon_from, + horizon_to, + json.dumps(run_meta, default=str), + json.dumps(intervals, default=str), ) - else: - await db.executemany( - """ - INSERT INTO ems.planning_interval - (run_id, interval_start, - battery_setpoint_w, battery_soc_target_pct, - grid_setpoint_w, - ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, - heat_pump_enabled, heat_pump_setpoint_w, - pv_a_curtailed_w, expected_cost_czk, - effective_buy_price, effective_sell_price, - is_predicted_price) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) - """, - [ - ( - run_id, - r.interval_start, - r.battery_setpoint_w, - r.battery_soc_target, - r.grid_setpoint_w, - r.ev1_setpoint_w, - r.ev2_setpoint_w, - r.ev1_via_bat_w, - r.ev2_via_bat_w, - r.heat_pump_enabled, - r.heat_pump_setpoint_w, - r.pv_a_curtailed_w, - r.expected_cost_czk, - r.effective_buy_price, - r.effective_sell_price, - r.is_predicted_price, - ) - for r in results - ], - ) - - # Aktivovat nový plán, supersede předchozí - await db.execute(""" - UPDATE ems.planning_run SET status = 'superseded' - WHERE site_id = $1 AND status = 'active' AND id <> $2 - """, site_id, run_id) - - await db.execute( - "UPDATE ems.planning_run SET status = 'active' WHERE id = $1", run_id ) - - return run_id diff --git a/backend/services/price_importer.py b/backend/services/price_importer.py index 90e351a..f2de5e2 100644 --- a/backend/services/price_importer.py +++ b/backend/services/price_importer.py @@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo import httpx from app.config import get_settings +from app.db_json import fetch_json logger = logging.getLogger(__name__) @@ -119,18 +120,14 @@ async def _apply_ote_json_to_db(conn, payload: dict) -> int: async def count_ote_slots_prague_day(conn, target_day: date) -> int: """Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100).""" - return int( - await conn.fetchval( - """ - SELECT COUNT(*)::int - FROM ems.market_interval_price - WHERE market_source = 'OTE_CZ' - AND (interval_start AT TIME ZONE 'Europe/Prague')::date = $1::date - """, - target_day, - ) - or 0 + stats = await fetch_json( + conn, + "select ems.fn_ote_day_slot_stats_prague($1::date)", + target_day, ) + if not isinstance(stats, dict): + stats = json.loads(stats) + return int(stats.get("count") or 0) async def import_ote_prices_for_day( @@ -147,18 +144,15 @@ async def import_ote_prices_for_day( return -1, day_str, 0.0, fetch_error or "fetch_failed" try: n = await _apply_ote_json_to_db(conn, payload) - first_price = await conn.fetchval( - """ - SELECT buy_raw_price_czk_kwh - FROM ems.market_interval_price - WHERE market_source = 'OTE_CZ' - AND (interval_start AT TIME ZONE 'Europe/Prague')::date = $1::date - ORDER BY interval_start - LIMIT 1 - """, + stats_after = await fetch_json( + conn, + "select ems.fn_ote_day_slot_stats_prague($1::date)", target_day, ) - n_imported = await count_ote_slots_prague_day(conn, target_day) + if not isinstance(stats_after, dict): + stats_after = json.loads(stats_after) + first_price = stats_after.get("first_price") + n_imported = int(stats_after.get("count") or 0) if not ote_prague_day_slots_look_complete(n_imported): logger.warning( "OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)", @@ -248,7 +242,7 @@ async def import_ote_prices( """ if site_id is not None: row = await db.fetchrow( - "SELECT timezone FROM ems.site WHERE id = $1", site_id + "select timezone from ems.vw_site_directory where id = $1", site_id ) if row is None: logger.error("OTE import: site id=%s nenalezen", site_id) @@ -290,26 +284,15 @@ async def import_ote_prices( try: n = await _apply_ote_json_to_db(db, payload) - first_price = await db.fetchval( - """ - SELECT buy_raw_price_czk_kwh - FROM ems.market_interval_price - WHERE market_source = 'OTE_CZ' - AND interval_start::date = $1::date - ORDER BY interval_start - LIMIT 1 - """, - target_day, - ) - n_imported = await db.fetchval( - """ - SELECT COUNT(*)::int - FROM ems.market_interval_price - WHERE market_source = 'OTE_CZ' - AND interval_start::date = $1::date - """, + stats_after = await fetch_json( + db, + "select ems.fn_ote_day_slot_stats_prague($1::date)", target_day, ) + if not isinstance(stats_after, dict): + stats_after = json.loads(stats_after) + first_price = stats_after.get("first_price") + n_imported = int(stats_after.get("count") or 0) incomplete = not ote_prague_day_slots_look_complete(n_imported or 0) if incomplete: now_p = datetime.now(ZoneInfo("Europe/Prague")) diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index c6bc9fe..3c9139f 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -41,13 +41,9 @@ def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int: async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: rows = await db.fetch( """ - SELECT ai.id, ai.code, se.host, se.port, se.unit_id - FROM ems.asset_inverter ai - JOIN ems.site_endpoint se ON se.id = ai.endpoint_id - WHERE ai.site_id = $1 - AND ai.active = true - AND se.enabled = true - AND se.endpoint_type = 'modbus_tcp' + select inverter_id as id, code, host, port, unit_id + from ems.vw_asset_inverter_modbus_poll + where site_id = $1 """, site_id, ) @@ -67,7 +63,7 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY) batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY) grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER) - load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER) + load_power = await mb.read_register_signed(DEYE_REG_LOAD_TOTAL_POWER) pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER) pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER) gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER) @@ -81,27 +77,7 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: logger.debug("inverter:%s Deye run_state raw=%s", code, run_state) await db.execute( - """ - INSERT INTO ems.telemetry_inverter ( - site_id, inverter_id, measured_at, - pv_power_w, pv1_power_w, pv2_power_w, gen_port_power_w, - battery_soc_percent, battery_power_w, - batt_charge_today_wh, batt_discharge_today_wh, - grid_power_w, load_power_w, - grid_import_total_wh, grid_export_total_wh, - run_state - ) - VALUES ( - $1, $2, $3, - $4, $5, $6, $7, - $8, $9, - $10, $11, - $12, $13, - $14, $15, - $16 - ) - ON CONFLICT (inverter_id, measured_at) DO NOTHING - """, + "select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int)", site_id, inv_id, measured_at, @@ -141,12 +117,9 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None: rows = await db.fetch( """ - SELECT ec.id, ec.code, se.host, se.port, se.unit_id - FROM ems.asset_ev_charger ec - JOIN ems.site_endpoint se ON se.id = ec.endpoint_id - WHERE ec.site_id = $1 - AND se.enabled = true - AND se.endpoint_type = 'modbus_tcp' + select charger_id as id, code, host, port, unit_id + from ems.vw_asset_ev_charger_modbus_poll + where site_id = $1 """, site_id, ) @@ -156,117 +129,52 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None: code = row["code"] charger_id = row["id"] logger.info("TODO: EV charger Modbus registry pending | %s", code) - # Placeholder až do mapování Modbus: status zůstává available (bez falešných přechodů). current_status = "available" previous_status = await db.fetchval( """ - SELECT status - FROM ems.telemetry_ev_charger - WHERE charger_id = $1 AND connector_id = $2 - ORDER BY measured_at DESC - LIMIT 1 + select status + from ems.telemetry_ev_charger + where charger_id = $1 and connector_id = $2 + order by measured_at desc + limit 1 """, charger_id, connector_id, ) await db.execute( - """ - INSERT INTO ems.telemetry_ev_charger ( - site_id, charger_id, measured_at, connector_id, - status, power_w, energy_kwh - ) - VALUES ($1, $2, $3, $4, $5, 0, 0) - ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING - """, + "select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8)", site_id, charger_id, measured_at, connector_id, current_status, + 0, + 0.0, ) if previous_status is not None: + await db.fetchval( + "select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)", + site_id, + charger_id, + str(previous_status), + current_status, + measured_at, + ) if previous_status == "available" and current_status != "available": - vehicle_id = await db.fetchval( - """ - SELECT av.id - FROM ems.asset_vehicle av - WHERE av.site_id = $1 - AND av.default_charger_id = $2 - AND av.active = true - ORDER BY av.id - LIMIT 1 - """, - site_id, - charger_id, - ) - await db.execute( - "SELECT ems.fn_update_ev_arrival_stats($1, $2, $3, $4)", - site_id, - charger_id, - vehicle_id, - measured_at, - ) logger.info("EV arrival detected on charger %s", code) - await db.execute( - """ - INSERT INTO ems.ev_session ( - site_id, charger_id, vehicle_id, session_start, - target_soc_pct, target_deadline - ) - SELECT - ac.site_id, - ac.id, - av.id, - now(), - av.default_target_soc_pct, - CASE - WHEN av.default_deadline_hour IS NOT NULL THEN - ( - (timezone('Europe/Prague', now()))::date + interval '1 day' - + make_interval(hours => av.default_deadline_hour) - )::timestamp AT TIME ZONE 'Europe/Prague' - END - FROM ems.asset_ev_charger ac - LEFT JOIN LATERAL ( - SELECT v.id, v.default_target_soc_pct, v.default_deadline_hour - FROM ems.asset_vehicle v - WHERE v.default_charger_id = ac.id - AND v.site_id = ac.site_id - AND v.active = true - ORDER BY v.id - LIMIT 1 - ) av ON true - WHERE ac.id = $1 AND ac.site_id = $2 - ON CONFLICT (charger_id) WHERE session_end IS NULL DO NOTHING - """, - charger_id, - site_id, - ) - - if previous_status != "available" and current_status == "available": - await db.execute( - """ - UPDATE ems.ev_session - SET session_end = now() - WHERE charger_id = $1 AND session_end IS NULL - """, - charger_id, - ) + elif previous_status != "available" and current_status == "available": logger.info("EV departure detected on charger %s", code) async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None: rows = await db.fetch( """ - SELECT hp.id, hp.code, se.host, se.port, se.unit_id - FROM ems.asset_heat_pump hp - JOIN ems.site_endpoint se ON se.id = hp.endpoint_id - WHERE hp.site_id = $1 - AND se.enabled = true - AND se.endpoint_type = 'modbus_tcp' + select heat_pump_id as id, code, host, port, unit_id + from ems.vw_asset_heat_pump_modbus_poll + where site_id = $1 """, site_id, ) @@ -275,18 +183,15 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None: code = row["code"] logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code) await db.execute( - """ - INSERT INTO ems.telemetry_heat_pump ( - site_id, heat_pump_id, measured_at, - power_w, outdoor_temp_c, water_outlet_temp_c, tuv_tank_temp_c, - operating_mode - ) - VALUES ($1, $2, $3, 0, 10.0, 45.0, 55.0, 'standby') - ON CONFLICT (heat_pump_id, measured_at) DO NOTHING - """, + "select ems.fn_telemetry_heat_pump_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8, $7::float8, $8::text)", site_id, row["id"], measured_at, + 0, + 10.0, + 45.0, + 55.0, + "standby", ) @@ -297,7 +202,9 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float: """ loop = asyncio.get_running_loop() start = loop.time() - sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + sites = await conn.fetch( + "select id from ems.vw_site_directory where active = true" + ) for site in sites: sid = site["id"] try: diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index 4134f1c..2d7c11b 100644 --- a/backend/tests/test_control_exporter_tou.py +++ b/backend/tests/test_control_exporter_tou.py @@ -5,7 +5,7 @@ from __future__ import annotations import unittest from dataclasses import replace -from services.control_exporter import ( +from services.control.exporter_monolith import ( ControlSetpoints, InverterConfig, _deye_reg178_verify_with_double_read, diff --git a/backend/tests/test_db_json_fetch_json.py b/backend/tests/test_db_json_fetch_json.py new file mode 100644 index 0000000..79c255e --- /dev/null +++ b/backend/tests/test_db_json_fetch_json.py @@ -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()) diff --git a/backend/tests/test_deye_clock.py b/backend/tests/test_deye_clock.py index d5fe7e0..8fcf164 100644 --- a/backend/tests/test_deye_clock.py +++ b/backend/tests/test_deye_clock.py @@ -6,7 +6,7 @@ import unittest from datetime import datetime, timedelta, timezone from types import SimpleNamespace -from services.control_exporter import ( +from services.control.exporter_monolith import ( DEYE_CLOCK_DRIFT_OK_SEC, DEYE_CLOCK_RESYNC_INTERVAL_HOURS, DEYE_CLOCK_VERIFY_MAX_DELTA_SEC, diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 442629f..b7fae2f 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -1,10 +1,7 @@ -"""`_select_charge_slots`: pre-selection nabíjecích slotů (anti-micro-cycling). +"""Pre-selection nabíjecích slotů (anti-micro-cycling) – referenční Python. -Ověřuje novou logiku podle varianty B: - - PV-surplus sloty jsou vždy zahrnuty. - - Zbytek rozpočtu doplnit nejlevnějšími sloty podle `buy_price` (ne `sell_price`). - - Žádné sloty nesmí být vyloučeny kvůli tomu, že nemají PV-surplus, když - `charge_slot_buffer` > 0 a ještě chybí energie do `soc_max`. +Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu +pro rychlé unit testy bez PostgreSQL. """ from __future__ import annotations @@ -13,7 +10,50 @@ import unittest from datetime import datetime, timezone from types import SimpleNamespace -from services.planning_engine import INTERVAL_H, PlanningSlot, _select_charge_slots +from services.planning_engine import INTERVAL_H, PlanningSlot + + +def _select_charge_slots( + slots: list[PlanningSlot], + battery: SimpleNamespace, + current_soc_wh: float, +) -> set[int]: + """Kopie logiky z ems.fn_load_planning_slots_full (charge mask).""" + charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0) + if charge_buf <= 0: + return set(range(len(slots))) + + energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh) + if energy_to_fill <= 0: + return set() + + eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0) + max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0) + per_slot_full_wh = max_p_w * eta * INTERVAL_H + + selected: set[int] = set() + for t, s in enumerate(slots): + pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w) + if pv_surplus_w > 0: + selected.add(t) + + grid_target_wh = energy_to_fill * charge_buf + if grid_target_wh <= 0 or per_slot_full_wh <= 0: + return selected + + grid_candidates = [ + (t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected + ] + grid_candidates.sort(key=lambda x: x[1]) + + cumulative = 0.0 + for t, _price in grid_candidates: + if cumulative >= grid_target_wh: + break + selected.add(t) + cumulative += per_slot_full_wh + + return selected def _slot(*, buy: float, sell: float = 1.0, pv: int = 0, load: int = 2_000) -> PlanningSlot: diff --git a/db/migration/V049__planning_config.sql b/db/migration/V049__planning_config.sql new file mode 100644 index 0000000..6358505 --- /dev/null +++ b/db/migration/V049__planning_config.sql @@ -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, …).'; diff --git a/db/routines/R__fn_battery_cycle_audit.sql b/db/routines/R__fn_battery_cycle_audit.sql new file mode 100644 index 0000000..d763ce2 --- /dev/null +++ b/db/routines/R__fn_battery_cycle_audit.sql @@ -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$; diff --git a/db/routines/R__fn_deye_clock_drift_sec.sql b/db/routines/R__fn_deye_clock_drift_sec.sql new file mode 100644 index 0000000..55dace8 --- /dev/null +++ b/db/routines/R__fn_deye_clock_drift_sec.sql @@ -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).'; diff --git a/db/routines/R__fn_deye_pack_system_time.sql b/db/routines/R__fn_deye_pack_system_time.sql new file mode 100644 index 0000000..3d738b8 --- /dev/null +++ b/db/routines/R__fn_deye_pack_system_time.sql @@ -0,0 +1,17 @@ +-- pack reg 62–64 (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$; diff --git a/db/routines/R__fn_deye_time_point_regs.sql b/db/routines/R__fn_deye_time_point_regs.sql new file mode 100644 index 0000000..0debd62 --- /dev/null +++ b/db/routines/R__fn_deye_time_point_regs.sql @@ -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).'; diff --git a/db/routines/R__fn_deye_tou_inactive_signature.sql b/db/routines/R__fn_deye_tou_inactive_signature.sql new file mode 100644 index 0000000..3730d6a --- /dev/null +++ b/db/routines/R__fn_deye_tou_inactive_signature.sql @@ -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).'; diff --git a/db/routines/R__fn_economics_daily_month.sql b/db/routines/R__fn_economics_daily_month.sql new file mode 100644 index 0000000..951966b --- /dev/null +++ b/db/routines/R__fn_economics_daily_month.sql @@ -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).'; diff --git a/db/routines/R__fn_economics_lock_day.sql b/db/routines/R__fn_economics_lock_day.sql new file mode 100644 index 0000000..b89a8e6 --- /dev/null +++ b/db/routines/R__fn_economics_lock_day.sql @@ -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).'; diff --git a/db/routines/R__fn_economics_monthly_chart.sql b/db/routines/R__fn_economics_monthly_chart.sql new file mode 100644 index 0000000..a28aa7f --- /dev/null +++ b/db/routines/R__fn_economics_monthly_chart.sql @@ -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).'; diff --git a/db/routines/R__fn_economics_unlock_day.sql b/db/routines/R__fn_economics_unlock_day.sql new file mode 100644 index 0000000..11b6653 --- /dev/null +++ b/db/routines/R__fn_economics_unlock_day.sql @@ -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).'; diff --git a/db/routines/R__fn_energy_flows_daily_month.sql b/db/routines/R__fn_energy_flows_daily_month.sql new file mode 100644 index 0000000..ddb0ba9 --- /dev/null +++ b/db/routines/R__fn_energy_flows_daily_month.sql @@ -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ů.'; diff --git a/db/routines/R__fn_energy_flows_intervals_day.sql b/db/routines/R__fn_energy_flows_intervals_day.sql new file mode 100644 index 0000000..b050d78 --- /dev/null +++ b/db/routines/R__fn_energy_flows_intervals_day.sql @@ -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.'; diff --git a/db/routines/R__fn_ev_arrival_prediction_bundle.sql b/db/routines/R__fn_ev_arrival_prediction_bundle.sql new file mode 100644 index 0000000..43ae57b --- /dev/null +++ b/db/routines/R__fn_ev_arrival_prediction_bundle.sql @@ -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).'; diff --git a/db/routines/R__fn_ev_session_patch.sql b/db/routines/R__fn_ev_session_patch.sql new file mode 100644 index 0000000..329a95b --- /dev/null +++ b/db/routines/R__fn_ev_session_patch.sql @@ -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).'; diff --git a/db/routines/R__fn_ev_session_transition.sql b/db/routines/R__fn_ev_session_transition.sql new file mode 100644 index 0000000..d21f3d9 --- /dev/null +++ b/db/routines/R__fn_ev_session_transition.sql @@ -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).'; diff --git a/db/routines/R__fn_ev_sessions_active.sql b/db/routines/R__fn_ev_sessions_active.sql new file mode 100644 index 0000000..74a84f3 --- /dev/null +++ b/db/routines/R__fn_ev_sessions_active.sql @@ -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).'; diff --git a/db/routines/R__fn_fill_audit_for_site_window.sql b/db/routines/R__fn_fill_audit_for_site_window.sql new file mode 100644 index 0000000..eb32c38 --- /dev/null +++ b/db/routines/R__fn_fill_audit_for_site_window.sql @@ -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$; diff --git a/db/routines/R__fn_forecast_pv_split.sql b/db/routines/R__fn_forecast_pv_split.sql new file mode 100644 index 0000000..d85165d --- /dev/null +++ b/db/routines/R__fn_forecast_pv_split.sql @@ -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.'; diff --git a/db/routines/R__fn_inverter_modbus_caps_patch.sql b/db/routines/R__fn_inverter_modbus_caps_patch.sql new file mode 100644 index 0000000..3f6a6a7 --- /dev/null +++ b/db/routines/R__fn_inverter_modbus_caps_patch.sql @@ -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.'; diff --git a/db/routines/R__fn_latest_ote_day_stats.sql b/db/routines/R__fn_latest_ote_day_stats.sql new file mode 100644 index 0000000..940e6b0 --- /dev/null +++ b/db/routines/R__fn_latest_ote_day_stats.sql @@ -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).'; diff --git a/db/routines/R__fn_load_planning_slots_full.sql b/db/routines/R__fn_load_planning_slots_full.sql new file mode 100644 index 0000000..e899f6e --- /dev/null +++ b/db/routines/R__fn_load_planning_slots_full.sql @@ -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).'; diff --git a/db/routines/R__fn_modbus_commands_by_ids.sql b/db/routines/R__fn_modbus_commands_by_ids.sql new file mode 100644 index 0000000..5b8e7ce --- /dev/null +++ b/db/routines/R__fn_modbus_commands_by_ids.sql @@ -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).'; diff --git a/db/routines/R__fn_modbus_journal_list.sql b/db/routines/R__fn_modbus_journal_list.sql new file mode 100644 index 0000000..aab1c67 --- /dev/null +++ b/db/routines/R__fn_modbus_journal_list.sql @@ -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).'; diff --git a/db/routines/R__fn_modbus_last_verified_map.sql b/db/routines/R__fn_modbus_last_verified_map.sql new file mode 100644 index 0000000..3546fc4 --- /dev/null +++ b/db/routines/R__fn_modbus_last_verified_map.sql @@ -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$; diff --git a/db/routines/R__fn_modbus_written_command_ids.sql b/db/routines/R__fn_modbus_written_command_ids.sql new file mode 100644 index 0000000..21eed49 --- /dev/null +++ b/db/routines/R__fn_modbus_written_command_ids.sql @@ -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).'; diff --git a/db/routines/R__fn_negative_price_predictions.sql b/db/routines/R__fn_negative_price_predictions.sql new file mode 100644 index 0000000..f17bfdc --- /dev/null +++ b/db/routines/R__fn_negative_price_predictions.sql @@ -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.'; diff --git a/db/routines/R__fn_ote_day_slot_stats_prague.sql b/db/routines/R__fn_ote_day_slot_stats_prague.sql new file mode 100644 index 0000000..b169950 --- /dev/null +++ b/db/routines/R__fn_ote_day_slot_stats_prague.sql @@ -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.'; diff --git a/db/routines/R__fn_ote_list_missing_days.sql b/db/routines/R__fn_ote_list_missing_days.sql new file mode 100644 index 0000000..c018263 --- /dev/null +++ b/db/routines/R__fn_ote_list_missing_days.sql @@ -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).'; diff --git a/db/routines/R__fn_plan_current_bundle.sql b/db/routines/R__fn_plan_current_bundle.sql new file mode 100644 index 0000000..38ccb1f --- /dev/null +++ b/db/routines/R__fn_plan_current_bundle.sql @@ -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).'; diff --git a/db/routines/R__fn_planning_active_run.sql b/db/routines/R__fn_planning_active_run.sql new file mode 100644 index 0000000..1d84126 --- /dev/null +++ b/db/routines/R__fn_planning_active_run.sql @@ -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$; diff --git a/db/routines/R__fn_planning_future_price_days.sql b/db/routines/R__fn_planning_future_price_days.sql new file mode 100644 index 0000000..3f55cc6 --- /dev/null +++ b/db/routines/R__fn_planning_future_price_days.sql @@ -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).'; diff --git a/db/routines/R__fn_planning_interval_at_offset.sql b/db/routines/R__fn_planning_interval_at_offset.sql new file mode 100644 index 0000000..0820b55 --- /dev/null +++ b/db/routines/R__fn_planning_interval_at_offset.sql @@ -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$; diff --git a/db/routines/R__fn_planning_run_commit.sql b/db/routines/R__fn_planning_run_commit.sql new file mode 100644 index 0000000..4c96005 --- /dev/null +++ b/db/routines/R__fn_planning_run_commit.sql @@ -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$; diff --git a/db/routines/R__fn_planning_run_horizon.sql b/db/routines/R__fn_planning_run_horizon.sql new file mode 100644 index 0000000..564f6c9 --- /dev/null +++ b/db/routines/R__fn_planning_run_horizon.sql @@ -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).'; diff --git a/db/routines/R__fn_planning_site_context.sql b/db/routines/R__fn_planning_site_context.sql new file mode 100644 index 0000000..996e7af --- /dev/null +++ b/db/routines/R__fn_planning_site_context.sql @@ -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).'; diff --git a/db/routines/R__fn_planning_slot_boundary_prague.sql b/db/routines/R__fn_planning_slot_boundary_prague.sql new file mode 100644 index 0000000..a68dfc9 --- /dev/null +++ b/db/routines/R__fn_planning_slot_boundary_prague.sql @@ -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.'; diff --git a/db/routines/R__fn_pv_forecast_correction_factor.sql b/db/routines/R__fn_pv_forecast_correction_factor.sql new file mode 100644 index 0000000..761ed36 --- /dev/null +++ b/db/routines/R__fn_pv_forecast_correction_factor.sql @@ -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$; diff --git a/db/routines/R__fn_set_mode_with_context.sql b/db/routines/R__fn_set_mode_with_context.sql new file mode 100644 index 0000000..dec5931 --- /dev/null +++ b/db/routines/R__fn_set_mode_with_context.sql @@ -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$; diff --git a/db/routines/R__fn_site_configuration.sql b/db/routines/R__fn_site_configuration.sql new file mode 100644 index 0000000..987c527 --- /dev/null +++ b/db/routines/R__fn_site_configuration.sql @@ -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).'; diff --git a/db/routines/R__fn_site_effective_prices_day_prague.sql b/db/routines/R__fn_site_effective_prices_day_prague.sql new file mode 100644 index 0000000..bbb52e0 --- /dev/null +++ b/db/routines/R__fn_site_effective_prices_day_prague.sql @@ -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.'; diff --git a/db/routines/R__fn_site_full_status.sql b/db/routines/R__fn_site_full_status.sql new file mode 100644 index 0000000..9a98588 --- /dev/null +++ b/db/routines/R__fn_site_full_status.sql @@ -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).'; diff --git a/db/routines/R__fn_site_notifications_context.sql b/db/routines/R__fn_site_notifications_context.sql new file mode 100644 index 0000000..dd1c245 --- /dev/null +++ b/db/routines/R__fn_site_notifications_context.sql @@ -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).'; diff --git a/db/routines/R__fn_telemetry_ev_charger_sample.sql b/db/routines/R__fn_telemetry_ev_charger_sample.sql new file mode 100644 index 0000000..143dbe6 --- /dev/null +++ b/db/routines/R__fn_telemetry_ev_charger_sample.sql @@ -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).'; diff --git a/db/routines/R__fn_telemetry_heat_pump_sample.sql b/db/routines/R__fn_telemetry_heat_pump_sample.sql new file mode 100644 index 0000000..e5035be --- /dev/null +++ b/db/routines/R__fn_telemetry_heat_pump_sample.sql @@ -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).'; diff --git a/db/routines/R__fn_telemetry_inverter_sample.sql b/db/routines/R__fn_telemetry_inverter_sample.sql new file mode 100644 index 0000000..4cfb8df --- /dev/null +++ b/db/routines/R__fn_telemetry_inverter_sample.sql @@ -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).'; diff --git a/db/views/R__vw_asset_ev_charger_modbus_poll.sql b/db/views/R__vw_asset_ev_charger_modbus_poll.sql new file mode 100644 index 0000000..6c82943 --- /dev/null +++ b/db/views/R__vw_asset_ev_charger_modbus_poll.sql @@ -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.'; diff --git a/db/views/R__vw_asset_heat_pump_modbus_poll.sql b/db/views/R__vw_asset_heat_pump_modbus_poll.sql new file mode 100644 index 0000000..bde30be --- /dev/null +++ b/db/views/R__vw_asset_heat_pump_modbus_poll.sql @@ -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.'; diff --git a/db/views/R__vw_asset_inverter_modbus_poll.sql b/db/views/R__vw_asset_inverter_modbus_poll.sql new file mode 100644 index 0000000..b1d95de --- /dev/null +++ b/db/views/R__vw_asset_inverter_modbus_poll.sql @@ -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.'; diff --git a/db/views/R__vw_battery_cycle_daily.sql b/db/views/R__vw_battery_cycle_daily.sql new file mode 100644 index 0000000..3628d6f --- /dev/null +++ b/db/views/R__vw_battery_cycle_daily.sql @@ -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; diff --git a/db/views/R__vw_modbus_last_verified.sql b/db/views/R__vw_modbus_last_verified.sql new file mode 100644 index 0000000..0261980 --- /dev/null +++ b/db/views/R__vw_modbus_last_verified.sql @@ -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ů.'; diff --git a/db/views/R__vw_site_directory.sql b/db/views/R__vw_site_directory.sql new file mode 100644 index 0000000..eb1edb4 --- /dev/null +++ b/db/views/R__vw_site_directory.sql @@ -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.'; diff --git a/docs/02-architecture.md b/docs/02-architecture.md index b7519f6..10e57f0 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -46,6 +46,10 @@ --- +## Read-model JSONB (`ems.fn_*`) + +FastAPI endpointy pro dashboard a konfiguraci preferují **jedno volání** `select ems.fn_*(…)` vracející **jsonb** (pole řádků, agregace, merge locků), aby v Pythonu nezůstávaly ad-hoc `SELECT`/`JOIN`/`WITH`. Pomocník `app.db_json.fetch_json` vrací `dict`/`list`. Telemetrie a IO zůstávají v Pythonu; čisté agregace a sjednocení TZ patří do SQL. Opakované migrace: `db/routines/R__fn_*.sql`, `db/views/R__vw_*.sql`. + ## Komponenty | Komponenta | Technologie | Port | Popis | @@ -77,7 +81,7 @@ ems-platform/ R__fn_cop_estimate.sql R__fn_baseline_consumption.sql R__fn_fill_audit_interval.sql - R__fn_plan_day.sql + (historicky) R__fn_plan_day.sql – primární plánování je PuLP v Pythonu R__fn_create_planning_run.sql views/ R__vw_site_effective_price.sql diff --git a/docs/refactor-weaknesses.md b/docs/refactor-weaknesses.md new file mode 100644 index 0000000..7100462 --- /dev/null +++ b/docs/refactor-weaknesses.md @@ -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.65–1.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_*`)