diff --git a/.env.example b/.env.example index 281c9f5..9929362 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,9 @@ POSTGREST_JWT_SECRET=change_me_jwt_secret_min_32_chars POSTGREST_ANON_ROLE=ems_anon # ---- OTE CZ import ---- -OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam -EUR_CZK_RATE=25.0 # fallback kurz pokud ČNB API nedostupné +# Veřejný chart endpoint; kód doplňuje ?report_date=YYYY-MM-DD&time_resolution=PT15M +OTE_API_URL=https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data +EUR_CZK_RATE=25.0 # přepočet EUR/MWh → CZK/kWh (EUR/MWh * rate / 1000) # ---- Weather / Forecast ---- OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast @@ -24,6 +25,9 @@ OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast LOXONE_USER=admin LOXONE_PASSWORD=change_me +# ---- Alerty ---- +DISCORD_WEBHOOK_URL= # Discord webhook URL pro alerty, prázdné = vypnuto + # ---- Telemetrie ---- TELEMETRY_POLL_INTERVAL_SEC=60 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..8bf4d45 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,6 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/ems-cursor.iml b/.idea/ems-cursor.iml new file mode 100644 index 0000000..0399c4b --- /dev/null +++ b/.idea/ems-cursor.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a7e2f34 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 76ec8bf..8764e56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá 4. **Loxone = exekutor + autonomní fallback, ne optimalizátor.** Logika a plán v EMS. Watchdog v Loxone nesmí záviset na čtení DB (`site_heartbeat` je jen pro EMS UI/diagnostiku). -5. **FVE pole B (`controllable = false`, typicky ongrid GEN) – žádný curtailment.** Curtailment jen pole A (Deye). Solver smí omezovat jen `pv_a`; pole B má zelený bonus (`site_market_config.green_bonus_*`, audit `pv_b_production_wh` / `green_bonus_czk`). +5. **FVE pole B (`controllable = false`, typicky ongrid GEN) – žádný curtailment.** Curtailment jen pole A (Deye). Solver smí omezovat jen `pv_a`; pole B může mít zelený bonus na `asset_pv_array` (`green_bonus_*`), audit `pv_b_production_wh` / `green_bonus_czk`. 6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint). @@ -58,9 +58,32 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá 8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků – dle docs). -9. **Deye Modbus: čtení i zápis** (setpointy). RS485→Waveshare→TCP, knihovna `pymodbus`. +9. **Zelený bonus je na `asset_pv_array`** (sloupce `green_bonus_*`), **nikdy** v `site_market_config`. Výpočet přes `fn_green_bonus_revenue()`. Bonus se nepočítá v solveru – pouze v audit_filler (`fn_fill_audit_interval`). -10. **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`. +10. **Deye Modbus: čtení i zápis** (setpointy). RS485→Waveshare→TCP, knihovna `pymodbus`. + +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`. + +### Provozní režimy (operating_mode) + +- Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**. +- Režim se načítá v `planning_engine._load_site_context()`; **dodatečné LP constraints** podle režimu jsou v **`solve_dispatch()`** (žádný export / limit importu / zákaz nabíjení nebo vybíjení baterie podle módu). +- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `control_exporter.get_deye_mode` a zapisují v `write_inverter_setpoints`. +- **`lock_battery=True`** u `ControlSetpoints` (PRESERVE): registry **108/109 = 0** – Deye baterii nepoužívá. Výjimka oproti obecnému pravidlu max A ve PASSIVE/SELL. + +12. **`forecast_pv_run` a `forecast_pv_interval` se NESMÍ mazat** – historické běhy zůstávají v DB pro tracking přesnosti (`forecast_accuracy`, `fn_fill_forecast_accuracy`). + +13. **Endpoint `GET …/forecast/pv`** vrací `DISTINCT ON (interval_start, pv_array_id)` seřazené podle nejnovějšího `forecast_pv_run.created_at`, aby UI nemělo duplikáty slotů; plná historie běhů zůstává v tabulkách. + +14. **Příchod a odjezd EV** detekuje `telemetry_collector` z telemetrie nabíječky: přechod `available` → `preparing` / `charging` (resp. jakýkoli stav ≠ `available`) znamená příjezd; přechod na `available` uzavře `ev_session`. Tabulka `ev_arrival_stats` se při příjezdu doplňuje přes `fn_update_ev_arrival_stats` a **nemá se mazat** (dlouhodobá historická statistika). + +15. **Bazální spotřeba** = `load_power_w` minus řízené zátěže (součet EV z `telemetry_ev_charger`, TČ z `telemetry_heat_pump`). Tabulka `consumption_baseline_stats` se plní denně (APScheduler 00:30) přes `fn_update_baseline_stats`. **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`. + +16. **Rozšířený horizont plánování (96h):** denní plán pokrývá **96h** od začátku aktuálního 15min slotu. Sloty v prvních **36h** používají přesné efektivní ceny z `vw_site_effective_price` (OTE). Sloty **36–96h** doplňuje **predikovaná cena** z `market_price_stats` přes `fn_get_predicted_price` (prodejní strana hrubý faktor 0,85 vs. nákupní predikce). V účelové funkci LP se uplatní **váhy nejistoty** podle vzdálenosti od začátku okna: **1,0** (0–36h), **0,7** (36–72h), **0,4** (72–96h). Statistiky cen plní `fn_update_market_price_stats` (job 14:45), TUV delta `fn_update_tuv_usage_stats` (job 00:45). Detail: `docs/04-modules/planning-extended-horizon.md`. + +17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → přepnutí na **SELF_SUSTAIN** (`fn_set_mode`, `system:mismatch`) + **Discord** alert, pokud je `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`. + +18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`, 3–6 na **23:59**); **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas:** **62–64** při každém `control_export` (Europe/Prague). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`. --- @@ -70,7 +93,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá |---------|--------| | `site` | Lokalita (časová zóna, GPS, aktivita). | | `site_endpoint` | Endpointy: Modbus, Loxone HTTP, atd. | -| `site_market_config` | Marže, režimy cenění, zelený bonus; časová platnost. | +| `site_market_config` | Marže, režimy cenění; časová platnost (zelený bonus není zde – viz `asset_pv_array`). | | `site_grid_connection` | Limity import/export, no_export, rezervovaný výkon. | | `site_override` | Manuální přepisy nad plánem (JSON + platnost). | | `site_operating_mode` | Aktuální provozní režim na site (1 řádek/site). | @@ -79,7 +102,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá | `operating_mode_def` | Číselník režimů (baterie/síť/EV/TČ, hodnota pro Loxone). | | `asset_inverter` | Střídač (výkony, endpoint, zda řiditelný). | | `asset_battery` | Baterie vázaná na střídač (SoC limity, účinnosti, degradace). | -| `asset_pv_array` | FVE pole (Wp, orientace, curtailable vs ne). | +| `asset_pv_array` | FVE pole (Wp, orientace, curtailable vs ne; volitelně `green_bonus_*` pro dotované pole). | | `asset_ev_charger` | Nabíječka EV (výkony, fáze, endpoint). | | `asset_heat_pump` | TČ (výkon, COP ref, limity běhu, TUV parametry). | | `asset_vehicle` | Vozidlo (kapacita, max AC výkon, default target SoC/deadline). | @@ -89,15 +112,22 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá | `telemetry_heat_pump` | 1min telemetrie TČ (Timescale). | | `forecast_pv_run` | Metadata běhu predikce FVE. | | `forecast_pv_interval` | Predikovaný výkon FVE po 15min (Timescale). | +| `forecast_accuracy` | Řádky přesnosti predikce vs telemetrie po 15min (per run); doplňuje `fn_fill_forecast_accuracy`. | | `forecast_weather_interval` | Počasí 15min pro site (Timescale). | | `forecast_correction_log` | Log korekcí forecastu vs skutečnost při rolling replanu. | | `planning_run` | Jeden běh plánovače (daily/rolling/manual, stav, parametry solveru). | | `planning_interval` | Výstup solveru po 15min (baterie, síť, EV, TČ, curtailment A). | | `audit_interval` | Skutečnost vs plán po 15min (náklady, odchylky, bonus pole B). | | `consumption_baseline_interval` | Bazální spotřeba actual/forecast 15min (Timescale). | +| `consumption_baseline_stats` | Historické průměry bazálu per DOW+hodina (EMA z telemetrie); vstup solveru. | +| `market_price_stats` | Historické průměry raw OTE ceny per DOW+hodina; predikce cen za horizont OTE (`fn_get_predicted_price`). | +| `tuv_usage_stats` | Průměrná změna teploty TUV zásobníku per DOW+hodina (telemetrie TČ); vstup TUV look-ahead ve solveru. | | `ev_session` | Nabíjecí session na WB (deadline, energie, náklady). | +| `ev_arrival_stats` | Agregované počty příjezdů EV podle dne v týdnu a hodiny (Europe/Prague); plní se z detekce příjezdu v telemetrii. | +| `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_audit_summary`, `vw_operating_mode`; `fn_effective_price`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_set_mode`. +**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `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_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`. --- @@ -110,10 +140,15 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan | `telemetry_collector` | každých **60 s** | Smyčka polling Modbus (Deye, EV×2, TČ) – viz `docs/04-modules/telemetry.md` | | `price_importer` | **14:00** denně + **00:05** kontrola | `docs/04-modules/market-prices.md` (časy CET v dokumentaci) | | `forecast_service` | **14:30** + **06:00** denně | `docs/04-modules/forecast.md` | -| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` (horizont 36 h) | +| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` (horizont **96 h**, váhy slotů 1,0 / 0,7 / 0,4) | | `run_rolling_replan` | **každých 15 min** (`*/15`) | `planning_engine.py` – přepočet od aktuálního slotu | | `control_exporter` | **každých 15 min** (slot boundary) | `docs/04-modules/control.md` | +| `verify_modbus` | **každé 2 min** | Ověření `modbus_command` ve stavu `written` (posledních 10 min); viz `docs/04-modules/modbus-command-journal.md` | | `audit_filler` / `fn_fill_audit_interval` | **každých 15 min** | `docs/02-architecture.md`, DB `fn_fill_audit_interval` | +| `forecast_accuracy` / `fn_fill_forecast_accuracy` | **každých 15 min** (min. 2,17,32,47) | Po audit filleru; doplní actual z telemetrie do `forecast_accuracy` | +| `fn_update_baseline_stats` | **00:30** denně | Aktualizace `consumption_baseline_stats` z telemetrie (30d lookback) | +| `fn_update_market_price_stats` | **14:45** denně | Po importu OTE a forecastu; `market_price_stats` (90d lookback) | +| `fn_update_tuv_usage_stats` | **00:45** denně | Po baseline jobu; `tuv_usage_stats` (30d lookback) | --- @@ -128,8 +163,10 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan | Bazální spotřeba | `docs/04-modules/consumption.md` | | TČ, COP, TUV | `docs/04-modules/heat-pump.md`, `db/routines/R__fn_cop_estimate.sql` | | Modbus, telemetrie, agregace | `docs/04-modules/telemetry.md` | +| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` | +| Deye registry (FC 0x10, 108/109/141/142/178/143) | `docs/04-modules/modbus-registers.md` | | Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` | -| LP solver, rolling replan, korekce FVE | `docs/04-modules/planning.md`, `backend/services/planning_engine.py` | +| LP solver, rolling replan, korekce FVE, horizont 96h | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` | | Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `R__fn_set_mode.sql` | | EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` | | Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` | @@ -145,3 +182,5 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan - Python: `snake_case`, type hints, Pydantic pro API modely. - SQL: `snake_case`, explicitní FK; Flyway pořadí `V###__` / repeatable `R__`. - Výkon **W**, energie **Wh**, ceny **Kč/kWh**; čas v DB **`TIMESTAMPTZ` (UTC)**. +- NIKDY neupravuj existující V__ migrační soubory po jejich aplikaci na DB. +- Pokud je potřeba opravit chybu ve verzované migraci, vytvoř novou V{N+1} migraci. diff --git a/backend/Dockerfile b/backend/Dockerfile index 20a794f..0d90d0e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,9 @@ FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates gcc g++ libgomp1 \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 diff --git a/backend/app/config.py b/backend/app/config.py index 1c50b7c..15f3029 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,17 +24,22 @@ class Settings(BaseSettings): postgrest_anon_role: str = Field(default="ems_anon") ote_api_url: str = Field( - default="https://www.ote-cr.cz/pubapi/v1/market-data/dam", + default=( + "https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data" + ), ) eur_czk_rate: float = Field(default=25.0) open_meteo_api_url: str = Field( default="https://api.open-meteo.com/v1/forecast", ) + open_meteo_forecast_days: int = Field(default=7) loxone_user: str = Field(default="") loxone_password: str = Field(default="") + discord_webhook_url: str = Field(default="") + telemetry_poll_interval_sec: int = Field(default=60) planning_horizon_hours: int = Field(default=36) planning_hp_max_cost_czk_kwh: float = Field(default=3.0) diff --git a/backend/app/main.py b/backend/app/main.py index ca0281b..6e3bdd4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ import os from contextlib import asynccontextmanager from datetime import date, datetime, timedelta, timezone from typing import Annotated, Any, Literal +from zoneinfo import ZoneInfo import asyncpg import httpx @@ -17,13 +18,29 @@ from app.deps import set_pg_pool from app.routers.ev import router as ev_router from app.routers.full_status import router as full_status_router from app.routers.plan import router as plan_router +from app.ws_log_handler import WSLogHandler +from app.ws_manager import manager +from fastapi import ( + APIRouter, + Depends, + FastAPI, + HTTPException, + Query, + Request, + WebSocket, + WebSocketDisconnect, +) +from fastapi.middleware.cors import CORSMiddleware +from services.audit_filler import fill_audit_for_completed_intervals +from services.control_exporter import ( + export_setpoints, + read_deye_registers_live, + verify_modbus_commands, +) +from services.heartbeat_service import send_heartbeat from services.forecast_service import fetch_pv_forecast from services.price_importer import import_ote_prices -from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request -from services.audit_filler import fill_audit_for_completed_intervals -from services.heartbeat_service import send_heartbeat from services.telemetry_collector import run_telemetry_loop_wrapper -from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field logger = logging.getLogger(__name__) @@ -47,7 +64,8 @@ async def get_pool() -> asyncpg.Pool: return pool -scheduler = AsyncIOScheduler() +# Cron hodiny/minuty = Europe/Prague (import OTE 13:30 / 14:00, denní plán 15:00, …) +scheduler = AsyncIOScheduler(timezone=ZoneInfo("Europe/Prague")) @asynccontextmanager @@ -57,7 +75,10 @@ async def lifespan(app: FastAPI): set_pg_pool(pool) app.state.pg_pool = pool - from services.control_exporter import export_setpoints + app.state.ws_log_handler = WSLogHandler() + app.state.ws_log_handler.setLevel(logging.INFO) + logging.getLogger().addHandler(app.state.ws_log_handler) + from services.planning_engine import run_daily_plan, run_rolling_replan async def scheduled_heartbeat() -> None: @@ -78,6 +99,26 @@ async def lifespan(app: FastAPI): except Exception: logger.exception("scheduled_audit_filler site=%s failed", site["id"]) + 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") + for site in sites: + try: + n = await conn.fetchval( + "SELECT ems.fn_fill_forecast_accuracy($1, 48)", + site["id"], + ) + if n: + logger.info( + "forecast_accuracy filled %s slots for site %s", + n, + site["id"], + ) + except Exception: + logger.exception( + "scheduled_forecast_accuracy site=%s failed", site["id"] + ) + async def scheduled_expire_modes() -> None: async with app.state.pg_pool.acquire() as conn: try: @@ -94,23 +135,200 @@ async def lifespan(app: FastAPI): except Exception as e: logger.exception("scheduled_control_export site=%s: %s", site["id"], e) - async def scheduled_daily_plan() -> None: + async def scheduled_verify_modbus() -> None: + """ + Ověří příkazy ve stavu written z posledních 20 minut. + 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") for site in sites: try: - await run_daily_plan(site["id"], conn) + cmd_rows = await conn.fetch( + """ + SELECT id FROM ems.modbus_command + WHERE site_id = $1 + AND status = 'written' + AND written_at >= now() - INTERVAL '20 minutes' + ORDER BY written_at + """, + site["id"], + ) + if cmd_rows: + await verify_modbus_commands( + [int(r["id"]) for r in cmd_rows], + conn, + int(site["id"]), + ) except Exception: - logger.exception("scheduled_daily_plan site=%s failed", site["id"]) + logger.exception( + "scheduled_verify_modbus site=%s failed", site["id"] + ) + + 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") + for site in sites: + site_id = int(site["id"]) + try: + await run_daily_plan(site_id, conn) + # Aplikuj nový active run okamžitě, nečekej na další 15min tick exportu. + await export_setpoints(site_id, conn) + except Exception: + logger.exception("scheduled_daily_plan site=%s failed", site_id) 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") for site in sites: + site_id = int(site["id"]) try: - await run_rolling_replan(site["id"], conn) + await run_rolling_replan(site_id, conn) + # Aplikuj nový active run okamžitě, nečekej na další 15min tick exportu. + await export_setpoints(site_id, conn) except Exception: - logger.exception("scheduled_rolling_replan site=%s failed", site["id"]) + logger.exception("scheduled_rolling_replan site=%s failed", site_id) + + 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") + for site in sites: + try: + n = await conn.fetchval( + "SELECT ems.fn_update_baseline_stats($1, 30)", + site["id"], + ) + logger.info( + "baseline_stats updated %s rows for site %s", + n, + site["id"], + ) + except Exception: + logger.exception( + "scheduled_baseline_update site=%s failed", site["id"] + ) + + 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") + for site in sites: + try: + n = await conn.fetchval( + "SELECT ems.fn_update_market_price_stats($1, 90)", + site["id"], + ) + logger.info( + "market_price_stats updated %s rows site=%s", + n, + site["id"], + ) + except Exception: + logger.exception( + "scheduled_market_price_stats site=%s failed", site["id"] + ) + + 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") + for site in sites: + try: + n = await conn.fetchval( + "SELECT ems.fn_update_tuv_usage_stats($1, 30)", + site["id"], + ) + logger.info( + "tuv_usage_stats updated %s rows site=%s", + n, + site["id"], + ) + except Exception: + logger.exception( + "scheduled_tuv_usage_stats site=%s failed", site["id"] + ) + + 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") + for site in sites: + site_id = int(site["id"]) + try: + intervals, pv_arrays = await fetch_pv_forecast(site_id, conn) + if intervals >= 0: + logger.info( + "scheduled_forecast_refresh site=%s intervals=%s arrays=%s", + site_id, + intervals, + pv_arrays, + ) + await _refresh_negative_price_predictions(conn, site_id) + else: + logger.warning( + "scheduled_forecast_refresh site=%s failed", + site_id, + ) + except Exception: + logger.exception("scheduled_forecast_refresh site=%s failed", site_id) + + async def _count_ote_slots_for_day( + conn: asyncpg.Connection, site_id: int, target_day: date + ) -> int: + return int( + await conn.fetchval( + """ + SELECT COUNT(*)::int + FROM ems.market_interval_price + WHERE market_source = 'OTE_CZ' + AND interval_start::date = $1::date + """, + target_day, + ) + or 0 + ) + + async def _scheduled_ote_import_for_site( + conn: asyncpg.Connection, site_id: int + ) -> None: + tz_name = await conn.fetchval( + "SELECT timezone FROM ems.site WHERE id = $1", + site_id, + ) + tz = ZoneInfo(tz_name or "Europe/Prague") + now_loc = datetime.now(tz) + today = now_loc.date() + tomorrow = today + timedelta(days=1) + + # Zajistit data pro dnešek i zítřek; import jen pokud není kompletních 96 slotů. + for day in (today, tomorrow): + slots = await _count_ote_slots_for_day(conn, site_id, day) + if slots >= 96: + continue + n, imported_day, _, err = await import_ote_prices( + site_id, conn, target_date=day + ) + if n < 0: + logger.warning( + "scheduled_ote_import site=%s day=%s failed (%s)", + site_id, + day.isoformat(), + err, + ) + continue + logger.info( + "scheduled_ote_import site=%s day=%s imported=%s", + site_id, + imported_day, + n, + ) + await _refresh_negative_price_predictions(conn, site_id) + + async def scheduled_ote_import() -> None: + async with app.state.pg_pool.acquire() as conn: + sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + for site in sites: + try: + await _scheduled_ote_import_for_site(conn, int(site["id"])) + except Exception: + logger.exception("scheduled_ote_import site=%s failed", site["id"]) scheduler.add_job(scheduled_heartbeat, "interval", seconds=60, id="heartbeat") scheduler.add_job( @@ -120,6 +338,13 @@ async def lifespan(app: FastAPI): second=0, id="audit_filler", ) + scheduler.add_job( + scheduled_forecast_accuracy, + "cron", + minute="2,17,32,47", + id="forecast_accuracy", + replace_existing=True, + ) scheduler.add_job(scheduled_expire_modes, "interval", minutes=1, id="expire_modes") scheduler.add_job( scheduled_control_export, @@ -128,6 +353,13 @@ async def lifespan(app: FastAPI): second=0, id="control_export", ) + scheduler.add_job( + scheduled_verify_modbus, + "interval", + minutes=2, + id="verify_modbus", + replace_existing=True, + ) scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan") scheduler.add_job( scheduled_rolling_replan, @@ -135,6 +367,62 @@ async def lifespan(app: FastAPI): minute="*/15", id="rolling_replan", ) + scheduler.add_job( + scheduled_baseline_update, + "cron", + hour=0, + minute=30, + id="baseline_update", + replace_existing=True, + ) + scheduler.add_job( + scheduled_market_price_stats, + "cron", + hour=14, + minute=45, + id="market_price_stats", + replace_existing=True, + ) + scheduler.add_job( + scheduled_tuv_usage_stats, + "cron", + hour=0, + minute=45, + id="tuv_usage_stats", + replace_existing=True, + ) + scheduler.add_job( + scheduled_ote_import, + "cron", + hour=13, + minute=30, + id="ote_import_preopen", + replace_existing=True, + ) + scheduler.add_job( + scheduled_ote_import, + "cron", + hour=14, + minute=0, + id="ote_import_main", + replace_existing=True, + ) + scheduler.add_job( + scheduled_ote_import, + "cron", + hour=0, + minute=5, + id="ote_import_backfill", + replace_existing=True, + ) + scheduler.add_job( + scheduled_forecast_refresh, + "cron", + hour="*/2", + minute=5, + id="forecast_refresh_2h", + replace_existing=True, + ) scheduler.start() telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool)) @@ -142,6 +430,11 @@ async def lifespan(app: FastAPI): yield + ws_h = getattr(app.state, "ws_log_handler", None) + if ws_h is not None: + logging.getLogger().removeHandler(ws_h) + app.state.ws_log_handler = None + telemetry_task.cancel() try: await telemetry_task @@ -230,6 +523,45 @@ class ForecastRunResponse(BaseModel): pv_arrays: int +class ModbusCommandVerifyItem(BaseModel): + id: int + asset_code: str + register_name: str | None + value_to_write: int + value_verified: int | None + status: str + + +class ModbusVerifyResponse(BaseModel): + checked: int + verified: int + mismatch: int + commands: list[ModbusCommandVerifyItem] + + +class NegativePricePredictionItem(BaseModel): + id: int + predicted_at: datetime + predicted_date: date + window_start_hour: int + window_end_hour: int + probability_pct: int + expected_min_price: float | None + reason: str | None + + +async def _refresh_negative_price_predictions(conn: asyncpg.Connection, site_id: int) -> None: + """Po importu cen / forecastu obnoví cache predikce záporných cen.""" + try: + await conn.fetch("SELECT * FROM ems.fn_predict_negative_price_windows($1, 7)", site_id) + except Exception: + logger.warning( + "fn_predict_negative_price_windows failed for site %s", + site_id, + exc_info=True, + ) + + @sites_router.post("/{site_id}/prices/import", response_model=PricesImportResponse) async def post_import_site_prices( site_id: int, @@ -241,15 +573,18 @@ async def post_import_site_prices( ), ) -> PricesImportResponse: target: date | None = _parse_ymd(date_str) if date_str is not None else None + import_error: str | None = None async with db.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, day, first_price = await import_ote_prices(site_id, conn, target_date=target) + n, day, first_price, import_error = await import_ote_prices(site_id, conn, target_date=target) + if n >= 0: + await _refresh_negative_price_predictions(conn, site_id) if n < 0: raise HTTPException( status_code=422, - detail="OTE API nedostupné nebo nevrátilo data", + detail=f"OTE import selhal ({import_error or 'unknown'})", ) return PricesImportResponse( slots_imported=n, @@ -258,6 +593,66 @@ async def post_import_site_prices( ) +@sites_router.get( + "/{site_id}/prices/negative-predictions", + response_model=list[NegativePricePredictionItem], +) +async def get_site_negative_price_predictions( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], +) -> list[NegativePricePredictionItem]: + """Záznamy z cache predikce záporných cen na příštích 7 kalendářních dní (v časové zóně lokality).""" + async with db.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") + rows = await conn.fetch( + """ + SELECT + p.id, + p.predicted_at, + p.predicted_date, + p.window_start_hour, + p.window_end_hour, + p.probability_pct, + p.expected_min_price, + p.reason + FROM ems.predicted_negative_price_window p + WHERE p.site_id = $1 + AND p.predicted_date > ( + CURRENT_TIMESTAMP AT TIME ZONE COALESCE( + NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''), + 'Europe/Prague' + ) + )::date + AND p.predicted_date <= ( + CURRENT_TIMESTAMP AT TIME ZONE COALESCE( + NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''), + 'Europe/Prague' + ) + )::date + 7 + ORDER BY p.predicted_date, p.window_start_hour + """, + site_id, + ) + out: list[NegativePricePredictionItem] = [] + for r in rows: + em = r["expected_min_price"] + out.append( + NegativePricePredictionItem( + id=int(r["id"]), + predicted_at=r["predicted_at"], + predicted_date=r["predicted_date"], + window_start_hour=int(r["window_start_hour"]), + window_end_hour=int(r["window_end_hour"]), + probability_pct=int(r["probability_pct"]), + expected_min_price=float(em) if em is not None else None, + reason=r["reason"], + ) + ) + return out + + @sites_router.get("/{site_id}/prices/latest", response_model=PricesLatestResponse) async def get_site_prices_latest( site_id: int, @@ -293,6 +688,186 @@ async def get_site_prices_latest( ) +@sites_router.get("/{site_id}/control/verify", response_model=ModbusVerifyResponse) +async def get_verify_modbus_commands( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], + minutes: int = Query(10, ge=1, le=1440, description="Jak daleko zpět hledat written příkazy"), +) -> ModbusVerifyResponse: + """ + Ruční ověření Modbus zápisů (written) z posledních N minut. + Vhodné hned po manuálním exportu setpointů. + """ + async with db.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") + + 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 + """, + site_id, + lookback, + ) + ids = [int(r["id"]) for r in rows] + 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 + """, + ids, + ) + if ids + else [] + ) + + commands = [ + ModbusCommandVerifyItem( + id=int(r["id"]), + asset_code=r["asset_code"], + register_name=r["register_name"], + value_to_write=int(r["value_to_write"]), + value_verified=int(r["value_verified"]) + if r["value_verified"] is not None + else None, + status=r["status"], + ) + for r in detail_rows + ] + verified = sum(1 for c in commands if c.status == "verified") + mismatch = sum(1 for c in commands if c.status == "mismatch") + return ModbusVerifyResponse( + checked=checked, + verified=verified, + mismatch=mismatch, + commands=commands, + ) + + +class DeyeRegistersLiveResponse(BaseModel): + reg108_charge_a: int + reg109_discharge_a: int + reg141_energy_mode: int + reg142_limit_control: int + reg143_export_limit_w: int + reg178_peak_shaving_switch: int + reg191_peak_shaving_w: int + read_at: str + + +@sites_router.get( + "/{site_id}/control/registers", + response_model=DeyeRegistersLiveResponse, +) +async def get_control_registers_live( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], +) -> DeyeRegistersLiveResponse: + """Živé hodnoty registrů Deye 108/109/141/142/143/178/191 přes sdílený Modbus klient.""" + async with db.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") + try: + payload = await read_deye_registers_live(site_id, conn) + except ValueError: + raise HTTPException( + status_code=404, + detail="No controllable Modbus inverter for this site", + ) from None + except Exception as e: + logger.warning("get_control_registers_live site=%s: %s", site_id, e) + raise HTTPException( + status_code=503, + detail=f"Modbus read failed: {e}", + ) from e + return DeyeRegistersLiveResponse(**payload) + + +class ModbusJournalCommandRow(BaseModel): + id: int + register: int + register_name: str | None + value_to_write: int + value_written: int | None + value_verified: int | None + status: str + attempt_count: int + created_at: str + + +class ModbusJournalListResponse(BaseModel): + commands: list[ModbusJournalCommandRow] + + +@sites_router.get( + "/{site_id}/control/journal", + response_model=ModbusJournalListResponse, +) +async def get_control_command_journal( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], + limit: int = Query(50, ge=1, le=100), +) -> ModbusJournalListResponse: + async with db.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") + 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 + """, + site_id, + limit, + ) + cmds: list[ModbusJournalCommandRow] = [] + for r in rows: + d = record_to_dict(r) + ca = d["created_at"] + cmds.append( + ModbusJournalCommandRow( + id=int(d["id"]), + register=int(d["register"]), + register_name=d.get("register_name"), + value_to_write=int(d["value_to_write"]), + value_written=int(d["value_written"]) + if d.get("value_written") is not None + else None, + value_verified=int(d["value_verified"]) + if d.get("value_verified") is not None + else None, + status=str(d["status"]), + attempt_count=int(d["attempt_count"]), + created_at=ca if isinstance(ca, str) else str(ca), + ) + ) + return ModbusJournalListResponse(commands=cmds) + + @sites_router.post("/{site_id}/forecast/run", response_model=ForecastRunResponse) async def post_run_site_forecast( site_id: int, @@ -302,7 +877,13 @@ async def post_run_site_forecast( 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") - intervals, pv_arrays = await fetch_pv_forecast(site_id, conn) + try: + intervals, pv_arrays = await fetch_pv_forecast(site_id, conn) + except Exception as e: + logger.error("Forecast failed: %s", e, exc_info=True) + raise HTTPException(status_code=422, detail=str(e)) from e + if intervals >= 0: + await _refresh_negative_price_predictions(conn, site_id) if intervals < 0: raise HTTPException( status_code=422, @@ -326,14 +907,27 @@ async def get_site_forecast_pv( raise HTTPException(status_code=404, detail="Site not found") rows = await conn.fetch( """ - SELECT fpi.*, apa.code AS pv_array_code - 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 = fpi.pv_array_id AND apa.site_id = fpr.site_id - WHERE fpr.site_id = $1 - AND fpi.interval_start::date = $2::date - AND fpr.status = 'ok' - ORDER BY apa.code, fpi.interval_start + SELECT run_id, pv_array_id, interval_start, power_w, + irradiance_wm2, temp_c, pv_array_code + 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 + 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::date = $2::date + AND fpr.status = 'ok' + ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC + ) latest + ORDER BY pv_array_code, interval_start """, site_id, d, @@ -351,6 +945,45 @@ async def get_site_forecast_pv( return {"pv_a": pv_a, "pv_b": pv_b} +class NegPricePredictionItem(BaseModel): + predicted_date: str + window_start_hour: int + window_end_hour: int + probability_pct: float + expected_min_price: float | None + reason: str + + +class NegativePredictionsResponse(BaseModel): + predictions: list[NegPricePredictionItem] + insufficient_history: bool + + +@sites_router.get( + "/{site_id}/prices/negative-predictions", + response_model=NegativePredictionsResponse, +) +async def get_site_negative_price_predictions( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], +) -> NegativePredictionsResponse: + """Zástupný endpoint – predikce modelu doplnit později; historii počítáme z OTE dat.""" + async with db.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") + 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' + """ + ) + n = int(ndays or 0) + return NegativePredictionsResponse(predictions=[], insufficient_history=n < 28) + + app.include_router(sites_router) app.add_middleware( @@ -362,6 +995,26 @@ app.add_middleware( ) +@app.websocket("/ws/telemetry") +async def ws_telemetry(websocket: WebSocket) -> None: + await manager.connect_telemetry(websocket) + try: + while True: + await websocket.receive_text() # keepalive + except WebSocketDisconnect: + manager.disconnect(websocket) + + +@app.websocket("/ws/logs") +async def ws_logs(websocket: WebSocket) -> None: + await manager.connect_logs(websocket) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(websocket) + + async def _health_payload(db: asyncpg.Pool) -> dict[str, Any]: db_status = "error" active_plan_slots = 0 diff --git a/backend/app/notifications_logic.py b/backend/app/notifications_logic.py new file mode 100644 index 0000000..c5eb0f8 --- /dev/null +++ b/backend/app/notifications_logic.py @@ -0,0 +1,249 @@ +"""Pravidla pro GET /sites/{id}/notifications (ceny, EV, predikce záporných cen).""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta, timezone +from decimal import Decimal +from typing import Any, Literal +from zoneinfo import ZoneInfo + +PRAGUE = ZoneInfo("Europe/Prague") + +NotificationLevel = Literal["success", "info", "warning", "error"] +NotificationAction = Literal["connect_ev", "replan", "import_prices", "switch_auto"] + + +@dataclass(frozen=True) +class PriceSlot: + interval_start: datetime + buy: float + sell: float + + +@dataclass(frozen=True) +class EvSessionRow: + id: int + charger_id: int + energy_delivered_wh: float + target_soc_pct: float | None + session_start: datetime + battery_capacity_kwh: float | None + make: str | None + model: str | None + default_target_soc_pct: float | None + charger_code: str + soc_at_connect_pct: float | None + + +@dataclass(frozen=True) +class NegWindowRow: + predicted_date: date + window_start_hour: int + window_end_hour: int + probability_pct: int + + +def _num(v: Any) -> float | None: + if v is None: + return None + if isinstance(v, Decimal): + return float(v) + return float(v) + + +def _ev_connect_hint(ev_sessions: list[EvSessionRow], current_price: float, avg_buy: float | None) -> str: + if ev_sessions: + return "" + kwh = 30.0 + if current_price < 0: + return f"Připojíš-li Teslu, dostaneš zaplaceno za ~{kwh * abs(current_price):.0f} Kč za 30 kWh." + if avg_buy is None: + return "" + savings = avg_buy - current_price + return f"Připojíš-li Teslu, ušetříš ~{kwh * max(0, savings):.0f} Kč." + + +def _estimate_ev_soc(s: EvSessionRow) -> float: + cap = s.battery_capacity_kwh + delivered_kwh = (s.energy_delivered_wh or 0) / 1000.0 + if s.soc_at_connect_pct is not None and cap and cap > 0: + return min(95.0, float(s.soc_at_connect_pct) + (delivered_kwh / cap) * 100.0) + if cap and cap > 0 and s.energy_delivered_wh: + return min(95.0, (delivered_kwh / cap) * 100.0 + 20.0) + return 50.0 + + +def _estimate_needed_kwh(soc_pct: float, battery_kwh: float, ev_sessions: list[EvSessionRow]) -> float: + bat_needed = max(0.0, (80.0 - soc_pct) / 100.0 * battery_kwh) + ev_needed = 0.0 + for s in ev_sessions: + cap = s.battery_capacity_kwh or 60.0 + tgt = (s.target_soc_pct if s.target_soc_pct is not None else None) or ( + s.default_target_soc_pct if s.default_target_soc_pct is not None else 80.0 + ) + delivered = (s.energy_delivered_wh or 0) / 1000.0 + want = max(0.0, (tgt / 100.0) * cap - delivered) + ev_needed += want + return bat_needed + ev_needed + + +def _estimate_ev_free_kwh(s: EvSessionRow) -> float: + cap = s.battery_capacity_kwh or 60.0 + delivered = (s.energy_delivered_wh or 0) / 1000.0 + return max(0.0, cap * 0.95 - delivered) + + +def _tesla_potential_kwh(ev_sessions: list[EvSessionRow]) -> float: + """Odhad „kolik lze ještě dobít“ pro typickou Teslu, pokud není připojena.""" + if ev_sessions: + return 0.0 + return 55.0 + + +def _date_label(d: date) -> str: + today = datetime.now(PRAGUE).date() + if d == today: + return "dnes" + if d == today + timedelta(days=1): + return "zítra" + return f"{d.day}. {d.month}." + + +def _hours_until(predicted_date: date, start_hour: int) -> float: + start_local = datetime.combine(predicted_date, time(hour=start_hour, minute=0), tzinfo=PRAGUE) + now = datetime.now(PRAGUE) + delta = start_local - now + return max(0.0, delta.total_seconds() / 3600.0) + + +def build_smart_notifications( + *, + prices: list[PriceSlot], + avg_buy: float | None, + soc_pct: float | None, + battery_kwh: float | None, + ev_sessions: list[EvSessionRow], + neg_windows: list[NegWindowRow], + mode: str, + sell_price_now: float | None, +) -> list[dict[str, Any]]: + notifications: list[dict[str, Any]] = [] + + soc = float(soc_pct) if soc_pct is not None else 50.0 + bat_kwh = float(battery_kwh) if battery_kwh is not None and battery_kwh > 0 else 10.0 + + current_buy = prices[0].buy if prices else None + current_sell = prices[0].sell if prices else None + + # 1. Záporná cena právě teď + if current_buy is not None and current_buy < 0: + bat_free_kwh = (100.0 - soc) / 100.0 * bat_kwh + ev_hint = _ev_connect_hint(ev_sessions, current_buy, avg_buy) + notifications.append( + { + "id": "neg_price_now", + "level": "success", + "title": f"Záporné ceny právě teď ({current_buy:.3f} Kč/kWh)", + "body": ( + f"Dostaneš zaplaceno za každý odebraný kWh. " + f"Baterie může pojmout ještě {bat_free_kwh:.1f} kWh. " + + ev_hint + ), + "eta_minutes": 0, + "action": "connect_ev" if not ev_sessions else None, + } + ) + + # 2. Levná cena v příštích 6 h + avg_ok = avg_buy is not None and avg_buy > 0 + if prices and avg_ok and not (current_buy is not None and current_buy < 0): + horizon = prices[:24] + cheap_slots = [p for p in horizon if p.buy < avg_buy * 0.60] + if cheap_slots: + cheapest = min(cheap_slots, key=lambda p: p.buy) + now_utc = datetime.now(timezone.utc) + istart = cheapest.interval_start + if istart.tzinfo is None: + istart = istart.replace(tzinfo=timezone.utc) + eta_min = int((istart - now_utc).total_seconds() / 60) + eta_min = max(0, eta_min) + savings_per_kwh = avg_buy - cheapest.buy + + ev_plug_useful = (not ev_sessions) or any(_estimate_ev_soc(s) < 60.0 for s in ev_sessions) + bat_low = soc < 70.0 + + if ev_plug_useful or bat_low: + needed_kwh = _estimate_needed_kwh(soc, bat_kwh, ev_sessions) + savings_czk = needed_kwh * max(0.0, savings_per_kwh) + extra_body = "" + if ev_plug_useful and not ev_sessions: + extra_body = " Připoj auto před tímto oknem." + notifications.append( + { + "id": "cheap_price_soon", + "level": "info", + "title": f"Levná elektřina za {eta_min} min ({cheapest.buy:.3f} Kč/kWh)", + "body": ( + f"Cena bude o {savings_per_kwh:.2f} Kč/kWh nižší než průměr. " + f"Potenciální úspora: ~{savings_czk:.0f} Kč." + + extra_body + ), + "eta_minutes": eta_min, + "action": "connect_ev" if ev_plug_useful and not ev_sessions else None, + } + ) + + # 3. Predikované záporné ceny + for window in neg_windows[:2]: + if any(n["id"] == "neg_price_now" for n in notifications): + continue + date_label = _date_label(window.predicted_date) + window_str = f"{window.window_start_hour:02d}:00–{window.window_end_hour:02d}:00" + hours_until = _hours_until(window.predicted_date, window.window_start_hour) + bat_free = (100.0 - soc) / 100.0 * bat_kwh + ev_free = sum(_estimate_ev_free_kwh(s) for s in ev_sessions) + total_free = bat_free + ev_free + tesla_kwh = _tesla_potential_kwh(ev_sessions) + ev_hint = ( + f" Připojíš-li Teslu, lze dobít až {tesla_kwh:.0f} kWh navíc." if not ev_sessions else "" + ) + lvl: NotificationLevel = "success" if window.probability_pct >= 70 else "info" + notifications.append( + { + "id": f"neg_pred_{window.predicted_date}_{window.window_start_hour}", + "level": lvl, + "title": ( + f"Záporné ceny {date_label} {window_str} ({window.probability_pct}% jistota)" + ), + "body": ( + f"Solver naplánuje max. odběr ze sítě. Lze dobít ~{total_free:.0f} kWh zdarma." + + ev_hint + ), + "eta_minutes": int(hours_until * 60), + "action": "connect_ev" if not ev_sessions else None, + } + ) + + # 4. Manuální režim + drahá cena + plná baterie + mode_u = (mode or "").strip().upper() + sell = sell_price_now if sell_price_now is not None else (current_sell if current_sell is not None else None) + if mode_u != "AUTO" and avg_ok and sell is not None and sell > avg_buy * 1.30 and soc > 70.0: + pct_above = ((sell / avg_buy) - 1.0) * 100.0 if avg_buy else 0.0 + notifications.append( + { + "id": "manual_expensive", + "level": "warning", + "title": "Drahá elektřina – systém není v AUTO", + "body": ( + f"Cena prodeje {sell:.3f} Kč/kWh (+{pct_above:.0f}% nad průměr). " + f"Baterie {soc:.0f}%. Přepnutím na AUTO solver využije cenové okno." + ), + "eta_minutes": None, + "action": "switch_auto", + } + ) + + priority = {"error": 0, "success": 1, "warning": 2, "info": 3} + notifications.sort(key=lambda n: priority.get(n["level"], 9)) + return notifications diff --git a/backend/app/routers/ev.py b/backend/app/routers/ev.py index 9472465..ce6004c 100644 --- a/backend/app/routers/ev.py +++ b/backend/app/routers/ev.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime from typing import Annotated, Any import asyncpg @@ -91,3 +91,88 @@ async def patch_ev_session( if row is None: raise HTTPException(status_code=404, detail="Session not found") return EvSessionPatchResponse(success=True, session_id=int(row["id"])) + + +class ArrivalHourItem(BaseModel): + hour: int + confidence_pct: int + samples: int + + +class ChargerTomorrowArrival(BaseModel): + tomorrow: list[ArrivalHourItem] + + +class EvArrivalPredictionResponse(BaseModel): + insufficient_data: bool + tomorrow_date: str + chargers: dict[str, ChargerTomorrowArrival] + + +@router.get("/arrival-prediction", response_model=EvArrivalPredictionResponse) +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 + """, + site_id, + ) + if tomorrow is None: + raise HTTPException(status_code=500, detail="Site date resolution failed") + tomorrow_d: date = tomorrow + + chargers_rows = await conn.fetch( + "SELECT id, code FROM ems.asset_ev_charger WHERE site_id = $1 ORDER BY id", + site_id, + ) + + chargers: dict[str, ChargerTomorrowArrival] = {} + for ch in chargers_rows: + code = str(ch["code"]) + preds = await conn.fetch( + "SELECT * FROM ems.fn_ev_expected_arrival($1, $2, $3::date)", + site_id, + ch["id"], + tomorrow_d, + ) + chargers[code] = ChargerTomorrowArrival( + tomorrow=[ + ArrivalHourItem( + hour=int(r["expected_hour"]), + confidence_pct=int(r["confidence_pct"]), + samples=int(r["sample_count"]), + ) + for r in preds + ] + ) + + return EvArrivalPredictionResponse( + insufficient_data=insufficient, + tomorrow_date=tomorrow_d.isoformat(), + chargers=chargers, + ) diff --git a/backend/app/routers/full_status.py b/backend/app/routers/full_status.py index 0bbf706..112d6b1 100644 --- a/backend/app/routers/full_status.py +++ b/backend/app/routers/full_status.py @@ -2,17 +2,38 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from typing import Annotated, Any, Literal +from zoneinfo import ZoneInfo import asyncpg from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field from app.db_json import record_to_dict from app.deps import get_pg_pool +from app.notifications_logic import ( + EvSessionRow, + NegWindowRow, + PriceSlot, + build_smart_notifications, +) router = APIRouter(prefix="/sites/{site_id}", tags=["sites"]) + +class SiteNotificationItem(BaseModel): + id: str + level: Literal["success", "info", "warning", "error"] + title: str + body: str + eta_minutes: int | None = None + action: Literal["connect_ev", "replan", "import_prices", "switch_auto"] | None = None + + +class SiteNotificationsResponse(BaseModel): + notifications: list[SiteNotificationItem] = Field(default_factory=list) + INV_STALE_SEC = 300 HEARTBEAT_STALE_SEC = 300 EXPECTED_TOMORROW_PRICE_SLOTS = 90 @@ -235,7 +256,10 @@ async def get_site_status_full( if not has_plan: add_alert("warn", "Není aktivní plán – EMS neoptimalizuje") - if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS: + # OTE D+1 typicky až po ~14:30 Europe/Prague – před tím nevarovat + now_prague = datetime.now(ZoneInfo("Europe/Prague")) + prices_expected = (now_prague.hour, now_prague.minute) >= (14, 30) + if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS and prices_expected: add_alert("warn", "Chybí spotové ceny pro zítřek") if mode_code.upper() == "MANUAL": @@ -266,3 +290,326 @@ async def get_site_status_full( "planning": planning, "alerts": alerts, } + + +_NOTIF_LEVEL_PRIORITY = {"error": 0, "success": 1, "warning": 2, "info": 3} + + +def _infrastructure_notification_items( + *, + has_plan: bool, + tomorrow_slots: int, + mode_code: str, + reserve_soc: float | None, + soc: float | None, + inv_age: int | None, + hb_age: int | None, +) -> list[SiteNotificationItem]: + """Kritické / provozní notifikace (telemetrie, plán, ceny, režim, heartbeat).""" + items: list[SiteNotificationItem] = [] + + def push( + nid: str, + level: Literal["success", "info", "warning", "error"], + title: str, + body: str, + *, + eta_minutes: int | None = None, + action: Literal["connect_ev", "replan", "import_prices", "switch_auto"] | None = None, + ) -> None: + items.append( + SiteNotificationItem( + id=nid, + level=level, + title=title, + body=body, + eta_minutes=eta_minutes, + action=action, + ) + ) + + if inv_age is None or inv_age > INV_STALE_SEC: + push("telemetry_inverter", "error", "Telemetrie střídače", "Data ze střídače nejsou aktuální.") + + if not has_plan: + push( + "no_active_plan", + "warning", + "Chybí aktivní plán", + "EMS zatím neoptimalizuje provoz – spusťte plánování.", + action="replan", + ) + + now_prague = datetime.now(ZoneInfo("Europe/Prague")) + prices_expected = (now_prague.hour, now_prague.minute) >= (14, 30) + if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS and prices_expected: + push( + "prices_tomorrow", + "warning", + "Ceny na zítřek", + "Nejsou kompletní spotové ceny OTE pro následující den.", + action="import_prices", + ) + + if mode_code.upper() == "MANUAL": + push("mode_manual", "info", "Manuální režim", "Automatická optimalizace je vypnutá.") + + if reserve_soc is not None and soc is not None and soc < reserve_soc: + push("soc_reserve", "error", "SoC pod rezervou", "Nabití baterie je pod nastavenou bezpečnostní rezervou.") + + if hb_age is None or hb_age > HEARTBEAT_STALE_SEC: + push("heartbeat", "error", "EMS heartbeat", "Služba EMS nehlásí pravidelný heartbeat.") + + return items + + +def _float_or_none(v: Any) -> float | None: + if v is None: + return None + return float(v) + + +@router.get("/notifications", response_model=SiteNotificationsResponse) +async def get_site_notifications( + site_id: int, + 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", + site_id, + ) + if site is None: + raise HTTPException(status_code=404, detail="Site not found") + tz = site["timezone"] or "Europe/Prague" + + mode_row = await conn.fetchrow( + """ + SELECT m.mode_code + FROM ems.site_operating_mode m + WHERE m.site_id = $1 + """, + site_id, + ) + run_row = await conn.fetchrow( + """ + SELECT id FROM ems.planning_run + WHERE site_id = $1 AND status = 'active' + ORDER BY created_at DESC + LIMIT 1 + """, + site_id, + ) + reserve_row = await conn.fetchrow( + """ + SELECT MIN(reserve_soc_percent)::float AS reserve_soc + FROM ems.asset_battery + WHERE site_id = $1 + """, + site_id, + ) + inv_row = await conn.fetchrow( + """ + SELECT battery_soc_percent, measured_at + FROM ems.vw_latest_inverter + WHERE site_id = $1 + ORDER BY measured_at DESC NULLS LAST + LIMIT 1 + """, + site_id, + ) + hb_row = await conn.fetchrow( + "SELECT last_seen FROM ems.site_heartbeat WHERE site_id = $1", + site_id, + ) + tomorrow_slots = await conn.fetchval( + """ + SELECT COUNT(*)::int + FROM ems.vw_site_effective_price v + WHERE v.site_id = $1 + AND (v.interval_start AT TIME ZONE $2)::date = + ((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date + """, + site_id, + tz, + ) + + price_rows = await conn.fetch( + """ + 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, + ) + + 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, + ) + + 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 = 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 + ) + soc = ( + float(inv_row["battery_soc_percent"]) + if inv_row and inv_row["battery_soc_percent"] is not None + else None + ) + inv_age = _age_seconds(inv_row["measured_at"] if inv_row else None) + hb_age = _age_seconds(hb_row["last_seen"] if hb_row else None) + + infra = _infrastructure_notification_items( + has_plan=has_plan, + tomorrow_slots=int(tomorrow_slots or 0), + mode_code=mode_code, + reserve_soc=reserve_soc, + soc=soc, + inv_age=inv_age, + hb_age=hb_age, + ) + + prices: list[PriceSlot] = [] + for r in price_rows: + buy = _float_or_none(r["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"] + prices.append( + PriceSlot( + interval_start=istart, + buy=buy, + sell=sell_v if sell_v is not None else buy, + ) + ) + + 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: + 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"]), + ) + ) + + neg_windows: list[NegWindowRow] = [] + for nr in neg_rows: + dr = nr["predicted_date"] + if isinstance(dr, datetime): + d_conv = dr.date() + elif isinstance(dr, date): + d_conv = dr + 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"]), + ) + ) + + sell_now = prices[0].sell if prices else None + + smart_raw = build_smart_notifications( + prices=prices, + avg_buy=avg_buy, + soc_pct=soc, + battery_kwh=battery_kwh, + ev_sessions=ev_sessions, + neg_windows=neg_windows, + mode=mode_code, + sell_price_now=sell_now, + ) + + smart_items = [ + SiteNotificationItem( + id=d["id"], + level=d["level"], + title=d["title"], + body=d["body"], + eta_minutes=d.get("eta_minutes"), + action=d.get("action"), + ) + for d in smart_raw + ] + + merged = infra + smart_items + merged.sort(key=lambda x: _NOTIF_LEVEL_PRIORITY.get(x.level, 9)) + return SiteNotificationsResponse(notifications=merged[:5]) diff --git a/backend/app/routers/plan.py b/backend/app/routers/plan.py index a1b2e3f..cd2c850 100644 --- a/backend/app/routers/plan.py +++ b/backend/app/routers/plan.py @@ -1,21 +1,21 @@ """REST API – aktivní plán a ruční přepočet.""" -from datetime import datetime, timedelta, timezone +import logging +from datetime import datetime, timezone from typing import Annotated, Any, Literal import asyncpg from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field from app.db_json import record_to_dict from app.deps import get_pg_pool -from services.planning_engine import _current_slot_start, run_plan_api +from services.control_exporter import export_setpoints +from services.planning_engine import run_plan_api router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"]) -PRICE_CHECK_HOURS = 24 -_SLOTS_PER_HOUR = 4 -_EXPECTED_PRICE_SLOTS = PRICE_CHECK_HOURS * _SLOTS_PER_HOUR +logger = logging.getLogger(__name__) class RunPlanResponse(BaseModel): @@ -25,6 +25,27 @@ class RunPlanResponse(BaseModel): horizon_end: datetime +class PlanningIntervalDto(BaseModel): + """Řádek `ems.planning_interval` v odpovědi aktivního plánu.""" + + model_config = ConfigDict(extra="allow") + + interval_start: str + is_predicted_price: bool = Field( + default=False, + description=( + "True pokud solver pro slot použil predikovanou cenu (market_price_stats), " + "nikoli přesný řádek z vw_site_effective_price / OTE." + ), + ) + + +class CurrentPlanResponseModel(BaseModel): + run: dict[str, Any] + intervals: list[PlanningIntervalDto] + summary: dict[str, Any] + + def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]: total_cost = 0.0 total_curtailed_kwh = 0.0 @@ -55,11 +76,29 @@ def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]: } -@router.get("/current") +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)], -) -> dict[str, Any]: +) -> CurrentPlanResponseModel: 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: @@ -81,17 +120,53 @@ async def get_current_plan( run_id = run_row["id"] int_rows = await conn.fetch( """ - SELECT * - FROM ems.planning_interval - WHERE run_id = $1 - ORDER BY interval_start + WITH latest_fc AS ( + SELECT id + FROM ems.forecast_pv_run + WHERE site_id = $2 AND status = 'ok' + ORDER BY created_at DESC + LIMIT 1 + ), + fc_slot AS ( + SELECT fpi.interval_start, COALESCE(SUM(fpi.power_w), 0)::BIGINT AS pv_forecast_total_w + FROM ems.forecast_pv_interval fpi + WHERE fpi.run_id = (SELECT id FROM latest_fc) + GROUP BY fpi.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 = [record_to_dict(r) for r in int_rows] - summary = _build_summary(intervals) - return {"run": record_to_dict(run_row), "intervals": intervals, "summary": summary} + intervals_raw = [record_to_dict(r) for r in int_rows] + summary = _build_summary(intervals_raw) + summary["pv_scarcity_factor"] = _pv_scarcity_factor_from_intervals( + intervals_raw, float(battery_usable_wh or 0.0) + ) + intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw] + return CurrentPlanResponseModel( + run=record_to_dict(run_row), + intervals=intervals, + summary=summary, + ) @router.post("/run", response_model=RunPlanResponse) @@ -100,52 +175,52 @@ async def post_run_plan( pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"), ) -> RunPlanResponse: - window_start = _current_slot_start(datetime.now(timezone.utc)) - window_end = window_start + timedelta(hours=PRICE_CHECK_HOURS) - 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") - price_slots = await conn.fetchval( + 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 COUNT(DISTINCT interval_start)::int - FROM ems.vw_site_effective_price - WHERE site_id = $1 - AND interval_start >= $2 - AND interval_start < $3 - """, - site_id, - window_start, - window_end, ) - if (price_slots or 0) < _EXPECTED_PRICE_SLOTS: + if (days_with_prices or 0) < 1: raise HTTPException( status_code=422, - detail="Nejsou dostupné tržní ceny. Spusťte nejdřív import cen.", + detail="Nejsou dostupné tržní ceny", ) try: 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 + """, + run_id, + ) + except HTTPException: + raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e except RuntimeError as e: raise HTTPException(status_code=422, detail=str(e)) from e + except Exception as e: + logger.error("Plan run failed: %s", e, exc_info=True) + raise HTTPException(status_code=422, detail=str(e)) from e - row = await conn.fetchrow( - """ - SELECT horizon_start, horizon_end - FROM ems.planning_run - WHERE id = $1 - """, - run_id, - ) - - if row is None: - raise HTTPException(status_code=500, detail="Planning run row missing after insert") + if row is None: + raise HTTPException(status_code=500, detail="Planning run row missing after insert") return RunPlanResponse( run_id=run_id, diff --git a/backend/app/ws_log_handler.py b/backend/app/ws_log_handler.py new file mode 100644 index 0000000..9ce93eb --- /dev/null +++ b/backend/app/ws_log_handler.py @@ -0,0 +1,25 @@ +import asyncio +import logging +from datetime import datetime, timezone + +from .ws_manager import manager + + +class WSLogHandler(logging.Handler): + """Posílá log záznamy přes WebSocket všem připojeným klientům /ws/logs.""" + + def emit(self, record: logging.LogRecord) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + ts = datetime.fromtimestamp(record.created, tz=timezone.utc).strftime("%H:%M:%S") + msg = { + "ts": ts, + "level": record.levelname, + "logger": record.name.split(".")[-1], + "msg": record.getMessage(), + } + loop.call_soon_threadsafe( + lambda: asyncio.ensure_future(manager.broadcast_log(msg)) + ) diff --git a/backend/app/ws_manager.py b/backend/app/ws_manager.py new file mode 100644 index 0000000..0f3568a --- /dev/null +++ b/backend/app/ws_manager.py @@ -0,0 +1,38 @@ +from fastapi import WebSocket + + +class ConnectionManager: + def __init__(self): + self._telemetry: list[WebSocket] = [] + self._logs: list[WebSocket] = [] + + async def connect_telemetry(self, ws: WebSocket): + await ws.accept() + self._telemetry.append(ws) + + async def connect_logs(self, ws: WebSocket): + await ws.accept() + self._logs.append(ws) + + def disconnect(self, ws: WebSocket): + self._telemetry = [w for w in self._telemetry if w != ws] + self._logs = [w for w in self._logs if w != ws] + + async def broadcast_telemetry(self, data: dict): + await self._broadcast(self._telemetry, data) + + async def broadcast_log(self, record: dict): + await self._broadcast(self._logs, record) + + async def _broadcast(self, clients: list, data: dict): + dead = [] + for ws in clients: + try: + await ws.send_json(data) + except Exception: + dead.append(ws) + for ws in dead: + self.disconnect(ws) + + +manager = ConnectionManager() diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index 4ff3efd..b62f244 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -6,15 +6,51 @@ import asyncio import logging import os from dataclasses import dataclass +from typing import Any from datetime import datetime, timezone +from zoneinfo import ZoneInfo + import asyncpg import httpx from app.config import get_settings -from services.telemetry_collector import ModbusDevice +from services.modbus_client import get_modbus_client logger = logging.getLogger(__name__) +# 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) + +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 built-in CT)", + 143: "export_limit_w (max export do sítě)", + 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: @@ -22,6 +58,50 @@ def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> i 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 výhradně z DB (_load_inverter_config).""" + return min(max(0, max_amps), max(0, round(abs(power_w) / BATT_VOLTAGE_V))) + + +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 + reserve_soc_percent: int | None + usable_capacity_wh: int | None + max_charge_a: int + max_discharge_a: int + + @dataclass class ControlSetpoints: battery_w: int | None @@ -32,6 +112,9 @@ class ControlSetpoints: grid_setpoint_w: int ev1_power_w: int ev2_power_w: int + target_soc_pct: int | None = None + #: True = reg 108/109 na 0 (PRESERVE – Deye baterii nepoužívá) + lock_battery: bool = False @dataclass @@ -44,8 +127,253 @@ class OperatingModeInfo: loxone_mode_value: int -def _clamp_u16(value: int) -> int: - return max(0, min(65535, int(value))) +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 + + +async def execute_modbus_commands( + command_ids: list[int], + db: asyncpg.Connection, +) -> bool: + """ + Zapíše příkazy z modbus_command do zařízení. + Aktualizuje status na 'written' nebo 'failed'. + Vrátí True pokud všechny příkazy uspěly. + """ + MAX_RETRIES = 3 + RETRY_DELAY = 0.5 + + all_ok = True + for cmd_id in command_ids: + cmd = await db.fetchrow( + "SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id + ) + if cmd is None: + continue + client = await get_modbus_client( + cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]) + ) + for attempt in range(MAX_RETRIES): + try: + await client.write_registers( + int(cmd["register"]), [int(cmd["value_to_write"])] + ) + 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 + """, + int(cmd["value_to_write"]), + cmd_id, + ) + logger.info( + "[cmd %s] %s 0x%04X=%s OK (attempt %s)", + cmd_id, + cmd["asset_code"], + int(cmd["register"]), + int(cmd["value_to_write"]), + attempt + 1, + ) + break + except Exception as e: + if attempt < MAX_RETRIES - 1: + logger.warning( + "[cmd %s] attempt %s failed: %s, retrying...", + cmd_id, + attempt + 1, + e, + ) + await asyncio.sleep(RETRY_DELAY) + client._client = None # force reconnect + else: + await db.execute( + """ + UPDATE ems.modbus_command + SET status='failed', error_msg=$1, + attempt_count=attempt_count+1 + WHERE id=$2 + """, + str(e), + cmd_id, + ) + logger.error( + "[cmd %s] all %s attempts failed: %s", + cmd_id, + 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 a zaloguje důvod.""" + await db.execute( + "SELECT ems.fn_set_mode($1, $2, $3, $4, $5)", + site_id, + "SELF_SUSTAIN", + "system:mismatch", + None, + reason, + ) + logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason) + + +async def verify_modbus_commands( + command_ids: list[int], + db: asyncpg.Connection, + site_id: int, +) -> bool: + """ + Přečte registry zpět a porovná s value_to_write. + Při mismatch: retry → SELF_SUSTAIN + Discord. + """ + from services.notification_service import ( + notify_modbus_mismatch, + notify_self_sustain_activated, + ) + + all_ok = True + for cmd_id in command_ids: + cmd = await db.fetchrow( + "SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id + ) + if cmd is None or cmd["status"] != "written": + continue + + try: + client = await get_modbus_client( + cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]) + ) + actual = await client.read_register(int(cmd["register"])) + await db.execute( + """ + UPDATE ems.modbus_command + SET value_verified=$1, verified_at=now(), + status=CASE WHEN $1=$2 THEN 'verified' ELSE 'mismatch' END + WHERE id=$3 + """, + actual, + int(cmd["value_to_write"]), + cmd_id, + ) + + if actual != int(cmd["value_to_write"]): + logger.error( + "[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s", + cmd_id, + cmd["asset_code"], + int(cmd["register"]), + cmd["value_to_write"], + actual, + ) + 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"], + int(cmd["register"]), + cmd["register_name"] or "", + int(cmd["value_to_write"]), + actual, + 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: + logger.critical( + "[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN", + cmd_id, + ) + site = await db.fetchrow( + "SELECT code FROM ems.site WHERE id=$1", site_id + ) + await _switch_to_self_sustain( + site_id, + db, + reason=( + f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} " + f"reg 0x{cmd['register']:04X}" + ), + ) + if site: + await notify_self_sustain_activated( + site["code"], + ( + f"Modbus mismatch: {cmd['asset_code']} " + f"0x{cmd['register']:04X} expected={cmd['value_to_write']} " + f"actual={actual}" + ), + ) + all_ok = False + else: + logger.info( + "[cmd %s] verified OK: %s 0x%04X=%s", + cmd_id, + cmd["asset_code"], + int(cmd["register"]), + actual, + ) + except Exception as e: + logger.error("[cmd %s] verify read failed: %s", cmd_id, e) + all_ok = False + + return all_ok async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None: @@ -80,21 +408,155 @@ async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> Operati ) -async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) -> asyncpg.Record | None: - """Řádek plánu pro následující 15min slot (export ~1 min před hranicí, např. 14:29 → 14:30–14:45).""" - return await db.fetchrow( +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.reserve_soc_percent, + ab.usable_capacity_wh, + LEAST( + COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w), + ai.max_battery_charge_w + ) / 51.2 AS max_charge_a, + LEAST( + COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w), + ai.max_battery_discharge_w + ) / 51.2 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 + 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, + reserve_soc_percent=int(row["reserve_soc_percent"]) + if row["reserve_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, + ) + + +def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]: + """Hodnoty pro reg 62–64 (Europe/Prague).""" + now = datetime.now(ZoneInfo("Europe/Prague")) + reg62 = ((now.year - 2000) << 8) | now.month + reg63 = (now.day << 8) | now.hour + reg64 = (now.minute << 8) | now.second + 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 = ( - SELECT MIN(pi2.interval_start) FROM ems.planning_interval pi2 - JOIN ems.planning_run pr2 ON pr2.id = pi2.run_id - WHERE pr2.site_id = $1 AND pr2.status = 'active' - AND pi2.interval_start >= date_trunc('hour', now()) - + INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM now()) / 15) - + INTERVAL '15 minutes' - ) + AND pi.interval_start = {t} LIMIT 1 """, site_id, @@ -104,10 +566,20 @@ async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) -> async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int: v = await db.fetchval( """ - SELECT ai.max_charge_power_w - FROM ems.asset_inverter ai - WHERE ai.site_id = $1 AND ai.controllable = true AND ai.active = true - ORDER BY ai.id + 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, @@ -129,6 +601,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont 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 return ControlSetpoints( battery_w=int(pi["battery_setpoint_w"] or 0), grid_export_limit=abs(min(grid_sp, 0)), @@ -138,6 +612,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont grid_setpoint_w=grid_sp, ev1_power_w=ev1_w, ev2_power_w=ev2_w, + target_soc_pct=target_soc, ) if code == "SELF_SUSTAIN": @@ -150,6 +625,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont grid_setpoint_w=0, ev1_power_w=0, ev2_power_w=0, + target_soc_pct=None, ) if code == "CHARGE_CHEAP": @@ -163,6 +639,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont grid_setpoint_w=0, ev1_power_w=0, ev2_power_w=0, + target_soc_pct=None, ) if code == "PRESERVE": @@ -175,62 +652,240 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont 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 -async def write_inverter_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str: - if setpoints.battery_w is None: - return "OK inverter: skipped (battery_w=None, Deye unchanged)" - - rows = await db.fetch( - """ - SELECT 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.controllable = true - AND ai.active = true - AND se.enabled = true - AND se.endpoint_type = 'modbus_tcp' - """, +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, ) - if not rows: + 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, + ) + + +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 get_deye_mode(setpoints: ControlSetpoints) -> str: + """ + Fyzický režim Deye: SELL | CHARGE | PASSIVE. + Solver: záporný grid_setpoint_w = export; kladný výrazný + nabíjení = CHARGE ze sítě. + battery_w=None (SELF_SUSTAIN) → bat_w považuj za 0 → typicky PASSIVE při grid_setpoint_w=0. + """ + grid_w = int(setpoints.grid_setpoint_w or 0) + if setpoints.battery_w is None: + bat_w = 0 + else: + bat_w = int(setpoints.battery_w) + if 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 min %, grid_charge. + Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints. + """ + reserve_soc = inv.reserve_soc_percent or 20 + 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 + if setpoints.lock_battery: + return tp_discharge_w, reserve_soc, 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 + target_soc = min(95, setpoints.target_soc_pct or 80) + tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V) + return tp_charge_w, target_soc, True + return tp_discharge_w, reserve_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" - bw = setpoints.battery_w - gex = _clamp_u16(setpoints.grid_export_limit) - chg = _clamp_u16(bw) if bw >= 0 else 0 - dis = _clamp_u16(abs(bw)) if bw < 0 else 0 + 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) + reserve_soc = inv.reserve_soc_percent or 20 + 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 - errors: list[str] = [] - for row in rows: - code = row["code"] - host = row["host"] - port = int(row["port"] or 502) - unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1) - dev = ModbusDevice(host, port, unit_id, f"inverter-write:{code}") - try: - if bw >= 0: - ok1 = await dev.write_register(0x00F3, chg) - ok2 = await dev.write_register(0x00F4, 0) - else: - ok1 = await dev.write_register(0x00F3, 0) - ok2 = await dev.write_register(0x00F4, dis) - ok3 = await dev.write_register(0x00F6, gex) - if not (ok1 and ok2 and ok3): - errors.append(f"{code}: Modbus write failed") - except Exception as e: - errors.append(f"{code}: {e}") - finally: - await dev.close() + try: + soc_telemetry = await _get_current_soc(site_id, db) - if errors: - return "FAIL inverter: " + "; ".join(errors) - return f"OK inverter: batt_w={bw} export_limit_w={gex}" + deye_mode = get_deye_mode(setpoints_now) + + if setpoints_now.lock_battery: + charge_a = 0 + discharge_a = 0 + elif deye_mode == "CHARGE": + battery_w = int(raw_bat) if raw_bat is not None else 0 + charge_a = battery_watts_to_amps(battery_w, inv.max_charge_a) + discharge_a = 0 + else: + charge_a = int(inv.max_charge_a) + discharge_a = int(inv.max_discharge_a) + + selling_mode = 0 if deye_mode == "SELL" else 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={'0=SELL' if deye_mode == 'SELL' else '1=ZERO_EXP'} " + f"reg178={reg178_val}" + ) + + now_cet, time_rows = _deye_system_time_register_rows() + logger.info("Deye time synced: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S")) + + registers: list[tuple[int, str, int]] = 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)) + + for idx in range(2, 6): + registers.extend( + _deye_time_point_rows( + idx, 2359, tp_discharge_w, reserve_soc, False + ) + ) + + registers.extend( + [ + (108, "", charge_a), + (109, "", discharge_a), + (141, "energy_mode (0)", 0), + (142, "limit_control (0=selling, 1=zero_export)", selling_mode), + (178, "grid_peak_shaving_switch", reg178_val), + (143, "", export_limit), + ] + ) + + logger.info( + "[control] %s: deye_mode=%s charge=%sA discharge=%sA limit_control=%s export=%sW " + "time_point1=%s time_point2=%s soc_telemetry=%s%% (batt=%r grid=%sW)", + inv.code, + deye_mode, + charge_a, + discharge_a, + selling_mode, + export_limit, + hh_cur, + hh_nxt, + soc_telemetry, + raw_bat, + grid_w, + ) + + 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) + 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, 142, 143, 178, 191 (stejné TCP spojení jako telemetrie/export). + """ + inv = await _load_inverter_config(site_id, db) + if inv is None: + raise ValueError("no controllable Modbus inverter for site") + + client = await get_modbus_client(inv.host, inv.port, inv.unit_id) + read_at = datetime.now(timezone.utc) + try: + r108 = await client.read_register(108) + r109 = await client.read_register(109) + r141 = await client.read_register(141) + r142 = await client.read_register(142) + r143 = await client.read_register(143) + r178 = await client.read_register(178) + r191 = await client.read_register(191) + 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), + "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: @@ -371,18 +1026,20 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: logger.info("control export site=%s: MANUAL, skip writes", site_id) return - pi = await _fetch_current_slot_plan_row(site_id, db) - sp = _build_setpoints(mode, pi) + 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 is None: - if pi is None: + 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 is None: + if sp_now is None: logger.warning( "control export site=%s: no setpoints for mode %s, skip", site_id, @@ -392,27 +1049,67 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: if mode.mode_code == "CHARGE_CHEAP": max_ch = await _fetch_max_charge_power_w(site_id, db) - sp = ControlSetpoints( + # 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=0, + grid_setpoint_w=grid_for_charge, ev1_power_w=0, ev2_power_w=0, + target_soc_pct=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"), - await asyncio.gather( - write_inverter_setpoints(site_id, sp, db), - write_ev_setpoints(site_id, sp, db), - write_heat_pump_setpoint(site_id, sp, db), - send_loxone_setpoints(site_id, sp, mode, db), - return_exceptions=True, - ), + (inv_res, ev_res, hp_res, lox_res), ) ) diff --git a/backend/services/forecast_service.py b/backend/services/forecast_service.py index a6e2ec0..d222b21 100644 --- a/backend/services/forecast_service.py +++ b/backend/services/forecast_service.py @@ -12,7 +12,6 @@ import httpx import pandas as pd import pvlib from pvlib import irradiance -from pvlib.pvsystem import pvwatts_dc from app.config import get_settings @@ -64,9 +63,12 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]: arrays = await db.fetch( """ - SELECT * + SELECT id, code, nominal_power_wp, azimuth_deg, tilt_deg, + shading_factor, controllable FROM ems.asset_pv_array WHERE site_id = $1 + AND azimuth_deg IS NOT NULL + AND tilt_deg IS NOT NULL ORDER BY id """, site_id, @@ -91,7 +93,7 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]: "temperature_2m", ] ), - "forecast_days": 2, + "forecast_days": max(2, min(int(settings.open_meteo_forecast_days), 16)), "timezone": "auto", } @@ -148,6 +150,7 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]: loc = pvlib.location.Location(lat, lon, tz=api_tz) solar_pos = loc.get_solarposition(times) + dni_extra = irradiance.get_extra_radiation(times) total_rows = 0 horizon_start = times[0].tz_convert(timezone.utc).to_pydatetime() @@ -156,13 +159,13 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]: ) for arr in arrays: - tilt = float(arr["tilt_deg"] or 0.0) - az_db = float(arr["azimuth_deg"] or 0.0) + tilt = float(arr["tilt_deg"]) + az_db = float(arr["azimuth_deg"]) az_pvlib = _db_azimuth_to_pvlib(az_db) - pdc0 = float(arr["nominal_power_wp"]) + nominal_power_wp = float(arr["nominal_power_wp"]) shading = float(arr["shading_factor"] or 1.0) - poa = irradiance.get_total_irradiance( + poa_global = irradiance.get_total_irradiance( surface_tilt=tilt, surface_azimuth=az_pvlib, solar_zenith=solar_pos["apparent_zenith"], @@ -170,20 +173,23 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]: dni=dni, ghi=ghi, dhi=dhi, + dni_extra=dni_extra, model="haydavies", )["poa_global"].fillna(0).clip(lower=0) - temp_cell = temp_air + 0.04 * poa - p_dc = pvwatts_dc(poa, temp_cell, pdc0, -0.004) - p_dc = p_dc.fillna(0).clip(lower=0) * shading - power_w = p_dc.round().astype(int) + area_m2 = nominal_power_wp / (1000.0 * 0.20) + power_w = poa_global * area_m2 * 0.20 * shading + cap_w = nominal_power_wp * 1.1 + power_w = power_w.clip(lower=0, upper=cap_w).round().astype(int) model_params: dict[str, Any] = { "source": "open_meteo", "endpoint": base, "params": params, "pvlib_model": "haydavies", - "pvwatts_gamma_pdc": -0.004, + "nominal_power_wp": nominal_power_wp, + "shading_factor": shading, + "area_m2_ref_20pct": area_m2, } run_id = await db.fetchval( diff --git a/backend/services/modbus_client.py b/backend/services/modbus_client.py new file mode 100644 index 0000000..0ed1192 --- /dev/null +++ b/backend/services/modbus_client.py @@ -0,0 +1,166 @@ +"""Persistentní Modbus TCP klient na sdílené Waveshare / RS485 bráně (jedno spojení + lock).""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from pymodbus.client import AsyncModbusTcpClient + +logger = logging.getLogger(__name__) + + +class ModbusBatch: + """Více read/write pod jedním držením locku (žádný jiný task na stejném klientovi mezi nimi).""" + + def __init__(self, owner: PersistentModbusClient) -> None: + self._o = owner + + async def read_register(self, address: int) -> int: + return await self._o._read_register_locked(address) + + async def read_register_signed(self, address: int) -> int: + raw = await self.read_register(address) + return raw - 65536 if raw > 32767 else raw + + async def write_register(self, address: int, value: int) -> bool: + return await self._o._write_register_locked(address, value) + + async def write_registers(self, address: int, values: list[int]) -> bool: + return await self._o._write_registers_locked(address, values) + + +class PersistentModbusClient: + """ + Jedno persistentní TCP spojení na převodník. + Serializuje všechny požadavky přes asyncio.Lock(). + Automaticky reconnectuje při výpadku. + """ + + def __init__(self, host: str, port: int, device_id: int = 1) -> None: + self.host = host + self.port = port + self.device_id = device_id + self._client: AsyncModbusTcpClient | None = None + self._lock = asyncio.Lock() + + async def _ensure_connected(self) -> None: + if self._client is not None and self._client.connected: + return + if self._client is not None: + self._client.close() + self._client = None + logger.info("Modbus connecting %s:%s dev=%s", self.host, self.port, self.device_id) + self._client = AsyncModbusTcpClient( + self.host, + port=self.port, + timeout=5, + retries=2, + ) + await self._client.connect() + if not self._client.connected: + self._client.close() + self._client = None + raise ConnectionError(f"Cannot connect Modbus {self.host}:{self.port}") + logger.info("Modbus connected %s:%s", self.host, self.port) + + async def _read_register_locked(self, address: int) -> int: + if self._client is None or not self._client.connected: + await self._ensure_connected() + assert self._client is not None + try: + r = await self._client.read_holding_registers( + address, count=1, device_id=self.device_id + ) + if r.isError() or not getattr(r, "registers", None): + raise OSError(f"Read error 0x{address:04X}: {r!r}") + return int(r.registers[0]) + except Exception as e: + logger.warning("Modbus read 0x%04X failed: %s", address, e) + self._client.close() + self._client = None + raise + + async def _write_registers_locked(self, address: int, values: list[int]) -> bool: + if self._client is None or not self._client.connected: + await self._ensure_connected() + assert self._client is not None + try: + clamped = [max(0, min(65535, int(v))) for v in values] + r = await self._client.write_registers( + address, clamped, device_id=self.device_id + ) + if r.isError(): + raise OSError(f"Write error 0x{address:04X}={clamped}: {r!r}") + return True + except Exception as e: + logger.warning( + "Modbus write_registers 0x%04X failed: %s", address, e + ) + self._client.close() + self._client = None + raise + + async def _write_register_locked(self, address: int, value: int) -> bool: + if self._client is None or not self._client.connected: + await self._ensure_connected() + assert self._client is not None + try: + v = max(0, min(65535, int(value))) + r = await self._client.write_register(address, v, device_id=self.device_id) + if r.isError(): + raise OSError(f"Write error 0x{address:04X}={v}: {r!r}") + return True + except Exception as e: + logger.warning("Modbus write 0x%04X=%s failed: %s", address, value, e) + self._client.close() + self._client = None + raise + + async def read_register(self, address: int) -> int: + async with self._lock: + await self._ensure_connected() + return await self._read_register_locked(address) + + async def read_register_signed(self, address: int) -> int: + raw = await self.read_register(address) + return raw - 65536 if raw > 32767 else raw + + async def write_register(self, address: int, value: int) -> bool: + async with self._lock: + await self._ensure_connected() + return await self._write_register_locked(address, value) + + async def write_registers(self, address: int, values: list[int]) -> bool: + """FC 0x10 – povinné pro Deye registry 60–499 (jeden i více registrů).""" + async with self._lock: + await self._ensure_connected() + return await self._write_registers_locked(address, values) + + @asynccontextmanager + async def batch(self) -> AsyncIterator[ModbusBatch]: + """Drží lock pro více po sobě jdoucích operací (telemetrie vs. control na stejné bráně).""" + async with self._lock: + await self._ensure_connected() + yield ModbusBatch(self) + + def close(self) -> None: + if self._client is not None: + self._client.close() + self._client = None + + +_clients: dict[str, PersistentModbusClient] = {} +_registry_lock = asyncio.Lock() + + +async def get_modbus_client( + host: str, port: int, device_id: int = 1 +) -> PersistentModbusClient: + key = f"{host}:{port}:{device_id}" + async with _registry_lock: + if key not in _clients: + _clients[key] = PersistentModbusClient(host, port, device_id) + return _clients[key] diff --git a/backend/services/notification_service.py b/backend/services/notification_service.py new file mode 100644 index 0000000..d44bcb1 --- /dev/null +++ b/backend/services/notification_service.py @@ -0,0 +1,65 @@ +"""Discord a další notifikace pro provoz EMS.""" + +from __future__ import annotations + +import logging + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) + + +async def send_discord(message: str, level: str = "info") -> bool: + """ + Pošle notifikaci na Discord webhook. + level: 'info', 'warning', 'error', 'critical' + Vrátí True při úspěchu. + """ + settings = get_settings() + webhook_url = settings.discord_webhook_url + if not webhook_url: + logger.debug("Discord webhook not configured, skipping notification") + return False + + emoji = {"info": "ℹ️", "warning": "⚠️", "error": "❌", "critical": "🚨"}.get(level, "ℹ️") + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + webhook_url, + json={ + "content": f"{emoji} **EMS Alert** [{level.upper()}]\n{message}", + }, + ) + resp.raise_for_status() + return True + except Exception as e: + logger.warning("Discord notification failed: %s", e) + return False + + +async def notify_modbus_mismatch( + asset_code: str, + register: int, + register_name: str, + value_written: int, + value_verified: int, + attempt: int, +) -> None: + msg = ( + f"Modbus mismatch na **{asset_code}**\n" + f"Registr: `0x{register:04X}` ({register_name})\n" + f"Zapsáno: `{value_written}` | Přečteno: `{value_verified}`\n" + f"Pokus č. {attempt}" + ) + await send_discord(msg, level="error") + + +async def notify_self_sustain_activated(site_code: str, reason: str) -> None: + msg = ( + f"Přepnutí na **SELF_SUSTAIN** – lokalita `{site_code}`\n" + f"Důvod: {reason}" + ) + await send_discord(msg, level="critical") diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 0845610..3433a41 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -13,9 +13,9 @@ from dataclasses import dataclass, replace from datetime import datetime, timezone, timedelta from types import SimpleNamespace from typing import Optional +from zoneinfo import ZoneInfo import pulp -from pulp import HiGHS_CMD logger = logging.getLogger(__name__) @@ -24,8 +24,11 @@ logger = logging.getLogger(__name__) # Konstanty # ============================================================ -HORIZON_HOURS = 36 # horizont denního plánu +HORIZON_HOURS = 96 # horizont denního plánu (OTE ~36h + predikce) INTERVAL_H = 0.25 # 15 minut v hodinách +SLOT_WEIGHT_FULL = 1.0 # 0–36h od začátku okna (přesné OTE ceny) +SLOT_WEIGHT_MEDIUM = 0.7 # 36–72h +SLOT_WEIGHT_LOW = 0.4 # 72–96h CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A SOLVER_TIME_LIMIT = 10 # sekund CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru @@ -34,6 +37,84 @@ CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru # Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0 +_PRAGUE_TZ = ZoneInfo("Europe/Prague") + + +def slot_weight(slot_index: int, now_index: int = 0) -> float: + """Váha slotu v účelové funkci podle vzdálenosti od začátku optimalizačního okna.""" + hours_ahead = (slot_index - now_index) * INTERVAL_H + if hours_ahead <= 36: + return SLOT_WEIGHT_FULL + if hours_ahead <= 72: + return SLOT_WEIGHT_MEDIUM + return SLOT_WEIGHT_LOW + + +def _pv_scarcity_penalty_multiplier(slots: list["PlanningSlot"], battery) -> float: + """ + Měkká úprava ekonomiky cyklu podle očekávaného slunečního zisku. + - málo očekávané FVE energie -> nižší penalizace cyklu (podpora precharge ze sítě), + - hodně očekávané FVE energie -> standardní penalizace. + """ + horizon_slots = min(len(slots), int(24 / INTERVAL_H)) # konzervativní 1 den dopředu + if horizon_slots <= 0: + return 1.0 + + pv_kwh = 0.0 + for s in slots[:horizon_slots]: + pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0 + + batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0) + # coverage = kolikanásobek baterie očekáváme ze slunce v horizontu. + coverage = pv_kwh / batt_kwh + coverage_clamped = max(0.0, min(1.0, coverage)) + # 0.65 při nízkém slunci, 1.0 při vysokém slunci. + return 0.65 + 0.35 * coverage_clamped + + +def _pv_coverage_ratio(slots: list["PlanningSlot"], battery, hours: int = 24) -> float: + horizon_slots = min(len(slots), int(hours / INTERVAL_H)) + if horizon_slots <= 0: + return 1.0 + pv_kwh = 0.0 + for s in slots[:horizon_slots]: + pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0 + batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0) + return max(0.0, min(1.0, pv_kwh / batt_kwh)) + + +def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float, float]: + """ + Při nízkém očekávaném slunci drží solver vyšší SoC buffer: + - cílový buffer: reserve + až 20 % usable capacity, + - ekonomická penalizace deficitu vůči bufferu z průměrné ceny. + """ + coverage = _pv_coverage_ratio(slots, battery, hours=24) + scarcity = 1.0 - coverage + usable_wh = float(getattr(battery, "usable_capacity_wh", 0.0)) + reserve_wh = float(getattr(battery, "reserve_soc_wh", 0.0)) + soc_max_wh = float(getattr(battery, "soc_max_wh", usable_wh)) + extra_buffer_wh = 0.35 * usable_wh * scarcity + target_wh = min(soc_max_wh, reserve_wh + extra_buffer_wh) + + h24 = min(len(slots), int(24 / INTERVAL_H)) + avg_buy = ( + sum(float(s.buy_price) for s in slots[:h24]) / h24 + if h24 > 0 + else 4.0 + ) + penalty_czk_kwh = max(0.1, avg_buy * 1.00 * scarcity) + return target_wh, penalty_czk_kwh + + +def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]: + """DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So.""" + dt = interval_start + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + loc = dt.astimezone(_PRAGUE_TZ) + return (loc.weekday() + 1) % 7, loc.hour + # ============================================================ # Datové třídy (lze nahradit pydantic modely) @@ -49,6 +130,7 @@ class PlanningSlot: load_baseline_w: int # W – predikce bazální spotřeby ev1_connected: bool ev2_connected: bool + is_predicted_price: bool = False @dataclass @@ -67,6 +149,7 @@ class DispatchResult: expected_cost_czk: float effective_buy_price: float effective_sell_price: float + is_predicted_price: bool # shodné s PlanningSlot (chybí OTE v efektivní ceně → fn_get_predicted_price) # ============================================================ @@ -179,6 +262,11 @@ def solve_dispatch( vehicles: list, # [vehicle1, vehicle2] current_soc_wh: float, current_tuv_temp_c: float, + *, + tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None, + now_slot_index: int = 0, + operating_mode: str = "AUTO", + price_failsafe_active: bool = False, ) -> tuple[list[DispatchResult], int]: """ LP solver pro dispatch optimalizaci. @@ -188,6 +276,9 @@ def solve_dispatch( EV = len(vehicles) # počet EV (typicky 2) EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency) + cycle_penalty_mult = _pv_scarcity_penalty_multiplier(slots, battery) + degradation_cost_effective = battery.degradation_cost_czk_kwh * cycle_penalty_mult + soc_buffer_target_wh, soc_deficit_penalty_czk_kwh = _soc_security_profile(slots, battery) prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize) @@ -199,6 +290,7 @@ def solve_dispatch( soc = [pulp.LpVariable(f"soc_{t}", battery.reserve_soc_wh, battery.soc_max_wh) for t in range(T)] ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)] hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)] + soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh) # EV proměnné per vozidlo ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0, @@ -208,19 +300,23 @@ def solve_dispatch( vehicles[e].max_charge_power_w) for t in range(T)] for e in range(EV)] - # --- Účelová funkce --- + # --- Účelová funkce (váhy slotů podle nejistoty za horizontem OTE) --- prob += pulp.lpSum( - gi[t] * slots[t].buy_price * INTERVAL_H / 1000 - - ge[t] * slots[t].sell_price * INTERVAL_H / 1000 - + (bc[t] + bd[t]) * battery.degradation_cost_czk_kwh * INTERVAL_H / 1000 - + pulp.lpSum( - ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000 - + ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000 - for e in range(EV) + slot_weight(t, now_slot_index) * ( + gi[t] * slots[t].buy_price * INTERVAL_H / 1000 + - ge[t] * slots[t].sell_price * INTERVAL_H / 1000 + # Degradační náklad rozložíme symetricky na charge/discharge (0.5 + 0.5), + # aby nebyl roundtrip penalizovaný dvojnásobně. + + 0.5 * (bc[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000 + + pulp.lpSum( + ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000 + + ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000 + for e in range(EV) + ) + + ca[t] * CURTAILMENT_PENALTY ) - + ca[t] * CURTAILMENT_PENALTY for t in range(T) - ) + ) + soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000 # --- Omezení --- for t in range(T): @@ -270,6 +366,27 @@ def solve_dispatch( else: prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w + om = (operating_mode or "AUTO").strip().upper() + if om == "SELF_SUSTAIN": + for t in range(T): + prob += ge[t] == 0 + prob += gi[t] <= slots[t].load_baseline_w + elif om == "PRESERVE": + for t in range(T): + prob += bc[t] == 0 + prob += bd[t] == 0 + elif om == "CHARGE_CHEAP": + for t in range(T): + prob += ge[t] == 0 + prob += bd[t] == 0 + + if price_failsafe_active: + for t in range(T): + # Fail-safe aplikujeme po slotech: v predikovaných cenách zakážeme pouze export. + # Baterie se má dál normálně používat pro interní spotřebu (nabíjení/vybíjení do domu). + if slots[t].is_predicted_price: + prob += ge[t] == 0 + # Deadline constraints pro EV for e, session in enumerate(ev_sessions): if session and session.target_deadline and session.energy_needed_wh > 0: @@ -283,14 +400,44 @@ def solve_dispatch( if (e == 0 and slots[t].ev1_connected) or (e == 1 and slots[t].ev2_connected) ) >= session.energy_needed_wh + # TUV look-ahead podle tuv_usage_stats (DOW+hodina, konvence jako v DB) + if ( + tuv_delta_stats + and heat_pump.rated_heating_power_w > 0 + and getattr(heat_pump, "tuv_min_temp_c", 0) is not None + ): + tuv_pred = float(current_tuv_temp_c) + tgt = float(getattr(heat_pump, "tuv_target_temp_c", 55.0) or 55.0) + thr = float(heat_pump.tuv_min_temp_c) + 5.0 + for t in range(T): + dow, hour = _prague_dow_hour(slots[t].interval_start) + delta = tuv_delta_stats.get((dow, hour), -0.1) + tuv_pred += float(delta) * INTERVAL_H + if tuv_pred < thr: + prob += ( + pulp.lpSum(hp[s] for s in range(max(0, t - 8), t + 1)) + >= heat_pump.rated_heating_power_w * 0.5 + ) + tuv_pred = tgt + # Nouzový ohřev TUV if current_tuv_temp_c < heat_pump.tuv_min_temp_c: prob += hp[0] >= heat_pump.rated_heating_power_w * 0.8 - # --- Řešení --- + # SoC bezpečnostní buffer vyhodnocený až na konci 24h horizontu + eod_idx = min(T - 1, int(24 / INTERVAL_H) - 1) + prob += soc_deficit_24h >= soc_buffer_target_wh - soc[eod_idx] + + # --- Řešení (HiGHS přes highspy / PuLP API; bez externí binárky HiGHS_CMD) --- t_start = time.monotonic() - solver = HiGHS_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT) - status = prob.solve(solver) + try: + solver = pulp.getSolver( + "HiGHS", msg=False, timeLimit=SOLVER_TIME_LIMIT + ) + except Exception: + logger.warning("HiGHS nedostupný, používám CBC fallback") + solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT) + status = prob.solve(solver) duration_ms = int((time.monotonic() - t_start) * 1000) if pulp.LpStatus[status] != 'Optimal': @@ -327,6 +474,7 @@ def solve_dispatch( expected_cost_czk = round(cost, 4), effective_buy_price = slots[t].buy_price, effective_sell_price = slots[t].sell_price, + is_predicted_price = bool(slots[t].is_predicted_price), )) return results, duration_ms @@ -340,7 +488,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily" """ Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00) a aktualizaci forecastu (14:30). - Horizont: od začátku aktuálního 15min slotu do +36h. + Horizont: od začátku aktuálního 15min slotu do +HORIZON_HOURS (96h; OTE + predikce). """ now = datetime.now(timezone.utc) horizon_from = _current_slot_start(now) @@ -349,13 +497,26 @@ 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) + 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 + if price_failsafe_active: + logger.warning( + "[site=%s] Price fail-safe active (daily): missing OTE slots in first 36h = %s", + site_id, + missing_ote_count, + ) - battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp = await _load_site_context( - site_id, db + 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 + slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, + tuv_delta_stats=tuv_stats, + operating_mode=operating_mode or "AUTO", + price_failsafe_active=price_failsafe_active, ) run_id = await _save_planning_run( @@ -421,18 +582,32 @@ 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 = await _load_site_context( - site_id, db + battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode = ( + 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) + 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 + if price_failsafe_active: + logger.warning( + "[site=%s] Price fail-safe active (rolling): missing OTE slots in first 36h = %s", + site_id, + missing_ote_count, + ) 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 + slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, + tuv_delta_stats=tuv_stats, + operating_mode=operating_mode or "AUTO", + price_failsafe_active=price_failsafe_active, ) run_id = await _save_planning_run( @@ -533,22 +708,45 @@ def _ev_session_ctx(row) -> Optional[SimpleNamespace]: async def _load_site_context(site_id: int, db): """ - Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC a TUV pro solver. + Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV a provozní režim pro solver. """ + operating_mode = await db.fetchval( + "SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1", + site_id, + ) + brow = await db.fetchrow( """ - SELECT bat.usable_capacity_wh, - bat.reserve_soc_percent, - bat.max_soc_percent, - bat.charge_efficiency, - bat.discharge_efficiency, - bat.degradation_cost_czk_kwh, - inv.max_charge_power_w, - inv.max_discharge_power_w - FROM ems.asset_battery bat - JOIN ems.asset_inverter inv ON inv.id = bat.inverter_id AND inv.site_id = bat.site_id - WHERE bat.site_id = $1 - ORDER BY bat.id + SELECT ab.usable_capacity_wh, + ab.reserve_soc_percent, + ab.max_soc_percent, + ab.charge_efficiency, + ab.discharge_efficiency, + ab.degradation_cost_czk_kwh, + 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, @@ -556,6 +754,21 @@ async def _load_site_context(site_id: int, db): 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"]) reserve_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc soc_max_wh = float(brow["max_soc_percent"]) / 100.0 * uc @@ -566,14 +779,15 @@ async def _load_site_context(site_id: int, db): charge_efficiency=float(brow["charge_efficiency"]), discharge_efficiency=float(brow["discharge_efficiency"]), degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]), - max_charge_power_w=int(brow["max_charge_power_w"]), - max_discharge_power_w=int(brow["max_discharge_power_w"]), + max_charge_power_w=ec_i, + max_discharge_power_w=ed_i, ) 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_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 @@ -582,12 +796,17 @@ async def _load_site_context(site_id: int, db): site_id, ) if hrow is None: - heat_pump = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=0.0) + 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( @@ -689,46 +908,90 @@ async def _load_site_context(site_id: int, db): ) tuv_temp = float(tuv) if tuv is not None else 50.0 - return battery, heat_pump, grid, vehicles, ev_sessions, soc_wh, tuv_temp + return ( + battery, + heat_pump, + grid, + vehicles, + ev_sessions, + soc_wh, + tuv_temp, + operating_mode, + ) + + +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).""" + rows = await db.fetch( + """ + SELECT day_of_week, hour_of_day, avg_temp_delta_c + FROM ems.tuv_usage_stats + WHERE site_id = $1 + """, + site_id, + ) + 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, forecasty a stavem EV z DB.""" + """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 - ep.interval_start, - ep.effective_buy_price_czk_kwh AS buy_price, - ep.effective_sell_price_czk_kwh AS sell_price, + 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(cbi.power_w, 500) AS load_baseline_w, - -- EV připojení z poslední telemetrie nabíječek (bez řádku = nepřipojeno) + 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 ems.vw_site_effective_price ep - -- FVE pole A forecast + 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 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 = fpi.pv_array_id AND apa.site_id = fpr.site_id WHERE fpr.site_id = $1 AND apa.code = 'pv-a' - AND fpi.interval_start = ep.interval_start AND fpr.status = 'ok' + AND fpi.interval_start = s.interval_start AND fpr.status = 'ok' ORDER BY fpr.created_at DESC LIMIT 1 ) fpi_a ON true - -- FVE pole B forecast LEFT JOIN LATERAL ( SELECT 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 = fpi.pv_array_id AND apa.site_id = fpr.site_id WHERE fpr.site_id = $1 AND apa.code = 'pv-b' - AND fpi.interval_start = ep.interval_start AND fpr.status = 'ok' + AND fpi.interval_start = s.interval_start AND fpr.status = 'ok' ORDER BY fpr.created_at DESC LIMIT 1 ) fpi_b ON true - -- Bazální spotřeba - LEFT JOIN ems.consumption_baseline_interval cbi - ON cbi.site_id = $1 AND cbi.interval_start = ep.interval_start - AND cbi.data_type = 'forecast' - -- Stav EV nabíječek (aktuální, pro celý horizont stejný) LEFT JOIN LATERAL ( SELECT t.status FROM ems.telemetry_ev_charger t @@ -743,9 +1006,7 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]: WHERE t.site_id = $1 AND ch.code = 'ev-charger-2' ORDER BY t.measured_at DESC LIMIT 1 ) ev2 ON true - WHERE ep.site_id = $1 - AND ep.interval_start >= $2 AND ep.interval_start < $3 - ORDER BY ep.interval_start + ORDER BY s.interval_start """, site_id, from_dt, to_dt) out: list[PlanningSlot] = [] @@ -761,6 +1022,7 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]: load_baseline_w=int(d["load_baseline_w"] or 0), ev1_connected=bool(d["ev1_connected"]), ev2_connected=bool(d["ev2_connected"]), + is_predicted_price=bool(d.get("is_predicted_price")), ) ) if not out: @@ -796,8 +1058,9 @@ async def _save_planning_run( 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) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + 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, @@ -805,7 +1068,8 @@ async def _save_planning_run( 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.effective_buy_price, r.effective_sell_price, + r.is_predicted_price) for r in results ]) diff --git a/backend/services/price_importer.py b/backend/services/price_importer.py index 4b97b6d..fe2eb48 100644 --- a/backend/services/price_importer.py +++ b/backend/services/price_importer.py @@ -1,11 +1,10 @@ -"""OTE CZ DAM spot price import (15min slots, shared market table).""" - +"""OTE CZ price import – Python dělá pouze HTTP fetch, logika je v PostgreSQL.""" from __future__ import annotations +import asyncio import json import logging -from datetime import date, datetime, timedelta, timezone -from typing import Any +from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo import httpx @@ -14,167 +13,178 @@ from app.config import get_settings logger = logging.getLogger(__name__) -MARKET_SOURCE = "OTE_CZ" +OTE_URL = ( + "https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/" + "@@chart-data?report_date={date}&time_resolution=PT15M" +) + + +def _is_retryable_status(status_code: int) -> bool: + return status_code in {408, 425, 429, 500, 502, 503, 504} + + +async def _fetch_ote_json(date_str: str) -> tuple[dict | None, str | None]: + url = OTE_URL.format(date=date_str) + timeout = httpx.Timeout(connect=10.0, read=45.0, write=10.0, pool=10.0) + headers = { + "User-Agent": "Mozilla/5.0 (compatible; EMS/1.0; +https://www.ote-cr.cz)", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "cs-CZ,cs;q=0.9,en;q=0.8", + "Connection": "keep-alive", + } + max_attempts = 4 + backoff_s = 1.0 + last_err: str | None = None + + async with httpx.AsyncClient( + timeout=timeout, + headers=headers, + follow_redirects=True, + ) as client: + for attempt in range(1, max_attempts + 1): + try: + logger.info("OTE fetch %s attempt %s/%s", date_str, attempt, max_attempts) + resp = await client.get(url) + if _is_retryable_status(resp.status_code) and attempt < max_attempts: + last_err = f"http_status:{resp.status_code}" + logger.warning( + "OTE temporary HTTP %s for %s (attempt %s/%s), retrying", + resp.status_code, + date_str, + attempt, + max_attempts, + ) + await asyncio.sleep(backoff_s) + backoff_s *= 2.0 + continue + resp.raise_for_status() + return resp.json(), None + except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e: + last_err = f"timeout_or_connect:{e.__class__.__name__}" + if attempt < max_attempts: + logger.warning( + "OTE request failed for %s (%s), retrying %s/%s", + date_str, + e.__class__.__name__, + attempt, + max_attempts, + ) + await asyncio.sleep(backoff_s) + backoff_s *= 2.0 + continue + logger.error("OTE fetch failed for %s after retries: %s", date_str, e) + except httpx.HTTPStatusError as e: + code = e.response.status_code if e.response is not None else "unknown" + last_err = f"http_status:{code}" + logger.error("OTE HTTP error for %s: %s", date_str, code) + break + except json.JSONDecodeError as e: + last_err = f"invalid_json:{e.__class__.__name__}" + logger.error("OTE invalid JSON for %s: %s", date_str, e) + break + except Exception as e: + last_err = f"unexpected:{e.__class__.__name__}" + logger.error("OTE fetch unexpected error for %s: %s", date_str, e) + break + + return None, last_err async def import_ote_prices( site_id: int, db, target_date: date | None = None, -) -> tuple[int, str, float]: +) -> tuple[int, str, float, str | None]: """ - Stáhne DAM ceny OTE pro zvolený den (nebo „zítřek“ v TZ lokality), uloží 96 slotů (15 min). - - Schéma DB: ``ems.market_interval_price`` má PK ``(market_source, interval_start)``; - ceny v ``buy_raw_price_czk_kwh`` / ``sell_raw_price_czk_kwh`` (pro OTE stejné). - - Returns: - ``(počet_slotů, datum_YMD, první_cena_kč_kwh)``. Počet 96 při úspěchu, -1 při chybě. - První cena je cena prvního 15min slotu dne; při chybě 0.0. - Datum je prázdný řetězec jen pokud site neexistuje nebo je neplatná timezone. + Stáhne OTE JSON a předá ho PostgreSQL funkci ems.fn_ote_import_from_json. + Python nedělá žádné parsování ani přepočty – vše je v DB funkcích. + Returns: (počet_slotů, datum_str, první_cena_kč_kwh, error_code) + (-1, datum_str, 0.0, error_code) při chybě """ + settings = get_settings() + row = await db.fetchrow( - "SELECT timezone FROM ems.site WHERE id = $1", - site_id, + "SELECT timezone FROM ems.site WHERE id = $1", site_id ) if row is None: - logger.error("import_ote_prices: site id=%s nenalezen", site_id) - return -1, "", 0.0 + logger.error("OTE import: site id=%s nenalezen", site_id) + return -1, "", 0.0, "site_not_found" - tz_name: str = row["timezone"] or "Europe/Prague" - try: - site_tz = ZoneInfo(tz_name) - except Exception as e: - logger.error("import_ote_prices: neplatná timezone %r: %s", tz_name, e) - return -1, "", 0.0 + site_tz = ZoneInfo(row["timezone"] or "Europe/Prague") + now_site = datetime.now(site_tz) + today_site = now_site.date() + tomorrow_site = today_site + timedelta(days=1) + candidate_days = [target_date] if target_date is not None else [tomorrow_site, today_site] + + payload: dict | None = None + fetch_error: str | None = None + target_day = candidate_days[0] + + # Varování před 13:30 CET při implicitním (zítra) importu. + if target_date is None: + now_cet = datetime.now(ZoneInfo("Europe/Prague")) + if now_cet.hour < 13 or (now_cet.hour == 13 and now_cet.minute < 30): + logger.warning( + "OTE: ceny pro %s nemusí být dostupné (před 13:30 CET), použiji fallback na dnešek", + tomorrow_site.isoformat(), + ) + + for day in candidate_days: + day_str = day.isoformat() + payload, fetch_error = await _fetch_ote_json(day_str) + if payload is not None: + target_day = day + break + logger.warning("OTE fetch selhal pro %s (err=%s)", day_str, fetch_error) + + if payload is None: + return -1, candidate_days[0].isoformat(), 0.0, fetch_error or "fetch_failed" - if target_date is not None: - target_day = target_date - else: - now_local = datetime.now(site_tz) - target_day = (now_local + timedelta(days=1)).date() date_str = target_day.isoformat() - cet = ZoneInfo("Europe/Prague") - now_cet = datetime.now(cet) - tomorrow_cet = (now_cet + timedelta(days=1)).date() - if target_day == tomorrow_cet: - cutoff = now_cet.replace(hour=13, minute=30, second=0, microsecond=0) - if now_cet < cutoff: - logger.warning( - "OTE prices for tomorrow may not be available yet (before 13:30 CET)" - ) - - settings = get_settings() - base_url = settings.ote_api_url.rstrip("/") - url = f"{base_url}?date={date_str}" + # Vše ostatní řeší PostgreSQL funkce eur_czk = float(settings.eur_czk_rate) - try: - async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.get(url) - resp.raise_for_status() - body = resp.json() - except httpx.TimeoutException: - logger.warning("import_ote_prices: timeout při GET %s", url) - return -1, date_str, 0.0 - except httpx.HTTPStatusError as e: - logger.warning( - "import_ote_prices: HTTP %s při GET %s: %s", - e.response.status_code, - url, - e.response.text[:500], + n = await db.fetchval( + "SELECT ems.fn_ote_import_from_json($1::jsonb, $2)", + json.dumps(payload), + eur_czk, ) - return -1, date_str, 0.0 - except httpx.HTTPError as e: - logger.warning("import_ote_prices: HTTP chyba při GET %s: %s", url, e) - return -1, date_str, 0.0 - except Exception as e: - logger.warning("import_ote_prices: neočekávaná chyba při stahování: %s", e) - return -1, date_str, 0.0 - - hourly_eur_mwh: dict[int, float] | None = None - try: - points: list[dict[str, Any]] = body["data"]["dataLine"][0]["point"] - hourly_eur_mwh = {} - for p in points: - x = int(p["x"]) - y = float(p["y"]) - hourly_eur_mwh[x] = y - except (KeyError, TypeError, ValueError, IndexError): - snippet = json.dumps(body, ensure_ascii=False)[:500] - logger.error("import_ote_prices: neočekádaná struktura OTE, začátek: %s", snippet) - return -1, date_str, 0.0 - - if len(hourly_eur_mwh) != 24 or set(hourly_eur_mwh.keys()) != set(range(1, 25)): - logger.error( - "import_ote_prices: očekáváno 24 bodů x=1..24, dostáno klíče %s", - sorted(hourly_eur_mwh.keys()), - ) - return -1, date_str, 0.0 - - slots: list[tuple[datetime, datetime, float]] = [] - for h in range(24): - x = h + 1 - eur_mwh = hourly_eur_mwh[x] - price_czk_kwh = eur_mwh * eur_czk / 1000.0 - for minute in (0, 15, 30, 45): - interval_start_local = datetime( - target_day.year, - target_day.month, - target_day.day, - h, - minute, - tzinfo=site_tz, - ) - interval_start_utc = interval_start_local.astimezone(timezone.utc) - interval_end_utc = interval_start_utc + timedelta(minutes=15) - slots.append((interval_start_utc, interval_end_utc, price_czk_kwh)) - - for interval_start_utc, interval_end_utc, price in slots: - await db.execute( + first_price = await db.fetchval( """ - INSERT INTO ems.market_interval_price ( - market_source, - interval_start, - interval_end, - buy_raw_price_czk_kwh, - sell_raw_price_czk_kwh, - currency, - imported_at - ) - VALUES ($1, $2, $3, $4, $5, 'CZK', now()) - ON CONFLICT (market_source, interval_start) - DO UPDATE SET - interval_end = EXCLUDED.interval_end, - buy_raw_price_czk_kwh = EXCLUDED.buy_raw_price_czk_kwh, - sell_raw_price_czk_kwh = EXCLUDED.sell_raw_price_czk_kwh, - imported_at = now() + 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 """, - MARKET_SOURCE, - interval_start_utc, - interval_end_utc, - price, - price, + target_day, ) - - first_price = float(slots[0][2]) if slots else 0.0 - return len(slots), date_str, first_price - - -if __name__ == "__main__": - import asyncio - import os - - import asyncpg - from dotenv import load_dotenv - - load_dotenv() - - async def test(): - conn = await asyncpg.connect(os.getenv("DATABASE_URL")) - n, d, fp = await import_ote_prices(1, conn) - print(f"Uloženo {n} slotů pro {d}, první cena {fp}") - await conn.close() - - asyncio.run(test()) + n_imported = await db.fetchval( + """ + SELECT COUNT(*)::int + FROM ems.market_interval_price + WHERE market_source = 'OTE_CZ' + AND interval_start::date = $1::date + """, + target_day, + ) + incomplete = (n_imported or 0) < 96 + if incomplete: + now_p = datetime.now(ZoneInfo("Europe/Prague")) + tomorrow_p = (now_p + timedelta(days=1)).date() + # Stejná logika jako dashboard: neúplný D+1 před 14:30 je očekávaný + if not ( + target_day == tomorrow_p + and (now_p.hour, now_p.minute) < (14, 30) + ): + logger.warning("OTE: jen %s/96 slotů pro %s", n_imported, date_str) + logger.info( + "OTE import OK: %s slotů pro %s, první cena %.4f Kč/kWh", + n, date_str, float(first_price or 0), + ) + return int(n), date_str, float(first_price or 0.0), None + except Exception as e: + logger.error("OTE import DB error: %s", e) + return -1, date_str, 0.0, f"db_import:{e.__class__.__name__}" diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index 0a5fb8c..2e91e9f 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -7,148 +7,23 @@ import logging from datetime import datetime, timezone import asyncpg -from pymodbus.client import AsyncModbusTcpClient -from pymodbus.exceptions import ConnectionException, ModbusIOException +from app.ws_manager import manager + +from services.modbus_client import get_modbus_client logger = logging.getLogger(__name__) - -def _to_signed_i16(value: int) -> int: - v = value & 0xFFFF - if v >= 0x8000: - return v - 0x10000 - return v - - -class ModbusDevice: - def __init__(self, host: str, port: int, unit_id: int, device_name: str) -> None: - self._host = host - self._port = int(port) if port else 502 - self._unit_id = int(unit_id) if unit_id is not None else 1 - self._device_name = device_name - self._client: AsyncModbusTcpClient | None = None - self._error_count = 0 - - def _log_prefix(self) -> str: - return f"[{self._device_name}]" - - def _note_communication_failure(self, exc: BaseException | None) -> None: - self._error_count += 1 - if isinstance(exc, ConnectionError): - logger.warning("%s ConnectionError: %s", self._log_prefix(), exc) - else: - logger.warning( - "%s komunikace selhala: %s", - self._log_prefix(), - exc if exc is not None else "neznámá chyba", - ) - if self._error_count >= 3: - logger.error("%s Opakované chyby komunikace", self._log_prefix()) - if self._error_count >= 10 and self._error_count % 10 == 0: - logger.critical( - "%s Opakované chyby komunikace, pokus o reconnect", - self._log_prefix(), - ) - - def _reset_error_count(self) -> None: - self._error_count = 0 - - async def _ensure_connected(self) -> bool: - if self._client is None: - self._client = AsyncModbusTcpClient(self._host, port=self._port) - if not self._client.connected: - try: - ok = await self._client.connect() - except ConnectionError as e: - self._note_communication_failure(e) - return False - except OSError as e: - self._note_communication_failure(e) - return False - if not ok: - self._note_communication_failure(ConnectionError("Modbus connect() returned False")) - return False - return True - - async def _reconnect(self) -> None: - if self._client is not None: - self._client.close() - self._client = None - self._client = AsyncModbusTcpClient(self._host, port=self._port) - try: - await self._client.connect() - except (ConnectionError, OSError) as e: - logger.warning("%s reconnect selhal: %s", self._log_prefix(), e) - - async def read_register(self, address: int) -> int: - """Čte jeden holding register. Vrátí 0 při chybě.""" - try: - if not await self._ensure_connected(): - if self._error_count >= 10 and self._error_count % 10 == 0: - await self._reconnect() - return 0 - assert self._client is not None - resp = await self._client.read_holding_registers( - address, count=1, device_id=self._unit_id - ) - if resp.isError() or not getattr(resp, "registers", None): - self._note_communication_failure( - ConnectionException(f"read_holding_registers@{address:#x}: {resp!r}") - ) - if self._error_count >= 10 and self._error_count % 10 == 0: - await self._reconnect() - return 0 - self._reset_error_count() - return int(resp.registers[0]) - except ConnectionError as e: - self._note_communication_failure(e) - if self._error_count >= 10 and self._error_count % 10 == 0: - await self._reconnect() - return 0 - except (OSError, ModbusIOException, ConnectionException) as e: - self._note_communication_failure(e) - if self._error_count >= 10 and self._error_count % 10 == 0: - await self._reconnect() - return 0 - - async def read_register_signed(self, address: int) -> int: - """Čte signed int16 (pro výkony které mohou být záporné).""" - u = await self.read_register(address) - return _to_signed_i16(u) - - async def write_register(self, address: int, value: int) -> bool: - """Zapíše jeden holding register. Vrátí False při chybě.""" - try: - if not await self._ensure_connected(): - if self._error_count >= 10 and self._error_count % 10 == 0: - await self._reconnect() - return False - assert self._client is not None - resp = await self._client.write_register(address, value, device_id=self._unit_id) - if resp.isError(): - self._note_communication_failure( - ConnectionException(f"write_register@{address:#x}: {resp!r}") - ) - if self._error_count >= 10 and self._error_count % 10 == 0: - await self._reconnect() - return False - self._reset_error_count() - return True - except ConnectionError as e: - self._note_communication_failure(e) - if self._error_count >= 10 and self._error_count % 10 == 0: - await self._reconnect() - return False - except (OSError, ModbusIOException, ConnectionException) as e: - self._note_communication_failure(e) - if self._error_count >= 10 and self._error_count % 10 == 0: - await self._reconnect() - return False - - async def close(self) -> None: - if self._client is not None: - self._client.close() - self._client = None +# Deye SUN – holding registry (decimal adresa = přímo pro read_holding_registers) +DEYE_REG_RUN_STATE = 500 +DEYE_REG_BATT_CHARGE_TODAY = 514 +DEYE_REG_BATT_DISCHARGE_TODAY = 515 +DEYE_REG_BATTERY_SOC = 588 +DEYE_REG_BATTERY_POWER_FLOW = 590 +DEYE_REG_GRID_TOTAL_POWER = 625 +DEYE_REG_GEN_PORT_POWER = 667 +DEYE_REG_LOAD_TOTAL_POWER = 653 +DEYE_REG_PV1_POWER = 672 +DEYE_REG_PV2_POWER = 673 async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: @@ -169,34 +44,43 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: inv_id = row["id"] code = row["code"] host = row["host"] - port = row["port"] or 502 - unit_id = row["unit_id"] if row["unit_id"] is not None else 1 - dev = ModbusDevice(host, port, unit_id, f"inverter:{code}") + port = int(row["port"] or 502) + unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1) try: - pv_power_w = await dev.read_register(0x0215) - battery_soc = await dev.read_register(0x0103) - battery_power = await dev.read_register_signed(0x0105) - battery_voltage = (await dev.read_register(0x0101)) / 10.0 - grid_power = await dev.read_register_signed(0x0169) - grid_voltage = (await dev.read_register(0x016F)) / 10.0 - load_power = await dev.read_register(0x0213) - inv_temp = (await dev.read_register(0x0220)) / 10.0 - op_mode = await dev.read_register(0x0168) - fault_code = await dev.read_register(0x0180) + client = await get_modbus_client(host, port, unit_id) + async with client.batch() as mb: + run_state = await mb.read_register(DEYE_REG_RUN_STATE) + battery_soc = await mb.read_register(DEYE_REG_BATTERY_SOC) + battery_power = await mb.read_register_signed(DEYE_REG_BATTERY_POWER_FLOW) + batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY) + batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY) + gen_port_power = await mb.read_register(DEYE_REG_GEN_PORT_POWER) + grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER) + load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER) + pv1_power = await mb.read_register(DEYE_REG_PV1_POWER) + pv2_power = await mb.read_register(DEYE_REG_PV2_POWER) + # Celková výroba FVE na této instalaci = stringy PV + výkon přes GEN port. + pv_power_w = int(pv1_power) + int(pv2_power) + int(gen_port_power) + + 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, battery_soc_percent, battery_power_w, battery_voltage_v, - grid_power_w, grid_voltage_v, load_power_w, - inverter_temp_c, operating_mode, fault_code + 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, + run_state ) VALUES ( $1, $2, $3, $4, $5, $6, $7, - $8, $9, $10, - $11, $12, $13 + $8, $9, + $10, $11, + $12, $13, + $14 ) ON CONFLICT (inverter_id, measured_at) DO NOTHING """, @@ -204,20 +88,34 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: inv_id, measured_at, pv_power_w, - battery_soc, + pv1_power, + pv2_power, + gen_port_power, + float(battery_soc), battery_power, - battery_voltage, + batt_charge_today, + batt_discharge_today, grid_power, - grid_voltage, load_power, - inv_temp, - str(op_mode), - fault_code, + run_state, + ) + inv_temp: float | None = None + await manager.broadcast_telemetry( + { + "type": "telemetry", + "site_id": site_id, + "ts": datetime.now(timezone.utc).isoformat(), + "pv_power_w": pv_power_w, + "battery_soc_pct": float(battery_soc), + "battery_power_w": battery_power, + "grid_power_w": grid_power, + "load_power_w": load_power, + "gen_port_power_w": gen_port_power, + "inverter_temp_c": inv_temp, + } ) except Exception as e: logger.error("poll_inverter site=%s inverter=%s: %s", site_id, code, e) - finally: - await dev.close() async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None: @@ -233,23 +131,112 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None: site_id, ) measured_at = datetime.now(timezone.utc) + connector_id = 1 for row in rows: 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 + """, + 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, 1, 'available', 0, 0) + VALUES ($1, $2, $3, $4, $5, 0, 0) ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING """, site_id, - row["id"], + charger_id, measured_at, + connector_id, + current_status, ) + if previous_status is not None: + if previous_status == "available" and current_status != "available": + vehicle_id = await db.fetchval( + """ + SELECT av.id + FROM ems.asset_vehicle av + WHERE av.site_id = $1 + AND av.default_charger_id = $2 + AND av.active = true + ORDER BY av.id + LIMIT 1 + """, + 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, + ) + 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( diff --git a/db/migration/V003__seed_site_home01.sql b/db/migration/V003__seed_site_home01.sql index 51d3a4e..37b5211 100644 --- a/db/migration/V003__seed_site_home01.sql +++ b/db/migration/V003__seed_site_home01.sql @@ -30,7 +30,7 @@ VALUES ( -- Deye střídač přes Waveshare RS485→TCP INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes) -SELECT id, 'modbus_tcp', '192.168.1.100', 502, 'modbus_tcp', 1, true, 'Waveshare WS-ETH pro Deye SUN-20K. Unit ID dle DIP přepínače.' +SELECT id, 'modbus_tcp', '172.16.1.10', 502, 'modbus_tcp', 1, true, 'Waveshare WS-ETH pro Deye SUN-20K. Unit ID dle DIP přepínače.' FROM ems.site WHERE code = 'home-01'; -- Teltonika EV nabíječka 1 přes Waveshare @@ -88,7 +88,7 @@ INSERT INTO ems.asset_inverter (site_id, code, manufacturer, model, endpoint_id, SELECT s.id, 'deye-main', 'Deye', 'SUN-20K-SG01LP1-EU', ep.id, - 20000, 20000, 20000, + 18000, 18000, 18000, true, 'Hlavní hybridní střídač 20kW LV. RS485 Modbus RTU přes Waveshare.' FROM ems.site s @@ -129,8 +129,8 @@ SELECT s.id, inv.id, 'pv-a', 'FVE pole A', 10000, -- 10 kWp 184, - 35, -- sklon odhad; upřesnit dle střechy - NULL, + 22, -- sklon odhad; upřesnit dle střechy + 18, 1.0, true, 'Hlavní FVE pole řízené Deye střídačem.' diff --git a/db/migration/V005__planning_curtailment.sql b/db/migration/V005__planning_curtailment.sql index 3e8b124..544a929 100644 --- a/db/migration/V005__planning_curtailment.sql +++ b/db/migration/V005__planning_curtailment.sql @@ -41,11 +41,7 @@ COMMENT ON COLUMN ems.site_market_config.green_bonus_asset_code IS 'Kód FVE pole (asset_pv_array.code) na které se zelený bonus vztahuje. ' 'Příklad: pv-b. NULL = bonus se nevztahuje na žádné konkrétní pole.'; --- Seed: doplnit zelený bonus pro home-01 --- (hodnota bonusu bude upřesněna dle smlouvy s OTE/ERU) -UPDATE ems.site_market_config -SET - green_bonus_czk_kwh = 1.20, -- TODO: doplnit skutečnou výši bonusu ze smlouvy - green_bonus_asset_code = 'pv-b' -WHERE site_id = (SELECT id FROM ems.site WHERE code = 'home-01') - AND valid_to IS NULL; +-- Seed zeleného bonusu přesunut: V017__green_bonus.sql (ems.asset_pv_array.green_bonus_*). +-- Sloupce green_bonus_* na site_market_config odstraňuje V018__cleanup_legacy_green_bonus.sql; +-- UPDATE zde by při změně pořadí / rebuild konfliktních migrací selhal. +-- UPDATE ems.site_market_config SET green_bonus_czk_kwh = 1.20, green_bonus_asset_code = 'pv-b' ... diff --git a/db/migration/V012__telemetry_inverter_columns.sql b/db/migration/V012__telemetry_inverter_columns.sql new file mode 100644 index 0000000..643cde4 --- /dev/null +++ b/db/migration/V012__telemetry_inverter_columns.sql @@ -0,0 +1,29 @@ +-- Nové sloupce telemetrie Deye (GEN port, PV1/PV2, denní energie baterie, run_state). +-- V011 je již použito pro indexy/agregace. + +ALTER TABLE ems.telemetry_inverter + ADD COLUMN IF NOT EXISTS pv1_power_w INT, + ADD COLUMN IF NOT EXISTS pv2_power_w INT, + ADD COLUMN IF NOT EXISTS gen_port_power_w INT, + ADD COLUMN IF NOT EXISTS batt_charge_today_wh INT, + ADD COLUMN IF NOT EXISTS batt_discharge_today_wh INT, + ADD COLUMN IF NOT EXISTS run_state INT; + +COMMENT ON COLUMN ems.telemetry_inverter.pv1_power_w IS + 'Výkon PV1 vstupu W (Deye holding register).'; +COMMENT ON COLUMN ems.telemetry_inverter.pv2_power_w IS + 'Výkon PV2 vstupu W (Deye holding register).'; +COMMENT ON COLUMN ems.telemetry_inverter.gen_port_power_w IS + 'Výkon GEN portu W – výroba FVE pole B (ongridový střídač). +Nelze řídit, jen měřit. Klíčový pro audit zeleného bonusu.'; +COMMENT ON COLUMN ems.telemetry_inverter.batt_charge_today_wh IS + 'Dnešní nabití baterie Wh (denní čítač z Modbus).'; +COMMENT ON COLUMN ems.telemetry_inverter.batt_discharge_today_wh IS + 'Dnešní vybití baterie Wh (denní čítač z Modbus).'; +COMMENT ON COLUMN ems.telemetry_inverter.run_state IS + 'Provozní stav střídače (raw enum z Modbus registru run_state).'; + +COMMENT ON COLUMN ems.telemetry_inverter.battery_power_w IS + 'Výkon baterie v W (signed). Kladné = vybíjení, záporné = nabíjení (mapování dle registru 590).'; +COMMENT ON COLUMN ems.telemetry_inverter.pv_power_w IS + 'Součet okamžitého výkonu FVE stringů na střídači (typicky PV1+PV2) v W.'; diff --git a/db/migration/V013__predicted_negative_price_window.sql b/db/migration/V013__predicted_negative_price_window.sql new file mode 100644 index 0000000..42aba04 --- /dev/null +++ b/db/migration/V013__predicted_negative_price_window.sql @@ -0,0 +1,20 @@ +-- Predikovaná okna záporných spotových cen (cache pro UI / API) + +CREATE TABLE ems.predicted_negative_price_window ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site (id), + predicted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + predicted_date DATE NOT NULL, + window_start_hour INT NOT NULL, + window_end_hour INT NOT NULL, + probability_pct INT NOT NULL, + expected_min_price NUMERIC(10, 4), + reason TEXT, + UNIQUE (site_id, predicted_date, window_start_hour) +); + +COMMENT ON TABLE ems.predicted_negative_price_window IS +'Výstup ems.fn_predict_negative_price_windows – predikce intervalů s rizikem záporné nákupní ceny; obnovovat po importu cen a forecastu FVE.'; + +CREATE INDEX idx_predicted_neg_price_site_date + ON ems.predicted_negative_price_window (site_id, predicted_date); diff --git a/db/migration/V014__asset_model_refinement.sql b/db/migration/V014__asset_model_refinement.sql new file mode 100644 index 0000000..35597fe --- /dev/null +++ b/db/migration/V014__asset_model_refinement.sql @@ -0,0 +1,78 @@ +-- ============================================================= +-- V014__asset_model_refinement.sql +-- Rozlišení limitů: AC střídač / DC FVE / baterie přes měnič / BMS+C-rate +-- ============================================================= + +-- Střídač: nové sloupce (staré max_charge_power_w / max_discharge_power_w ponechány kvůli kompatibilitě) +ALTER TABLE ems.asset_inverter + ADD COLUMN IF NOT EXISTS max_ac_output_w INT, + ADD COLUMN IF NOT EXISTS max_dc_input_w INT, + ADD COLUMN IF NOT EXISTS max_battery_charge_w INT, + ADD COLUMN IF NOT EXISTS max_battery_discharge_w INT, + ADD COLUMN IF NOT EXISTS gen_port_max_power_w INT; + +COMMENT ON COLUMN ems.asset_inverter.max_ac_output_w IS +'Maximální AC výkon střídače v W. Deye SUN-20K = 22000 W.'; +COMMENT ON COLUMN ems.asset_inverter.max_dc_input_w IS +'Maximální DC vstupní výkon z FVE v W. Deye SUN-20K = 40000 W.'; +COMMENT ON COLUMN ems.asset_inverter.max_battery_charge_w IS +'Maximální výkon nabíjení baterie v W – limit střídače (ne BMS). +Deye SUN-20K s LV baterií = 18000 W (350A × 51.2V).'; +COMMENT ON COLUMN ems.asset_inverter.max_battery_discharge_w IS +'Maximální výkon vybíjení baterie v W – limit střídače. +Deye SUN-20K s LV baterií = 18000 W.'; +COMMENT ON COLUMN ems.asset_inverter.gen_port_max_power_w IS +'Maximální výkon GEN portu v W. Zahrnuje součet všech zařízení +zapojených do GEN portu (mikroinvertory, ongrid střídač). +Pro home-01 = 10080 W (pv-b pole). +Pro druhou instalaci = 4400 W (2× 2.2 kW mikroinvertory).'; + +-- Baterie: C-rate a BMS +ALTER TABLE ems.asset_battery + ADD COLUMN IF NOT EXISTS max_charge_c_rate NUMERIC(4,2), + ADD COLUMN IF NOT EXISTS max_discharge_c_rate NUMERIC(4,2), + ADD COLUMN IF NOT EXISTS bms_max_charge_w INT, + ADD COLUMN IF NOT EXISTS bms_max_discharge_w INT; + +COMMENT ON COLUMN ems.asset_battery.max_charge_c_rate IS +'Maximální nabíjecí C-rate. 0.5C pro 64 kWh = 32 kW teoretické maximum. +Skutečný limit je min(bms_max_charge_w, inverter.max_battery_charge_w).'; +COMMENT ON COLUMN ems.asset_battery.max_discharge_c_rate IS +'Maximální vybíjecí C-rate (symetricky k nabíjení).'; +COMMENT ON COLUMN ems.asset_battery.bms_max_charge_w IS +'Maximální nabíjecí výkon dle BMS v W. Pokud NULL, použij C-rate výpočet.'; +COMMENT ON COLUMN ems.asset_battery.bms_max_discharge_w IS +'Maximální vybíjecí výkon dle BMS v W. Pokud NULL, použij C-rate výpočet.'; + +-- Z existujících sloupců přejmenujeme sémantiku do nových (kde ještě nejsou vyplněné) +UPDATE ems.asset_inverter +SET + max_battery_charge_w = COALESCE(max_battery_charge_w, max_charge_power_w), + max_battery_discharge_w = COALESCE(max_battery_discharge_w, max_discharge_power_w) +WHERE max_charge_power_w IS NOT NULL + OR max_discharge_power_w IS NOT NULL; + +-- Seed home-01: hlavní Deye (ne ongrid řádek) +UPDATE ems.asset_inverter inv +SET + max_ac_output_w = 22000, + max_dc_input_w = 40000, + max_battery_charge_w = 18000, + max_battery_discharge_w = 18000, + gen_port_max_power_w = 10080 +FROM ems.site s +WHERE s.id = inv.site_id + AND s.code = 'home-01' + AND inv.code = 'deye-main'; + +UPDATE ems.asset_battery ab +SET + max_charge_c_rate = 0.28, + max_discharge_c_rate = 0.28, + bms_max_charge_w = 18000, + bms_max_discharge_w = 18000 +FROM ems.asset_inverter inv +JOIN ems.site s ON s.id = inv.site_id +WHERE ab.inverter_id = inv.id + AND s.code = 'home-01' + AND inv.code = 'deye-main'; diff --git a/db/migration/V015__distribution_tariff.sql b/db/migration/V015__distribution_tariff.sql new file mode 100644 index 0000000..e93fbd2 --- /dev/null +++ b/db/migration/V015__distribution_tariff.sql @@ -0,0 +1,100 @@ +-- ============================================================ +-- Distribuční tarify a HDO +-- ============================================================ + +CREATE TABLE ems.distribution_tariff ( + id SERIAL PRIMARY KEY, + distributor TEXT NOT NULL, -- 'EGD', 'CEZ', 'PRE' + code TEXT NOT NULL, -- 'D02d', 'C25d', 'custom_fve' + name TEXT NOT NULL, + has_dual_rate BOOLEAN NOT NULL DEFAULT true, -- NT/VT nebo jednotarif + vat_rate NUMERIC(5,4) NOT NULL DEFAULT 0.21, + notes TEXT, + UNIQUE (distributor, code) +); + +COMMENT ON TABLE ems.distribution_tariff IS +'Číselník distribučních tarifů. Jeden záznam = jeden tarif jednoho distributora. +has_dual_rate=true znamená NT/VT dvojsazba, false = jednotarif.'; + +-- ============================================================ + +CREATE TABLE ems.distribution_tariff_rate ( + id SERIAL PRIMARY KEY, + tariff_id INT NOT NULL REFERENCES ems.distribution_tariff(id), + rate_type TEXT NOT NULL, -- 'NT', 'VT', 'single' + price_czk_kwh NUMERIC(10,6) NOT NULL, -- variabilní složka bez DPH + valid_from DATE NOT NULL, + valid_to DATE, -- NULL = platí dosud + notes TEXT, + UNIQUE (tariff_id, rate_type, valid_from) +); + +COMMENT ON TABLE ems.distribution_tariff_rate IS +'Sazby distribučního tarifu Kč/kWh bez DPH. Verzováno přes valid_from/valid_to. +Při roční změně tarifů: nastav valid_to na starém záznamu a přidej nový. +price_czk_kwh = pouze variabilní distribuce, BEZ systémových služeb a OTE.'; + +COMMENT ON COLUMN ems.distribution_tariff_rate.price_czk_kwh IS +'Variabilní distribuční složka Kč/kWh bez DPH. +Nezahrnuje: systémové služby ČEPS, poplatek OTE, silovou elektřinu (spot). +Tyto ostatní fixní složky jsou v site_market_config jako system_services_czk_kwh.'; + +-- ============================================================ + +CREATE TABLE ems.hdo_code ( + id SERIAL PRIMARY KEY, + distributor TEXT NOT NULL, -- 'EGD', 'CEZ', 'PRE' + code TEXT NOT NULL, -- 'B1', 'C3', 'custom_fve_home01' + description TEXT, + valid_from DATE NOT NULL, + valid_to DATE, -- NULL = platí dosud + notes TEXT, + UNIQUE (distributor, code, valid_from) +); + +COMMENT ON TABLE ems.hdo_code IS +'Číselník HDO kódů per distributor. Při roční změně přidat nový záznam +s novým valid_from – starý zůstane v historii pro audit. +Kód "custom_fve_home01" pro FVE instalace bez standardního HDO kódu.'; + +-- ============================================================ + +CREATE TABLE ems.hdo_code_window ( + id SERIAL PRIMARY KEY, + hdo_code_id INT NOT NULL REFERENCES ems.hdo_code(id), + day_type TEXT NOT NULL DEFAULT 'all', + -- 'all' = každý den, 'workday' = Po-Pá, 'weekend' = So-Ne + rate_type TEXT NOT NULL DEFAULT 'VT', -- 'VT' nebo 'NT' + window_from TIME NOT NULL, -- začátek okna (inclusive) + window_to TIME NOT NULL -- konec okna (exclusive) +); + +COMMENT ON TABLE ems.hdo_code_window IS +'NT/VT časová okna pro HDO kód. Více řádků = více oken za den. +Logika: pokud aktuální čas spadá do VT okna → rate_type=VT, jinak NT. +day_type=all znamená stejná okna každý den (workday i weekend). +Příklad home-01: VT 09:00-10:00, 12:00-13:00, 16:00-17:00, 20:00-21:00.'; + +-- ============================================================ +-- Rozšíření site_market_config +-- ============================================================ + +ALTER TABLE ems.site_market_config + ADD COLUMN IF NOT EXISTS tariff_id INT REFERENCES ems.distribution_tariff(id), + ADD COLUMN IF NOT EXISTS hdo_code_id INT REFERENCES ems.hdo_code(id), + ADD COLUMN IF NOT EXISTS system_services_czk_kwh NUMERIC(10,6) DEFAULT 0, + ADD COLUMN IF NOT EXISTS ote_fee_czk_kwh NUMERIC(10,6) DEFAULT 0; + +COMMENT ON COLUMN ems.site_market_config.tariff_id IS +'Distribuční tarif přiřazený k této lokalitě. FK na distribution_tariff.'; +COMMENT ON COLUMN ems.site_market_config.hdo_code_id IS +'HDO kód přiřazený k této lokalitě. Určuje NT/VT časová okna. +Při změně HDO předpisu stačí přidat nový hdo_code záznam a aktualizovat FK.'; +COMMENT ON COLUMN ems.site_market_config.system_services_czk_kwh IS +'Součet systémových poplatků Kč/kWh bez DPH: +systémové služby ČEPS + poplatek OTE + příspěvek na OZE. +Orientační hodnota EG.D 2025: ~0.40 Kč/kWh. Doplnit ze smlouvy/faktury.'; +COMMENT ON COLUMN ems.site_market_config.ote_fee_czk_kwh IS +'Poplatek OTE za použití trhu Kč/kWh bez DPH. +Orientačně ~0.001 Kč/kWh. Lze zahrnout do system_services_czk_kwh.'; diff --git a/db/migration/V016__seed_distribution_home01.sql b/db/migration/V016__seed_distribution_home01.sql new file mode 100644 index 0000000..bbb2294 --- /dev/null +++ b/db/migration/V016__seed_distribution_home01.sql @@ -0,0 +1,55 @@ +-- EG.D tarif pro home-01 (FVE speciální režim) +INSERT INTO ems.distribution_tariff + (distributor, code, name, has_dual_rate, vat_rate, notes) +VALUES + ('EGD', 'custom_fve_home01', + 'EG.D FVE vlastní spotřeba – dvojsazba', + true, 0.21, + 'Speciální tarif pro FVE instalaci home-01. VT okna dle smlouvy.'); + +-- Sazby – placeholder hodnoty, doplnit ze smlouvy/faktury +INSERT INTO ems.distribution_tariff_rate + (tariff_id, rate_type, price_czk_kwh, valid_from) +SELECT + id, 'NT', 0.2243, '2025-01-01' +FROM ems.distribution_tariff WHERE distributor='EGD' AND code='custom_fve_home01'; + +INSERT INTO ems.distribution_tariff_rate + (tariff_id, rate_type, price_czk_kwh, valid_from) +SELECT + id, 'VT', 0.74987, '2025-01-01' +FROM ems.distribution_tariff WHERE distributor='EGD' AND code='custom_fve_home01'; + +-- HDO kód pro home-01 +INSERT INTO ems.hdo_code + (distributor, code, description, valid_from, notes) +VALUES + ('EGD', 'custom_fve_home01', + 'FVE home-01 – VT okna 09-10, 12-13, 16-17, 20-21 (každý den)', + '2025-01-01', + 'Platí stejně workday i weekend. Při změně přidat nový záznam s novým valid_from.'); + +-- VT okna (4 okna, každý den) +INSERT INTO ems.hdo_code_window + (hdo_code_id, day_type, rate_type, window_from, window_to) +SELECT + hc.id, 'all', 'VT', w.wf::TIME, w.wt::TIME +FROM ems.hdo_code hc, +(VALUES + ('09:00', '10:00'), + ('12:00', '13:00'), + ('16:00', '17:00'), + ('20:00', '21:00') +) AS w(wf, wt) +WHERE hc.distributor = 'EGD' AND hc.code = 'custom_fve_home01'; + +-- Napojit home-01 na tarif a HDO kód +UPDATE ems.site_market_config SET + tariff_id = (SELECT id FROM ems.distribution_tariff + WHERE distributor='EGD' AND code='custom_fve_home01'), + hdo_code_id = (SELECT id FROM ems.hdo_code + WHERE distributor='EGD' AND code='custom_fve_home01' + ORDER BY valid_from DESC LIMIT 1), + system_services_czk_kwh = 0.192, + ote_fee_czk_kwh = 0.001 +WHERE site_id = (SELECT id FROM ems.site WHERE code='home-01'); diff --git a/db/migration/V017__green_bonus.sql b/db/migration/V017__green_bonus.sql new file mode 100644 index 0000000..21317f7 --- /dev/null +++ b/db/migration/V017__green_bonus.sql @@ -0,0 +1,47 @@ +-- ============================================================= +-- V017__green_bonus.sql +-- Zelený bonus na úrovni FVE pole (asset_pv_array), ne v prodejní ceně +-- ============================================================= + +ALTER TABLE ems.asset_pv_array + ADD COLUMN IF NOT EXISTS green_bonus_czk_kwh NUMERIC(10,6), + ADD COLUMN IF NOT EXISTS green_bonus_valid_from DATE, + ADD COLUMN IF NOT EXISTS green_bonus_valid_to DATE, + ADD COLUMN IF NOT EXISTS green_bonus_meter_code TEXT; + +COMMENT ON COLUMN ems.asset_pv_array.green_bonus_czk_kwh IS +'Aktuální sazba zeleného bonusu Kč/kWh za vyrobenou elektřinu. +NULL = pole nemá zelený bonus. Bonus se počítá z celkové výroby pole +bez ohledu na to kam energie šla (interní spotřeba i export). +Sazba se mění ročně – při změně nastav green_bonus_valid_to na starém +záznamu a aktualizuj na novou hodnotu s novým green_bonus_valid_from.'; + +COMMENT ON COLUMN ems.asset_pv_array.green_bonus_valid_from IS +'Datum od kdy platí aktuální sazba zeleného bonusu (včetně).'; + +COMMENT ON COLUMN ems.asset_pv_array.green_bonus_valid_to IS +'Datum do kdy platí aktuální sazba zeleného bonusu (exclusive). +NULL = platí dosud. Při roční změně nastav na první den nového roku +a aktualizuj green_bonus_czk_kwh na novou sazbu.'; + +COMMENT ON COLUMN ems.asset_pv_array.green_bonus_meter_code IS +'Číslo zeleného elektroměru (EAN nebo číslo ze smlouvy s distributorem). +Slouží pro audit – bonus se počítá z odečtů tohoto elektroměru.'; + +ALTER TABLE ems.audit_interval + ADD COLUMN IF NOT EXISTS green_bonus_czk NUMERIC(10,4) DEFAULT 0; + +COMMENT ON COLUMN ems.audit_interval.green_bonus_czk IS +'Příjem ze zeleného bonusu za výrobu bonusových FVE polí v Kč. +Počítáno přes fn_green_bonus_revenue() v audit_filler. +Nezahrnuto v actual_cost_czk – je to samostatný příjem.'; + +-- Seed home-01: zelený bonus jen na pv-b (ongrid střídač na GEN portu) +UPDATE ems.asset_pv_array +SET + green_bonus_czk_kwh = 7.135, -- TODO: doplnit skutečnou sazbu ze smlouvy + green_bonus_valid_from = '2026-01-01', + green_bonus_valid_to = NULL, -- platí dosud + green_bonus_meter_code = 'TODO' -- doplnit EAN zeleného elektroměru +WHERE site_id = (SELECT id FROM ems.site WHERE code = 'home-01') + AND code = 'pv-b'; diff --git a/db/migration/V018__cleanup_legacy_green_bonus.sql b/db/migration/V018__cleanup_legacy_green_bonus.sql new file mode 100644 index 0000000..a79fc34 --- /dev/null +++ b/db/migration/V018__cleanup_legacy_green_bonus.sql @@ -0,0 +1,13 @@ +-- ============================================================= +-- V018__cleanup_legacy_green_bonus.sql +-- Odstranění legacy sloupců zeleného bonusu ze site_market_config (nahrazeno V017 asset_pv_array) +-- ============================================================= + +ALTER TABLE ems.site_market_config + DROP COLUMN IF EXISTS green_bonus_czk_kwh, + DROP COLUMN IF EXISTS green_bonus_asset_code; + +COMMENT ON TABLE ems.site_market_config IS +'Konfigurace tržního prostředí per site. +Zelený bonus je od V017 na ems.asset_pv_array (green_bonus_czk_kwh), +nikoliv zde – bonus je vlastností fyzického FVE pole, ne site konfigurace.'; diff --git a/db/migration/V019__forecast_accuracy.sql b/db/migration/V019__forecast_accuracy.sql new file mode 100644 index 0000000..cde8fd8 --- /dev/null +++ b/db/migration/V019__forecast_accuracy.sql @@ -0,0 +1,43 @@ +-- ============================================================ +-- Tabulka pro tracking přesnosti forecastu +-- ============================================================ + +CREATE TABLE ems.forecast_accuracy ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + pv_array_id INT NOT NULL REFERENCES ems.asset_pv_array(id), + interval_start TIMESTAMPTZ NOT NULL, + run_id INT NOT NULL REFERENCES ems.forecast_pv_run(id), + -- Forecast hodnoty + forecast_power_w INT NOT NULL, + forecast_created_at TIMESTAMPTZ NOT NULL, + lead_time_hours NUMERIC(6,2), -- kolik hodin předem byl forecast vytvořen + -- Skutečnost (doplněna zpětně z telemetrie) + actual_power_w INT, + actual_filled_at TIMESTAMPTZ, + -- Odchylka + error_w INT, -- forecast - actual + error_pct NUMERIC(8,4), -- (forecast - actual) / actual * 100 + UNIQUE (run_id, interval_start) +); + +COMMENT ON TABLE ems.forecast_accuracy IS +'Tracking přesnosti FVE forecastu. Každý řádek = jeden 15min slot +z jednoho forecast runu. actual_power_w se doplňuje zpětně z telemetrie +po uplynutí intervalu přes fn_fill_forecast_accuracy(). +Uchovávat navždy – slouží pro analýzu přesnosti a budoucí kalibraci solveru.'; + +COMMENT ON COLUMN ems.forecast_accuracy.lead_time_hours IS +'Kolik hodin předem byl tento forecast vytvořen. +Příklad: forecast vytvořen v pondělí 14:00, interval ve středu 12:00 = 46h. +Slouží pro analýzu: je 6h forecast přesnější než 48h forecast?'; + +COMMENT ON COLUMN ems.forecast_accuracy.error_pct IS +'Relativní chyba v %. Kladná = forecast nadhodnotil, záporná = podhodnotil. +NULL pokud actual_power_w = 0 (zamezení dělení nulou).'; + +CREATE INDEX idx_forecast_accuracy_site_time + ON ems.forecast_accuracy (site_id, interval_start DESC); + +CREATE INDEX idx_forecast_accuracy_array_lead + ON ems.forecast_accuracy (pv_array_id, lead_time_hours, interval_start DESC); diff --git a/db/migration/V020__ev_arrival_stats.sql b/db/migration/V020__ev_arrival_stats.sql new file mode 100644 index 0000000..5d042f1 --- /dev/null +++ b/db/migration/V020__ev_arrival_stats.sql @@ -0,0 +1,30 @@ +-- Statistika příjezdů EV (den v týdnu × hodina); plní telemetry_collector při přechodu available → nabíjení. + +CREATE TABLE ems.ev_arrival_stats ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + vehicle_id INT REFERENCES ems.asset_vehicle(id), + charger_id INT NOT NULL REFERENCES ems.asset_ev_charger(id), + day_of_week INT NOT NULL, + arrival_hour INT NOT NULL, + sample_count INT NOT NULL DEFAULT 0, + last_updated TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_ev_arrival_stats_dow CHECK (day_of_week >= 0 AND day_of_week <= 6), + CONSTRAINT chk_ev_arrival_stats_hour CHECK (arrival_hour >= 0 AND arrival_hour <= 23), + UNIQUE (site_id, charger_id, day_of_week, arrival_hour) +); + +CREATE INDEX idx_ev_arrival_stats_site_charger + ON ems.ev_arrival_stats (site_id, charger_id); + +COMMENT ON TABLE ems.ev_arrival_stats IS +'Statistika příjezdů EV dle dne v týdnu a hodiny (časová zóna Europe/Prague). +Plní se z ev_session / telemetrie při detekci připojení (available → preparing/charging). +Po ~4 týdnech dat lze odhadovat typickou hodinu příjezdu.'; + +-- Nejvýše jedna otevřená session na nabíječku (pro INSERT … ON CONFLICT při startu session). +CREATE UNIQUE INDEX uidx_ev_session_charger_open + ON ems.ev_session (charger_id) + WHERE session_end IS NULL; + +GRANT SELECT ON ems.ev_arrival_stats TO ems_anon; diff --git a/db/migration/V021__baseline_consumption.sql b/db/migration/V021__baseline_consumption.sql new file mode 100644 index 0000000..e8e373b --- /dev/null +++ b/db/migration/V021__baseline_consumption.sql @@ -0,0 +1,27 @@ +-- Historické průměry bazální spotřeby (DOW + hodina) pro solver a forecast. + +CREATE TABLE ems.consumption_baseline_stats ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + day_of_week INT NOT NULL, -- 0=neděle, 1=pondělí... 6=sobota + hour_of_day INT NOT NULL, -- 0-23 + avg_power_w NUMERIC(10,2) NOT NULL, + stddev_power_w NUMERIC(10,2), + sample_count INT NOT NULL DEFAULT 0, + last_updated TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (site_id, day_of_week, hour_of_day) +); + +COMMENT ON TABLE ems.consumption_baseline_stats IS +'Historické průměry bazální spotřeby per den v týdnu a hodinu. +Plní se automaticky z telemetrie přes fn_update_baseline_stats(). +Bazální = load_power_w - ev - tc (bez řízených zátěží). +Používá se jako vstup do solveru pro predikci spotřeby.'; + +COMMENT ON COLUMN ems.consumption_baseline_stats.avg_power_w IS +'Průměrný výkon bazální spotřeby W pro daný DOW+hodinu. +Exponenciální klouzavý průměr – nová data mají větší váhu.'; + +COMMENT ON COLUMN ems.consumption_baseline_stats.stddev_power_w IS +'Směrodatná odchylka W – míra variability spotřeby. +Lze použít pro konzervativní odhad: avg + 0.5*stddev.'; diff --git a/db/migration/V022__extended_planning.sql b/db/migration/V022__extended_planning.sql new file mode 100644 index 0000000..5af1904 --- /dev/null +++ b/db/migration/V022__extended_planning.sql @@ -0,0 +1,38 @@ +-- Rozšířený horizont plánování: statistiky cen a TUV pro predikce za horizont OTE. + +CREATE TABLE ems.market_price_stats ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + day_of_week INT NOT NULL, + hour_of_day INT NOT NULL, + avg_price NUMERIC(10,6) NOT NULL, + stddev_price NUMERIC(10,6), + p25_price NUMERIC(10,6), + p75_price NUMERIC(10,6), + sample_count INT NOT NULL DEFAULT 0, + last_updated TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (site_id, day_of_week, hour_of_day) +); + +COMMENT ON TABLE ems.market_price_stats IS +'Historické průměry spotové ceny OTE per DOW+hodina. +Analogie consumption_baseline_stats pro ceny. +Používá se pro predikci cen za horizont OTE (36h+). +Min. 3 měsíce dat pro smysluplné průměry.'; + +CREATE TABLE ems.tuv_usage_stats ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + day_of_week INT NOT NULL, + hour_of_day INT NOT NULL, + avg_temp_delta_c NUMERIC(6,3) NOT NULL, + stddev_temp_delta NUMERIC(6,3), + sample_count INT NOT NULL DEFAULT 0, + last_updated TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (site_id, day_of_week, hour_of_day) +); + +COMMENT ON TABLE ems.tuv_usage_stats IS +'Průměrná změna teploty TUV zásobníku per DOW+hodina. +Záporná hodnota = zásobník se ochlazuje (spotřeba teplé vody). +Kladná = TČ ohřívalo. Používá se pro predikci kdy bude potřeba ohřev.'; diff --git a/db/migration/V023__modbus_command_journal.sql b/db/migration/V023__modbus_command_journal.sql new file mode 100644 index 0000000..e795b33 --- /dev/null +++ b/db/migration/V023__modbus_command_journal.sql @@ -0,0 +1,54 @@ +-- Modbus command journal + cut-off switch audit (EMS) + +CREATE TABLE ems.modbus_command ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + asset_type TEXT NOT NULL, -- 'inverter', 'ev_charger', 'heat_pump', 'cutoff' + asset_id INT NOT NULL, -- ID v příslušné asset tabulce + asset_code TEXT NOT NULL, -- např. 'deye-main' (denorm. pro čitelnost) + device_host TEXT NOT NULL, + device_port INT NOT NULL, + device_unit_id INT NOT NULL, + register INT NOT NULL, -- číslo registru (decimal) + register_name TEXT, -- 'export_limit', 'charge_limit' (pro čitelnost) + value_to_write INT NOT NULL, + value_written INT, -- skutečně zapsaná hodnota (NULL = nezapsáno) + value_verified INT, -- přečtená hodnota po verifikaci (NULL = neověřeno) + status TEXT NOT NULL DEFAULT 'pending', + -- 'pending', 'written', 'verified', 'failed', 'mismatch', 'retrying' + planning_run_id INT REFERENCES ems.planning_run(id), + attempt_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + written_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + error_msg TEXT +); + +COMMENT ON TABLE ems.modbus_command IS +'Command journal pro Modbus zápisy do zařízení. +Každý zápis = jeden řádek. Verifikační job ověří value_verified == value_to_write. +Při mismatch: retry max 3× → přepnout na SELF_SUSTAIN + Discord alert. +asset_type+asset_id odkazuje na příslušnou asset tabulku (inverter/ev_charger/...).'; + +CREATE INDEX idx_modbus_command_status + ON ems.modbus_command (site_id, status, created_at DESC); + +CREATE INDEX idx_modbus_command_pending + ON ems.modbus_command (status, created_at) + WHERE status IN ('pending', 'retrying'); + +CREATE TABLE ems.cutoff_switch_log ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + asset_code TEXT NOT NULL, -- 'cutoff-pv-b', 'cutoff-microinverter' + switched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + new_state BOOLEAN NOT NULL, -- true=zapnuto/připojeno, false=odpojeno + previous_state BOOLEAN, + reason TEXT NOT NULL, -- 'negative_sell_price', 'manual', 'auto_restore' + sell_price_czk NUMERIC(10,6), -- spotová cena v době přepnutí + triggered_by TEXT -- 'control_exporter', 'user:jan', 'system' +); + +COMMENT ON TABLE ems.cutoff_switch_log IS +'Log přepnutí cut-off přepínačů. Loguje se jen při změně stavu (edge trigger). +Používá se pro mikroinvertory na GEN portu při záporných prodejních cenách.'; diff --git a/db/migration/V024__planning_predicted_price.sql b/db/migration/V024__planning_predicted_price.sql new file mode 100644 index 0000000..d05953d --- /dev/null +++ b/db/migration/V024__planning_predicted_price.sql @@ -0,0 +1,8 @@ +-- Označení intervalů plánu, kde solver použil predikovanou cenu (mimo přesné OTE v efektivní ceně). + +ALTER TABLE ems.planning_interval + ADD COLUMN IF NOT EXISTS is_predicted_price BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN ems.planning_interval.is_predicted_price IS +'True pokud cena pro tento slot pochází z predikce (market_price_stats) +a ne z přesných OTE dat. Sloty > 36h od now() při daily_plan běhu.'; diff --git a/db/migration/V025__deye_physical_mode.sql b/db/migration/V025__deye_physical_mode.sql new file mode 100644 index 0000000..e3b87af --- /dev/null +++ b/db/migration/V025__deye_physical_mode.sql @@ -0,0 +1,8 @@ +-- Fyzický režim Deye u záznamů journalu (PASSIVE / SELL / CHARGE) + +ALTER TABLE ems.modbus_command + ADD COLUMN IF NOT EXISTS deye_physical_mode TEXT; + +COMMENT ON COLUMN ems.modbus_command.deye_physical_mode IS +'Fyzický režim Deye při zápisu: PASSIVE / SELL / CHARGE. +Slouží pro audit a analýzu přepínání režimů.'; diff --git a/db/migration/V026__battery_economics_tuning.sql b/db/migration/V026__battery_economics_tuning.sql new file mode 100644 index 0000000..7d177de --- /dev/null +++ b/db/migration/V026__battery_economics_tuning.sql @@ -0,0 +1,11 @@ +-- Tune battery economics for planning behavior: +-- - lower reserve SOC to allow economically justified discharge +-- - lower degradation cost to avoid overly conservative cycling +-- +-- Idempotent update for currently deployed sites. +UPDATE ems.asset_battery +SET + reserve_soc_percent = 10.00, + degradation_cost_czk_kwh = 0.1500 +WHERE reserve_soc_percent <> 10.00 + OR degradation_cost_czk_kwh <> 0.1500; diff --git a/db/routines/R__fn_baseline_consumption.sql b/db/routines/R__fn_baseline_consumption.sql new file mode 100644 index 0000000..5aeee96 --- /dev/null +++ b/db/routines/R__fn_baseline_consumption.sql @@ -0,0 +1,118 @@ +CREATE OR REPLACE FUNCTION ems.fn_update_baseline_stats( + p_site_id INT, + p_lookback_days INT DEFAULT 30 +) +RETURNS INT +LANGUAGE plpgsql +AS $$ +DECLARE + v_count INT; +BEGIN + WITH raw AS ( + SELECT + EXTRACT(DOW FROM ti.measured_at AT TIME ZONE 'Europe/Prague')::INT AS dow, + EXTRACT(HOUR FROM ti.measured_at AT TIME ZONE 'Europe/Prague')::INT AS hour, + GREATEST(0, + ti.load_power_w + - COALESCE(( + SELECT AVG(tev.power_w) + FROM ems.telemetry_ev_charger tev + WHERE tev.site_id = ti.site_id + AND tev.measured_at BETWEEN + ti.measured_at - INTERVAL '30 seconds' + AND ti.measured_at + INTERVAL '30 seconds' + ), 0)::INT + - COALESCE(( + SELECT AVG(thp.power_w) + FROM ems.telemetry_heat_pump thp + WHERE thp.site_id = ti.site_id + AND thp.measured_at BETWEEN + ti.measured_at - INTERVAL '30 seconds' + AND ti.measured_at + INTERVAL '30 seconds' + ), 0)::INT + ) AS baseline_w + FROM ems.telemetry_inverter ti + WHERE ti.site_id = p_site_id + AND ti.measured_at >= now() - make_interval(days => p_lookback_days) + AND ti.load_power_w IS NOT NULL + AND ti.load_power_w > 0 + ), + agg AS ( + SELECT + dow, + hour, + AVG(baseline_w) AS avg_w, + STDDEV(baseline_w) AS stddev_w, + COUNT(*) AS samples + FROM raw + GROUP BY dow, hour + HAVING COUNT(*) >= 4 + ) + INSERT INTO ems.consumption_baseline_stats + (site_id, day_of_week, hour_of_day, + avg_power_w, stddev_power_w, sample_count, last_updated) + SELECT + p_site_id, dow, hour, + ROUND(avg_w::NUMERIC, 2), + ROUND(stddev_w::NUMERIC, 2), + samples, + now() + FROM agg + ON CONFLICT (site_id, day_of_week, hour_of_day) DO UPDATE SET + avg_power_w = ROUND( + 0.7 * ems.consumption_baseline_stats.avg_power_w + + 0.3 * EXCLUDED.avg_power_w, 2), + stddev_power_w = ROUND( + COALESCE(0.7 * ems.consumption_baseline_stats.stddev_power_w + + 0.3 * EXCLUDED.stddev_power_w, + EXCLUDED.stddev_power_w), 2), + sample_count = ems.consumption_baseline_stats.sample_count + + EXCLUDED.sample_count, + last_updated = now(); + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION ems.fn_update_baseline_stats(INT, INT) IS +'Aktualizuje průměry bazální spotřeby z telemetrie posledních N dní. +Používá exponenciální klouzavý průměr (EMA 70/30) pro postupné zpřesňování. +Volat denně po půlnoci. Pro první naplnění: fn_update_baseline_stats(2, 90).'; + + +CREATE OR REPLACE FUNCTION ems.fn_get_baseline_forecast( + p_site_id INT, + p_from TIMESTAMPTZ, + p_to TIMESTAMPTZ +) +RETURNS TABLE ( + interval_start TIMESTAMPTZ, + forecast_w INT, + confidence_w INT +) +LANGUAGE sql +STABLE +AS $$ + SELECT + gs.slot AS interval_start, + COALESCE(cbs.avg_power_w, 500)::INT AS forecast_w, + COALESCE( + cbs.avg_power_w + 0.5 * COALESCE(cbs.stddev_power_w, 100), + 550 + )::INT AS confidence_w + FROM generate_series(p_from, p_to - INTERVAL '15 minutes', + INTERVAL '15 minutes') AS gs(slot) + LEFT JOIN ems.consumption_baseline_stats cbs + ON cbs.site_id = p_site_id + AND cbs.day_of_week = EXTRACT(DOW FROM gs.slot AT TIME ZONE 'Europe/Prague')::INT + AND cbs.hour_of_day = EXTRACT(HOUR FROM gs.slot AT TIME ZONE 'Europe/Prague')::INT + ORDER BY gs.slot; +$$; + +COMMENT ON FUNCTION ems.fn_get_baseline_forecast(INT, TIMESTAMPTZ, TIMESTAMPTZ) IS +'Vrátí předpověď bazální spotřeby pro zadaný horizont jako 15min sloty. +forecast_w = průměr dle DOW+hodina z historických dat. +confidence_w = konzervativní odhad (avg + 0.5*stddev). +Fallback 500W pokud nejsou historická data. +Použití v solveru: nahrazuje pevný fallback 500W v _load_slots().'; diff --git a/db/routines/R__fn_effective_price.sql b/db/routines/R__fn_effective_price.sql index 7169f8a..334d96a 100644 --- a/db/routines/R__fn_effective_price.sql +++ b/db/routines/R__fn_effective_price.sql @@ -5,31 +5,122 @@ -- ============================================================= CREATE OR REPLACE FUNCTION ems.fn_effective_buy_price( - p_site_id INT, + p_site_id INT, p_interval_start TIMESTAMPTZ ) RETURNS NUMERIC(10,6) -LANGUAGE sql +LANGUAGE plpgsql STABLE AS $$ +DECLARE + v_spot_price NUMERIC; + v_dist_rate NUMERIC; + v_system_services NUMERIC; + v_ote_fee NUMERIC; + v_vat_rate NUMERIC; + v_buy_margin_fixed NUMERIC; + v_buy_margin_pct NUMERIC; + v_buy_margin NUMERIC; + v_is_vt BOOLEAN; + v_local_time TIME; + v_dow INT; + v_hdo_code_id INT; + v_tariff_id INT; + v_rate_type TEXT; +BEGIN SELECT - mip.buy_raw_price_czk_kwh - + smc.buy_margin_fixed_czk - + (mip.buy_raw_price_czk_kwh * smc.buy_margin_percent / 100.0) - FROM ems.market_interval_price mip - CROSS JOIN ems.site_market_config smc - WHERE mip.market_source = 'OTE_CZ' - AND mip.interval_start = p_interval_start - AND smc.site_id = p_site_id + smc.buy_margin_fixed_czk, + smc.buy_margin_percent, + smc.system_services_czk_kwh, + smc.ote_fee_czk_kwh, + smc.hdo_code_id, + smc.tariff_id, + dt.vat_rate + INTO + v_buy_margin_fixed, + v_buy_margin_pct, + v_system_services, + v_ote_fee, + v_hdo_code_id, + v_tariff_id, + v_vat_rate + FROM ems.site_market_config smc + LEFT JOIN ems.distribution_tariff dt ON dt.id = smc.tariff_id + WHERE smc.site_id = p_site_id AND smc.valid_from <= p_interval_start AND (smc.valid_to IS NULL OR smc.valid_to > p_interval_start) ORDER BY smc.valid_from DESC LIMIT 1; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + SELECT buy_raw_price_czk_kwh INTO v_spot_price + FROM ems.market_interval_price + WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM') + AND interval_start = p_interval_start + LIMIT 1; + + IF v_spot_price IS NULL THEN + RETURN NULL; + END IF; + + v_local_time := (p_interval_start AT TIME ZONE 'Europe/Prague')::TIME; + v_dow := EXTRACT(DOW FROM p_interval_start AT TIME ZONE 'Europe/Prague'); + -- 0=neděle, 6=sobota + + IF v_hdo_code_id IS NOT NULL THEN + SELECT EXISTS ( + SELECT 1 + FROM ems.hdo_code_window w + WHERE w.hdo_code_id = v_hdo_code_id + AND ( + w.day_type = 'all' + OR (w.day_type = 'workday' AND v_dow BETWEEN 1 AND 5) + OR (w.day_type = 'weekend' AND v_dow IN (0, 6)) + ) + AND w.rate_type = 'VT' + AND v_local_time >= w.window_from + AND v_local_time < w.window_to + ) INTO v_is_vt; + ELSE + v_is_vt := false; + END IF; + + v_rate_type := CASE WHEN v_is_vt THEN 'VT' ELSE 'NT' END; + + IF v_tariff_id IS NOT NULL THEN + SELECT price_czk_kwh INTO v_dist_rate + FROM ems.distribution_tariff_rate + WHERE tariff_id = v_tariff_id + AND rate_type = v_rate_type + AND valid_from <= p_interval_start::DATE + AND (valid_to IS NULL OR valid_to > p_interval_start::DATE) + ORDER BY valid_from DESC + LIMIT 1; + END IF; + + v_dist_rate := COALESCE(v_dist_rate, 0); + v_system_services := COALESCE(v_system_services, 0); + v_ote_fee := COALESCE(v_ote_fee, 0); + v_buy_margin_fixed := COALESCE(v_buy_margin_fixed, 0); + v_buy_margin_pct := COALESCE(v_buy_margin_pct, 0); + v_buy_margin := v_buy_margin_fixed + (v_spot_price * v_buy_margin_pct / 100.0); + v_vat_rate := COALESCE(v_vat_rate, 0.21); + + RETURN ROUND( + (v_spot_price + v_dist_rate + v_system_services + v_ote_fee + v_buy_margin) + * (1 + v_vat_rate), + 6 + ); +END; $$; COMMENT ON FUNCTION ems.fn_effective_buy_price(INT, TIMESTAMPTZ) IS -'Vrátí efektivní nákupní cenu elektřiny v Kč/kWh pro danou lokalitu a 15min interval. -Přičítá fixní a procentní nákupní marži dle aktuálně platné site_market_config.'; +'Efektivní nákupní cena elektřiny Kč/kWh včetně DPH. +Složky: spot OTE + distribuce NT/VT (dle HDO) + systémové služby + OTE poplatek + marže (fix + % ze spotu). +DPH aplikováno na celou částku. Distribuce závisí na HDO kódu site.'; -- ------------------------------------------------------------ @@ -38,24 +129,86 @@ CREATE OR REPLACE FUNCTION ems.fn_effective_sell_price( p_interval_start TIMESTAMPTZ ) RETURNS NUMERIC(10,6) -LANGUAGE sql +LANGUAGE plpgsql STABLE AS $$ - SELECT - mip.sell_raw_price_czk_kwh - + smc.sell_margin_fixed_czk - + (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100.0) - FROM ems.market_interval_price mip - CROSS JOIN ems.site_market_config smc - WHERE mip.market_source = 'OTE_CZ' - AND mip.interval_start = p_interval_start - AND smc.site_id = p_site_id - AND smc.valid_from <= p_interval_start - AND (smc.valid_to IS NULL OR smc.valid_to > p_interval_start) - ORDER BY smc.valid_from DESC +DECLARE + v_spot_price NUMERIC; + v_sell_margin_fixed NUMERIC; + v_sell_margin_pct NUMERIC; +BEGIN + SELECT sell_margin_fixed_czk, sell_margin_percent + INTO v_sell_margin_fixed, v_sell_margin_pct + FROM ems.site_market_config + WHERE site_id = p_site_id + AND valid_from <= p_interval_start + AND (valid_to IS NULL OR valid_to > p_interval_start) + ORDER BY valid_from DESC LIMIT 1; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + SELECT sell_raw_price_czk_kwh INTO v_spot_price + FROM ems.market_interval_price + WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM') + AND interval_start = p_interval_start + LIMIT 1; + + IF v_spot_price IS NULL THEN + RETURN NULL; + END IF; + + RETURN ROUND( + v_spot_price + + COALESCE(v_sell_margin_fixed, 0) + + (v_spot_price * COALESCE(v_sell_margin_pct, 0) / 100.0), + 6 + ); +END; $$; COMMENT ON FUNCTION ems.fn_effective_sell_price(INT, TIMESTAMPTZ) IS -'Vrátí efektivní prodejní cenu elektřiny v Kč/kWh pro danou lokalitu a 15min interval. -Aplikuje fixní a procentní prodejní marži (záporná marže = srážka z prodejní ceny).'; +'Efektivní prodejní cena elektřiny Kč/kWh bez DPH (neplátce DPH). +Složky: spot OTE + fixní/procentní prodejní marže (záporná = srážka). +Zelený bonus není součástí ceny – počítá se z výroby přes fn_green_bonus_revenue(). +Záporná hodnota = platíme za export (záporné spotové ceny).'; + +-- ------------------------------------------------------------ + +CREATE OR REPLACE FUNCTION ems.fn_green_bonus_revenue( + p_pv_array_id INT, + p_interval_start TIMESTAMPTZ, + p_production_wh NUMERIC +) +RETURNS NUMERIC +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_bonus_rate NUMERIC; +BEGIN + SELECT green_bonus_czk_kwh INTO v_bonus_rate + FROM ems.asset_pv_array + WHERE id = p_pv_array_id + AND green_bonus_czk_kwh IS NOT NULL + AND green_bonus_valid_from <= p_interval_start::DATE + AND (green_bonus_valid_to IS NULL + OR green_bonus_valid_to > p_interval_start::DATE); + + IF v_bonus_rate IS NULL OR p_production_wh IS NULL OR p_production_wh <= 0 THEN + RETURN 0; + END IF; + + RETURN ROUND((p_production_wh / 1000.0) * v_bonus_rate, 6); +END; +$$; + +COMMENT ON FUNCTION ems.fn_green_bonus_revenue(INT, TIMESTAMPTZ, NUMERIC) IS +'Příjem ze zeleného bonusu za výrobu FVE pole v daném intervalu. +Bonus plyne z celkové výroby bez ohledu na to kam energie šla +(interní spotřeba, baterie, EV, TČ i export do sítě). +Sazba se načítá dle platnosti (valid_from/valid_to) – ročně aktualizovatelné. +Vrátí 0 pokud pole nemá zelený bonus nebo výroba je nulová. +Použití: SELECT ems.fn_green_bonus_revenue(pv_array_id, interval_start, production_wh);'; diff --git a/db/routines/R__fn_ev_arrival_stats.sql b/db/routines/R__fn_ev_arrival_stats.sql new file mode 100644 index 0000000..3f90122 --- /dev/null +++ b/db/routines/R__fn_ev_arrival_stats.sql @@ -0,0 +1,65 @@ +CREATE OR REPLACE FUNCTION ems.fn_update_ev_arrival_stats( + p_site_id INT, + p_charger_id INT, + p_vehicle_id INT, + p_arrived_at TIMESTAMPTZ +) +RETURNS VOID +LANGUAGE sql +AS $$ + INSERT INTO ems.ev_arrival_stats + (site_id, charger_id, vehicle_id, day_of_week, arrival_hour, sample_count, last_updated) + VALUES ( + p_site_id, + p_charger_id, + p_vehicle_id, + EXTRACT(DOW FROM p_arrived_at AT TIME ZONE 'Europe/Prague')::INT, + EXTRACT(HOUR FROM p_arrived_at AT TIME ZONE 'Europe/Prague')::INT, + 1, + now() + ) + ON CONFLICT (site_id, charger_id, day_of_week, arrival_hour) DO UPDATE SET + sample_count = ems.ev_arrival_stats.sample_count + 1, + last_updated = now(); +$$; + +COMMENT ON FUNCTION ems.fn_update_ev_arrival_stats(INT, INT, INT, TIMESTAMPTZ) IS +'Přidá jeden příjezd do statistiky. Volat při otevření nové EV session +(telemetry_collector: přechod status available → preparing/charging).'; + + +CREATE OR REPLACE FUNCTION ems.fn_ev_expected_arrival( + p_site_id INT, + p_charger_id INT, + p_for_date DATE DEFAULT ( + (CURRENT_TIMESTAMP AT TIME ZONE 'Europe/Prague')::date + 1 + ) +) +RETURNS TABLE ( + expected_hour INT, + confidence_pct INT, + sample_count INT +) +LANGUAGE sql +STABLE +AS $$ + SELECT + s.arrival_hour, + ROUND( + s.sample_count::NUMERIC + / NULLIF(SUM(s.sample_count) OVER (PARTITION BY s.day_of_week), 0) + * 100 + )::INT, + s.sample_count + FROM ems.ev_arrival_stats s + WHERE s.site_id = p_site_id + AND s.charger_id = p_charger_id + AND s.day_of_week = EXTRACT(DOW FROM p_for_date)::INT + AND s.sample_count >= 2 + ORDER BY s.sample_count DESC + LIMIT 3; +$$; + +COMMENT ON FUNCTION ems.fn_ev_expected_arrival(INT, INT, DATE) IS +'Top 3 nejčastější hodiny příjezdu EV pro den v týdnu odpovídající kalendářnímu datu p_for_date. +Backend předává „zítřek“ v časové zóně lokality. Použití: notifikace, později solver.'; diff --git a/db/routines/R__fn_extended_planning.sql b/db/routines/R__fn_extended_planning.sql new file mode 100644 index 0000000..75d06f2 --- /dev/null +++ b/db/routines/R__fn_extended_planning.sql @@ -0,0 +1,153 @@ +-- fn_update_market_price_stats, fn_update_tuv_usage_stats, fn_get_predicted_price +-- (rozšířený horizont plánování – predikce cen a TUV) + +CREATE OR REPLACE FUNCTION ems.fn_update_market_price_stats( + p_site_id INT, + p_lookback_days INT DEFAULT 90 +) +RETURNS INT +LANGUAGE plpgsql +AS $$ +DECLARE v_count INT; +BEGIN + INSERT INTO ems.market_price_stats + (site_id, day_of_week, hour_of_day, + avg_price, stddev_price, p25_price, p75_price, + sample_count, last_updated) + SELECT + p_site_id, + EXTRACT(DOW FROM interval_start AT TIME ZONE 'Europe/Prague')::INT, + EXTRACT(HOUR FROM interval_start AT TIME ZONE 'Europe/Prague')::INT, + AVG(buy_raw_price_czk_kwh), + STDDEV(buy_raw_price_czk_kwh), + PERCENTILE_CONT(0.25) WITHIN GROUP ( + ORDER BY buy_raw_price_czk_kwh), + PERCENTILE_CONT(0.75) WITHIN GROUP ( + ORDER BY buy_raw_price_czk_kwh), + COUNT(*)::INT, + now() + FROM ems.market_interval_price + WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM') + AND interval_start >= now() - make_interval(days => p_lookback_days) + GROUP BY + EXTRACT(DOW FROM interval_start AT TIME ZONE 'Europe/Prague'), + EXTRACT(HOUR FROM interval_start AT TIME ZONE 'Europe/Prague') + HAVING COUNT(*) >= 4 + ON CONFLICT (site_id, day_of_week, hour_of_day) DO UPDATE SET + avg_price = 0.7 * ems.market_price_stats.avg_price + + 0.3 * EXCLUDED.avg_price, + stddev_price = COALESCE( + 0.7 * ems.market_price_stats.stddev_price + + 0.3 * EXCLUDED.stddev_price, + EXCLUDED.stddev_price), + p25_price = EXCLUDED.p25_price, + p75_price = EXCLUDED.p75_price, + sample_count = ems.market_price_stats.sample_count + + EXCLUDED.sample_count, + last_updated = now(); + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION ems.fn_update_market_price_stats IS +'Aktualizuje historické průměry spotové ceny per DOW+hodina. +EMA 70/30 pro postupné zpřesňování. Volat denně po importu OTE. +Pro první naplnění: SELECT ems.fn_update_market_price_stats(2, 180);'; + + +CREATE OR REPLACE FUNCTION ems.fn_update_tuv_usage_stats( + p_site_id INT, + p_lookback_days INT DEFAULT 30 +) +RETURNS INT +LANGUAGE plpgsql +AS $$ +DECLARE v_count INT; +BEGIN + INSERT INTO ems.tuv_usage_stats + (site_id, day_of_week, hour_of_day, + avg_temp_delta_c, stddev_temp_delta, + sample_count, last_updated) + WITH deltas AS ( + SELECT + measured_at, + EXTRACT(DOW FROM measured_at AT TIME ZONE 'Europe/Prague')::INT AS dow, + EXTRACT(HOUR FROM measured_at AT TIME ZONE 'Europe/Prague')::INT AS hour, + tuv_tank_temp_c - LAG(tuv_tank_temp_c) OVER ( + PARTITION BY site_id ORDER BY measured_at + ) AS temp_delta_c + FROM ems.telemetry_heat_pump + WHERE site_id = p_site_id + AND measured_at >= now() - make_interval(days => p_lookback_days) + AND tuv_tank_temp_c IS NOT NULL + ) + SELECT + p_site_id, dow, hour, + AVG(temp_delta_c), + STDDEV(temp_delta_c), + COUNT(*)::INT, + now() + FROM deltas + WHERE temp_delta_c IS NOT NULL + AND ABS(temp_delta_c) < 5 + GROUP BY dow, hour + HAVING COUNT(*) >= 4 + ON CONFLICT (site_id, day_of_week, hour_of_day) DO UPDATE SET + avg_temp_delta_c = 0.7 * ems.tuv_usage_stats.avg_temp_delta_c + + 0.3 * EXCLUDED.avg_temp_delta_c, + stddev_temp_delta = COALESCE( + 0.7 * ems.tuv_usage_stats.stddev_temp_delta + + 0.3 * EXCLUDED.stddev_temp_delta, + EXCLUDED.stddev_temp_delta), + sample_count = ems.tuv_usage_stats.sample_count + + EXCLUDED.sample_count, + last_updated = now(); + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION ems.fn_update_tuv_usage_stats IS +'Aktualizuje statistiku poklesu teploty TUV zásobníku per DOW+hodina. +Záporné avg_temp_delta_c = zásobník se ochlazuje (spotřeba teplé vody). +Potřeba min. 1 měsíc telemetrie TČ. Volat denně.'; + + +CREATE OR REPLACE FUNCTION ems.fn_get_predicted_price( + p_site_id INT, + p_interval_start TIMESTAMPTZ +) +RETURNS NUMERIC +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_dow INT; + v_hour INT; + v_price NUMERIC; + v_corr NUMERIC := 1.0; +BEGIN + v_dow := EXTRACT(DOW FROM p_interval_start AT TIME ZONE 'Europe/Prague')::INT; + v_hour := EXTRACT(HOUR FROM p_interval_start AT TIME ZONE 'Europe/Prague')::INT; + + SELECT avg_price INTO v_price + FROM ems.market_price_stats + WHERE site_id = p_site_id + AND day_of_week = v_dow + AND hour_of_day = v_hour; + + IF v_price IS NULL THEN + RETURN 2.50; + END IF; + + RETURN ROUND(v_price * v_corr, 6); +END; +$$; + +COMMENT ON FUNCTION ems.fn_get_predicted_price IS +'Vrátí predikovanou cenu pro slot za horizontem OTE. +Zatím používá jen sezónní průměr. Fáze 3d přidá korekci počasím. +Fallback 2.50 Kč/kWh pokud nejsou historická data (min. 3 měsíce).'; diff --git a/db/routines/R__fn_fill_audit_interval.sql b/db/routines/R__fn_fill_audit_interval.sql index 34e3600..c9ac76f 100644 --- a/db/routines/R__fn_fill_audit_interval.sql +++ b/db/routines/R__fn_fill_audit_interval.sql @@ -12,19 +12,23 @@ RETURNS VOID LANGUAGE plpgsql AS $$ DECLARE - v_interval_end TIMESTAMPTZ := p_interval_start + INTERVAL '15 minutes'; - v_run_id INT; - v_avg_pv_power_w INT; - v_avg_battery_power_w INT; - v_avg_grid_power_w INT; - v_avg_load_power_w INT; - v_last_soc NUMERIC(5,2); - v_sum_ev_power_w INT; - v_avg_hp_power_w INT; - v_plan ems.planning_interval%ROWTYPE; - v_buy_price NUMERIC; - v_sell_price NUMERIC; - v_actual_cost NUMERIC := NULL; + v_interval_end TIMESTAMPTZ := p_interval_start + INTERVAL '15 minutes'; + v_run_id INT; + v_avg_pv_power_w INT; + v_avg_battery_power_w INT; + v_avg_grid_power_w INT; + v_avg_load_power_w INT; + v_last_soc NUMERIC(5,2); + v_sum_ev_power_w INT; + v_avg_hp_power_w INT; + v_plan ems.planning_interval%ROWTYPE; + v_buy_price NUMERIC; + v_sell_price NUMERIC; + v_actual_cost NUMERIC := NULL; + v_green_bonus_czk NUMERIC := 0; + v_pv_b_production_wh NUMERIC; + v_array_prod_wh NUMERIC; + r_bonus RECORD; BEGIN -- Najít aktivní plán pro tento interval SELECT pi.* INTO v_plan @@ -88,6 +92,37 @@ BEGIN ELSE COALESCE(v_sell_price, 0) END; END IF; + -- Zelený bonus: výroba bonusových polí z poslední ok predikce pro slot (Wh = W × 0,25 h) + v_pv_b_production_wh := NULL; + FOR r_bonus IN + SELECT id + FROM ems.asset_pv_array + WHERE site_id = p_site_id + AND green_bonus_czk_kwh IS NOT NULL + LOOP + SELECT fpi.power_w * 0.25 + INTO v_array_prod_wh + 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 fpr.pv_array_id = r_bonus.id + AND fpi.interval_start = p_interval_start + AND fpr.status = 'ok' + ORDER BY fpr.created_at DESC + LIMIT 1; + + v_array_prod_wh := COALESCE(v_array_prod_wh, 0); + IF v_pv_b_production_wh IS NULL THEN + v_pv_b_production_wh := 0; + END IF; + v_pv_b_production_wh := v_pv_b_production_wh + v_array_prod_wh; + v_green_bonus_czk := v_green_bonus_czk + ems.fn_green_bonus_revenue( + r_bonus.id, + p_interval_start, + v_array_prod_wh + ); + END LOOP; + -- Upsert do audit_interval INSERT INTO ems.audit_interval ( site_id, interval_start, planning_run_id, @@ -97,6 +132,8 @@ BEGIN actual_ev_power_w, actual_heat_pump_power_w, actual_cost_czk, + pv_b_production_wh, + green_bonus_czk, deviation_grid_w, deviation_cost_czk ) VALUES ( @@ -109,6 +146,8 @@ BEGIN v_sum_ev_power_w, v_avg_hp_power_w, ROUND(v_actual_cost, 4), + v_pv_b_production_wh, + ROUND(v_green_bonus_czk, 4), CASE WHEN v_plan.run_id IS NOT NULL THEN v_avg_grid_power_w - v_plan.grid_setpoint_w ELSE NULL END, @@ -126,6 +165,8 @@ BEGIN actual_ev_power_w = EXCLUDED.actual_ev_power_w, actual_heat_pump_power_w = EXCLUDED.actual_heat_pump_power_w, actual_cost_czk = EXCLUDED.actual_cost_czk, + pv_b_production_wh = EXCLUDED.pv_b_production_wh, + green_bonus_czk = EXCLUDED.green_bonus_czk, deviation_grid_w = EXCLUDED.deviation_grid_w, deviation_cost_czk = EXCLUDED.deviation_cost_czk; END; @@ -134,6 +175,7 @@ $$; COMMENT ON FUNCTION ems.fn_fill_audit_interval(INT, TIMESTAMPTZ) IS 'Naplní nebo aktualizuje jeden řádek v audit_interval pro danou lokalitu a 15min interval. Agreguje průměry z telemetrie (střídač, EV, TČ), porovná se skutečným plánem a spočítá odchylky. +Zelený bonus: součet přes všechna pole s nastaveným bonusem, výroba z poslední ok forecast_pv_interval. Volat každých 15 minut pro interval který právě skončil.'; -- ============================================================ diff --git a/db/routines/R__fn_fill_forecast_accuracy.sql b/db/routines/R__fn_fill_forecast_accuracy.sql new file mode 100644 index 0000000..308d045 --- /dev/null +++ b/db/routines/R__fn_fill_forecast_accuracy.sql @@ -0,0 +1,75 @@ +CREATE OR REPLACE FUNCTION ems.fn_fill_forecast_accuracy( + p_site_id INT, + p_lookback_hours INT DEFAULT 48 +) +RETURNS INT +LANGUAGE plpgsql +AS $$ +DECLARE + v_count INT := 0; +BEGIN + INSERT INTO ems.forecast_accuracy ( + site_id, pv_array_id, interval_start, run_id, + forecast_power_w, forecast_created_at, lead_time_hours, + actual_power_w, actual_filled_at, + error_w, error_pct + ) + SELECT + fpr.site_id, + fpr.pv_array_id, + fpi.interval_start, + fpi.run_id, + fpi.power_w AS forecast_power_w, + fpr.created_at AS forecast_created_at, + ROUND( + EXTRACT(EPOCH FROM (fpi.interval_start - fpr.created_at)) + / 3600.0, 2 + ) AS lead_time_hours, + slot.avg_actual_w::INT AS actual_power_w, + now() AS actual_filled_at, + fpi.power_w - COALESCE(slot.avg_actual_w::INT, 0) AS error_w, + CASE + WHEN slot.avg_actual_w IS NOT NULL + AND slot.avg_actual_w > 0 + THEN ROUND( + (fpi.power_w::NUMERIC - slot.avg_actual_w::NUMERIC) + / slot.avg_actual_w::NUMERIC * 100, + 4 + ) + ELSE NULL + END AS error_pct + FROM ems.forecast_pv_interval fpi + JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id + JOIN ems.asset_pv_array pa ON pa.id = fpr.pv_array_id + LEFT JOIN LATERAL ( + SELECT AVG( + CASE + WHEN pa.controllable = false THEN ti.gen_port_power_w::NUMERIC + ELSE (COALESCE(ti.pv1_power_w, 0) + COALESCE(ti.pv2_power_w, 0))::NUMERIC + END + ) AS avg_actual_w + FROM ems.telemetry_inverter ti + WHERE ti.site_id = fpr.site_id + AND ti.measured_at >= fpi.interval_start + AND ti.measured_at < fpi.interval_start + INTERVAL '15 minutes' + ) slot ON true + WHERE fpr.site_id = p_site_id + AND fpr.status = 'ok' + AND fpi.interval_start < now() - INTERVAL '15 minutes' + AND fpi.interval_start >= now() - make_interval(hours => p_lookback_hours) + ON CONFLICT (run_id, interval_start) DO UPDATE SET + actual_power_w = EXCLUDED.actual_power_w, + actual_filled_at = EXCLUDED.actual_filled_at, + error_w = EXCLUDED.error_w, + error_pct = EXCLUDED.error_pct; + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION ems.fn_fill_forecast_accuracy(INT, INT) IS +'Doplní skutečné hodnoty výroby do forecast_accuracy z telemetrie. +Volat každých 15 minut (spolu s audit_filler) pro inkrementální plnění. +p_lookback_hours: kolik hodin zpět zpracovat (default 48h pro catch-up). +Pro první backfill: SELECT ems.fn_fill_forecast_accuracy(2, 8760) -- 1 rok'; diff --git a/db/routines/R__fn_ote_import.sql b/db/routines/R__fn_ote_import.sql new file mode 100644 index 0000000..9edf374 --- /dev/null +++ b/db/routines/R__fn_ote_import.sql @@ -0,0 +1,135 @@ +-- ============================================================= +-- R__fn_ote_import.sql +-- OTE CZ import – parser a import funkce +-- Repeatable migration – při změně funkce stačí upravit tento soubor +-- ============================================================= + +-- Parser: raw jsonb → 96 cenových řádků +CREATE OR REPLACE FUNCTION ems.fn_ote_parse_15m_price_json( + in_payload jsonb, + in_czk_per_eur numeric DEFAULT 25.000 +) +RETURNS TABLE ( + interval_start timestamptz, + interval_end timestamptz, + raw_price_czk_kwh numeric(10,6) +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_date_text text; + v_market_date date; +BEGIN + IF in_payload IS NULL THEN + RAISE EXCEPTION 'in_payload must not be null'; + END IF; + IF in_czk_per_eur IS NULL OR in_czk_per_eur <= 0 THEN + RAISE EXCEPTION 'in_czk_per_eur must be > 0, got: %', in_czk_per_eur; + END IF; + + -- Datum z graph.title ve formátu "... DD.MM.YYYY" + v_date_text := substring( + in_payload #>> '{graph,title}' + FROM '([0-9]{2}\.[0-9]{2}\.[0-9]{4})' + ); + IF v_date_text IS NULL THEN + RAISE EXCEPTION 'cannot parse date from graph.title: %', + in_payload #>> '{graph,title}'; + END IF; + v_market_date := to_date(v_date_text, 'DD.MM.YYYY'); + + RETURN QUERY + WITH price_line AS ( + -- Správná série: 15min ceny (tooltip rozlišuje od Množství a 60min) + SELECT dl + FROM jsonb_array_elements(in_payload #> '{data,dataLine}') AS t(dl) + WHERE dl ->> 'tooltip' = 'flash_chart_01_y_15m_price_tooltip' + LIMIT 1 + ), + points AS ( + SELECT + (p ->> 'x')::int AS block_no, + (p ->> 'y')::numeric AS price_eur_mwh + FROM price_line pl + CROSS JOIN LATERAL jsonb_array_elements(pl.dl -> 'point') AS p + ) + SELECT + ((v_market_date::timestamp + + ((block_no - 1) * INTERVAL '15 minutes')) + AT TIME ZONE 'Europe/Prague') AS interval_start, + ((v_market_date::timestamp + + (block_no * INTERVAL '15 minutes')) + AT TIME ZONE 'Europe/Prague') AS interval_end, + ROUND( + (price_eur_mwh * in_czk_per_eur / 1000.0)::numeric, 6 + )::numeric(10,6) AS raw_price_czk_kwh + FROM points + ORDER BY block_no; + + IF NOT FOUND THEN + RAISE EXCEPTION + 'dataLine tooltip=flash_chart_01_y_15m_price_tooltip not found; ' + 'dostupné tooltips: %', + (SELECT jsonb_agg(dl ->> 'tooltip') + FROM jsonb_array_elements(in_payload #> '{data,dataLine}') dl); + END IF; +END; +$$; + +COMMENT ON FUNCTION ems.fn_ote_parse_15m_price_json(jsonb, numeric) IS +'Parsuje raw JSON z OTE @@chart-data?time_resolution=PT15M. +Datum extrahuje z graph.title (DD.MM.YYYY). +Série: flash_chart_01_y_15m_price_tooltip (EUR/MWh → Kč/kWh přes kurz). +Výstup: 96 řádků, interval_start/end jako timestamptz (UTC), cena Kč/kWh. +Testování přímo v DB: + COPY tmp_ote FROM ''/tmp/ote.json''; + SELECT * FROM ems.fn_ote_parse_15m_price_json(pg_read_file(''/tmp/ote.json'')::jsonb, 25.0) LIMIT 5;'; + + +CREATE OR REPLACE FUNCTION ems.fn_ote_import_from_json( + in_payload jsonb, + in_czk_per_eur numeric DEFAULT 25.000 +) +RETURNS integer +LANGUAGE plpgsql +AS $$ +DECLARE + v_rowcount integer; +BEGIN + INSERT INTO ems.market_interval_price ( + market_source, + interval_start, + interval_end, + buy_raw_price_czk_kwh, + sell_raw_price_czk_kwh, + currency, + imported_at + ) + SELECT + 'OTE_CZ', + p.interval_start, + p.interval_end, + p.raw_price_czk_kwh, + p.raw_price_czk_kwh, -- spot trh: buy = sell + 'CZK', + now() + FROM ems.fn_ote_parse_15m_price_json(in_payload, in_czk_per_eur) AS p + ON CONFLICT (market_source, interval_start) DO UPDATE SET + interval_end = EXCLUDED.interval_end, + buy_raw_price_czk_kwh = EXCLUDED.buy_raw_price_czk_kwh, + sell_raw_price_czk_kwh = EXCLUDED.sell_raw_price_czk_kwh, + imported_at = EXCLUDED.imported_at; + + GET DIAGNOSTICS v_rowcount = ROW_COUNT; + RETURN v_rowcount; +END; +$$; + +COMMENT ON FUNCTION ems.fn_ote_import_from_json(jsonb, numeric) IS +'Uloží výstup fn_ote_parse_15m_price_json do ems.market_interval_price. +Python předá raw jsonb z HTTP response + kurz EUR/CZK. +Vrátí počet upsertnutých řádků (očekáváno 96). +Testování přímo v DB: + SELECT ems.fn_ote_import_from_json( + pg_read_file(''/tmp/ote.json'')::jsonb, 25.0 + );'; diff --git a/db/routines/R__fn_predict_negative_prices.sql b/db/routines/R__fn_predict_negative_prices.sql new file mode 100644 index 0000000..8f31359 --- /dev/null +++ b/db/routines/R__fn_predict_negative_prices.sql @@ -0,0 +1,227 @@ +-- ============================================================= +-- R__fn_predict_negative_prices.sql +-- Predikce oken se zvýšeným rizikem záporné spotové ceny (OTE). +-- Volat denně po importu cen a po forecastu FVE; výsledky ukládá do +-- ems.predicted_negative_price_window. +-- ============================================================= + +CREATE OR REPLACE FUNCTION ems.fn_predict_negative_price_windows( + p_site_id INT, + p_days_ahead INT DEFAULT 7 +) +RETURNS TABLE ( + predicted_date DATE, + window_start_hour INT, + window_end_hour INT, + probability_pct INT, + expected_min_price NUMERIC(10, 4), + reason TEXT +) +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + v_start DATE; + v_end DATE; + v_days INT; +BEGIN + v_days := COALESCE(p_days_ahead, 7); + IF v_days < 1 OR v_days > 60 THEN + RAISE EXCEPTION 'p_days_ahead must be between 1 and 60, got %', p_days_ahead; + END IF; + + v_start := (CURRENT_TIMESTAMP AT TIME ZONE 'Europe/Prague')::DATE + 1; + v_end := (CURRENT_TIMESTAMP AT TIME ZONE 'Europe/Prague')::DATE + v_days; + + IF NOT EXISTS (SELECT 1 FROM ems.site s WHERE s.id = p_site_id) THEN + RAISE EXCEPTION 'site not found: %', p_site_id; + END IF; + + DELETE FROM ems.predicted_negative_price_window p + WHERE p.site_id = p_site_id + AND p.predicted_date BETWEEN v_start AND v_end; + + RETURN QUERY + WITH hist_price AS ( + SELECT + EXTRACT(DOW FROM mip.interval_start AT TIME ZONE 'Europe/Prague')::INT AS dow, + EXTRACT(HOUR FROM mip.interval_start AT TIME ZONE 'Europe/Prague')::INT AS hour, + COUNT(*)::INT AS total_slots, + COUNT(*) FILTER (WHERE mip.buy_raw_price_czk_kwh < 0)::INT AS neg_slots, + AVG(mip.buy_raw_price_czk_kwh) AS avg_price, + MIN(mip.buy_raw_price_czk_kwh) AS min_price + FROM ems.market_interval_price mip + WHERE mip.market_source IN ('OTE_CZ', 'OTE_CZ_DAM') + AND mip.interval_start >= NOW() - INTERVAL '6 months' + GROUP BY 1, 2 + HAVING COUNT(*) >= 4 + ), + latest_run AS ( + SELECT fpr.id + FROM ems.forecast_pv_run fpr + WHERE fpr.site_id = p_site_id + AND fpr.status = 'ok' + ORDER BY fpr.created_at DESC NULLS LAST + LIMIT 1 + ), + slot_power_hist AS ( + SELECT + fpi.interval_start, + SUM(fpi.power_w)::BIGINT AS total_w + FROM ems.forecast_pv_interval fpi + INNER JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id + WHERE fpr.site_id = p_site_id + AND fpr.status = 'ok' + AND fpi.interval_start >= NOW() - INTERVAL '6 months' + GROUP BY fpi.interval_start + ), + pv_max_by_hour AS ( + SELECT + EXTRACT(HOUR FROM sp.interval_start AT TIME ZONE 'Europe/Prague')::INT AS hour, + MAX(sp.total_w)::BIGINT AS hist_max_w + FROM slot_power_hist sp + GROUP BY 1 + ), + pred_slot AS ( + SELECT + fpi.interval_start, + SUM(fpi.power_w)::BIGINT AS total_w + FROM ems.forecast_pv_interval fpi + INNER JOIN latest_run lr ON lr.id = fpi.run_id + GROUP BY fpi.interval_start + ), + pred_by_hour AS ( + SELECT + (ps.interval_start AT TIME ZONE 'Europe/Prague')::DATE AS d, + EXTRACT(HOUR FROM ps.interval_start AT TIME ZONE 'Europe/Prague')::INT AS hour, + MAX(ps.total_w)::BIGINT AS pred_max_w + FROM pred_slot ps + GROUP BY 1, 2 + ), + future_days AS ( + SELECT gs::DATE AS d + FROM generate_series(v_start, v_end, INTERVAL '1 day') AS gs + ), + hourly_base AS ( + SELECT + fd.d AS predicted_date, + h.hour, + EXTRACT(DOW FROM fd.d)::INT AS dow, + LEAST( + 100, + GREATEST( + 0, + (100.0 * hp.neg_slots / NULLIF(hp.total_slots, 0))::INT + ) + ) AS base_prob, + hp.min_price + FROM future_days fd + CROSS JOIN generate_series(0, 23) AS h (hour) + INNER JOIN hist_price hp + ON hp.dow = EXTRACT(DOW FROM fd.d)::INT + AND hp.hour = h.hour + ), + hourly_adj AS ( + SELECT + hb.predicted_date, + hb.hour, + hb.dow, + hb.base_prob, + hb.min_price, + CASE + WHEN pm.hist_max_w IS NULL OR pm.hist_max_w <= 0 THEN hb.base_prob + WHEN ph.pred_max_w IS NULL THEN hb.base_prob + WHEN ph.pred_max_w::NUMERIC > 0.8 * pm.hist_max_w::NUMERIC THEN + LEAST(100, hb.base_prob + 15) + WHEN ph.pred_max_w::NUMERIC < 0.4 * pm.hist_max_w::NUMERIC THEN + GREATEST(0, hb.base_prob - 20) + ELSE hb.base_prob + END AS probability_pct + FROM hourly_base hb + LEFT JOIN pred_by_hour ph + ON ph.d = hb.predicted_date + AND ph.hour = hb.hour + LEFT JOIN pv_max_by_hour pm ON pm.hour = hb.hour + ), + qualified AS ( + SELECT + ha.predicted_date, + ha.hour, + ha.probability_pct, + ha.min_price AS expected_hour_min, + ha.hour + - ROW_NUMBER() OVER ( + PARTITION BY ha.predicted_date, ha.probability_pct + ORDER BY ha.hour + ) AS grp + FROM hourly_adj ha + WHERE ha.probability_pct >= 30 + ), + windows AS ( + SELECT + q.predicted_date, + q.probability_pct, + MIN(q.hour) AS window_start_hour, + MAX(q.hour) AS window_end_hour, + MIN(q.expected_hour_min) AS expected_min_price + FROM qualified q + GROUP BY q.predicted_date, q.probability_pct, q.grp + ), + final_rows AS ( + SELECT + w.predicted_date, + w.window_start_hour, + w.window_end_hour, + w.probability_pct, + ROUND(w.expected_min_price::NUMERIC, 4) AS expected_min_price, + CASE + WHEN w.probability_pct >= 70 THEN + 'Historicky vysoká FVE výroba v tento čas – velmi pravděpodobné' + WHEN w.probability_pct >= 50 THEN + 'Víkendový vzor + dobrá předpověď FVE' + ELSE + 'Možné na základě historických dat' + END AS reason + FROM windows w + WHERE w.probability_pct >= 25 + ), + ins AS ( + INSERT INTO ems.predicted_negative_price_window ( + site_id, + predicted_date, + window_start_hour, + window_end_hour, + probability_pct, + expected_min_price, + reason + ) + SELECT + p_site_id, + fr.predicted_date, + fr.window_start_hour, + fr.window_end_hour, + fr.probability_pct, + fr.expected_min_price, + fr.reason + FROM final_rows fr + RETURNING + predicted_negative_price_window.predicted_date, + predicted_negative_price_window.window_start_hour, + predicted_negative_price_window.window_end_hour, + predicted_negative_price_window.probability_pct, + predicted_negative_price_window.expected_min_price, + predicted_negative_price_window.reason + ) + SELECT + ins.predicted_date, + ins.window_start_hour, + ins.window_end_hour, + ins.probability_pct, + ins.expected_min_price, + ins.reason + FROM ins; +END; +$$; + +COMMENT ON FUNCTION ems.fn_predict_negative_price_windows(INT, INT) IS +'Predikuje okna se zvýšeným rizikem záporné nákupní ceny z historie OTE (6 měsíců), upraví podle forecastu FVE a zapíše do ems.predicted_negative_price_window. Volat denně po importu cen a forecastu.'; diff --git a/db/views/R__vw_audit_summary.sql b/db/views/R__vw_audit_summary.sql index 7ad1e78..ac5ea50 100644 --- a/db/views/R__vw_audit_summary.sql +++ b/db/views/R__vw_audit_summary.sql @@ -21,12 +21,15 @@ SELECT ROUND(SUM(actual_cost_czk), 2) AS actual_cost_czk, ROUND(SUM(deviation_cost_czk), 2) AS total_deviation_czk, -- Počet intervalů s velkými odchylkami (>1kW) - COUNT(*) FILTER (WHERE ABS(deviation_grid_w) > 1000) AS high_deviation_count + COUNT(*) FILTER (WHERE ABS(deviation_grid_w) > 1000) AS high_deviation_count, + ROUND(SUM(green_bonus_czk), 4) AS green_bonus_czk, + ROUND(COALESCE(SUM(actual_cost_czk), 0) * -1 + COALESCE(SUM(green_bonus_czk), 0), 2) AS total_revenue_czk FROM ems.audit_interval GROUP BY site_id, date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague'); COMMENT ON VIEW ems.vw_audit_daily IS 'Denní souhrn auditu per lokalita. Energie v kWh, náklady v Kč. +total_revenue_czk = -actual_cost_czk + green_bonus_czk (export/síť vs. bonus). Používat pro dashboard denního přehledu a reporty.'; -- ============================================================ diff --git a/db/views/R__vw_forecast_accuracy.sql b/db/views/R__vw_forecast_accuracy.sql new file mode 100644 index 0000000..5e5de70 --- /dev/null +++ b/db/views/R__vw_forecast_accuracy.sql @@ -0,0 +1,78 @@ +CREATE OR REPLACE VIEW ems.vw_forecast_accuracy_by_lead_time AS +SELECT * +FROM ( + SELECT + site_id, + pv_array_id, + CASE + WHEN lead_time_hours <= 6 THEN '0-6h' + WHEN lead_time_hours <= 12 THEN '6-12h' + WHEN lead_time_hours <= 24 THEN '12-24h' + WHEN lead_time_hours <= 48 THEN '24-48h' + ELSE '48h+' + END AS lead_time_bucket, + COUNT(*) AS slot_count, + COUNT(*) FILTER (WHERE actual_power_w > 100) AS daylight_slots, + ROUND(AVG(error_pct) + FILTER (WHERE actual_power_w > 100), 2) AS avg_error_pct, + ROUND(AVG(ABS(error_pct)) + FILTER (WHERE actual_power_w > 100), 2) AS avg_abs_error_pct, + ROUND(STDDEV(error_pct) + FILTER (WHERE actual_power_w > 100), 2) AS stddev_error_pct, + CASE + WHEN AVG(error_pct) FILTER (WHERE actual_power_w > 100) > 10 + THEN 'nadhodnocuje' + WHEN AVG(error_pct) FILTER (WHERE actual_power_w > 100) < -10 + THEN 'podhodnocuje' + ELSE 'ok' + END AS bias + FROM ems.forecast_accuracy + WHERE actual_power_w IS NOT NULL + GROUP BY + site_id, + pv_array_id, + CASE + WHEN lead_time_hours <= 6 THEN '0-6h' + WHEN lead_time_hours <= 12 THEN '6-12h' + WHEN lead_time_hours <= 24 THEN '12-24h' + WHEN lead_time_hours <= 48 THEN '24-48h' + ELSE '48h+' + END +) bucketed +ORDER BY + site_id, + pv_array_id, + CASE lead_time_bucket + WHEN '0-6h' THEN 1 + WHEN '6-12h' THEN 2 + WHEN '12-24h' THEN 3 + WHEN '24-48h' THEN 4 + WHEN '48h+' THEN 5 + END; + +COMMENT ON VIEW ems.vw_forecast_accuracy_by_lead_time IS +'Přesnost FVE forecastu dle lead time (jak daleko předem byl forecast vytvořen). +Ignoruje noční sloty (actual < 100W). avg_error_pct > 0 = forecast nadhodnocuje. +Po 4+ týdnech dat lze použít pro kalibraci safety_factor v solveru.'; + +CREATE OR REPLACE VIEW ems.vw_forecast_accuracy_daily AS +SELECT + site_id, + pv_array_id, + DATE(interval_start AT TIME ZONE 'Europe/Prague') AS day, + COUNT(*) FILTER (WHERE actual_power_w IS NOT NULL + AND actual_power_w > 100) AS daylight_slots, + ROUND(SUM(forecast_power_w)::NUMERIC / 4000, 2) AS forecast_kwh, + ROUND(SUM(actual_power_w)::NUMERIC / 4000, 2) AS actual_kwh, + ROUND((SUM(forecast_power_w) - SUM(COALESCE(actual_power_w,0))) + ::NUMERIC / NULLIF(SUM(actual_power_w),0) * 100, 2) AS day_error_pct +FROM ems.forecast_accuracy +GROUP BY + site_id, + pv_array_id, + DATE(interval_start AT TIME ZONE 'Europe/Prague') +ORDER BY day DESC; + +COMMENT ON VIEW ems.vw_forecast_accuracy_daily IS +'Denní souhrn přesnosti FVE forecastu v kWh. forecast_kwh vs actual_kwh. +day_error_pct > 0 = forecast nadhodnotil denní výrobu.'; diff --git a/db/views/R__vw_latest_telemetry.sql b/db/views/R__vw_latest_telemetry.sql index f900197..6461336 100644 --- a/db/views/R__vw_latest_telemetry.sql +++ b/db/views/R__vw_latest_telemetry.sql @@ -18,7 +18,13 @@ SELECT DISTINCT ON (t.inverter_id) t.inverter_temp_c, t.operating_mode, t.fault_code, - now() - t.measured_at AS data_age + now() - t.measured_at AS data_age, + t.pv1_power_w, + t.pv2_power_w, + t.gen_port_power_w, + t.batt_charge_today_wh, + t.batt_discharge_today_wh, + t.run_state FROM ems.telemetry_inverter t JOIN ems.asset_inverter inv ON inv.id = t.inverter_id ORDER BY t.inverter_id, t.measured_at DESC; diff --git a/db/views/R__vw_site_effective_price.sql b/db/views/R__vw_site_effective_price.sql index 3d05311..5671e67 100644 --- a/db/views/R__vw_site_effective_price.sql +++ b/db/views/R__vw_site_effective_price.sql @@ -5,38 +5,80 @@ -- ============================================================= CREATE OR REPLACE VIEW ems.vw_site_effective_price AS +WITH cfg_price AS ( + SELECT + smc.site_id, + smc.tariff_id, + smc.hdo_code_id, + smc.system_services_czk_kwh, + smc.buy_margin_fixed_czk, + smc.buy_margin_percent, + smc.sell_margin_fixed_czk, + smc.sell_margin_percent, + mip.interval_start, + mip.interval_end, + mip.market_source, + mip.buy_raw_price_czk_kwh, + mip.sell_raw_price_czk_kwh, + (mip.interval_start AT TIME ZONE 'Europe/Prague')::time AS local_prague_time, + EXTRACT(DOW FROM mip.interval_start AT TIME ZONE 'Europe/Prague')::integer AS prague_dow + FROM ems.market_interval_price mip + CROSS JOIN ems.site_market_config smc + WHERE smc.valid_from <= mip.interval_start + AND (smc.valid_to IS NULL OR smc.valid_to > mip.interval_start) +), +rated AS ( + SELECT + cp.*, + CASE + WHEN cp.hdo_code_id IS NOT NULL AND EXISTS ( + SELECT 1 + FROM ems.hdo_code_window w + WHERE w.hdo_code_id = cp.hdo_code_id + AND ( + w.day_type = 'all' + OR (w.day_type = 'workday' AND cp.prague_dow BETWEEN 1 AND 5) + OR (w.day_type = 'weekend' AND cp.prague_dow IN (0, 6)) + ) + AND w.rate_type = 'VT' + AND cp.local_prague_time >= w.window_from + AND cp.local_prague_time < w.window_to + ) THEN 'VT'::text + ELSE 'NT'::text + END AS rate_type + FROM cfg_price cp +) SELECT - smc.site_id, - mip.interval_start, - mip.interval_end, - mip.market_source, - -- Raw ceny - mip.buy_raw_price_czk_kwh, - mip.sell_raw_price_czk_kwh, - -- Marže - smc.buy_margin_fixed_czk, - smc.buy_margin_percent, - smc.sell_margin_fixed_czk, - smc.sell_margin_percent, - -- Efektivní ceny - ROUND( - mip.buy_raw_price_czk_kwh - + smc.buy_margin_fixed_czk - + (mip.buy_raw_price_czk_kwh * smc.buy_margin_percent / 100.0), - 6 - ) AS effective_buy_price_czk_kwh, - ROUND( - mip.sell_raw_price_czk_kwh - + smc.sell_margin_fixed_czk - + (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100.0), - 6 - ) AS effective_sell_price_czk_kwh -FROM ems.market_interval_price mip -CROSS JOIN ems.site_market_config smc -WHERE smc.valid_from <= mip.interval_start - AND (smc.valid_to IS NULL OR smc.valid_to > mip.interval_start); + r.site_id, + r.interval_start, + r.interval_end, + r.market_source, + r.buy_raw_price_czk_kwh, + r.sell_raw_price_czk_kwh, + r.buy_margin_fixed_czk, + r.buy_margin_percent, + r.sell_margin_fixed_czk, + r.sell_margin_percent, + ems.fn_effective_buy_price(r.site_id, r.interval_start) AS effective_buy_price_czk_kwh, + ems.fn_effective_sell_price(r.site_id, r.interval_start) AS effective_sell_price_czk_kwh, + r.rate_type, + COALESCE( + ( + SELECT dtr.price_czk_kwh + FROM ems.distribution_tariff_rate dtr + WHERE dtr.tariff_id = r.tariff_id + AND dtr.rate_type = r.rate_type + AND dtr.valid_from <= r.interval_start::date + AND (dtr.valid_to IS NULL OR dtr.valid_to > r.interval_start::date) + ORDER BY dtr.valid_from DESC + LIMIT 1 + ), + 0::numeric + ) AS dist_rate_czk_kwh, + COALESCE(r.system_services_czk_kwh, 0::numeric) AS system_services_czk_kwh +FROM rated r; COMMENT ON VIEW ems.vw_site_effective_price IS 'Efektivní nákupní a prodejní ceny elektřiny per lokalita a 15min interval. -Dopočítává marže z site_market_config na raw ceny z market_interval_price. -Nezahrnuje data bez platné market_config. Používat pro plánování a audit.'; +rate_type NT/VT dle HDO oken; dist_rate = variabilní distribuce bez DPH. +effective_* z fn_effective_buy_price / fn_effective_sell_price (marže, DPH u nákupu dle tarifu).'; diff --git a/db/views/R__z_postgrest_ems_anon_grants.sql b/db/views/R__z_postgrest_ems_anon_grants.sql index 588b523..54893ae 100644 --- a/db/views/R__z_postgrest_ems_anon_grants.sql +++ b/db/views/R__z_postgrest_ems_anon_grants.sql @@ -11,3 +11,10 @@ GRANT SELECT ON ems.vw_mode_log_recent TO ems_anon; GRANT SELECT ON ems.vw_operating_mode TO ems_anon; GRANT SELECT ON ems.telemetry_inverter_hourly TO ems_anon; GRANT SELECT ON ems.vw_telemetry_hourly_7d TO ems_anon; +GRANT SELECT ON ems.telemetry_heat_pump TO ems_anon; +GRANT SELECT ON ems.forecast_accuracy TO ems_anon; +GRANT SELECT ON ems.vw_forecast_accuracy_by_lead_time TO ems_anon; +GRANT SELECT ON ems.vw_forecast_accuracy_daily TO ems_anon; +GRANT SELECT ON ems.consumption_baseline_stats TO ems_anon; +GRANT SELECT ON ems.market_price_stats TO ems_anon; +GRANT SELECT ON ems.tuv_usage_stats TO ems_anon; diff --git a/docker-compose.yml b/docker-compose.yml index a21ed6d..f1153b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,7 @@ services: env_file: - .env environment: + TZ: Europe/Prague DB_HOST: db DB_PORT: "5432" POSTGRES_HOST: db @@ -72,7 +73,7 @@ services: DB_NAME: ems DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} - OTE_API_URL: ${OTE_API_URL:-https://www.ote-cr.cz/pubapi/v1/market-data/dam} + OTE_API_URL: ${OTE_API_URL:-https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data} EUR_CZK_RATE: ${EUR_CZK_RATE:-25.0} OPEN_METEO_API_URL: ${OPEN_METEO_API_URL:-https://api.open-meteo.com/v1/forecast} TELEMETRY_POLL_INTERVAL_SEC: ${TELEMETRY_POLL_INTERVAL_SEC:-60} diff --git a/docs/02-architecture.md b/docs/02-architecture.md index d15d1d0..f5e4cb6 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -25,11 +25,12 @@ │ FastAPI (Python) │ │ – Scheduled tasks (APScheduler) │ │ – telemetry_collector (každých 60s) │ -│ – price_importer (denně 14:00) │ -│ – forecast_service (denně 14:30) │ +│ – price_importer (13:30, 14:00, 00:05)│ +│ – forecast_service (každé 2h, minute 05)│ │ – planning_engine (denně 15:00) │ │ – control_exporter (každých 15min) │ │ – audit_filler (každých 15min) │ +│ – verify_modbus (každé 2 min) │ └──────┬──────────────────────────┬────────────┘ │ Modbus TCP │ HTTP ┌──────▼──────┐ ┌───────▼────────────┐ @@ -158,6 +159,14 @@ Zařízení → Waveshare → Modbus TCP → telemetry_collector → PostgreSQL PostgreSQL (ceny + forecast) → fn_create_planning_run() → planning_interval ``` +### Operátorské manuální akce (UI) +``` +Browser → FastAPI: + POST /api/v1/sites/{site_id}/prices/import?date=YYYY-MM-DD + POST /api/v1/sites/{site_id}/forecast/run + POST /api/v1/sites/{site_id}/plan/run?type=daily|rolling +``` + ### Export setpointů (každých 15min) ``` PostgreSQL (planning_interval + overrides) → control_exporter diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 08a4f66..18ddfc0 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -120,7 +120,7 @@ CREATE TABLE asset_battery ( ``` ### `asset_pv_array` -Každé FVE pole zvlášť – důležité pro predikci (azimut, sklon). +Každé FVE pole zvlášť – důležité pro predikci (azimut, sklon). **Zelený bonus** (dotace za vyrobenou elektřinu) se váže na **úroveň pole**, ne na celou lokalitu: které pole má bonus, jaká je sazba Kč/kWh a platnost, určují sloupce `green_bonus_*`. Výpočet příjmu za interval probíhá funkcí `ems.fn_green_bonus_revenue` z výroby v Wh; není součástí efektivní prodejní ceny ze sítě. ```sql CREATE TABLE asset_pv_array ( @@ -135,6 +135,11 @@ CREATE TABLE asset_pv_array ( module_count INT, shading_factor NUMERIC(4,3) DEFAULT 1.0, controllable BOOLEAN DEFAULT false, -- ongridový = false + -- zelený bonus (NULL = pole bez bonusu) + green_bonus_czk_kwh NUMERIC(10,6), -- sazba Kč/kWh za vyrobenou kWh + green_bonus_valid_from DATE, -- platnost od (včetně) + green_bonus_valid_to DATE, -- platnost do (exclusive), NULL = dosud + green_bonus_meter_code TEXT, -- EAN / číslo zeleného elektroměru (audit) notes TEXT ); ``` @@ -382,6 +387,8 @@ CREATE TABLE audit_interval ( -- odchylky deviation_grid_w INT, -- actual - planned actual_cost_czk NUMERIC(10,4), + pv_b_production_wh NUMERIC(10,3), -- výroba bonusových polí (Wh / interval), podklad pro bonus + green_bonus_czk NUMERIC(10,4), -- příjem zeleného bonusu (fn_green_bonus_revenue), mimo actual_cost_czk PRIMARY KEY (site_id, interval_start) ); -- SELECT create_hypertable('audit_interval', 'interval_start'); diff --git a/docs/04-modules/consumption.md b/docs/04-modules/consumption.md index 16775b7..c8f3a68 100644 --- a/docs/04-modules/consumption.md +++ b/docs/04-modules/consumption.md @@ -26,21 +26,25 @@ Střídač Deye poskytuje přes Modbus registr `load_power_w` = celková okamži load_power_w (Deye) = bazální_spotřeba + EV_nabíjení + TUV + ostatní flexibilní ``` -### Odvození bazální spotřeby +### Výpočet bazální spotřeby + +Bazální výkon je to, co zůstane po odečtení řízených zátěží od celkové spotřeby ze střídače: ``` -bazální_w = load_power_w - sum(flexibilní zařízení aktuální výkon) +bazální_w = load_power_w - ev_power_w - heat_pump_power_w ``` -V praxi: -``` -bazální_w = load_power_w - - ev_charger_1_power_w - - ev_charger_2_power_w - - tuv_power_w (pokud je měřitelná zvlášť) -``` +- **`load_power_w`** – telemetrie střídače (`telemetry_inverter`), 1min. +- **`ev_power_w`** – v agregaci statistik se bere průměr výkonu ze všech nabíječek site v časovém okně ±30 s kolem měření střídače (`telemetry_ev_charger`). +- **`heat_pump_power_w`** – stejně z `telemetry_heat_pump` (TČ včetně kompresoru; slouží jako proxy za měřitelný příkon TČ). -> **Předpoklad:** TUV výkon není přímo měřen, pouze víme že je ON/OFF (přes Loxone). Pokud je ON, odečítáme `asset_flexible_device.max_power_w`. Toto je zjednodušení – lze zpřesnit později podružným měřením. +**Ukládání profilu:** tabulka `consumption_baseline_stats` (unikátní `(site_id, day_of_week, hour_of_day)`). Plní ji **`ems.fn_update_baseline_stats(site_id, lookback_days)`** z minutové telemetrie za posledních *N* dní; agregace po DOW a hodině (Europe/Prague). Do řádku se zapisuje jen bucket s alespoň 4 vzorky (minuty). **EMA 70/30** při `ON CONFLICT`: nový batch má váhu 30 %, existující průměr 70 % (postupné zpřesňování). Denní job v backendu: **00:30** (`fn_update_baseline_stats(..., 30)`). + +**Predikce do horizontu:** **`ems.fn_get_baseline_forecast(site_id, from, to)`** generuje 15min sloty (`generate_series`), pro každý slot najde řádek podle DOW+hodiny v Praze. **`forecast_w`** = uložený průměr; **`confidence_w`** = konzervativní odhad `avg + 0.5 * COALESCE(stddev, 100)`. Pokud pro slot neexistuje statistika, fallback **`forecast_w = 500` W** (málo nebo žádná historie; prakticky odpovídá situaci před ~4 týdny kvalitních dat v jednotlivých hodinách). Směrodatná odchylka je v DB k dispozici pro budoucí použití v solveru (fáze 2). + +**Solver (`planning_engine._load_slots`):** pro každý 15min interval efektivní ceny bere **`avg_power_w` z `consumption_baseline_stats`** podle DOW+hodiny slotu, jinak **500 W** – nečte `consumption_baseline_interval`. + +> **Poznámka:** TUV jako samostatný odečet zůstává otevřený bod, pokud není měřen zvlášť; aktuálně je TČ zahrnut v `heat_pump_power_w`. --- @@ -52,43 +56,16 @@ Celková spotřeba je součástí `telemetry_inverter.load_power_w` (1min zázna EV nabíječky mají vlastní tabulku `telemetry_ev_charger` s přesným výkonem. ### Agregovaná spotřeba pro plánování -Tabulka `consumption_baseline_interval` ukládá 15min průměry bazální spotřeby: - -- `data_type = 'actual'` – historická skutečnost (zpětně dopočítáno z telemetrie) -- `data_type = 'forecast'` – predikce pro plánování +- **`consumption_baseline_stats`** – primární vstup solveru: hodinový profil (DOW + hodina) z telemetrie, EMA, viz výše. +- **`consumption_baseline_interval`** – volitelné 15min řady (`actual` / `forecast`) pro jiné účely; solver z ní bazál nečte. --- ## Predikce bazální spotřeby -### Metoda: historický průměr + denní profil +### Metoda: DOW + hodina + EMA -Jednoduchý model pro začátek: - -```python -def forecast_baseline_consumption(site_id: int, target_date: date): - """ - Predikce bazální spotřeby na základě průměru posledních N podobných dní. - Podobnost: stejný den v týdnu, přibližně stejná roční doba. - """ - lookback_weeks = 4 - day_of_week = target_date.weekday() - - # Stáhnout historické bazální hodnoty pro stejné dny v týdnu - historical = db.query(""" - SELECT interval_start, power_w - FROM consumption_baseline_interval - WHERE site_id = %s - AND data_type = 'actual' - AND EXTRACT(dow FROM interval_start) = %s - AND interval_start >= %s - ORDER BY interval_start - """, site_id, day_of_week, target_date - timedelta(weeks=lookback_weeks)) - - # Průměr per 15min slot - profile = aggregate_by_time_of_day(historical) # 96 hodnot (15min sloty) - return profile -``` +Operativní predikce je v **`fn_get_baseline_forecast`** a v přímém dotazu v `_load_slots` na **`consumption_baseline_stats`**. Doplňkově lze z historie stavět 15min profily v `consumption_baseline_interval`, pokud je k tomu samostatný job – není nutné pro běh LP. --- diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 0ba178b..b1d6b12 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -5,7 +5,7 @@ | Vozidlo | Nabíječka | Max výkon | Řízení | API | |---|---|---|---|---| | Tesla | ev-charger-1 (Teltonika 22kW) | 22 kW | WB proud limit + Tesla API | Zatím nerozhodnuto (Tessie nebo přímé) | -| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max ~7-11kW) | WB proud limit (Zoe respektuje) | Žádné – Zoe jako fixní zátěž při připojení | +| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max 22kW) | WB proud limit (Zoe respektuje) | Žádné – Zoe jako fixní zátěž při připojení | --- @@ -82,7 +82,7 @@ CREATE TABLE ems.asset_vehicle ( name TEXT, make TEXT, -- 'Tesla', 'Renault' model TEXT, -- 'Model Y', 'Zoe' - battery_capacity_kwh NUMERIC(6,2), -- Tesla ~75, Zoe ~52 + battery_capacity_kwh NUMERIC(6,2), -- Tesla ~58, Zoe ~22 max_charge_power_w INT, -- max přijímaný výkon vozidla default_charger_id INT REFERENCES ems.asset_ev_charger(id), api_type TEXT, -- 'tesla', 'none' @@ -241,6 +241,32 @@ SoC Zoe neznáme přesně – použijeme energii dodanou v session (kumulativní --- +## Statistika příjezdů + +### Tabulka `ems.ev_arrival_stats` + +Agregace podle `site_id`, `charger_id`, `day_of_week` (0 = neděle … 6 = sobota) a `arrival_hour` (0–23). Čas příjezdu se počítá v **Europe/Prague**. Unikátní klíč `(site_id, charger_id, day_of_week, arrival_hour)`; sloupec `sample_count` roste s každým zaznamenaným příjezdem. + +Účel: po několika týdnech dat odhadnout typickou hodinu připojení vozidla na danou wallbox — pro notifikace („obvykle přijíždíš kolem 17–18h“) a později jako měkký vstup do plánovače. + +### `ems.fn_update_ev_arrival_stats(site_id, charger_id, vehicle_id, arrived_at)` + +Inkrementuje statistiku pro příslušný bucket (INSERT nebo `ON CONFLICT` +1). Volá se při **detekci nového příjezdu** v `telemetry_collector`: přechod telemetrie z `available` na stav připojení (`preparing`, `charging`, …). + +### `ems.fn_ev_expected_arrival(site_id, charger_id, for_date)` + +Vrátí až **3 řádky**: nejčastější hodiny příjezdu pro den v týdnu odpovídající kalendářnímu datu `for_date` (typicky „zítřek“ v časové zóně lokality z backendu). Filtr `sample_count >= 2`; `confidence_pct` = podíl dané hodiny na součtu vzorků pro stejný `day_of_week` u té nabíječky. + +### API + +`GET /api/v1/sites/{site_id}/ev/arrival-prediction` vrátí pro každou nabíječku (klíč = `asset_ev_charger.code`) pole `tomorrow` s `{ hour, confidence_pct, samples }`. Pokud je na site méně než **5** záznamů v `ev_session` celkem, odpověď má `insufficient_data: true` (predikce se může vracet prázdné nebo řídké). + +### Provozní poznámka + +Historie v `ev_arrival_stats` se **nemaže** — jde o dlouhodobou agregaci. Po **4+ týdnech** reálných příjezdů má smysl UI notifikace a experimentální zapojení do solveru (soft constraint). + +--- + ## Seed data – vozidla home-01 ```sql @@ -252,7 +278,7 @@ INSERT INTO ems.asset_vehicle default_target_soc_pct, default_deadline_hour) SELECT s.id, 'tesla-my', 'Tesla Model Y', 'Tesla', 'Model Y', - 75.0, 11000, -- Tesla Model Y AC max ~11kW + 58.0, 11000, -- Tesla Model Y AC max ~11kW ch.id, 'none', -- Tesla API fáze 2 80, 7 FROM ems.site s @@ -265,7 +291,7 @@ INSERT INTO ems.asset_vehicle default_target_soc_pct, default_deadline_hour) SELECT s.id, 'zoe-r135', 'Renault Zoe R135', 'Renault', 'Zoe R135', - 52.0, 7400, -- Zoe max 7.4kW AC + 22.0, 22000, -- Zoe max 22kW AC ch.id, 'none', 90, 7 -- Zoe: vyšší target SoC (menší baterie, kritičtější) FROM ems.site s diff --git a/docs/04-modules/forecast.md b/docs/04-modules/forecast.md index 022cf95..f583b88 100644 --- a/docs/04-modules/forecast.md +++ b/docs/04-modules/forecast.md @@ -89,10 +89,15 @@ def calculate_pv_power( | Trigger | Čas | Popis | |---|---|---| -| Scheduled (cron) | každý den 14:30 CET | Po importu cen, před plánováním | -| Scheduled (cron) | každý den 06:00 CET | Aktualizace predikce na dnešní den | -| Před plánováním | automaticky | Plánovač zkontroluje čerstvost, spustí pokud starší než 2h | -| Manual trigger | na vyžádání | `POST /admin/run-forecast?site_id=1&date=YYYY-MM-DD` | +| Scheduled (cron) | každé 2 hodiny v `:05` | Průběžný refresh forecastu pro všechny aktivní site | +| Manual trigger | na vyžádání | `POST /api/v1/sites/{site_id}/forecast/run` | + +### Implementované provozní změny (2026-03) + +- Forecast horizont je konfigurovatelný přes `open_meteo_forecast_days`. +- Runtime guard: hodnota se clampuje do rozmezí `2..16`. +- Default je `7` dní. +- Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů. --- @@ -153,11 +158,21 @@ Viz `03-data-model.md`: --- +## Tracking přesnosti forecastu + +- **`ems.forecast_accuracy`** – pro každý úspěšný `forecast_pv_run` a každý 15min slot ukládá predikovaný výkon, čas vzniku predikce, lead time (hodiny před začátkem slotu), později doplněnou skutečnost z telemetrie a odchylku (`error_w`, `error_pct`). Záznamy se **uchovávají trvale** (včetně všech historických běhů v `forecast_pv_run` / `forecast_pv_interval` – ty se nemazají). +- **`ems.fn_fill_forecast_accuracy(site_id, lookback_hours)`** – inkrementálně vloží nebo aktualizuje řádky z `forecast_pv_interval` + run metadata a dopočte `actual_power_w` jako průměr 1min telemetrie ve slotu (pole B: `gen_port_power_w`, pole A: `pv1_power_w` + `pv2_power_w`). Volat **každých 15 minut** (např. spolu s audit fillerem); parametr `lookback_hours` omezuje okno zpětného zpracování (např. 48 h běžně, větší hodnota pro jednorázový backfill). +- **`ems.vw_forecast_accuracy_by_lead_time`** – agregace přesnosti podle bucketů lead time (0–6 h, …, 48 h+); noční sloty s nízkou výrobou (`actual_power_w` ≤ 100 W) se v metrikách typicky vynechávají. +- **`ems.vw_forecast_accuracy_daily`** – denní součty forecast vs actual v kWh (Praha kalendářní den) a relativní odchylka dne. +- **Po 4+ týdnech dat** lze statistiky použít pro kalibraci `safety_factor` (nebo obdobných parametrů) v solveru – viz plánovací modul. + +--- + ## Konfigurace (env proměnné) ```env OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast -FORECAST_HORIZON_DAYS=3 +OPEN_METEO_FORECAST_DAYS=7 FORECAST_MAX_AGE_HOURS=2 # plánovač odmítne starší predikci FORECAST_RETRY_COUNT=3 ``` diff --git a/docs/04-modules/market-prices.md b/docs/04-modules/market-prices.md index 6656481..c4f87f5 100644 --- a/docs/04-modules/market-prices.md +++ b/docs/04-modules/market-prices.md @@ -5,7 +5,7 @@ - Stahuje spotové ceny elektřiny z OTE CZ - Ukládá raw data bez vazby na lokalitu (sdílená tabulka) - Efektivní ceny (s marží) se dopočítávají per site přes view -- Granularita: **15 minut** nativně (OTE CZ publikuje po hodinách → konvertujeme na 15min replikací) +- Granularita: **15 minut** nativně (veřejný JSON `@@chart-data` s `time_resolution=PT15M`) --- @@ -15,18 +15,20 @@ OTE CZ publikuje denní ceny zpravidla **den předem (D-1)** okolo 13:00–14:00 středoevropského času. -### Formát dat OTE CZ +### Formát dat OTE CZ (implementace) -OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky: -1. Stáhnout XML/JSON feed nebo scrape HTML tabulky -2. Převést EUR/MWh → CZK/kWh (kurz ČNB nebo fixní koeficient dle konfigurace) -3. Rozložit hodinový interval na 4× 15min sloty (stejná hodnota) -4. Uložit do `market_interval_price` +**Primární zdroj:** JSON grafu denního trhu (96 bodů na den): -### Alternativní API +`https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data?report_date=YYYY-MM-DD&time_resolution=PT15M` -- **OTE XML feed:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?date=YYYY-MM-DD&market=DAM&type=FIN` -- Autentikace: nepotřebná pro veřejná data +- Body: `data.dataLine[0].point[]` s `x` = 1…96 (15min slot), `y` = cena. +- Jednotka ceny se bere z `axis.y.legend` (typicky **EUR/MWh**); kód podporuje i CZK/MWh a CZK/kWh. +- Přepočet do `buy_raw_price_czk_kwh`: EUR/MWh → `× EUR_CZK / 1000`; CZK/MWh → `/ 1000`; CZK/kWh beze změny. +- Časové razítko slotu: půlnoc v **timezone lokality** (`site.timezone`) + (x−1)×15 min → UTC. + +### Legacy / alternativa + +- **OTE pubapi:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?...` (hodinová data – v projektu už není primární) --- @@ -36,14 +38,26 @@ OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky: Samostatný modul (ne součást FastAPI, ale může být volán z ní jako task). +### Implementované provozní změny (2026-03) + +- Robustní HTTP fetch přes `httpx`: + - oddělené timeouty (`connect/read/write/pool`), + - retry s exponential backoff pro transient chyby, + - detailní chybové kódy (`http_status:*`, `timeout_or_connect:*`, `db_import:*`). +- API endpoint `POST /api/v1/sites/{site_id}/prices/import` vrací při chybě konkrétní důvod. +- Pokud je import volán bez `date`, importer nejdřív zkusí D+1 a při neúspěchu fallback na dnešní den. + ### Kdy se spouští | Trigger | Čas | Popis | |---|---|---| -| Scheduled (cron) | každý den 14:00 CET | Stažení cen na zítřek (D+1) | -| Scheduled (cron) | každý den 00:05 CET | Kontrola – ověření že dnešní data jsou v DB | -| Manual trigger | na vyžádání | API endpoint `POST /admin/import-prices?date=YYYY-MM-DD` | -| Retry | při chybě, 3× s backoffem | Automatický opakovaný pokus | +| Scheduled (cron) | každý den 13:30 CET | Předběžný pokus importu (D+1 + doplnění dneška) | +| Scheduled (cron) | každý den 14:00 CET | Hlavní import OTE (D+1 + doplnění dneška) | +| Scheduled (cron) | každý den 00:05 CET | Backfill kontrola úplnosti 96 slotů pro dnešek/zítřek | +| Manual trigger | na vyžádání | API endpoint `POST /api/v1/sites/{site_id}/prices/import?date=YYYY-MM-DD` | +| Retry | při chybě | Automatický opakovaný pokus v importéru | + +Cronové časy v tabulce jsou v **Europe/Prague** (CET/CEST): `AsyncIOScheduler` v `app/main.py` má `timezone=ZoneInfo("Europe/Prague")`; kontejner backendu má `TZ=Europe/Prague` v `docker-compose.yml`. Bez toho by se hodiny/minuty v cronu vyhodnocovaly v **UTC** a např. „13:30 CET“ by odpovídalo jinému okamžiku na hodinách. ### Logika importu @@ -57,15 +71,11 @@ def import_prices_for_date(date: date, source: str = "OTE_CZ"): log.info("Data already exist, skipping") return - # 2. Stáhnout z OTE API - raw_data = ote_client.fetch_dam_prices(date) # vrátí list hodinových cen v EUR/MWh + # 2. Stáhnout chart-data (96× 15 min) + raw_points = ote_client.fetch_chart_data_15m(date) - # 3. Konvertovat EUR/MWh → CZK/kWh - eur_czk_rate = get_exchange_rate() # z konfigurace nebo ČNB API - czk_per_kwh = [(price_eur_mwh * eur_czk_rate) / 1000 for price in raw_data] - - # 4. Rozložit na 15min intervaly (1 hodina = 4 sloty se stejnou cenou) - intervals = expand_hourly_to_15min(czk_per_kwh, date) + # 3. Detekovat jednotku (EUR/MWh, CZK/MWh, …) a převést na CZK/kWh + intervals = convert_points_to_czk_kwh(raw_points, date, site_tz) # 5. Upsert do DB (idempotentní) db.upsert_many("market_interval_price", intervals, conflict_keys=["market_source", "interval_start"]) @@ -98,12 +108,25 @@ Marže se konfigurují v `site_market_config`: | `sell_margin_fixed_czk` | Kč/kWh | -0.02 (srážka) | | `sell_margin_percent` | % | 0 | +**Zelený bonus** není součástí `fn_effective_sell_price` ani view efektivní prodejní ceny – jde o samostatný příjem z výroby, viz níže. + +--- + +## Zelený bonus + +- Konfigurace je na **`ems.asset_pv_array`** (konkrétní FVE pole), ne na `site` ani na střídači. Sloupce: `green_bonus_czk_kwh`, `green_bonus_valid_from`, `green_bonus_valid_to`, `green_bonus_meter_code`. +- Sazba se typicky mění **ročně**; verzování je přes `valid_from` / `valid_to` (při změně uzavři staré období a zadej novou sazbu s novým `valid_from`). +- **Výpočet příjmu** za interval: `ems.fn_green_bonus_revenue(pv_array_id, interval_start, production_wh)` kde `production_wh` je skutečná nebo odhadnutá výroba pole v daném 15min slotu (Wh). Bonus platí z **celkové výroby** pole (interní spotřeba, baterie, EV, TČ i export) – nejde o prodejní cenu. +- **Nezahrnovat do** `fn_effective_sell_price` – spot + prodejní marže jsou odděleně od dotace; v auditu se bonus ukládá do `audit_interval.green_bonus_czk` (plní `fn_fill_audit_interval`). + +Historicky mohou v DB zůstat sloupce `green_bonus_*` na `site_market_config`; efektivní prodejní cena je z nich se nepočítá. + --- ## Konfigurace (env proměnné) ```env -OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam +OTE_API_URL=https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data OTE_IMPORT_HOUR=14 # hodina kdy se spouští denní import EUR_CZK_RATE=25.0 # fallback kurz pokud ČNB API nedostupné CNB_API_URL=https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md new file mode 100644 index 0000000..812d0db --- /dev/null +++ b/docs/04-modules/modbus-command-journal.md @@ -0,0 +1,59 @@ +# Modbus command journal + +## Účel + +Každý zápis na Modbus TCP (Deye a později další aktiva) se ukládá do tabulky `ems.modbus_command` jako samostatný řádek: cílový registr, hodnota, endpoint, vazba na `site_id` a volitelně na `planning_run_id`. Po zápisu má řádek stav `written`; samostatný **verifikační job** (každé 2 minuty) nebo ruční **GET** `/api/v1/sites/{site_id}/control/verify` přečte registr zpět a nastaví `value_verified` a stav `verified` nebo `mismatch`. + +## Schéma `ems.modbus_command` + +| Sloupec | Význam | +|---------|--------| +| `asset_type` / `asset_id` / `asset_code` | Typ aktiva (`inverter`, …), FK logicky na příslušnou tabulku, čitelný kód | +| `device_*` | Host, port, Modbus unit ID | +| `register` | Číslo registru (decimal); v logu též hex | +| `register_name` | Např. `charge_limit`, `export_limit` | +| `value_to_write` / `value_written` / `value_verified` | Požadavek, potvrzený zápis, ověření čtením | +| `status` | `pending`, `written`, `verified`, `failed`, `mismatch`, `retrying` | +| `planning_run_id` | Volitelná vazba na aktivní plán | +| `deye_physical_mode` | U zápisů z `write_inverter_setpoints`: **PASSIVE** / **SELL** / **CHARGE** (stejná hodnota na všech řádcích daného běhu exportu); jinak NULL | +| `attempt_count` | Počet pokusů o zápis (pro limity retry) | + +Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending` / `retrying`. + +## Verifikace a bezpečnost + +1. Po `mismatch` se odešle **Discord** alert (`notification_service.send_discord` / `notify_modbus_mismatch`), pokud je nastaven `DISCORD_WEBHOOK_URL`. +2. **Retry** zápisu max. **3×** (počítáno přes `attempt_count` po zápisech). +3. Po třech neúspěšných cyklech: přepnutí lokality na **SELF_SUSTAIN** přes `ems.fn_set_mode` (`activated_by` = `system:mismatch`, poznámka = důvod) a **kritický** Discord alert (`notify_self_sustain_activated`). + +Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_switch_to_self_sustain`. + +## Střídač (Deye) + +`write_inverter_setpoints` přidá do journalu mimo **62–64** (čas) a **time pointy 148–177** také řádky pro **108** (max charge A), **109** (max discharge A), **141** (energy mode, vždy 0), **142** (limit control), **178** (pevné hodnoty 32 / 48 podle fyzického režimu, bez read-modify-write), **143** (export limit W). Každý řádek daného exportního běhu má vyplněný **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**) pro audit přepínání. **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu baterie na proud: `battery_watts_to_amps` viz `modbus-registers.md`. Všechny zápisy journalu jdou přes **`write_registers`** (FC **0x10**), ne FC 0x06. Detail režimů a registrů: `docs/04-modules/modbus-registers.md`. + +## APScheduler + +| Job | Frekvence | Popis | +|-----|-----------|--------| +| `verify_modbus` | každé **2 min** | Pro každou aktivní site vybere `written` příkazy s `written_at` v posledních **10 min** a zavolá `verify_modbus_commands`. | + +## Ruční API + +`GET /api/v1/sites/{site_id}/control/verify?minutes=10` + +Vrátí počty `checked` / `verified` / `mismatch` a seznam dotčených příkazů s aktuálním stavem po verifikaci. + +## `ems.cutoff_switch_log` + +Tabulka pro budoucí logování **cut-off** přepínačů (mikroinvertory / GEN při záporné prodejní ceně). Záznam při **změně** stavu: `asset_code`, `new_state`, `previous_state`, `reason`, `sell_price_czk`, `triggered_by`. Zatím jen schéma; logika napojení v `control_exporter` je v TODO. + +## Konfigurace + +- `.env`: `DISCORD_WEBHOOK_URL` — prázdné = notifikace vypnuté (jen log). + +## Související soubory + +- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql` +- Backend: `backend/services/control_exporter.py`, `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py` +- Registry Deye: `docs/04-modules/modbus-registers.md` diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md new file mode 100644 index 0000000..cb15aa8 --- /dev/null +++ b/docs/04-modules/modbus-registers.md @@ -0,0 +1,184 @@ +# Deye Modbus Registry – EMS řízení + +## Důležité pravidlo + +- Registry **60–499**: POUZE **FC 0x10** (`write_registers`) +- Registry **0–59**: FC 0x03 čtení, FC 0x06 zápis +- Registry **500+**: FC 0x03 pouze čtení + +EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_registers`** (FC 0x10), nikdy `write_register` (FC 0x06) pro rozsah 60–499. + +## Řídící registry (R/W, FC 0x10) + +| Reg | Název | Rozsah | Jednotka | Použití v EMS | +|-----|-------|--------|----------|---------------| +| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit nabíjení baterie; horní mez není napříč modely stejná (nižší výkonové řady mívají jiný strop než např. SUN-20K) | +| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit vybíjení baterie; viz výše | +| 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě | +| 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě | +| 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) | +| 142 | Limit control | 0/1/2 | — | **0** = selling first, **1** = zero export (built-in CT); EMS přepíná export vs. idle/nabíjení | +| 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` | +| 178 | Grid peak shaving switch | bitmask | — | EMS zapisuje **pevnou** hodnotu (bez read-modify-write kvůli kolizím s paralelním čtením z Loxone): **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE** a **CHARGE**. | +| 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu | +| 191 | Grid peak shaving power | 0–16000 | 1 W | **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. | + +`control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal + verifikace) po řadě: **62–64** (čas), **time points 148–177**, **108, 109, 141, 142, 178, 143**. Popisné názvy registrů v DB bere `DEYE_REGISTER_NAMES` v `control_exporter.py`. **Reg 191** do journalu nepatří – EMS ho nezapisuje. + +### Reg 191 (výkon grid peak shaving) + +- **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**. +- Hodnota určuje výkon peak shavingu v **W** (typicky 0–16 000). + +### Reg 178 – hodnoty podle fyzického režimu + +- **SELL:** **32** – bit4–5 = **10**, grid peak shaving **disable** (export do sítě). +- **PASSIVE** a **CHARGE:** **48** – bit4–5 = **11**, grid peak shaving **enable**. + +EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může způsobit nesoulad). + +## Klíčové registry podle fyzického režimu Deye + +Provozní režimy EMS (AUTO, SELF_SUSTAIN, SELL, …) se mapují na **tři fyzické režimy** střídače: **PASSIVE**, **SELL**, **CHARGE**. Ostatní je politika solveru / EMS, ne samostatný „režim“ invertoru. + +| Reg | PASSIVE | SELL | CHARGE | +|-----|---------|------|--------| +| 142 | 1 (zero export to load) | 0 (selling first) | 1 | +| 108 | `max_charge_a` z DB | `max_charge_a` z DB | `battery_watts_to_amps(battery_w, max_charge_a)` | +| 109 | `max_discharge_a` z DB | `max_discharge_a` z DB | 0 | +| 178 | 48 | 32 | 48 | +| 143 | max export W z DB | max export W z DB | max export W z DB | +| 141 | 0 | 0 | 0 | + +**Důležité:** V **PASSIVE** i **SELL** jsou registry **108** a **109** vždy na **plném limitu z DB**. Deye si tok energie reguluje sám; snížení 108/109 pod maximum brání reakci na nepředvídatelnou spotřebu nebo přebytky FVE. + +### Detekce fyzického režimu (`get_deye_mode` v `control_exporter.py`) + +Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivní plán / politika EMS), ne z telemetrie. + +| Režim | Podmínka | +|-------|----------| +| **SELL** | `grid_setpoint_w` < −200 | +| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 | +| **PASSIVE** | vše ostatní (včetně SELF_SUSTAIN, IDLE, …) | + +Režim **CHARGE_CHEAP** v EMS nastaví `grid_setpoint_w` tak, aby platila podmínka importu (> 200 W), jinak by fyzicky zůstal PASSIVE. + +Všechny limity (`max_charge_a`, `max_discharge_a`, `max_export_power_w` / reg 143) pocházejí **výhradně z DB** (`_load_inverter_config`). + +## Time Points – řízení podle fyzického režimu + +Deye má 6 časových bloků. EMS přepisuje **bloky 1–2** při každém `control_export` (cron např. :14, :29, :44, :59). + +**Výběr aktivního segmentu na invertoru:** platí poslední časový bod, jehož **HH:MM ≤ aktuálnímu času** na hodinách střídače (po synchronizaci 62–64). Proto **nesmí** zůstat jako jediný „minulý“ bod např. **00:00** s pasivním profilem, zatímco profil s nabíjením ze sítě je až u budoucího času – mezi půlnocí a tím budoucím časem by invertor celou dobu používal špatný segment. + +| Blok | Čas (HHMM, Europe/Prague) | Zdroj plánu | Účel | SOC min | Grid charge | +|------|---------------------------|-------------|------|---------|-------------| +| 1 | **`current_slot_hhmm()`** – začátek **probíhajícího** 15min slotu | `planning_interval` pro **aktuální** slot (`_fetch_plan_row_for_slot_offset(..., 0)`) | PASSIVE / SELL / CHARGE dle `_deye_tou_params` | viz tabulka níže | viz tabulka níže | +| 2 | **`next_slot_hhmm()`** – začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže | +| 3–6 | 23:59 | — | Neaktivní (rezerva) | `reserve_soc` (DB) | NE | + +**Registry 108 / 109 / 142 / 178 / 143** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 1–2 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`). + +Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** – mezi 14:15 a 14:29 je aktivní segment z bloku 1 (sladěný s plánem pro 14:15–14:30), po 14:30 blok 2 (plán 14:30–14:45). Po dalším exportu se oba časy posunou (např. 14:30 / 14:45). + +### Fyzické režimy Deye – parametry jednoho time pointu (bloky 1–2) + +| Režim | Výkon (W) | SOC min | Grid charge | +|-------|-----------|---------|-------------| +| **PASSIVE** | `max_discharge_a × 51,2` | rezerva z DB | NE | +| **SELL** | `max_discharge_a × 51,2` | rezerva z DB | NE | +| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO | + +Bloky 3–6 zůstávají na **23:59** s pasivním profilem (`reserve_soc`, grid charge = NE). + +### Synchronizace času + +Registry **62–64** se při každém `control_export` nastaví na aktuální čas v **Europe/Prague**: + +- reg **62:** `(rok - 2000) << 8 | měsíc` +- reg **63:** `den << 8 | hodina` +- reg **64:** `minuta << 8 | sekunda` + +Zápis time pointů i systémového času prochází stejným **`modbus_command`** journal jako registry 108 / 109 / 141 / 142 / 178 / 143 (FC 0x10 po jednom registru). + +### Mapování registrů (time point *i*, i = 0…5) + +| Účel | Adresa | +|------|--------| +| Čas HHMM | 148 + *i* | +| Výkon (W) | 154 + *i* | +| Min. SOC % | 166 + *i* | +| Grid charge enable 0/1 | 172 + *i* | + +Limity nabíjení/vybíjení v ampérech a export z **site_grid_connection** / **asset_inverter** / **asset_battery** načítá `_load_inverter_config()` (`max_charge_a` / `max_discharge_a` jako `LEAST(BMS, střídač) / 51,2`). Python **neřeže** na univerzální číslo – hodnoty v DB mají odpovídat **skutečnému modelu** střídače a BMS (maximální povolená hodnota v registru se liší podle typu; není to všude např. 185 A). Ověřit v dokumentaci k danému SUN-*K. + +## Telemetrické registry (R only, FC 0x03) + +| Reg | Název | Jednotka | Poznámka | +|-----|-------|----------|----------| +| 500 | Run state | — | 0 = standby, 2 = normal | +| 588 | Battery SOC | 1 % | | +| 590 | Battery power | 1 W S16 | + vybíjení / − nabíjení | +| 625 | Grid total power | 1 W S16 | + import / − export | +| 653 | Load total power | 1 W S16 | | +| 667 | GEN port power | 1 W | FVE pole B | +| 672 | PV1 power | 1 W | | +| 673 | PV2 power | 1 W | | + +## Přepočty + +- Výkon baterie → proud (LV 51,2 V): `battery_watts_to_amps(power_w, max_amps) = min(max(0, max_amps), max(0, round(|power_w| / 51.2)))`, kde `max_amps` je z DB +- `max_export_power_w` / `max_import_power_w` / limity baterie berou se z DB (`_load_inverter_config`), ne z natvrdo v Pythonu +- Export do registru **143** = `site_grid_connection.max_export_power_w` (např. home-01 / SUN-20K **13 500 W**) + +## Ověření (Modbus + DB) + +```bash +docker compose up -d --build backend +``` + +```python +import asyncio +from pymodbus.client import AsyncModbusTcpClient + +async def check(): + c = AsyncModbusTcpClient('172.16.1.10', port=502, timeout=5) + await c.connect() + + times = await c.read_holding_registers(148, count=2) + for i in range(2): + h, m = divmod(times.registers[i], 100) + print(f'Time point {i+1}: {h:02d}:{m:02d}') + + for name, reg in [ + ('Limit control', 142), + ('Peak sw (bit4-5)', 178), + ('Export limit', 143), + ('Discharge A', 109), + ('Grid power', 625), + ]: + r = await c.read_holding_registers(reg, count=1) + raw = r.registers[0] + signed = raw - 65536 if raw > 32767 else raw + print(f'{name} ({reg}): {signed}') + + c.close() + +asyncio.run(check()) +``` + +```bash +docker compose exec db psql -U ems_user -d ems -c " + SELECT register_name, value_to_write, status, + created_at AT TIME ZONE 'Europe/Prague' AS cas + FROM ems.modbus_command + WHERE site_id=2 AND register IN (108, 109, 142) + ORDER BY created_at DESC LIMIT 9;" +``` + +## Související + +- `docs/04-modules/modbus-command-journal.md` – journal a verifikace +- `backend/services/control_exporter.py` – zápisy +- `backend/services/modbus_client.py` – `write_registers` (FC 0x10) diff --git a/docs/04-modules/operating-modes.md b/docs/04-modules/operating-modes.md index f6979e6..778d282 100644 --- a/docs/04-modules/operating-modes.md +++ b/docs/04-modules/operating-modes.md @@ -1,132 +1,65 @@ -# Modul: Operating Modes (Provozní režimy) +# Provozní režimy EMS -## Koncept +## Přehled -EMS a Loxone komunikují přes **provozní režimy** – pojmenované stavy které mají smysl pro obě strany. +| Mode | Solver constraints | Deye fyzický režim | Baterie | +|------|-------------------|-------------------|---------| +| AUTO | žádné | PASSIVE/SELL/CHARGE dle plánu | dle plánu | +| SELF_SUSTAIN | no_export, min_import | vždy PASSIVE | plné limity | +| CHARGE_CHEAP | no_export, no_discharge | CHARGE | nabíjení max | +| PRESERVE | no_charge, no_discharge | PASSIVE | lock (0/0) | +| MANUAL | solver neběží | EMS nezapisuje | — | -EMS rozhoduje a přepíná režimy. Loxone dostane kód režimu jako číslo přes Virtual Input a ví jak se v daném režimu chovat **autonomně a nezávisle na EMS**. +Implementace: omezení LP v `planning_engine.solve_dispatch()` podle `mode_code` z `ems.site_operating_mode`; zápis Deye v `control_exporter.write_inverter_setpoints()` (včetně `lock_battery` u PRESERVE). -``` -EMS backend (každou minutu) - → HTTP GET /dev/sps/io/EMS_Heartbeat/1 ← pulz do Loxone +## Fyzické režimy Deye (výstup control_exporteru) -EMS backend (při přepnutí režimu) - → ems.fn_set_mode(site_id, 'SELF_SUSTAIN') ← zapsat do DB - → HTTP GET /dev/sps/io/EMS_Mode/2 ← informovat Loxone +Detekce z `battery_w` a `grid_setpoint_w` (`get_deye_mode`): -Loxone (zcela nezávisle na EMS) - → sleduje přítomnost EMS_Heartbeat pulzů - → pokud 5min žádný pulz → sám přepne na SELF_SUSTAIN - → řídí střídač dle aktivního režimu bez čekání na setpointy -``` +- **PASSIVE:** `grid_setpoint_w >= -200` → reg142=1, reg178=48, 108/109=max z DB (nebo 0/0 při `lock_battery`) +- **SELL:** `grid_setpoint_w < -200` → reg142=0, reg178=32, 108/109=max +- **CHARGE:** `grid_setpoint_w > 200` **a** `battery_w > 500` → reg142=1, reg178=48 -**Klíčový princip:** Loxone watchdog nečte DB. Sleduje pouze HTTP pulzy přímo. -Pokud padne celý server (RPi, Docker, síť) – Loxone to pozná sám a přepne bezpečný režim. +`battery_w = None` (SELF_SUSTAIN – Deye řídí sám) ⇒ pro detekci režimu se bere jako 0 ⇒ při `grid_setpoint_w = 0` je výsledek **PASSIVE**; registry 108/109 se nastaví na **plné limity z DB** (ne na nulu). -Viz `docs/loxone-integration.md` pro kompletní popis Loxone implementace. +## EMS politiky (nejsou fyzické stavy Deye) + +- **PV_SELL_ONLY:** AUTO + constraint solveru `max_discharge_from_pv` +- **BLOCK_EXPORT:** AUTO + záporná sell_price → `ge[t]=0` +- **NEGATIVE_HARVEST:** AUTO + záporná buy_price → max charge/load +- **PROTECT:** SELF_SUSTAIN s konzervativními limity + +Tyto politiky jsou parametrizace AUTO/SELF_SUSTAIN, ne samostatné fyzické stavy. --- -## Přehled režimů +## Loxone a UI (shrnutí) -| Kód | Loxone int | EV | TČ | Baterie | Síť | Loxone autonomní | -|---|---|---|---|---|---|---| -| `AUTO` | 1 | dle plánu | dle plánu | dle plánu | dle plánu | **ne** – čeká na setpointy | -| `SELF_SUSTAIN` | 2 | ❌ stop | ❌ stop | vybíjí do domu | bez exportu | **ano** | -| `CHARGE_CHEAP` | 3 | ❌ stop | ❌ stop | max nabíjení | import ok | **ne** – EMS posílá výkon | -| `PRESERVE` | 4 | ❌ stop | ❌ stop | drží SoC | import ok | **ano** | -| `MANUAL` | 0 | ❌ stop | ❌ stop | žádné akce | žádné akce | **ano** | - -### `AUTO` -Normální provoz. EMS posílá přesné setpointy W každých 15 minut. -Loxone je čistý exekutor – přijme číslo a zapíše do střídače. -Pokud setpoint nepřijde (výpadek EMS) → Loxone watchdog přepne na `SELF_SUSTAIN`. - -### `SELF_SUSTAIN` ← výchozí stav + fallback -Aktivuje se: -- automaticky watchdogem při výpadku EMS (5min bez pulzu) -- manuálně uživatelem z UI (dovolená, odchod z domu) -- při prvním startu systému (seed data) - -Loxone sám bez EMS: -- FVE pokrývá spotřebu -- baterie vybíjí do domu (ne do sítě) -- blokuje export do sítě -- zastavuje EV nabíjení a TČ - -### `CHARGE_CHEAP` -Manuální přepis. EMS posílá max charge setpoint. -Použít při levné ceně nebo přetoku FVE ze sousedství (pokud víš o levné ceně dopředu). - -### `PRESERVE` -Dovolená / servis. Loxone drží baterii na aktuálním SoC, žádné optimalizace. -Autonomní – Loxone nevyžaduje setpointy od EMS. - -### `MANUAL` -Technické práce. Žádná logika neřídí střídač. Pouze pro servis. - ---- - -## Přepínání z UI (React) +EMS a Loxone sdílí pojmenované provozní režimy; Loxone dostává číslo režimu přes Virtual Input a může fungovat autonomně (watchdog při výpadku EMS). ``` POST /api/sites/{site_id}/mode { "mode": "SELF_SUSTAIN", - "valid_until": null, // nebo "2025-03-15T06:00:00+01:00" pro dočasný přepis - "notes": "Odjezd na dovolenou" + "valid_until": null, + "notes": "…" } ``` -Backend při přepnutí: -1. Zavolá `ems.fn_set_mode(site_id, mode, 'user:'+username)` → zápis do DB + log -2. Okamžitě odešle HTTP do Loxone: `/dev/sps/io/EMS_Mode/{loxone_mode_value}` -3. Pokud `CHARGE_CHEAP` nebo návrat na `AUTO` → spustí replanning +Backend: `ems.fn_set_mode` + HTTP na Loxone `/dev/sps/io/EMS_Mode/{loxone_mode_value}`. Dočasné přepisy s `valid_until` ruší `fn_expire_modes()`. -**Dočasný přepis s automatickým návratem:** -`fn_expire_modes()` běží každou minutu a přepíná zpět lokality s prosahlým `valid_until`. +**Klíčový princip:** Loxone watchdog nečte DB – sleduje pulzy `EMS_Heartbeat`. Detail: `docs/loxone-integration.md`. ---- +### Tabulka režimů (Loxone / zátěže) -## EMS restart / reconnect +| Kód | Loxone int | EV | TČ | Poznámka | +|-----|------------|----|----|----------| +| `AUTO` | 1 | dle plánu | dle plánu | setpointy z plánu | +| `SELF_SUSTAIN` | 2 | stop | stop | fallback / výpadek EMS | +| `CHARGE_CHEAP` | 3 | stop | stop | max nabíjení ze sítě | +| `PRESERVE` | 4 | stop | stop | baterie uzamčena (Modbus 0/0) | +| `MANUAL` | 0 | stop | stop | servis, EMS neexportuje | -Při startu backendu: -1. Přečíst z Loxone aktuální `EMS_Mode_Active` (Virtual Output) přes HTTP GET -2. Porovnat s `ems.site_operating_mode` v DB -3. Pokud Loxone přepnul na `SELF_SUSTAIN` během výpadku → logovat, informovat, spustit nový plán -4. Přepnout na `AUTO` a začít posílat setpointy + heartbeat pulzy +### Otevřené body ---- - -## Heartbeat v DB – pouze informační - -Tabulka `ems.site_heartbeat` zaznamenává kdy EMS naposledy úspěšně odeslal pulz do Loxone. -Slouží pro EMS dashboard (`vw_site_status.ems_heartbeat_status`) a případný alerting. - -**Neplní funkci watchdogu** – to je čistě na Loxone straně. - -```python -# backend/services/control_exporter.py – každou minutu -async def send_heartbeat(site_id: int, loxone_endpoint, db): - try: - await loxone_http.get(f"/dev/sps/io/EMS_Heartbeat/1") - await db.execute( - "SELECT ems.fn_update_heartbeat($1, 'ok', $2)", - site_id, EMS_VERSION - ) - except Exception as e: - logger.error(f"Heartbeat failed for site {site_id}: {e}") - await db.execute( - "SELECT ems.fn_update_heartbeat($1, 'error', $2)", - site_id, EMS_VERSION - ) - # EMS nemůže nic dělat – Loxone watchdog to vyřeší sám -``` - ---- - -## Otevřené body - -- [ ] Ověřit Deye Modbus registry pro přepnutí Self-Consumption / Grid-First modu (pro SELF_SUSTAIN) -- [ ] Implementace Loxone watchdog – viz `docs/loxone-integration.md` -- [ ] Alert notifikace (email / push) pokud `ems_heartbeat_status = 'stale'` déle než 10 minut +- [ ] Doplnit alerty při `ems_heartbeat_status = 'stale'` diff --git a/docs/04-modules/planning-extended-horizon.md b/docs/04-modules/planning-extended-horizon.md new file mode 100644 index 0000000..8b49208 --- /dev/null +++ b/docs/04-modules/planning-extended-horizon.md @@ -0,0 +1,190 @@ +# Rozšířený horizont plánování (96h) + +## Motivace + +OTE publikuje ceny max 36h dopředu. FVE forecast je dostupný na 7 dní. +Rozšířením horizontu solver vidí vzdálené příležitosti (záporné ceny, levná okna) +a může optimálně připravit baterii, TUV zásobník a EV nabíjení. + +Klíčový princip: solver nepotřebuje explicitní "šetři baterii před zápornou cenou" +constraint. Pokud dostane správné (odhadované) ceny pro celých 96h, sám pozná +že je výhodnější počkat na zápornou cenu než vybíjet dnes za průměrnou. + +## Datové zdroje pro predikci cen za horizont OTE + +### Vrstva 1 – Sezónní průměr z historických OTE dat + +Tabulka `ems.market_price_stats` (analogie `consumption_baseline_stats`): +```sql +SELECT + EXTRACT(DOW FROM interval_start AT TIME ZONE 'Europe/Prague') AS dow, + EXTRACT(HOUR FROM interval_start AT TIME ZONE 'Europe/Prague') AS hour, + AVG(buy_raw_price_czk_kwh) AS avg_price, + STDDEV(buy_raw_price_czk_kwh) AS stddev_price, + PERCENTILE_CONT(0.25) WITHIN GROUP ( + ORDER BY buy_raw_price_czk_kwh) AS p25, + PERCENTILE_CONT(0.75) WITHIN GROUP ( + ORDER BY buy_raw_price_czk_kwh) AS p75 +FROM ems.market_interval_price +WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM') + AND interval_start >= now() - INTERVAL '6 months' +GROUP BY dow, hour +``` + +Plnit denně po importu OTE. Min. 3 měsíce dat pro smysluplné průměry. + +### Vrstva 2 – Korekce počasím (proxy) + +Záporné/nízké ceny korelují s vysokou FVE výrobou v celé síti CZ. +``` +predicted_irradiance > historical_avg * 1.3 → cena * 0.70 (30% sleva) +predicted_irradiance < historical_avg * 0.5 → cena * 1.20 (20% přirážka) +``` + +Korelaci ověřit po 3+ měsících dat. Zatím použít konzervativní korekci ±15%. + +### Kombinovaná predikce s uncertainty margin +``` +predicted_price[t] = seasonal_avg[dow, hour] + × weather_correction_factor[t] + × (1 ± uncertainty_margin[t]) + +uncertainty_margin roste s horizontem: + 0-36h: 0% (přesné OTE ceny, žádná predikce) + 36-72h: 20% + 72-96h: 35% +``` + +## Uncertainty weighting v objective function + +Vzdálenější sloty mají nižší váhu – solver je konzervativnější: +```python +def slot_weight(t: int, now_index: int) -> float: + hours_ahead = (t - now_index) * 0.25 + if hours_ahead <= 36: return 1.0 # přesné OTE ceny + if hours_ahead <= 72: return 0.7 # predikce, střední jistota + return 0.4 # predikce, nízká jistota + +# V objective function: +prob += pulp.lpSum( + slot_weight(t, now_index) * ( + gi[t] * slots[t].buy_price * INTERVAL_H / 1000 + - ge[t] * slots[t].sell_price * INTERVAL_H / 1000 + + ... + ) + for t in range(T) +) +``` + +## TUV predikce potřeby + +### Princip + +TUV zásobník drží teplo ~24h. Solver může ohřát vodu v levném okně +před očekávanou spotřebou. Potřebuje vědět: +- Aktuální teplotu zásobníku (z telemetrie) +- Kdy typicky klesá teplota (statistika per DOW+hodina) +- Minimální přijatelnou teplotu (tuv_min_temp_c) + +### Tabulka `ems.tuv_usage_stats` + +Analogie `consumption_baseline_stats` pro TUV zásobník: +```sql +-- Průměrný pokles teploty zásobníku per DOW+hodina +-- (záporné = zásobník se ochladil, kladné = TČ ohřívalo) +SELECT + EXTRACT(DOW FROM measured_at AT TIME ZONE 'Europe/Prague') AS dow, + EXTRACT(HOUR FROM measured_at AT TIME ZONE 'Europe/Prague') AS hour, + AVG(temp_delta_c) AS avg_temp_delta, -- průměrná změna za hodinu + STDDEV(temp_delta_c) AS stddev_temp_delta +FROM ( + SELECT + measured_at, + tuv_tank_temp_c - LAG(tuv_tank_temp_c) OVER ( + PARTITION BY site_id ORDER BY measured_at + ) AS temp_delta_c + FROM ems.telemetry_heat_pump + WHERE site_id = $1 + AND measured_at >= now() - INTERVAL '30 days' +) sub +WHERE temp_delta_c IS NOT NULL + AND ABS(temp_delta_c) < 5 -- filtruj extrémní skoky (start TČ) +GROUP BY dow, hour +``` + +### Použití v solveru +```python +# Pro každý slot t zjisti predikovanou teplotu zásobníku: +tuv_predicted[t] = tuv_current + SUM(avg_temp_delta[dow, hour] + for slots before t) + +# Pokud tuv_predicted[t] < tuv_min_temp + safety_margin: +# → solver musí naplánovat ohřev před tímto slotem +# → heat_pump_enabled[t-N] = True (kde N = počet slotů potřebných pro ohřev) + +# Potřebný čas ohřevu (orientační): +# delta_temp = tuv_target - tuv_current +# time_h = delta_temp × volume_l × 1.163 / (cop × hp_power_w / 1000) +``` + +## EV v rozšířeném horizontu + +### Tesla (s API – fáze 2) +``` +Vstup: aktuální SoC z Tesla API, nastavený deadline uživatelem +Solver: deadline constraint přes celých 96h + nabij nejlevněji v rámci časového okna +``` + +### Zoe (bez API) +``` +Vstup: ev_arrival_stats (statistika příjezdů per DOW+hodina) + energy_delivered_wh z aktuální session (odhad SoC) +Solver: soft constraint – pravděpodobnost příjezdu jako váha + pokud P(příjezd v slot t) > 60%: rezervuj nabíjecí kapacitu +``` + +### Predikce příjezdu v solveru +```python +# Pro každý slot t kde P(příjezd) > 0.4: +arrival_prob = ev_arrival_stats[dow, hour] / total_arrivals_this_dow + +# Soft constraint (ne hard – auto nemusí přijet): +# Přidej "expected EV consumption" jako součást load_baseline +ev_expected_w[t] = arrival_prob * ev_charge_power_typical +``` + +## Implementační plán + +### Fáze 3a – Historické průměry cen (hotovo) + +1. Tabulka `ems.market_price_stats` – migrace **V022__extended_planning.sql** +2. `fn_update_market_price_stats()` – `db/routines/R__fn_extended_planning.sql`, APScheduler **14:45** (`main.py`) +3. Solver: slotová páteř `generate_series` + `COALESCE(effective_*, fn_get_predicted_price(...))` v `_load_slots` + +### Fáze 3b – TUV statistika potřeby (hotovo) + +1. Tabulka `ems.tuv_usage_stats` – V022 +2. `fn_update_tuv_usage_stats()` – repeatable výše, job **00:45** +3. Solver: look-ahead simulace teploty + součet `hp` v okně 9 slotů (`solve_dispatch`) + +### Fáze 3c – Rozšíření solveru na 96h (hotovo) + +1. `HORIZON_HOURS = 96`, `slot_weight()` – váhy **1,0 / 0,7 / 0,4** v účelové funkci +2. Příznak `PlanningSlot.is_predicted_price` (z SQL `(ep.effective_buy IS NULL)`) + +### Fáze 3d – EV v rozšířeném horizontu (závisí na Tesla API) + +1. Pravděpodobnostní příjezd ze statistiky +2. Deadline constraint přes celých 96h +3. Tesla API integrace + +### Fáze 3e – Korekce cen počasím + +Po nasbírání 3+ měsíců korelačních dat rozšířit `fn_get_predicted_price` (viz vrstva 2 výše). + +## Prerekvizity + +- Min. 3 měsíce historických OTE dat pro smysluplné průměry +- Min. 1 měsíc telemetrie TUV pro tuv_usage_stats +- Stabilní základní provoz (Modbus zápis, telemetrie) \ No newline at end of file diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 6f1275a..4df5c49 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -4,6 +4,24 @@ **PuLP + HiGHS solver** – lineární programování (LP) s uvolněním binárních proměnných. +### Implementované provozní změny (2026-03) + +- **Strict price fail-safe:** + - pokud v prvních 36h chybí OTE data (sloty jsou predikované), solver zapíná fail-safe režim, + - v predikovaných slotech (`is_predicted_price=true`) je zakázán export do sítě, + - baterie se ale dál používá standardně pro interní spotřebu (nabíjení i vybíjení do domu je povoleno). +- **Runtime guard v exportu setpointů:** + - při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování. +- **Ekonomika baterie:** + - `reserve_soc_percent` naladěn na 10 %, + - `degradation_cost_czk_kwh` naladěn na 0.1500, + - penalizace cyklu je v objective symetrická (`0.5*(charge+discharge)`). +- **PV-aware nejistota:** + - objective používá `pv_scarcity_factor` (0.65..1.0), odvozený z forecastu slunce, + - při slabém slunci je plán ochotnější držet energii v baterii. +- **SoC buffer bez hard pravidel:** + - místo explicitních pravidel se používá ekonomická penalizace deficitu vůči bezpečnostnímu SoC cíli na konci 24h horizontu. + Solver optimalizuje celý horizont (typicky 36h) najednou, čímž přirozeně zvládá: - pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie) - kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu @@ -394,12 +412,9 @@ PLANNING_HORIZON_HOURS=36 PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu -PV_B_GREEN_BONUS_CZK_KWH=1.20 # zelený bonus Kč/kWh (informativní, do účelové funkce přidat pokud chceš) ``` -> **Zelený bonus v účelové funkci:** Pokud chceš bonus explicitně zahrnout, přidat do objective function: -> `- pv_b[t] * GREEN_BONUS_CZK_KWH * H / 1000` jako konstantní příjem (pole B vždy vyrábí). -> Protože je to konstanta, neovlivní optimalizaci – ale správně zobrazí ekonomiku v auditu. +> **Zelený bonus:** Sazba a platnost jsou v `ems.asset_pv_array` (`green_bonus_*`). Bonus **není** v objective function LP solveru – jako aditivní konstanta k nákladům by optimalizaci stejně neměnil. Příjem z bonusu se počítá v **`fn_fill_audit_interval`** přes `ems.fn_green_bonus_revenue()` a ukládá se do `audit_interval.green_bonus_czk`; v přehledech (např. `vw_audit_daily`) je samostatná položka příjmů vedle nákladů ze sítě. Viz `docs/04-modules/market-prices.md` → sekce Zelený bonus. --- @@ -417,7 +432,7 @@ highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD) ## Otevřené body - [ ] Post-processing min_run_duration pro TČ – po LP výsledku zkontrolovat a upravit krátké ON/OFF sekvence -- [ ] Zelený bonus zahrnout do auditního výpočtu nákladů (ne jen do objective) +- [x] Zelený bonus v auditu (`fn_fill_audit_interval`, `green_bonus_czk`) – mimo solver - [ ] EV rozdělení výkonu mezi 2 nabíječky – zatím řešeno jako agregát - [ ] Curtailment pole A – ověřit Modbus registr pro Output Power Limit na Deye SUN-20K - [ ] Testovat solver na reálných datech – ověřit čas výpočtu pro 36h horizont (144 slotů) diff --git a/docs/04-modules/telemetry.md b/docs/04-modules/telemetry.md index ef5c365..516f8f8 100644 --- a/docs/04-modules/telemetry.md +++ b/docs/04-modules/telemetry.md @@ -35,21 +35,23 @@ Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI. Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1). -> Registry jsou specifické pro Deye SUN-20K-SG01LP1-EU. -> Finální hodnoty ověřit z Deye Modbus protokolu / Loxone šablony. +> Mapování v kódu: `backend/services/telemetry_collector.py` (holding registry, decimal adresa = offset pro `read_holding_registers`). -| Registr (hex) | Typ | Popis | Jednotka | Přepočet | +| Dec (hex) | Typ | Popis | Jednotka | Poznámka | |---|---|---|---|---| -| 0x0215 | Read Holding | PV celkový výkon | W | ×1 | -| 0x0103 | Read Holding | Battery SoC | % | ×1 | -| 0x0105 | Read Holding | Battery power | W | signed, kladné=nabíjení | -| 0x0101 | Read Holding | Battery voltage | 0.1V | ×0.1 | -| 0x0169 | Read Holding | Grid power | W | signed, kladné=import | -| 0x016F | Read Holding | Grid voltage L1 | 0.1V | ×0.1 | -| 0x0213 | Read Holding | Load power | W | ×1 | -| 0x0220 | Read Holding | Inverter temperature | 0.1°C | ×0.1 | -| 0x0168 | Read Holding | Operating mode | enum | viz tabulka módů | -| 0x0180 | Read Holding | Fault code | bitfield | 0=ok | +| 500 (0x01F4) | uint16 | Provozní stav střídače | enum | raw do `run_state`, ladění | +| 514 (0x0202) | uint16 | Dnešní nabití baterie | Wh | `batt_charge_today_wh` | +| 515 (0x0203) | uint16 | Dnešní vybití baterie | Wh | `batt_discharge_today_wh` | +| 588 (0x024C) | uint16 | Battery SoC | % | `battery_soc_percent` | +| 590 (0x024E) | int16 | Tok výkonu baterie | W | signed: **+ vybíjení, − nabíjení** | +| 625 (0x0271) | int16 | Výkon sítě | W | signed: **+ import, − export** | +| 653 (0x028D) | uint16 | Celková spotřeba | W | `load_power_w` | +| 667 (0x029B) | uint16 | Výkon GEN portu (FVE pole B) | W | `gen_port_power_w`, nelze curtailovat | +| 672 (0x02A0) | uint16 | Výkon PV1 | W | `pv1_power_w` | +| 673 (0x02A1) | uint16 | Výkon PV2 | W | `pv2_power_w` | + +`pv_power_w` v DB = **PV1 + PV2 + GEN port** (celková výroba na instalaci home-01). +`gen_port_power_w` zůstává i nadále uložen samostatně pro audit a detailní diagnostiku. **Zápis setpointů (plánování → Deye):** @@ -60,8 +62,7 @@ Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1). | 0x00F6 | Write Single | Grid export power limit | W | | 0x00F0 | Write Single | Work mode | enum (viz tabulka) | -> **TODO:** Přesné registry doplnit z Deye SUN-20K Modbus protokolu PDF. -> Loxone šablona pro Deye je dobrý výchozí bod pro mapování registrů. +Rychlá kontrola komunikace: `scripts/test_modbus_deye.py`. --- @@ -108,66 +109,7 @@ Komunikace: Modbus TCP přes Waveshare. ## Kód telemetrie (Python) -```python -# backend/services/telemetry_collector.py - -import asyncio -from pymodbus.client import AsyncModbusTcpClient -from datetime import datetime, timezone - -async def poll_inverter(site_id: int, inverter: AssetInverter, endpoint: SiteEndpoint, db): - """Přečte všechny registry Deye a uloží záznam do telemetry_inverter.""" - async with AsyncModbusTcpClient(endpoint.host, port=endpoint.port) as client: - try: - # Čtení bloku registrů (optimalizovat jako jeden read multiple) - pv_power = await read_register(client, 0x0215, endpoint.unit_id) - batt_soc = await read_register(client, 0x0103, endpoint.unit_id) - batt_power = await read_register_signed(client, 0x0105, endpoint.unit_id) - batt_voltage = await read_register(client, 0x0101, endpoint.unit_id) / 10.0 - grid_power = await read_register_signed(client, 0x0169, endpoint.unit_id) - grid_voltage = await read_register(client, 0x016F, endpoint.unit_id) / 10.0 - load_power = await read_register(client, 0x0213, endpoint.unit_id) - inv_temp = await read_register(client, 0x0220, endpoint.unit_id) / 10.0 - op_mode = await read_register(client, 0x0168, endpoint.unit_id) - fault_code = await read_register(client, 0x0180, endpoint.unit_id) - - await db.execute(""" - INSERT INTO ems.telemetry_inverter - (site_id, inverter_id, measured_at, - pv_power_w, battery_soc_percent, battery_power_w, battery_voltage_v, - grid_power_w, grid_voltage_v, load_power_w, - inverter_temp_c, operating_mode, fault_code) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - ON CONFLICT (inverter_id, measured_at) DO NOTHING - """, - site_id, inverter.id, datetime.now(timezone.utc), - pv_power, batt_soc, batt_power, batt_voltage, - grid_power, grid_voltage, load_power, - inv_temp, str(op_mode), fault_code - ) - - except Exception as e: - logger.warning(f"Inverter poll failed [{inverter.code}]: {e}") - raise - - -async def run_collector(db): - """Hlavní smyčka – každých 60s sbírá data ze všech aktivních zařízení.""" - while True: - start = asyncio.get_event_loop().time() - - sites = await db.fetch("SELECT id FROM ems.site WHERE active = true") - for site in sites: - await asyncio.gather( - poll_all_inverters(site.id, db), - poll_all_ev_chargers(site.id, db), - poll_all_heat_pumps(site.id, db), - return_exceptions=True # jeden výpadek nezastaví ostatní - ) - - elapsed = asyncio.get_event_loop().time() - start - await asyncio.sleep(max(0, 60 - elapsed)) -``` +Implementace: `backend/services/telemetry_collector.py` — `poll_inverter()` používá konstanty `DEYE_REG_*` a třídu `ModbusDevice`; hlavní smyčka je `run_telemetry_loop` / `run_telemetry_loop_wrapper`. --- @@ -209,7 +151,7 @@ MODBUS_READ_TIMEOUT_SEC=3 ## Otevřené body -- [ ] Doplnit přesné Modbus registry Deye z PDF protokolu +- [x] Základní mapování Deye (holding registry 500–673) v `telemetry_collector.py` - [ ] Doplnit Modbus registry Teltonika z dokumentace / Loxone šablony - [ ] Doplnit Modbus registry Samsung z dokumentace / Loxone šablony - [ ] Ověřit Unit ID všech zařízení při instalaci diff --git a/docs/05-todo.md b/docs/05-todo.md index f327412..051fabe 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -6,7 +6,31 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec --- -## Blokující – nutné před prvním spuštěním +## Vyřešeno + +| Popis | +|-------| +| **Zelený bonus:** přesunuto na `asset_pv_array` (`green_bonus_*`), výpočet `fn_green_bonus_revenue()`, audit_filler (`fn_fill_audit_interval`) počítá bonus z výroby pole; legacy sloupce odstraněny ze `site_market_config` (V018). | +| **Rozšířený horizont plánování 96h** (fáze 3a+3b+3c): tabulky `market_price_stats`, `tuv_usage_stats`, funkce `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price` (V022 + `R__fn_extended_planning.sql`), solver váhy 1,0 / 0,7 / 0,4, joby 14:45 / 00:45 v `main.py`. | +| **Import OTE – robustní provoz:** timeouty + retry/backoff v `price_importer.py`, detailní error kódy v API, fallback D+1 → dnešek, scheduler importů 13:30 / 14:00 / 00:05. | +| **Fail-safe bez OTE dat:** při predikovaných cenách v kritickém okně je zákaz exportu; vybíjení baterie omezeno jen v predikovaných slotech; runtime guard v `control_exporter.py` brání SELL v nejistém stavu. | +| **Forecast provoz:** refresh každé 2 hodiny (`:05`), konfigurovatelný Open-Meteo horizont (`OPEN_METEO_FORECAST_DAYS`, default 7, clamp 2..16), endpoint pro UI vrací latest-run bez duplicit slotů. | +| **Telemetry – výroba FVE:** `pv_power_w` je součet `pv1 + pv2 + gen_port`, takže dashboard reflektuje obě pole i GEN větev instalace home-01. | +| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. | +| **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. | + +--- + +## Fáze 3d – rozšířený horizont (zbývá) + +| Popis | Kde | Kdo | +|-------|-----|-----| +| **EV v rozšířeném horizontu** (pravděpodobnost příjezdu, deadline přes 96h) – závisí na Tesla API / rozšíření modelu. | `docs/04-modules/planning-extended-horizon.md` | programátor | +| **Korekce predikce cen počasím** – potřeba 3+ měsíce korelačních dat. | stejný modul | programátor | + +--- + +## Blokující – nutné před prvním reálným provozem Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smysluplný forecast nebo dokončit kritická rozhodnutí před implementací řízení. @@ -19,7 +43,10 @@ Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smyslu | **Rozhodnout Teltonika: OCPP 1.6 vs REST API** před implementací EV řízení a sběru. | `docs/06-open-questions.md` ř. 9–10; `docs/04-modules/consumption.md` ř. 184 | majitel + programátor | | **Doplnit přesné Modbus registry** (čtení i zápis) pro Deye, Teltonika, Samsung – bez mapy registrů nejde napsat funkční `telemetry_collector` / `control_exporter`. | `docs/04-modules/telemetry.md` ř. 63, 76–105 (tabulky TBD), 212–214; `docs/04-modules/heat-pump.md` ř. 79–85, 102; `docs/04-modules/control.md` ř. 249–251; pseudokód `TBD_*_REGISTER` ř. 166–171, 192–197; `docs/loxone-integration.md` ř. 259–261 | majitel dodá PDF/šablony → programátor; část ověření s **Loxone programátor** | | Ověřit **Modbus registr Output Power Limit** (curtailment pole A) na Deye SUN-20K. | `docs/04-modules/planning.md` ř. 422 | programátor (+ dokumentace od majitele) | -| Doplnit **skutečnou výši zeleného bonusu** (`green_bonus_czk_kwh`) dle smlouvy – aktuálně placeholder. | `db/migration/V005__planning_curtailment.sql` ř. 45–50 | majitel (smlouva) → programátor | +| Doplnit **skutečnou sazbu zeleného bonusu** do `asset_pv_array.green_bonus_czk_kwh` pro `pv-b` (aktuální placeholder: **7.135** Kč/kWh – ověřit ze smlouvy s EG.D). | `db/migration/V017__green_bonus.sql` (seed `pv-b`) | majitel (smlouva) → programátor | +| Doplnit **`green_bonus_meter_code`** (EAN zeleného elektroměru) pro `pv-b` v `asset_pv_array`. | `db/migration/V017__green_bonus.sql` / přímá úprava DB | majitel → programátor | +| Nastavit **`DISCORD_WEBHOOK_URL`** pro produkční alerty (Modbus mismatch, přepnutí SELF_SUSTAIN). | `.env` / `backend/app/config.py` | majitel → programátor | +| **Cut-off přepínač** pro mikroinvertory (druhá instalace) – napojit logiku na `ems.cutoff_switch_log` a řízení. | `docs/04-modules/modbus-command-journal.md` | programátor | --- @@ -41,7 +68,6 @@ Potřebné pro reálný, stabilní provoz; lze část EMS otestovat bez nich (na | **Přístup k logu** přepnutí watchdogu pro EMS po restartu. | `docs/loxone-integration.md` ř. 263 | Loxone programátor + programátor | | Implementace **Loxone watchdog** dle integračního dokumentu. | `docs/04-modules/operating-modes.md` ř. 131; celý `docs/loxone-integration.md` | Loxone programátor + programátor | | **Post-processing min_run/min_stop** TČ po výstupu LP (krátké ON/OFF). | `docs/04-modules/planning.md` ř. 419 | programátor | -| **Zelený bonus** započítat do **auditního** výpočtu nákladů, ne jen do optimalizace. | `docs/04-modules/planning.md` ř. 420 | programátor | | **EV:** přesnější než agregát – sladit s `ev1_setpoint_w` / `ev2_setpoint_w` v DB a solveru. | `docs/04-modules/planning.md` ř. 421 | programátor | | **Test solveru** na reálných datech (výkon pro 36h / 144 slotů). | `docs/04-modules/planning.md` ř. 423 | programátor | | **Optimalizace čtení Deye** – jeden blok `read_holding_registers`. | `docs/04-modules/telemetry.md` ř. 216 | programátor | diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 79a4fac..e230499 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -16,6 +16,15 @@ server { application/xml+rss text/plain; + location /ws/ { + proxy_pass http://backend:8000/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } + location /api/ { proxy_pass http://backend:8000/api/; proxy_http_version 1.1; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bd40992..db3a5d9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,9 +7,11 @@ "name": "ems-frontend", "dependencies": { "axios": "^1.7.9", + "chart.js": "^4.4.8", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^7.6.0", "recharts": "^2.15.0", "sonner": "^1.4.0" }, @@ -733,6 +735,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1664,6 +1671,17 @@ } ] }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1689,6 +1707,18 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2649,6 +2679,42 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -2770,6 +2836,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -3345,6 +3416,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3907,6 +3983,14 @@ "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "dev": true }, + "chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "requires": { + "@kurkle/color": "^0.3.0" + } + }, "clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3926,6 +4010,11 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==" + }, "csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4507,6 +4596,23 @@ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true }, + "react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "requires": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + } + }, + "react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "requires": { + "react-router": "7.13.1" + } + }, "react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -4600,6 +4706,11 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true }, + "set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, "sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 49b8f23..4620fee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,10 +8,12 @@ "preview": "vite preview" }, "dependencies": { + "chart.js": "^4.4.8", "axios": "^1.7.9", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^7.6.0", "recharts": "^2.15.0", "sonner": "^1.4.0" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 014797b..69d5062 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,49 +1,63 @@ -import { useState } from 'react' import { Toaster } from 'sonner' -import Planning from './pages/Planning' +import { NavLink, Outlet, Route, Routes } from 'react-router-dom' + +import { useWsLogErrorCount } from './hooks/useWsLogErrorCount' import { Dashboard } from './pages/Dashboard' +import { Logs } from './pages/Logs' +import Planning from './pages/Planning' import { Settings } from './pages/Settings' -type Page = 'dashboard' | 'planning' | 'settings' +function AppLayout() { + const logErrors = useWsLogErrorCount(true) -export default function App() { - const [page, setPage] = useState('dashboard') + const tabClass = ({ isActive }: { isActive: boolean }) => + `rounded-lg px-3 py-2 text-sm font-medium transition ${ + isActive ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200' + }` return (
- {page === 'dashboard' ? : page === 'planning' ? : } +
) } + +export default function App() { + return ( + + }> + } /> + } /> + } /> + + } /> + + ) +} diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index a4d6ba5..5444823 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -1,6 +1,7 @@ import axios, { type AxiosInstance } from 'axios' import type { FullStatusResponse } from '../types/fullStatus' +import type { Notification } from '../types/dashboard' import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan' const client: AxiosInstance = axios.create({ @@ -34,6 +35,19 @@ export async function getSiteStatusFull(siteId: number): Promise { + const { data } = await client.get(`/sites/${siteId}/notifications`, { + timeout: 30_000, + }) + return { + notifications: Array.isArray(data?.notifications) ? data.notifications : [], + } +} + export type SetSiteModePayload = { mode: string notes: string | null @@ -61,6 +75,72 @@ export async function getCurrentPlan(siteId: number): Promise { + const { data } = await client.get(`/sites/${siteId}/prices`, { + params: { date }, + timeout: 60_000, + }) + return Array.isArray(data) ? data : [] +} + +export type ForecastPvIntervalRow = { + interval_start: string + power_w?: number | string | null + pv_array_id?: number +} + +export type ForecastPvDayResponse = { + pv_a: ForecastPvIntervalRow[] + pv_b: ForecastPvIntervalRow[] +} + +export async function getSiteForecastPv(siteId: number, date: string): Promise { + const { data } = await client.get(`/sites/${siteId}/forecast/pv`, { + params: { date }, + timeout: 60_000, + }) + return { + pv_a: Array.isArray(data?.pv_a) ? data.pv_a : [], + pv_b: Array.isArray(data?.pv_b) ? data.pv_b : [], + } +} + +export type NegPricePredictionDto = { + predicted_date: string + window_start_hour: number + window_end_hour: number + probability_pct: number + expected_min_price: number | null + reason: string +} + +export type NegativePredictionsResponseDto = { + predictions: NegPricePredictionDto[] + insufficient_history: boolean +} + +export async function getNegativePricePredictions( + siteId: number, +): Promise { + const { data } = await client.get( + `/sites/${siteId}/prices/negative-predictions`, + { timeout: 30_000 }, + ) + return { + predictions: Array.isArray(data?.predictions) ? data.predictions : [], + insufficient_history: Boolean(data?.insufficient_history), + } +} + export async function postRunPlan( siteId: number, planType: 'daily' | 'rolling', @@ -155,4 +235,52 @@ export async function patchEvSession( return data } +/** Živé hodnoty registrů Deye (GET …/control/registers). */ +export type DeyeRegistersLive = { + reg108_charge_a: number + reg109_discharge_a: number + reg141_energy_mode: number + reg142_limit_control: number + reg143_export_limit_w: number + reg178_peak_shaving_switch: number + reg191_peak_shaving_w: number + read_at: string +} + +export async function getDeyeRegisters(siteId: number): Promise { + const { data } = await client.get(`/sites/${siteId}/control/registers`, { + timeout: 15_000, + }) + return data +} + +export type ModbusJournalCommandDto = { + id: number + register: number + register_name: string | null + value_to_write: number + value_written: number | null + value_verified: number | null + status: string + attempt_count: number + created_at: string +} + +export type ModbusJournalResponse = { + commands: ModbusJournalCommandDto[] +} + +export async function getCommandJournal( + siteId: number, + limit = 50, +): Promise { + const { data } = await client.get( + `/sites/${siteId}/control/journal`, + { params: { limit }, timeout: 15_000 }, + ) + return { + commands: Array.isArray(data?.commands) ? data.commands : [], + } +} + export { client as backendClient } diff --git a/frontend/src/components/ControlPanel.tsx b/frontend/src/components/ControlPanel.tsx new file mode 100644 index 0000000..4b03892 --- /dev/null +++ b/frontend/src/components/ControlPanel.tsx @@ -0,0 +1,307 @@ +import axios from 'axios' +import { RefreshCw } from 'lucide-react' +import { memo, useCallback, useEffect, useState } from 'react' + +import { getCommandJournal, getDeyeRegisters, type DeyeRegistersLive, type ModbusJournalCommandDto } from '../api/backend' + +const BATT_VOLTAGE_V = 51.2 +const POLL_REGISTERS_MS = 30_000 +const POLL_JOURNAL_MS = 60_000 + +const TZ = 'Europe/Prague' + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleString('cs-CZ', { + timeZone: TZ, + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function ampsToKw(a: number | null | undefined): string { + if (a == null || Number.isNaN(a)) return '—' + return `${((a * BATT_VOLTAGE_V) / 1000).toFixed(2)} kW` +} + +function fmtW(w: number | null | undefined): string { + if (w == null || Number.isNaN(w)) return '—' + return `${w} W` +} + +function journalSignature(cmds: ModbusJournalCommandDto[]): string { + return cmds + .map( + (c) => + `${c.id}:${c.status}:${c.attempt_count}:${c.value_written ?? ''}:${c.value_verified ?? ''}`, + ) + .join('|') +} + +function statusBadgeClass(status: string): string { + const u = status.toLowerCase() + if (u === 'verified') return 'bg-emerald-600/25 text-emerald-200 ring-1 ring-emerald-500/40' + if (u === 'written') return 'bg-sky-600/25 text-sky-200 ring-1 ring-sky-500/40' + if (u === 'pending' || u === 'retrying') return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35' + if (u === 'failed' || u === 'mismatch') + return u === 'mismatch' + ? 'bg-red-600/30 text-red-100 font-bold ring-1 ring-red-500/50' + : 'bg-red-600/25 text-red-200 ring-1 ring-red-500/40' + return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35' +} + +type LiveSectionProps = { + live: DeyeRegistersLive | null + liveLoading: boolean + onRefresh: () => void +} + +const LiveRegistersSection = memo( + function LiveRegistersSection({ live, liveLoading, onRefresh }: LiveSectionProps) { + return ( +
+
+

Živé registry

+ +
+
+ + + + + + +
+ {live?.read_at ? ( +

Načteno: {fmtTime(live.read_at)}

+ ) : null} +
+ ) + }, + (a, b) => + a.liveLoading === b.liveLoading && + a.live?.read_at === b.live?.read_at && + a.live?.reg108_charge_a === b.live?.reg108_charge_a && + a.live?.reg109_discharge_a === b.live?.reg109_discharge_a && + a.live?.reg141_energy_mode === b.live?.reg141_energy_mode && + a.live?.reg142_limit_control === b.live?.reg142_limit_control && + a.live?.reg143_export_limit_w === b.live?.reg143_export_limit_w && + a.live?.reg178_peak_shaving_switch === b.live?.reg178_peak_shaving_switch && + a.live?.reg191_peak_shaving_w === b.live?.reg191_peak_shaving_w, +) + +type MetricProps = { + label: string + reg: number + unitA?: number | null + kwHint?: boolean + valueText?: string + sub?: string +} + +function Metric({ label, reg, unitA, kwHint, valueText, sub }: MetricProps) { + const main = + valueText ?? + (unitA != null && !Number.isNaN(unitA) ? `${unitA} A` : '—') + const extra = kwHint ? ampsToKw(unitA ?? null) : null + return ( +
+

{label}

+

+ reg {reg}: {main} + {extra && extra !== '—' ? · {extra} : null} +

+ {sub ?

{sub}

: null} +
+ ) +} + +type JournalSectionProps = { + commands: ModbusJournalCommandDto[] +} + +const JournalSection = memo( + function JournalSection({ commands }: JournalSectionProps) { + return ( +
+

Posledních 50 zápisů

+
+ + + + + + + + + + + + + {commands.length === 0 ? ( + + + + ) : ( + commands.map((c) => ( + + + + + + + + + )) + )} + +
ČasRegPopisHodnotaPokusStatus
+ Žádné záznamy v journalu. +
+ {fmtTime(c.created_at)} + {c.register} + {c.register_name ?? '—'} + + {c.value_to_write} + {c.value_verified != null ? ( + → {c.value_verified} + ) : null} + {c.attempt_count} + + {c.status} + +
+
+
+ ) + }, + (a, b) => journalSignature(a.commands) === journalSignature(b.commands), +) + +function ControlPanelImpl({ siteId }: { siteId: number }) { + const [live, setLive] = useState(null) + const [liveError, setLiveError] = useState(null) + const [liveLoading, setLiveLoading] = useState(false) + + const [commands, setCommands] = useState([]) + const [journalError, setJournalError] = useState(null) + + const fetchRegisters = useCallback(async () => { + setLiveLoading(true) + setLiveError(null) + try { + const data = await getDeyeRegisters(siteId) + setLive(data) + } catch (e: unknown) { + let msg = 'Chyba čtení registrů' + if (axios.isAxiosError(e)) { + const d = e.response?.data as { detail?: string } | undefined + if (typeof d?.detail === 'string') msg = d.detail + } else if (e instanceof Error) { + msg = e.message + } + setLiveError(msg) + setLive(null) + } finally { + setLiveLoading(false) + } + }, [siteId]) + + const fetchJournal = useCallback(async () => { + setJournalError(null) + try { + const res = await getCommandJournal(siteId, 50) + setCommands(res.commands) + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Chyba načtení journalu' + setJournalError(msg) + setCommands([]) + } + }, [siteId]) + + useEffect(() => { + void fetchRegisters() + }, [fetchRegisters]) + + useEffect(() => { + void fetchJournal() + }, [fetchJournal]) + + useEffect(() => { + const t = window.setInterval(() => void fetchRegisters(), POLL_REGISTERS_MS) + return () => window.clearInterval(t) + }, [fetchRegisters]) + + useEffect(() => { + const t = window.setInterval(() => void fetchJournal(), POLL_JOURNAL_MS) + return () => window.clearInterval(t) + }, [fetchJournal]) + + const apiError = liveError ?? journalError + + return ( +
+ {apiError ? ( +
+

Chyba API řízení / Modbus

+ {liveError ? ( +

+ GET …/control/registers: + {liveError} +

+ ) : null} + {journalError ? ( +

+ Journal: + {journalError} +

+ ) : null} +
+ ) : null} +
+ + +
+
+ ) +} + +export const ControlPanel = memo(ControlPanelImpl) diff --git a/frontend/src/components/ModeBar.tsx b/frontend/src/components/ModeBar.tsx new file mode 100644 index 0000000..2f8cc01 --- /dev/null +++ b/frontend/src/components/ModeBar.tsx @@ -0,0 +1,77 @@ +type Props = { + modeName: string + activatedAt: string | null + nextReplanIn: number | null + onReplan: () => void + onModeChange: () => void +} + +const MODE_DOT: Record = { + AUTO: '#1D9E75', + SELF_SUSTAIN: '#E24B4A', + CHARGE_CHEAP: '#EF9F27', + PRESERVE: '#378ADD', + MANUAL: '#888780', +} + +function fmtActivatedPrague(iso: string | null): string | null { + if (!iso) return null + return new Intl.DateTimeFormat('cs-CZ', { + timeZone: 'Europe/Prague', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(new Date(iso)) +} + +export function ModeBar({ modeName, activatedAt, nextReplanIn, onReplan, onModeChange }: Props) { + const code = (modeName || 'AUTO').toUpperCase().replace(/-/g, '_') + const dot = MODE_DOT[code] ?? MODE_DOT.MANUAL! + const tAct = fmtActivatedPrague(activatedAt) + const subParts: string[] = [] + if (tAct) subParts.push(`aktivní od ${tAct}`) + if (nextReplanIn != null) subParts.push(`příští replan za ${nextReplanIn} min`) + + return ( + <> + +
+
+ + {code} + {subParts.length > 0 ? ( + {subParts.join(' · ')} + ) : null} +
+
+ + +
+
+ + ) +} diff --git a/frontend/src/components/NegPricePanel.tsx b/frontend/src/components/NegPricePanel.tsx new file mode 100644 index 0000000..fc96c3e --- /dev/null +++ b/frontend/src/components/NegPricePanel.tsx @@ -0,0 +1,64 @@ +export interface NegPricePrediction { + predicted_date: string + window_start_hour: number + window_end_hour: number + probability_pct: number + expected_min_price: number | null + reason: string +} + +function pad2(n: number): string { + return n.toString().padStart(2, '0') +} + +function borderClass(pct: number): string { + if (pct >= 70) return 'border-l-emerald-500' + if (pct >= 50) return 'border-l-amber-400' + return 'border-l-slate-500' +} + +export function NegPricePanel({ + predictions, + insufficientHistory = false, +}: { + predictions: NegPricePrediction[] + insufficientHistory?: boolean +}) { + if (insufficientHistory) { + return ( +
+ Predikce bude dostupná po 4 týdnech provozu. +
+ ) + } + + if (!predictions.length) { + return ( +
+ Žádné záporné ceny v příštích 7 dnech. +
+ ) + } + + return ( +
+ {predictions.map((p, i) => ( +
+

+ {p.predicted_date} · {pad2(p.window_start_hour)}:00–{pad2(p.window_end_hour)}:00 +

+

{p.reason}

+

+ {p.probability_pct.toFixed(0)}% jistota + {p.expected_min_price != null + ? ` · očekávané min. ${p.expected_min_price.toFixed(2)} Kč/kWh` + : ''} +

+
+ ))} +
+ ) +} diff --git a/frontend/src/components/NotificationBar.tsx b/frontend/src/components/NotificationBar.tsx new file mode 100644 index 0000000..ba56669 --- /dev/null +++ b/frontend/src/components/NotificationBar.tsx @@ -0,0 +1,140 @@ +import type { Notification, NotificationAction, NotificationLevel } from '../types/dashboard' + +type Props = { + notifications: Notification[] + onReplan?: () => void + onImportPrices?: () => void + onSwitchAuto?: () => void +} + +const LEVEL_STYLES: Record< + NotificationLevel, + { bg: string; border: string; icon: string; iconClass: string } +> = { + success: { + bg: '#1D9E7508', + border: '#1D9E7544', + icon: '⚡', + iconClass: 'text-emerald-500', + }, + info: { + bg: '#E6F1FB08', + border: '#378ADD44', + icon: 'ℹ', + iconClass: 'text-blue-400', + }, + warning: { + bg: '#EF9F2708', + border: '#EF9F2744', + icon: '⚠', + iconClass: 'text-amber-400', + }, + error: { + bg: '#E24B4A08', + border: '#E24B4A44', + icon: '✕', + iconClass: 'text-red-400', + }, +} + +function fmtEtaMinutes(mins: number): string { + const h = Math.floor(mins / 60) + const m = mins % 60 + if (h > 0) return `za ${h}h ${m}min` + return `za ${m}min` +} + +function ActionControls({ + action, + onReplan, + onImportPrices, + onSwitchAuto, +}: { + action: NotificationAction | null | undefined + onReplan?: () => void + onImportPrices?: () => void + onSwitchAuto?: () => void +}) { + if (action === 'connect_ev') { + return ( + + Připoj auto + + ) + } + if (action === 'replan') { + return ( + + ) + } + if (action === 'import_prices') { + return ( + + ) + } + if (action === 'switch_auto') { + return ( + + ) + } + return null +} + +export function NotificationBar({ notifications, onReplan, onImportPrices, onSwitchAuto }: Props) { + const shown = notifications.slice(0, 2) + if (shown.length === 0) return null + + return ( +
+ {shown.map((n) => { + const st = LEVEL_STYLES[n.level] ?? LEVEL_STYLES.info! + return ( +
+ + {st.icon} + +
+
+

{n.title}

+ {n.eta_minutes != null && n.eta_minutes >= 0 ? ( + {fmtEtaMinutes(n.eta_minutes)} + ) : null} +
+

{n.body}

+
+ +
+
+
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/StatePanel.tsx b/frontend/src/components/StatePanel.tsx new file mode 100644 index 0000000..8e80af2 --- /dev/null +++ b/frontend/src/components/StatePanel.tsx @@ -0,0 +1,496 @@ +import { memo, useMemo } from 'react' + +import { SLOT_MS, TOTAL_SLOTS } from './charts/chartConstants' +import type { SlotData } from '../types/dashboard' + +export type StatePanelProps = { + slots: SlotData[] + nowIndex: number +} + +/** Stav segmentu pro jeden track */ +export type TrackSegment = { + widthPct: number + label: string + color: string + textColor: string + isFuture: boolean + tooltip?: string + /** Zákaz exportu (záporná prodejní cena) – overlay „0!“ */ + exportBanOverlay?: boolean +} + +const TIME_PRAGUE = new Intl.DateTimeFormat('cs-CZ', { + timeZone: 'Europe/Prague', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}) + +function slotRangeLabel(slots: SlotData[], i0: number, i1: number): string { + const t0 = new Date(slots[i0]!.interval_start).getTime() + const t1 = new Date(slots[i1]!.interval_start).getTime() + SLOT_MS + return `${TIME_PRAGUE.format(t0)}–${TIME_PRAGUE.format(t1)}` +} + +function fmtMoney(v: number | null | undefined): string | null { + if (v == null || Number.isNaN(v)) return null + return `${v.toFixed(2)} Kč/kWh` +} + +function avg(nums: number[]): number { + if (nums.length === 0) return 0 + return nums.reduce((a, b) => a + b, 0) / nums.length +} + +type GridKind = 'import' | 'export' | 'idle' + +function gridKind(s: SlotData): GridKind { + const sp = s.grid_setpoint_w + const pw = s.grid_power_w + const imp = (sp != null && sp > 500) || (pw != null && pw > 500) + const exp = (sp != null && sp < -500) || (pw != null && pw < -500) + if (imp) return 'import' + if (exp) return 'export' + return 'idle' +} + +function gridFlowW(s: SlotData): number { + return s.grid_setpoint_w ?? s.grid_power_w ?? 0 +} + +export function buildGridSegments(slots: SlotData[], nowIndex: number): TrackSegment[] { + const n = slots.length + if (n === 0) return [] + const out: TrackSegment[] = [] + let i = 0 + while (i < n) { + const g = gridKind(slots[i]!) + const neg = slots[i]!.sell_price != null && slots[i]!.sell_price! < 0 + const fut = i > nowIndex + const start = i + const gw: number[] = [] + const buys: number[] = [] + const sells: number[] = [] + while (i < n) { + const s = slots[i]! + if (gridKind(s) !== g) break + if ((s.sell_price != null && s.sell_price < 0) !== neg) break + if ((i > nowIndex) !== fut) break + gw.push(gridFlowW(s)) + if (s.buy_price != null) buys.push(s.buy_price) + if (s.sell_price != null) sells.push(s.sell_price) + i++ + } + const count = i - start + const widthPct = (count / n) * 100 + const avgW = avg(gw) + const avgBuy = buys.length ? avg(buys) : null + const avgSell = sells.length ? avg(sells) : null + + let color = '#88878012' + let textColor = '#5F5E5A' + let label = '–' + if (g === 'import') { + color = '#E24B4A1A' + textColor = '#993C1D' + label = `↓ ${(avgW / 1000).toFixed(1)} kW` + } else if (g === 'export') { + color = '#1D9E751A' + textColor = '#0F6E56' + label = `↑ ${(Math.abs(avgW) / 1000).toFixed(1)} kW` + } + + const range = slotRangeLabel(slots, start, i - 1) + let tooltip = range + if (g === 'import') { + tooltip += ` · import ${(avgW / 1000).toFixed(1)} kW` + const p = fmtMoney(avgBuy) + if (p) tooltip += ` · cena nákup ${p}` + } else if (g === 'export') { + tooltip += ` · export ${(Math.abs(avgW) / 1000).toFixed(1)} kW` + const p = fmtMoney(avgSell) + if (p) tooltip += ` · cena prodej ${p}` + } else { + tooltip += ' · síť v klidu' + const p = fmtMoney(avgSell ?? avgBuy) + if (p) tooltip += ` · cena ${p}` + } + + out.push({ + widthPct, + label, + color, + textColor, + isFuture: fut, + tooltip, + exportBanOverlay: neg, + }) + } + return out +} + +type BatKind = 'fve' | 'grid' | 'dis' | 'idle' + +function batKind(s: SlotData): BatKind { + const bsp = s.battery_setpoint_w + const bpw = s.battery_power_w + const gsp = s.grid_setpoint_w + const gpw = s.grid_power_w + + if ((bsp != null && bsp < -500) || (bpw != null && bpw < -500)) return 'dis' + + const gridHeavy = (gsp != null && gsp > 500) || (gpw != null && gpw > 500) + + if (bsp != null && bsp > 500) { + if (gsp != null && gsp > 500) return 'grid' + if (gridHeavy) return 'grid' + return 'fve' + } + + if (bpw != null && bpw > 500) { + if (gridHeavy) return 'grid' + return 'fve' + } + + return 'idle' +} + +export function buildBatterySegments(slots: SlotData[], nowIndex: number): TrackSegment[] { + const n = slots.length + if (n === 0) return [] + const out: TrackSegment[] = [] + let i = 0 + while (i < n) { + const k = batKind(slots[i]!) + const fut = i > nowIndex + const start = i + while (i < n) { + if (batKind(slots[i]!) !== k) break + if ((i > nowIndex) !== fut) break + i++ + } + const count = i - start + const widthPct = (count / n) * 100 + let color = '#88878012' + let textColor = '#5F5E5A' + let label = '–' + if (k === 'fve') { + color = '#1D9E751A' + textColor = '#0F6E56' + label = 'nabíjení FVE' + } else if (k === 'grid') { + color = '#378ADD1A' + textColor = '#185FA5' + label = 'nabíjení sítě' + } else if (k === 'dis') { + color = '#EF9F271A' + textColor = '#854F0B' + label = 'vybíjení' + } + const range = slotRangeLabel(slots, start, i - 1) + out.push({ + widthPct, + label, + color, + textColor, + isFuture: fut, + tooltip: `${range} · ${label}`, + }) + } + return out +} + +export type DeviceKind = 'ev1' | 'ev2' | 'tc' + +function evSegmentKind(sp: number | null): 'charge' | 'idle' | 'off' { + if (sp === null) return 'off' + if (sp > 0) return 'charge' + return 'idle' +} + +function tcRunning(s: SlotData): boolean { + if (s.heat_pump_enabled === true) return true + if (s.heat_pump_enabled === false) return false + return (s.heat_pump_setpoint_w ?? 0) > 0 +} + +export function buildDeviceSegments( + slots: SlotData[], + nowIndex: number, + device: DeviceKind, +): TrackSegment[] { + const n = slots.length + if (n === 0) return [] + const out: TrackSegment[] = [] + + if (device === 'tc') { + let i = 0 + while (i < n) { + const on = tcRunning(slots[i]!) + const fut = i > nowIndex + const start = i + while (i < n) { + if (tcRunning(slots[i]!) !== on) break + if ((i > nowIndex) !== fut) break + i++ + } + const count = i - start + const widthPct = (count / n) * 100 + out.push({ + widthPct, + label: on ? '6kW' : '–', + color: on ? '#D4537E1A' : '#88878012', + textColor: on ? '#8B3055' : '#5F5E5A', + isFuture: fut, + tooltip: `${slotRangeLabel(slots, start, i - 1)} · ${on ? 'TČ běží' : 'TČ odstaveno'}`, + }) + } + return out + } + + const pick = (s: SlotData) => (device === 'ev1' ? s.ev1_setpoint_w : s.ev2_setpoint_w) + + let i = 0 + while (i < n) { + const ek = evSegmentKind(pick(slots[i]!)) + const fut = i > nowIndex + const start = i + const pws: number[] = [] + while (i < n) { + const s = slots[i]! + if (evSegmentKind(pick(s)) !== ek) break + if ((i > nowIndex) !== fut) break + const sp = pick(s) + if (sp != null && sp > 0) pws.push(sp) + i++ + } + const count = i - start + const widthPct = (count / n) * 100 + if (ek === 'off') { + out.push({ + widthPct, + label: 'nepřipojeno', + color: '#88878008', + textColor: '#5F5E5A', + isFuture: fut, + tooltip: `${slotRangeLabel(slots, start, i - 1)} · vozidlo nepřipojeno`, + }) + } else if (ek === 'charge') { + const avgW = avg(pws.length ? pws : [0]) + out.push({ + widthPct, + label: `${(avgW / 1000).toFixed(1)} kW`, + color: '#534AB71A', + textColor: '#3D3480', + isFuture: fut, + tooltip: `${slotRangeLabel(slots, start, i - 1)} · nabíjení ${(avgW / 1000).toFixed(1)} kW`, + }) + } else { + out.push({ + widthPct, + label: '–', + color: '#88878012', + textColor: '#5F5E5A', + isFuture: fut, + tooltip: `${slotRangeLabel(slots, start, i - 1)} · klid`, + }) + } + } + return out +} + +function isFourHourTick(iso: string): boolean { + const d = new Date(iso) + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Europe/Prague', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(d) + const hi = parts.find((p) => p.type === 'hour') + const mi = parts.find((p) => p.type === 'minute') + if (!hi || !mi) return false + const h = parseInt(hi.value, 10) + const m = parseInt(mi.value, 10) + return m === 0 && h % 4 === 0 +} + +function TickRow({ slots }: { slots: SlotData[] }) { + const n = slots.length + if (n === 0) return null + return ( +
+ {slots.map((s, i) => + isFourHourTick(s.interval_start) ? ( + + {TIME_PRAGUE.format(new Date(s.interval_start))} + + ) : null, + )} +
+ ) +} + +function SegmentBar({ + segments, + nowIndex, + showNowLabel, +}: { + segments: TrackSegment[] + nowIndex: number + showNowLabel?: boolean +}) { + const n = TOTAL_SLOTS + const leftPct = (nowIndex / n) * 100 + return ( +
+
+ {segments.map((seg, idx) => ( +
+ {seg.label} + {seg.exportBanOverlay ? ( +
+ 0! +
+ ) : null} +
+ ))} +
+
+ {showNowLabel ? ( + + teď + + ) : null} +
+
+
+ ) +} + +function TrackRow({ + label, + segments, + nowIndex, + showNowLabel, +}: { + label: string + segments: TrackSegment[] + nowIndex: number + showNowLabel?: boolean +}) { + return ( +
+
{label}
+ +
+ ) +} + +function StatePanelRaw({ slots, nowIndex }: StatePanelProps) { + const gridSegs = useMemo(() => buildGridSegments(slots, nowIndex), [slots, nowIndex]) + const batSegs = useMemo(() => buildBatterySegments(slots, nowIndex), [slots, nowIndex]) + const ev1Segs = useMemo(() => buildDeviceSegments(slots, nowIndex, 'ev1'), [slots, nowIndex]) + const ev2Segs = useMemo(() => buildDeviceSegments(slots, nowIndex, 'ev2'), [slots, nowIndex]) + const tcSegs = useMemo(() => buildDeviceSegments(slots, nowIndex, 'tc'), [slots, nowIndex]) + + if (slots.length === 0) return null + + return ( +
+
+

+ Energetický tok +

+
+ + +
+
+
+ +
+
    +
  • + + Import +
  • +
  • + + Export +
  • +
  • + + Klid +
  • +
  • + + Zákaz exportu (0!) +
  • +
+
+ +
+

+ Variabilní zátěže +

+
+ + + +
+
+
+ +
+
    +
  • + + EV nabíjení +
  • +
  • + + Nepřipojeno +
  • +
  • + + TČ běh +
  • +
+
+
+ ) +} + +export const StatePanel = memo(StatePanelRaw, (prev, next) => { + return prev.slots === next.slots && prev.nowIndex === next.nowIndex +}) diff --git a/frontend/src/components/charts/EnergyChart.tsx b/frontend/src/components/charts/EnergyChart.tsx new file mode 100644 index 0000000..80f052a --- /dev/null +++ b/frontend/src/components/charts/EnergyChart.tsx @@ -0,0 +1,339 @@ +import { useEffect, useMemo, useRef } from 'react' +import { Chart } from 'chart.js/auto' +import type { ChartArea, TooltipItem } from 'chart.js' + +import type { SlotData } from '../../types/dashboard' +import { CHART_LAYOUT_PADDING } from './chartConstants' +import { + computeNegWeekendRanges, + createNowLinePluginRef, + createSlotBackgroundPluginRefs, +} from './chartPlugins' + +const COL = { + fve: '#EF9F27', + baz: '#378ADD', + ev: '#534AB7', + tc: '#D4537E', + bat: '#1D9E75', + sit: '#E24B4A', + buy: '#E24B4A', + sell: '#1D9E75', +} as const + +function kwFromW(w: number | null | undefined): number | null { + if (w == null || Number.isNaN(Number(w))) return null + return Number(w) / 1000 +} + +function sumW(a: number | null, b: number | null): number | null { + if (a == null && b == null) return null + return (a ?? 0) + (b ?? 0) +} + +export type EnergyLegendItem = { key: string; label: string; color: string; dashed?: boolean } + +export const ENERGY_LEGEND: EnergyLegendItem[] = [ + { key: 'fve_real', label: 'FVE skutečnost', color: COL.fve }, + { key: 'fve_pred', label: 'FVE předpověď', color: COL.fve, dashed: true }, + { key: 'baz_real', label: 'Spotřeba skutečnost', color: COL.baz }, + { key: 'baz_pred', label: 'Spotřeba předpověď', color: COL.baz, dashed: true }, + { key: 'ev', label: 'EV plán', color: COL.ev }, + { key: 'tc', label: 'TČ plán', color: COL.tc }, + { key: 'bat', label: 'Baterie', color: COL.bat }, + { key: 'sit', label: 'Síť', color: COL.sit }, + { key: 'buy_price', label: 'Cena nákup', color: COL.buy, dashed: true }, + { key: 'sell_price', label: 'Cena prodej', color: COL.sell, dashed: true }, +] + +type Props = { + slots: SlotData[] + nowIndex: number + hidden: Set + onToggle: (key: string) => void + onChartArea?: (area: ChartArea) => void +} + +export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: Props) { + const canvasRef = useRef(null) + const chartRef = useRef(null) + const onChartAreaRef = useRef(onChartArea) + onChartAreaRef.current = onChartArea + + const slotsRef = useRef([]) + const negRangesRef = useRef>([]) + const nowIndexRef = useRef(0) + const labelsRef = useRef([]) + + slotsRef.current = slots + nowIndexRef.current = nowIndex + + const labels = useMemo( + () => + slots.map((s) => { + const d = new Date(s.interval_start) + return d.toLocaleTimeString('cs-CZ', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Prague', + }) + }), + [slots], + ) + labelsRef.current = labels + + const negRanges = useMemo(() => computeNegWeekendRanges(slots, nowIndex), [slots, nowIndex]) + negRangesRef.current = negRanges + + const windowKey = useMemo( + () => (slots.length ? `${slots[0]!.interval_start}|${slots.length}` : ''), + [slots], + ) + + const series = useMemo(() => { + const fveReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.pv_power_w) : null)) + const fvePred = slots.map((s) => kwFromW(sumW(s.pv_a_forecast_w, s.pv_b_forecast_w))) + const bazReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.load_power_w) : null)) + const bazPred = slots.map((s) => kwFromW(s.load_baseline_w)) + const ev = slots.map((s) => kwFromW(sumW(s.ev1_setpoint_w, s.ev2_setpoint_w))) + const tc = slots.map((s) => kwFromW(s.heat_pump_setpoint_w)) + const bat = slots.map((s, i) => + i <= nowIndex ? kwFromW(s.battery_power_w) : kwFromW(s.battery_setpoint_w), + ) + const sit = slots.map((s, i) => + i <= nowIndex ? kwFromW(s.grid_power_w) : kwFromW(s.grid_setpoint_w), + ) + const buy = slots.map((s) => (s.buy_price == null ? null : s.buy_price)) + const sell = slots.map((s) => (s.sell_price == null ? null : s.sell_price)) + return { fveReal, fvePred, bazReal, bazPred, ev, tc, bat, sit, buy, sell } + }, [slots, nowIndex]) + + const bgPlugin = useMemo( + () => createSlotBackgroundPluginRefs(slotsRef, negRangesRef), + [], + ) + const nowPlugin = useMemo(() => createNowLinePluginRef(nowIndexRef, 'teď'), []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || !windowKey) return + + const mkDs = ( + key: string, + label: string, + d: (number | null)[], + color: string, + opts: { + fill?: boolean | 'origin' + dashed?: boolean + yAxisID?: string + order: number + borderWidth?: number + }, + ) => ({ + label, + data: d, + borderColor: color, + backgroundColor: + opts.fill === true ? `${color}33` : opts.fill === 'origin' ? `${color}40` : undefined, + fill: opts.fill ?? false, + borderDash: opts.dashed ? [5, 4] : undefined, + borderWidth: opts.borderWidth ?? (opts.dashed ? 1 : 1.2), + pointRadius: 0, + hitRadius: 6, + tension: 0.15, + yAxisID: opts.yAxisID ?? 'y', + order: opts.order, + hidden: hidden.has(key), + }) + + const chart = new Chart(canvas, { + type: 'line', + plugins: [bgPlugin, nowPlugin], + data: { + labels: [...labels], + datasets: [ + mkDs('sit', 'Síť', series.sit, COL.sit, { fill: 'origin', order: 2 }), + mkDs('bat', 'Baterie', series.bat, COL.bat, { fill: 'origin', order: 3 }), + mkDs('ev', 'EV plán', series.ev, COL.ev, { fill: true, order: 4 }), + mkDs('tc', 'TČ plán', series.tc, COL.tc, { fill: true, order: 5 }), + mkDs('baz_real', 'Spotřeba ■', series.bazReal, COL.baz, { fill: true, order: 6 }), + mkDs('fve_real', 'FVE ■', series.fveReal, COL.fve, { fill: true, order: 7 }), + mkDs('baz_pred', 'Spotřeba ···', series.bazPred, COL.baz, { dashed: true, order: 8 }), + mkDs('fve_pred', 'FVE ···', series.fvePred, COL.fve, { dashed: true, order: 9 }), + mkDs('buy_price', 'Nákup', series.buy, COL.buy, { + dashed: true, + yAxisID: 'y1', + order: 10, + borderWidth: 1, + }), + mkDs('sell_price', 'Prodej', series.sell, COL.sell, { + dashed: true, + yAxisID: 'y1', + order: 11, + borderWidth: 1, + }), + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + layout: { padding: { ...CHART_LAYOUT_PADDING } }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items: TooltipItem<'line'>[]) { + const i = items[0]?.dataIndex ?? 0 + return labelsRef.current[i] ?? '' + }, + label(ctx: TooltipItem<'line'>) { + const label = ctx.dataset.label ?? '' + const v = ctx.parsed.y as number | null + if (v == null || Number.isNaN(v)) return `${label}: —` + if (ctx.dataset.yAxisID === 'y1') return `${label}: ${v.toFixed(3)} Kč/kWh` + return `${label}: ${v.toFixed(2)} kW` + }, + }, + }, + }, + scales: { + x: { + type: 'category', + offset: false, + grid: { color: 'rgba(148,163,184,0.12)' }, + ticks: { + color: '#94a3b8', + maxRotation: 0, + autoSkip: false, + callback(_val: string | number, i: number) { + return i % 8 === 0 ? labelsRef.current[i] ?? '' : '' + }, + }, + }, + y: { + position: 'left', + grid: { color: 'rgba(148,163,184,0.12)' }, + ticks: { color: '#94a3b8', font: { size: 10 } }, + title: { display: true, text: 'kW', color: '#64748b', font: { size: 10 } }, + }, + y1: { + position: 'right', + grid: { drawOnChartArea: false }, + ticks: { color: '#94a3b8', font: { size: 9 } }, + title: { display: true, text: 'Kč/kWh', color: '#64748b', font: { size: 10 } }, + }, + }, + }, + }) + + chartRef.current = chart + requestAnimationFrame(() => { + const a = chart.chartArea + if (a) onChartAreaRef.current?.(a) + }) + + const ro = new ResizeObserver(() => { + chart.resize() + requestAnimationFrame(() => { + const a = chart.chartArea + if (a) onChartAreaRef.current?.(a) + }) + }) + ro.observe(canvas.parentElement ?? canvas) + + return () => { + ro.disconnect() + chart.destroy() + chartRef.current = null + } + // Jen při změně okna (první slot / počet); data dorovnává druhý effect. + }, [windowKey, bgPlugin, nowPlugin]) + + useEffect(() => { + const ch = chartRef.current + if (!ch || !slots.length) return + ch.data.labels = [...labels] + const dss = ch.data.datasets + if (!dss?.length) return + const s = series + const rows: (number | null)[][] = [ + s.sit, + s.bat, + s.ev, + s.tc, + s.bazReal, + s.fveReal, + s.bazPred, + s.fvePred, + s.buy, + s.sell, + ] + rows.forEach((data, i) => { + const ds = dss[i] + if (ds) ds.data = data + }) + ch.update('none') + requestAnimationFrame(() => { + const a = ch.chartArea + if (a) onChartAreaRef.current?.(a) + }) + }, [labels, series, slots.length]) + + const keys = [ + 'sit', + 'bat', + 'ev', + 'tc', + 'baz_real', + 'fve_real', + 'baz_pred', + 'fve_pred', + 'buy_price', + 'sell_price', + ] as const + + useEffect(() => { + const ch = chartRef.current + const dss = ch?.data.datasets + if (!ch || !dss?.length) return + keys.forEach((k, i) => { + const ds = dss[i] + if (ds) ds.hidden = hidden.has(k) + }) + ch.update('none') + }, [hidden]) + + return ( +
+
+ +
+
+ {ENERGY_LEGEND.map((item) => { + const off = hidden.has(item.key) + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/charts/ForecastPanel.tsx b/frontend/src/components/charts/ForecastPanel.tsx new file mode 100644 index 0000000..ce0bad7 --- /dev/null +++ b/frontend/src/components/charts/ForecastPanel.tsx @@ -0,0 +1,37 @@ +import type { ForecastDayTotal } from '../../types/dashboard' + +type Props = { + days: ForecastDayTotal[] +} + +export function ForecastPanel({ days }: Props) { + const max = Math.max(1, ...days.map((d) => d.kwh)) + + return ( +
+

+ Předpověď výroby FVE (7 dní) +

+ {days.length === 0 ? ( +

Žádná data forecastu.

+ ) : ( +
    + {days.map((d) => ( +
  • + {d.label} +
    +
    +
    + + {d.kwh.toFixed(1)} kWh + +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/charts/NegPricePanel.tsx b/frontend/src/components/charts/NegPricePanel.tsx new file mode 100644 index 0000000..4efddc6 --- /dev/null +++ b/frontend/src/components/charts/NegPricePanel.tsx @@ -0,0 +1,51 @@ +import type { NegPriceItem } from '../../types/dashboard' + +type Props = { + items: NegPriceItem[] +} + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleString('cs-CZ', { + weekday: 'short', + day: 'numeric', + month: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Prague', + }) +} + +export function NegPricePanel({ items }: Props) { + return ( +
+

+ Záporné ceny (nadcházející) +

+ {items.length === 0 ? ( +

V dostupných datech nejsou záporné ceny.

+ ) : ( +
    + {items.map((it) => ( +
  • + {fmtTime(it.interval_start)} + + nákup:{' '} + + {it.buy == null ? '—' : `${it.buy.toFixed(3)} Kč/kWh`} + + {' · '} + prodej:{' '} + + {it.sell == null ? '—' : `${it.sell.toFixed(3)} Kč/kWh`} + + +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/charts/RegimeBar.tsx b/frontend/src/components/charts/RegimeBar.tsx new file mode 100644 index 0000000..0f4d9f5 --- /dev/null +++ b/frontend/src/components/charts/RegimeBar.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef } from 'react' + +import type { SlotData } from '../../types/dashboard' + +type Props = { + slots: SlotData[] + nowIndex: number + chartPaddingLeft: number + chartPaddingRight: number + /** Pixely plot oblasti z EnergyChart (chartArea), pokud známe – přesnější zarovnání. */ + chartArea: { left: number; right: number } | null +} + +const REGIME_STYLES: Record< + string, + { bg: string; fg: string; bgPlan: string; fgPlan: string } +> = { + AUTO: { bg: '#1D9E7518', fg: '#0F6E56', bgPlan: '#1D9E7510', fgPlan: '#0F6E5699' }, + SELF_SUSTAIN: { bg: '#E24B4A18', fg: '#993C1D', bgPlan: '#E24B4A10', fgPlan: '#993C1D99' }, + CHARGE_CHEAP: { bg: '#EF9F2718', fg: '#854F0B', bgPlan: '#EF9F2710', fgPlan: '#854F0B99' }, + PRESERVE: { bg: '#53B0AA18', fg: '#185FA5', bgPlan: '#53B0AA10', fgPlan: '#185FA599' }, + MANUAL: { bg: '#88878018', fg: '#5F5E5A', bgPlan: '#88878010', fgPlan: '#5F5E5A99' }, +} + +const DEFAULT_STYLE = { bg: '#88878018', fg: '#5F5E5A', bgPlan: '#88878010', fgPlan: '#5F5E5A99' } + +function normCode(code: string | null): string { + return (code ?? 'AUTO').toUpperCase().replace(/-/g, '_') +} + +export function RegimeBar({ slots, nowIndex, chartPaddingLeft, chartPaddingRight, chartArea }: Props) { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || !slots.length) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const dpr = window.devicePixelRatio || 1 + const h = 28 + const w = canvas.clientWidth || canvas.parentElement?.clientWidth || 300 + canvas.width = Math.floor(w * dpr) + canvas.height = Math.floor(h * dpr) + canvas.style.height = `${h}px` + canvas.style.width = `${w}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + const plotLeft = chartArea?.left ?? chartPaddingLeft + const plotRight = chartArea?.right ?? w - chartPaddingRight + const plotW = Math.max(1, plotRight - plotLeft) + const n = slots.length + const segW = plotW / n + + ctx.clearRect(0, 0, w, h) + + for (let i = 0; i < n; i++) { + const s = slots[i]! + const code = normCode(s.regime_code) + const st = REGIME_STYLES[code] ?? DEFAULT_STYLE + const planned = s.regime_is_planned + ctx.fillStyle = planned ? st.bgPlan : st.bg + const x0 = plotLeft + i * segW + ctx.fillRect(x0, 0, segW + 0.5, h) + } + + if (nowIndex >= 0 && nowIndex < n) { + const x = plotLeft + nowIndex * segW + ctx.save() + ctx.strokeStyle = '#378ADD' + ctx.setLineDash([4, 3]) + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x, h) + ctx.stroke() + ctx.restore() + } + }, [slots, nowIndex, chartPaddingLeft, chartPaddingRight, chartArea]) + + return ( + + ) +} diff --git a/frontend/src/components/charts/SocTuvChart.tsx b/frontend/src/components/charts/SocTuvChart.tsx new file mode 100644 index 0000000..561db4b --- /dev/null +++ b/frontend/src/components/charts/SocTuvChart.tsx @@ -0,0 +1,230 @@ +import { useEffect, useMemo, useRef } from 'react' +import { Chart } from 'chart.js/auto' +import type { TooltipItem } from 'chart.js' + +import type { SlotData } from '../../types/dashboard' +import { CHART_LAYOUT_PADDING } from './chartConstants' +import { + computeNegWeekendRanges, + createNowLinePluginRef, + createSlotBackgroundPluginRefs, +} from './chartPlugins' + +type Props = { + slots: SlotData[] + nowIndex: number +} + +export function SocTuvChart({ slots, nowIndex }: Props) { + const canvasRef = useRef(null) + const chartRef = useRef(null) + + const slotsRef = useRef([]) + const negRangesRef = useRef>([]) + const nowIndexRef = useRef(0) + const labelsRef = useRef([]) + + slotsRef.current = slots + nowIndexRef.current = nowIndex + + const labels = useMemo( + () => + slots.map((s) => { + const d = new Date(s.interval_start) + return d.toLocaleTimeString('cs-CZ', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Prague', + }) + }), + [slots], + ) + labelsRef.current = labels + + const negRanges = useMemo(() => computeNegWeekendRanges(slots, nowIndex), [slots, nowIndex]) + negRangesRef.current = negRanges + + const windowKey = useMemo( + () => (slots.length ? `${slots[0]!.interval_start}|${slots.length}` : ''), + [slots], + ) + + const series = useMemo(() => { + const socReal = slots.map((s, i) => (i <= nowIndex ? s.soc_actual_pct : null)) + const socPlan = slots.map((s) => s.soc_plan_pct) + const tuvReal = slots.map((s, i) => (i <= nowIndex ? s.tuv_actual_c : null)) + const tuvPlan = slots.map((s) => s.tuv_plan_c) + return { socReal, socPlan, tuvReal, tuvPlan } + }, [slots, nowIndex]) + + const bgPlugin = useMemo( + () => createSlotBackgroundPluginRefs(slotsRef, negRangesRef), + [], + ) + const nowPlugin = useMemo(() => createNowLinePluginRef(nowIndexRef, 'teď'), []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || !windowKey) return + + const chart = new Chart(canvas, { + type: 'line', + plugins: [bgPlugin, nowPlugin], + data: { + labels: [...labels], + datasets: [ + { + label: 'SoC ■', + data: series.socReal, + borderColor: '#1D9E75', + backgroundColor: '#1D9E7526', + fill: true, + borderWidth: 1.2, + pointRadius: 0, + tension: 0.2, + yAxisID: 'y', + }, + { + label: 'SoC plán', + data: series.socPlan, + borderColor: '#1D9E75', + borderDash: [5, 4], + fill: false, + borderWidth: 1, + pointRadius: 0, + tension: 0.2, + yAxisID: 'y', + }, + { + label: 'TUV ■', + data: series.tuvReal, + borderColor: '#EF9F27', + backgroundColor: '#EF9F2726', + fill: true, + borderWidth: 1.2, + pointRadius: 0, + tension: 0.2, + yAxisID: 'y1', + }, + { + label: 'TUV cíl', + data: series.tuvPlan, + borderColor: '#EF9F27', + borderDash: [5, 4], + fill: false, + borderWidth: 1, + pointRadius: 0, + tension: 0.2, + yAxisID: 'y1', + }, + { + label: '_layout', + data: slots.map(() => 0), + borderColor: 'transparent', + backgroundColor: 'transparent', + borderWidth: 0, + pointRadius: 0, + yAxisID: 'y2', + order: 100, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + layout: { padding: { ...CHART_LAYOUT_PADDING } }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items: TooltipItem<'line'>[]) { + const i = items[0]?.dataIndex ?? 0 + return labelsRef.current[i] ?? '' + }, + label(ctx: TooltipItem<'line'>) { + if (ctx.dataset.label === '_layout') return '' + if (!ctx.dataset.label) return '' + const v = ctx.parsed.y as number | null + if (v == null || Number.isNaN(v)) return `${ctx.dataset.label}: —` + if (ctx.dataset.yAxisID === 'y') return `${ctx.dataset.label}: ${v.toFixed(1)} %` + return `${ctx.dataset.label}: ${v.toFixed(1)} °C` + }, + }, + }, + }, + scales: { + x: { + type: 'category', + offset: false, + grid: { color: 'rgba(148,163,184,0.1)' }, + ticks: { + color: '#94a3b8', + maxRotation: 0, + autoSkip: false, + callback(_v: string | number, i: number) { + return i % 8 === 0 ? labelsRef.current[i] ?? '' : '' + }, + }, + }, + y: { + position: 'left', + min: 0, + max: 100, + grid: { color: 'rgba(148,163,184,0.12)' }, + ticks: { color: '#1D9E75', font: { size: 9 } }, + title: { display: true, text: '% SoC', color: '#1D9E75', font: { size: 10 } }, + }, + y1: { + position: 'right', + min: 30, + max: 75, + grid: { drawOnChartArea: false }, + ticks: { color: '#EF9F27', font: { size: 9 } }, + title: { display: true, text: 'TUV °C', color: '#EF9F27', font: { size: 10 } }, + }, + y2: { + type: 'linear', + position: 'right', + display: false, + min: 0, + max: 1, + grid: { display: false }, + ticks: { display: false }, + weight: 0.35, + }, + }, + }, + }) + + chartRef.current = chart + const ro = new ResizeObserver(() => chart.resize()) + ro.observe(canvas.parentElement ?? canvas) + return () => { + ro.disconnect() + chart.destroy() + chartRef.current = null + } + }, [windowKey, bgPlugin, nowPlugin]) + + useEffect(() => { + const ch = chartRef.current + if (!ch || !slots.length) return + ch.data.labels = [...labels] + const dss = ch.data.datasets + if (!dss?.length) return + const s = series + if (dss[0]) dss[0].data = s.socReal + if (dss[1]) dss[1].data = s.socPlan + if (dss[2]) dss[2].data = s.tuvReal + if (dss[3]) dss[3].data = s.tuvPlan + if (dss[4]) dss[4].data = slots.map(() => 0) + ch.update('none') + }, [labels, series, slots, slots.length]) + + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/charts/chartConstants.ts b/frontend/src/components/charts/chartConstants.ts new file mode 100644 index 0000000..37a0344 --- /dev/null +++ b/frontend/src/components/charts/chartConstants.ts @@ -0,0 +1,16 @@ +export const CHART_LAYOUT_PADDING = { left: 8, right: 45 } as const + +export const SLOT_MS = 15 * 60 * 1000 +export const SLOT_COUNT_BACK = 60 +export const SLOT_COUNT_FWD = 144 +export const TOTAL_SLOTS = SLOT_COUNT_BACK + SLOT_COUNT_FWD + +export function floorSlotUtcMs(ms: number): number { + return Math.floor(ms / SLOT_MS) * SLOT_MS +} + +/** Index aktuálního 15min slotu v okně [0, TOTAL_SLOTS). */ +export function currentSlotIndexInWindow(windowStartMs: number): number { + const cur = floorSlotUtcMs(Date.now()) + return Math.round((cur - windowStartMs) / SLOT_MS) +} diff --git a/frontend/src/components/charts/chartPlugins.ts b/frontend/src/components/charts/chartPlugins.ts new file mode 100644 index 0000000..579bfb6 --- /dev/null +++ b/frontend/src/components/charts/chartPlugins.ts @@ -0,0 +1,200 @@ +import type { MutableRefObject } from 'react' +import type { Chart, Plugin } from 'chart.js' + +import type { SlotData } from '../../types/dashboard' + +export type NegWeekendRange = { start: number; end: number } + +const SELL_NEG = 'rgba(226,75,74,0.07)' +const BUY_NEG = 'rgba(29,158,117,0.07)' +const WEEKEND_NEG = 'rgba(239,159,39,0.07)' + +function isWeekendPrague(iso: string): boolean { + const w = new Date(iso).toLocaleDateString('en-US', { timeZone: 'Europe/Prague', weekday: 'short' }) + return w === 'Sat' || w === 'Sun' +} + +export function computeNegWeekendRanges(slots: SlotData[], nowIndex: number): NegWeekendRange[] { + const ranges: NegWeekendRange[] = [] + let i = 0 + const n = slots.length + while (i < n) { + if (i <= nowIndex) { + i += 1 + continue + } + const s = slots[i]! + const buy = s.buy_price + if (!(buy != null && buy < 0 && isWeekendPrague(s.interval_start))) { + i += 1 + continue + } + const start = i + while ( + i < n && + slots[i]!.buy_price != null && + slots[i]!.buy_price! < 0 && + isWeekendPrague(slots[i]!.interval_start) + ) { + i += 1 + } + ranges.push({ start, end: i }) + } + return ranges +} + +export function createSlotBackgroundPlugin( + slots: SlotData[], + _nowIndex: number, + negWeekendRanges: NegWeekendRange[], +): Plugin { + return { + id: 'emsSlotBg', + beforeDatasetsDraw(chart: Chart) { + const { ctx, chartArea } = chart + if (!chartArea || !slots.length) return + const n = slots.length + const w = chartArea.width / n + + for (let i = 0; i < n; i++) { + const s = slots[i]! + const x0 = chartArea.left + i * w + let fill: string | null = null + if (s.sell_price != null && s.sell_price < 0) fill = SELL_NEG + else if (s.buy_price != null && s.buy_price < 0) fill = BUY_NEG + if (fill) { + ctx.save() + ctx.fillStyle = fill + ctx.fillRect(x0, chartArea.top, w, chartArea.bottom - chartArea.top) + ctx.restore() + } + } + + for (const r of negWeekendRanges) { + if (r.start >= n || r.end <= r.start) continue + const x0 = chartArea.left + r.start * w + const x1 = chartArea.left + r.end * w + ctx.save() + ctx.fillStyle = WEEKEND_NEG + ctx.fillRect(x0, chartArea.top, x1 - x0, chartArea.bottom - chartArea.top) + ctx.strokeStyle = 'rgba(239,159,39,0.45)' + ctx.setLineDash([4, 3]) + ctx.lineWidth = 1 + ctx.strokeRect(x0 + 0.5, chartArea.top + 0.5, x1 - x0 - 1, chartArea.bottom - chartArea.top - 1) + ctx.restore() + } + }, + } +} + +/** Pozadí slotů – čte aktuální slots z ref (bez přepínání instance grafu). */ +export function createSlotBackgroundPluginRefs( + slotsRef: MutableRefObject, + negRangesRef: MutableRefObject, +): Plugin { + return { + id: 'emsSlotBgRef', + beforeDatasetsDraw(chart: Chart) { + const { ctx, chartArea } = chart + const slots = slotsRef.current + const negWeekendRanges = negRangesRef.current + if (!chartArea || !slots.length) return + const n = slots.length + const w = chartArea.width / n + + for (let i = 0; i < n; i++) { + const s = slots[i]! + const x0 = chartArea.left + i * w + let fill: string | null = null + if (s.sell_price != null && s.sell_price < 0) fill = SELL_NEG + else if (s.buy_price != null && s.buy_price < 0) fill = BUY_NEG + if (fill) { + ctx.save() + ctx.fillStyle = fill + ctx.fillRect(x0, chartArea.top, w, chartArea.bottom - chartArea.top) + ctx.restore() + } + } + + for (const r of negWeekendRanges) { + if (r.start >= n || r.end <= r.start) continue + const x0 = chartArea.left + r.start * w + const x1 = chartArea.left + r.end * w + ctx.save() + ctx.fillStyle = WEEKEND_NEG + ctx.fillRect(x0, chartArea.top, x1 - x0, chartArea.bottom - chartArea.top) + ctx.strokeStyle = 'rgba(239,159,39,0.45)' + ctx.setLineDash([4, 3]) + ctx.lineWidth = 1 + ctx.strokeRect(x0 + 0.5, chartArea.top + 0.5, x1 - x0 - 1, chartArea.bottom - chartArea.top - 1) + ctx.restore() + } + }, + } +} + +/** Čára „teď“ – index z ref. */ +export function createNowLinePluginRef(nowIndexRef: MutableRefObject, label: string): Plugin { + return { + id: 'emsNowLineRef', + afterDatasetsDraw(chart: Chart) { + const nowIndex = nowIndexRef.current + const { ctx, chartArea } = chart + const labels = chart.data.labels + if (!chartArea || !labels?.length) return + const n = labels.length + if (nowIndex < 0 || nowIndex >= n) return + const w = chartArea.width / n + const x = chartArea.left + nowIndex * w + + ctx.save() + ctx.beginPath() + ctx.strokeStyle = '#378ADD' + ctx.setLineDash([5, 4]) + ctx.lineWidth = 1.5 + ctx.moveTo(x, chartArea.top) + ctx.lineTo(x, chartArea.bottom) + ctx.stroke() + + ctx.setLineDash([]) + ctx.fillStyle = '#378ADD' + ctx.font = '600 10px system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + ctx.fillText(label, Math.min(x + 3, chartArea.right - 28), chartArea.top + 2) + ctx.restore() + }, + } +} + +export function createNowLinePlugin(nowIndex: number, label: string): Plugin { + return { + id: 'emsNowLine', + afterDatasetsDraw(chart: Chart) { + const { ctx, chartArea } = chart + const labels = chart.data.labels + if (!chartArea || !labels?.length) return + const n = labels.length + if (nowIndex < 0 || nowIndex >= n) return + const w = chartArea.width / n + const x = chartArea.left + nowIndex * w + + ctx.save() + ctx.beginPath() + ctx.strokeStyle = '#378ADD' + ctx.setLineDash([5, 4]) + ctx.lineWidth = 1.5 + ctx.moveTo(x, chartArea.top) + ctx.lineTo(x, chartArea.bottom) + ctx.stroke() + + ctx.setLineDash([]) + ctx.fillStyle = '#378ADD' + ctx.font = '600 10px system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + ctx.fillText(label, Math.min(x + 3, chartArea.right - 28), chartArea.top + 2) + ctx.restore() + }, + } +} diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts new file mode 100644 index 0000000..b27c2a2 --- /dev/null +++ b/frontend/src/hooks/useDashboardData.ts @@ -0,0 +1,520 @@ +import axios from 'axios' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { + getCurrentPlan, + getSiteForecastPv, + getSitePrices, + type SiteEffectivePriceRowDto, +} from '../api/backend' +import { getJson } from '../api/postgrest' +import { + currentSlotIndexInWindow, + SLOT_COUNT_BACK, + SLOT_MS, + TOTAL_SLOTS, + floorSlotUtcMs, +} from '../components/charts/chartConstants' +import { pragueAddCalendarDays, pragueCalendarDay } from '../lib/pragueDate' +import type { ForecastDayTotal, LiveMetrics, NegPriceItem, SlotData } from '../types/dashboard' +import type { + AuditTodayHourlyRow, + HeatPumpLatestRow, + ModeLogRecentRow, + SiteStatusRow, + TelemetryHourly7dRow, +} from '../types/ems' +import type { PlanningIntervalDto } from '../types/plan' + +const POLL_FULL_MS = 30_000 +const POLL_LIVE_MS = 5_000 + +function parseNum(v: string | number | null | undefined): number | null { + if (v == null) return null + if (typeof v === 'number' && !Number.isNaN(v)) return v + const n = Number(v) + return Number.isFinite(n) ? n : null +} + +function numFromWs(v: unknown): number | null { + if (v == null) return null + const n = typeof v === 'number' ? v : Number(v) + return Number.isFinite(n) ? n : null +} + +function buildLiveMetrics( + status: SiteStatusRow | null, + _hp: HeatPumpLatestRow | null, +): LiveMetrics | null { + if (status == null) return null + return { + pv_w: parseNum(status.pv_power_w), + load_w: parseNum(status.load_power_w), + grid_w: parseNum(status.grid_power_w), + bat_soc: parseNum(status.battery_soc_percent), + bat_w: parseNum(status.battery_power_w), + } +} + +function hourFloorUtcMs(ms: number): number { + const d = new Date(ms) + d.setUTCMinutes(0, 0, 0) + d.setUTCSeconds(0, 0) + return d.getTime() +} + +/** Klíč hodiny v Europe/Prague (pro shodu s vw_audit_today_hourly.hour_local). */ +function pragueHourKey(ms: number): string { + return new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Europe/Prague', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hour12: false, + }).format(new Date(ms)) +} + +function slotTimeKey(ms: number): string { + return String(floorSlotUtcMs(ms)) +} + +function modeAt(logs: ModeLogRecentRow[], tMs: number): string | null { + let best: ModeLogRecentRow | null = null + let bestA = -Infinity + for (const l of logs) { + const a = new Date(l.activated_at).getTime() + const d = l.deactivated_at ? new Date(l.deactivated_at).getTime() : Number.POSITIVE_INFINITY + if (a <= tMs && tMs < d && a >= bestA) { + bestA = a + best = l + } + } + return best?.mode_code ?? null +} + +function emptySlot(iso: string): SlotData { + return { + interval_start: iso, + pv_power_w: null, + battery_power_w: null, + battery_setpoint_w: null, + grid_power_w: null, + grid_setpoint_w: null, + load_power_w: null, + gen_port_power_w: null, + pv_a_forecast_w: null, + pv_b_forecast_w: null, + load_baseline_w: null, + ev1_setpoint_w: null, + ev2_setpoint_w: null, + heat_pump_setpoint_w: null, + heat_pump_enabled: null, + battery_soc_target_pct: null, + buy_price: null, + sell_price: null, + regime_code: null, + regime_is_planned: false, + soc_actual_pct: null, + soc_plan_pct: null, + tuv_actual_c: null, + tuv_plan_c: null, + } +} + +function mergeInterval(s: SlotData, p: PlanningIntervalDto): void { + s.battery_setpoint_w = p.battery_setpoint_w ?? s.battery_setpoint_w + s.grid_setpoint_w = p.grid_setpoint_w ?? s.grid_setpoint_w + s.ev1_setpoint_w = p.ev1_setpoint_w ?? s.ev1_setpoint_w + s.ev2_setpoint_w = p.ev2_setpoint_w ?? s.ev2_setpoint_w + if (s.ev1_setpoint_w == null && s.ev2_setpoint_w == null && p.ev_charge_power_w != null) { + s.ev1_setpoint_w = p.ev_charge_power_w + } + if (p.heat_pump_enabled === true) { + s.heat_pump_enabled = true + } else if (p.heat_pump_enabled === false) { + s.heat_pump_enabled = false + } + if (p.heat_pump_setpoint_w != null) { + s.heat_pump_setpoint_w = p.heat_pump_setpoint_w + if (s.heat_pump_enabled == null) { + s.heat_pump_enabled = p.heat_pump_setpoint_w > 0 + } + } else if (p.heat_pump_enabled === false) { + s.heat_pump_setpoint_w = 0 + s.heat_pump_enabled = false + } + s.load_baseline_w = p.load_baseline_w ?? s.load_baseline_w + s.buy_price = parseNum(p.effective_buy_price) ?? s.buy_price + s.sell_price = parseNum(p.effective_sell_price) ?? s.sell_price + const tgtSoc = parseNum(p.battery_soc_target_pct) + if (tgtSoc != null) { + s.battery_soc_target_pct = tgtSoc + s.soc_plan_pct = tgtSoc + } + const pva = p.pv_forecast_total_w != null ? Math.round(Number(p.pv_forecast_total_w) * 0.6) : null + const pvb = p.pv_forecast_total_w != null ? Math.round(Number(p.pv_forecast_total_w) * 0.4) : null + if (s.pv_a_forecast_w == null && pva != null) s.pv_a_forecast_w = pva + if (s.pv_b_forecast_w == null && pvb != null) s.pv_b_forecast_w = pvb + if (p.heat_pump_setpoint_w != null && p.heat_pump_setpoint_w > 0) { + s.tuv_plan_c = 52 + } +} + +export function useDashboardData(siteId: number | null) { + const [slots, setSlots] = useState([]) + const [liveMetrics, setLiveMetrics] = useState(null) + const [forecastWeek, setForecastWeek] = useState([]) + const [negPrices, setNegPrices] = useState([]) + const [error, setError] = useState(null) + const [ready, setReady] = useState(false) + + const wsRef = useRef(null) + const siteIdRef = useRef(siteId) + siteIdRef.current = siteId + + const load = useCallback(async () => { + if (siteId == null) { + setSlots([]) + setForecastWeek([]) + setNegPrices([]) + setLiveMetrics(null) + setError(null) + setReady(true) + return + } + + const windowStart = floorSlotUtcMs(Date.now()) - SLOT_COUNT_BACK * SLOT_MS + const nIdx = currentSlotIndexInWindow(windowStart) + + try { + const todayPrague = pragueCalendarDay() + const dates = new Set() + for (let i = 0; i < TOTAL_SLOTS; i++) { + const ms = windowStart + i * SLOT_MS + dates.add(pragueCalendarDay(new Date(ms))) + } + + const [ + planMaybe, + statusArr, + hourly7d, + auditHourly, + modeLog, + hpArr, + ...priceLists + ] = await Promise.all([ + getCurrentPlan(siteId).catch((e: unknown) => { + if (axios.isAxiosError(e) && e.response?.status === 404) { + return { run: null, intervals: [] as PlanningIntervalDto[], summary: null } + } + throw e + }), + getJson('/vw_site_status', { site_id: `eq.${siteId}` }), + getJson('/vw_telemetry_hourly_7d', { + site_id: `eq.${siteId}`, + order: 'hour.asc', + limit: '500', + }), + getJson('/vw_audit_today_hourly', { + site_id: `eq.${siteId}`, + order: 'hour_local.asc', + }), + getJson('/vw_mode_log_recent', { + site_id: `eq.${siteId}`, + order: 'activated_at.asc', + limit: '200', + }), + getJson('/vw_latest_heat_pump', { site_id: `eq.${siteId}` }), + ...[...dates].map((d) => getSitePrices(siteId, d)), + ]) + + const status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null + const hp = Array.isArray(hpArr) && hpArr[0] ? hpArr[0]! : null + setLiveMetrics(buildLiveMetrics(status, hp)) + + const plan = planMaybe as { intervals: PlanningIntervalDto[] } + const planBySlot = new Map() + for (const iv of plan.intervals) { + planBySlot.set(slotTimeKey(new Date(iv.interval_start).getTime()), iv) + } + + const priceBySlot = new Map() + const flatPrices: SiteEffectivePriceRowDto[] = priceLists.flat() as SiteEffectivePriceRowDto[] + for (const r of flatPrices) { + const k = slotTimeKey(new Date(r.interval_start).getTime()) + priceBySlot.set(k, { + buy: parseNum(r.effective_buy_price_czk_kwh), + sell: parseNum(r.effective_sell_price_czk_kwh), + }) + } + + const forecastBySlot = new Map() + const forecastDays: ForecastDayTotal[] = [] + const today = todayPrague + const forecastResults = await Promise.all( + Array.from({ length: 7 }, (_, d) => { + const ymd = pragueAddCalendarDays(today, d) + return getSiteForecastPv(siteId, ymd) + .then((fc) => ({ ymd, fc })) + .catch(() => ({ ymd, fc: null as Awaited> | null })) + }), + ) + for (const { ymd, fc } of forecastResults) { + if (!fc) { + forecastDays.push({ date: ymd, label: ymd, kwh: 0 }) + continue + } + let kwh = 0 + const byStart = new Map() + for (const x of fc.pv_a ?? []) { + const t = new Date(x.interval_start).getTime() + const p = Number(x.power_w ?? 0) + const cur = byStart.get(slotTimeKey(t)) ?? { a: 0, b: 0 } + cur.a += p + byStart.set(slotTimeKey(t), cur) + } + for (const x of fc.pv_b ?? []) { + const t = new Date(x.interval_start).getTime() + const p = Number(x.power_w ?? 0) + const cur = byStart.get(slotTimeKey(t)) ?? { a: 0, b: 0 } + cur.b += p + byStart.set(slotTimeKey(t), cur) + } + for (const [, v] of byStart) { + kwh += ((v.a + v.b) * 0.25) / 1000 + } + for (const [k, v] of byStart) { + forecastBySlot.set(k, v) + } + const label = new Date(ymd + 'T12:00:00Z').toLocaleDateString('cs-CZ', { + weekday: 'short', + day: 'numeric', + month: 'numeric', + timeZone: 'Europe/Prague', + }) + forecastDays.push({ date: ymd, label, kwh: Math.round(kwh * 10) / 10 }) + } + setForecastWeek(forecastDays) + + const hourlyMap = new Map() + if (Array.isArray(hourly7d)) { + for (const r of hourly7d) { + hourlyMap.set(new Date(r.hour).getTime(), r) + } + } + + const auditMap = new Map() + if (Array.isArray(auditHourly)) { + for (const r of auditHourly) { + auditMap.set(pragueHourKey(new Date(r.hour_local).getTime()), r) + } + } + + const logs = Array.isArray(modeLog) ? modeLog : [] + const activeMode = status?.active_mode ?? 'AUTO' + + const built: SlotData[] = [] + for (let i = 0; i < TOTAL_SLOTS; i++) { + const startMs = windowStart + i * SLOT_MS + const iso = new Date(startMs).toISOString() + const base = emptySlot(iso) + const k = slotTimeKey(startMs) + + const tel = hourlyMap.get(hourFloorUtcMs(startMs)) + if (tel) { + base.pv_power_w = tel.avg_pv_w ?? base.pv_power_w + base.battery_power_w = tel.avg_battery_w ?? base.battery_power_w + base.grid_power_w = tel.avg_grid_w ?? base.grid_power_w + base.load_power_w = tel.avg_load_w ?? base.load_power_w + base.soc_actual_pct = parseNum(tel.last_soc_pct) ?? base.soc_actual_pct + } + + const aud = auditMap.get(pragueHourKey(startMs)) + if (aud) { + if (base.pv_power_w == null && aud.avg_pv_kw != null) { + base.pv_power_w = Math.round((parseNum(aud.avg_pv_kw) ?? 0) * 1000) + } + if (base.load_power_w == null && aud.avg_load_kw != null) { + base.load_power_w = Math.round((parseNum(aud.avg_load_kw) ?? 0) * 1000) + } + if (base.battery_power_w == null && aud.avg_battery_kw != null) { + base.battery_power_w = Math.round((parseNum(aud.avg_battery_kw) ?? 0) * 1000) + } + if (base.grid_power_w == null && aud.avg_grid_kw != null) { + base.grid_power_w = Math.round((parseNum(aud.avg_grid_kw) ?? 0) * 1000) + } + if (base.soc_actual_pct == null) { + base.soc_actual_pct = parseNum(aud.avg_soc_pct) ?? base.soc_actual_pct + } + } + + const pr = priceBySlot.get(k) + if (pr) { + base.buy_price = pr.buy + base.sell_price = pr.sell + } + + const fc = forecastBySlot.get(k) + if (fc) { + base.pv_a_forecast_w = fc.a + base.pv_b_forecast_w = fc.b + } + + const pi = planBySlot.get(k) + if (pi) mergeInterval(base, pi) + + const past = i <= nIdx + const regimePast = modeAt(logs, startMs) ?? activeMode + built.push({ + ...base, + regime_code: past ? regimePast : activeMode, + regime_is_planned: !past, + }) + } + + const neg: NegPriceItem[] = [] + const nowMs = Date.now() + for (const r of flatPrices) { + const t = new Date(r.interval_start).getTime() + if (t < nowMs) continue + const buy = parseNum(r.effective_buy_price_czk_kwh) + const sell = parseNum(r.effective_sell_price_czk_kwh) + if ((buy != null && buy < 0) || (sell != null && sell < 0)) { + neg.push({ + interval_start: r.interval_start, + buy, + sell, + }) + } + } + neg.sort((a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime()) + setNegPrices(neg.slice(0, 32)) + + setSlots(built) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Chyba načítání dashboardu') + setSlots([]) + } finally { + setReady(true) + } + }, [siteId]) + + useEffect(() => { + void load() + const id = window.setInterval(() => void load(), POLL_FULL_MS) + return () => window.clearInterval(id) + }, [load]) + + useEffect(() => { + if (siteId == null) { + setLiveMetrics(null) + return + } + const fetchLive = async () => { + try { + const [statusArr, hpArr] = await Promise.all([ + getJson('/vw_site_status', { site_id: `eq.${siteId}` }), + getJson('/vw_latest_heat_pump', { site_id: `eq.${siteId}` }), + ]) + const status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null + const hp = Array.isArray(hpArr) && hpArr[0] ? hpArr[0]! : null + setLiveMetrics(buildLiveMetrics(status, hp)) + } catch { + /* ignore */ + } + } + void fetchLive() + const id = window.setInterval(() => void fetchLive(), POLL_LIVE_MS) + return () => window.clearInterval(id) + }, [siteId]) + + useEffect(() => { + if (siteId == null) { + wsRef.current?.close() + wsRef.current = null + return + } + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${window.location.host}/ws/telemetry`) + wsRef.current = ws + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data as string) as Record + if (msg.type !== 'telemetry' || Number(msg.site_id) !== siteIdRef.current) return + + const pv = numFromWs(msg.pv_power_w) + const batW = numFromWs(msg.battery_power_w) + const gridW = numFromWs(msg.grid_power_w) + const loadW = numFromWs(msg.load_power_w) + const genW = numFromWs(msg.gen_port_power_w) + const soc = numFromWs(msg.battery_soc_pct) + + setLiveMetrics((prev) => ({ + pv_w: pv ?? prev?.pv_w ?? null, + load_w: loadW ?? prev?.load_w ?? null, + grid_w: gridW ?? prev?.grid_w ?? null, + bat_soc: soc ?? prev?.bat_soc ?? null, + bat_w: batW ?? prev?.bat_w ?? null, + })) + + const tsStr = typeof msg.ts === 'string' ? msg.ts : null + if (!tsStr) return + const tsMs = new Date(tsStr).getTime() + if (!Number.isFinite(tsMs)) return + + setSlots((prev) => { + const idx = prev.findIndex((s) => { + const start = new Date(s.interval_start).getTime() + return start <= tsMs && tsMs < start + SLOT_MS + }) + if (idx === -1) return prev + const cur = prev[idx]! + const updated = [...prev] + updated[idx] = { + ...cur, + pv_power_w: pv ?? cur.pv_power_w, + battery_power_w: batW ?? cur.battery_power_w, + grid_power_w: gridW ?? cur.grid_power_w, + load_power_w: loadW ?? cur.load_power_w, + gen_port_power_w: genW ?? cur.gen_port_power_w, + soc_actual_pct: soc ?? cur.soc_actual_pct, + } + return updated + }) + } catch { + /* ignore */ + } + } + ws.onclose = () => { + if (wsRef.current === ws) wsRef.current = null + } + return () => { + ws.close() + if (wsRef.current === ws) wsRef.current = null + } + }, [siteId]) + + const liveNowIndex = useMemo( + () => currentSlotIndexInWindow(floorSlotUtcMs(Date.now()) - SLOT_COUNT_BACK * SLOT_MS), + [slots], + ) + + const buyNow = + slots.length && liveNowIndex >= 0 && liveNowIndex < slots.length + ? slots[liveNowIndex]!.buy_price + : null + + return { + slots, + nowIndex: liveNowIndex, + forecastWeek, + negPrices, + error, + ready, + reload: load, + liveMetrics, + buyNow, + } +} diff --git a/frontend/src/hooks/useFullStatus.ts b/frontend/src/hooks/useFullStatus.ts index dd20d56..8313970 100644 --- a/frontend/src/hooks/useFullStatus.ts +++ b/frontend/src/hooks/useFullStatus.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { getSiteStatusFull } from '../api/backend' import type { FullStatusResponse } from '../types/fullStatus' -const POLL_MS = 30_000 +const POLL_MS = 60_000 export function useFullStatus(siteId: number | null) { const [data, setData] = useState(null) diff --git a/frontend/src/hooks/useLogSeverityBadge.ts b/frontend/src/hooks/useLogSeverityBadge.ts new file mode 100644 index 0000000..dcc2cdf --- /dev/null +++ b/frontend/src/hooks/useLogSeverityBadge.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react' + +const SEVERE = new Set(['ERROR', 'CRITICAL']) + +function wsLogsUrl(): string { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${proto}//${window.location.host}/ws/logs` +} + +/** Počítá ERROR/CRITICAL z /ws/logs jen když stránka Logy není aktivní (aby nebyly 2 sockety). */ +export function useLogSeverityBadge(logsPageActive: boolean): number { + const [count, setCount] = useState(0) + + useEffect(() => { + if (logsPageActive) { + setCount(0) + return + } + + let ws: WebSocket | null = null + let reconnectTimer: ReturnType | null = null + let disposed = false + + const connect = () => { + if (disposed) return + ws = new WebSocket(wsLogsUrl()) + ws.onmessage = (e) => { + try { + const o = JSON.parse(e.data) as { level?: string } + if (o.level && SEVERE.has(o.level)) setCount((c) => c + 1) + } catch { + /* ignore */ + } + } + ws.onclose = () => { + if (disposed) return + reconnectTimer = setTimeout(connect, 3000) + } + } + + connect() + + return () => { + disposed = true + if (reconnectTimer != null) clearTimeout(reconnectTimer) + ws?.close() + } + }, [logsPageActive]) + + return count +} diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts new file mode 100644 index 0000000..5599881 --- /dev/null +++ b/frontend/src/hooks/useNotifications.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react' + +import { getSiteNotifications } from '../api/backend' +import type { Notification } from '../types/dashboard' + +const POLL_MS = 60_000 + +export function useNotifications(siteId: number | null) { + const [notifications, setNotifications] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const load = useCallback(async (silent?: boolean) => { + if (siteId == null) { + setNotifications([]) + setError(null) + setLoading(false) + return + } + if (!silent) setLoading(true) + try { + const res = await getSiteNotifications(siteId) + setNotifications(res.notifications) + setError(null) + } catch { + setNotifications([]) + setError('Notifikace se nepodařilo načíst') + } finally { + if (!silent) setLoading(false) + } + }, [siteId]) + + useEffect(() => { + void load(false) + const id = window.setInterval(() => void load(true), POLL_MS) + return () => window.clearInterval(id) + }, [load]) + + return { + notifications, + loading, + error, + reload: () => void load(false), + } +} diff --git a/frontend/src/hooks/useRollingReplanMinutes.ts b/frontend/src/hooks/useRollingReplanMinutes.ts new file mode 100644 index 0000000..b4c5858 --- /dev/null +++ b/frontend/src/hooks/useRollingReplanMinutes.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useState } from 'react' + +import { getBackendHealthDetailed } from '../api/backend' + +/** Minuty do dalšího naplánovaného `rolling_replan` jobu (globální scheduler). */ +export function useRollingReplanMinutes() { + const [nextReplanIn, setNextReplanIn] = useState(null) + + const refresh = useCallback(async () => { + try { + const h = await getBackendHealthDetailed() + const job = h.active_jobs.find((j) => j.id === 'rolling_replan') + if (job?.next_run_time == null) { + setNextReplanIn(null) + return + } + const diffMin = (new Date(job.next_run_time).getTime() - Date.now()) / 60_000 + setNextReplanIn(Math.max(0, Math.round(diffMin))) + } catch { + setNextReplanIn(null) + } + }, []) + + useEffect(() => { + void refresh() + const id = window.setInterval(() => void refresh(), 60_000) + return () => window.clearInterval(id) + }, [refresh]) + + return { nextReplanIn, refreshRollingEta: refresh } +} diff --git a/frontend/src/hooks/useSiteStatus.ts b/frontend/src/hooks/useSiteStatus.ts index 6d20f44..4813391 100644 --- a/frontend/src/hooks/useSiteStatus.ts +++ b/frontend/src/hooks/useSiteStatus.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { getJson } from '../api/postgrest' import type { SiteStatusRow } from '../types/ems' -const POLL_MS = 5_000 +const POLL_MS = 30_000 export function useSiteStatus() { const [row, setRow] = useState(null) diff --git a/frontend/src/hooks/useWsLogErrorCount.ts b/frontend/src/hooks/useWsLogErrorCount.ts new file mode 100644 index 0000000..bd0f6ff --- /dev/null +++ b/frontend/src/hooks/useWsLogErrorCount.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' + +const WINDOW_MS = 5 * 60_000 +const PRUNE_MS = 10_000 + +/** Počet ERROR logů z /ws/logs za posledních 5 minut (podle času přijetí zprávy). */ +export function useWsLogErrorCount(enabled: boolean): number { + const [count, setCount] = useState(0) + + useEffect(() => { + if (!enabled) { + setCount(0) + return + } + + const timestamps: number[] = [] + const prune = () => { + const cutoff = Date.now() - WINDOW_MS + while (timestamps.length > 0 && timestamps[0]! < cutoff) { + timestamps.shift() + } + setCount(timestamps.length) + } + + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const ws = new WebSocket(`${proto}//${window.location.host}/ws/logs`) + + ws.onmessage = (ev) => { + let rec: { level?: string } + try { + rec = JSON.parse(ev.data as string) as { level?: string } + } catch { + return + } + if (rec.level === 'ERROR') { + timestamps.push(Date.now()) + prune() + } + } + + const id = window.setInterval(prune, PRUNE_MS) + return () => { + window.clearInterval(id) + ws.close() + } + }, [enabled]) + + return count +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 7a5c422..9b29e3a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,7 +2,28 @@ @config "../tailwind.config.ts"; @layer base { + :root { + --border-radius-md: 0.5rem; + --color-border-tertiary: rgb(51 65 85 / 0.6); + } + body { @apply min-h-screen bg-slate-950 text-slate-100 antialiased; } } + +@keyframes critical-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +@layer utilities { + .animate-critical-pulse { + animation: critical-pulse 1.1s ease-in-out infinite; + } +} diff --git a/frontend/src/lib/pragueDate.ts b/frontend/src/lib/pragueDate.ts index 110d20e..10ca0b6 100644 --- a/frontend/src/lib/pragueDate.ts +++ b/frontend/src/lib/pragueDate.ts @@ -16,3 +16,16 @@ export function instantPragueDay(iso: string): string { day: '2-digit', }).format(new Date(iso)) } + +/** Kalendářní den v Praze posunutý o N dní (od ref YYYY-MM-DD v Praze). */ +export function pragueAddCalendarDays(ymd: string, deltaDays: number): string { + const [y, m, d] = ymd.split('-').map(Number) + const utc = new Date(Date.UTC(y, m - 1, d, 12, 0, 0)) + utc.setUTCDate(utc.getUTCDate() + deltaDays) + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Prague', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(utc) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fab1219..8e0deb1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import App from './App' import './index.css' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0fa2e10..7bb1f07 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,262 +1,159 @@ -import { useState } from 'react' -import { Sun, Battery, Zap, Home, ChevronDown, ChevronUp } from 'lucide-react' +import type { ChartArea } from 'chart.js' +import { Activity, Battery, ChevronDown, ChevronUp, Sun, Zap } from 'lucide-react' +import { memo, useCallback, useEffect, useState } from 'react' + +import { EnergyChart } from '../components/charts/EnergyChart' +import { ForecastPanel } from '../components/charts/ForecastPanel' +import { NegPricePanel } from '../components/charts/NegPricePanel' +import { RegimeBar } from '../components/charts/RegimeBar' +import { SocTuvChart } from '../components/charts/SocTuvChart' import { - Area, - Bar, - CartesianGrid, - Cell, - ComposedChart, - Line, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts' - -import { useAuditDailyToday } from '../hooks/useAuditDailyToday' -import { useCurrentPlan } from '../hooks/useCurrentPlan' + postImportSitePrices, + postRunPlan, + postSiteMode, +} from '../api/backend' +import { CHART_LAYOUT_PADDING } from '../components/charts/chartConstants' +import { ControlPanel } from '../components/ControlPanel' +import { ModeBar } from '../components/ModeBar' +import { NotificationBar } from '../components/NotificationBar' +import { StatePanel } from '../components/StatePanel' +import { useDashboardData } from '../hooks/useDashboardData' import { useFullStatus } from '../hooks/useFullStatus' +import { useNotifications } from '../hooks/useNotifications' +import { useRollingReplanMinutes } from '../hooks/useRollingReplanMinutes' import { useSiteStatus } from '../hooks/useSiteStatus' -import { useTelemetryToday, type TelemetryChartPoint } from '../hooks/useTelemetryToday' -import type { PlanningIntervalDto } from '../types/plan' -const BAT_PLAN_W = 80 +const MemoEnergyChart = memo(EnergyChart, (prev, next) => + prev.slots === next.slots && prev.nowIndex === next.nowIndex && prev.hidden === next.hidden, +) + +const MemoRegimeBar = memo(RegimeBar, (prev, next) => + prev.slots === next.slots && + prev.nowIndex === next.nowIndex && + prev.chartArea === next.chartArea, +) + +const MemoSocTuvChart = memo(SocTuvChart, (prev, next) => + prev.slots === next.slots && prev.nowIndex === next.nowIndex, +) function fmtKw2(w: number | null | undefined): string { if (w == null || Number.isNaN(w)) return '—' return `${(w / 1000).toFixed(2)} kW` } -function fmtEnergy(v: string | number | null | undefined): string { - const n = typeof v === 'number' ? v : v == null ? NaN : Number(v) - if (!Number.isFinite(n)) return '—' - return `${n.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh` +function fmtMoney3(v: number | null | undefined): string { + if (v == null || Number.isNaN(v)) return '—' + return `${v.toLocaleString('cs-CZ', { minimumFractionDigits: 3, maximumFractionDigits: 3 })} Kč/kWh` } -function fmtMoney(v: string | number | null | undefined): string { - const n = typeof v === 'number' ? v : v == null ? NaN : Number(v) - if (!Number.isFinite(n)) return '—' - return `${n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč` -} - -function parseNum(v: string | number | null | undefined): number | null { - if (v == null) return null - if (typeof v === 'number' && !Number.isNaN(v)) return v - const n = Number(v) - return Number.isFinite(n) ? n : null -} - -function modeBadgeClass(code: string | null): string { - const c = (code ?? '').toUpperCase() - if (c.includes('AUTO')) return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35' - if (c.includes('SELF')) return 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/35' - if (c.includes('MANUAL') || c.includes('FORCE')) return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35' - if (c.includes('OFF') || c.includes('IDLE')) return 'bg-slate-600/40 text-slate-300 ring-1 ring-slate-500/30' - return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50' -} - -function formatTelemetryAgo(iso: string | null | undefined): string { - if (iso == null) return '—' - const diffMin = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000) - if (diffMin <= 0) return 'právě teď' - if (diffMin === 1) return 'před 1 minutou' - if (diffMin >= 2 && diffMin <= 4) return `před ${diffMin} minutami` - return `před ${diffMin} minutami` -} - -function floorToSlotUtc(ms: number): number { - const slot = 15 * 60 * 1000 - return Math.floor(ms / slot) * slot -} - -function nextPlanSlots(intervals: PlanningIntervalDto[], count: number): PlanningIntervalDto[] { - if (!intervals.length) return [] - const sorted = [...intervals].sort( - (a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime(), - ) - const boundary = floorToSlotUtc(Date.now()) - const upcoming = sorted.filter((iv) => new Date(iv.interval_start).getTime() >= boundary - 1) - return upcoming.slice(0, count) -} - -function meanBuyPrice(slots: PlanningIntervalDto[]): number | null { - const vals = slots - .map((s) => s.effective_buy_price) - .filter((x): x is number => x != null && Number.isFinite(x)) - if (!vals.length) return null - return vals.reduce((a, b) => a + b, 0) / vals.length -} - -function slotBgClass(slot: PlanningIntervalDto, avgBuy: number | null): string { - const b = slot.battery_setpoint_w ?? 0 - if (b > BAT_PLAN_W) return 'bg-emerald-500' - if (b < -BAT_PLAN_W) return 'bg-orange-500' - const buy = slot.effective_buy_price - if (buy != null && avgBuy != null && avgBuy > 0) { - if (buy > avgBuy * 1.15) return 'bg-red-500' - if (buy < avgBuy * 0.85) return 'bg-amber-400' - } - return 'bg-slate-600' -} - -function formatSlotLabel(iso: string): string { - return new Date(iso).toLocaleTimeString('cs-CZ', { - hour: '2-digit', - minute: '2-digit', - timeZone: 'Europe/Prague', - }) -} - -type ChartTipPayload = { name?: string; value?: number; dataKey?: string | number } - -function ChartTooltip({ - active, - payload, - label, -}: { - active?: boolean - payload?: ChartTipPayload[] - label?: string -}) { - if (!active || !payload?.length) return null - return ( -
-

{label}

-
    - {payload.map((p) => ( -
  • - {p.name} - {typeof p.value === 'number' ? `${p.value.toFixed(2)} kW` : '—'} -
  • - ))} -
-
- ) -} - -function SemicircleSocGauge({ socPercent }: { socPercent: string | number | null | undefined }) { - const raw = parseNum(socPercent) - const pct = raw == null ? null : Math.max(0, Math.min(100, raw)) - const r = 88 - const halfLen = Math.PI * r - const stroke = - pct == null ? 'text-slate-600' : pct < 20 ? 'text-red-500' : pct > 80 ? 'text-blue-500' : 'text-emerald-500' - - return ( -
-
- - - {pct != null && ( - - )} - -
- - {pct == null ? '—' : `${pct.toFixed(0)}`} - - % SoC -
-
-
- ) +function fmtSoc(p: number | null | undefined): string { + if (p == null || Number.isNaN(p)) return '—' + return `${p.toFixed(0)} %` } function MetricSkeleton() { - return
-} - -function BlockSkeleton({ className = '' }: { className?: string }) { - return
+ return
} export function Dashboard() { - const { site, ready: siteReady, error: siteError, hasLiveData, reload: reloadSite } = useSiteStatus() - const siteId = site?.site_id ?? null + const { site: siteRow, ready: siteReady, error: siteErr } = useSiteStatus() + const siteId = siteRow?.site_id ?? null + const data = useDashboardData(siteId) + const { notifications, reload: reloadNotifications } = useNotifications(siteId) + const { nextReplanIn, refreshRollingEta } = useRollingReplanMinutes() + const { fullStatus } = useFullStatus(siteId) const [alertsOpen, setAlertsOpen] = useState(false) + const [inverterDiagOpen, setInverterDiagOpen] = useState(true) + const [hiddenSeries, setHiddenSeries] = useState>(() => new Set()) - const { - points, - ready: chartReady, - error: chartError, - hasChartData, - reload: reloadChart, - } = useTelemetryToday(siteId) - const { - daily, - ready: auditReady, - error: auditError, - hasDaily, - reload: reloadAudit, - } = useAuditDailyToday(siteId) - const { plan, ready: planReady, error: planError, reload: reloadPlan } = useCurrentPlan(siteId) + useEffect(() => { + console.log('siteId:', siteId) + console.log('inverterDiagOpen:', inverterDiagOpen) + }, [siteId, inverterDiagOpen]) + const [chartArea, setChartArea] = useState(null) - const fetchError = siteError ?? chartError ?? auditError ?? planError - const retryAll = () => { - void reloadSite() - void reloadChart() - void reloadAudit() - void reloadPlan() - } + const toggleSeries = useCallback((key: string) => { + setHiddenSeries((prev) => { + const n = new Set(prev) + if (n.has(key)) n.delete(key) + else n.add(key) + return n + }) + }, []) - const metricsLoading = !siteReady - const chartLoading = !chartReady - const summaryLoading = !auditReady - const planLoading = !planReady - - const hbOnline = site?.ems_heartbeat_status === 'ok' + const onChartArea = useCallback((area: ChartArea) => { + setChartArea(area) + }, []) + const site = siteRow + const fetchError = data.error ?? siteErr + const metricsLoading = !data.ready || !siteReady const monitoringAlerts = fullStatus?.alerts ?? [] const hasMonitoringAlerts = monitoringAlerts.length > 0 const monitoringHasError = monitoringAlerts.some((a) => a.level === 'error') + const hbOnline = site?.ems_heartbeat_status === 'ok' - const gridW = site?.grid_power_w ?? null - const gridLabel = - gridW == null || Number.isNaN(gridW) - ? '—' - : gridW >= 0 - ? `+${(gridW / 1000).toFixed(2)} kW import` - : `${(gridW / 1000).toFixed(2)} kW export` + /** Horní karty (FVE, síť, SoC, cena): liveMetrics z useDashboardData (5s poll / WS), ne siteRow. */ + const lm = data.liveMetrics - const batW = site?.battery_power_w ?? null - const batPct = parseNum(site?.battery_soc_percent) - const batSignedKw = - batW == null || Number.isNaN(batW) ? null : Math.abs(batW / 1000) * (batW >= 0 ? 1 : -1) + const modeName = site?.active_mode ?? fullStatus?.operating_mode.mode_code ?? 'AUTO' + const modeActivatedAt = site?.activated_at ?? fullStatus?.operating_mode.activated_at ?? null - const planSlots = nextPlanSlots(plan.intervals, 16) - const avgBuy = meanBuyPrice(planSlots) + const handleReplan = useCallback(() => { + if (siteId == null) return + void postRunPlan(siteId, 'rolling') + .then(() => { + void data.reload() + void refreshRollingEta() + void reloadNotifications() + }) + .catch(() => { + /* ignore */ + }) + }, [siteId, data, refreshRollingEta, reloadNotifications]) - const chartData: TelemetryChartPoint[] = points + const handleImportPrices = useCallback(() => { + if (siteId == null) return + void postImportSitePrices(siteId) + .then(() => { + void data.reload() + void reloadNotifications() + }) + .catch(() => { + /* ignore */ + }) + }, [siteId, data, reloadNotifications]) + + const handleSwitchAuto = useCallback(() => { + if (siteId == null) return + void postSiteMode(siteId, { + mode: 'AUTO', + notes: 'Přepnuto z notifikace', + valid_until: null, + }) + .then(() => { + void data.reload() + void reloadNotifications() + }) + .catch(() => { + /* ignore */ + }) + }, [siteId, data, reloadNotifications]) return ( -
-
+
+
{fetchError ? (
-

Chyba načítání dat

+

{fetchError}

) : null} -
+

EMS Platform

-

Přehled lokality, auditu a plánu

+

Přehled výkonů, režimů a cen

- {/* Horní metriky */} + {siteId != null ? ( + {}} + /> + ) : null} + + {notifications.length > 0 ? ( + + ) : null} +
-
+
{metricsLoading ? ( <> + ) : site == null ? ( -

Žádná lokalita ve vw_site_status.

+

Žádná aktivní lokalita ve vw_site_status.

) : ( <> -
-
-
- -
-
-

FVE výroba

-

- {hasLiveData ? fmtKw2(site.pv_power_w) : '—'}{' '} - - ☀️ - -

-
-
+
+

FVE

+

{fmtKw2(lm?.pv_w)}

+
- -
-
-
= 0 - ? 'text-emerald-400' - : 'text-orange-400' - : 'text-slate-400' - }`} - > - -
-
-

Baterie

-

- {batPct == null ? '—' : `${batPct.toFixed(0)}%`} - {batSignedKw == null ? '' : ` / ${batSignedKw >= 0 ? '+' : ''}${batSignedKw.toFixed(2)} kW`} -

-
-
-
-
-
+
+

Spotřeba

+

{fmtKw2(lm?.load_w)}

+
- -
= 0 - ? 'border-l-red-500' - : 'border-l-emerald-500' - : 'border-l-slate-600' - }`} - > -
-
- = 0 - ? 'text-red-400' - : 'text-emerald-400' - : 'text-slate-400' - }`} - aria-hidden - /> -
-
-

Síť

-

{gridLabel}

-

- {gridW != null && !Number.isNaN(gridW) - ? gridW >= 0 - ? 'import' - : 'export' - : ''} -

-
-
+
+

Síť

+

{fmtKw2(lm?.grid_w)}

+

+ {lm?.grid_w == null ? '' : lm.grid_w >= 0 ? 'import' : 'export'} +

+
- -
-
-
- -
-
-

Spotřeba

-

- {hasLiveData ? fmtKw2(site.load_power_w) : '—'} -

-
-
+
+

SOC

+

{fmtSoc(lm?.bat_soc)}

+ +
+
+

Cena nákup

+

+ {fmtMoney3(data.buyNow)} +

+

Aktuální 15min slot

)}
- {/* Status řádek */} {!metricsLoading && site != null ? ( -
-
- Aktivní režim: - +
+
+ Režim: + {site.active_mode ?? '—'} {site.mode_name ? ` · ${site.mode_name}` : ''} - - - + EMS:{' '} {hbOnline ? 'online' : 'offline'} - - Poslední telemetrie:{' '} - {formatTelemetryAgo(site.telemetry_at)} -
{hasMonitoringAlerts ? ( -
+
) : null}
- ) : metricsLoading ? ( -
) : null}
- {/* Graf + denní souhrn */} -
-
- {chartLoading ? ( - - ) : !hasChartData ? ( -
- Zatím žádná data pro dnešní den -
- ) : ( -
- - - - - - } /> - - - - {chartData.map((e, i) => ( - = 0 - ? '#22c55e' - : '#f97316' - } - /> - ))} - - - - -
- )} -
- -
- {summaryLoading ? ( -
- - - - - -
- ) : ( -
-

Dnešní souhrn

-
    -
  • - FVE výroba - {fmtEnergy(daily?.pv_kwh)} -
  • -
  • - Import ze sítě - {fmtEnergy(daily?.import_kwh)} -
  • -
  • - Export do sítě - {fmtEnergy(daily?.export_kwh)} -
  • -
  • - Náklady / příjem - {(() => { - const c = parseNum(daily?.actual_cost_czk) - const cls = - c == null - ? 'text-slate-200' - : c > 0 - ? 'text-red-400' - : c < 0 - ? 'text-emerald-400' - : 'text-slate-200' - return {fmtMoney(daily?.actual_cost_czk)} - })()} -
  • -
- {!hasDaily ? ( -

Pro dnešek zatím nejsou uzavřené intervaly auditu.

- ) : null} - -
- )} -
-
- - {/* Plán 4 h */}
-

- Nejbližší plán (4 hodiny) -

- {planLoading ? ( - - ) : planSlots.length === 0 ? ( -

Plán zatím není k dispozici

+ {data.slots.length === 0 && data.ready ? ( +
+ Nedostatek dat pro graf (zkontrolujte plán a telemetrii). +
) : ( -
-
- {planSlots.map((slot, i) => ( -
-
-
-

{formatSlotLabel(slot.interval_start)}

-

- cena:{' '} - {slot.effective_buy_price == null - ? '—' - : `${slot.effective_buy_price.toFixed(3)} Kč/kWh`} -

-

- baterie: {fmtKw2(slot.battery_setpoint_w ?? undefined)} -

-

síť: {fmtKw2(slot.grid_setpoint_w ?? undefined)}

-
-
- ))} +
+
+
+ +
+
-

16× 15 min · najet myší pro detail

)}
+ + {data.slots.length > 0 && data.ready ? ( +
+ +
+ ) : null} + + {siteId != null ? ( +
+ + {inverterDiagOpen ? ( +
+ +
+ ) : null} +
+ ) : null} + +
+ + +
) diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..ee8cd17 --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef, useState } from 'react' + +type LogRecord = { + ts?: string + level?: string + logger?: string + msg?: string +} + +export function Logs() { + const [lines, setLines] = useState([]) + const bottomRef = useRef(null) + + useEffect(() => { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const ws = new WebSocket(`${proto}//${window.location.host}/ws/logs`) + ws.onmessage = (ev) => { + try { + const rec = JSON.parse(ev.data as string) as LogRecord + setLines((prev) => { + const next = [...prev, rec] + return next.length > 500 ? next.slice(-500) : next + }) + } catch { + /* ignore */ + } + } + return () => ws.close() + }, []) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [lines.length]) + + return ( +
+
+

Logy EMS

+

Stream z backendu (WebSocket)

+
+          {lines.map((r, i) => (
+            
+ {r.ts ?? '—'} + [{r.level ?? '?'}] + {r.logger ?? ''}: + {r.msg ?? ''} +
+ ))} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index f41284d..070758a 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -24,6 +24,7 @@ import { } from 'recharts' import { getCurrentPlan, postImportSitePrices, postRunForecast, postRunPlan } from '../api/backend' +import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants' import { useSiteStatus } from '../hooks/useSiteStatus' import type { CurrentPlanResponse, PlanningIntervalDto } from '../types/plan' @@ -48,10 +49,115 @@ function formatLocalTime(iso: string): string { }) } +function pragueYmd(d: Date): string { + return new Intl.DateTimeFormat('sv-SE', { + timeZone: TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(d) +} + function slotStartUtcMs(iso: string): number { return new Date(iso).getTime() } +const PREDICTED_LEAD_MS = 36 * 60 * 60 * 1000 +const MAX_FUTURE_SLOTS = 384 + +function pragueDayKey(iso: string): string { + return new Intl.DateTimeFormat('sv-SE', { + timeZone: TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date(iso)) +} + +function formatPragueDateLabel(iso: string): string { + return new Date(iso).toLocaleDateString('cs-CZ', { + timeZone: TZ, + weekday: 'short', + day: 'numeric', + month: 'numeric', + year: 'numeric', + }) +} + +function isPredictedPriceSlot(i: PlanningIntervalDto, nowMs: number): boolean { + if (i.is_predicted_price === true) return true + if (i.is_predicted_price === false) return false + return slotStartUtcMs(i.interval_start) > nowMs + PREDICTED_LEAD_MS +} + +function groupByDay(slots: PlanningIntervalDto[]): Record { + return slots.reduce( + (acc, slot) => { + const day = pragueDayKey(slot.interval_start) + if (!acc[day]) acc[day] = [] + acc[day].push(slot) + return acc + }, + {} as Record, + ) +} + +function dayStats(slots: PlanningIntervalDto[]): { + fveKwh: number + exportKwh: number + avgBuy: number | null +} { + const slotHours = SLOT_MS / 3_600_000 + let fveWh = 0 + let expWh = 0 + const buys: number[] = [] + for (const s of slots) { + fveWh += (s.pv_forecast_total_w ?? 0) * slotHours + const gw = s.grid_setpoint_w ?? 0 + if (gw < 0) expWh += -gw * slotHours + if (s.effective_buy_price != null) buys.push(s.effective_buy_price) + } + const avgBuy = buys.length ? buys.reduce((a, b) => a + b, 0) / buys.length : null + return { fveKwh: fveWh / 1000, exportKwh: expWh / 1000, avgBuy } +} + +type HorizonHours = 24 | 48 | 96 + +type PlanTableRow = + | { + kind: 'summary' + dayKey: string + dateLabel: string + fveKwh: number + exportKwh: number + avgBuy: number | null + } + | { kind: 'slot'; i: PlanningIntervalDto } + +function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[] { + const groups = groupByDay(visibleSlots) + const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort() + const rows: PlanTableRow[] = [] + for (const dk of dayKeys) { + const sl = groups[dk] + if (!sl?.length) continue + rows.push({ + kind: 'summary', + dayKey: dk, + dateLabel: formatPragueDateLabel(sl[0]!.interval_start), + ...dayStats(sl), + }) + for (const i of sl) rows.push({ kind: 'slot', i }) + } + return rows +} + +function horizonToggleClass(active: boolean): string { + return active + ? 'border-cyan-600 bg-cyan-950/50 text-cyan-100' + : 'border-slate-600 bg-slate-800/80 text-slate-300 hover:bg-slate-800' +} + /** * Vizuál FVE: API posílá součet A+B (`pv_forecast_total_w`). * Pokud je hodnota null (data chybí), použijeme jednoduchou proxy z ceny nákupu (W). @@ -67,6 +173,53 @@ function pvAProxyW(i: PlanningIntervalDto): number { return Math.max(0, Math.min(15000, w)) } +/** Budoucí slot (od začátku ještě nenastal): předpověď; proběhlý / probíhající: telemetrie z auditu. */ +function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null { + const start = slotStartUtcMs(i.interval_start) + const future = start >= nowMs + if (future) { + const f = i.pv_forecast_total_w + if (f != null) return Number(f) + return null + } + const a = i.pv_power_w + if (a != null) return Number(a) + const f = i.pv_forecast_total_w + return f != null ? Number(f) : null +} + +/** Stejná idea jako výkonové buňky: velké hodnoty v kW, jinak W (bez suffixu u malých čísel jako Bat. W). */ +function formatPlanPowerW(w: number | null): string { + if (w == null || Number.isNaN(w)) return '—' + const v = Math.round(Number(w)) + if (Math.abs(v) >= 1000) { + const k = v / 1000 + const s = k.toFixed(1).replace(/\.0$/, '') + return `${s} kW` + } + return String(v) +} + +function FveWCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) { + const w = slotFveDisplayW(i, nowMs) + const color = + w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500' + return ( + {formatPlanPowerW(w)} + ) +} + +function VynosKcCell({ v }: { v: number | null | undefined }) { + if (v == null || Number.isNaN(Number(v))) { + return — + } + const n = Number(v) + const color = n < 0 ? 'text-emerald-400' : n > 0 ? 'text-red-400' : 'text-slate-500' + return ( + {n.toFixed(4)} + ) +} + function runTypeBadgeClass(t: string): string { const u = t.toLowerCase() if (u === 'daily') return 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-500/35' @@ -90,6 +243,31 @@ function axiosDetail(e: unknown): string { return e instanceof Error ? e.message : 'Neznámá chyba' } +/** Zrcadlí logiku TOU řádků z `write_inverter_setpoints` (PASSIVE/SELL/CHARGE) pro jeden plánovací interval. */ +function deyeSetpointLabel(i: PlanningIntervalDto): string { + const battery_w = i.battery_setpoint_w ?? 0 + const grid_w = i.grid_setpoint_w ?? 0 + const is_exporting = battery_w < -500 || grid_w < -500 + const is_charging = battery_w > 500 + const tgt = i.battery_soc_target_pct + const targetSoc = tgt != null ? Math.min(95, Math.round(Number(tgt))) : 80 + + const fmtKw = (w: number) => { + const k = Math.abs(w) / 1000 + const s = k.toFixed(1).replace(/\.0$/, '') + return `${s}kW` + } + + if (is_exporting) { + const tpPowerW = Math.abs(battery_w) + return `⬇ ${fmtKw(tpPowerW)} | reg178 bit4–5=10 (grid PS off)` + } + if (is_charging) { + return `⬆ ${fmtKw(battery_w)} | grid=yes | SOC→${targetSoc}%` + } + return '~ 2kW | hold' +} + function tableRowClass( i: PlanningIntervalDto, selected: boolean, @@ -117,6 +295,8 @@ type ChartRow = { type PlanPrepActionsProps = { prepAction: null | 'import' | 'forecast' | 'init' replanning: boolean + importDate: 'today' | 'tomorrow' + onImportDateChange: (v: 'today' | 'tomorrow') => void onImport: () => void onForecast: () => void onInit: () => void @@ -126,6 +306,8 @@ type PlanPrepActionsProps = { function PlanPrepActions({ prepAction, replanning, + importDate, + onImportDateChange, onImport, onForecast, onInit, @@ -148,6 +330,18 @@ function PlanPrepActions({ )} Importovat ceny + + ))} +
+
+ ) +} + export default function Planning() { const { site, ready: siteReady } = useSiteStatus() const siteId = site?.site_id ?? null @@ -212,7 +471,10 @@ export default function Planning() { const [error, setError] = useState(null) const [replanning, setReplanning] = useState(false) const [prepAction, setPrepAction] = useState(null) + const [importDate, setImportDate] = useState<'today' | 'tomorrow'>('tomorrow') const [selectedStart, setSelectedStart] = useState(null) + const [tableHorizonH, setTableHorizonH] = useState(48) + const [chartHorizonH, setChartHorizonH] = useState(48) const load = useCallback(async () => { if (siteId == null) return @@ -239,36 +501,46 @@ export default function Planning() { }, [siteId, load]) const nowMs = Date.now() - const dayMs = 24 * 60 * 60 * 1000 + const slotFloorMs = floorSlotUtcMs(nowMs) - const intervals24h = useMemo(() => { + const futureSlots = useMemo(() => { if (!data?.intervals?.length) return [] - const end = nowMs + dayMs return data.intervals - .filter((i) => { - const t = slotStartUtcMs(i.interval_start) - return t >= nowMs && t < end - }) - .slice(0, 96) - }, [data?.intervals, nowMs]) + .filter((i) => slotStartUtcMs(i.interval_start) >= slotFloorMs) + .sort((a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start)) + .slice(0, MAX_FUTURE_SLOTS) + }, [data?.intervals, slotFloorMs]) + + const visibleSlots = useMemo(() => { + const endMs = nowMs + tableHorizonH * 60 * 60 * 1000 + return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs) + }, [futureSlots, nowMs, tableHorizonH]) + + const chartIntervals = useMemo(() => { + const endMs = nowMs + chartHorizonH * 60 * 60 * 1000 + return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs) + }, [futureSlots, nowMs, chartHorizonH]) + + const planTableRows = useMemo(() => buildPlanTableRows(visibleSlots), [visibleSlots]) const xTicks = useMemo(() => { - if (!intervals24h.length) return undefined - const stepMs = 2 * 60 * 60 * 1000 - const first = slotStartUtcMs(intervals24h[0].interval_start) - const last = slotStartUtcMs(intervals24h[intervals24h.length - 1].interval_start) + if (!chartIntervals.length) return undefined + const stepH = chartHorizonH <= 24 ? 2 : chartHorizonH <= 48 ? 4 : 6 + const stepMs = stepH * 60 * 60 * 1000 + const first = slotStartUtcMs(chartIntervals[0].interval_start) + const last = slotStartUtcMs(chartIntervals[chartIntervals.length - 1].interval_start) const ticks: string[] = [] let t = Math.ceil(first / stepMs) * stepMs while (t <= last) { - const hit = intervals24h.find((i) => Math.abs(slotStartUtcMs(i.interval_start) - t) < 30 * 60 * 1000) + const hit = chartIntervals.find((i) => Math.abs(slotStartUtcMs(i.interval_start) - t) < 30 * 60 * 1000) if (hit) ticks.push(hit.interval_start) t += stepMs } return ticks.length ? ticks.map((iso) => formatLocalTime(iso)) : undefined - }, [intervals24h]) + }, [chartIntervals, chartHorizonH]) const chartRows: ChartRow[] = useMemo(() => { - return intervals24h.map((i) => ({ + return chartIntervals.map((i) => ({ label: formatLocalTime(i.interval_start), ts: slotStartUtcMs(i.interval_start), pv_a_w: pvAProxyW(i), @@ -277,7 +549,7 @@ export default function Planning() { effective_buy_price: i.effective_buy_price, raw: i, })) - }, [intervals24h]) + }, [chartIntervals]) async function onReplan() { if (siteId == null) return @@ -304,7 +576,11 @@ export default function Planning() { setPrepAction('import') setError(null) try { - const r = await postImportSitePrices(siteId) + const selectedDate = new Date() + if (importDate === 'tomorrow') { + selectedDate.setDate(selectedDate.getDate() + 1) + } + const r = await postImportSitePrices(siteId, pragueYmd(selectedDate)) toast.success( `Ceny: ${r.slots_imported} slotů (${r.date}), první ${r.first_price_czk_kwh.toFixed(3)} Kč/kWh`, ) @@ -336,7 +612,11 @@ export default function Planning() { setPrepAction('init') setError(null) try { - const imp = await postImportSitePrices(siteId) + const selectedDate = new Date() + if (importDate === 'tomorrow') { + selectedDate.setDate(selectedDate.getDate() + 1) + } + const imp = await postImportSitePrices(siteId, pragueYmd(selectedDate)) toast.success( `Ceny: ${imp.slots_imported} slotů (${imp.date}), první ${imp.first_price_czk_kwh.toFixed(3)} Kč/kWh`, ) @@ -370,9 +650,7 @@ export default function Planning() { const run = data?.run const summary = data?.summary - const planStale = - run != null && Date.now() - new Date(run.created_at).getTime() > 2 * 60 * 60 * 1000 - const showPrepActions = !loading && (run == null || planStale) + const showPrepActions = !loading const prepBusy = prepAction !== null const correctionPct = @@ -384,7 +662,8 @@ export default function Planning() {

Plánování

- Aktuální LP plán a dalších 24 h od teď ({site?.site_name ?? 'lokalita'}) + Aktuální LP plán až 96 h od aktuálního slotu ({site?.site_name ?? 'lokalita'}) — tabulka a graf lze zúžit + horizontem 24 / 48 / 96 h.

@@ -410,6 +689,8 @@ export default function Planning() { void handleImportPrices()} onForecast={() => void handleRunForecast()} onInit={() => void handleInitializePlan()} @@ -462,6 +743,17 @@ export default function Planning() { {run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
+ {summary?.pv_scarcity_factor != null && ( +
+ PV scarcity factor: + + {summary.pv_scarcity_factor.toFixed(3)} + + + (nižší = méně očekávaného slunce, ekonomika víc toleruje precharge ze sítě) + +
+ )} {summary && (

Summary

@@ -501,6 +793,8 @@ export default function Planning() { void handleImportPrices()} onForecast={() => void handleRunForecast()} onInit={() => void handleInitializePlan()} @@ -523,9 +817,12 @@ export default function Planning() { {/* Sekce 2 */}
-

Graf plánu

+

Graf plánu

+ {!chartRows.length ? ( -

Žádná data pro graf (24 h od teď, max. 96 slotů).

+

+ Žádná data pro graf (budoucí sloty aktivního plánu, horizont {chartHorizonH} h). +

) : (
@@ -534,11 +831,11 @@ export default function Planning() { - } /> + } /> -

Tabulka slotů

-
+

Tabulka slotů

+ +
- - + + + - + - {intervals24h.map((i) => { + {planTableRows.map((row) => { + if (row.kind === 'summary') { + return ( + + + + ) + } + const i = row.i const sel = selectedStart === i.interval_start return ( {formatLocalTime(i.interval_start)} - - + + + - + ) })}
ČasCena kupCena prod + Cena + Kč/kWh · kup / prod + Bat. WDeye setpoint SoC %FVE W Síť W EV1 W EV2 W Náklady KčVýnos Kč
+ {row.dateLabel} + · + FVE celkem{' '} + + {row.fveKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh + + · + Export celkem{' '} + + {row.exportKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh + + · + Prům. cena nákup{' '} + + {row.avgBuy != null ? `${row.avgBuy.toFixed(3)} Kč/kWh` : '—'} + +
- {i.effective_buy_price?.toFixed(3) ?? '—'} - - {i.effective_sell_price?.toFixed(3) ?? '—'} - {i.battery_setpoint_w ?? '—'} + {deyeSetpointLabel(i)} + {i.battery_soc_target_pct != null ? `${i.battery_soc_target_pct.toFixed(1)}` : '—'} {i.grid_setpoint_w ?? '—'} {i.ev1_setpoint_w ?? '—'} {i.ev2_setpoint_w ?? '—'} {i.heat_pump_enabled ? 'on' : 'off'} - {i.expected_cost_czk?.toFixed(4) ?? '—'} -
- {!intervals24h.length && !loading && ( -

Žádné řádky v 24h okně.

+ {!visibleSlots.length && !loading && ( +

+ Žádné budoucí sloty v horizontu {tableHorizonH} h (aktivní plán může být prázdný nebo starý). +

)}
diff --git a/frontend/src/types/chart-js-ambient.d.ts b/frontend/src/types/chart-js-ambient.d.ts new file mode 100644 index 0000000..fa8536b --- /dev/null +++ b/frontend/src/types/chart-js-ambient.d.ts @@ -0,0 +1,49 @@ +/** Minimalní deklarace pro build bez nainstalovaných typů / modulů chart.js. */ +declare module 'chart.js' { + export type ChartArea = { + left: number + right: number + top: number + bottom: number + width: number + height: number + } + + export type TooltipItem<_T = unknown> = { + dataIndex: number + dataset: { label?: string; yAxisID?: string } + parsed: { y: number } + } + + export interface Chart { + chartArea: ChartArea + ctx: CanvasRenderingContext2D + data: { + labels?: unknown[] + datasets?: Array<{ hidden?: boolean; label?: string; yAxisID?: string; [key: string]: unknown }> + } + destroy(): void + resize(): void + update(mode?: string): void + } + + export type Plugin<_T = Chart> = { + id: string + beforeDatasetsDraw?(chart: Chart): void + afterDatasetsDraw?(chart: Chart): void + } +} + +declare module 'chart.js/auto' { + import type { Chart } from 'chart.js' + + export class Chart { + constructor(ctx: unknown, config: unknown) + chartArea: Chart['chartArea'] + data: Chart['data'] + ctx: Chart['ctx'] + destroy(): void + resize(): void + update(mode?: string): void + } +} diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts new file mode 100644 index 0000000..3733b26 --- /dev/null +++ b/frontend/src/types/dashboard.ts @@ -0,0 +1,91 @@ +/** Jedna 15min buňka pro dashboardové grafy (minulost + plán). */ +export type SlotData = { + interval_start: string + pv_power_w: number | null + battery_power_w: number | null + /** Plánovaný výkon baterie (W) – budoucí sloty. */ + battery_setpoint_w: number | null + grid_power_w: number | null + /** Plánovaný výkon sítě (W) – budoucí sloty. */ + grid_setpoint_w: number | null + load_power_w: number | null + gen_port_power_w: number | null + pv_a_forecast_w: number | null + pv_b_forecast_w: number | null + load_baseline_w: number | null + ev1_setpoint_w: number | null + ev2_setpoint_w: number | null + heat_pump_setpoint_w: number | null + /** Z plánu (`heat_pump_enabled`); pro StatePanel TČ. */ + heat_pump_enabled: boolean | null + battery_soc_target_pct: number | null + buy_price: number | null + sell_price: number | null + /** Provozní režim pro RegimeBar (kód z operating_mode_def). */ + regime_code: string | null + /** Budoucí segment – zobrazit jako plán (světlejší). */ + regime_is_planned: boolean + /** SoC % skutečnost (telemetrie / audit). */ + soc_actual_pct: number | null + /** SoC % z plánu. */ + soc_plan_pct: number | null + /** TUV °C skutečnost. */ + tuv_actual_c: number | null + /** TUV °C cíl z plánu (zjednodušeně při běhu TČ). */ + tuv_plan_c: number | null +} + +export type ChartAreaLike = { + left: number + right: number + top: number + bottom: number +} + +export type ForecastDayTotal = { + date: string + label: string + kwh: number +} + +export type NegPriceItem = { + interval_start: string + buy: number | null + sell: number | null +} + +/** Živé metriky pro horní karty (5s poll / WebSocket; nezávislé na slots[]). */ +export type LiveMetrics = { + pv_w: number | null + load_w: number | null + grid_w: number | null + bat_soc: number | null + bat_w: number | null +} + +export type NotificationLevel = 'success' | 'info' | 'warning' | 'error' + +export type NotificationAction = 'connect_ev' | 'replan' | 'import_prices' | 'switch_auto' + +/** Odpověď GET /api/v1/sites/{id}/notifications */ +export type Notification = { + id: string + level: NotificationLevel + title: string + body: string + /** Minuty do události (zobrazí se „za Xh Ymin“). */ + eta_minutes?: number | null + action?: NotificationAction | null +} + +export type TelemetryWsPayload = { + type: string + site_id: number + ts?: string + pv_power_w?: number | null + battery_soc_pct?: number | null + battery_power_w?: number | null + grid_power_w?: number | null + load_power_w?: number | null + gen_port_power_w?: number | null +} diff --git a/frontend/src/types/ems.ts b/frontend/src/types/ems.ts index fa11d85..6687d43 100644 --- a/frontend/src/types/ems.ts +++ b/frontend/src/types/ems.ts @@ -36,6 +36,28 @@ export type AuditTodayHourlyRow = { cost_czk: string | number | null } +/** ems.vw_telemetry_hourly_7d (řádky z telemetry_inverter_hourly) */ +export type TelemetryHourly7dRow = { + hour: string + site_id: number + avg_pv_w: number | null + avg_battery_w: number | null + avg_grid_w: number | null + avg_load_w: number | null + last_soc_pct: string | number | null + sample_count: number | null +} + +/** ems.vw_latest_heat_pump */ +export type HeatPumpLatestRow = { + site_id: number + heat_pump_id: number + heat_pump_code: string | null + measured_at: string + tuv_tank_temp_c: number | string | null + power_w: number | null +} + /** ems.vw_audit_daily */ export type AuditDailyRow = { site_id: number diff --git a/frontend/src/types/plan.ts b/frontend/src/types/plan.ts index 9724128..2a652bc 100644 --- a/frontend/src/types/plan.ts +++ b/frontend/src/types/plan.ts @@ -17,12 +17,18 @@ export type PlanningIntervalDto = { grid_setpoint_w: number | null ev1_setpoint_w: number | null ev2_setpoint_w: number | null + ev_charge_power_w?: number | null heat_pump_enabled: boolean | null + heat_pump_setpoint_w?: number | null pv_a_curtailed_w: number | null expected_cost_czk: number | null effective_buy_price: number | null effective_sell_price: number | null + /** True pokud cena pro slot byla při plánování predikovaná (DB sloupec `is_predicted_price`). */ + is_predicted_price: boolean pv_forecast_total_w: number | null + /** Průměrná skutečná FVE výkon za slot z audit_interval (GET /plan/current JOIN). */ + pv_power_w?: number | null load_baseline_w: number | null } @@ -32,6 +38,8 @@ export type PlanningSummaryDto = { charge_slots: number discharge_slots: number export_slots: number + /** 0.65..1.0; nižší znamená očekávaně méně slunce -> větší ochota precharge ze sítě */ + pv_scarcity_factor?: number } export type CurrentPlanResponse = { diff --git a/frontend/src/types/react-router-dom-ambient.d.ts b/frontend/src/types/react-router-dom-ambient.d.ts new file mode 100644 index 0000000..1943566 --- /dev/null +++ b/frontend/src/types/react-router-dom-ambient.d.ts @@ -0,0 +1,9 @@ +declare module 'react-router-dom' { + import type { ReactNode } from 'react' + + export function BrowserRouter(props: { children?: ReactNode }): JSX.Element + export function Routes(props: { children?: ReactNode }): JSX.Element + export function Route(props: Record): JSX.Element | null + export function Outlet(): JSX.Element | null + export function NavLink(props: Record): JSX.Element +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e5710c7..dea2b87 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -27,6 +27,11 @@ export default defineConfig(async () => { }, server: { proxy: { + '/ws': { + target: 'http://localhost:8000', + changeOrigin: true, + ws: true, + }, '/api': { target: 'http://localhost:8000', changeOrigin: true, diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh index 59909c5..e393bdb 100755 --- a/scripts/smoke_test.sh +++ b/scripts/smoke_test.sh @@ -38,10 +38,18 @@ STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/sites/${SITE_ID}/p echo -n "OTE price import... " RESULT=$(curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/prices/import" \ -H "Content-Type: application/json" 2>/dev/null) || RESULT="" +PRICES_EXPECTED=$(python3 -c " +from zoneinfo import ZoneInfo +from datetime import datetime +n = datetime.now(ZoneInfo('Europe/Prague')) +print(1 if (n.hour, n.minute) >= (14, 30) else 0) +") if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('slots_imported',0)>0" 2>/dev/null; then echo "OK ($(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['slots_imported'])") slotů)" +elif [ "$PRICES_EXPECTED" = "1" ]; then + echo "WARN – OTE API možná nemá data pro zítřek nebo je nedostupné (po 14:30 Europe/Prague)" else - echo "WARN – OTE API možná nemá data pro zítřek nebo je nedostupné" + echo "OK (před 14:30 Europe/Prague – D+1 ceny typicky ještě nejsou; import přeskočen bez WARN)" fi # 6. PV forecast diff --git a/scripts/test_modbus_deye.py b/scripts/test_modbus_deye.py new file mode 100644 index 0000000..40859e5 --- /dev/null +++ b/scripts/test_modbus_deye.py @@ -0,0 +1,69 @@ +"""Rychlý test Modbus TCP na Deye přes Waveshare (registry dle aktuální mapy).""" + +from __future__ import annotations + +import asyncio +import sys + +from pymodbus.client import AsyncModbusTcpClient + +HOST = "172.16.1.10" +PORT = 502 +UNIT_ID = 1 # dle DIP přepínače + +# (adresa, typ, počet registrů, jednotka, popis) +REGISTERS: dict[str, tuple[int, str, int, str, str]] = { + "run_state": (500, "uint", 1, "", "enum provozní stav střídače (raw)"), + "battery_soc_%": (588, "uint", 1, "%", "SoC baterie"), + "battery_power_w": (590, "sint", 1, "W", "+ vybíjení / − nabíjení"), + "batt_charge_today_wh": (514, "uint", 1, "Wh", "dnešní nabití baterie"), + "batt_discharge_today_wh": (515, "uint", 1, "Wh", "dnešní vybití baterie"), + "gen_port_power_w": (667, "uint", 1, "W", "GEN port – FVE pole B"), + "grid_total_power_w": (625, "sint", 1, "W", "+ import ze sítě / − export"), + "load_total_power_w": (653, "uint", 1, "W", "celková spotřeba"), + "pv1_power_w": (672, "uint", 1, "W", "výkon PV1"), + "pv2_power_w": (673, "uint", 1, "W", "výkon PV2"), +} + + +async def read_reg(client: AsyncModbusTcpClient, address: int, reg_type: str) -> int | None: + result = await client.read_holding_registers( + address, count=1, device_id=UNIT_ID + ) + if result.isError(): + return None + raw = int(result.registers[0]) + if reg_type == "sint" and raw > 32767: + raw -= 65536 + return raw + + +async def test_deye() -> None: + print(f"Připojuji se na {HOST}:{PORT} device_id={UNIT_ID}...") + client = AsyncModbusTcpClient(HOST, port=PORT) + try: + ok = await client.connect() + if not ok or not client.connected: + print("CHYBA: Nelze se připojit na Waveshare") + return + + print("Připojeno OK\n") + print(f"{'Signál':<28} {'Hodnota':>10} {'Jedn.':<6} Reg(dec) Popis") + print("-" * 85) + for name, (addr, rtype, _count, unit, desc) in REGISTERS.items(): + val = await read_reg(client, addr, rtype) + reg_h = f"{addr} (0x{addr:04X})" + if val is None: + print(f" {name:<26} {'CHYBA':>10} {'':6} {reg_h:<12} {desc}") + else: + u = unit or "—" + print(f" {name:<26} {val:>10} {u:<6} {reg_h:<12} {desc}") + finally: + client.close() + + +if __name__ == "__main__": + try: + asyncio.run(test_deye()) + except KeyboardInterrupt: + sys.exit(130) diff --git a/scripts/update_waveshare.sql b/scripts/update_waveshare.sql new file mode 100644 index 0000000..4a4db77 --- /dev/null +++ b/scripts/update_waveshare.sql @@ -0,0 +1,18 @@ +-- Aktualizace Waveshare endpointu pro Deye (home-01) bez resetu migrací. +-- OR musí být v závorkách, jinak by se aktualizovaly i řádky jiných site. + +UPDATE ems.site_endpoint +SET host = '172.16.1.10', port = 502, unit_id = 1 +WHERE endpoint_type = 'modbus_tcp' + AND site_id = (SELECT id FROM ems.site WHERE code = 'home-01') + AND ( + notes LIKE '%Deye%' + OR notes ILIKE '%inverter%' + OR notes ILIKE '%střídač%' + ); + +-- Ověření +SELECT id, endpoint_type, host, port, unit_id, notes +FROM ems.site_endpoint +WHERE site_id = (SELECT id FROM ems.site WHERE code = 'home-01') +ORDER BY id;