second version
This commit is contained in:
@@ -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
6
.idea/.gitignore
generated
vendored
Normal 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
8
.idea/ems-cursor.iml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
55
CLAUDE.md
55
CLAUDE.md
@@ -50,7 +50,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
|
||||
4. **Loxone = exekutor + autonomní fallback, ne optimalizátor.** Logika a plán v EMS. Watchdog v Loxone nesmí záviset na čtení DB (`site_heartbeat` je jen pro EMS UI/diagnostiku).
|
||||
|
||||
5. **FVE pole B (`controllable = false`, typicky ongrid GEN) – žádný curtailment.** Curtailment jen pole A (Deye). Solver smí omezovat jen `pv_a`; pole B má zelený bonus (`site_market_config.green_bonus_*`, audit `pv_b_production_wh` / `green_bonus_czk`).
|
||||
5. **FVE pole B (`controllable = false`, typicky ongrid GEN) – žádný curtailment.** Curtailment jen pole A (Deye). Solver smí omezovat jen `pv_a`; pole B může mít zelený bonus na `asset_pv_array` (`green_bonus_*`), audit `pv_b_production_wh` / `green_bonus_czk`.
|
||||
|
||||
6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint).
|
||||
|
||||
@@ -58,9 +58,32 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
|
||||
8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků – dle docs).
|
||||
|
||||
9. **Deye Modbus: čtení i zápis** (setpointy). RS485→Waveshare→TCP, knihovna `pymodbus`.
|
||||
9. **Zelený bonus je na `asset_pv_array`** (sloupce `green_bonus_*`), **nikdy** v `site_market_config`. Výpočet přes `fn_green_bonus_revenue()`. Bonus se nepočítá v solveru – pouze v audit_filler (`fn_fill_audit_interval`).
|
||||
|
||||
10. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` – držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`.
|
||||
10. **Deye Modbus: čtení i zápis** (setpointy). RS485→Waveshare→TCP, knihovna `pymodbus`.
|
||||
|
||||
11. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` – držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`.
|
||||
|
||||
### Provozní režimy (operating_mode)
|
||||
|
||||
- Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**.
|
||||
- Režim se načítá v `planning_engine._load_site_context()`; **dodatečné LP constraints** podle režimu jsou v **`solve_dispatch()`** (žádný export / limit importu / zákaz nabíjení nebo vybíjení baterie podle módu).
|
||||
- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `control_exporter.get_deye_mode` a zapisují v `write_inverter_setpoints`.
|
||||
- **`lock_battery=True`** u `ControlSetpoints` (PRESERVE): registry **108/109 = 0** – Deye baterii nepoužívá. Výjimka oproti obecnému pravidlu max A ve PASSIVE/SELL.
|
||||
|
||||
12. **`forecast_pv_run` a `forecast_pv_interval` se NESMÍ mazat** – historické běhy zůstávají v DB pro tracking přesnosti (`forecast_accuracy`, `fn_fill_forecast_accuracy`).
|
||||
|
||||
13. **Endpoint `GET …/forecast/pv`** vrací `DISTINCT ON (interval_start, pv_array_id)` seřazené podle nejnovějšího `forecast_pv_run.created_at`, aby UI nemělo duplikáty slotů; plná historie běhů zůstává v tabulkách.
|
||||
|
||||
14. **Příchod a odjezd EV** detekuje `telemetry_collector` z telemetrie nabíječky: přechod `available` → `preparing` / `charging` (resp. jakýkoli stav ≠ `available`) znamená příjezd; přechod na `available` uzavře `ev_session`. Tabulka `ev_arrival_stats` se při příjezdu doplňuje přes `fn_update_ev_arrival_stats` a **nemá se mazat** (dlouhodobá historická statistika).
|
||||
|
||||
15. **Bazální spotřeba** = `load_power_w` minus řízené zátěže (součet EV z `telemetry_ev_charger`, TČ z `telemetry_heat_pump`). Tabulka `consumption_baseline_stats` se plní denně (APScheduler 00:30) přes `fn_update_baseline_stats`. **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`.
|
||||
|
||||
16. **Rozšířený horizont plánování (96h):** denní plán pokrývá **96h** od začátku aktuálního 15min slotu. Sloty v prvních **36h** používají přesné efektivní ceny z `vw_site_effective_price` (OTE). Sloty **36–96h** doplňuje **predikovaná cena** z `market_price_stats` přes `fn_get_predicted_price` (prodejní strana hrubý faktor 0,85 vs. nákupní predikce). V účelové funkci LP se uplatní **váhy nejistoty** podle vzdálenosti od začátku okna: **1,0** (0–36h), **0,7** (36–72h), **0,4** (72–96h). Statistiky cen plní `fn_update_market_price_stats` (job 14:45), TUV delta `fn_update_tuv_usage_stats` (job 00:45). Detail: `docs/04-modules/planning-extended-horizon.md`.
|
||||
|
||||
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → přepnutí na **SELF_SUSTAIN** (`fn_set_mode`, `system:mismatch`) + **Discord** alert, pokud je `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`.
|
||||
|
||||
18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`, 3–6 na **23:59**); **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas:** **62–64** při každém `control_export` (Europe/Prague). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,7 +93,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
|---------|--------|
|
||||
| `site` | Lokalita (časová zóna, GPS, aktivita). |
|
||||
| `site_endpoint` | Endpointy: Modbus, Loxone HTTP, atd. |
|
||||
| `site_market_config` | Marže, režimy cenění, zelený bonus; časová platnost. |
|
||||
| `site_market_config` | Marže, režimy cenění; časová platnost (zelený bonus není zde – viz `asset_pv_array`). |
|
||||
| `site_grid_connection` | Limity import/export, no_export, rezervovaný výkon. |
|
||||
| `site_override` | Manuální přepisy nad plánem (JSON + platnost). |
|
||||
| `site_operating_mode` | Aktuální provozní režim na site (1 řádek/site). |
|
||||
@@ -79,7 +102,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
| `operating_mode_def` | Číselník režimů (baterie/síť/EV/TČ, hodnota pro Loxone). |
|
||||
| `asset_inverter` | Střídač (výkony, endpoint, zda řiditelný). |
|
||||
| `asset_battery` | Baterie vázaná na střídač (SoC limity, účinnosti, degradace). |
|
||||
| `asset_pv_array` | FVE pole (Wp, orientace, curtailable vs ne). |
|
||||
| `asset_pv_array` | FVE pole (Wp, orientace, curtailable vs ne; volitelně `green_bonus_*` pro dotované pole). |
|
||||
| `asset_ev_charger` | Nabíječka EV (výkony, fáze, endpoint). |
|
||||
| `asset_heat_pump` | TČ (výkon, COP ref, limity běhu, TUV parametry). |
|
||||
| `asset_vehicle` | Vozidlo (kapacita, max AC výkon, default target SoC/deadline). |
|
||||
@@ -89,15 +112,22 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
| `telemetry_heat_pump` | 1min telemetrie TČ (Timescale). |
|
||||
| `forecast_pv_run` | Metadata běhu predikce FVE. |
|
||||
| `forecast_pv_interval` | Predikovaný výkon FVE po 15min (Timescale). |
|
||||
| `forecast_accuracy` | Řádky přesnosti predikce vs telemetrie po 15min (per run); doplňuje `fn_fill_forecast_accuracy`. |
|
||||
| `forecast_weather_interval` | Počasí 15min pro site (Timescale). |
|
||||
| `forecast_correction_log` | Log korekcí forecastu vs skutečnost při rolling replanu. |
|
||||
| `planning_run` | Jeden běh plánovače (daily/rolling/manual, stav, parametry solveru). |
|
||||
| `planning_interval` | Výstup solveru po 15min (baterie, síť, EV, TČ, curtailment A). |
|
||||
| `audit_interval` | Skutečnost vs plán po 15min (náklady, odchylky, bonus pole B). |
|
||||
| `consumption_baseline_interval` | Bazální spotřeba actual/forecast 15min (Timescale). |
|
||||
| `consumption_baseline_stats` | Historické průměry bazálu per DOW+hodina (EMA z telemetrie); vstup solveru. |
|
||||
| `market_price_stats` | Historické průměry raw OTE ceny per DOW+hodina; predikce cen za horizont OTE (`fn_get_predicted_price`). |
|
||||
| `tuv_usage_stats` | Průměrná změna teploty TUV zásobníku per DOW+hodina (telemetrie TČ); vstup TUV look-ahead ve solveru. |
|
||||
| `ev_session` | Nabíjecí session na WB (deadline, energie, náklady). |
|
||||
| `ev_arrival_stats` | Agregované počty příjezdů EV podle dne v týdnu a hodiny (Europe/Prague); plní se z detekce příjezdu v telemetrii. |
|
||||
| `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). |
|
||||
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
||||
|
||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_audit_summary`, `vw_operating_mode`; `fn_effective_price`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_set_mode`.
|
||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`.
|
||||
|
||||
---
|
||||
|
||||
@@ -110,10 +140,15 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
||||
| `telemetry_collector` | každých **60 s** | Smyčka polling Modbus (Deye, EV×2, TČ) – viz `docs/04-modules/telemetry.md` |
|
||||
| `price_importer` | **14:00** denně + **00:05** kontrola | `docs/04-modules/market-prices.md` (časy CET v dokumentaci) |
|
||||
| `forecast_service` | **14:30** + **06:00** denně | `docs/04-modules/forecast.md` |
|
||||
| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` (horizont 36 h) |
|
||||
| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` (horizont **96 h**, váhy slotů 1,0 / 0,7 / 0,4) |
|
||||
| `run_rolling_replan` | **každých 15 min** (`*/15`) | `planning_engine.py` – přepočet od aktuálního slotu |
|
||||
| `control_exporter` | **každých 15 min** (slot boundary) | `docs/04-modules/control.md` |
|
||||
| `verify_modbus` | **každé 2 min** | Ověření `modbus_command` ve stavu `written` (posledních 10 min); viz `docs/04-modules/modbus-command-journal.md` |
|
||||
| `audit_filler` / `fn_fill_audit_interval` | **každých 15 min** | `docs/02-architecture.md`, DB `fn_fill_audit_interval` |
|
||||
| `forecast_accuracy` / `fn_fill_forecast_accuracy` | **každých 15 min** (min. 2,17,32,47) | Po audit filleru; doplní actual z telemetrie do `forecast_accuracy` |
|
||||
| `fn_update_baseline_stats` | **00:30** denně | Aktualizace `consumption_baseline_stats` z telemetrie (30d lookback) |
|
||||
| `fn_update_market_price_stats` | **14:45** denně | Po importu OTE a forecastu; `market_price_stats` (90d lookback) |
|
||||
| `fn_update_tuv_usage_stats` | **00:45** denně | Po baseline jobu; `tuv_usage_stats` (30d lookback) |
|
||||
|
||||
---
|
||||
|
||||
@@ -128,8 +163,10 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
||||
| Bazální spotřeba | `docs/04-modules/consumption.md` |
|
||||
| TČ, COP, TUV | `docs/04-modules/heat-pump.md`, `db/routines/R__fn_cop_estimate.sql` |
|
||||
| Modbus, telemetrie, agregace | `docs/04-modules/telemetry.md` |
|
||||
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` |
|
||||
| Deye registry (FC 0x10, 108/109/141/142/178/143) | `docs/04-modules/modbus-registers.md` |
|
||||
| Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` |
|
||||
| LP solver, rolling replan, korekce FVE | `docs/04-modules/planning.md`, `backend/services/planning_engine.py` |
|
||||
| LP solver, rolling replan, korekce FVE, horizont 96h | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
|
||||
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `R__fn_set_mode.sql` |
|
||||
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
|
||||
| Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` |
|
||||
@@ -145,3 +182,5 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
||||
- Python: `snake_case`, type hints, Pydantic pro API modely.
|
||||
- SQL: `snake_case`, explicitní FK; Flyway pořadí `V###__` / repeatable `R__`.
|
||||
- Výkon **W**, energie **Wh**, ceny **Kč/kWh**; čas v DB **`TIMESTAMPTZ` (UTC)**.
|
||||
- NIKDY neupravuj existující V__ migrační soubory po jejich aplikaci na DB.
|
||||
- Pokud je potřeba opravit chybu ve verzované migraci, vytvoř novou V{N+1} migraci.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
249
backend/app/notifications_logic.py
Normal file
249
backend/app/notifications_logic.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
25
backend/app/ws_log_handler.py
Normal file
25
backend/app/ws_log_handler.py
Normal 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
38
backend/app/ws_manager.py
Normal 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()
|
||||
@@ -6,15 +6,51 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from services.telemetry_collector import ModbusDevice
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md)
|
||||
BATT_VOLTAGE_V = 51.2
|
||||
|
||||
# Reg 178 – pevné hodnoty (bit4–5); bez read-modify-write (kolize s Loxone / transaction ID)
|
||||
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
|
||||
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
|
||||
|
||||
DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
108: "max_charge_a (max nabíjecí proud baterie)",
|
||||
109: "max_discharge_a (max vybíjecí proud baterie)",
|
||||
141: "energy_mode (0, EMS nemění)",
|
||||
142: "limit_control (0=selling first, 1=zero export built-in CT)",
|
||||
143: "export_limit_w (max export do sítě)",
|
||||
178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)",
|
||||
148: "time_point_1_time",
|
||||
149: "time_point_2_time",
|
||||
154: "time_point_1_power_w",
|
||||
155: "time_point_2_power_w",
|
||||
166: "time_point_1_soc_min_pct",
|
||||
167: "time_point_2_soc_min_pct",
|
||||
172: "time_point_1_grid_charge",
|
||||
173: "time_point_2_grid_charge",
|
||||
62: "system_time_year_month",
|
||||
63: "system_time_day_hour",
|
||||
64: "system_time_min_sec",
|
||||
}
|
||||
for _tp_i in range(6):
|
||||
_n = _tp_i + 1
|
||||
DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time")
|
||||
DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w")
|
||||
DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct")
|
||||
DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge")
|
||||
|
||||
|
||||
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
|
||||
if not power_w or power_w <= 0:
|
||||
@@ -22,6 +58,50 @@ def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> i
|
||||
return min(32, max(0, int(power_w / (phases * voltage))))
|
||||
|
||||
|
||||
def battery_watts_to_amps(power_w: int, max_amps: int) -> int:
|
||||
"""Proud z |výkonu| baterie; max_amps výhradně z DB (_load_inverter_config)."""
|
||||
return min(max(0, max_amps), max(0, round(abs(power_w) / BATT_VOLTAGE_V)))
|
||||
|
||||
|
||||
def current_slot_hhmm() -> int:
|
||||
"""Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM (např. 1415)."""
|
||||
now = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
slot_min = (now.minute // 15) * 15
|
||||
return now.hour * 100 + slot_min
|
||||
|
||||
|
||||
def next_slot_hhmm() -> int:
|
||||
"""Začátek příštího 15min slotu v Europe/Prague, formát HHMM (např. 1430)."""
|
||||
now = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
minutes = now.minute
|
||||
slot_minutes = ((minutes // 15) + 1) * 15
|
||||
if slot_minutes >= 60:
|
||||
next_hour = (now.hour + 1) % 24
|
||||
next_min = 0
|
||||
else:
|
||||
next_hour = now.hour
|
||||
next_min = slot_minutes
|
||||
return next_hour * 100 + next_min
|
||||
|
||||
|
||||
@dataclass
|
||||
class InverterConfig:
|
||||
id: int
|
||||
code: str
|
||||
host: str
|
||||
port: int
|
||||
unit_id: int
|
||||
max_export_power_w: int | None
|
||||
max_import_power_w: int | None
|
||||
no_export: bool
|
||||
max_battery_charge_w: int | None
|
||||
max_battery_discharge_w: int | None
|
||||
reserve_soc_percent: int | None
|
||||
usable_capacity_wh: int | None
|
||||
max_charge_a: int
|
||||
max_discharge_a: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlSetpoints:
|
||||
battery_w: int | None
|
||||
@@ -32,6 +112,9 @@ class ControlSetpoints:
|
||||
grid_setpoint_w: int
|
||||
ev1_power_w: int
|
||||
ev2_power_w: int
|
||||
target_soc_pct: int | None = None
|
||||
#: True = reg 108/109 na 0 (PRESERVE – Deye baterii nepoužívá)
|
||||
lock_battery: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,8 +127,253 @@ class OperatingModeInfo:
|
||||
loxone_mode_value: int
|
||||
|
||||
|
||||
def _clamp_u16(value: int) -> int:
|
||||
return max(0, min(65535, int(value)))
|
||||
async def create_modbus_commands(
|
||||
site_id: int,
|
||||
planning_run_id: int | None,
|
||||
asset_type: str,
|
||||
asset_id: int,
|
||||
asset_code: str,
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
registers: list[tuple[int, str, int]],
|
||||
db: asyncpg.Connection,
|
||||
deye_physical_mode: str | None = None,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Vytvoří záznamy v modbus_command pro sadu zápisů.
|
||||
Vrátí list command IDs.
|
||||
Pro Deye se jméno registru bere z DEYE_REGISTER_NAMES (prostřední položka tuplu se ignoruje).
|
||||
"""
|
||||
ids: list[int] = []
|
||||
for reg, _ignored_name, val in registers:
|
||||
register_name = DEYE_REGISTER_NAMES.get(reg, f"reg_{reg}")
|
||||
cmd_id = await db.fetchval(
|
||||
"""
|
||||
INSERT INTO ems.modbus_command
|
||||
(site_id, asset_type, asset_id, asset_code,
|
||||
device_host, device_port, device_unit_id,
|
||||
register, register_name, value_to_write,
|
||||
planning_run_id, status, deye_physical_mode)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12)
|
||||
RETURNING id
|
||||
""",
|
||||
site_id,
|
||||
asset_type,
|
||||
asset_id,
|
||||
asset_code,
|
||||
host,
|
||||
port,
|
||||
unit_id,
|
||||
reg,
|
||||
register_name,
|
||||
val,
|
||||
planning_run_id,
|
||||
deye_physical_mode,
|
||||
)
|
||||
if cmd_id is not None:
|
||||
ids.append(int(cmd_id))
|
||||
return ids
|
||||
|
||||
|
||||
async def execute_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
) -> bool:
|
||||
"""
|
||||
Zapíše příkazy z modbus_command do zařízení.
|
||||
Aktualizuje status na 'written' nebo 'failed'.
|
||||
Vrátí True pokud všechny příkazy uspěly.
|
||||
"""
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 0.5
|
||||
|
||||
all_ok = True
|
||||
for cmd_id in command_ids:
|
||||
cmd = await db.fetchrow(
|
||||
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
if cmd is None:
|
||||
continue
|
||||
client = await get_modbus_client(
|
||||
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
|
||||
)
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
await client.write_registers(
|
||||
int(cmd["register"]), [int(cmd["value_to_write"])]
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='written', value_written=$1, written_at=now(),
|
||||
attempt_count=attempt_count+1, error_msg=NULL
|
||||
WHERE id=$2
|
||||
""",
|
||||
int(cmd["value_to_write"]),
|
||||
cmd_id,
|
||||
)
|
||||
logger.info(
|
||||
"[cmd %s] %s 0x%04X=%s OK (attempt %s)",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
int(cmd["value_to_write"]),
|
||||
attempt + 1,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
logger.warning(
|
||||
"[cmd %s] attempt %s failed: %s, retrying...",
|
||||
cmd_id,
|
||||
attempt + 1,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
client._client = None # force reconnect
|
||||
else:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='failed', error_msg=$1,
|
||||
attempt_count=attempt_count+1
|
||||
WHERE id=$2
|
||||
""",
|
||||
str(e),
|
||||
cmd_id,
|
||||
)
|
||||
logger.error(
|
||||
"[cmd %s] all %s attempts failed: %s",
|
||||
cmd_id,
|
||||
MAX_RETRIES,
|
||||
e,
|
||||
)
|
||||
all_ok = False
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None:
|
||||
"""Přepne lokalitu na SELF_SUSTAIN a zaloguje důvod."""
|
||||
await db.execute(
|
||||
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
|
||||
site_id,
|
||||
"SELF_SUSTAIN",
|
||||
"system:mismatch",
|
||||
None,
|
||||
reason,
|
||||
)
|
||||
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
|
||||
|
||||
|
||||
async def verify_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
site_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Přečte registry zpět a porovná s value_to_write.
|
||||
Při mismatch: retry → SELF_SUSTAIN + Discord.
|
||||
"""
|
||||
from services.notification_service import (
|
||||
notify_modbus_mismatch,
|
||||
notify_self_sustain_activated,
|
||||
)
|
||||
|
||||
all_ok = True
|
||||
for cmd_id in command_ids:
|
||||
cmd = await db.fetchrow(
|
||||
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
if cmd is None or cmd["status"] != "written":
|
||||
continue
|
||||
|
||||
try:
|
||||
client = await get_modbus_client(
|
||||
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
|
||||
)
|
||||
actual = await client.read_register(int(cmd["register"]))
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET value_verified=$1, verified_at=now(),
|
||||
status=CASE WHEN $1=$2 THEN 'verified' ELSE 'mismatch' END
|
||||
WHERE id=$3
|
||||
""",
|
||||
actual,
|
||||
int(cmd["value_to_write"]),
|
||||
cmd_id,
|
||||
)
|
||||
|
||||
if actual != int(cmd["value_to_write"]):
|
||||
logger.error(
|
||||
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
cmd["value_to_write"],
|
||||
actual,
|
||||
)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||
await notify_modbus_mismatch(
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
cmd["register_name"] or "",
|
||||
int(cmd["value_to_write"]),
|
||||
actual,
|
||||
attempts,
|
||||
)
|
||||
|
||||
if attempts < 3:
|
||||
await db.execute(
|
||||
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
|
||||
cmd_id,
|
||||
)
|
||||
await execute_modbus_commands([cmd_id], db)
|
||||
await verify_modbus_commands([cmd_id], db, site_id)
|
||||
else:
|
||||
logger.critical(
|
||||
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
|
||||
cmd_id,
|
||||
)
|
||||
site = await db.fetchrow(
|
||||
"SELECT code FROM ems.site WHERE id=$1", site_id
|
||||
)
|
||||
await _switch_to_self_sustain(
|
||||
site_id,
|
||||
db,
|
||||
reason=(
|
||||
f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} "
|
||||
f"reg 0x{cmd['register']:04X}"
|
||||
),
|
||||
)
|
||||
if site:
|
||||
await notify_self_sustain_activated(
|
||||
site["code"],
|
||||
(
|
||||
f"Modbus mismatch: {cmd['asset_code']} "
|
||||
f"0x{cmd['register']:04X} expected={cmd['value_to_write']} "
|
||||
f"actual={actual}"
|
||||
),
|
||||
)
|
||||
all_ok = False
|
||||
else:
|
||||
logger.info(
|
||||
"[cmd %s] verified OK: %s 0x%04X=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
actual,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[cmd %s] verify read failed: %s", cmd_id, e)
|
||||
all_ok = False
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None:
|
||||
@@ -80,21 +408,155 @@ async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> Operati
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) -> asyncpg.Record | None:
|
||||
"""Řádek plánu pro následující 15min slot (export ~1 min před hranicí, např. 14:29 → 14:30–14:45)."""
|
||||
return await db.fetchrow(
|
||||
async def _get_current_soc(site_id: int, db: asyncpg.Connection) -> int:
|
||||
soc = await db.fetchval(
|
||||
"""
|
||||
SELECT battery_soc_percent
|
||||
FROM ems.telemetry_inverter
|
||||
WHERE site_id = $1 AND battery_soc_percent IS NOT NULL
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
return int(soc) if soc is not None else 50
|
||||
|
||||
|
||||
async def _load_inverter_config(
|
||||
site_id: int, db: asyncpg.Connection
|
||||
) -> InverterConfig | None:
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
ai.id, ai.code,
|
||||
se.host, se.port, se.unit_id,
|
||||
sgc.max_export_power_w,
|
||||
sgc.max_import_power_w,
|
||||
sgc.no_export,
|
||||
ai.max_battery_charge_w,
|
||||
ai.max_battery_discharge_w,
|
||||
ab.reserve_soc_percent,
|
||||
ab.usable_capacity_wh,
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
|
||||
ai.max_battery_charge_w
|
||||
) / 51.2 AS max_charge_a,
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w),
|
||||
ai.max_battery_discharge_w
|
||||
) / 51.2 AS max_discharge_a
|
||||
FROM ems.asset_inverter ai
|
||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||
JOIN ems.asset_battery ab ON ab.inverter_id = ai.id
|
||||
LEFT JOIN ems.site_grid_connection sgc ON sgc.site_id = ai.site_id
|
||||
WHERE ai.site_id = $1
|
||||
AND ai.active = true
|
||||
AND ai.controllable = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
ORDER BY ai.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
mc = row["max_charge_a"]
|
||||
md = row["max_discharge_a"]
|
||||
max_charge_a = int(mc) if mc is not None else 0
|
||||
max_discharge_a = int(md) if md is not None else 0
|
||||
port = int(row["port"] or 502)
|
||||
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||
return InverterConfig(
|
||||
id=int(row["id"]),
|
||||
code=row["code"],
|
||||
host=row["host"],
|
||||
port=port,
|
||||
unit_id=uid,
|
||||
max_export_power_w=int(row["max_export_power_w"])
|
||||
if row["max_export_power_w"] is not None
|
||||
else None,
|
||||
max_import_power_w=int(row["max_import_power_w"])
|
||||
if row["max_import_power_w"] is not None
|
||||
else None,
|
||||
no_export=bool(row["no_export"] or False),
|
||||
max_battery_charge_w=int(row["max_battery_charge_w"])
|
||||
if row["max_battery_charge_w"] is not None
|
||||
else None,
|
||||
max_battery_discharge_w=int(row["max_battery_discharge_w"])
|
||||
if row["max_battery_discharge_w"] is not None
|
||||
else None,
|
||||
reserve_soc_percent=int(row["reserve_soc_percent"])
|
||||
if row["reserve_soc_percent"] is not None
|
||||
else None,
|
||||
usable_capacity_wh=int(row["usable_capacity_wh"])
|
||||
if row["usable_capacity_wh"] is not None
|
||||
else None,
|
||||
max_charge_a=max_charge_a,
|
||||
max_discharge_a=max_discharge_a,
|
||||
)
|
||||
|
||||
|
||||
def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]:
|
||||
"""Hodnoty pro reg 62–64 (Europe/Prague)."""
|
||||
now = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
reg62 = ((now.year - 2000) << 8) | now.month
|
||||
reg63 = (now.day << 8) | now.hour
|
||||
reg64 = (now.minute << 8) | now.second
|
||||
rows = [
|
||||
(62, "", reg62),
|
||||
(63, "", reg63),
|
||||
(64, "", reg64),
|
||||
]
|
||||
return now, rows
|
||||
|
||||
|
||||
def _deye_time_point_rows(
|
||||
slot_index: int,
|
||||
time_hhmm: int,
|
||||
power_w: int,
|
||||
soc_pct: int,
|
||||
grid_charge: bool,
|
||||
) -> list[tuple[int, str, int]]:
|
||||
g = 1 if grid_charge else 0
|
||||
return [
|
||||
(148 + slot_index, "", time_hhmm),
|
||||
(154 + slot_index, "", power_w),
|
||||
(166 + slot_index, "", soc_pct),
|
||||
(172 + slot_index, "", g),
|
||||
]
|
||||
|
||||
|
||||
def _slot_start_prague_sql(slot_offset: int) -> str:
|
||||
"""Výraz TIMESTAMPTZ = začátek aktuálního (+offset) 15min slotu v Europe/Prague."""
|
||||
off = int(slot_offset)
|
||||
return f"""
|
||||
(
|
||||
WITH loc AS (SELECT now() AT TIME ZONE 'Europe/Prague' AS ts)
|
||||
SELECT (
|
||||
(date_trunc('day', ts)
|
||||
+ make_interval(
|
||||
hours => EXTRACT(HOUR FROM ts)::int,
|
||||
mins => (FLOOR(EXTRACT(MINUTE FROM ts) / 15) * 15)::int
|
||||
)
|
||||
)::timestamp AT TIME ZONE 'Europe/Prague'
|
||||
) + INTERVAL '{off * 15} minutes'
|
||||
FROM loc
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
async def _fetch_plan_row_for_slot_offset(
|
||||
site_id: int, db: asyncpg.Connection, slot_offset: int
|
||||
) -> asyncpg.Record | None:
|
||||
"""Řádek plánu pro slot: 0 = probíhající 15min, 1 = následující (hranice v Europe/Prague)."""
|
||||
t = _slot_start_prague_sql(slot_offset)
|
||||
return await db.fetchrow(
|
||||
f"""
|
||||
SELECT pi.* FROM ems.planning_interval pi
|
||||
JOIN ems.planning_run pr ON pr.id = pi.run_id
|
||||
WHERE pr.site_id = $1 AND pr.status = 'active'
|
||||
AND pi.interval_start = (
|
||||
SELECT MIN(pi2.interval_start) FROM ems.planning_interval pi2
|
||||
JOIN ems.planning_run pr2 ON pr2.id = pi2.run_id
|
||||
WHERE pr2.site_id = $1 AND pr2.status = 'active'
|
||||
AND pi2.interval_start >= date_trunc('hour', now())
|
||||
+ INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM now()) / 15)
|
||||
+ INTERVAL '15 minutes'
|
||||
)
|
||||
AND pi.interval_start = {t}
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
@@ -104,10 +566,20 @@ async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) ->
|
||||
async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int:
|
||||
v = await db.fetchval(
|
||||
"""
|
||||
SELECT ai.max_charge_power_w
|
||||
FROM ems.asset_inverter ai
|
||||
WHERE ai.site_id = $1 AND ai.controllable = true AND ai.active = true
|
||||
ORDER BY ai.id
|
||||
SELECT LEAST(
|
||||
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w),
|
||||
COALESCE(
|
||||
ab.bms_max_charge_w,
|
||||
CASE WHEN ab.max_charge_c_rate IS NOT NULL
|
||||
THEN (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
|
||||
END,
|
||||
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w)
|
||||
)
|
||||
) AS effective_charge_w
|
||||
FROM ems.asset_battery ab
|
||||
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id AND ai.site_id = ab.site_id
|
||||
WHERE ab.site_id = $1 AND ai.controllable = true AND ai.active = true
|
||||
ORDER BY ab.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
@@ -129,6 +601,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
|
||||
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
|
||||
hp_en = bool(pi["heat_pump_enabled"])
|
||||
tgt = pi["battery_soc_target_pct"]
|
||||
target_soc = int(round(float(tgt))) if tgt is not None else None
|
||||
return ControlSetpoints(
|
||||
battery_w=int(pi["battery_setpoint_w"] or 0),
|
||||
grid_export_limit=abs(min(grid_sp, 0)),
|
||||
@@ -138,6 +612,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
grid_setpoint_w=grid_sp,
|
||||
ev1_power_w=ev1_w,
|
||||
ev2_power_w=ev2_w,
|
||||
target_soc_pct=target_soc,
|
||||
)
|
||||
|
||||
if code == "SELF_SUSTAIN":
|
||||
@@ -150,6 +625,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
)
|
||||
|
||||
if code == "CHARGE_CHEAP":
|
||||
@@ -163,6 +639,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
)
|
||||
|
||||
if code == "PRESERVE":
|
||||
@@ -175,62 +652,240 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
lock_battery=True,
|
||||
)
|
||||
|
||||
logger.warning("Unknown mode_code %s for site export, skipping", code)
|
||||
return None
|
||||
|
||||
|
||||
async def write_inverter_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
|
||||
if setpoints.battery_w is None:
|
||||
return "OK inverter: skipped (battery_w=None, Deye unchanged)"
|
||||
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ai.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_inverter ai
|
||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||
WHERE ai.site_id = $1
|
||||
AND ai.controllable = true
|
||||
AND ai.active = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
""",
|
||||
def _apply_price_failsafe_guard(
|
||||
site_id: int,
|
||||
mode: OperatingModeInfo,
|
||||
pi: asyncpg.Record | None,
|
||||
sp: ControlSetpoints,
|
||||
) -> ControlSetpoints:
|
||||
if mode.mode_code != "AUTO" or pi is None:
|
||||
return sp
|
||||
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
|
||||
return sp
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
|
||||
site_id,
|
||||
)
|
||||
if not rows:
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=sp.ev1_current_a,
|
||||
ev2_current_a=sp.ev2_current_a,
|
||||
heat_pump_enable=sp.heat_pump_enable,
|
||||
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
|
||||
ev1_power_w=sp.ev1_power_w,
|
||||
ev2_power_w=sp.ev2_power_w,
|
||||
target_soc_pct=sp.target_soc_pct,
|
||||
)
|
||||
|
||||
|
||||
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
|
||||
"""Reg 143 – max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
|
||||
if no_export:
|
||||
return 0
|
||||
return max(0, int(max_export_power_w or 0))
|
||||
|
||||
|
||||
def get_deye_mode(setpoints: ControlSetpoints) -> str:
|
||||
"""
|
||||
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
|
||||
Solver: záporný grid_setpoint_w = export; kladný výrazný + nabíjení = CHARGE ze sítě.
|
||||
battery_w=None (SELF_SUSTAIN) → bat_w považuj za 0 → typicky PASSIVE při grid_setpoint_w=0.
|
||||
"""
|
||||
grid_w = int(setpoints.grid_setpoint_w or 0)
|
||||
if setpoints.battery_w is None:
|
||||
bat_w = 0
|
||||
else:
|
||||
bat_w = int(setpoints.battery_w)
|
||||
if grid_w < -200:
|
||||
return "SELL"
|
||||
if bat_w > 500 and grid_w > 200:
|
||||
return "CHARGE"
|
||||
return "PASSIVE"
|
||||
|
||||
|
||||
def _deye_tou_params(
|
||||
setpoints: ControlSetpoints,
|
||||
inv: InverterConfig,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""
|
||||
Parametry jednoho Deye time pointu: výkon W, SOC min %, grid_charge.
|
||||
Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints.
|
||||
"""
|
||||
reserve_soc = inv.reserve_soc_percent or 20
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
|
||||
if setpoints.lock_battery:
|
||||
return tp_discharge_w, reserve_soc, False
|
||||
deye_mode = get_deye_mode(setpoints)
|
||||
if deye_mode == "CHARGE":
|
||||
raw_bat = setpoints.battery_w
|
||||
battery_w = int(raw_bat) if raw_bat is not None else 0
|
||||
target_soc = min(95, setpoints.target_soc_pct or 80)
|
||||
tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V)
|
||||
return tp_charge_w, target_soc, True
|
||||
return tp_discharge_w, reserve_soc, False
|
||||
|
||||
|
||||
async def write_inverter_setpoints(
|
||||
site_id: int,
|
||||
setpoints_now: ControlSetpoints,
|
||||
setpoints_next: ControlSetpoints | None,
|
||||
db: asyncpg.Connection,
|
||||
planning_run_id: int | None = None,
|
||||
) -> str:
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
return "FAIL inverter: no controllable Modbus endpoint"
|
||||
|
||||
bw = setpoints.battery_w
|
||||
gex = _clamp_u16(setpoints.grid_export_limit)
|
||||
chg = _clamp_u16(bw) if bw >= 0 else 0
|
||||
dis = _clamp_u16(abs(bw)) if bw < 0 else 0
|
||||
raw_bat = setpoints_now.battery_w
|
||||
grid_w = int(setpoints_now.grid_setpoint_w or 0)
|
||||
no_export = inv.no_export
|
||||
export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w)
|
||||
reserve_soc = inv.reserve_soc_percent or 20
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge
|
||||
|
||||
errors: list[str] = []
|
||||
for row in rows:
|
||||
code = row["code"]
|
||||
host = row["host"]
|
||||
port = int(row["port"] or 502)
|
||||
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||
dev = ModbusDevice(host, port, unit_id, f"inverter-write:{code}")
|
||||
try:
|
||||
if bw >= 0:
|
||||
ok1 = await dev.write_register(0x00F3, chg)
|
||||
ok2 = await dev.write_register(0x00F4, 0)
|
||||
else:
|
||||
ok1 = await dev.write_register(0x00F3, 0)
|
||||
ok2 = await dev.write_register(0x00F4, dis)
|
||||
ok3 = await dev.write_register(0x00F6, gex)
|
||||
if not (ok1 and ok2 and ok3):
|
||||
errors.append(f"{code}: Modbus write failed")
|
||||
except Exception as e:
|
||||
errors.append(f"{code}: {e}")
|
||||
finally:
|
||||
await dev.close()
|
||||
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),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
166
backend/services/modbus_client.py
Normal file
166
backend/services/modbus_client.py
Normal 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 60–499 (jeden i více registrů)."""
|
||||
async with self._lock:
|
||||
await self._ensure_connected()
|
||||
return await self._write_registers_locked(address, values)
|
||||
|
||||
@asynccontextmanager
|
||||
async def batch(self) -> AsyncIterator[ModbusBatch]:
|
||||
"""Drží lock pro více po sobě jdoucích operací (telemetrie vs. control na stejné bráně)."""
|
||||
async with self._lock:
|
||||
await self._ensure_connected()
|
||||
yield ModbusBatch(self)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
|
||||
_clients: dict[str, PersistentModbusClient] = {}
|
||||
_registry_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_modbus_client(
|
||||
host: str, port: int, device_id: int = 1
|
||||
) -> PersistentModbusClient:
|
||||
key = f"{host}:{port}:{device_id}"
|
||||
async with _registry_lock:
|
||||
if key not in _clients:
|
||||
_clients[key] = PersistentModbusClient(host, port, device_id)
|
||||
return _clients[key]
|
||||
65
backend/services/notification_service.py
Normal file
65
backend/services/notification_service.py
Normal 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")
|
||||
@@ -13,9 +13,9 @@ from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pulp
|
||||
from pulp import HiGHS_CMD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,8 +24,11 @@ logger = logging.getLogger(__name__)
|
||||
# Konstanty
|
||||
# ============================================================
|
||||
|
||||
HORIZON_HOURS = 36 # horizont denního plánu
|
||||
HORIZON_HOURS = 96 # horizont denního plánu (OTE ~36h + predikce)
|
||||
INTERVAL_H = 0.25 # 15 minut v hodinách
|
||||
SLOT_WEIGHT_FULL = 1.0 # 0–36h od začátku okna (přesné OTE ceny)
|
||||
SLOT_WEIGHT_MEDIUM = 0.7 # 36–72h
|
||||
SLOT_WEIGHT_LOW = 0.4 # 72–96h
|
||||
CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A
|
||||
SOLVER_TIME_LIMIT = 10 # sekund
|
||||
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
|
||||
@@ -34,6 +37,84 @@ CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
||||
# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast
|
||||
CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0
|
||||
|
||||
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
def slot_weight(slot_index: int, now_index: int = 0) -> float:
|
||||
"""Váha slotu v účelové funkci podle vzdálenosti od začátku optimalizačního okna."""
|
||||
hours_ahead = (slot_index - now_index) * INTERVAL_H
|
||||
if hours_ahead <= 36:
|
||||
return SLOT_WEIGHT_FULL
|
||||
if hours_ahead <= 72:
|
||||
return SLOT_WEIGHT_MEDIUM
|
||||
return SLOT_WEIGHT_LOW
|
||||
|
||||
|
||||
def _pv_scarcity_penalty_multiplier(slots: list["PlanningSlot"], battery) -> float:
|
||||
"""
|
||||
Měkká úprava ekonomiky cyklu podle očekávaného slunečního zisku.
|
||||
- málo očekávané FVE energie -> nižší penalizace cyklu (podpora precharge ze sítě),
|
||||
- hodně očekávané FVE energie -> standardní penalizace.
|
||||
"""
|
||||
horizon_slots = min(len(slots), int(24 / INTERVAL_H)) # konzervativní 1 den dopředu
|
||||
if horizon_slots <= 0:
|
||||
return 1.0
|
||||
|
||||
pv_kwh = 0.0
|
||||
for s in slots[:horizon_slots]:
|
||||
pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0
|
||||
|
||||
batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0)
|
||||
# coverage = kolikanásobek baterie očekáváme ze slunce v horizontu.
|
||||
coverage = pv_kwh / batt_kwh
|
||||
coverage_clamped = max(0.0, min(1.0, coverage))
|
||||
# 0.65 při nízkém slunci, 1.0 při vysokém slunci.
|
||||
return 0.65 + 0.35 * coverage_clamped
|
||||
|
||||
|
||||
def _pv_coverage_ratio(slots: list["PlanningSlot"], battery, hours: int = 24) -> float:
|
||||
horizon_slots = min(len(slots), int(hours / INTERVAL_H))
|
||||
if horizon_slots <= 0:
|
||||
return 1.0
|
||||
pv_kwh = 0.0
|
||||
for s in slots[:horizon_slots]:
|
||||
pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0
|
||||
batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0)
|
||||
return max(0.0, min(1.0, pv_kwh / batt_kwh))
|
||||
|
||||
|
||||
def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float, float]:
|
||||
"""
|
||||
Při nízkém očekávaném slunci drží solver vyšší SoC buffer:
|
||||
- cílový buffer: reserve + až 20 % usable capacity,
|
||||
- ekonomická penalizace deficitu vůči bufferu z průměrné ceny.
|
||||
"""
|
||||
coverage = _pv_coverage_ratio(slots, battery, hours=24)
|
||||
scarcity = 1.0 - coverage
|
||||
usable_wh = float(getattr(battery, "usable_capacity_wh", 0.0))
|
||||
reserve_wh = float(getattr(battery, "reserve_soc_wh", 0.0))
|
||||
soc_max_wh = float(getattr(battery, "soc_max_wh", usable_wh))
|
||||
extra_buffer_wh = 0.35 * usable_wh * scarcity
|
||||
target_wh = min(soc_max_wh, reserve_wh + extra_buffer_wh)
|
||||
|
||||
h24 = min(len(slots), int(24 / INTERVAL_H))
|
||||
avg_buy = (
|
||||
sum(float(s.buy_price) for s in slots[:h24]) / h24
|
||||
if h24 > 0
|
||||
else 4.0
|
||||
)
|
||||
penalty_czk_kwh = max(0.1, avg_buy * 1.00 * scarcity)
|
||||
return target_wh, penalty_czk_kwh
|
||||
|
||||
|
||||
def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
|
||||
"""DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So."""
|
||||
dt = interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
loc = dt.astimezone(_PRAGUE_TZ)
|
||||
return (loc.weekday() + 1) % 7, loc.hour
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Datové třídy (lze nahradit pydantic modely)
|
||||
@@ -49,6 +130,7 @@ class PlanningSlot:
|
||||
load_baseline_w: int # W – predikce bazální spotřeby
|
||||
ev1_connected: bool
|
||||
ev2_connected: bool
|
||||
is_predicted_price: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,6 +149,7 @@ class DispatchResult:
|
||||
expected_cost_czk: float
|
||||
effective_buy_price: float
|
||||
effective_sell_price: float
|
||||
is_predicted_price: bool # shodné s PlanningSlot (chybí OTE v efektivní ceně → fn_get_predicted_price)
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -179,6 +262,11 @@ def solve_dispatch(
|
||||
vehicles: list, # [vehicle1, vehicle2]
|
||||
current_soc_wh: float,
|
||||
current_tuv_temp_c: float,
|
||||
*,
|
||||
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
|
||||
now_slot_index: int = 0,
|
||||
operating_mode: str = "AUTO",
|
||||
price_failsafe_active: bool = False,
|
||||
) -> tuple[list[DispatchResult], int]:
|
||||
"""
|
||||
LP solver pro dispatch optimalizaci.
|
||||
@@ -188,6 +276,9 @@ def solve_dispatch(
|
||||
EV = len(vehicles) # počet EV (typicky 2)
|
||||
|
||||
EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
|
||||
cycle_penalty_mult = _pv_scarcity_penalty_multiplier(slots, battery)
|
||||
degradation_cost_effective = battery.degradation_cost_czk_kwh * cycle_penalty_mult
|
||||
soc_buffer_target_wh, soc_deficit_penalty_czk_kwh = _soc_security_profile(slots, battery)
|
||||
|
||||
prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)
|
||||
|
||||
@@ -199,6 +290,7 @@ def solve_dispatch(
|
||||
soc = [pulp.LpVariable(f"soc_{t}", battery.reserve_soc_wh, battery.soc_max_wh) for t in range(T)]
|
||||
ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)]
|
||||
hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)]
|
||||
soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh)
|
||||
|
||||
# EV proměnné per vozidlo
|
||||
ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0,
|
||||
@@ -208,19 +300,23 @@ def solve_dispatch(
|
||||
vehicles[e].max_charge_power_w)
|
||||
for t in range(T)] for e in range(EV)]
|
||||
|
||||
# --- Účelová funkce ---
|
||||
# --- Účelová funkce (váhy slotů podle nejistoty za horizontem OTE) ---
|
||||
prob += pulp.lpSum(
|
||||
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
|
||||
])
|
||||
|
||||
|
||||
@@ -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__}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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' ...
|
||||
|
||||
29
db/migration/V012__telemetry_inverter_columns.sql
Normal file
29
db/migration/V012__telemetry_inverter_columns.sql
Normal 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.';
|
||||
20
db/migration/V013__predicted_negative_price_window.sql
Normal file
20
db/migration/V013__predicted_negative_price_window.sql
Normal 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);
|
||||
78
db/migration/V014__asset_model_refinement.sql
Normal file
78
db/migration/V014__asset_model_refinement.sql
Normal 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';
|
||||
100
db/migration/V015__distribution_tariff.sql
Normal file
100
db/migration/V015__distribution_tariff.sql
Normal 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.';
|
||||
55
db/migration/V016__seed_distribution_home01.sql
Normal file
55
db/migration/V016__seed_distribution_home01.sql
Normal 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');
|
||||
47
db/migration/V017__green_bonus.sql
Normal file
47
db/migration/V017__green_bonus.sql
Normal 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';
|
||||
13
db/migration/V018__cleanup_legacy_green_bonus.sql
Normal file
13
db/migration/V018__cleanup_legacy_green_bonus.sql
Normal 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.';
|
||||
43
db/migration/V019__forecast_accuracy.sql
Normal file
43
db/migration/V019__forecast_accuracy.sql
Normal 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);
|
||||
30
db/migration/V020__ev_arrival_stats.sql
Normal file
30
db/migration/V020__ev_arrival_stats.sql
Normal 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;
|
||||
27
db/migration/V021__baseline_consumption.sql
Normal file
27
db/migration/V021__baseline_consumption.sql
Normal 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.';
|
||||
38
db/migration/V022__extended_planning.sql
Normal file
38
db/migration/V022__extended_planning.sql
Normal 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.';
|
||||
54
db/migration/V023__modbus_command_journal.sql
Normal file
54
db/migration/V023__modbus_command_journal.sql
Normal 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.';
|
||||
8
db/migration/V024__planning_predicted_price.sql
Normal file
8
db/migration/V024__planning_predicted_price.sql
Normal 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.';
|
||||
8
db/migration/V025__deye_physical_mode.sql
Normal file
8
db/migration/V025__deye_physical_mode.sql
Normal 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ů.';
|
||||
11
db/migration/V026__battery_economics_tuning.sql
Normal file
11
db/migration/V026__battery_economics_tuning.sql
Normal 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;
|
||||
118
db/routines/R__fn_baseline_consumption.sql
Normal file
118
db/routines/R__fn_baseline_consumption.sql
Normal 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().';
|
||||
@@ -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);';
|
||||
|
||||
65
db/routines/R__fn_ev_arrival_stats.sql
Normal file
65
db/routines/R__fn_ev_arrival_stats.sql
Normal 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.';
|
||||
153
db/routines/R__fn_extended_planning.sql
Normal file
153
db/routines/R__fn_extended_planning.sql
Normal 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).';
|
||||
@@ -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.';
|
||||
|
||||
-- ============================================================
|
||||
|
||||
75
db/routines/R__fn_fill_forecast_accuracy.sql
Normal file
75
db/routines/R__fn_fill_forecast_accuracy.sql
Normal 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';
|
||||
135
db/routines/R__fn_ote_import.sql
Normal file
135
db/routines/R__fn_ote_import.sql
Normal 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
|
||||
);';
|
||||
227
db/routines/R__fn_predict_negative_prices.sql
Normal file
227
db/routines/R__fn_predict_negative_prices.sql
Normal 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.';
|
||||
@@ -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.';
|
||||
|
||||
-- ============================================================
|
||||
|
||||
78
db/views/R__vw_forecast_accuracy.sql
Normal file
78
db/views/R__vw_forecast_accuracy.sql
Normal 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.';
|
||||
@@ -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;
|
||||
|
||||
@@ -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).';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| Vozidlo | Nabíječka | Max výkon | Řízení | API |
|
||||
|---|---|---|---|---|
|
||||
| Tesla | ev-charger-1 (Teltonika 22kW) | 22 kW | WB proud limit + Tesla API | Zatím nerozhodnuto (Tessie nebo přímé) |
|
||||
| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max ~7-11kW) | WB proud limit (Zoe respektuje) | Žádné – Zoe jako fixní zátěž při připojení |
|
||||
| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max 22kW) | WB proud limit (Zoe respektuje) | Žádné – Zoe jako fixní zátěž při připojení |
|
||||
|
||||
---
|
||||
|
||||
@@ -82,7 +82,7 @@ CREATE TABLE ems.asset_vehicle (
|
||||
name TEXT,
|
||||
make TEXT, -- 'Tesla', 'Renault'
|
||||
model TEXT, -- 'Model Y', 'Zoe'
|
||||
battery_capacity_kwh NUMERIC(6,2), -- Tesla ~75, Zoe ~52
|
||||
battery_capacity_kwh NUMERIC(6,2), -- Tesla ~58, Zoe ~22
|
||||
max_charge_power_w INT, -- max přijímaný výkon vozidla
|
||||
default_charger_id INT REFERENCES ems.asset_ev_charger(id),
|
||||
api_type TEXT, -- 'tesla', 'none'
|
||||
@@ -241,6 +241,32 @@ SoC Zoe neznáme přesně – použijeme energii dodanou v session (kumulativní
|
||||
|
||||
---
|
||||
|
||||
## Statistika příjezdů
|
||||
|
||||
### Tabulka `ems.ev_arrival_stats`
|
||||
|
||||
Agregace podle `site_id`, `charger_id`, `day_of_week` (0 = neděle … 6 = sobota) a `arrival_hour` (0–23). Čas příjezdu se počítá v **Europe/Prague**. Unikátní klíč `(site_id, charger_id, day_of_week, arrival_hour)`; sloupec `sample_count` roste s každým zaznamenaným příjezdem.
|
||||
|
||||
Účel: po několika týdnech dat odhadnout typickou hodinu připojení vozidla na danou wallbox — pro notifikace („obvykle přijíždíš kolem 17–18h“) a později jako měkký vstup do plánovače.
|
||||
|
||||
### `ems.fn_update_ev_arrival_stats(site_id, charger_id, vehicle_id, arrived_at)`
|
||||
|
||||
Inkrementuje statistiku pro příslušný bucket (INSERT nebo `ON CONFLICT` +1). Volá se při **detekci nového příjezdu** v `telemetry_collector`: přechod telemetrie z `available` na stav připojení (`preparing`, `charging`, …).
|
||||
|
||||
### `ems.fn_ev_expected_arrival(site_id, charger_id, for_date)`
|
||||
|
||||
Vrátí až **3 řádky**: nejčastější hodiny příjezdu pro den v týdnu odpovídající kalendářnímu datu `for_date` (typicky „zítřek“ v časové zóně lokality z backendu). Filtr `sample_count >= 2`; `confidence_pct` = podíl dané hodiny na součtu vzorků pro stejný `day_of_week` u té nabíječky.
|
||||
|
||||
### API
|
||||
|
||||
`GET /api/v1/sites/{site_id}/ev/arrival-prediction` vrátí pro každou nabíječku (klíč = `asset_ev_charger.code`) pole `tomorrow` s `{ hour, confidence_pct, samples }`. Pokud je na site méně než **5** záznamů v `ev_session` celkem, odpověď má `insufficient_data: true` (predikce se může vracet prázdné nebo řídké).
|
||||
|
||||
### Provozní poznámka
|
||||
|
||||
Historie v `ev_arrival_stats` se **nemaže** — jde o dlouhodobou agregaci. Po **4+ týdnech** reálných příjezdů má smysl UI notifikace a experimentální zapojení do solveru (soft constraint).
|
||||
|
||||
---
|
||||
|
||||
## Seed data – vozidla home-01
|
||||
|
||||
```sql
|
||||
@@ -252,7 +278,7 @@ INSERT INTO ems.asset_vehicle
|
||||
default_target_soc_pct, default_deadline_hour)
|
||||
SELECT
|
||||
s.id, 'tesla-my', 'Tesla Model Y', 'Tesla', 'Model Y',
|
||||
75.0, 11000, -- Tesla Model Y AC max ~11kW
|
||||
58.0, 11000, -- Tesla Model Y AC max ~11kW
|
||||
ch.id, 'none', -- Tesla API fáze 2
|
||||
80, 7
|
||||
FROM ems.site s
|
||||
@@ -265,7 +291,7 @@ INSERT INTO ems.asset_vehicle
|
||||
default_target_soc_pct, default_deadline_hour)
|
||||
SELECT
|
||||
s.id, 'zoe-r135', 'Renault Zoe R135', 'Renault', 'Zoe R135',
|
||||
52.0, 7400, -- Zoe max 7.4kW AC
|
||||
22.0, 22000, -- Zoe max 22kW AC
|
||||
ch.id, 'none',
|
||||
90, 7 -- Zoe: vyšší target SoC (menší baterie, kritičtější)
|
||||
FROM ems.site s
|
||||
|
||||
@@ -89,10 +89,15 @@ def calculate_pv_power(
|
||||
|
||||
| Trigger | Čas | Popis |
|
||||
|---|---|---|
|
||||
| Scheduled (cron) | každý den 14:30 CET | Po importu cen, před plánováním |
|
||||
| Scheduled (cron) | každý den 06:00 CET | Aktualizace predikce na dnešní den |
|
||||
| Před plánováním | automaticky | Plánovač zkontroluje čerstvost, spustí pokud starší než 2h |
|
||||
| Manual trigger | na vyžádání | `POST /admin/run-forecast?site_id=1&date=YYYY-MM-DD` |
|
||||
| Scheduled (cron) | každé 2 hodiny v `:05` | Průběžný refresh forecastu pro všechny aktivní site |
|
||||
| Manual trigger | na vyžádání | `POST /api/v1/sites/{site_id}/forecast/run` |
|
||||
|
||||
### Implementované provozní změny (2026-03)
|
||||
|
||||
- Forecast horizont je konfigurovatelný přes `open_meteo_forecast_days`.
|
||||
- Runtime guard: hodnota se clampuje do rozmezí `2..16`.
|
||||
- Default je `7` dní.
|
||||
- Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů.
|
||||
|
||||
---
|
||||
|
||||
@@ -153,11 +158,21 @@ Viz `03-data-model.md`:
|
||||
|
||||
---
|
||||
|
||||
## Tracking přesnosti forecastu
|
||||
|
||||
- **`ems.forecast_accuracy`** – pro každý úspěšný `forecast_pv_run` a každý 15min slot ukládá predikovaný výkon, čas vzniku predikce, lead time (hodiny před začátkem slotu), později doplněnou skutečnost z telemetrie a odchylku (`error_w`, `error_pct`). Záznamy se **uchovávají trvale** (včetně všech historických běhů v `forecast_pv_run` / `forecast_pv_interval` – ty se nemazají).
|
||||
- **`ems.fn_fill_forecast_accuracy(site_id, lookback_hours)`** – inkrementálně vloží nebo aktualizuje řádky z `forecast_pv_interval` + run metadata a dopočte `actual_power_w` jako průměr 1min telemetrie ve slotu (pole B: `gen_port_power_w`, pole A: `pv1_power_w` + `pv2_power_w`). Volat **každých 15 minut** (např. spolu s audit fillerem); parametr `lookback_hours` omezuje okno zpětného zpracování (např. 48 h běžně, větší hodnota pro jednorázový backfill).
|
||||
- **`ems.vw_forecast_accuracy_by_lead_time`** – agregace přesnosti podle bucketů lead time (0–6 h, …, 48 h+); noční sloty s nízkou výrobou (`actual_power_w` ≤ 100 W) se v metrikách typicky vynechávají.
|
||||
- **`ems.vw_forecast_accuracy_daily`** – denní součty forecast vs actual v kWh (Praha kalendářní den) a relativní odchylka dne.
|
||||
- **Po 4+ týdnech dat** lze statistiky použít pro kalibraci `safety_factor` (nebo obdobných parametrů) v solveru – viz plánovací modul.
|
||||
|
||||
---
|
||||
|
||||
## Konfigurace (env proměnné)
|
||||
|
||||
```env
|
||||
OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast
|
||||
FORECAST_HORIZON_DAYS=3
|
||||
OPEN_METEO_FORECAST_DAYS=7
|
||||
FORECAST_MAX_AGE_HOURS=2 # plánovač odmítne starší predikci
|
||||
FORECAST_RETRY_COUNT=3
|
||||
```
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- Stahuje spotové ceny elektřiny z OTE CZ
|
||||
- Ukládá raw data bez vazby na lokalitu (sdílená tabulka)
|
||||
- Efektivní ceny (s marží) se dopočítávají per site přes view
|
||||
- Granularita: **15 minut** nativně (OTE CZ publikuje po hodinách → konvertujeme na 15min replikací)
|
||||
- Granularita: **15 minut** nativně (veřejný JSON `@@chart-data` s `time_resolution=PT15M`)
|
||||
|
||||
---
|
||||
|
||||
@@ -15,18 +15,20 @@
|
||||
|
||||
OTE CZ publikuje denní ceny zpravidla **den předem (D-1)** okolo 13:00–14:00 středoevropského času.
|
||||
|
||||
### Formát dat OTE CZ
|
||||
### Formát dat OTE CZ (implementace)
|
||||
|
||||
OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky:
|
||||
1. Stáhnout XML/JSON feed nebo scrape HTML tabulky
|
||||
2. Převést EUR/MWh → CZK/kWh (kurz ČNB nebo fixní koeficient dle konfigurace)
|
||||
3. Rozložit hodinový interval na 4× 15min sloty (stejná hodnota)
|
||||
4. Uložit do `market_interval_price`
|
||||
**Primární zdroj:** JSON grafu denního trhu (96 bodů na den):
|
||||
|
||||
### Alternativní API
|
||||
`https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data?report_date=YYYY-MM-DD&time_resolution=PT15M`
|
||||
|
||||
- **OTE XML feed:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?date=YYYY-MM-DD&market=DAM&type=FIN`
|
||||
- Autentikace: nepotřebná pro veřejná data
|
||||
- Body: `data.dataLine[0].point[]` s `x` = 1…96 (15min slot), `y` = cena.
|
||||
- Jednotka ceny se bere z `axis.y.legend` (typicky **EUR/MWh**); kód podporuje i CZK/MWh a CZK/kWh.
|
||||
- Přepočet do `buy_raw_price_czk_kwh`: EUR/MWh → `× EUR_CZK / 1000`; CZK/MWh → `/ 1000`; CZK/kWh beze změny.
|
||||
- Časové razítko slotu: půlnoc v **timezone lokality** (`site.timezone`) + (x−1)×15 min → UTC.
|
||||
|
||||
### Legacy / alternativa
|
||||
|
||||
- **OTE pubapi:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?...` (hodinová data – v projektu už není primární)
|
||||
|
||||
---
|
||||
|
||||
@@ -36,14 +38,26 @@ OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky:
|
||||
|
||||
Samostatný modul (ne součást FastAPI, ale může být volán z ní jako task).
|
||||
|
||||
### Implementované provozní změny (2026-03)
|
||||
|
||||
- Robustní HTTP fetch přes `httpx`:
|
||||
- oddělené timeouty (`connect/read/write/pool`),
|
||||
- retry s exponential backoff pro transient chyby,
|
||||
- detailní chybové kódy (`http_status:*`, `timeout_or_connect:*`, `db_import:*`).
|
||||
- API endpoint `POST /api/v1/sites/{site_id}/prices/import` vrací při chybě konkrétní důvod.
|
||||
- Pokud je import volán bez `date`, importer nejdřív zkusí D+1 a při neúspěchu fallback na dnešní den.
|
||||
|
||||
### Kdy se spouští
|
||||
|
||||
| Trigger | Čas | Popis |
|
||||
|---|---|---|
|
||||
| Scheduled (cron) | každý den 14:00 CET | Stažení cen na zítřek (D+1) |
|
||||
| Scheduled (cron) | každý den 00:05 CET | Kontrola – ověření že dnešní data jsou v DB |
|
||||
| Manual trigger | na vyžádání | API endpoint `POST /admin/import-prices?date=YYYY-MM-DD` |
|
||||
| Retry | při chybě, 3× s backoffem | Automatický opakovaný pokus |
|
||||
| Scheduled (cron) | každý den 13:30 CET | Předběžný pokus importu (D+1 + doplnění dneška) |
|
||||
| Scheduled (cron) | každý den 14:00 CET | Hlavní import OTE (D+1 + doplnění dneška) |
|
||||
| Scheduled (cron) | každý den 00:05 CET | Backfill kontrola úplnosti 96 slotů pro dnešek/zítřek |
|
||||
| Manual trigger | na vyžádání | API endpoint `POST /api/v1/sites/{site_id}/prices/import?date=YYYY-MM-DD` |
|
||||
| Retry | při chybě | Automatický opakovaný pokus v importéru |
|
||||
|
||||
Cronové časy v tabulce jsou v **Europe/Prague** (CET/CEST): `AsyncIOScheduler` v `app/main.py` má `timezone=ZoneInfo("Europe/Prague")`; kontejner backendu má `TZ=Europe/Prague` v `docker-compose.yml`. Bez toho by se hodiny/minuty v cronu vyhodnocovaly v **UTC** a např. „13:30 CET“ by odpovídalo jinému okamžiku na hodinách.
|
||||
|
||||
### Logika importu
|
||||
|
||||
@@ -57,15 +71,11 @@ def import_prices_for_date(date: date, source: str = "OTE_CZ"):
|
||||
log.info("Data already exist, skipping")
|
||||
return
|
||||
|
||||
# 2. Stáhnout z OTE API
|
||||
raw_data = ote_client.fetch_dam_prices(date) # vrátí list hodinových cen v EUR/MWh
|
||||
# 2. Stáhnout chart-data (96× 15 min)
|
||||
raw_points = ote_client.fetch_chart_data_15m(date)
|
||||
|
||||
# 3. Konvertovat EUR/MWh → CZK/kWh
|
||||
eur_czk_rate = get_exchange_rate() # z konfigurace nebo ČNB API
|
||||
czk_per_kwh = [(price_eur_mwh * eur_czk_rate) / 1000 for price in raw_data]
|
||||
|
||||
# 4. Rozložit na 15min intervaly (1 hodina = 4 sloty se stejnou cenou)
|
||||
intervals = expand_hourly_to_15min(czk_per_kwh, date)
|
||||
# 3. Detekovat jednotku (EUR/MWh, CZK/MWh, …) a převést na CZK/kWh
|
||||
intervals = convert_points_to_czk_kwh(raw_points, date, site_tz)
|
||||
|
||||
# 5. Upsert do DB (idempotentní)
|
||||
db.upsert_many("market_interval_price", intervals, conflict_keys=["market_source", "interval_start"])
|
||||
@@ -98,12 +108,25 @@ Marže se konfigurují v `site_market_config`:
|
||||
| `sell_margin_fixed_czk` | Kč/kWh | -0.02 (srážka) |
|
||||
| `sell_margin_percent` | % | 0 |
|
||||
|
||||
**Zelený bonus** není součástí `fn_effective_sell_price` ani view efektivní prodejní ceny – jde o samostatný příjem z výroby, viz níže.
|
||||
|
||||
---
|
||||
|
||||
## Zelený bonus
|
||||
|
||||
- Konfigurace je na **`ems.asset_pv_array`** (konkrétní FVE pole), ne na `site` ani na střídači. Sloupce: `green_bonus_czk_kwh`, `green_bonus_valid_from`, `green_bonus_valid_to`, `green_bonus_meter_code`.
|
||||
- Sazba se typicky mění **ročně**; verzování je přes `valid_from` / `valid_to` (při změně uzavři staré období a zadej novou sazbu s novým `valid_from`).
|
||||
- **Výpočet příjmu** za interval: `ems.fn_green_bonus_revenue(pv_array_id, interval_start, production_wh)` kde `production_wh` je skutečná nebo odhadnutá výroba pole v daném 15min slotu (Wh). Bonus platí z **celkové výroby** pole (interní spotřeba, baterie, EV, TČ i export) – nejde o prodejní cenu.
|
||||
- **Nezahrnovat do** `fn_effective_sell_price` – spot + prodejní marže jsou odděleně od dotace; v auditu se bonus ukládá do `audit_interval.green_bonus_czk` (plní `fn_fill_audit_interval`).
|
||||
|
||||
Historicky mohou v DB zůstat sloupce `green_bonus_*` na `site_market_config`; efektivní prodejní cena je z nich se nepočítá.
|
||||
|
||||
---
|
||||
|
||||
## Konfigurace (env proměnné)
|
||||
|
||||
```env
|
||||
OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam
|
||||
OTE_API_URL=https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data
|
||||
OTE_IMPORT_HOUR=14 # hodina kdy se spouští denní import
|
||||
EUR_CZK_RATE=25.0 # fallback kurz pokud ČNB API nedostupné
|
||||
CNB_API_URL=https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml
|
||||
|
||||
59
docs/04-modules/modbus-command-journal.md
Normal file
59
docs/04-modules/modbus-command-journal.md
Normal 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 **62–64** (čas) a **time pointy 148–177** také řádky pro **108** (max charge A), **109** (max discharge A), **141** (energy mode, vždy 0), **142** (limit control), **178** (pevné hodnoty 32 / 48 podle fyzického režimu, bez read-modify-write), **143** (export limit W). Každý řádek daného exportního běhu má vyplněný **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**) pro audit přepínání. **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu baterie na proud: `battery_watts_to_amps` viz `modbus-registers.md`. Všechny zápisy journalu jdou přes **`write_registers`** (FC **0x10**), ne FC 0x06. Detail režimů a registrů: `docs/04-modules/modbus-registers.md`.
|
||||
|
||||
## APScheduler
|
||||
|
||||
| Job | Frekvence | Popis |
|
||||
|-----|-----------|--------|
|
||||
| `verify_modbus` | každé **2 min** | Pro každou aktivní site vybere `written` příkazy s `written_at` v posledních **10 min** a zavolá `verify_modbus_commands`. |
|
||||
|
||||
## Ruční API
|
||||
|
||||
`GET /api/v1/sites/{site_id}/control/verify?minutes=10`
|
||||
|
||||
Vrátí počty `checked` / `verified` / `mismatch` a seznam dotčených příkazů s aktuálním stavem po verifikaci.
|
||||
|
||||
## `ems.cutoff_switch_log`
|
||||
|
||||
Tabulka pro budoucí logování **cut-off** přepínačů (mikroinvertory / GEN při záporné prodejní ceně). Záznam při **změně** stavu: `asset_code`, `new_state`, `previous_state`, `reason`, `sell_price_czk`, `triggered_by`. Zatím jen schéma; logika napojení v `control_exporter` je v TODO.
|
||||
|
||||
## Konfigurace
|
||||
|
||||
- `.env`: `DISCORD_WEBHOOK_URL` — prázdné = notifikace vypnuté (jen log).
|
||||
|
||||
## Související soubory
|
||||
|
||||
- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`
|
||||
- Backend: `backend/services/control_exporter.py`, `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py`
|
||||
- Registry Deye: `docs/04-modules/modbus-registers.md`
|
||||
184
docs/04-modules/modbus-registers.md
Normal file
184
docs/04-modules/modbus-registers.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Deye Modbus Registry – EMS řízení
|
||||
|
||||
## Důležité pravidlo
|
||||
|
||||
- Registry **60–499**: POUZE **FC 0x10** (`write_registers`)
|
||||
- Registry **0–59**: FC 0x03 čtení, FC 0x06 zápis
|
||||
- Registry **500+**: FC 0x03 pouze čtení
|
||||
|
||||
EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_registers`** (FC 0x10), nikdy `write_register` (FC 0x06) pro rozsah 60–499.
|
||||
|
||||
## Řídící registry (R/W, FC 0x10)
|
||||
|
||||
| Reg | Název | Rozsah | Jednotka | Použití v EMS |
|
||||
|-----|-------|--------|----------|---------------|
|
||||
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit nabíjení baterie; horní mez není napříč modely stejná (nižší výkonové řady mívají jiný strop než např. SUN-20K) |
|
||||
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit vybíjení baterie; viz výše |
|
||||
| 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě |
|
||||
| 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě |
|
||||
| 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) |
|
||||
| 142 | Limit control | 0/1/2 | — | **0** = selling first, **1** = zero export (built-in CT); EMS přepíná export vs. idle/nabíjení |
|
||||
| 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` |
|
||||
| 178 | Grid peak shaving switch | bitmask | — | EMS zapisuje **pevnou** hodnotu (bez read-modify-write kvůli kolizím s paralelním čtením z Loxone): **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE** a **CHARGE**. |
|
||||
| 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu |
|
||||
| 191 | Grid peak shaving power | 0–16000 | 1 W | **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. |
|
||||
|
||||
`control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal + verifikace) po řadě: **62–64** (čas), **time points 148–177**, **108, 109, 141, 142, 178, 143**. Popisné názvy registrů v DB bere `DEYE_REGISTER_NAMES` v `control_exporter.py`. **Reg 191** do journalu nepatří – EMS ho nezapisuje.
|
||||
|
||||
### Reg 191 (výkon grid peak shaving)
|
||||
|
||||
- **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**.
|
||||
- Hodnota určuje výkon peak shavingu v **W** (typicky 0–16 000).
|
||||
|
||||
### Reg 178 – hodnoty podle fyzického režimu
|
||||
|
||||
- **SELL:** **32** – bit4–5 = **10**, grid peak shaving **disable** (export do sítě).
|
||||
- **PASSIVE** a **CHARGE:** **48** – bit4–5 = **11**, grid peak shaving **enable**.
|
||||
|
||||
EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může způsobit nesoulad).
|
||||
|
||||
## Klíčové registry podle fyzického režimu Deye
|
||||
|
||||
Provozní režimy EMS (AUTO, SELF_SUSTAIN, SELL, …) se mapují na **tři fyzické režimy** střídače: **PASSIVE**, **SELL**, **CHARGE**. Ostatní je politika solveru / EMS, ne samostatný „režim“ invertoru.
|
||||
|
||||
| Reg | PASSIVE | SELL | CHARGE |
|
||||
|-----|---------|------|--------|
|
||||
| 142 | 1 (zero export to load) | 0 (selling first) | 1 |
|
||||
| 108 | `max_charge_a` z DB | `max_charge_a` z DB | `battery_watts_to_amps(battery_w, max_charge_a)` |
|
||||
| 109 | `max_discharge_a` z DB | `max_discharge_a` z DB | 0 |
|
||||
| 178 | 48 | 32 | 48 |
|
||||
| 143 | max export W z DB | max export W z DB | max export W z DB |
|
||||
| 141 | 0 | 0 | 0 |
|
||||
|
||||
**Důležité:** V **PASSIVE** i **SELL** jsou registry **108** a **109** vždy na **plném limitu z DB**. Deye si tok energie reguluje sám; snížení 108/109 pod maximum brání reakci na nepředvídatelnou spotřebu nebo přebytky FVE.
|
||||
|
||||
### Detekce fyzického režimu (`get_deye_mode` v `control_exporter.py`)
|
||||
|
||||
Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivní plán / politika EMS), ne z telemetrie.
|
||||
|
||||
| Režim | Podmínka |
|
||||
|-------|----------|
|
||||
| **SELL** | `grid_setpoint_w` < −200 |
|
||||
| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 |
|
||||
| **PASSIVE** | vše ostatní (včetně SELF_SUSTAIN, IDLE, …) |
|
||||
|
||||
Režim **CHARGE_CHEAP** v EMS nastaví `grid_setpoint_w` tak, aby platila podmínka importu (> 200 W), jinak by fyzicky zůstal PASSIVE.
|
||||
|
||||
Všechny limity (`max_charge_a`, `max_discharge_a`, `max_export_power_w` / reg 143) pocházejí **výhradně z DB** (`_load_inverter_config`).
|
||||
|
||||
## Time Points – řízení podle fyzického režimu
|
||||
|
||||
Deye má 6 časových bloků. EMS přepisuje **bloky 1–2** při každém `control_export` (cron např. :14, :29, :44, :59).
|
||||
|
||||
**Výběr aktivního segmentu na invertoru:** platí poslední časový bod, jehož **HH:MM ≤ aktuálnímu času** na hodinách střídače (po synchronizaci 62–64). Proto **nesmí** zůstat jako jediný „minulý“ bod např. **00:00** s pasivním profilem, zatímco profil s nabíjením ze sítě je až u budoucího času – mezi půlnocí a tím budoucím časem by invertor celou dobu používal špatný segment.
|
||||
|
||||
| Blok | Čas (HHMM, Europe/Prague) | Zdroj plánu | Účel | SOC min | Grid charge |
|
||||
|------|---------------------------|-------------|------|---------|-------------|
|
||||
| 1 | **`current_slot_hhmm()`** – začátek **probíhajícího** 15min slotu | `planning_interval` pro **aktuální** slot (`_fetch_plan_row_for_slot_offset(..., 0)`) | PASSIVE / SELL / CHARGE dle `_deye_tou_params` | viz tabulka níže | viz tabulka níže |
|
||||
| 2 | **`next_slot_hhmm()`** – začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže |
|
||||
| 3–6 | 23:59 | — | Neaktivní (rezerva) | `reserve_soc` (DB) | NE |
|
||||
|
||||
**Registry 108 / 109 / 142 / 178 / 143** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 1–2 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`).
|
||||
|
||||
Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** – mezi 14:15 a 14:29 je aktivní segment z bloku 1 (sladěný s plánem pro 14:15–14:30), po 14:30 blok 2 (plán 14:30–14:45). Po dalším exportu se oba časy posunou (např. 14:30 / 14:45).
|
||||
|
||||
### Fyzické režimy Deye – parametry jednoho time pointu (bloky 1–2)
|
||||
|
||||
| Režim | Výkon (W) | SOC min | Grid charge |
|
||||
|-------|-----------|---------|-------------|
|
||||
| **PASSIVE** | `max_discharge_a × 51,2` | rezerva z DB | NE |
|
||||
| **SELL** | `max_discharge_a × 51,2` | rezerva z DB | NE |
|
||||
| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO |
|
||||
|
||||
Bloky 3–6 zůstávají na **23:59** s pasivním profilem (`reserve_soc`, grid charge = NE).
|
||||
|
||||
### Synchronizace času
|
||||
|
||||
Registry **62–64** se při každém `control_export` nastaví na aktuální čas v **Europe/Prague**:
|
||||
|
||||
- reg **62:** `(rok - 2000) << 8 | měsíc`
|
||||
- reg **63:** `den << 8 | hodina`
|
||||
- reg **64:** `minuta << 8 | sekunda`
|
||||
|
||||
Zápis time pointů i systémového času prochází stejným **`modbus_command`** journal jako registry 108 / 109 / 141 / 142 / 178 / 143 (FC 0x10 po jednom registru).
|
||||
|
||||
### Mapování registrů (time point *i*, i = 0…5)
|
||||
|
||||
| Účel | Adresa |
|
||||
|------|--------|
|
||||
| Čas HHMM | 148 + *i* |
|
||||
| Výkon (W) | 154 + *i* |
|
||||
| Min. SOC % | 166 + *i* |
|
||||
| Grid charge enable 0/1 | 172 + *i* |
|
||||
|
||||
Limity nabíjení/vybíjení v ampérech a export z **site_grid_connection** / **asset_inverter** / **asset_battery** načítá `_load_inverter_config()` (`max_charge_a` / `max_discharge_a` jako `LEAST(BMS, střídač) / 51,2`). Python **neřeže** na univerzální číslo – hodnoty v DB mají odpovídat **skutečnému modelu** střídače a BMS (maximální povolená hodnota v registru se liší podle typu; není to všude např. 185 A). Ověřit v dokumentaci k danému SUN-*K.
|
||||
|
||||
## Telemetrické registry (R only, FC 0x03)
|
||||
|
||||
| Reg | Název | Jednotka | Poznámka |
|
||||
|-----|-------|----------|----------|
|
||||
| 500 | Run state | — | 0 = standby, 2 = normal |
|
||||
| 588 | Battery SOC | 1 % | |
|
||||
| 590 | Battery power | 1 W S16 | + vybíjení / − nabíjení |
|
||||
| 625 | Grid total power | 1 W S16 | + import / − export |
|
||||
| 653 | Load total power | 1 W S16 | |
|
||||
| 667 | GEN port power | 1 W | FVE pole B |
|
||||
| 672 | PV1 power | 1 W | |
|
||||
| 673 | PV2 power | 1 W | |
|
||||
|
||||
## Přepočty
|
||||
|
||||
- Výkon baterie → proud (LV 51,2 V): `battery_watts_to_amps(power_w, max_amps) = min(max(0, max_amps), max(0, round(|power_w| / 51.2)))`, kde `max_amps` je z DB
|
||||
- `max_export_power_w` / `max_import_power_w` / limity baterie berou se z DB (`_load_inverter_config`), ne z natvrdo v Pythonu
|
||||
- Export do registru **143** = `site_grid_connection.max_export_power_w` (např. home-01 / SUN-20K **13 500 W**)
|
||||
|
||||
## Ověření (Modbus + DB)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build backend
|
||||
```
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from pymodbus.client import AsyncModbusTcpClient
|
||||
|
||||
async def check():
|
||||
c = AsyncModbusTcpClient('172.16.1.10', port=502, timeout=5)
|
||||
await c.connect()
|
||||
|
||||
times = await c.read_holding_registers(148, count=2)
|
||||
for i in range(2):
|
||||
h, m = divmod(times.registers[i], 100)
|
||||
print(f'Time point {i+1}: {h:02d}:{m:02d}')
|
||||
|
||||
for name, reg in [
|
||||
('Limit control', 142),
|
||||
('Peak sw (bit4-5)', 178),
|
||||
('Export limit', 143),
|
||||
('Discharge A', 109),
|
||||
('Grid power', 625),
|
||||
]:
|
||||
r = await c.read_holding_registers(reg, count=1)
|
||||
raw = r.registers[0]
|
||||
signed = raw - 65536 if raw > 32767 else raw
|
||||
print(f'{name} ({reg}): {signed}')
|
||||
|
||||
c.close()
|
||||
|
||||
asyncio.run(check())
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U ems_user -d ems -c "
|
||||
SELECT register_name, value_to_write, status,
|
||||
created_at AT TIME ZONE 'Europe/Prague' AS cas
|
||||
FROM ems.modbus_command
|
||||
WHERE site_id=2 AND register IN (108, 109, 142)
|
||||
ORDER BY created_at DESC LIMIT 9;"
|
||||
```
|
||||
|
||||
## Související
|
||||
|
||||
- `docs/04-modules/modbus-command-journal.md` – journal a verifikace
|
||||
- `backend/services/control_exporter.py` – zápisy
|
||||
- `backend/services/modbus_client.py` – `write_registers` (FC 0x10)
|
||||
@@ -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'`
|
||||
|
||||
190
docs/04-modules/planning-extended-horizon.md
Normal file
190
docs/04-modules/planning-extended-horizon.md
Normal 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)
|
||||
@@ -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ů)
|
||||
|
||||
@@ -35,21 +35,23 @@ Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI.
|
||||
|
||||
Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
|
||||
|
||||
> Registry jsou specifické pro Deye SUN-20K-SG01LP1-EU.
|
||||
> Finální hodnoty ověřit z Deye Modbus protokolu / Loxone šablony.
|
||||
> Mapování v kódu: `backend/services/telemetry_collector.py` (holding registry, decimal adresa = offset pro `read_holding_registers`).
|
||||
|
||||
| Registr (hex) | Typ | Popis | Jednotka | Přepočet |
|
||||
| Dec (hex) | Typ | Popis | Jednotka | Poznámka |
|
||||
|---|---|---|---|---|
|
||||
| 0x0215 | Read Holding | PV celkový výkon | W | ×1 |
|
||||
| 0x0103 | Read Holding | Battery SoC | % | ×1 |
|
||||
| 0x0105 | Read Holding | Battery power | W | signed, kladné=nabíjení |
|
||||
| 0x0101 | Read Holding | Battery voltage | 0.1V | ×0.1 |
|
||||
| 0x0169 | Read Holding | Grid power | W | signed, kladné=import |
|
||||
| 0x016F | Read Holding | Grid voltage L1 | 0.1V | ×0.1 |
|
||||
| 0x0213 | Read Holding | Load power | W | ×1 |
|
||||
| 0x0220 | Read Holding | Inverter temperature | 0.1°C | ×0.1 |
|
||||
| 0x0168 | Read Holding | Operating mode | enum | viz tabulka módů |
|
||||
| 0x0180 | Read Holding | Fault code | bitfield | 0=ok |
|
||||
| 500 (0x01F4) | uint16 | Provozní stav střídače | enum | raw do `run_state`, ladění |
|
||||
| 514 (0x0202) | uint16 | Dnešní nabití baterie | Wh | `batt_charge_today_wh` |
|
||||
| 515 (0x0203) | uint16 | Dnešní vybití baterie | Wh | `batt_discharge_today_wh` |
|
||||
| 588 (0x024C) | uint16 | Battery SoC | % | `battery_soc_percent` |
|
||||
| 590 (0x024E) | int16 | Tok výkonu baterie | W | signed: **+ vybíjení, − nabíjení** |
|
||||
| 625 (0x0271) | int16 | Výkon sítě | W | signed: **+ import, − export** |
|
||||
| 653 (0x028D) | uint16 | Celková spotřeba | W | `load_power_w` |
|
||||
| 667 (0x029B) | uint16 | Výkon GEN portu (FVE pole B) | W | `gen_port_power_w`, nelze curtailovat |
|
||||
| 672 (0x02A0) | uint16 | Výkon PV1 | W | `pv1_power_w` |
|
||||
| 673 (0x02A1) | uint16 | Výkon PV2 | W | `pv2_power_w` |
|
||||
|
||||
`pv_power_w` v DB = **PV1 + PV2 + GEN port** (celková výroba na instalaci home-01).
|
||||
`gen_port_power_w` zůstává i nadále uložen samostatně pro audit a detailní diagnostiku.
|
||||
|
||||
**Zápis setpointů (plánování → Deye):**
|
||||
|
||||
@@ -60,8 +62,7 @@ Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
|
||||
| 0x00F6 | Write Single | Grid export power limit | W |
|
||||
| 0x00F0 | Write Single | Work mode | enum (viz tabulka) |
|
||||
|
||||
> **TODO:** Přesné registry doplnit z Deye SUN-20K Modbus protokolu PDF.
|
||||
> Loxone šablona pro Deye je dobrý výchozí bod pro mapování registrů.
|
||||
Rychlá kontrola komunikace: `scripts/test_modbus_deye.py`.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,66 +109,7 @@ Komunikace: Modbus TCP přes Waveshare.
|
||||
|
||||
## Kód telemetrie (Python)
|
||||
|
||||
```python
|
||||
# backend/services/telemetry_collector.py
|
||||
|
||||
import asyncio
|
||||
from pymodbus.client import AsyncModbusTcpClient
|
||||
from datetime import datetime, timezone
|
||||
|
||||
async def poll_inverter(site_id: int, inverter: AssetInverter, endpoint: SiteEndpoint, db):
|
||||
"""Přečte všechny registry Deye a uloží záznam do telemetry_inverter."""
|
||||
async with AsyncModbusTcpClient(endpoint.host, port=endpoint.port) as client:
|
||||
try:
|
||||
# Čtení bloku registrů (optimalizovat jako jeden read multiple)
|
||||
pv_power = await read_register(client, 0x0215, endpoint.unit_id)
|
||||
batt_soc = await read_register(client, 0x0103, endpoint.unit_id)
|
||||
batt_power = await read_register_signed(client, 0x0105, endpoint.unit_id)
|
||||
batt_voltage = await read_register(client, 0x0101, endpoint.unit_id) / 10.0
|
||||
grid_power = await read_register_signed(client, 0x0169, endpoint.unit_id)
|
||||
grid_voltage = await read_register(client, 0x016F, endpoint.unit_id) / 10.0
|
||||
load_power = await read_register(client, 0x0213, endpoint.unit_id)
|
||||
inv_temp = await read_register(client, 0x0220, endpoint.unit_id) / 10.0
|
||||
op_mode = await read_register(client, 0x0168, endpoint.unit_id)
|
||||
fault_code = await read_register(client, 0x0180, endpoint.unit_id)
|
||||
|
||||
await db.execute("""
|
||||
INSERT INTO ems.telemetry_inverter
|
||||
(site_id, inverter_id, measured_at,
|
||||
pv_power_w, battery_soc_percent, battery_power_w, battery_voltage_v,
|
||||
grid_power_w, grid_voltage_v, load_power_w,
|
||||
inverter_temp_c, operating_mode, fault_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
||||
""",
|
||||
site_id, inverter.id, datetime.now(timezone.utc),
|
||||
pv_power, batt_soc, batt_power, batt_voltage,
|
||||
grid_power, grid_voltage, load_power,
|
||||
inv_temp, str(op_mode), fault_code
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Inverter poll failed [{inverter.code}]: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def run_collector(db):
|
||||
"""Hlavní smyčka – každých 60s sbírá data ze všech aktivních zařízení."""
|
||||
while True:
|
||||
start = asyncio.get_event_loop().time()
|
||||
|
||||
sites = await db.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
await asyncio.gather(
|
||||
poll_all_inverters(site.id, db),
|
||||
poll_all_ev_chargers(site.id, db),
|
||||
poll_all_heat_pumps(site.id, db),
|
||||
return_exceptions=True # jeden výpadek nezastaví ostatní
|
||||
)
|
||||
|
||||
elapsed = asyncio.get_event_loop().time() - start
|
||||
await asyncio.sleep(max(0, 60 - elapsed))
|
||||
```
|
||||
Implementace: `backend/services/telemetry_collector.py` — `poll_inverter()` používá konstanty `DEYE_REG_*` a třídu `ModbusDevice`; hlavní smyčka je `run_telemetry_loop` / `run_telemetry_loop_wrapper`.
|
||||
|
||||
---
|
||||
|
||||
@@ -209,7 +151,7 @@ MODBUS_READ_TIMEOUT_SEC=3
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Doplnit přesné Modbus registry Deye z PDF protokolu
|
||||
- [x] Základní mapování Deye (holding registry 500–673) v `telemetry_collector.py`
|
||||
- [ ] Doplnit Modbus registry Teltonika z dokumentace / Loxone šablony
|
||||
- [ ] Doplnit Modbus registry Samsung z dokumentace / Loxone šablony
|
||||
- [ ] Ověřit Unit ID všech zařízení při instalaci
|
||||
|
||||
@@ -6,7 +6,31 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec
|
||||
|
||||
---
|
||||
|
||||
## Blokující – nutné před prvním spuštěním
|
||||
## Vyřešeno
|
||||
|
||||
| Popis |
|
||||
|-------|
|
||||
| **Zelený bonus:** přesunuto na `asset_pv_array` (`green_bonus_*`), výpočet `fn_green_bonus_revenue()`, audit_filler (`fn_fill_audit_interval`) počítá bonus z výroby pole; legacy sloupce odstraněny ze `site_market_config` (V018). |
|
||||
| **Rozšířený horizont plánování 96h** (fáze 3a+3b+3c): tabulky `market_price_stats`, `tuv_usage_stats`, funkce `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price` (V022 + `R__fn_extended_planning.sql`), solver váhy 1,0 / 0,7 / 0,4, joby 14:45 / 00:45 v `main.py`. |
|
||||
| **Import OTE – robustní provoz:** timeouty + retry/backoff v `price_importer.py`, detailní error kódy v API, fallback D+1 → dnešek, scheduler importů 13:30 / 14:00 / 00:05. |
|
||||
| **Fail-safe bez OTE dat:** při predikovaných cenách v kritickém okně je zákaz exportu; vybíjení baterie omezeno jen v predikovaných slotech; runtime guard v `control_exporter.py` brání SELL v nejistém stavu. |
|
||||
| **Forecast provoz:** refresh každé 2 hodiny (`:05`), konfigurovatelný Open-Meteo horizont (`OPEN_METEO_FORECAST_DAYS`, default 7, clamp 2..16), endpoint pro UI vrací latest-run bez duplicit slotů. |
|
||||
| **Telemetry – výroba FVE:** `pv_power_w` je součet `pv1 + pv2 + gen_port`, takže dashboard reflektuje obě pole i GEN větev instalace home-01. |
|
||||
| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. |
|
||||
| **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. |
|
||||
|
||||
---
|
||||
|
||||
## Fáze 3d – rozšířený horizont (zbývá)
|
||||
|
||||
| Popis | Kde | Kdo |
|
||||
|-------|-----|-----|
|
||||
| **EV v rozšířeném horizontu** (pravděpodobnost příjezdu, deadline přes 96h) – závisí na Tesla API / rozšíření modelu. | `docs/04-modules/planning-extended-horizon.md` | programátor |
|
||||
| **Korekce predikce cen počasím** – potřeba 3+ měsíce korelačních dat. | stejný modul | programátor |
|
||||
|
||||
---
|
||||
|
||||
## Blokující – nutné před prvním reálným provozem
|
||||
|
||||
Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smysluplný forecast nebo dokončit kritická rozhodnutí před implementací řízení.
|
||||
|
||||
@@ -19,7 +43,10 @@ Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smyslu
|
||||
| **Rozhodnout Teltonika: OCPP 1.6 vs REST API** před implementací EV řízení a sběru. | `docs/06-open-questions.md` ř. 9–10; `docs/04-modules/consumption.md` ř. 184 | majitel + programátor |
|
||||
| **Doplnit přesné Modbus registry** (čtení i zápis) pro Deye, Teltonika, Samsung – bez mapy registrů nejde napsat funkční `telemetry_collector` / `control_exporter`. | `docs/04-modules/telemetry.md` ř. 63, 76–105 (tabulky TBD), 212–214; `docs/04-modules/heat-pump.md` ř. 79–85, 102; `docs/04-modules/control.md` ř. 249–251; pseudokód `TBD_*_REGISTER` ř. 166–171, 192–197; `docs/loxone-integration.md` ř. 259–261 | majitel dodá PDF/šablony → programátor; část ověření s **Loxone programátor** |
|
||||
| Ověřit **Modbus registr Output Power Limit** (curtailment pole A) na Deye SUN-20K. | `docs/04-modules/planning.md` ř. 422 | programátor (+ dokumentace od majitele) |
|
||||
| Doplnit **skutečnou výši zeleného bonusu** (`green_bonus_czk_kwh`) dle smlouvy – aktuálně placeholder. | `db/migration/V005__planning_curtailment.sql` ř. 45–50 | majitel (smlouva) → programátor |
|
||||
| Doplnit **skutečnou sazbu zeleného bonusu** do `asset_pv_array.green_bonus_czk_kwh` pro `pv-b` (aktuální placeholder: **7.135** Kč/kWh – ověřit ze smlouvy s EG.D). | `db/migration/V017__green_bonus.sql` (seed `pv-b`) | majitel (smlouva) → programátor |
|
||||
| Doplnit **`green_bonus_meter_code`** (EAN zeleného elektroměru) pro `pv-b` v `asset_pv_array`. | `db/migration/V017__green_bonus.sql` / přímá úprava DB | majitel → programátor |
|
||||
| Nastavit **`DISCORD_WEBHOOK_URL`** pro produkční alerty (Modbus mismatch, přepnutí SELF_SUSTAIN). | `.env` / `backend/app/config.py` | majitel → programátor |
|
||||
| **Cut-off přepínač** pro mikroinvertory (druhá instalace) – napojit logiku na `ems.cutoff_switch_log` a řízení. | `docs/04-modules/modbus-command-journal.md` | programátor |
|
||||
|
||||
---
|
||||
|
||||
@@ -41,7 +68,6 @@ Potřebné pro reálný, stabilní provoz; lze část EMS otestovat bez nich (na
|
||||
| **Přístup k logu** přepnutí watchdogu pro EMS po restartu. | `docs/loxone-integration.md` ř. 263 | Loxone programátor + programátor |
|
||||
| Implementace **Loxone watchdog** dle integračního dokumentu. | `docs/04-modules/operating-modes.md` ř. 131; celý `docs/loxone-integration.md` | Loxone programátor + programátor |
|
||||
| **Post-processing min_run/min_stop** TČ po výstupu LP (krátké ON/OFF). | `docs/04-modules/planning.md` ř. 419 | programátor |
|
||||
| **Zelený bonus** započítat do **auditního** výpočtu nákladů, ne jen do optimalizace. | `docs/04-modules/planning.md` ř. 420 | programátor |
|
||||
| **EV:** přesnější než agregát – sladit s `ev1_setpoint_w` / `ev2_setpoint_w` v DB a solveru. | `docs/04-modules/planning.md` ř. 421 | programátor |
|
||||
| **Test solveru** na reálných datech (výkon pro 36h / 144 slotů). | `docs/04-modules/planning.md` ř. 423 | programátor |
|
||||
| **Optimalizace čtení Deye** – jeden blok `read_holding_registers`. | `docs/04-modules/telemetry.md` ř. 216 | programátor |
|
||||
|
||||
@@ -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;
|
||||
|
||||
111
frontend/package-lock.json
generated
111
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
307
frontend/src/components/ControlPanel.tsx
Normal file
307
frontend/src/components/ControlPanel.tsx
Normal 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="Bit4–5: 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)
|
||||
77
frontend/src/components/ModeBar.tsx
Normal file
77
frontend/src/components/ModeBar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
64
frontend/src/components/NegPricePanel.tsx
Normal file
64
frontend/src/components/NegPricePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
frontend/src/components/NotificationBar.tsx
Normal file
140
frontend/src/components/NotificationBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
496
frontend/src/components/StatePanel.tsx
Normal file
496
frontend/src/components/StatePanel.tsx
Normal 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' }} />
|
||||
TČ běh
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StatePanel = memo(StatePanelRaw, (prev, next) => {
|
||||
return prev.slots === next.slots && prev.nowIndex === next.nowIndex
|
||||
})
|
||||
339
frontend/src/components/charts/EnergyChart.tsx
Normal file
339
frontend/src/components/charts/EnergyChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/charts/ForecastPanel.tsx
Normal file
37
frontend/src/components/charts/ForecastPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/charts/NegPricePanel.tsx
Normal file
51
frontend/src/components/charts/NegPricePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
frontend/src/components/charts/RegimeBar.tsx
Normal file
90
frontend/src/components/charts/RegimeBar.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
230
frontend/src/components/charts/SocTuvChart.tsx
Normal file
230
frontend/src/components/charts/SocTuvChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/charts/chartConstants.ts
Normal file
16
frontend/src/components/charts/chartConstants.ts
Normal 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)
|
||||
}
|
||||
200
frontend/src/components/charts/chartPlugins.ts
Normal file
200
frontend/src/components/charts/chartPlugins.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
520
frontend/src/hooks/useDashboardData.ts
Normal file
520
frontend/src/hooks/useDashboardData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
51
frontend/src/hooks/useLogSeverityBadge.ts
Normal file
51
frontend/src/hooks/useLogSeverityBadge.ts
Normal 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
|
||||
}
|
||||
45
frontend/src/hooks/useNotifications.ts
Normal file
45
frontend/src/hooks/useNotifications.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
31
frontend/src/hooks/useRollingReplanMinutes.ts
Normal file
31
frontend/src/hooks/useRollingReplanMinutes.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
49
frontend/src/hooks/useWsLogErrorCount.ts
Normal file
49
frontend/src/hooks/useWsLogErrorCount.ts
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
63
frontend/src/pages/Logs.tsx
Normal file
63
frontend/src/pages/Logs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 bit4–5=10 (grid PS off)`
|
||||
}
|
||||
if (is_charging) {
|
||||
return `⬆ ${fmtKw(battery_w)} | grid=yes | SOC→${targetSoc}%`
|
||||
}
|
||||
return '~ 2kW | hold'
|
||||
}
|
||||
|
||||
function tableRowClass(
|
||||
i: PlanningIntervalDto,
|
||||
selected: boolean,
|
||||
@@ -117,6 +295,8 @@ type ChartRow = {
|
||||
type PlanPrepActionsProps = {
|
||||
prepAction: null | 'import' | 'forecast' | 'init'
|
||||
replanning: boolean
|
||||
importDate: 'today' | 'tomorrow'
|
||||
onImportDateChange: (v: 'today' | 'tomorrow') => void
|
||||
onImport: () => void
|
||||
onForecast: () => void
|
||||
onInit: () => void
|
||||
@@ -126,6 +306,8 @@ type PlanPrepActionsProps = {
|
||||
function PlanPrepActions({
|
||||
prepAction,
|
||||
replanning,
|
||||
importDate,
|
||||
onImportDateChange,
|
||||
onImport,
|
||||
onForecast,
|
||||
onInit,
|
||||
@@ -148,6 +330,18 @@ function PlanPrepActions({
|
||||
)}
|
||||
Importovat ceny
|
||||
</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">Kč/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">TČ</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Náklady Kč</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Výnos Kč</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>
|
||||
|
||||
49
frontend/src/types/chart-js-ambient.d.ts
vendored
Normal file
49
frontend/src/types/chart-js-ambient.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
91
frontend/src/types/dashboard.ts
Normal file
91
frontend/src/types/dashboard.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user