second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View File

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

6
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,6 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/ems-cursor.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="DBE_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ems-cursor.iml" filepath="$PROJECT_DIR$/.idea/ems-cursor.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -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 **3696h** 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** (036h), **0,7** (3672h), **0,4** (7296h). 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ů 60499:** 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`, 36 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:** **6264** 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.

View File

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

View File

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

View File

@@ -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")
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
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 = fpi.pv_array_id AND apa.site_id = fpr.site_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 apa.code, fpi.interval_start
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

View File

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

View File

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

View File

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

View File

@@ -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,41 +175,32 @@ 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"
)
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
# 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
@@ -143,6 +209,15 @@ async def post_run_plan(
""",
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
if row is None:
raise HTTPException(status_code=500, detail="Planning run row missing after insert")

View File

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

38
backend/app/ws_manager.py Normal file
View File

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

View File

@@ -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 (bit45); 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:3014: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 6264 (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()
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),
)
)

View File

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

View File

@@ -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 60499 (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]

View File

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

View File

@@ -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 # 036h od začátku okna (přesné OTE ceny)
SLOT_WEIGHT_MEDIUM = 0.7 # 3672h
SLOT_WEIGHT_LOW = 0.4 # 7296h
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(
slot_weight(t, now_slot_index) * (
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
# 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
for t in range(T)
)
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,13 +400,43 @@ 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)
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)
@@ -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)
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp = await _load_site_context(
site_id, 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, 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
])

View File

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

View File

@@ -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,22 +131,111 @@ 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:

View File

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

View File

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

View File

@@ -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.';

View File

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

View File

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

View File

@@ -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.';

View File

@@ -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');

View File

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

View File

@@ -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.';

View File

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

View File

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

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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ů.';

View File

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

View File

@@ -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().';

View File

@@ -9,27 +9,118 @@ CREATE OR REPLACE FUNCTION ems.fn_effective_buy_price(
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);';

View File

@@ -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.';

View File

@@ -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).';

View File

@@ -25,6 +25,10 @@ DECLARE
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.';
-- ============================================================

View File

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

View File

@@ -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
);';

View File

@@ -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.';

View File

@@ -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.';
-- ============================================================

View File

@@ -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.';

View File

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

View File

@@ -5,38 +5,80 @@
-- =============================================================
CREATE OR REPLACE VIEW ems.vw_site_effective_price AS
SELECT
WITH cfg_price AS (
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.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,
-- 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);
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
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).';

View File

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

View File

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

View File

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

View File

@@ -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');

View File

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

View File

@@ -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` (023). Č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 1718h“) 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

View File

@@ -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 (06 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
```

View File

@@ -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:0014: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`) + (x1)×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``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

View File

@@ -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 **6264** (čas) a **time pointy 148177** 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`

View File

@@ -0,0 +1,184 @@
# Deye Modbus Registry EMS řízení
## Důležité pravidlo
- Registry **60499**: POUZE **FC 0x10** (`write_registers`)
- Registry **059**: 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 60499.
## Ří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`, bit45 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit45 = **11**) v **PASSIVE** a **CHARGE**. |
| 190 | GEN peak shaving | 016000 | 1 W | Peak shaving na GEN portu |
| 191 | Grid peak shaving power | 016000 | 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ě: **6264** (čas), **time points 148177**, **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 016 000).
### Reg 178 hodnoty podle fyzického režimu
- **SELL:** **32** bit45 = **10**, grid peak shaving **disable** (export do sítě).
- **PASSIVE** a **CHARGE:** **48** bit45 = **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 12** 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 6264). 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 |
| 36 | 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 12 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:1514:30), po 14:30 blok 2 (plán 14:3014:45). Po dalším exportu se oba časy posunou (např. 14:30 / 14:45).
### Fyzické režimy Deye parametry jednoho time pointu (bloky 12)
| 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 36 zůstávají na **23:59** s pasivním profilem (`reserve_soc`, grid charge = NE).
### Synchronizace času
Registry **6264** 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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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` ř. 910; `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, 76105 (tabulky TBD), 212214; `docs/04-modules/heat-pump.md` ř. 7985, 102; `docs/04-modules/control.md` ř. 249251; pseudokód `TBD_*_REGISTER` ř. 166171, 192197; `docs/loxone-integration.md` ř. 259261 | 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` ř. 4550 | 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 |

View File

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

View File

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

View File

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

View File

@@ -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<Page>('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 (
<div className="min-h-screen bg-slate-950">
<nav className="sticky top-0 z-40 border-b border-slate-800/80 bg-slate-950/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center gap-1 px-4 py-2 md:px-8">
<button
type="button"
onClick={() => setPage('dashboard')}
className={`rounded-lg px-3 py-2 text-sm font-medium transition ${
page === 'dashboard' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
}`}
>
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-1 px-4 py-2 md:px-8">
<NavLink to="/" end className={tabClass}>
Přehled
</button>
<button
type="button"
onClick={() => setPage('planning')}
className={`rounded-lg px-3 py-2 text-sm font-medium transition ${
page === 'planning' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
}`}
>
</NavLink>
<NavLink to="/planning" className={tabClass}>
Plánování
</button>
<button
type="button"
onClick={() => setPage('settings')}
className={`rounded-lg px-3 py-2 text-sm font-medium transition ${
page === 'settings' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
}`}
>
</NavLink>
<NavLink to="/settings" className={tabClass}>
Nastavení
</button>
</NavLink>
<a
href="/logs"
target="_blank"
rel="noopener noreferrer"
className="relative rounded-lg px-3 py-2 text-sm font-medium text-slate-400 transition hover:bg-slate-900 hover:text-slate-200"
>
Logy
{logErrors > 0 ? (
<span className="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold text-white">
{logErrors > 99 ? '99+' : logErrors}
</span>
) : null}
</a>
</div>
</nav>
{page === 'dashboard' ? <Dashboard /> : page === 'planning' ? <Planning /> : <Settings />}
<Outlet />
<Toaster richColors position="top-right" theme="dark" />
</div>
)
}
export default function App() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route index element={<Dashboard />} />
<Route path="planning" element={<Planning />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="logs" element={<Logs />} />
</Routes>
)
}

View File

@@ -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<FullStatusRespo
return data
}
export type SiteNotificationsResponse = {
notifications: Notification[]
}
export async function getSiteNotifications(siteId: number): Promise<SiteNotificationsResponse> {
const { data } = await client.get<SiteNotificationsResponse>(`/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<CurrentPlanRespons
return data
}
/** GET /api/v1/sites/{id}/prices?date=YYYY-MM-DD */
export type SiteEffectivePriceRowDto = {
site_id: number
interval_start: string
interval_end?: string
effective_buy_price_czk_kwh?: number | string | null
effective_sell_price_czk_kwh?: number | string | null
}
export async function getSitePrices(siteId: number, date: string): Promise<SiteEffectivePriceRowDto[]> {
const { data } = await client.get<SiteEffectivePriceRowDto[]>(`/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<ForecastPvDayResponse> {
const { data } = await client.get<ForecastPvDayResponse>(`/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<NegativePredictionsResponseDto> {
const { data } = await client.get<NegativePredictionsResponseDto>(
`/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<DeyeRegistersLive> {
const { data } = await client.get<DeyeRegistersLive>(`/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<ModbusJournalResponse> {
const { data } = await client.get<ModbusJournalResponse>(
`/sites/${siteId}/control/journal`,
{ params: { limit }, timeout: 15_000 },
)
return {
commands: Array.isArray(data?.commands) ? data.commands : [],
}
}
export { client as backendClient }

View File

@@ -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 (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-200">Živé registry</h3>
<button
type="button"
onClick={() => onRefresh()}
disabled={liveLoading}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${liveLoading ? 'animate-spin' : ''}`} aria-hidden />
Refresh
</button>
</div>
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
<Metric label="Max nabíjení" reg={108} unitA={live?.reg108_charge_a} kwHint />
<Metric label="Max vybíjení" reg={109} unitA={live?.reg109_discharge_a} kwHint />
<Metric
label="Limit control"
reg={142}
sub="0 = selling first, 1 = zero export"
valueText={live?.reg142_limit_control != null ? String(live.reg142_limit_control) : undefined}
/>
<Metric label="Energy mode" reg={141} valueText={live?.reg141_energy_mode != null ? String(live.reg141_energy_mode) : undefined} />
<Metric
label="Peak shaving switch"
reg={178}
sub="Bit45: 10 = disable při exportu, 11 = enable při IDLE/CHARGE"
valueText={live?.reg178_peak_shaving_switch != null ? String(live.reg178_peak_shaving_switch) : undefined}
/>
<Metric
label="Grid peak shaving W"
reg={191}
sub="EMS nezapisuje nastavit v SolarmanApp (výkon peak shavingu v W)"
valueText={fmtW(live?.reg191_peak_shaving_w)}
/>
</div>
{live?.read_at ? (
<p className="mt-3 text-[10px] text-slate-500">Načteno: {fmtTime(live.read_at)}</p>
) : null}
</div>
)
},
(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 (
<div className="rounded-lg border border-slate-800/80 bg-slate-950/40 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-0.5 font-mono text-sm text-slate-100">
reg {reg}: {main}
{extra && extra !== '—' ? <span className="text-slate-400"> · {extra}</span> : null}
</p>
{sub ? <p className="mt-0.5 text-[10px] text-slate-500">{sub}</p> : null}
</div>
)
}
type JournalSectionProps = {
commands: ModbusJournalCommandDto[]
}
const JournalSection = memo(
function JournalSection({ commands }: JournalSectionProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<h3 className="mb-3 text-sm font-semibold text-slate-200">Posledních 50 zápisů</h3>
<div
className="overflow-x-auto"
style={{
maxHeight: '400px',
overflowY: 'auto',
borderRadius: 'var(--border-radius-md)',
border: '0.5px solid var(--color-border-tertiary)',
}}
>
<table className="w-full border-collapse text-left text-xs">
<thead>
<tr className="text-slate-500">
<th className="py-2 pr-2 font-medium">Čas</th>
<th className="py-2 pr-2 font-medium">Reg</th>
<th className="py-2 pr-2 font-medium">Popis</th>
<th className="py-2 pr-2 font-medium">Hodnota</th>
<th className="py-2 pr-2 font-medium">Pokus</th>
<th className="py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{commands.length === 0 ? (
<tr>
<td colSpan={6} className="py-4 text-slate-500">
Žádné záznamy v journalu.
</td>
</tr>
) : (
commands.map((c) => (
<tr key={c.id} className="border-t border-slate-800/80">
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-400">
{fmtTime(c.created_at)}
</td>
<td className="pr-2 font-mono text-slate-300">{c.register}</td>
<td className="max-w-[140px] truncate pr-2 text-slate-400" title={c.register_name ?? ''}>
{c.register_name ?? '—'}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-200">
{c.value_to_write}
{c.value_verified != null ? (
<span className="text-slate-500"> {c.value_verified}</span>
) : null}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{c.attempt_count}</td>
<td className="py-1.5">
<span
className={`inline-block rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase ${statusBadgeClass(c.status)}`}
>
{c.status}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
},
(a, b) => journalSignature(a.commands) === journalSignature(b.commands),
)
function ControlPanelImpl({ siteId }: { siteId: number }) {
const [live, setLive] = useState<DeyeRegistersLive | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [liveLoading, setLiveLoading] = useState(false)
const [commands, setCommands] = useState<ModbusJournalCommandDto[]>([])
const [journalError, setJournalError] = useState<string | null>(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 (
<div className="space-y-4">
{apiError ? (
<div
role="alert"
className="rounded-lg border border-red-500/45 bg-red-950/50 px-4 py-3 text-sm text-red-100"
>
<p className="font-semibold text-red-50">Chyba API řízení / Modbus</p>
{liveError ? (
<p className="mt-1.5">
<span className="text-red-200/90">GET /control/registers: </span>
{liveError}
</p>
) : null}
{journalError ? (
<p className={liveError ? 'mt-2' : 'mt-1.5'}>
<span className="text-red-200/90">Journal: </span>
{journalError}
</p>
) : null}
</div>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<LiveRegistersSection live={live} liveLoading={liveLoading} onRefresh={fetchRegisters} />
<JournalSection commands={commands} />
</div>
</div>
)
}
export const ControlPanel = memo(ControlPanelImpl)

View File

@@ -0,0 +1,77 @@
type Props = {
modeName: string
activatedAt: string | null
nextReplanIn: number | null
onReplan: () => void
onModeChange: () => void
}
const MODE_DOT: Record<string, string> = {
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 (
<>
<style>{`
@keyframes ems-mode-auto-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.ems-mode-auto-pulse {
animation: ems-mode-auto-pulse 2s ease-in-out infinite;
}
`}</style>
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-700/90 bg-slate-950/95 px-3 py-2 text-sm text-slate-200">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${code === 'AUTO' ? 'ems-mode-auto-pulse' : ''}`}
style={{ backgroundColor: dot }}
aria-hidden
/>
<span className="font-semibold tracking-wide text-slate-100">{code}</span>
{subParts.length > 0 ? (
<span className="truncate text-xs text-slate-400 sm:text-sm">{subParts.join(' · ')}</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={onReplan}
className="rounded-md border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700"
>
Přeplánovat
</button>
<button
type="button"
onClick={onModeChange}
className="rounded-md border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700"
>
Změnit režim
</button>
</div>
</div>
</>
)
}

View File

@@ -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 (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4 text-sm text-slate-400">
Predikce bude dostupná po 4 týdnech provozu.
</div>
)
}
if (!predictions.length) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4 text-sm text-slate-400">
Žádné záporné ceny v příštích 7 dnech.
</div>
)
}
return (
<div className="space-y-2">
{predictions.map((p, i) => (
<article
key={`${p.predicted_date}-${p.window_start_hour}-${i}`}
className={`rounded-lg border border-slate-800 border-l-4 bg-slate-900/60 py-3 pl-3 pr-4 ${borderClass(p.probability_pct)}`}
>
<p className="text-xs font-medium text-slate-300">
{p.predicted_date} · {pad2(p.window_start_hour)}:00{pad2(p.window_end_hour)}:00
</p>
<p className="mt-1 text-sm text-slate-400">{p.reason}</p>
<p className="mt-2 text-xs tabular-nums text-slate-500">
{p.probability_pct.toFixed(0)}% jistota
{p.expected_min_price != null
? ` · očekávané min. ${p.expected_min_price.toFixed(2)} Kč/kWh`
: ''}
</p>
</article>
))}
</div>
)
}

View File

@@ -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 (
<span className="rounded-md bg-slate-800 px-2 py-0.5 text-[10px] font-semibold uppercase text-slate-300">
Připoj auto
</span>
)
}
if (action === 'replan') {
return (
<button
type="button"
onClick={onReplan}
className="rounded-md border border-slate-600 bg-slate-900/60 px-2 py-1 text-xs font-medium text-slate-100 hover:bg-slate-800"
>
Přeplánovat nyní
</button>
)
}
if (action === 'import_prices') {
return (
<button
type="button"
onClick={onImportPrices}
className="rounded-md border border-slate-600 bg-slate-900/60 px-2 py-1 text-xs font-medium text-slate-100 hover:bg-slate-800"
>
Importovat ceny
</button>
)
}
if (action === 'switch_auto') {
return (
<button
type="button"
onClick={onSwitchAuto}
className="rounded-md border border-emerald-700/60 bg-emerald-950/40 px-2 py-1 text-xs font-medium text-emerald-100 hover:bg-emerald-900/50"
>
Přepnout na AUTO
</button>
)
}
return null
}
export function NotificationBar({ notifications, onReplan, onImportPrices, onSwitchAuto }: Props) {
const shown = notifications.slice(0, 2)
if (shown.length === 0) return null
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{shown.map((n) => {
const st = LEVEL_STYLES[n.level] ?? LEVEL_STYLES.info!
return (
<div
key={n.id}
className="flex gap-3 rounded-xl border px-3 py-2.5 text-sm"
style={{ backgroundColor: st.bg, borderColor: st.border }}
>
<span className={`shrink-0 text-lg leading-none ${st.iconClass}`} aria-hidden>
{st.icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-bold text-slate-100">{n.title}</p>
{n.eta_minutes != null && n.eta_minutes >= 0 ? (
<span className="text-xs text-slate-400">{fmtEtaMinutes(n.eta_minutes)}</span>
) : null}
</div>
<p className="mt-0.5 text-slate-300">{n.body}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ActionControls
action={n.action}
onReplan={onReplan}
onImportPrices={onImportPrices}
onSwitchAuto={onSwitchAuto}
/>
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -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 (
<div className="relative mt-0.5 h-4 w-full">
{slots.map((s, i) =>
isFourHourTick(s.interval_start) ? (
<span
key={`${s.interval_start}-${i}`}
className="absolute top-0 -translate-x-1/2 text-[9px] tabular-nums text-slate-500"
style={{ left: `${((i + 0.5) / n) * 100}%` }}
>
{TIME_PRAGUE.format(new Date(s.interval_start))}
</span>
) : null,
)}
</div>
)
}
function SegmentBar({
segments,
nowIndex,
showNowLabel,
}: {
segments: TrackSegment[]
nowIndex: number
showNowLabel?: boolean
}) {
const n = TOTAL_SLOTS
const leftPct = (nowIndex / n) * 100
return (
<div className="relative min-h-[28px]">
<div className="flex h-[28px] w-full overflow-hidden rounded-sm border border-slate-800/80">
{segments.map((seg, idx) => (
<div
key={idx}
title={seg.tooltip}
className="relative flex min-w-0 shrink-0 items-center justify-center overflow-hidden px-0.5 text-center font-medium leading-tight"
style={{
width: `${seg.widthPct}%`,
background: seg.color,
opacity: seg.isFuture ? 0.6 : 1,
borderLeft: seg.isFuture ? '1px dashed rgba(148,163,184,0.45)' : 'none',
color: seg.textColor,
fontSize: 9,
}}
>
<span className="truncate">{seg.label}</span>
{seg.exportBanOverlay ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center text-[9px] font-bold"
style={{ background: '#E24B4A28', color: '#993C1D' }}
>
0!
</div>
) : null}
</div>
))}
</div>
<div
className="pointer-events-none absolute inset-0 z-10"
aria-hidden
>
{showNowLabel ? (
<span
className="absolute -top-5 z-20 whitespace-nowrap text-[9px] font-semibold text-[#378ADD]"
style={{ left: `${leftPct}%`, transform: 'translateX(-50%)' }}
>
teď
</span>
) : null}
<div
className="absolute bottom-0 top-0 w-[1.5px] bg-[#378ADD]"
style={{ left: `${leftPct}%`, transform: 'translateX(-50%)' }}
/>
</div>
</div>
)
}
function TrackRow({
label,
segments,
nowIndex,
showNowLabel,
}: {
label: string
segments: TrackSegment[]
nowIndex: number
showNowLabel?: boolean
}) {
return (
<div className="grid grid-cols-[52px_1fr] items-center gap-x-1 gap-y-0">
<div className="pr-1 text-right text-[10px] font-medium text-slate-400">{label}</div>
<SegmentBar segments={segments} nowIndex={nowIndex} showNowLabel={showNowLabel} />
</div>
)
}
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 (
<div className="space-y-5 rounded-xl border border-slate-800 bg-slate-900/40 p-3">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
Energetický tok
</p>
<div className="space-y-2">
<TrackRow label="Síť" segments={gridSegs} nowIndex={nowIndex} showNowLabel />
<TrackRow label="Baterie" segments={batSegs} nowIndex={nowIndex} />
</div>
<div className="mt-1 grid grid-cols-[52px_1fr] gap-x-1">
<div />
<TickRow slots={slots} />
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-slate-500">
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#E24B4A1A' }} />
Import
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#1D9E751A' }} />
Export
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#88878012' }} />
Klid
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#E24B4A28' }} />
Zákaz exportu (0!)
</li>
</ul>
</div>
<div className="border-t border-slate-800 pt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
Variabilní zátěže
</p>
<div className="space-y-2">
<TrackRow label="Tesla" segments={ev1Segs} nowIndex={nowIndex} />
<TrackRow label="Zoe" segments={ev2Segs} nowIndex={nowIndex} />
<TrackRow label="TČ" segments={tcSegs} nowIndex={nowIndex} />
</div>
<div className="mt-1 grid grid-cols-[52px_1fr] gap-x-1">
<div />
<TickRow slots={slots} />
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-slate-500">
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#534AB71A' }} />
EV nabíjení
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#88878008' }} />
Nepřipojeno
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#D4537E1A' }} />
běh
</li>
</ul>
</div>
</div>
)
}
export const StatePanel = memo(StatePanelRaw, (prev, next) => {
return prev.slots === next.slots && prev.nowIndex === next.nowIndex
})

View File

@@ -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<string>
onToggle: (key: string) => void
onChartArea?: (area: ChartArea) => void
}
export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const chartRef = useRef<Chart | null>(null)
const onChartAreaRef = useRef(onChartArea)
onChartAreaRef.current = onChartArea
const slotsRef = useRef<SlotData[]>([])
const negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([])
const nowIndexRef = useRef(0)
const labelsRef = useRef<string[]>([])
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 (
<div className="flex flex-col gap-2">
<div className="h-[260px] w-full">
<canvas ref={canvasRef} className="max-h-[260px] w-full" role="img" aria-label="Graf výkonů a cen" />
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1.5 px-1">
{ENERGY_LEGEND.map((item) => {
const off = hidden.has(item.key)
return (
<button
key={item.key}
type="button"
onClick={() => onToggle(item.key)}
className={`inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] transition hover:bg-white/5 ${
off ? 'text-slate-500 line-through opacity-60' : 'text-slate-200'
}`}
>
<span
className="h-2.5 w-4 shrink-0 rounded-sm border border-white/10"
style={{
backgroundColor: off ? 'transparent' : item.color,
borderStyle: item.dashed ? 'dashed' : 'solid',
}}
/>
{item.label}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -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 (
<section className="rounded-xl border border-slate-800/90 bg-slate-900/50 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Předpověď výroby FVE (7 dní)
</h3>
{days.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">Žádná data forecastu.</p>
) : (
<ul className="mt-3 space-y-2.5">
{days.map((d) => (
<li key={d.date} className="flex items-center gap-3 text-sm">
<span className="w-24 shrink-0 text-slate-400">{d.label}</span>
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-800">
<div
className="h-full rounded-full bg-amber-500/80 transition-all"
style={{ width: `${Math.min(100, (d.kwh / max) * 100)}%` }}
/>
</div>
<span className="w-16 shrink-0 text-right tabular-nums text-amber-200/90">
{d.kwh.toFixed(1)} kWh
</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -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 (
<section className="rounded-xl border border-slate-800/90 bg-slate-900/50 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Záporné ceny (nadcházející)
</h3>
{items.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">V dostupných datech nejsou záporné ceny.</p>
) : (
<ul className="mt-3 max-h-48 space-y-2 overflow-y-auto text-sm">
{items.map((it) => (
<li
key={it.interval_start}
className="flex flex-col gap-0.5 rounded-lg border border-slate-700/60 bg-slate-950/40 px-2 py-1.5"
>
<span className="text-slate-300">{fmtTime(it.interval_start)}</span>
<span className="tabular-nums text-xs text-slate-400">
nákup:{' '}
<span className={it.buy != null && it.buy < 0 ? 'text-emerald-400' : ''}>
{it.buy == null ? '—' : `${it.buy.toFixed(3)} Kč/kWh`}
</span>
{' · '}
prodej:{' '}
<span className={it.sell != null && it.sell < 0 ? 'text-red-300' : ''}>
{it.sell == null ? '—' : `${it.sell.toFixed(3)} Kč/kWh`}
</span>
</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -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<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
className="block w-full"
style={{ height: 28 }}
aria-hidden
height={28}
/>
)
}

View File

@@ -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<HTMLCanvasElement>(null)
const chartRef = useRef<Chart | null>(null)
const slotsRef = useRef<SlotData[]>([])
const negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([])
const nowIndexRef = useRef(0)
const labelsRef = useRef<string[]>([])
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 (
<div className="h-[100px] w-full">
<canvas ref={canvasRef} className="max-h-[100px] w-full" role="img" aria-label="SoC a TUV" />
</div>
)
}

View File

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

View File

@@ -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<SlotData[]>,
negRangesRef: MutableRefObject<NegWeekendRange[]>,
): 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<number>, 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()
},
}
}

View File

@@ -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<SlotData[]>([])
const [liveMetrics, setLiveMetrics] = useState<LiveMetrics | null>(null)
const [forecastWeek, setForecastWeek] = useState<ForecastDayTotal[]>([])
const [negPrices, setNegPrices] = useState<NegPriceItem[]>([])
const [error, setError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const wsRef = useRef<WebSocket | null>(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<string>()
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<SiteStatusRow[]>('/vw_site_status', { site_id: `eq.${siteId}` }),
getJson<TelemetryHourly7dRow[]>('/vw_telemetry_hourly_7d', {
site_id: `eq.${siteId}`,
order: 'hour.asc',
limit: '500',
}),
getJson<AuditTodayHourlyRow[]>('/vw_audit_today_hourly', {
site_id: `eq.${siteId}`,
order: 'hour_local.asc',
}),
getJson<ModeLogRecentRow[]>('/vw_mode_log_recent', {
site_id: `eq.${siteId}`,
order: 'activated_at.asc',
limit: '200',
}),
getJson<HeatPumpLatestRow[]>('/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<string, PlanningIntervalDto>()
for (const iv of plan.intervals) {
planBySlot.set(slotTimeKey(new Date(iv.interval_start).getTime()), iv)
}
const priceBySlot = new Map<string, { buy: number | null; sell: number | null }>()
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<string, { a: number; b: number }>()
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<ReturnType<typeof getSiteForecastPv>> | 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<string, { a: number; b: number }>()
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<number, TelemetryHourly7dRow>()
if (Array.isArray(hourly7d)) {
for (const r of hourly7d) {
hourlyMap.set(new Date(r.hour).getTime(), r)
}
}
const auditMap = new Map<string, AuditTodayHourlyRow>()
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<SiteStatusRow[]>('/vw_site_status', { site_id: `eq.${siteId}` }),
getJson<HeatPumpLatestRow[]>('/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<string, unknown>
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,
}
}

View File

@@ -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<FullStatusResponse | null>(null)

View File

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

View File

@@ -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<Notification[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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),
}
}

View File

@@ -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<number | null>(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 }
}

View File

@@ -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<SiteStatusRow | null>(null)

View File

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

View File

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

View File

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

View File

@@ -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(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View File

@@ -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 (
<div className="rounded-lg border border-slate-600 bg-slate-900/95 px-3 py-2 text-xs text-slate-100 shadow-xl">
<p className="mb-1 font-medium text-slate-200">{label}</p>
<ul className="space-y-0.5 tabular-nums">
{payload.map((p) => (
<li key={String(p.dataKey)} className="flex justify-between gap-6">
<span className="text-slate-400">{p.name}</span>
<span>{typeof p.value === 'number' ? `${p.value.toFixed(2)} kW` : '—'}</span>
</li>
))}
</ul>
</div>
)
}
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 (
<div className="flex flex-col items-center pt-2">
<div className="relative h-[120px] w-[220px]">
<svg viewBox="0 0 200 110" className="h-full w-full" aria-hidden>
<path
d="M 12 100 A 88 88 0 0 1 188 100"
fill="none"
stroke="currentColor"
strokeWidth="14"
className="text-slate-800"
/>
{pct != null && (
<path
d="M 12 100 A 88 88 0 0 1 188 100"
fill="none"
stroke="currentColor"
strokeWidth="14"
strokeLinecap="round"
strokeDasharray={halfLen}
strokeDashoffset={halfLen * (1 - pct / 100)}
className={stroke}
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
/>
)}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1">
<span className="text-3xl font-bold tabular-nums text-slate-50">
{pct == null ? '—' : `${pct.toFixed(0)}`}
</span>
<span className="text-xs text-slate-500">% SoC</span>
</div>
</div>
</div>
)
function fmtSoc(p: number | null | undefined): string {
if (p == null || Number.isNaN(p)) return '—'
return `${p.toFixed(0)} %`
}
function MetricSkeleton() {
return <div className="h-[104px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
function BlockSkeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse rounded-xl border border-slate-800 bg-slate-900/40 ${className}`} />
return <div className="h-[92px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
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<Set<string>>(() => 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<ChartArea | null>(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 (
<div className="min-h-screen bg-slate-950 p-4 text-slate-100 md:p-8">
<div className="mx-auto max-w-7xl space-y-8">
<div className="min-h-screen bg-gray-950 p-4 text-slate-100 md:p-8">
<div className="mx-auto max-w-7xl space-y-6">
{fetchError ? (
<div
className="flex flex-col gap-3 rounded-xl border border-red-500/40 bg-red-950/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
role="alert"
>
<p className="text-sm font-medium text-red-200">Chyba načítání dat</p>
<p className="text-sm font-medium text-red-200">{fetchError}</p>
<button
type="button"
onClick={() => retryAll()}
onClick={() => void data.reload()}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-500"
>
Zkusit znovu
@@ -264,157 +161,99 @@ export function Dashboard() {
</div>
) : null}
<header className="border-b border-slate-800/80 pb-6">
<header className="border-b border-slate-800/80 pb-5">
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
<p className="mt-1 text-sm text-slate-400">Přehled lokality, auditu a plánu</p>
<p className="mt-1 text-sm text-slate-400">Přehled výkonů, režimů a cen</p>
</header>
{/* Horní metriky */}
{siteId != null ? (
<ModeBar
modeName={modeName}
activatedAt={modeActivatedAt}
nextReplanIn={nextReplanIn}
onReplan={handleReplan}
onModeChange={() => {}}
/>
) : null}
{notifications.length > 0 ? (
<NotificationBar
notifications={notifications}
onReplan={handleReplan}
onImportPrices={handleImportPrices}
onSwitchAuto={handleSwitchAuto}
/>
) : null}
<section>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{metricsLoading ? (
<>
<MetricSkeleton />
<MetricSkeleton />
<MetricSkeleton />
<MetricSkeleton />
<MetricSkeleton />
</>
) : site == null ? (
<p className="col-span-full text-sm text-slate-500">Žádná lokalita ve vw_site_status.</p>
<p className="col-span-full text-sm text-slate-500">Žádná aktivní lokalita ve vw_site_status.</p>
) : (
<>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-amber-400 bg-slate-900/60 p-4 pl-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Sun className="h-6 w-6 text-amber-400" aria-hidden />
<div className="rounded-xl border border-slate-800 border-l-4 border-l-amber-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">FVE</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-amber-300">{fmtKw2(lm?.pv_w)}</p>
<Sun className="mt-2 h-5 w-5 text-amber-500/80" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">FVE výroba</p>
<p className="text-xl font-semibold tabular-nums text-amber-300">
{hasLiveData ? fmtKw2(site.pv_power_w) : '—'}{' '}
<span className="text-lg" aria-hidden>
</span>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-blue-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">Spotřeba</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-blue-300">{fmtKw2(lm?.load_w)}</p>
<Activity className="mt-2 h-5 w-5 text-blue-500/80" aria-hidden />
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-red-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">Síť</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-slate-100">{fmtKw2(lm?.grid_w)}</p>
<p className="mt-0.5 text-[10px] text-slate-500">
{lm?.grid_w == null ? '' : lm.grid_w >= 0 ? 'import' : 'export'}
</p>
<Zap className="mt-1 h-5 w-5 text-red-400/80" aria-hidden />
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-emerald-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">SOC</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-emerald-300">{fmtSoc(lm?.bat_soc)}</p>
<Battery className="mt-2 h-5 w-5 text-emerald-500/80" aria-hidden />
</div>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<div className="flex items-center gap-3">
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80 ${
batW != null && !Number.isNaN(batW)
? batW >= 0
? 'text-emerald-400'
: 'text-orange-400'
: 'text-slate-400'
}`}
>
<Battery className="h-6 w-6" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Baterie</p>
<p className="text-xl font-semibold tabular-nums text-slate-100">
{batPct == null ? '—' : `${batPct.toFixed(0)}%`}
{batSignedKw == null ? '' : ` / ${batSignedKw >= 0 ? '+' : ''}${batSignedKw.toFixed(2)} kW`}
<div className="rounded-xl border border-slate-800 border-l-4 border-l-rose-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">Cena nákup</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-rose-200">
{fmtMoney3(data.buyNow)}
</p>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
<div
className={`h-full rounded-full transition-all ${
batW != null && !Number.isNaN(batW) && batW < 0 ? 'bg-orange-500' : 'bg-emerald-500'
}`}
style={{ width: `${batPct ?? 0}%` }}
/>
</div>
</div>
</div>
</div>
<div
className={`rounded-xl border border-slate-800 bg-slate-900/60 p-4 pl-3 border-l-4 ${
gridW != null && !Number.isNaN(gridW)
? gridW >= 0
? 'border-l-red-500'
: 'border-l-emerald-500'
: 'border-l-slate-600'
}`}
>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Zap
className={`h-6 w-6 ${
gridW != null && !Number.isNaN(gridW)
? gridW >= 0
? 'text-red-400'
: 'text-emerald-400'
: 'text-slate-400'
}`}
aria-hidden
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Síť</p>
<p className="text-xl font-semibold tabular-nums text-slate-100">{gridLabel}</p>
<p className="mt-0.5 text-xs text-slate-500">
{gridW != null && !Number.isNaN(gridW)
? gridW >= 0
? 'import'
: 'export'
: ''}
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-blue-500 bg-slate-900/60 p-4 pl-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Home className="h-6 w-6 text-blue-400" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Spotřeba</p>
<p className="text-xl font-semibold tabular-nums text-blue-300">
{hasLiveData ? fmtKw2(site.load_power_w) : '—'}
</p>
</div>
</div>
<p className="mt-1 text-[10px] text-slate-500">Aktuální 15min slot</p>
</div>
</>
)}
</div>
{/* Status řádek */}
{!metricsLoading && site != null ? (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="text-slate-500">Aktivní režim:</span>
<span
className={`rounded-md px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(site.active_mode)}`}
title={site.mode_description ?? undefined}
>
<div className="mt-4 space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-3">
<span className="text-slate-500">Režim:</span>
<span className="rounded-md bg-slate-800 px-2 py-1 text-xs font-semibold uppercase text-slate-200">
{site.active_mode ?? '—'}
{site.mode_name ? ` · ${site.mode_name}` : ''}
</span>
<span className="flex items-center gap-2 text-slate-400">
<span className="relative flex h-2.5 w-2.5">
<span
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOnline ? 'bg-emerald-500' : 'bg-red-500'}`}
/>
</span>
EMS:{' '}
<span className={hbOnline ? 'text-emerald-400' : 'text-red-400'}>
{hbOnline ? 'online' : 'offline'}
</span>
</span>
<span className="text-slate-500">
Poslední telemetrie:{' '}
<span className="text-slate-300">{formatTelemetryAgo(site.telemetry_at)}</span>
</span>
</div>
{hasMonitoringAlerts ? (
<div className="w-full max-w-2xl">
<div className="max-w-2xl">
<button
type="button"
onClick={() => setAlertsOpen((o) => !o)}
@@ -428,7 +267,6 @@ export function Dashboard() {
<span>
{monitoringAlerts.length}{' '}
{monitoringAlerts.length === 1 ? 'alert' : 'alertů'}
{monitoringHasError ? ' · obsahuje chyby' : ''}
</span>
{alertsOpen ? (
<ChevronUp className="h-4 w-4 shrink-0 opacity-80" aria-hidden />
@@ -443,18 +281,10 @@ export function Dashboard() {
? 'border-red-500/30 bg-red-950/25 text-red-100'
: 'border-amber-500/25 bg-amber-950/20 text-amber-50'
}`}
role="list"
>
{monitoringAlerts.map((a, i) => (
<li
key={`${a.level}-${i}-${a.message}`}
className={
a.level === 'error'
? 'text-red-200'
: 'text-amber-200'
}
>
<span className="font-semibold uppercase tracking-wide text-[10px] opacity-80">
<li key={`${a.level}-${i}`} className={a.level === 'error' ? 'text-red-200' : 'text-amber-200'}>
<span className="text-[10px] font-semibold uppercase opacity-80">
{a.level === 'error' ? 'Chyba' : 'Varování'}
</span>
<span className="ml-2">{a.message}</span>
@@ -465,164 +295,72 @@ export function Dashboard() {
</div>
) : null}
</div>
) : metricsLoading ? (
<div className="mt-4 h-5 w-full max-w-md animate-pulse rounded bg-slate-800/80" />
) : null}
</section>
{/* Graf + denní souhrn */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
{chartLoading ? (
<BlockSkeleton className="h-[300px] w-full" />
) : !hasChartData ? (
<div className="flex h-[300px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40 text-sm text-slate-500">
Zatím žádná data pro dnešní den
</div>
) : (
<div className="h-[300px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pr-4 pb-4 pt-4">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="timeLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} width={40} />
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone"
dataKey="pv_kw"
name="FVE"
stroke="#fbbf24"
fill="#fbbf24"
fillOpacity={0.25}
connectNulls
/>
<Line
type="monotone"
dataKey="load_kw"
name="Spotřeba"
stroke="#60a5fa"
strokeWidth={2}
dot={false}
connectNulls
/>
<Bar dataKey="battery_kw" name="Baterie" barSize={14}>
{chartData.map((e, i) => (
<Cell
key={`c-${i}`}
fill={
e.battery_kw == null || Number.isNaN(e.battery_kw)
? '#475569'
: e.battery_kw >= 0
? '#22c55e'
: '#f97316'
}
/>
))}
</Bar>
<Line
type="monotone"
dataKey="grid_kw"
name="Síť"
stroke="#94a3b8"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="lg:col-span-1">
{summaryLoading ? (
<div className="space-y-3">
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-40 w-full" />
</div>
) : (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Dnešní souhrn</h2>
<ul className="mt-4 space-y-3 text-sm">
<li className="flex justify-between gap-2">
<span className="text-slate-400">FVE výroba</span>
<span className="tabular-nums text-amber-200">{fmtEnergy(daily?.pv_kwh)}</span>
</li>
<li className="flex justify-between gap-2">
<span className="text-slate-400">Import ze sítě</span>
<span className="tabular-nums text-red-300">{fmtEnergy(daily?.import_kwh)}</span>
</li>
<li className="flex justify-between gap-2">
<span className="text-slate-400">Export do sítě</span>
<span className="tabular-nums text-emerald-300">{fmtEnergy(daily?.export_kwh)}</span>
</li>
<li className="flex justify-between gap-2">
<span className="text-slate-400">Náklady / příjem</span>
{(() => {
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 <span className={`tabular-nums font-medium ${cls}`}>{fmtMoney(daily?.actual_cost_czk)}</span>
})()}
</li>
</ul>
{!hasDaily ? (
<p className="mt-3 text-xs text-slate-500">Pro dnešek zatím nejsou uzavřené intervaly auditu.</p>
) : null}
<SemicircleSocGauge socPercent={site?.battery_soc_percent} />
</div>
)}
</div>
</section>
{/* Plán 4 h */}
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Nejbližší plán (4 hodiny)
</h2>
{planLoading ? (
<BlockSkeleton className="h-16 w-full" />
) : planSlots.length === 0 ? (
<p className="text-sm text-slate-500">Plán zatím není k dispozici</p>
{data.slots.length === 0 && data.ready ? (
<div className="rounded-xl border border-slate-800 bg-slate-900/40 px-4 py-8 text-center text-sm text-slate-500">
Nedostatek dat pro graf (zkontrolujte plán a telemetrii).
</div>
) : (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="flex gap-1">
{planSlots.map((slot, i) => (
<div key={`${slot.interval_start}-${i}`} className="min-w-0 flex-1 group relative">
<div
className={`h-10 w-full rounded-sm ${slotBgClass(slot, avgBuy)} opacity-90 transition group-hover:opacity-100`}
title=""
<div className="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/40">
<div className="px-2 pb-1 pt-2">
<MemoEnergyChart
slots={data.slots}
nowIndex={data.nowIndex}
hidden={hiddenSeries}
onToggle={toggleSeries}
onChartArea={onChartArea}
/>
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden w-max min-w-[140px] -translate-x-1/2 rounded-md border border-slate-600 bg-slate-900 px-2 py-1.5 text-[10px] text-slate-100 shadow-lg group-hover:block">
<p className="font-medium text-slate-200">{formatSlotLabel(slot.interval_start)}</p>
<p className="tabular-nums text-slate-400">
cena:{' '}
{slot.effective_buy_price == null
? '—'
: `${slot.effective_buy_price.toFixed(3)} Kč/kWh`}
</p>
<p className="tabular-nums text-slate-400">
baterie: {fmtKw2(slot.battery_setpoint_w ?? undefined)}
</p>
<p className="tabular-nums text-slate-400">síť: {fmtKw2(slot.grid_setpoint_w ?? undefined)}</p>
</div>
<MemoRegimeBar
slots={data.slots}
nowIndex={data.nowIndex}
chartPaddingLeft={CHART_LAYOUT_PADDING.left}
chartPaddingRight={CHART_LAYOUT_PADDING.right}
chartArea={chartArea}
/>
<div className="border-t border-slate-800 px-2 py-2">
<MemoSocTuvChart slots={data.slots} nowIndex={data.nowIndex} />
</div>
))}
</div>
<p className="mt-2 text-center text-[10px] text-slate-600">16× 15 min · najet myší pro detail</p>
</div>
)}
</section>
{data.slots.length > 0 && data.ready ? (
<section>
<StatePanel slots={data.slots} nowIndex={data.nowIndex} />
</section>
) : null}
{siteId != null ? (
<section className="rounded-xl border border-slate-800 bg-slate-900/40">
<button
type="button"
onClick={() => setInverterDiagOpen((o) => !o)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-medium text-slate-200 transition hover:bg-slate-800/30"
aria-expanded={inverterDiagOpen}
>
<span>Diagnostika střídače (Deye · Modbus)</span>
{inverterDiagOpen ? (
<ChevronUp className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
)}
</button>
{inverterDiagOpen ? (
<div className="border-t border-slate-800 p-4">
<ControlPanel siteId={siteId} />
</div>
) : null}
</section>
) : null}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<ForecastPanel days={data.forecastWeek} />
<NegPricePanel items={data.negPrices} />
</section>
</div>
</div>
)

View File

@@ -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<LogRecord[]>([])
const bottomRef = useRef<HTMLDivElement>(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 (
<div className="min-h-screen bg-slate-950 p-4 text-slate-100 md:p-8">
<div className="mx-auto max-w-5xl">
<h1 className="text-xl font-bold text-white">Logy EMS</h1>
<p className="mt-1 text-sm text-slate-500">Stream z backendu (WebSocket)</p>
<pre className="mt-6 max-h-[calc(100vh-8rem)] overflow-auto rounded-xl border border-slate-800 bg-slate-900/80 p-4 font-mono text-xs leading-relaxed">
{lines.map((r, i) => (
<div
key={`${i}-${r.ts}-${r.msg}`}
className={
r.level === 'ERROR'
? 'text-red-300'
: r.level === 'WARNING'
? 'text-amber-200'
: 'text-slate-300'
}
>
<span className="text-slate-600">{r.ts ?? '—'} </span>
<span className="text-slate-500">[{r.level ?? '?'}] </span>
<span className="text-slate-500">{r.logger ?? ''}: </span>
{r.msg ?? ''}
</div>
))}
<div ref={bottomRef} />
</pre>
</div>
</div>
)
}

View File

@@ -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<string, PlanningIntervalDto[]> {
return slots.reduce(
(acc, slot) => {
const day = pragueDayKey(slot.interval_start)
if (!acc[day]) acc[day] = []
acc[day].push(slot)
return acc
},
{} as Record<string, PlanningIntervalDto[]>,
)
}
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 (
<td className={`pr-2 font-mono tabular-nums ${color}`}>{formatPlanPowerW(w)}</td>
)
}
function VynosKcCell({ v }: { v: number | null | undefined }) {
if (v == null || Number.isNaN(Number(v))) {
return <td className="pr-2 font-mono tabular-nums text-slate-500"></td>
}
const n = Number(v)
const color = n < 0 ? 'text-emerald-400' : n > 0 ? 'text-red-400' : 'text-slate-500'
return (
<td className={`pr-2 font-mono tabular-nums ${color}`}>{n.toFixed(4)}</td>
)
}
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 bit45=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
</button>
<label className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-xs text-slate-300">
Den OTE
<select
value={importDate}
onChange={(e) => onImportDateChange(e.target.value === 'today' ? 'today' : 'tomorrow')}
disabled={dis}
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-xs text-slate-100"
>
<option value="today">dnes</option>
<option value="tomorrow">zítra</option>
</select>
</label>
<button
type="button"
onClick={onForecast}
@@ -178,15 +372,27 @@ function PlanPrepActions({
)
}
function PlanTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: ChartRow }> }) {
function PlanTooltip({
active,
payload,
nowMs,
}: {
active?: boolean
payload?: Array<{ payload: ChartRow }>
nowMs: number
}) {
if (!active || !payload?.length) return null
const p = payload[0].payload
const i = p.raw
const buy = i.effective_buy_price
const sell = i.effective_sell_price
const pred = isPredictedPriceSlot(i, nowMs)
return (
<div className="rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-xs text-slate-200 shadow-xl">
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
{pred && (
<div className="mb-1 text-[10px] uppercase tracking-wide text-slate-500">Cena: odhad (predikce)</div>
)}
<div className="space-y-0.5 font-mono tabular-nums">
<div>
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
@@ -203,6 +409,59 @@ function PlanTooltip({ active, payload }: { active?: boolean; payload?: Array<{
)
}
function CenaCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) {
const pred = isPredictedPriceSlot(i, nowMs)
return (
<td className={`max-w-[200px] pr-2 font-mono text-xs tabular-nums ${pred ? 'text-slate-500' : 'text-slate-300'}`}>
<span className="inline-flex flex-wrap items-center gap-x-1.5 align-middle">
{pred && (
<span
className="shrink-0 rounded bg-slate-700/70 px-1 py-0.5 text-[10px] font-sans font-semibold uppercase tracking-wide text-slate-400"
title="Predikovaná cena (mimo přesné OTE)"
>
odhad
</span>
)}
<span>
{i.effective_buy_price != null ? i.effective_buy_price.toFixed(3) : '—'}
<span className="text-slate-600"> / </span>
{i.effective_sell_price != null ? i.effective_sell_price.toFixed(3) : '—'}
</span>
</span>
</td>
)
}
function HorizonToggle({
value,
onChange,
disabled,
}: {
value: HorizonHours
onChange: (h: HorizonHours) => void
disabled?: boolean
}) {
const opts: HorizonHours[] = [24, 48, 96]
return (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-500">Horizont:</span>
<div className="flex gap-1">
{opts.map((h) => (
<button
key={h}
type="button"
disabled={disabled}
onClick={() => onChange(h)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition disabled:opacity-50 ${horizonToggleClass(value === h)}`}
>
{h}h
</button>
))}
</div>
</div>
)
}
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<string | null>(null)
const [replanning, setReplanning] = useState(false)
const [prepAction, setPrepAction] = useState<null | 'import' | 'forecast' | 'init'>(null)
const [importDate, setImportDate] = useState<'today' | 'tomorrow'>('tomorrow')
const [selectedStart, setSelectedStart] = useState<string | null>(null)
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(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() {
<header className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
<p className="text-sm text-slate-400">
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.
</p>
</header>
@@ -410,6 +689,8 @@ export default function Planning() {
<PlanPrepActions
prepAction={prepAction}
replanning={replanning}
importDate={importDate}
onImportDateChange={setImportDate}
onImport={() => 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` : '—'}
</span>
</div>
{summary?.pv_scarcity_factor != null && (
<div className="text-sm">
<span className="text-slate-500">PV scarcity factor: </span>
<span className="font-mono text-slate-200">
{summary.pv_scarcity_factor.toFixed(3)}
</span>
<span className="ml-2 text-xs text-slate-500">
(nižší = méně očekávaného slunce, ekonomika víc toleruje precharge ze sítě)
</span>
</div>
)}
{summary && (
<div className="border-t border-slate-800 pt-3 text-sm">
<p className="mb-2 text-slate-500">Summary</p>
@@ -501,6 +793,8 @@ export default function Planning() {
<PlanPrepActions
prepAction={prepAction}
replanning={replanning}
importDate={importDate}
onImportDateChange={setImportDate}
onImport={() => void handleImportPrices()}
onForecast={() => void handleRunForecast()}
onInit={() => void handleInitializePlan()}
@@ -523,9 +817,12 @@ export default function Planning() {
{/* Sekce 2 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
<HorizonToggle value={chartHorizonH} onChange={setChartHorizonH} disabled={futureSlots.length === 0} />
{!chartRows.length ? (
<p className="text-sm text-slate-500">Žádná data pro graf (24 h od teď, max. 96 slotů).</p>
<p className="text-sm text-slate-500">
Žádná data pro graf (budoucí sloty aktivního plánu, horizont {chartHorizonH} h).
</p>
) : (
<div className="h-[350px] w-full">
<ResponsiveContainer width="100%" height="100%">
@@ -534,11 +831,11 @@ export default function Planning() {
<XAxis
dataKey="label"
ticks={xTicks}
tick={{ fill: '#94a3b8', fontSize: 10 }}
tick={{ fill: '#94a3b8', fontSize: 9 }}
interval={0}
angle={-35}
textAnchor="end"
height={48}
height={52}
/>
<YAxis
yAxisId="power"
@@ -568,7 +865,7 @@ export default function Planning() {
offset: 10,
}}
/>
<Tooltip content={<PlanTooltip />} />
<Tooltip content={<PlanTooltip nowMs={nowMs} />} />
<Area
yAxisId="power"
type="monotone"
@@ -616,25 +913,58 @@ export default function Planning() {
{/* Sekce 3 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
<div className="max-h-[400px] overflow-y-auto overflow-x-auto rounded-lg border border-slate-800/80">
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
<HorizonToggle value={tableHorizonH} onChange={setTableHorizonH} disabled={futureSlots.length === 0} />
<div className="max-h-[min(70vh,720px)] overflow-y-auto overflow-x-auto rounded-lg border border-slate-800/80">
<table className="w-full border-collapse text-left text-xs">
<thead className="sticky top-0 z-10 bg-slate-900 shadow-[0_1px_0_0_rgb(30_41_59)]">
<tr className="text-slate-500">
<th className="whitespace-nowrap py-2 pl-2 pr-2 font-medium">Čas</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena kup</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena prod</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">
<span className="block">Cena</span>
<span className="block text-[10px] font-normal normal-case text-slate-600">/kWh · kup / prod</span>
</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Bat. W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Deye setpoint</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">FVE W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Síť W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV1 W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV2 W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium"></th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Náklady </th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Výnos </th>
</tr>
</thead>
<tbody>
{intervals24h.map((i) => {
{planTableRows.map((row) => {
if (row.kind === 'summary') {
return (
<tr
key={`sum-${row.dayKey}`}
className="border-b border-slate-700/90 bg-slate-800/70 text-slate-200"
>
<td colSpan={11} className="px-2 py-2 text-xs font-medium">
<span className="text-slate-100">{row.dateLabel}</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">FVE celkem</span>{' '}
<span className="font-mono tabular-nums text-slate-200">
{row.fveKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh
</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">Export celkem</span>{' '}
<span className="font-mono tabular-nums text-slate-200">
{row.exportKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh
</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">Prům. cena nákup</span>{' '}
<span className="font-mono tabular-nums text-slate-200">
{row.avgBuy != null ? `${row.avgBuy.toFixed(3)} Kč/kWh` : '—'}
</span>
</td>
</tr>
)
}
const i = row.i
const sel = selectedStart === i.interval_start
return (
<tr
@@ -653,33 +983,32 @@ export default function Planning() {
<td className="whitespace-nowrap py-1.5 pl-2 pr-2 font-mono text-slate-300">
{formatLocalTime(i.interval_start)}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">
{i.effective_buy_price?.toFixed(3) ?? '—'}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">
{i.effective_sell_price?.toFixed(3) ?? '—'}
</td>
<CenaCell i={i} nowMs={nowMs} />
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.battery_setpoint_w ?? '—'}</td>
<td className="max-w-[200px] whitespace-normal break-words pr-2 text-slate-300">
{deyeSetpointLabel(i)}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">
{i.battery_soc_target_pct != null
? `${i.battery_soc_target_pct.toFixed(1)}`
: '—'}
</td>
<FveWCell i={i} nowMs={nowMs} />
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'on' : 'off'}</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">
{i.expected_cost_czk?.toFixed(4) ?? '—'}
</td>
<VynosKcCell v={i.expected_cost_czk} />
</tr>
)
})}
</tbody>
</table>
</div>
{!intervals24h.length && !loading && (
<p className="mt-2 text-sm text-slate-500">Žádné řádky v 24h okně.</p>
{!visibleSlots.length && !loading && (
<p className="mt-2 text-sm text-slate-500">
Žádné budoucí sloty v horizontu {tableHorizonH} h (aktivní plán může být prázdný nebo starý).
</p>
)}
</section>
</div>

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More