Initial commit

Made-with: Cursor
This commit is contained in:
Dusan Vojacek
2026-03-20 13:27:37 +01:00
commit 8b4af663d8
77 changed files with 13337 additions and 0 deletions

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# ============================================================
# EMS Platform environment proměnné
# Zkopírovat jako .env a doplnit hodnoty
# NIKDY necommitovat .env do gitu!
# ============================================================
# ---- PostgreSQL ----
DB_USER=ems_user
DB_PASSWORD=change_me_strong_password
# ---- PostgREST ----
POSTGREST_JWT_SECRET=change_me_jwt_secret_min_32_chars
# Pro lokální dev může být stejná jako DB_USER (PostgREST SELECT pod ems_user).
POSTGREST_ANON_ROLE=ems_user
# ---- 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é
# ---- Weather / Forecast ----
OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast
# ---- Loxone ----
LOXONE_USER=admin
LOXONE_PASSWORD=change_me
# ---- Telemetrie ----
TELEMETRY_POLL_INTERVAL_SEC=60
# ---- Plánování ----
PLANNING_HORIZON_HOURS=36
PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ
PLANNING_CHEAP_PRICE_THRESHOLD=0.85
PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
.env
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
venv/
.venv/
node_modules/
dist/
*.tsbuildinfo

147
CLAUDE.md Normal file
View File

@@ -0,0 +1,147 @@
# CLAUDE.md EMS Platform (Cursor Agent)
Čti před každou implementační změnou. Stručná orientace; detail v `docs/` a SQL v `db/`.
---
## 1. Co to je
Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zátěž (EV, TČ) podle spotových cen OTE CZ a předpovědí; výstupy řídí zařízení (Modbus) a informuje Loxone jako exekutora. Referenční lokalita v seedu: `home-01` (Deye, baterie, 2× EV Teltonika, Samsung TČ).
---
## 2. Technologický stack
| Vrstva | Technologie |
|--------|-------------|
| DB | PostgreSQL 16 + TimescaleDB |
| Migrace | Flyway (`db/migration`, `db/routines`, `db/views`) |
| API | PostgREST (REST ze schématu `ems`) + FastAPI (logika, joby plán v docs) |
| Frontend | React + TypeScript + Vite (očekáváno u kořene / Docker) |
| Pole / zařízení | Modbus TCP (`pymodbus`), HTTP (Loxone, případně API vozidel) |
| Solver | PuLP + HiGHS (`HiGHS_CMD`) |
| Runtime | Docker Compose |
---
## 3. Adresářová struktura
| Cesta | Účel |
|-------|------|
| `CLAUDE.md`, `.env.example`, `docker-compose.yml` | Kořen: pravidla, env šablona, compose |
| `docs/` | Produktová a technická specifikace (overview, architektura, datový model, integrace) |
| `docs/04-modules/` | Modulové specifikace (ceny, forecast, spotřeba, TČ, telemetrie, řízení, plánování, režimy, EV) |
| `docs/loxone-integration.md` | Loxone watchdog, heartbeat, role exekutora |
| `docs/06-open-questions.md` | Nedokončené rozhodnutí doplňovat místo hádání |
| `db/migration/` | Flyway versioned migrace `V00x__*.sql` (schéma, seed, alter) |
| `db/routines/` | Repeatable SQL: funkce `ems.fn_*` |
| `db/views/` | Repeatable SQL: view `ems.vw_*` |
| `backend/services/` | Python služby (v repozitáři zatím hlavně plánování) |
---
## 4. Pravidla NIKDY neporušovat
1. **15min logika pro plán/ceny/baseline/audit/forecast intervaly.** Časové řady v těchto doménách = 15min sloty. Telemetrie zařízení je 1min (hypertables) agregace do 15min přes SQL/job, ne ukládat „hodinové“ řádky jako primární plánovací záznam.
2. **Všechny doménové záznamy vázat na `site_id`** (telemetrie, plány, audit, konfigurace aktiv, session, …). Výjimka: `market_interval_price` je globální pro zdroj/trh; vazba na site je přes konfiguraci a view.
3. **Raw ceny ≠ efektivní ceny.** `ems.market_interval_price` = bez marží. Efektivní nákup/prodej jen přes `ems.vw_site_effective_price` (join na platnou `site_market_config`).
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`).
6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint).
7. **Záporná nákupní cena → omezit import** na realistický horní strop (viz `solve_dispatch` v `planning_engine.py` nesmí „nekonečný“ import).
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`.
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`.
---
## 5. Schéma `ems` tabulky (jedna věta)
| Tabulka | Popis |
|---------|--------|
| `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_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). |
| `site_operating_mode_log` | Historie přepnutí režimů. |
| `site_heartbeat` | Poslední EMS heartbeat pro dashboard (ne pro Loxone watchdog). |
| `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_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). |
| `market_interval_price` | Raw spot OTE (15min), bez marží. |
| `telemetry_inverter` | 1min telemetrie střídače (Timescale). |
| `telemetry_ev_charger` | 1min telemetrie nabíječky (Timescale). |
| `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_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). |
| `ev_session` | Nabíjecí session na WB (deadline, energie, náklady). |
**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`.
---
## 6. Periodické úlohy backendu (APScheduler / smyčky)
Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `planning_engine.py`. **V gitu je zatím rozpracovaný backend** joby mají být v `backend/app/main.py` (zatím často chybí).
| Úloha | Frekvence | Poznámka |
|-------|-----------|----------|
| `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_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` |
| `audit_filler` / `fn_fill_audit_interval` | **každých 15 min** | `docs/02-architecture.md`, DB `fn_fill_audit_interval` |
---
## 7. Kde hledat co
| Chci… | Kam |
|-------|-----|
| Pochopit systém end-to-end | `docs/01-overview.md`, `docs/02-architecture.md` |
| Tabulky, vazby, jednotky | `docs/03-data-model.md` |
| OTE ceny, marže, efektivní cena | `docs/04-modules/market-prices.md`, `db/views/R__vw_site_effective_price.sql` |
| FVE forecast, počasí | `docs/04-modules/forecast.md` |
| 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` |
| 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` |
| 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` |
| Rolling plán, forecast log | `db/migration/V007__rolling_replanning.sql` |
| Audit 15min | `db/routines/R__fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` |
| Nové sloupce / tabulky | nový `db/migration/V00x__*.sql` + případně `db/routines` / `db/views` |
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |
---
## Konvence (krátce)
- 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)**.

13
backend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# EMS Platform FastAPI application

47
backend/app/config.py Normal file
View File

@@ -0,0 +1,47 @@
"""Application settings loaded from environment (see .env.example)."""
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
db_host: str = Field(default="localhost")
db_port: int = Field(default=5432)
db_name: str = Field(default="ems")
db_user: str = Field(default="ems_user")
db_password: str = Field(default="")
database_url: str | None = Field(default=None)
postgrest_jwt_secret: str = Field(default="")
postgrest_anon_role: str = Field(default="ems_user")
ote_api_url: str = Field(
default="https://www.ote-cr.cz/pubapi/v1/market-data/dam",
)
eur_czk_rate: float = Field(default=25.0)
open_meteo_api_url: str = Field(
default="https://api.open-meteo.com/v1/forecast",
)
loxone_user: str = Field(default="")
loxone_password: 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)
planning_cheap_price_threshold: float = Field(default=0.85)
planning_expensive_price_threshold: float = Field(default=1.15)
@lru_cache
def get_settings() -> Settings:
return Settings()

48
backend/app/database.py Normal file
View File

@@ -0,0 +1,48 @@
"""asyncpg connection pool and DB access helpers."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import asyncpg
from app.config import get_settings
_pool: asyncpg.Pool | None = None
async def init_db_pool() -> None:
"""Create global pool (call from FastAPI lifespan)."""
global _pool
if _pool is not None:
return
s = get_settings()
_pool = await asyncpg.create_pool(
host=s.db_host,
port=s.db_port,
user=s.db_user,
password=s.db_password,
database=s.db_name,
min_size=1,
max_size=10,
)
async def close_db_pool() -> None:
global _pool
if _pool is not None:
await _pool.close()
_pool = None
def get_pool() -> asyncpg.Pool:
if _pool is None:
raise RuntimeError("DB pool not initialized; call init_db_pool() first")
return _pool
@asynccontextmanager
async def get_db() -> AsyncIterator[asyncpg.Connection]:
"""Async context manager yielding a connection from the pool."""
pool = get_pool()
async with pool.acquire() as conn:
yield conn

19
backend/app/deps.py Normal file
View File

@@ -0,0 +1,19 @@
"""Sdílené FastAPI závislosti (DB pool)."""
from __future__ import annotations
import asyncpg
from fastapi import HTTPException
_pg_pool: asyncpg.Pool | None = None
def set_pg_pool(pool: asyncpg.Pool | None) -> None:
global _pg_pool
_pg_pool = pool
async def get_pg_pool() -> asyncpg.Pool:
if _pg_pool is None:
raise HTTPException(status_code=503, detail="Database pool not ready")
return _pg_pool

157
backend/app/main.py Normal file
View File

@@ -0,0 +1,157 @@
"""EMS FastAPI health, provozní režimy, PostgREST doplňky."""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Annotated
import asyncpg
import httpx
from app.deps import set_pg_pool
from app.routers.plan import router as plan_router
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
def _dsn() -> str:
host = os.getenv("DB_HOST", "localhost")
port = os.getenv("DB_PORT", "5432")
name = os.getenv("DB_NAME", "ems")
user = os.getenv("DB_USER", "ems_user")
password = os.getenv("DB_PASSWORD", "")
return f"postgresql://{user}:{password}@{host}:{port}/{name}"
pool: asyncpg.Pool | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5)
set_pg_pool(pool)
yield
set_pg_pool(None)
if pool:
await pool.close()
pool = None
app = FastAPI(title="EMS Platform", lifespan=lifespan)
app.include_router(plan_router, prefix="/api/v1")
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
async def get_pool() -> asyncpg.Pool:
if pool is None:
raise HTTPException(status_code=503, detail="Database pool not ready")
return pool
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
class SetSiteModeBody(BaseModel):
mode: str = Field(..., min_length=1)
notes: str | None = None
valid_until: datetime | None = None
class SetSiteModeResponse(BaseModel):
success: bool
mode: str
activated_at: datetime
@app.post("/api/v1/sites/{site_id}/mode", response_model=SetSiteModeResponse)
async def set_site_mode(
site_id: int,
body: SetSiteModeBody,
db: Annotated[asyncpg.Pool, Depends(get_pool)],
) -> SetSiteModeResponse:
mode = body.mode.strip().upper()
allowed = {"AUTO", "SELF_SUSTAIN", "CHARGE_CHEAP", "PRESERVE", "MANUAL"}
if mode not in allowed:
raise HTTPException(status_code=400, detail=f"Unsupported mode: {body.mode}")
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:
await conn.execute(
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
site_id,
mode,
"user:api",
body.valid_until,
body.notes,
)
except asyncpg.PostgresError as e:
logger.warning("fn_set_mode failed: %s", e)
raise HTTPException(status_code=400, detail=str(e)) from e
row = await conn.fetchrow(
"""
SELECT m.mode_code, m.activated_at, d.loxone_mode_value
FROM ems.site_operating_mode m
JOIN ems.operating_mode_def d ON d.code = m.mode_code
WHERE m.site_id = $1
""",
site_id,
)
if row is None:
raise HTTPException(status_code=500, detail="Mode row missing after set")
ep = await conn.fetchrow(
"""
SELECT host, port, protocol
FROM ems.site_endpoint
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
ORDER BY id
LIMIT 1
""",
site_id,
)
activated_at: datetime = row["activated_at"]
if activated_at.tzinfo is None:
activated_at = activated_at.replace(tzinfo=timezone.utc)
loxone_val: int | None = row["loxone_mode_value"]
if ep and loxone_val is not None:
proto = (ep["protocol"] or "http").lower()
if proto not in ("http", "https"):
proto = "http"
host = ep["host"]
port = int(ep["port"] or (443 if proto == "https" else 80))
base = f"{proto}://{host}:{port}"
url = f"{base}/dev/sps/io/EMS_Mode/{loxone_val}"
user = os.getenv("LOXONE_USER") or ""
password = os.getenv("LOXONE_PASSWORD") or ""
auth = (user, password) if user else None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(url, auth=auth)
r.raise_for_status()
except Exception as e:
logger.warning("Loxone EMS_Mode notify failed for site %s: %s", site_id, e)
return SetSiteModeResponse(success=True, mode=row["mode_code"], activated_at=activated_at)

View File

@@ -0,0 +1 @@
"""FastAPI routers."""

237
backend/app/routers/plan.py Normal file
View File

@@ -0,0 +1,237 @@
"""REST API aktivní plán a ruční přepočet."""
from datetime import datetime
from typing import Annotated, Any, Literal
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app.deps import get_pg_pool
from services.planning_engine import run_plan_api
router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"])
class PlanningRunOut(BaseModel):
id: int
created_at: datetime
run_type: str
horizon_start: datetime
horizon_end: datetime
forecast_correction_factor: float | None = None
solver_duration_ms: int | None = None
class PlanningIntervalOut(BaseModel):
interval_start: datetime
battery_setpoint_w: int | None = None
battery_soc_target_pct: float | None = None
grid_setpoint_w: int | None = None
ev1_setpoint_w: int | None = None
ev2_setpoint_w: int | None = None
heat_pump_enabled: bool | None = None
pv_a_curtailed_w: int | None = None
expected_cost_czk: float | None = None
effective_buy_price: float | None = None
effective_sell_price: float | None = None
pv_forecast_total_w: int | None = Field(
default=None,
description="Součet FVE forecast A+B pro graf (k aktuálnímu slotu z DB).",
)
load_baseline_w: int | None = Field(
default=None,
description="Bazální spotřeba forecast pro graf.",
)
class PlanningSummaryOut(BaseModel):
total_expected_cost_czk: float
total_pv_curtailed_kwh: float
charge_slots: int
discharge_slots: int
export_slots: int
class CurrentPlanResponse(BaseModel):
run: PlanningRunOut | None
intervals: list[PlanningIntervalOut]
summary: PlanningSummaryOut | None
class RunPlanResponse(BaseModel):
run_id: int
solver_duration_ms: int
def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
total_cost = 0.0
curtailed_wh = 0.0
charge_slots = 0
discharge_slots = 0
export_slots = 0
for row in intervals:
ec = row.get("expected_cost_czk")
if ec is not None:
total_cost += float(ec)
c = row.get("pv_a_curtailed_w") or 0
curtailed_wh += int(c) * 0.25
b = row.get("battery_setpoint_w")
if b is not None:
if int(b) > 0:
charge_slots += 1
elif int(b) < 0:
discharge_slots += 1
g = row.get("grid_setpoint_w")
if g is not None and int(g) < 0:
export_slots += 1
return PlanningSummaryOut(
total_expected_cost_czk=round(total_cost, 4),
total_pv_curtailed_kwh=round(curtailed_wh / 1000.0, 6),
charge_slots=charge_slots,
discharge_slots=discharge_slots,
export_slots=export_slots,
)
@router.get("/current", response_model=CurrentPlanResponse)
async def get_current_plan(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> CurrentPlanResponse:
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
if not exists:
raise HTTPException(status_code=404, detail="Site not found")
run_row = await conn.fetchrow(
"""
SELECT id, created_at, run_type, horizon_start, horizon_end,
forecast_correction_factor, solver_duration_ms
FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
if not run_row:
return CurrentPlanResponse(run=None, intervals=[], summary=None)
run_id = run_row["id"]
int_rows = await conn.fetch(
"""
SELECT
pi.interval_start,
pi.battery_setpoint_w,
pi.battery_soc_target_pct,
pi.grid_setpoint_w,
pi.ev1_setpoint_w,
pi.ev2_setpoint_w,
pi.heat_pump_enabled,
pi.pv_a_curtailed_w,
pi.expected_cost_czk,
pi.effective_buy_price,
pi.effective_sell_price,
COALESCE(fa.power_w, 0) + COALESCE(fb.power_w, 0) AS pv_forecast_total_w,
COALESCE(cbi.power_w, 500) AS load_baseline_w
FROM ems.planning_interval pi
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 = $2
AND apa.code = 'pv-a'
AND fpi.interval_start = pi.interval_start
AND fpr.status = 'ok'
ORDER BY fpr.created_at DESC
LIMIT 1
) fa ON true
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 = $2
AND apa.code = 'pv-b'
AND fpi.interval_start = pi.interval_start
AND fpr.status = 'ok'
ORDER BY fpr.created_at DESC
LIMIT 1
) fb ON true
LEFT JOIN ems.consumption_baseline_interval cbi
ON cbi.site_id = $2
AND cbi.interval_start = pi.interval_start
AND cbi.data_type = 'forecast'
WHERE pi.run_id = $1
ORDER BY pi.interval_start
""",
run_id,
site_id,
)
intervals_dicts = [dict(r) for r in int_rows]
summary = _build_summary(intervals_dicts) if intervals_dicts else None
run_out = PlanningRunOut(
id=run_row["id"],
created_at=run_row["created_at"],
run_type=run_row["run_type"],
horizon_start=run_row["horizon_start"],
horizon_end=run_row["horizon_end"],
forecast_correction_factor=float(run_row["forecast_correction_factor"])
if run_row["forecast_correction_factor"] is not None
else None,
solver_duration_ms=run_row["solver_duration_ms"],
)
intervals_out = [
PlanningIntervalOut(
interval_start=r["interval_start"],
battery_setpoint_w=r["battery_setpoint_w"],
battery_soc_target_pct=float(r["battery_soc_target_pct"])
if r["battery_soc_target_pct"] is not None
else None,
grid_setpoint_w=r["grid_setpoint_w"],
ev1_setpoint_w=r["ev1_setpoint_w"],
ev2_setpoint_w=r["ev2_setpoint_w"],
heat_pump_enabled=r["heat_pump_enabled"],
pv_a_curtailed_w=r["pv_a_curtailed_w"],
expected_cost_czk=float(r["expected_cost_czk"])
if r["expected_cost_czk"] is not None
else None,
effective_buy_price=float(r["effective_buy_price"])
if r["effective_buy_price"] is not None
else None,
effective_sell_price=float(r["effective_sell_price"])
if r["effective_sell_price"] is not None
else None,
pv_forecast_total_w=int(r["pv_forecast_total_w"] or 0),
load_baseline_w=int(r["load_baseline_w"] or 0),
)
for r in intervals_dicts
]
return CurrentPlanResponse(run=run_out, intervals=intervals_out, summary=summary)
@router.post("/run", response_model=RunPlanResponse)
async def post_run_plan(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
plan_type: Literal["daily", "rolling"] = Query(..., alias="type"),
) -> RunPlanResponse:
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
if not exists:
raise HTTPException(status_code=404, detail="Site not found")
try:
run_id, duration_ms = await run_plan_api(
site_id, conn, plan_type=plan_type, 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=500, detail=str(e)) from e
return RunPlanResponse(run_id=run_id, solver_duration_ms=duration_ms)

14
backend/requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
asyncpg>=0.30.0
python-dotenv>=1.0.0
pydantic-settings>=2.6.0
apscheduler>=3.10.4
pymodbus>=3.8.0
aiohttp>=3.11.0
pulp>=2.9.0
highspy>=1.7.0
pvlib>=0.11.0
pandas>=2.2.0
numpy>=2.0.0
httpx>=0.28.0

View File

@@ -0,0 +1 @@
# Background services

View File

@@ -0,0 +1,817 @@
# backend/services/planning_engine.py
#
# EMS Platform plánovací engine
# Obsahuje: hlavní denní plán + rolling 15min replan
#
# Spouštění (APScheduler v main.py):
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
import time
import logging
from dataclasses import dataclass, replace
from datetime import datetime, timezone, timedelta
from types import SimpleNamespace
from typing import Optional
import pulp
from pulp import HiGHS_CMD
logger = logging.getLogger(__name__)
# ============================================================
# Konstanty
# ============================================================
HORIZON_HOURS = 36 # horizont denního plánu
INTERVAL_H = 0.25 # 15 minut v hodinách
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
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
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
# ============================================================
# Datové třídy (lze nahradit pydantic modely)
# ============================================================
@dataclass
class PlanningSlot:
interval_start: datetime
buy_price: float # Kč/kWh
sell_price: float # Kč/kWh
pv_a_forecast_w: int # W pole A (řiditelné)
pv_b_forecast_w: int # W pole B (zelený bonus, pevné)
load_baseline_w: int # W predikce bazální spotřeby
ev1_connected: bool
ev2_connected: bool
@dataclass
class DispatchResult:
interval_start: datetime
battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení
battery_soc_target: float # % SoC na konci intervalu
grid_setpoint_w: int # kladné = import, záporné = export
ev1_setpoint_w: Optional[int]
ev2_setpoint_w: Optional[int]
ev1_via_bat_w: int
ev2_via_bat_w: int
heat_pump_enabled: bool
heat_pump_setpoint_w: int
pv_a_curtailed_w: int
expected_cost_czk: float
effective_buy_price: float
effective_sell_price: float
# ============================================================
# Korekce forecastu na základě skutečné výroby
# ============================================================
async def compute_correction_factor(
site_id: int,
now: datetime,
db,
window_h: float = CORRECTION_WINDOW_H,
) -> tuple[float, dict]:
"""
Spočítá korekční faktor FVE forecastu z posledních window_h hodin.
Vrátí (factor, log_data) kde factor je v rozsahu [CORRECTION_MIN_CLAMP, CORRECTION_MAX_CLAMP].
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
"""
window_start = now - timedelta(hours=window_h)
# Skutečná výroba za okno (z telemetrie)
actual = await db.fetchval("""
SELECT COALESCE(SUM(pv_power_w) * 0.25 / 1000.0, 0) -- kWh
FROM ems.telemetry_inverter
WHERE site_id = $1
AND measured_at >= $2 AND measured_at < $3
""", site_id, window_start, now)
# Předpovídaná výroba za stejné okno (z nejnovějšího forecastu který platil tehdy)
forecast = await db.fetchval("""
SELECT COALESCE(SUM(fpi.power_w) * 0.25 / 1000.0, 0)
FROM ems.forecast_pv_interval fpi
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
WHERE fpr.site_id = $1
AND fpi.interval_start >= $2 AND fpi.interval_start < $3
AND fpr.status = 'ok'
AND fpr.created_at = (
SELECT MAX(fpr2.created_at)
FROM ems.forecast_pv_run fpr2
WHERE fpr2.site_id = $1 AND fpr2.status = 'ok'
AND fpr2.created_at <= $2
)
""", site_id, window_start, now)
log_data = {
"window_start": window_start,
"window_end": now,
"actual_pv_wh": actual * 1000,
"forecast_pv_wh": forecast * 1000,
}
# Pokud forecast nebo actual jsou příliš malé (noc, <0.1 kWh) → žádná korekce
if forecast < 0.1 or actual < 0.05:
log_data["correction_factor"] = 1.0
log_data["reason"] = "insufficient_data"
return 1.0, log_data
raw_factor = actual / forecast
factor = max(CORRECTION_MIN_CLAMP, min(CORRECTION_MAX_CLAMP, raw_factor))
log_data["correction_factor"] = factor
log_data["raw_factor"] = raw_factor
return factor, log_data
def apply_forecast_correction(
slots: list[PlanningSlot],
now: datetime,
factor: float,
decay_slots: int = CORRECTION_DECAY_SLOTS,
) -> list[PlanningSlot]:
"""
Aplikuje korekční faktor na FVE forecast zbývajících slotů.
Korekce se lineárně utlumuje: na 1. slotu plná korekce,
na decay_slots-tém slotu žádná korekce.
Příklad: factor=0.85, slot 0 → pv_a *= 0.85, slot 8 → pv_a *= 0.925, slot 16+ → žádná korekce
"""
corrected = []
for i, slot in enumerate(slots):
if factor == 1.0 or i >= decay_slots:
corrected.append(slot)
continue
# Lineární útlum: weight klesá od 1.0 (slot 0) do 0.0 (slot decay_slots)
weight = 1.0 - (i / decay_slots)
effective_factor = 1.0 + (factor - 1.0) * weight
corrected.append(
replace(
slot,
pv_a_forecast_w=max(0, int(slot.pv_a_forecast_w * effective_factor)),
pv_b_forecast_w=max(0, int(slot.pv_b_forecast_w * effective_factor)),
)
)
return corrected
# ============================================================
# LP Solver
# ============================================================
def solve_dispatch(
slots: list[PlanningSlot],
battery,
heat_pump,
grid,
ev_sessions: list, # aktivní EV sessions [ev1_session, ev2_session]
vehicles: list, # [vehicle1, vehicle2]
current_soc_wh: float,
current_tuv_temp_c: float,
) -> tuple[list[DispatchResult], int]:
"""
LP solver pro dispatch optimalizaci.
Vrátí (výsledky, solver_duration_ms).
"""
T = len(slots)
EV = len(vehicles) # počet EV (typicky 2)
EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)
# --- Proměnné ---
gi = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w) for t in range(T)]
ge = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)]
bc = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)]
bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)]
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)]
# EV proměnné per vozidlo
ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0,
min(vehicles[e].max_charge_power_w, grid.max_import_power_w))
for t in range(T)] for e in range(EV)]
ev_via_bat = [[pulp.LpVariable(f"evb_{e}_{t}", 0,
vehicles[e].max_charge_power_w)
for t in range(T)] for e in range(EV)]
# --- Účelová funkce ---
prob += pulp.lpSum(
gi[t] * slots[t].buy_price * INTERVAL_H / 1000
- ge[t] * slots[t].sell_price * INTERVAL_H / 1000
+ (bc[t] + bd[t]) * battery.degradation_cost_czk_kwh * INTERVAL_H / 1000
+ pulp.lpSum(
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000
+ ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000
for e in range(EV)
)
+ ca[t] * CURTAILMENT_PENALTY
for t in range(T)
)
# --- Omezení ---
for t in range(T):
s = slots[t]
pv_a_net = s.pv_a_forecast_w - ca[t]
ev_total_t = pulp.lpSum(ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV))
# Energetická bilance
prob += (
pv_a_net + s.pv_b_forecast_w + gi[t] + bd[t]
== s.load_baseline_w + ev_total_t + hp[t] + bc[t] + ge[t]
)
# SoC kontinuita
soc_prev = current_soc_wh if t == 0 else soc[t - 1]
prob += soc[t] == (
soc_prev
+ bc[t] * battery.charge_efficiency * INTERVAL_H
- bd[t] / battery.discharge_efficiency * INTERVAL_H
)
# ev_via_bat kryto z discharge
prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t]
# Záporná prodejní cena → zakázat export
if s.sell_price < 0:
prob += ge[t] == 0
# Záporná nákupní cena → cap import na reálnou spotřebu
if s.buy_price < 0:
prob += gi[t] <= (
battery.max_charge_power_w
+ sum(v.max_charge_power_w for v in vehicles)
+ heat_pump.rated_heating_power_w
)
# EV limity a připojení
for e in range(EV):
connected = (
(e == 0 and s.ev1_connected) or
(e == 1 and s.ev2_connected)
)
if not connected:
prob += ev_direct[e][t] == 0
prob += ev_via_bat[e][t] == 0
else:
prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w
# Deadline constraints pro EV
for e, session in enumerate(ev_sessions):
if session and session.target_deadline and session.energy_needed_wh > 0:
t_dl = next(
(t for t, s in enumerate(slots) if s.interval_start >= session.target_deadline),
T - 1
)
prob += pulp.lpSum(
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
for t in range(t_dl + 1)
if (e == 0 and slots[t].ev1_connected) or (e == 1 and slots[t].ev2_connected)
) >= session.energy_needed_wh
# 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í ---
t_start = time.monotonic()
solver = HiGHS_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
status = prob.solve(solver)
duration_ms = int((time.monotonic() - t_start) * 1000)
if pulp.LpStatus[status] != 'Optimal':
raise RuntimeError(f"Solver: {pulp.LpStatus[status]}")
# --- Post-processing ---
results = []
for t in range(T):
hp_raw = pulp.value(hp[t])
hp_on = hp_raw > heat_pump.rated_heating_power_w * 0.3
batt_w = round(pulp.value(bc[t]) - pulp.value(bd[t]))
grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t]))
soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1)
cost = (
pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000
- pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000
)
results.append(DispatchResult(
interval_start = slots[t].interval_start,
battery_setpoint_w = batt_w,
battery_soc_target = soc_pct,
grid_setpoint_w = grid_w,
ev1_setpoint_w = round(pulp.value(ev_direct[0][t]) + pulp.value(ev_via_bat[0][t]))
if slots[t].ev1_connected else None,
ev2_setpoint_w = round(pulp.value(ev_direct[1][t]) + pulp.value(ev_via_bat[1][t]))
if slots[t].ev2_connected else None,
ev1_via_bat_w = round(pulp.value(ev_via_bat[0][t])),
ev2_via_bat_w = round(pulp.value(ev_via_bat[1][t])),
heat_pump_enabled = hp_on,
heat_pump_setpoint_w = heat_pump.rated_heating_power_w if hp_on else 0,
pv_a_curtailed_w = round(pulp.value(ca[t])),
expected_cost_czk = round(cost, 4),
effective_buy_price = slots[t].buy_price,
effective_sell_price = slots[t].sell_price,
))
return results, duration_ms
# ============================================================
# Denní plán (15:00)
# ============================================================
async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily") -> tuple[int, int]:
"""
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.
"""
now = datetime.now(timezone.utc)
horizon_from = _current_slot_start(now)
horizon_to = horizon_from + timedelta(hours=HORIZON_HOURS)
logger.info(f"[site={site_id}] Daily plan: {horizon_from}{horizon_to}")
slots = await _load_slots(site_id, horizon_from, horizon_to, db)
if not slots:
raise RuntimeError(f"No planning slots for site_id={site_id} (prices/forecast horizon?)")
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp = await _load_site_context(
site_id, db
)
results, duration_ms = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp
)
run_id = await _save_planning_run(
site_id,
results,
horizon_from,
horizon_to,
run_type="daily",
triggered_by=triggered_by,
replan_from=None,
soc_wh=soc_wh,
duration_ms=duration_ms,
correction=1.0,
db=db,
)
logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
return run_id, duration_ms
# ============================================================
# Rolling replan (každých 15min)
# ============================================================
async def run_rolling_replan(
site_id: int,
db,
*,
triggered_by: str = "scheduler:rolling",
allow_skip: bool = True,
) -> tuple[Optional[int], Optional[int]]:
"""
Rolling replan každých 15 minut.
1. Zjistí aktuální SoC baterie z telemetrie
2. Spočítá korekční faktor FVE forecastu z poslední hodiny
3. Aplikuje korekci na forecast zbytku dne (s útlumem)
4. Spustí solver pro zbývající horizont aktivního plánu
5. Uloží jako nový planning_run (aktivní plán se stane superseded)
Pokud allow_skip=True (scheduler) a horizont je vyčerpaný → vrátí (None, None).
Pokud allow_skip=False (API) → spustí denní plán jako náhradu.
"""
now = datetime.now(timezone.utc)
replan_from = _current_slot_start(now)
active_run = await db.fetchrow("""
SELECT id, horizon_end FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC LIMIT 1
""", site_id)
if not active_run:
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
horizon_to = active_run["horizon_end"]
if (horizon_to - replan_from).total_seconds() < 1800:
if allow_skip:
logger.info(f"[site={site_id}] Rolling replan: horizon almost exhausted, skipping")
return None, None
logger.info(f"[site={site_id}] Rolling replan: horizon exhausted, running daily plan")
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
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
)
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
slots = await _load_slots(site_id, replan_from, horizon_to, db)
if not slots:
logger.warning(f"[site={site_id}] Rolling replan: no slots, running daily plan")
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
slots = apply_forecast_correction(slots, now, correction_factor)
results, duration_ms = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp
)
run_id = await _save_planning_run(
site_id,
results,
replan_from,
horizon_to,
run_type="rolling",
triggered_by=triggered_by,
replan_from=replan_from,
soc_wh=soc_wh,
duration_ms=duration_ms,
correction=correction_factor,
db=db,
)
await db.execute(
"""
INSERT INTO ems.forecast_correction_log
(site_id, window_start, window_end, actual_pv_wh, forecast_pv_wh,
correction_factor, applied_to_run_id)
VALUES ($1,$2,$3,$4,$5,$6,$7)
""",
site_id,
correction_log["window_start"],
correction_log["window_end"],
correction_log.get("actual_pv_wh"),
correction_log.get("forecast_pv_wh"),
correction_factor,
run_id,
)
logger.info(
f"[site={site_id}] Rolling replan done in {duration_ms} ms "
f"(correction={correction_factor:.3f})"
)
return run_id, duration_ms
async def run_plan_api(site_id: int, db, plan_type: str, triggered_by: str = "api") -> tuple[int, int]:
"""Ruční / UI spuštění plánu. Vždy vrátí (run_id, solver_duration_ms)."""
pt = plan_type.lower().strip()
if pt == "daily":
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
if pt == "rolling":
rid, ms = await run_rolling_replan(
site_id, db, triggered_by=triggered_by, allow_skip=False
)
if rid is None or ms is None:
raise RuntimeError("Rolling replan did not return a run")
return rid, ms
raise ValueError(f"Unknown plan_type: {plan_type!r} (use daily or rolling)")
# ============================================================
# Pomocné funkce
# ============================================================
def _current_slot_start(dt: datetime) -> datetime:
"""Zaokrouhlí čas dolů na začátek aktuálního 15min slotu."""
minute = (dt.minute // 15) * 15
return dt.replace(minute=minute, second=0, microsecond=0)
def _ev_session_ctx(row) -> Optional[SimpleNamespace]:
"""Kontext deadline constraintu pro jedno EV (nebo None)."""
if row is None or row["target_deadline"] is None:
return None
cap_kwh = row["veh_cap_kwh"]
if cap_kwh is None:
return None
cap_wh = float(cap_kwh) * 1000.0
tgt = row["target_soc_pct"]
if tgt is None:
tgt = row["default_target_soc_pct"]
if tgt is None:
return None
tgt_f = float(tgt)
soc0 = row["soc_at_connect_pct"]
if soc0 is None:
return None
needed_wh = (tgt_f - float(soc0)) / 100.0 * cap_wh
delivered = float(row["energy_delivered_wh"] or 0)
remaining = max(0.0, needed_wh - delivered)
if remaining <= 0:
return None
return SimpleNamespace(
target_deadline=row["target_deadline"],
energy_needed_wh=remaining,
)
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.
"""
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
LIMIT 1
""",
site_id,
)
if brow is None:
raise RuntimeError(f"No asset_battery for site_id={site_id}")
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
battery = SimpleNamespace(
usable_capacity_wh=uc,
reserve_soc_wh=reserve_wh,
soc_max_wh=soc_max_wh,
charge_efficiency=float(brow["charge_efficiency"]),
discharge_efficiency=float(brow["discharge_efficiency"]),
degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]),
max_charge_power_w=int(brow["max_charge_power_w"]),
max_discharge_power_w=int(brow["max_discharge_power_w"]),
)
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
FROM ems.asset_heat_pump
WHERE site_id = $1
ORDER BY id
LIMIT 1
""",
site_id,
)
if hrow is None:
heat_pump = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=0.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"]),
)
grow = await db.fetchrow(
"""
SELECT max_import_power_w, max_export_power_w
FROM ems.site_grid_connection
WHERE site_id = $1
ORDER BY id
LIMIT 1
""",
site_id,
)
if grow is None:
raise RuntimeError(f"No site_grid_connection for site_id={site_id}")
grid = SimpleNamespace(
max_import_power_w=int(grow["max_import_power_w"]),
max_export_power_w=int(grow["max_export_power_w"]),
)
vrows = await db.fetch(
"""
SELECT v.battery_capacity_kwh,
v.max_charge_power_w,
v.default_target_soc_pct,
ch.code AS charger_code
FROM ems.asset_vehicle v
JOIN ems.asset_ev_charger ch ON ch.id = v.default_charger_id
WHERE v.site_id = $1
AND ch.code IN ('ev-charger-1', 'ev-charger-2')
ORDER BY ch.code
""",
site_id,
)
vehicles: list[SimpleNamespace] = [
SimpleNamespace(
max_charge_power_w=int(r["max_charge_power_w"]),
battery_capacity_kwh=float(r["battery_capacity_kwh"]),
default_target_soc_pct=float(r["default_target_soc_pct"]),
)
for r in vrows
]
while len(vehicles) < 2:
vehicles.append(
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
)
)
srows = await db.fetch(
"""
SELECT es.target_deadline,
es.target_soc_pct,
es.soc_at_connect_pct,
es.energy_delivered_wh,
ch.code AS charger_code,
v.battery_capacity_kwh AS veh_cap_kwh,
v.default_target_soc_pct
FROM ems.ev_session es
JOIN ems.asset_ev_charger ch ON ch.id = es.charger_id
LEFT JOIN ems.asset_vehicle v ON v.id = es.vehicle_id
WHERE es.site_id = $1
AND es.session_end IS NULL
""",
site_id,
)
by_charger = {r["charger_code"]: r for r in srows}
ev_sessions = [
_ev_session_ctx(by_charger.get("ev-charger-1")),
_ev_session_ctx(by_charger.get("ev-charger-2")),
]
soc_pct = await db.fetchval(
"""
SELECT battery_soc_percent
FROM ems.telemetry_inverter
WHERE site_id = $1
ORDER BY measured_at DESC
LIMIT 1
""",
site_id,
)
if soc_pct is None:
soc_wh = reserve_wh
else:
soc_wh = float(soc_pct) / 100.0 * uc
soc_wh = max(reserve_wh, min(soc_wh, soc_max_wh))
tuv = await db.fetchval(
"""
SELECT tuv_tank_temp_c
FROM ems.telemetry_heat_pump
WHERE site_id = $1
ORDER BY measured_at DESC
LIMIT 1
""",
site_id,
)
tuv_temp = float(tuv) if tuv is not None else 50.0
return battery, heat_pump, grid, vehicles, ev_sessions, soc_wh, tuv_temp
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."""
rows = await db.fetch("""
SELECT
ep.interval_start,
ep.effective_buy_price_czk_kwh AS buy_price,
ep.effective_sell_price_czk_kwh AS sell_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 aktuálního stavu nabíječek
(ev1.status NOT IN ('available', 'unavailable')) AS ev1_connected,
(ev2.status NOT IN ('available', 'unavailable')) AS ev2_connected
FROM ems.vw_site_effective_price ep
-- FVE pole A 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-a'
AND fpi.interval_start = ep.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'
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
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
WHERE t.site_id = $1 AND ch.code = 'ev-charger-1'
ORDER BY t.measured_at DESC LIMIT 1
) ev1 ON true
LEFT JOIN LATERAL (
SELECT t.status
FROM ems.telemetry_ev_charger t
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
WHERE t.site_id = $1 AND ch.code = 'ev-charger-2'
ORDER BY t.measured_at DESC LIMIT 1
) ev2 ON true
WHERE ep.site_id = $1
AND ep.interval_start >= $2 AND ep.interval_start < $3
ORDER BY ep.interval_start
""", site_id, from_dt, to_dt)
out: list[PlanningSlot] = []
for r in rows:
d = dict(r)
out.append(
PlanningSlot(
interval_start=d["interval_start"],
buy_price=float(d["buy_price"]),
sell_price=float(d["sell_price"]),
pv_a_forecast_w=int(d["pv_a_forecast_w"] or 0),
pv_b_forecast_w=int(d["pv_b_forecast_w"] or 0),
load_baseline_w=int(d["load_baseline_w"] or 0),
ev1_connected=bool(d["ev1_connected"]),
ev2_connected=bool(d["ev2_connected"]),
)
)
return out
async def _save_planning_run(
site_id, results, horizon_from, horizon_to,
run_type, triggered_by, replan_from,
soc_wh, duration_ms, correction, db
) -> int:
"""Uloží výsledky solveru jako nový planning_run, deaktivuje předchozí."""
run_id = await db.fetchval("""
INSERT INTO ems.planning_run
(site_id, horizon_start, horizon_end, status,
run_type, triggered_by, replan_from,
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor)
VALUES ($1,$2,$3,'draft',$4,$5,$6,$7,$8,$9)
RETURNING id
""", site_id, horizon_from, horizon_to,
run_type, triggered_by, replan_from,
soc_wh, duration_ms, correction)
# Bulk insert výsledků
await db.executemany("""
INSERT INTO ems.planning_interval
(run_id, interval_start,
battery_setpoint_w, battery_soc_target_pct,
grid_setpoint_w,
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
effective_buy_price, effective_sell_price)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
""", [
(run_id, r.interval_start,
r.battery_setpoint_w, r.battery_soc_target,
r.grid_setpoint_w,
r.ev1_setpoint_w, r.ev2_setpoint_w, r.ev1_via_bat_w, r.ev2_via_bat_w,
r.heat_pump_enabled, r.heat_pump_setpoint_w,
r.pv_a_curtailed_w, r.expected_cost_czk,
r.effective_buy_price, r.effective_sell_price)
for r in results
])
# Aktivovat nový plán, supersede předchozí
await db.execute("""
UPDATE ems.planning_run SET status = 'superseded'
WHERE site_id = $1 AND status = 'active' AND id <> $2
""", site_id, run_id)
await db.execute(
"UPDATE ems.planning_run SET status = 'active' WHERE id = $1", run_id
)
return run_id

View File

@@ -0,0 +1,608 @@
-- =============================================================
-- V001__init_schema.sql
-- EMS Platform inicializace schématu a všech tabulek
-- =============================================================
CREATE SCHEMA IF NOT EXISTS ems;
-- ============================================================
-- LOKALITY
-- ============================================================
CREATE TABLE ems.site (
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Europe/Prague',
latitude NUMERIC(9,6),
longitude NUMERIC(9,6),
active BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ems.site IS 'Lokalita / jeden objekt v systému EMS.';
COMMENT ON COLUMN ems.site.id IS 'Primární klíč lokality.';
COMMENT ON COLUMN ems.site.code IS 'Krátký unikátní kód lokality. Příklad: home-01.';
COMMENT ON COLUMN ems.site.name IS 'Lidsky čitelný název lokality.';
COMMENT ON COLUMN ems.site.timezone IS 'Časová zóna IANA. Výchozí Europe/Prague.';
COMMENT ON COLUMN ems.site.latitude IS 'Zeměpisná šířka vstup pro weather API a výpočty irradiance.';
COMMENT ON COLUMN ems.site.longitude IS 'Zeměpisná délka vstup pro weather API a výpočty irradiance.';
COMMENT ON COLUMN ems.site.active IS 'Pokud false, lokalita se přeskočí při plánování a sběru dat.';
COMMENT ON COLUMN ems.site.notes IS 'Volné poznámky.';
COMMENT ON COLUMN ems.site.created_at IS 'Čas vytvoření záznamu.';
-- ------------------------------------------------------------
CREATE TABLE ems.site_endpoint (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
endpoint_type TEXT NOT NULL,
host TEXT NOT NULL,
port INT,
protocol TEXT,
unit_id INT,
auth_reference TEXT,
enabled BOOLEAN NOT NULL DEFAULT true,
notes TEXT
);
COMMENT ON TABLE ems.site_endpoint IS 'Komunikační endpointy lokality. Jedna lokalita může mít více endpointů různých typů.';
COMMENT ON COLUMN ems.site_endpoint.id IS 'Primární klíč endpointu.';
COMMENT ON COLUMN ems.site_endpoint.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.site_endpoint.endpoint_type IS 'Typ endpointu: modbus_tcp, loxone_http, http_api.';
COMMENT ON COLUMN ems.site_endpoint.host IS 'IP adresa nebo hostname cílového zařízení (Waveshare, Loxone).';
COMMENT ON COLUMN ems.site_endpoint.port IS 'TCP port. Modbus TCP typicky 502, Loxone 80.';
COMMENT ON COLUMN ems.site_endpoint.protocol IS 'Protokol: modbus_tcp, http, https.';
COMMENT ON COLUMN ems.site_endpoint.unit_id IS 'Modbus Unit ID (slave address). Relevantní pro modbus_tcp.';
COMMENT ON COLUMN ems.site_endpoint.auth_reference IS 'Název env proměnné nebo secret s přihlašovacími údaji.';
COMMENT ON COLUMN ems.site_endpoint.enabled IS 'Pokud false, endpoint se nepoužívá.';
COMMENT ON COLUMN ems.site_endpoint.notes IS 'Volné poznámky.';
-- ------------------------------------------------------------
CREATE TABLE ems.site_market_config (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
purchase_pricing_mode TEXT NOT NULL DEFAULT 'spot',
sale_pricing_mode TEXT NOT NULL DEFAULT 'spot',
buy_margin_fixed_czk NUMERIC(10,4) NOT NULL DEFAULT 0,
buy_margin_percent NUMERIC(6,4) NOT NULL DEFAULT 0,
sell_margin_fixed_czk NUMERIC(10,4) NOT NULL DEFAULT 0,
sell_margin_percent NUMERIC(6,4) NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'CZK',
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ,
notes TEXT
);
COMMENT ON TABLE ems.site_market_config IS 'Obchodní konfigurace lokality s maržemi. valid_from/valid_to umožňuje historii změn.';
COMMENT ON COLUMN ems.site_market_config.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.site_market_config.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.site_market_config.purchase_pricing_mode IS 'Režim nákupní ceny: spot, fixed, hybrid.';
COMMENT ON COLUMN ems.site_market_config.sale_pricing_mode IS 'Režim prodejní ceny: spot, fixed, hybrid.';
COMMENT ON COLUMN ems.site_market_config.buy_margin_fixed_czk IS 'Fixní nákupní marže Kč/kWh přičítaná k raw ceně.';
COMMENT ON COLUMN ems.site_market_config.buy_margin_percent IS 'Procentní nákupní marže aplikovaná na raw cenu.';
COMMENT ON COLUMN ems.site_market_config.sell_margin_fixed_czk IS 'Fixní prodejní marže Kč/kWh. Záporná = srážka z prodejní ceny.';
COMMENT ON COLUMN ems.site_market_config.sell_margin_percent IS 'Procentní prodejní marže.';
COMMENT ON COLUMN ems.site_market_config.currency IS 'Měna konfigurace.';
COMMENT ON COLUMN ems.site_market_config.valid_from IS 'Začátek platnosti konfigurace.';
COMMENT ON COLUMN ems.site_market_config.valid_to IS 'Konec platnosti. NULL = aktuálně platný záznam.';
COMMENT ON COLUMN ems.site_market_config.notes IS 'Volné poznámky.';
-- ------------------------------------------------------------
CREATE TABLE ems.site_grid_connection (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id) UNIQUE,
max_import_power_w INT NOT NULL,
max_export_power_w INT NOT NULL DEFAULT 0,
no_export BOOLEAN NOT NULL DEFAULT false,
reserved_capacity_w INT NOT NULL DEFAULT 0,
notes TEXT
);
COMMENT ON TABLE ems.site_grid_connection IS 'Síťová omezení připojení lokality k distribuční síti.';
COMMENT ON COLUMN ems.site_grid_connection.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.site_grid_connection.site_id IS 'Vazba na lokalitu. Každá lokalita má nejvýše jeden záznam.';
COMMENT ON COLUMN ems.site_grid_connection.max_import_power_w IS 'Maximální povolený odběr ze sítě v W dle jističe/smlouvy.';
COMMENT ON COLUMN ems.site_grid_connection.max_export_power_w IS 'Maximální povolený export do sítě v W. 0 = export zakázán.';
COMMENT ON COLUMN ems.site_grid_connection.no_export IS 'Pokud true, export do sítě je zakázán bez ohledu na max_export_power_w.';
COMMENT ON COLUMN ems.site_grid_connection.reserved_capacity_w IS 'Výkon rezervovaný pro interní potřeby, odečítá se z dostupné kapacity.';
COMMENT ON COLUMN ems.site_grid_connection.notes IS 'Volné poznámky.';
-- ============================================================
-- AKTIVA
-- ============================================================
CREATE TABLE ems.asset_inverter (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
code TEXT NOT NULL,
manufacturer TEXT,
model TEXT,
endpoint_id INT REFERENCES ems.site_endpoint(id),
max_charge_power_w INT,
max_discharge_power_w INT,
max_export_power_w INT,
controllable BOOLEAN NOT NULL DEFAULT true,
notes TEXT
);
COMMENT ON TABLE ems.asset_inverter IS 'Střídač / hybridní měnič na lokalitě.';
COMMENT ON COLUMN ems.asset_inverter.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.asset_inverter.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.asset_inverter.code IS 'Kód aktiva, unikátní v rámci lokality.';
COMMENT ON COLUMN ems.asset_inverter.manufacturer IS 'Výrobce. Příklad: Deye.';
COMMENT ON COLUMN ems.asset_inverter.model IS 'Model. Příklad: SUN-20K-SG01LP1-EU.';
COMMENT ON COLUMN ems.asset_inverter.endpoint_id IS 'Modbus TCP endpoint přes Waveshare.';
COMMENT ON COLUMN ems.asset_inverter.max_charge_power_w IS 'Maximální nabíjecí výkon baterie v W.';
COMMENT ON COLUMN ems.asset_inverter.max_discharge_power_w IS 'Maximální vybíjecí výkon baterie v W.';
COMMENT ON COLUMN ems.asset_inverter.max_export_power_w IS 'Maximální výkon exportu do sítě v W.';
COMMENT ON COLUMN ems.asset_inverter.controllable IS 'Pokud false, střídač není řízen EMS (ongridový na GEN portu).';
COMMENT ON COLUMN ems.asset_inverter.notes IS 'Volné poznámky.';
-- ------------------------------------------------------------
CREATE TABLE ems.asset_battery (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
inverter_id INT NOT NULL REFERENCES ems.asset_inverter(id),
code TEXT NOT NULL,
usable_capacity_wh INT NOT NULL,
min_soc_percent NUMERIC(5,2) NOT NULL DEFAULT 10,
reserve_soc_percent NUMERIC(5,2) NOT NULL DEFAULT 20,
max_soc_percent NUMERIC(5,2) NOT NULL DEFAULT 95,
charge_efficiency NUMERIC(5,4) NOT NULL DEFAULT 0.95,
discharge_efficiency NUMERIC(5,4) NOT NULL DEFAULT 0.95,
degradation_cost_czk_kwh NUMERIC(8,4) NOT NULL DEFAULT 0.5
);
COMMENT ON TABLE ems.asset_battery IS 'Bateriový systém připojený ke střídači.';
COMMENT ON COLUMN ems.asset_battery.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.asset_battery.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.asset_battery.inverter_id IS 'Střídač ke kterému je baterie připojena.';
COMMENT ON COLUMN ems.asset_battery.code IS 'Kód aktiva.';
COMMENT ON COLUMN ems.asset_battery.usable_capacity_wh IS 'Použitelná kapacita baterie v Wh bez rezerv výrobce.';
COMMENT ON COLUMN ems.asset_battery.min_soc_percent IS 'Minimální SoC v % absolutní spodní limit, nikdy nepřekročit.';
COMMENT ON COLUMN ems.asset_battery.reserve_soc_percent IS 'Rezervní SoC v % zachován pro výpadky sítě, nevyužívat v běžném plánu.';
COMMENT ON COLUMN ems.asset_battery.max_soc_percent IS 'Maximální SoC v % horní limit pro denní provoz.';
COMMENT ON COLUMN ems.asset_battery.charge_efficiency IS 'Účinnost nabíjení (01). Typicky 0.95.';
COMMENT ON COLUMN ems.asset_battery.discharge_efficiency IS 'Účinnost vybíjení (01). Typicky 0.95.';
COMMENT ON COLUMN ems.asset_battery.degradation_cost_czk_kwh IS 'Odhadovaný náklad degradace v Kč/kWh cyklu. Vstupuje do optimalizace jako cena za použití baterie.';
-- ------------------------------------------------------------
CREATE TABLE ems.asset_pv_array (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
inverter_id INT REFERENCES ems.asset_inverter(id),
code TEXT NOT NULL,
name TEXT,
nominal_power_wp INT NOT NULL,
azimuth_deg NUMERIC(6,2),
tilt_deg NUMERIC(5,2),
module_count INT,
shading_factor NUMERIC(4,3) NOT NULL DEFAULT 1.0,
controllable BOOLEAN NOT NULL DEFAULT false,
notes TEXT
);
COMMENT ON TABLE ems.asset_pv_array IS 'FVE pole. Každé pole jako samostatný záznam klíčové pro přesnou predikci výroby.';
COMMENT ON COLUMN ems.asset_pv_array.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.asset_pv_array.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.asset_pv_array.inverter_id IS 'Střídač ke kterému je pole připojeno.';
COMMENT ON COLUMN ems.asset_pv_array.code IS 'Kód pole, unikátní v rámci lokality. Příklad: pv-a, pv-b.';
COMMENT ON COLUMN ems.asset_pv_array.name IS 'Popis pole. Příklad: Jižní střecha.';
COMMENT ON COLUMN ems.asset_pv_array.nominal_power_wp IS 'Nominální výkon pole v Wp (součet výkonů panelů).';
COMMENT ON COLUMN ems.asset_pv_array.azimuth_deg IS 'Azimut panelů ve stupních. 0=jih, 90=západ, -90=východ.';
COMMENT ON COLUMN ems.asset_pv_array.tilt_deg IS 'Sklon panelů od horizontály ve stupních.';
COMMENT ON COLUMN ems.asset_pv_array.module_count IS 'Počet panelů v poli.';
COMMENT ON COLUMN ems.asset_pv_array.shading_factor IS 'Koeficient stínění (01). 1.0 = bez stínění.';
COMMENT ON COLUMN ems.asset_pv_array.controllable IS 'Pokud false, pole není řízeno EMS (ongridový autonomní střídač na GEN portu).';
COMMENT ON COLUMN ems.asset_pv_array.notes IS 'Volné poznámky.';
-- ------------------------------------------------------------
CREATE TABLE ems.asset_ev_charger (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
code TEXT NOT NULL,
manufacturer TEXT,
model TEXT,
endpoint_id INT REFERENCES ems.site_endpoint(id),
max_power_w INT NOT NULL,
min_power_w INT NOT NULL DEFAULT 1380,
phases INT NOT NULL DEFAULT 3,
connector_count INT NOT NULL DEFAULT 1,
schedulable BOOLEAN NOT NULL DEFAULT true,
notes TEXT
);
COMMENT ON TABLE ems.asset_ev_charger IS 'EV nabíjecí stanice. Komunikace přes Modbus TCP (Waveshare převodník).';
COMMENT ON COLUMN ems.asset_ev_charger.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.asset_ev_charger.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.asset_ev_charger.code IS 'Kód aktiva, unikátní v rámci lokality.';
COMMENT ON COLUMN ems.asset_ev_charger.manufacturer IS 'Výrobce nabíječky. Příklad: Teltonika.';
COMMENT ON COLUMN ems.asset_ev_charger.model IS 'Model nabíječky. Příklad: TeltoCharge 22kW.';
COMMENT ON COLUMN ems.asset_ev_charger.endpoint_id IS 'Modbus TCP endpoint přes Waveshare převodník.';
COMMENT ON COLUMN ems.asset_ev_charger.max_power_w IS 'Maximální výkon nabíječky v W.';
COMMENT ON COLUMN ems.asset_ev_charger.min_power_w IS 'Minimální výkon nabíjení v W. 1380 W = 6A jednofázové minimum IEC 61851.';
COMMENT ON COLUMN ems.asset_ev_charger.phases IS 'Počet fází nabíjení.';
COMMENT ON COLUMN ems.asset_ev_charger.connector_count IS 'Počet konektorů na nabíječce.';
COMMENT ON COLUMN ems.asset_ev_charger.schedulable IS 'Pokud true, nabíječka je zapojená do plánování EMS.';
COMMENT ON COLUMN ems.asset_ev_charger.notes IS 'Volné poznámky.';
-- ------------------------------------------------------------
CREATE TABLE ems.asset_heat_pump (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
code TEXT NOT NULL,
manufacturer TEXT,
model TEXT,
endpoint_id INT REFERENCES ems.site_endpoint(id),
rated_heating_power_w INT NOT NULL,
cop_rated NUMERIC(4,2),
cop_temp_reference_c NUMERIC(5,2),
min_run_duration_min INT NOT NULL DEFAULT 30,
min_stop_duration_min INT NOT NULL DEFAULT 15,
tuv_tank_volume_l INT,
tuv_min_temp_c NUMERIC(5,2) NOT NULL DEFAULT 45,
tuv_max_temp_c NUMERIC(5,2) NOT NULL DEFAULT 60,
tuv_target_temp_c NUMERIC(5,2) NOT NULL DEFAULT 55,
tuv_temp_sensor_ref TEXT,
schedulable BOOLEAN NOT NULL DEFAULT true,
notes TEXT
);
COMMENT ON TABLE ems.asset_heat_pump IS 'Tepelné čerpadlo s Modbus řízením (Samsung). Řízeno na základě COP, venkovní teploty a stavu zásobníku TUV.';
COMMENT ON COLUMN ems.asset_heat_pump.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.asset_heat_pump.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.asset_heat_pump.code IS 'Kód aktiva, unikátní v rámci lokality.';
COMMENT ON COLUMN ems.asset_heat_pump.manufacturer IS 'Výrobce tepelného čerpadla. Příklad: Samsung.';
COMMENT ON COLUMN ems.asset_heat_pump.model IS 'Model tepelného čerpadla.';
COMMENT ON COLUMN ems.asset_heat_pump.endpoint_id IS 'Modbus TCP endpoint přes Waveshare převodník.';
COMMENT ON COLUMN ems.asset_heat_pump.rated_heating_power_w IS 'Jmenovitý topný výkon v W při referenčních podmínkách.';
COMMENT ON COLUMN ems.asset_heat_pump.cop_rated IS 'Jmenovitý COP při referenční teplotě cop_temp_reference_c.';
COMMENT ON COLUMN ems.asset_heat_pump.cop_temp_reference_c IS 'Referenční venkovní teplota pro jmenovitý COP. Typicky 7°C (norma A7/W35).';
COMMENT ON COLUMN ems.asset_heat_pump.min_run_duration_min IS 'Minimální nepřerušená doba běhu v minutách. Chrání kompresor před krátkými cykly.';
COMMENT ON COLUMN ems.asset_heat_pump.min_stop_duration_min IS 'Minimální doba stání po vypnutí v minutách. Ochrana kompresoru při restartu.';
COMMENT ON COLUMN ems.asset_heat_pump.tuv_tank_volume_l IS 'Objem zásobníku TUV v litrech. Slouží k výpočtu doby ohřevu.';
COMMENT ON COLUMN ems.asset_heat_pump.tuv_min_temp_c IS 'Minimální teplota TUV zásobníku v °C. Pod touto hodnotou se ohřev spustí bez ohledu na cenu.';
COMMENT ON COLUMN ems.asset_heat_pump.tuv_max_temp_c IS 'Maximální teplota TUV zásobníku v °C. Nad touto hodnotou se ohřev zastaví.';
COMMENT ON COLUMN ems.asset_heat_pump.tuv_target_temp_c IS 'Cílová teplota TUV pro normální plánovaný provoz.';
COMMENT ON COLUMN ems.asset_heat_pump.tuv_temp_sensor_ref IS 'Název nebo kód čidla teploty zásobníku (Modbus registr nebo Loxone Virtual Output).';
COMMENT ON COLUMN ems.asset_heat_pump.schedulable IS 'Pokud true, tepelné čerpadlo je zapojeno do plánování EMS.';
COMMENT ON COLUMN ems.asset_heat_pump.notes IS 'Volné poznámky.';
-- ============================================================
-- TRŽNÍ DATA
-- ============================================================
CREATE TABLE ems.market_interval_price (
market_source TEXT NOT NULL DEFAULT 'OTE_CZ',
interval_start TIMESTAMPTZ NOT NULL,
interval_end TIMESTAMPTZ NOT NULL,
buy_raw_price_czk_kwh NUMERIC(10,6) NOT NULL,
sell_raw_price_czk_kwh NUMERIC(10,6) NOT NULL,
currency TEXT NOT NULL DEFAULT 'CZK',
imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (market_source, interval_start)
);
COMMENT ON TABLE ems.market_interval_price IS 'Raw spotové ceny elektřiny OTE CZ. Sdílené pro všechny lokality, bez marží. Granularita 15 minut.';
COMMENT ON COLUMN ems.market_interval_price.market_source IS 'Zdroj tržních dat. Příklad: OTE_CZ.';
COMMENT ON COLUMN ems.market_interval_price.interval_start IS 'Začátek 15minutového intervalu (UTC).';
COMMENT ON COLUMN ems.market_interval_price.interval_end IS 'Konec 15minutového intervalu (UTC).';
COMMENT ON COLUMN ems.market_interval_price.buy_raw_price_czk_kwh IS 'Raw nákupní cena v Kč/kWh bez marží.';
COMMENT ON COLUMN ems.market_interval_price.sell_raw_price_czk_kwh IS 'Raw prodejní referenční cena v Kč/kWh bez marží. Pro OTE CZ = DAM cena.';
COMMENT ON COLUMN ems.market_interval_price.currency IS 'Měna cen záznamu.';
COMMENT ON COLUMN ems.market_interval_price.imported_at IS 'Čas importu záznamu do DB. Slouží pro audit importů.';
-- ============================================================
-- TELEMETRIE
-- ============================================================
CREATE TABLE ems.telemetry_inverter (
site_id INT NOT NULL REFERENCES ems.site(id),
inverter_id INT NOT NULL REFERENCES ems.asset_inverter(id),
measured_at TIMESTAMPTZ NOT NULL,
pv_power_w INT,
battery_soc_percent NUMERIC(5,2),
battery_power_w INT,
battery_voltage_v NUMERIC(7,3),
grid_power_w INT,
grid_voltage_v NUMERIC(7,3),
load_power_w INT,
inverter_temp_c NUMERIC(5,2),
operating_mode TEXT,
fault_code INT,
PRIMARY KEY (inverter_id, measured_at)
);
COMMENT ON TABLE ems.telemetry_inverter IS 'Telemetrie ze střídače Deye čtená přes Modbus TCP. 1min granularita. TimescaleDB hypertable.';
COMMENT ON COLUMN ems.telemetry_inverter.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.telemetry_inverter.inverter_id IS 'Vazba na střídač.';
COMMENT ON COLUMN ems.telemetry_inverter.measured_at IS 'Čas měření (UTC).';
COMMENT ON COLUMN ems.telemetry_inverter.pv_power_w IS 'Celkový okamžitý výkon FVE v W (součet všech stringů čtených tímto střídačem, včetně GEN portu).';
COMMENT ON COLUMN ems.telemetry_inverter.battery_soc_percent IS 'Aktuální stav nabití baterie v %.';
COMMENT ON COLUMN ems.telemetry_inverter.battery_power_w IS 'Výkon baterie v W. Kladné = nabíjení, záporné = vybíjení.';
COMMENT ON COLUMN ems.telemetry_inverter.battery_voltage_v IS 'Napětí bateriového systému v V.';
COMMENT ON COLUMN ems.telemetry_inverter.grid_power_w IS 'Výkon přenosu se sítí v W. Kladné = import ze sítě, záporné = export do sítě.';
COMMENT ON COLUMN ems.telemetry_inverter.grid_voltage_v IS 'Napětí sítě v V.';
COMMENT ON COLUMN ems.telemetry_inverter.load_power_w IS 'Celková spotřeba objektu v W (vše za AC výstupem střídače, včetně EV a TUV).';
COMMENT ON COLUMN ems.telemetry_inverter.inverter_temp_c IS 'Teplota střídače v °C.';
COMMENT ON COLUMN ems.telemetry_inverter.operating_mode IS 'Provozní režim dle Modbus registru (raw hodnota pro ladění).';
COMMENT ON COLUMN ems.telemetry_inverter.fault_code IS 'Kód chyby z Modbus registru. 0 = bez chyby.';
-- ------------------------------------------------------------
CREATE TABLE ems.telemetry_ev_charger (
site_id INT NOT NULL REFERENCES ems.site(id),
charger_id INT NOT NULL REFERENCES ems.asset_ev_charger(id),
measured_at TIMESTAMPTZ NOT NULL,
connector_id INT NOT NULL DEFAULT 1,
status TEXT,
power_w INT,
energy_kwh NUMERIC(10,3),
current_a NUMERIC(7,3),
voltage_v NUMERIC(7,3),
session_id TEXT,
error_code TEXT,
PRIMARY KEY (charger_id, connector_id, measured_at)
);
COMMENT ON TABLE ems.telemetry_ev_charger IS 'Telemetrie EV nabíječky Teltonika čtená přes Modbus TCP (Waveshare). 1min granularita. TimescaleDB hypertable.';
COMMENT ON COLUMN ems.telemetry_ev_charger.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.telemetry_ev_charger.charger_id IS 'Vazba na EV nabíječku.';
COMMENT ON COLUMN ems.telemetry_ev_charger.measured_at IS 'Čas měření (UTC).';
COMMENT ON COLUMN ems.telemetry_ev_charger.connector_id IS 'Číslo konektoru (1-based). Teltonika TeltoCharge má 1 konektor.';
COMMENT ON COLUMN ems.telemetry_ev_charger.status IS 'Stav konektoru dle OCPP: available, preparing, charging, suspended_ev, suspended_evse, finishing, faulted.';
COMMENT ON COLUMN ems.telemetry_ev_charger.power_w IS 'Aktuální nabíjecí výkon v W.';
COMMENT ON COLUMN ems.telemetry_ev_charger.energy_kwh IS 'Kumulativní energie aktuální session v kWh. Resetuje se při nové session.';
COMMENT ON COLUMN ems.telemetry_ev_charger.current_a IS 'Nabíjecí proud v A.';
COMMENT ON COLUMN ems.telemetry_ev_charger.voltage_v IS 'Napětí v bodě připojení v V.';
COMMENT ON COLUMN ems.telemetry_ev_charger.session_id IS 'Identifikátor aktuální nabíjecí session z Modbus registru.';
COMMENT ON COLUMN ems.telemetry_ev_charger.error_code IS 'Kód chyby z Modbus registru nabíječky.';
-- ------------------------------------------------------------
CREATE TABLE ems.telemetry_heat_pump (
site_id INT NOT NULL REFERENCES ems.site(id),
heat_pump_id INT NOT NULL REFERENCES ems.asset_heat_pump(id),
measured_at TIMESTAMPTZ NOT NULL,
outdoor_temp_c NUMERIC(5,2),
water_inlet_temp_c NUMERIC(5,2),
water_outlet_temp_c NUMERIC(5,2),
tuv_tank_temp_c NUMERIC(5,2),
power_w INT,
operating_mode TEXT,
cop_actual NUMERIC(4,2),
defrost_active BOOLEAN,
alarm_code INT,
PRIMARY KEY (heat_pump_id, measured_at)
);
COMMENT ON TABLE ems.telemetry_heat_pump IS 'Telemetrie tepelného čerpadla Samsung čtená přes Modbus TCP (Waveshare). 1min granularita. TimescaleDB hypertable.';
COMMENT ON COLUMN ems.telemetry_heat_pump.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.telemetry_heat_pump.heat_pump_id IS 'Vazba na tepelné čerpadlo.';
COMMENT ON COLUMN ems.telemetry_heat_pump.measured_at IS 'Čas měření (UTC).';
COMMENT ON COLUMN ems.telemetry_heat_pump.outdoor_temp_c IS 'Venkovní teplota naměřená čerpadlem v °C. Klíčový vstup pro výpočet COP a rozhodnutí o ohřevu.';
COMMENT ON COLUMN ems.telemetry_heat_pump.water_inlet_temp_c IS 'Teplota vody na vstupu do výměníku čerpadla v °C.';
COMMENT ON COLUMN ems.telemetry_heat_pump.water_outlet_temp_c IS 'Teplota vody na výstupu z čerpadla v °C.';
COMMENT ON COLUMN ems.telemetry_heat_pump.tuv_tank_temp_c IS 'Teplota TUV zásobníku v °C. Klíčový vstup pro rozhodnutí kdy ohřívat.';
COMMENT ON COLUMN ems.telemetry_heat_pump.power_w IS 'Aktuální příkon čerpadla v W.';
COMMENT ON COLUMN ems.telemetry_heat_pump.operating_mode IS 'Provozní režim: heating, cooling, dhw (domestic hot water), standby.';
COMMENT ON COLUMN ems.telemetry_heat_pump.cop_actual IS 'Aktuálně vypočtený COP pokud je dostupný z Modbus registru.';
COMMENT ON COLUMN ems.telemetry_heat_pump.defrost_active IS 'Příznak aktivního odmrazovacího cyklu. Při defrostu je skutečný příkon vyšší.';
COMMENT ON COLUMN ems.telemetry_heat_pump.alarm_code IS 'Kód alarmu z Modbus registru. 0 = bez alarmu.';
-- ============================================================
-- PREDIKCE
-- ============================================================
CREATE TABLE ems.forecast_pv_run (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
pv_array_id INT REFERENCES ems.asset_pv_array(id),
forecast_source TEXT NOT NULL DEFAULT 'open_meteo',
model_params JSONB,
horizon_start TIMESTAMPTZ NOT NULL,
horizon_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL DEFAULT 'ok'
);
COMMENT ON TABLE ems.forecast_pv_run IS 'Metadata jednoho běhu predikce výroby FVE.';
COMMENT ON COLUMN ems.forecast_pv_run.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.forecast_pv_run.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.forecast_pv_run.pv_array_id IS 'Konkrétní FVE pole. NULL = agregovaná predikce celé lokality.';
COMMENT ON COLUMN ems.forecast_pv_run.forecast_source IS 'Zdroj meteorologických dat: open_meteo, solcast, manual.';
COMMENT ON COLUMN ems.forecast_pv_run.model_params IS 'Parametry predikčního modelu použité při tomto běhu (JSON).';
COMMENT ON COLUMN ems.forecast_pv_run.horizon_start IS 'Začátek predikčního horizontu.';
COMMENT ON COLUMN ems.forecast_pv_run.horizon_end IS 'Konec predikčního horizontu.';
COMMENT ON COLUMN ems.forecast_pv_run.created_at IS 'Čas spuštění predikce.';
COMMENT ON COLUMN ems.forecast_pv_run.status IS 'Stav běhu: ok, partial, failed.';
-- ------------------------------------------------------------
CREATE TABLE ems.forecast_pv_interval (
run_id INT NOT NULL REFERENCES ems.forecast_pv_run(id),
pv_array_id INT NOT NULL REFERENCES ems.asset_pv_array(id),
interval_start TIMESTAMPTZ NOT NULL,
power_w INT NOT NULL,
irradiance_wm2 NUMERIC(8,2),
temp_c NUMERIC(5,2),
PRIMARY KEY (run_id, pv_array_id, interval_start)
);
COMMENT ON TABLE ems.forecast_pv_interval IS 'Predikovaný výkon FVE po 15min intervalech. TimescaleDB hypertable.';
COMMENT ON COLUMN ems.forecast_pv_interval.run_id IS 'Vazba na běh predikce.';
COMMENT ON COLUMN ems.forecast_pv_interval.pv_array_id IS 'Konkrétní FVE pole.';
COMMENT ON COLUMN ems.forecast_pv_interval.interval_start IS 'Začátek 15min intervalu (UTC).';
COMMENT ON COLUMN ems.forecast_pv_interval.power_w IS 'Predikovaný výkon FVE pole v W.';
COMMENT ON COLUMN ems.forecast_pv_interval.irradiance_wm2 IS 'GHI irradiance ze weather service v W/m² použitá při výpočtu.';
COMMENT ON COLUMN ems.forecast_pv_interval.temp_c IS 'Predikovaná venkovní teplota v °C použitá při výpočtu.';
-- ------------------------------------------------------------
CREATE TABLE ems.forecast_weather_interval (
site_id INT NOT NULL REFERENCES ems.site(id),
forecast_source TEXT NOT NULL DEFAULT 'open_meteo',
interval_start TIMESTAMPTZ NOT NULL,
outdoor_temp_c NUMERIC(5,2),
irradiance_wm2 NUMERIC(8,2),
cloud_cover_pct NUMERIC(5,2),
wind_speed_ms NUMERIC(6,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (site_id, forecast_source, interval_start)
);
COMMENT ON TABLE ems.forecast_weather_interval IS 'Predikce počasí per lokalita, 15min granularita. Sdílený vstup pro FVE predikci i COP odhad tepelného čerpadla.';
COMMENT ON COLUMN ems.forecast_weather_interval.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.forecast_weather_interval.forecast_source IS 'Zdroj predikce počasí.';
COMMENT ON COLUMN ems.forecast_weather_interval.interval_start IS 'Začátek 15min intervalu (UTC).';
COMMENT ON COLUMN ems.forecast_weather_interval.outdoor_temp_c IS 'Predikovaná venkovní teplota v °C. Klíčový vstup pro COP odhad tepelného čerpadla.';
COMMENT ON COLUMN ems.forecast_weather_interval.irradiance_wm2 IS 'Predikovaná irradiance GHI v W/m².';
COMMENT ON COLUMN ems.forecast_weather_interval.cloud_cover_pct IS 'Predikovaná oblačnost v %.';
COMMENT ON COLUMN ems.forecast_weather_interval.wind_speed_ms IS 'Predikovaná rychlost větru v m/s.';
COMMENT ON COLUMN ems.forecast_weather_interval.created_at IS 'Čas importu predikce do DB.';
-- ============================================================
-- PLÁNOVÁNÍ
-- ============================================================
CREATE TABLE ems.planning_run (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
horizon_start TIMESTAMPTZ NOT NULL,
horizon_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL DEFAULT 'draft',
solver_params JSONB,
notes TEXT
);
COMMENT ON TABLE ems.planning_run IS 'Jeden plánovací běh pro konkrétní lokalitu a horizont.';
COMMENT ON COLUMN ems.planning_run.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.planning_run.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.planning_run.horizon_start IS 'Začátek plánovaného horizontu (typicky dnešní půlnoc).';
COMMENT ON COLUMN ems.planning_run.horizon_end IS 'Konec plánovaného horizontu (typicky zítřejší půlnoc nebo +48h).';
COMMENT ON COLUMN ems.planning_run.created_at IS 'Čas vytvoření plánu.';
COMMENT ON COLUMN ems.planning_run.status IS 'Stav plánu: draft (sestavován), approved (schválen), active (aktuálně platný), superseded (nahrazen novějším).';
COMMENT ON COLUMN ems.planning_run.solver_params IS 'Parametry optimalizačního solveru použité při tomto běhu (JSON).';
COMMENT ON COLUMN ems.planning_run.notes IS 'Volné poznámky k plánu.';
-- ------------------------------------------------------------
CREATE TABLE ems.planning_interval (
run_id INT NOT NULL REFERENCES ems.planning_run(id),
interval_start TIMESTAMPTZ NOT NULL,
battery_setpoint_w INT,
battery_soc_target_pct NUMERIC(5,2),
grid_setpoint_w INT,
ev_charge_power_w INT,
heat_pump_enabled BOOLEAN,
heat_pump_setpoint_w INT,
expected_cost_czk NUMERIC(10,4),
effective_buy_price NUMERIC(10,6),
effective_sell_price NUMERIC(10,6),
PRIMARY KEY (run_id, interval_start)
);
COMMENT ON TABLE ems.planning_interval IS 'Výstup optimalizace jeden řádek = jeden 15min slot plánu.';
COMMENT ON COLUMN ems.planning_interval.run_id IS 'Vazba na plánovací běh.';
COMMENT ON COLUMN ems.planning_interval.interval_start IS 'Začátek 15min intervalu (UTC).';
COMMENT ON COLUMN ems.planning_interval.battery_setpoint_w IS 'Plánovaný výkon baterie v W. Kladné = nabíjení, záporné = vybíjení.';
COMMENT ON COLUMN ems.planning_interval.battery_soc_target_pct IS 'Cílový SoC baterie na konci intervalu v %.';
COMMENT ON COLUMN ems.planning_interval.grid_setpoint_w IS 'Plánovaný výkon se sítí v W. Kladné = import, záporné = export.';
COMMENT ON COLUMN ems.planning_interval.ev_charge_power_w IS 'Plánovaný agregovaný výkon nabíjení EV v W (součet všech nabíječek na site).';
COMMENT ON COLUMN ems.planning_interval.heat_pump_enabled IS 'Zda má tepelné čerpadlo v tomto intervalu běžet.';
COMMENT ON COLUMN ems.planning_interval.heat_pump_setpoint_w IS 'Plánovaný výkon tepelného čerpadla v W.';
COMMENT ON COLUMN ems.planning_interval.expected_cost_czk IS 'Očekávané náklady intervalu v Kč. Záporné = příjem z prodeje.';
COMMENT ON COLUMN ems.planning_interval.effective_buy_price IS 'Efektivní nákupní cena použitá při plánování v Kč/kWh.';
COMMENT ON COLUMN ems.planning_interval.effective_sell_price IS 'Efektivní prodejní cena použitá při plánování v Kč/kWh.';
-- ============================================================
-- AUDIT
-- ============================================================
CREATE TABLE ems.audit_interval (
site_id INT NOT NULL REFERENCES ems.site(id),
interval_start TIMESTAMPTZ NOT NULL,
planning_run_id INT REFERENCES ems.planning_run(id),
actual_pv_power_w INT,
actual_battery_power_w INT,
actual_grid_power_w INT,
actual_load_power_w INT,
actual_battery_soc_pct NUMERIC(5,2),
actual_ev_power_w INT,
actual_heat_pump_power_w INT,
actual_cost_czk NUMERIC(10,4),
deviation_grid_w INT,
deviation_cost_czk NUMERIC(10,4),
PRIMARY KEY (site_id, interval_start)
);
COMMENT ON TABLE ems.audit_interval IS 'Skutečnost vs plán po 15min intervalech. Plněno zpětně funkcí ems.fn_fill_audit_interval().';
COMMENT ON COLUMN ems.audit_interval.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.audit_interval.interval_start IS 'Začátek 15min intervalu (UTC).';
COMMENT ON COLUMN ems.audit_interval.planning_run_id IS 'Plán který byl v tomto intervalu aktivní.';
COMMENT ON COLUMN ems.audit_interval.actual_pv_power_w IS 'Skutečný výkon FVE v W (průměr za 15min z telemetrie).';
COMMENT ON COLUMN ems.audit_interval.actual_battery_power_w IS 'Skutečný výkon baterie v W (průměr za 15min).';
COMMENT ON COLUMN ems.audit_interval.actual_grid_power_w IS 'Skutečný výkon přenosu se sítí v W (průměr za 15min).';
COMMENT ON COLUMN ems.audit_interval.actual_load_power_w IS 'Skutečná celková spotřeba v W (průměr za 15min).';
COMMENT ON COLUMN ems.audit_interval.actual_battery_soc_pct IS 'Skutečný SoC baterie na konci intervalu v %.';
COMMENT ON COLUMN ems.audit_interval.actual_ev_power_w IS 'Skutečný výkon nabíjení EV v W (suma všech nabíječek, průměr za 15min).';
COMMENT ON COLUMN ems.audit_interval.actual_heat_pump_power_w IS 'Skutečný příkon tepelného čerpadla v W (průměr za 15min).';
COMMENT ON COLUMN ems.audit_interval.actual_cost_czk IS 'Skutečné náklady intervalu v Kč vypočtené ze skutečného grid_power a efektivní ceny.';
COMMENT ON COLUMN ems.audit_interval.deviation_grid_w IS 'Odchylka skutečný - plánovaný výkon sítě v W.';
COMMENT ON COLUMN ems.audit_interval.deviation_cost_czk IS 'Odchylka skutečných nákladů od plánovaných v Kč.';
-- ============================================================
-- SPOTŘEBA
-- ============================================================
CREATE TABLE ems.consumption_baseline_interval (
site_id INT NOT NULL REFERENCES ems.site(id),
interval_start TIMESTAMPTZ NOT NULL,
data_type TEXT NOT NULL,
power_w INT NOT NULL,
source TEXT,
PRIMARY KEY (site_id, data_type, interval_start)
);
COMMENT ON TABLE ems.consumption_baseline_interval IS 'Bazální (neflexibilní) spotřeba po 15min intervalech. Historická skutečnost i predikce.';
COMMENT ON COLUMN ems.consumption_baseline_interval.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.consumption_baseline_interval.interval_start IS 'Začátek 15min intervalu (UTC).';
COMMENT ON COLUMN ems.consumption_baseline_interval.data_type IS 'Typ záznamu: actual (historická skutečnost), forecast (predikce pro plánování).';
COMMENT ON COLUMN ems.consumption_baseline_interval.power_w IS 'Bazální spotřeba v W = celková spotřeba mínus měřitelná flexibilní zařízení.';
COMMENT ON COLUMN ems.consumption_baseline_interval.source IS 'Metoda výpočtu: measured (z telemetrie), model_v1 (statistický model).';
-- ============================================================
-- OVERRIDE
-- ============================================================
CREATE TABLE ems.site_override (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
override_type TEXT NOT NULL,
value_json JSONB,
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ,
reason TEXT,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ems.site_override IS 'Manuální přepisy provozních stavů. Mají přednost před automatickým plánem.';
COMMENT ON COLUMN ems.site_override.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.site_override.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.site_override.override_type IS 'Typ přepisu: force_charge, force_discharge, block_export, manual_setpoint, block_heat_pump.';
COMMENT ON COLUMN ems.site_override.value_json IS 'Parametry přepisu jako JSON. Obsah závisí na override_type.';
COMMENT ON COLUMN ems.site_override.valid_from IS 'Začátek platnosti přepisu.';
COMMENT ON COLUMN ems.site_override.valid_to IS 'Konec platnosti. NULL = platí do odvolání.';
COMMENT ON COLUMN ems.site_override.reason IS 'Důvod přepisu pro auditní účely.';
COMMENT ON COLUMN ems.site_override.created_by IS 'Uživatel nebo systémová komponenta která přepis vytvořila.';
COMMENT ON COLUMN ems.site_override.created_at IS 'Čas vytvoření záznamu.';

View File

@@ -0,0 +1,81 @@
-- =============================================================
-- V002__timescale_hypertables.sql
-- EMS Platform vytvoření TimescaleDB hypertable pro časové série
-- Spouštět po V001 a po instalaci TimescaleDB extension
-- =============================================================
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Telemetrie střídače 1min záznamy, partitioning po 1 týdnu
SELECT create_hypertable(
'ems.telemetry_inverter',
'measured_at',
chunk_time_interval => INTERVAL '1 week',
if_not_exists => TRUE
);
-- Telemetrie EV nabíječek
SELECT create_hypertable(
'ems.telemetry_ev_charger',
'measured_at',
chunk_time_interval => INTERVAL '1 week',
if_not_exists => TRUE
);
-- Telemetrie tepelného čerpadla
SELECT create_hypertable(
'ems.telemetry_heat_pump',
'measured_at',
chunk_time_interval => INTERVAL '1 week',
if_not_exists => TRUE
);
-- Spotové ceny 15min záznamy, partitioning po 1 měsíci
SELECT create_hypertable(
'ems.market_interval_price',
'interval_start',
chunk_time_interval => INTERVAL '1 month',
if_not_exists => TRUE
);
-- FVE predikce 15min záznamy
SELECT create_hypertable(
'ems.forecast_pv_interval',
'interval_start',
chunk_time_interval => INTERVAL '1 month',
if_not_exists => TRUE
);
-- Predikce počasí
SELECT create_hypertable(
'ems.forecast_weather_interval',
'interval_start',
chunk_time_interval => INTERVAL '1 month',
if_not_exists => TRUE
);
-- Audit
SELECT create_hypertable(
'ems.audit_interval',
'interval_start',
chunk_time_interval => INTERVAL '1 month',
if_not_exists => TRUE
);
-- Bazální spotřeba
SELECT create_hypertable(
'ems.consumption_baseline_interval',
'interval_start',
chunk_time_interval => INTERVAL '1 month',
if_not_exists => TRUE
);
-- ============================================================
-- Kompresní politiky pro staré chunky
-- Telemetrie starší 30 dní komprimovat (čtení stačí)
-- ============================================================
SELECT add_compression_policy('ems.telemetry_inverter', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_compression_policy('ems.telemetry_ev_charger', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_compression_policy('ems.telemetry_heat_pump', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_compression_policy('ems.market_interval_price', INTERVAL '90 days', if_not_exists => TRUE);

View File

@@ -0,0 +1,203 @@
-- =============================================================
-- V003__seed_site_home01.sql
-- EMS Platform seed data první lokality home-01
-- Doplnit: latitude, longitude, IP adresy, azimuty FVE polí
-- =============================================================
-- ============================================================
-- LOKALITA
-- ============================================================
INSERT INTO ems.site (code, name, timezone, latitude, longitude, active, notes)
VALUES (
'home-01',
'Hlavní objekt',
'Europe/Prague',
NULL, -- TODO: doplnit GPS
NULL, -- TODO: doplnit GPS
true,
'První instalace. Deye 20kW + 64kWh baterie + 2x Teltonika EV + Samsung TČ.'
);
-- ============================================================
-- ENDPOINTY
-- ============================================================
-- 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.'
FROM ems.site WHERE code = 'home-01';
-- TODO: doplnit skutečnou IP adresy Waveshare
-- Teltonika EV nabíječka 1 přes Waveshare
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
SELECT id, 'modbus_tcp', '192.168.1.101', 502, 'modbus_tcp', 1, true, 'Waveshare pro Teltonika TeltoCharge #1.'
FROM ems.site WHERE code = 'home-01';
-- TODO: doplnit IP a unit_id
-- Teltonika EV nabíječka 2 přes Waveshare
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
SELECT id, 'modbus_tcp', '192.168.1.102', 502, 'modbus_tcp', 1, true, 'Waveshare pro Teltonika TeltoCharge #2.'
FROM ems.site WHERE code = 'home-01';
-- Samsung tepelné čerpadlo přes Waveshare
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
SELECT id, 'modbus_tcp', '192.168.1.103', 502, 'modbus_tcp', 1, true, 'Waveshare pro Samsung tepelné čerpadlo.'
FROM ems.site WHERE code = 'home-01';
-- Loxone Miniserver
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
SELECT id, 'loxone_http', '192.168.1.10', 80, 'http', NULL, true, 'Loxone Miniserver příjem setpointů přes Virtual HTTP Inputs.'
FROM ems.site WHERE code = 'home-01';
-- TODO: doplnit IP Loxone
-- ============================================================
-- SÍŤOVÉ PŘIPOJENÍ
-- ============================================================
INSERT INTO ems.site_grid_connection (site_id, max_import_power_w, max_export_power_w, no_export, reserved_capacity_w, notes)
SELECT id, 22000, 20000, false, 0, 'Třífázová přípojka. Limity upřesnit dle smlouvy s distributorem.'
FROM ems.site WHERE code = 'home-01';
-- ============================================================
-- TRŽNÍ KONFIGURACE
-- ============================================================
INSERT INTO ems.site_market_config (
site_id, purchase_pricing_mode, sale_pricing_mode,
buy_margin_fixed_czk, buy_margin_percent,
sell_margin_fixed_czk, sell_margin_percent,
currency, valid_from, valid_to, notes
)
SELECT
id, 'spot', 'spot',
0.050, -- 5 haléřů/kWh nákupní marže (distribuce, poplatky)
0,
-0.020, -- 2 haléře/kWh srážka z prodejní ceny
0,
'CZK', now(), NULL, 'Výchozí konfigurace. Upřesnit dle skutečné smlouvy.'
FROM ems.site WHERE code = 'home-01';
-- ============================================================
-- AKTIVA STŘÍDAČ
-- ============================================================
INSERT INTO ems.asset_inverter (site_id, code, manufacturer, model, endpoint_id, max_charge_power_w, max_discharge_power_w, max_export_power_w, controllable, notes)
SELECT
s.id, 'deye-main', 'Deye', 'SUN-20K-SG01LP1-EU',
ep.id,
20000, 20000, 20000,
true,
'Hlavní hybridní střídač 20kW LV. RS485 Modbus RTU přes Waveshare.'
FROM ems.site s
JOIN ems.site_endpoint ep ON ep.site_id = s.id AND ep.notes LIKE '%Deye%'
WHERE s.code = 'home-01';
-- Ongridový střídač na GEN portu (autonomní, neřídíme)
INSERT INTO ems.asset_inverter (site_id, code, manufacturer, model, endpoint_id, max_export_power_w, controllable, notes)
SELECT
id, 'ongrid-gen', NULL, NULL, NULL, 10000, false,
'Ongridový střídač zapojený do GEN portu Deye. Autonomní provoz, EMS neřídí.'
FROM ems.site WHERE code = 'home-01';
-- ============================================================
-- AKTIVA BATERIE
-- ============================================================
INSERT INTO ems.asset_battery (site_id, inverter_id, code, usable_capacity_wh, min_soc_percent, reserve_soc_percent, max_soc_percent, charge_efficiency, discharge_efficiency, degradation_cost_czk_kwh)
SELECT
s.id, inv.id, 'bat-main',
64000, -- 64 kWh
10, -- min SoC 10 %
20, -- rezerva 20 % pro výpadek sítě
95, -- max SoC 95 %
0.95, 0.95,
0.50 -- Kč/kWh degradace, upřesnit dle záruky výrobce
FROM ems.site s
JOIN ems.asset_inverter inv ON inv.site_id = s.id AND inv.code = 'deye-main'
WHERE s.code = 'home-01';
-- ============================================================
-- AKTIVA FVE POLE
-- ============================================================
-- Pole A řízené Deye střídačem
INSERT INTO ems.asset_pv_array (site_id, inverter_id, code, name, nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor, controllable, notes)
SELECT
s.id, inv.id, 'pv-a', 'FVE pole A',
10000, -- 10 kWp
NULL, -- TODO: doplnit azimut (0=jih)
NULL, -- TODO: doplnit sklon (stupně)
NULL,
1.0,
true,
'Hlavní FVE pole řízené Deye střídačem. Doplnit azimut a sklon.'
FROM ems.site s
JOIN ems.asset_inverter inv ON inv.site_id = s.id AND inv.code = 'deye-main'
WHERE s.code = 'home-01';
-- Pole B ongridový, autonomní
INSERT INTO ems.asset_pv_array (site_id, inverter_id, code, name, nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor, controllable, notes)
SELECT
s.id, inv.id, 'pv-b', 'FVE pole B (ongrid)',
10000,
NULL, -- TODO: doplnit azimut
NULL, -- TODO: doplnit sklon
NULL,
1.0,
false,
'Ongridový střídač na GEN portu Deye. EMS neřídí, výkon se projeví v telemetrii Deye jako součást pv_power_w.'
FROM ems.site s
JOIN ems.asset_inverter inv ON inv.site_id = s.id AND inv.code = 'ongrid-gen'
WHERE s.code = 'home-01';
-- ============================================================
-- AKTIVA EV NABÍJEČKY
-- ============================================================
INSERT INTO ems.asset_ev_charger (site_id, code, manufacturer, model, endpoint_id, max_power_w, min_power_w, phases, connector_count, schedulable, notes)
SELECT
s.id, 'ev-charger-1', 'Teltonika', 'TeltoCharge 22kW',
ep.id,
22000, 1380, 3, 1, true,
'EV nabíječka č. 1. Modbus TCP přes Waveshare. Ověřit Modbus registry z dokumentace Teltonika.'
FROM ems.site s
JOIN ems.site_endpoint ep ON ep.site_id = s.id AND ep.notes LIKE '%TeltoCharge #1%'
WHERE s.code = 'home-01';
INSERT INTO ems.asset_ev_charger (site_id, code, manufacturer, model, endpoint_id, max_power_w, min_power_w, phases, connector_count, schedulable, notes)
SELECT
s.id, 'ev-charger-2', 'Teltonika', 'TeltoCharge 22kW',
ep.id,
22000, 1380, 3, 1, true,
'EV nabíječka č. 2. Modbus TCP přes Waveshare.'
FROM ems.site s
JOIN ems.site_endpoint ep ON ep.site_id = s.id AND ep.notes LIKE '%TeltoCharge #2%'
WHERE s.code = 'home-01';
-- ============================================================
-- AKTIVA TEPELNÉ ČERPADLO
-- ============================================================
INSERT INTO ems.asset_heat_pump (
site_id, code, manufacturer, model, endpoint_id,
rated_heating_power_w, cop_rated, cop_temp_reference_c,
min_run_duration_min, min_stop_duration_min,
tuv_tank_volume_l, tuv_min_temp_c, tuv_max_temp_c, tuv_target_temp_c,
tuv_temp_sensor_ref, schedulable, notes
)
SELECT
s.id, 'hp-samsung', 'Samsung', NULL, -- TODO: doplnit model
ep.id,
NULL, -- TODO: doplnit jmenovitý výkon W
NULL, -- TODO: doplnit COP rated
7.0, -- referenční teplota A7/W35
30, 15,
NULL, -- TODO: doplnit objem zásobníku
45, 60, 55,
NULL, -- TODO: doplnit odkaz na teplotní čidlo
true,
'Samsung tepelné čerpadlo s Modbus modulem. Řídit dle COP a venkovní teploty (výhodné kolem poledne v chladných měsících).'
FROM ems.site s
JOIN ems.site_endpoint ep ON ep.site_id = s.id AND ep.notes LIKE '%Samsung%'
WHERE s.code = 'home-01';

View File

@@ -0,0 +1,117 @@
-- =============================================================
-- V004__operating_modes.sql
-- EMS Platform provozní režimy lokalit
-- =============================================================
-- ============================================================
-- Číselník provozních režimů
-- ============================================================
CREATE TABLE ems.operating_mode_def (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
ev_enabled BOOLEAN NOT NULL DEFAULT false,
heat_pump_enabled BOOLEAN NOT NULL DEFAULT false,
battery_mode TEXT NOT NULL, -- 'plan', 'self_sustain', 'charge_max', 'hold', 'none'
grid_mode TEXT NOT NULL, -- 'plan', 'import_ok', 'no_import', 'no_export', 'none'
loxone_mode_value INT NOT NULL, -- integer posílaný do Loxone Virtual Input
is_autonomous BOOLEAN NOT NULL DEFAULT false, -- Loxone umí sám bez EMS setpointů
sort_order INT NOT NULL DEFAULT 50
);
COMMENT ON TABLE ems.operating_mode_def IS 'Číselník provozních režimů systému. Každý režim definuje chování baterie, sítě a flexibilních zařízení.';
COMMENT ON COLUMN ems.operating_mode_def.code IS 'Unikátní kód režimu. Příklad: AUTO, SELF_SUSTAIN.';
COMMENT ON COLUMN ems.operating_mode_def.name IS 'Lidsky čitelný název režimu pro UI.';
COMMENT ON COLUMN ems.operating_mode_def.description IS 'Popis chování v daném režimu.';
COMMENT ON COLUMN ems.operating_mode_def.ev_enabled IS 'Zda je povoleno nabíjení EV v tomto režimu.';
COMMENT ON COLUMN ems.operating_mode_def.heat_pump_enabled IS 'Zda je tepelné čerpadlo povoleno v tomto režimu.';
COMMENT ON COLUMN ems.operating_mode_def.battery_mode IS 'Chování baterie: plan=dle EMS plánu, self_sustain=vybíjí do domu, charge_max=max nabíjení, hold=drží SoC, none=žádné akce.';
COMMENT ON COLUMN ems.operating_mode_def.grid_mode IS 'Chování vůči síti: plan=dle EMS, import_ok=import povolen, no_import=bez importu, no_export=bez exportu, none=žádné akce.';
COMMENT ON COLUMN ems.operating_mode_def.loxone_mode_value IS 'Celočíselná hodnota posílaná do Loxone Virtual Input EMS_Mode. Loxone interně přepíná stavový stroj.';
COMMENT ON COLUMN ems.operating_mode_def.is_autonomous IS 'Pokud true, Loxone zvládne tento režim bez průběžných setpointů od EMS (fallback bezpečný).';
COMMENT ON COLUMN ems.operating_mode_def.sort_order IS 'Pořadí zobrazení v UI.';
INSERT INTO ems.operating_mode_def
(code, name, description, ev_enabled, heat_pump_enabled, battery_mode, grid_mode, loxone_mode_value, is_autonomous, sort_order)
VALUES
('AUTO', 'Automatický', 'EMS řídí vše podle optimalizovaného plánu. Setpointy posílány každých 15 min.',
true, true, 'plan', 'plan', 1, false, 10),
('SELF_SUSTAIN', 'Soběstačný', 'Fallback bez EMS. FVE + baterie pokrývají spotřebu. Žádný import, žádný export, EV a TČ odstaveny.',
false, false, 'self_sustain', 'no_export', 2, true, 20),
('CHARGE_CHEAP', 'Nabíjení levnou cenou', 'Manuální: max nabíjení baterie ze sítě. Použít při ručně identifikované levné ceně nebo akci.',
false, false, 'charge_max', 'import_ok', 3, false, 30),
('PRESERVE', 'Ochrana baterie', 'Dovolená / servis. Baterie drží aktuální SoC, žádné akce. EV a TČ odstaveny.',
false, false, 'hold', 'import_ok', 4, true, 40),
('MANUAL', 'Manuální', 'Technický přepis. EMS ani Loxone logika neřídí střídač. Pouze pro servisní práce.',
false, false, 'none', 'none', 0, true, 50);
-- ============================================================
-- Aktivní provozní režim per lokalita
-- ============================================================
CREATE TABLE ems.site_operating_mode (
site_id INT NOT NULL REFERENCES ems.site(id) PRIMARY KEY,
mode_code TEXT NOT NULL REFERENCES ems.operating_mode_def(code),
activated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_by TEXT,
valid_until TIMESTAMPTZ, -- NULL = platí do ručního přepnutí
previous_mode TEXT REFERENCES ems.operating_mode_def(code),
notes TEXT
);
COMMENT ON TABLE ems.site_operating_mode IS 'Aktuálně aktivní provozní režim per lokalita. Jeden řádek na lokalitu (upsert při přepnutí).';
COMMENT ON COLUMN ems.site_operating_mode.site_id IS 'Vazba na lokalitu. PK jedna lokalita má vždy právě jeden aktivní režim.';
COMMENT ON COLUMN ems.site_operating_mode.mode_code IS 'Aktuálně aktivní režim. FK na operating_mode_def.';
COMMENT ON COLUMN ems.site_operating_mode.activated_at IS 'Čas přepnutí do tohoto režimu.';
COMMENT ON COLUMN ems.site_operating_mode.activated_by IS 'Kdo nebo co přepnulo režim: user:jan, system:watchdog, api, loxone.';
COMMENT ON COLUMN ems.site_operating_mode.valid_until IS 'Automatické vypršení režimu. NULL = platí do ručního přepnutí.';
COMMENT ON COLUMN ems.site_operating_mode.previous_mode IS 'Předchozí režim pro rychlý návrat zpět.';
COMMENT ON COLUMN ems.site_operating_mode.notes IS 'Volný komentář k přepnutí.';
-- ============================================================
-- Historie přepnutí režimů (audit log)
-- ============================================================
CREATE TABLE ems.site_operating_mode_log (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
mode_code TEXT NOT NULL REFERENCES ems.operating_mode_def(code),
activated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deactivated_at TIMESTAMPTZ,
activated_by TEXT,
notes TEXT
);
COMMENT ON TABLE ems.site_operating_mode_log IS 'Auditní log všech přepnutí provozních režimů per lokalita.';
COMMENT ON COLUMN ems.site_operating_mode_log.id IS 'Primární klíč.';
COMMENT ON COLUMN ems.site_operating_mode_log.site_id IS 'Vazba na lokalitu.';
COMMENT ON COLUMN ems.site_operating_mode_log.mode_code IS 'Aktivovaný režim.';
COMMENT ON COLUMN ems.site_operating_mode_log.activated_at IS 'Čas aktivace režimu.';
COMMENT ON COLUMN ems.site_operating_mode_log.deactivated_at IS 'Čas deaktivace (přepnutí na jiný režim). NULL = stále aktivní.';
COMMENT ON COLUMN ems.site_operating_mode_log.activated_by IS 'Zdroj přepnutí: user:jan, system:watchdog, system:ems_start.';
COMMENT ON COLUMN ems.site_operating_mode_log.notes IS 'Volný komentář.';
-- ============================================================
-- Heartbeat tabulka pouze informační, pro EMS vlastní diagnostiku
-- POZOR: Loxone watchdog NEČTE tuto tabulku.
-- Loxone sleduje HTTP pulzy přímo (nezávisle na DB).
-- Tato tabulka slouží jen pro EMS dashboard a alerting.
-- ============================================================
CREATE TABLE ems.site_heartbeat (
site_id INT NOT NULL REFERENCES ems.site(id) PRIMARY KEY,
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
ems_version TEXT,
status TEXT NOT NULL DEFAULT 'ok'
);
COMMENT ON TABLE ems.site_heartbeat IS 'Informační záznam posledního úspěšného heartbeat pulzu EMS per lokalita. Slouží pouze pro EMS dashboard a alerting Loxone watchdog tuto tabulku nečte a nezávisí na ní. Fallback logika je implementována čistě v Loxone (viz docs/loxone-integration.md).';
COMMENT ON COLUMN ems.site_heartbeat.site_id IS 'Vazba na lokalitu. PK.';
COMMENT ON COLUMN ems.site_heartbeat.last_seen IS 'Čas kdy EMS backend naposledy úspěšně odeslal pulz do Loxone.';
COMMENT ON COLUMN ems.site_heartbeat.ems_version IS 'Verze EMS backendu pro diagnostiku.';
COMMENT ON COLUMN ems.site_heartbeat.status IS 'Stav EMS backendu: ok, degraded (částečný výpadek), error (kritická chyba).';

View File

@@ -0,0 +1,51 @@
-- =============================================================
-- V005__planning_curtailment.sql
-- EMS Platform rozšíření plánování o curtailment FVE pole A
-- a zelený bonus pole B
-- =============================================================
-- Přidat curtailment výsledek do planning_interval
ALTER TABLE ems.planning_interval
ADD COLUMN IF NOT EXISTS pv_a_curtailed_w INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS
'Plánované omezení výroby FVE pole A v W rozhodnuté LP solverem. '
'0 = žádné omezení výroby. Hodnota > 0 znamená že Deye dostane příkaz '
'omezit Output Power Limit na (pv_a_forecast_w - pv_a_curtailed_w).';
-- Přidat zelený bonus do audit_interval pro správnou ekonomiku
ALTER TABLE ems.audit_interval
ADD COLUMN IF NOT EXISTS pv_b_production_wh NUMERIC(10,3),
ADD COLUMN IF NOT EXISTS green_bonus_czk NUMERIC(10,4);
COMMENT ON COLUMN ems.audit_interval.pv_b_production_wh IS
'Skutečná výroba FVE pole B v Wh za 15min interval. '
'Odvozena z telemetrie: celkový pv_power_w minus výroba pole A (pokud měřena odděleně). '
'Slouží pro výpočet nároku na zelený bonus.';
COMMENT ON COLUMN ems.audit_interval.green_bonus_czk IS
'Příjem ze zeleného bonusu za výrobu pole B v Kč. '
'Vypočteno jako pv_b_production_wh / 1000 * green_bonus_czk_kwh z site_market_config. '
'Zahrnovat do celkových nákladů/příjmů lokality.';
-- Rozšíření site_market_config o zelený bonus
ALTER TABLE ems.site_market_config
ADD COLUMN IF NOT EXISTS green_bonus_czk_kwh NUMERIC(8,4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS green_bonus_asset_code TEXT;
COMMENT ON COLUMN ems.site_market_config.green_bonus_czk_kwh IS
'Výše zeleného bonusu (dotace) v Kč/kWh za vyrobenou elektřinu z FVE pole s dotací. '
'Bonus se vztahuje vždy na výrobu bez ohledu na cenu nebo způsob využití energie.';
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;

View File

@@ -0,0 +1,119 @@
-- =============================================================
-- V006__vehicles.sql
-- EMS Platform vozidla a EV session tracking
-- =============================================================
-- ============================================================
-- Vozidla
-- ============================================================
CREATE TABLE ems.asset_vehicle (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
code TEXT NOT NULL,
name TEXT,
make TEXT, -- 'Tesla', 'Renault'
model TEXT, -- 'Model Y', 'Zoe R135'
battery_capacity_kwh NUMERIC(6,2) NOT NULL, -- kapacita trakční baterie
max_charge_power_w INT NOT NULL, -- max přijímaný AC výkon vozidla
default_charger_id INT REFERENCES ems.asset_ev_charger(id),
api_type TEXT NOT NULL DEFAULT 'none', -- 'tesla', 'none'
api_reference TEXT, -- klíč do env/secrets pro API
default_target_soc_pct NUMERIC(5,2) NOT NULL DEFAULT 80,
default_deadline_hour INT NOT NULL DEFAULT 7, -- hodina rána
active BOOLEAN NOT NULL DEFAULT true,
UNIQUE (site_id, code)
);
COMMENT ON TABLE ems.asset_vehicle IS 'Vozidla registrovaná v EMS. Každé vozidlo má výchozí cílový SoC a deadline pro plánování nabíjení.';
COMMENT ON COLUMN ems.asset_vehicle.battery_capacity_kwh IS 'Celková kapacita trakční baterie vozidla v kWh. Tesla Model Y ~75 kWh, Renault Zoe R135 ~52 kWh.';
COMMENT ON COLUMN ems.asset_vehicle.max_charge_power_w IS 'Maximální výkon který vozidlo přijme přes AC nabíjení. Může být nižší než výkon WB. Tesla MY ~11 000 W, Zoe R135 ~7 400 W.';
COMMENT ON COLUMN ems.asset_vehicle.api_type IS 'Typ API pro čtení stavu vozidla. tesla = Tesla API (Tessie nebo přímé), none = žádné API.';
COMMENT ON COLUMN ems.asset_vehicle.api_reference IS 'Název env proměnné nebo klíče v secrets kde je uložen API token/přihlášení. Např. TESLA_MY_TOKEN.';
COMMENT ON COLUMN ems.asset_vehicle.default_target_soc_pct IS 'Výchozí cílový SoC pro deadline charging. Uživatel může přepsat v UI před odjezdem.';
COMMENT ON COLUMN ems.asset_vehicle.default_deadline_hour IS 'Výchozí hodina rána do které musí být dosažen target SoC. 7 = 07:00.';
-- ============================================================
-- EV session tracking
-- ============================================================
CREATE TABLE ems.ev_session (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
charger_id INT NOT NULL REFERENCES ems.asset_ev_charger(id),
vehicle_id INT REFERENCES ems.asset_vehicle(id), -- NULL pokud neznámé vozidlo
session_start TIMESTAMPTZ NOT NULL DEFAULT now(),
session_end TIMESTAMPTZ,
-- Stav při připojení (pokud znám)
soc_at_connect_pct NUMERIC(5,2),
-- Deadline požadavek
target_soc_pct NUMERIC(5,2),
target_deadline TIMESTAMPTZ,
-- Průběžný stav (aktualizován z telemetrie)
energy_delivered_wh NUMERIC(12,3) NOT NULL DEFAULT 0,
last_power_w INT,
-- Výsledek při odpojení
soc_at_disconnect_pct NUMERIC(5,2),
total_cost_czk NUMERIC(10,4),
deadline_met BOOLEAN, -- byl deadline splněn?
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ems.ev_session IS 'Záznamy jednotlivých nabíjecích sessions EV. Session začíná připojením (status != available na WB) a končí odpojením.';
COMMENT ON COLUMN ems.ev_session.vehicle_id IS 'Identifikace vozidla. Může být NULL pokud EMS neví které auto je připojeno (Zoe nemá API).';
COMMENT ON COLUMN ems.ev_session.soc_at_connect_pct IS 'SoC baterie vozidla při připojení. Pro Tesla čteno z API, pro Zoe NULL (neznáme).';
COMMENT ON COLUMN ems.ev_session.target_soc_pct IS 'Cílový SoC pro tuto session. Výchozí z asset_vehicle.default_target_soc_pct, uživatel může změnit v UI.';
COMMENT ON COLUMN ems.ev_session.target_deadline IS 'Deadline pro dosažení target_soc_pct. Výchozí = dnešní/zítřejší default_deadline_hour.';
COMMENT ON COLUMN ems.ev_session.energy_delivered_wh IS 'Kumulativní energie dodaná v této session v Wh. Čteno z WB Modbus (kWh counter). Slouží i pro odhad SoC u Zoe.';
COMMENT ON COLUMN ems.ev_session.deadline_met IS 'True pokud byl target_soc_pct dosažen před target_deadline. NULL pokud session ještě probíhá.';
-- Hypertable pro session log (sessions jsou časová série)
-- Poznámka: ev_session není hypertable sessions jsou krátké záznamy, ne telemetrie.
-- Index pro rychlé dotazy na aktivní session
CREATE INDEX idx_ev_session_active
ON ems.ev_session (charger_id, session_end)
WHERE session_end IS NULL;
CREATE INDEX idx_ev_session_site_time
ON ems.ev_session (site_id, session_start DESC);
-- ============================================================
-- Seed vozidla home-01
-- ============================================================
INSERT INTO ems.asset_vehicle
(site_id, code, name, make, model,
battery_capacity_kwh, max_charge_power_w,
default_charger_id, api_type,
default_target_soc_pct, default_deadline_hour)
SELECT
s.id,
'tesla-my', 'Tesla Model Y', 'Tesla', 'Model Y',
75.0,
11000, -- Tesla MY AC max ~11kW (3fáze × 16A × 230V)
ch1.id,
'none', -- Tesla API fáze 2, zatím none
80, -- 80% výchozí target Tesla má velkou baterii
7 -- do 7:00 ráno
FROM ems.site s
JOIN ems.asset_ev_charger ch1 ON ch1.site_id = s.id AND ch1.code = 'ev-charger-1'
WHERE s.code = 'home-01';
INSERT INTO ems.asset_vehicle
(site_id, code, name, make, model,
battery_capacity_kwh, max_charge_power_w,
default_charger_id, api_type,
default_target_soc_pct, default_deadline_hour)
SELECT
s.id,
'zoe-r135', 'Renault Zoe R135', 'Renault', 'Zoe R135',
52.0,
7400, -- Zoe R135 max 7.4kW AC (jednofáze 32A nebo 3fáze nižší)
ch2.id,
'none', -- Zoe nemá API
90, -- 90% výchozí target Zoe má menší baterii, kritičtější
7
FROM ems.site s
JOIN ems.asset_ev_charger ch2 ON ch2.site_id = s.id AND ch2.code = 'ev-charger-2'
WHERE s.code = 'home-01';

View File

@@ -0,0 +1,91 @@
-- =============================================================
-- V007__rolling_replanning.sql
-- EMS Platform rozšíření planning_run o rolling horizon typ
-- a forecast korekční faktory
-- =============================================================
-- ============================================================
-- Rozšíření planning_run o typ a kontext replanningu
-- ============================================================
ALTER TABLE ems.planning_run
ADD COLUMN IF NOT EXISTS run_type TEXT NOT NULL DEFAULT 'daily',
ADD COLUMN IF NOT EXISTS triggered_by TEXT,
ADD COLUMN IF NOT EXISTS replan_from TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS soc_at_replan_wh NUMERIC(10,2),
ADD COLUMN IF NOT EXISTS solver_duration_ms INT,
ADD COLUMN IF NOT EXISTS forecast_correction_factor NUMERIC(6,4);
COMMENT ON COLUMN ems.planning_run.run_type IS
'Typ plánovacího běhu:
daily = hlavní denní plán (15:00, horizont 36h)
rolling = průběžný replan každých 15min
manual = spuštěno ručně z UI nebo API';
COMMENT ON COLUMN ems.planning_run.triggered_by IS
'Co spustilo tento plánovací běh:
scheduler:daily, scheduler:rolling, user:jan, api, override_change';
COMMENT ON COLUMN ems.planning_run.replan_from IS
'Od kterého slotu byl plán přepočítán (pro rolling). NULL pro daily plán.
Sloty před replan_from jsou převzaty z předchozího aktivního plánu.';
COMMENT ON COLUMN ems.planning_run.soc_at_replan_wh IS
'Skutečný SoC baterie v Wh v okamžiku replanningu (z telemetrie).
Vstupní podmínka pro solver zpřesňuje počáteční SoC oproti dennímu plánu.';
COMMENT ON COLUMN ems.planning_run.solver_duration_ms IS
'Čas výpočtu LP solveru v milisekundách. Pro monitoring výkonu.';
COMMENT ON COLUMN ems.planning_run.forecast_correction_factor IS
'Korekční faktor aplikovaný na FVE forecast při tomto replanningu.
Vypočten z poměru skutečné vs. předpovídané výroby za posledních 60 minut.
1.0 = žádná korekce, 0.8 = skutečnost byla 80% forecastu.';
-- ============================================================
-- Rozšíření planning_interval o per-EV setpointy
-- (nahrazuje jeden agregovaný ev_charge_power_w)
-- ============================================================
ALTER TABLE ems.planning_interval
ADD COLUMN IF NOT EXISTS ev1_setpoint_w INT,
ADD COLUMN IF NOT EXISTS ev2_setpoint_w INT,
ADD COLUMN IF NOT EXISTS ev1_via_bat_w INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS ev2_via_bat_w INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN ems.planning_interval.ev1_setpoint_w IS
'Plánovaný celkový výkon nabíjení EV nabíječky 1 (Tesla) v W. NULL = auto nepřipojeno.';
COMMENT ON COLUMN ems.planning_interval.ev2_setpoint_w IS
'Plánovaný celkový výkon nabíjení EV nabíječky 2 (Zoe) v W. NULL = auto nepřipojeno.';
COMMENT ON COLUMN ems.planning_interval.ev1_via_bat_w IS
'Část výkonu EV1 která jde přes baterii (round-trip ztráta). 0 = přímé napájení.';
COMMENT ON COLUMN ems.planning_interval.ev2_via_bat_w IS
'Část výkonu EV2 která jde přes baterii (round-trip ztráta). 0 = přímé napájení.';
-- ============================================================
-- Tabulka forecast korekcí pro analýzu přesnosti
-- ============================================================
CREATE TABLE IF NOT EXISTS ems.forecast_correction_log (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
logged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
window_start TIMESTAMPTZ NOT NULL, -- začátek okna pro výpočet faktoru
window_end TIMESTAMPTZ NOT NULL,
actual_pv_wh NUMERIC(12,3), -- skutečná výroba za okno
forecast_pv_wh NUMERIC(12,3), -- předpovídaná výroba za okno
correction_factor NUMERIC(6,4), -- actual / forecast
applied_to_run_id INT REFERENCES ems.planning_run(id)
);
COMMENT ON TABLE ems.forecast_correction_log IS
'Log výpočtu korekčních faktorů FVE forecastu. Každý rolling replan zaznamená
poměr skutečné vs. předpovídané výroby za posledních 60 minut. Slouží pro
analýzu přesnosti forecastu a ladění modelu.';
COMMENT ON COLUMN ems.forecast_correction_log.correction_factor IS
'Poměr actual_pv_wh / forecast_pv_wh. Hodnoty typicky 0.51.5.
Extrémní hodnoty (oblačnost, porucha) jsou odfiltrovány v kódu (clamp 0.51.5).';

View File

@@ -0,0 +1,137 @@
-- =============================================================
-- R__fn_cop_estimate.sql
-- EMS Platform odhad COP tepelného čerpadla dle venkovní teploty
-- Repeatable migration
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_cop_estimate(
p_heat_pump_id INT,
p_outdoor_temp_c NUMERIC
)
RETURNS NUMERIC(4,2)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_cop_rated NUMERIC;
v_cop_ref_temp NUMERIC;
v_cop_estimated NUMERIC;
BEGIN
-- Načíst referenční COP a teplotu z konfigurace čerpadla
SELECT cop_rated, cop_temp_reference_c
INTO v_cop_rated, v_cop_ref_temp
FROM ems.asset_heat_pump
WHERE id = p_heat_pump_id;
IF v_cop_rated IS NULL OR v_cop_ref_temp IS NULL THEN
-- Fallback: obecný odhad pro vzduch-voda TČ bez konfigurace
-- Zdroj: přibližná lineární závislost COP na venkovní teplotě
-- COP ≈ 2.0 při -10°C, COP ≈ 4.5 při +15°C
v_cop_estimated := 2.0 + (p_outdoor_temp_c + 10.0) * (4.5 - 2.0) / 25.0;
ELSE
-- Lineární interpolace od referenčního bodu
-- COP klesá přibližně o 0.10 na každý stupeň poklesu venkovní teploty
-- Toto je zjednodušený model zpřesnit dle skutečných dat z tepelky
v_cop_estimated := v_cop_rated + (p_outdoor_temp_c - v_cop_ref_temp) * 0.10;
END IF;
-- Omezit na rozumné hodnoty (COP vzduch-voda reálně 1.56.0)
v_cop_estimated := GREATEST(1.5, LEAST(6.0, v_cop_estimated));
RETURN ROUND(v_cop_estimated, 2);
END;
$$;
COMMENT ON FUNCTION ems.fn_cop_estimate(INT, NUMERIC) IS
'Odhadne COP tepelného čerpadla pro danou venkovní teplotu.
Používá lineární model od referenčního bodu (cop_rated při cop_temp_reference_c).
Výstup slouží k rozhodnutí zda je výhodné spustit TČ v daném intervalu.
Přesnost modelu zlepšit kalibrací na historická data (cop_actual z telemetrie).
Výsledek omezen na rozsah 1.56.0.';
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION ems.fn_heat_pump_cost_per_kwh_heat(
p_heat_pump_id INT,
p_outdoor_temp_c NUMERIC,
p_buy_price_czk_kwh NUMERIC
)
RETURNS NUMERIC(8,4)
LANGUAGE sql
STABLE
AS $$
-- Cena za 1 kWh tepla = cena elektřiny / COP
-- Čím vyšší COP, tím levnější teplo
SELECT ROUND(
p_buy_price_czk_kwh / NULLIF(ems.fn_cop_estimate(p_heat_pump_id, p_outdoor_temp_c), 0),
4
);
$$;
COMMENT ON FUNCTION ems.fn_heat_pump_cost_per_kwh_heat(INT, NUMERIC, NUMERIC) IS
'Vypočte efektivní cenu za 1 kWh dodaného tepla v Kč.
Vstup: ID tepelného čerpadla, venkovní teplota, nákupní cena elektřiny.
Výstup slouží k porovnání výhodnosti ohřevu v různých časových intervalech.
Nižší hodnota = výhodnější čas pro provoz TČ.';
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION ems.fn_heat_pump_should_run(
p_heat_pump_id INT,
p_interval_start TIMESTAMPTZ,
p_outdoor_temp_c NUMERIC,
p_tuv_tank_temp_c NUMERIC,
p_buy_price_czk_kwh NUMERIC,
p_max_cost_threshold NUMERIC DEFAULT 3.0 -- Kč/kWh tepla maximální akceptovatelná cena
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_hp ems.asset_heat_pump%ROWTYPE;
v_cost_per_kwh NUMERIC;
v_override BOOLEAN;
BEGIN
SELECT * INTO v_hp FROM ems.asset_heat_pump WHERE id = p_heat_pump_id;
-- Kontrola override (blokování TČ)
SELECT EXISTS(
SELECT 1 FROM ems.site_override
WHERE site_id = v_hp.site_id
AND override_type = 'block_heat_pump'
AND valid_from <= p_interval_start
AND (valid_to IS NULL OR valid_to > p_interval_start)
) INTO v_override;
IF v_override THEN
RETURN false;
END IF;
-- Povinný ohřev: teplota pod minimem
IF p_tuv_tank_temp_c IS NOT NULL AND p_tuv_tank_temp_c < v_hp.tuv_min_temp_c THEN
RETURN true;
END IF;
-- Zásobník je plný
IF p_tuv_tank_temp_c IS NOT NULL AND p_tuv_tank_temp_c >= v_hp.tuv_max_temp_c THEN
RETURN false;
END IF;
-- Ekonomické rozhodnutí: spustit pokud cena tepla je pod prahem
v_cost_per_kwh := ems.fn_heat_pump_cost_per_kwh_heat(
p_heat_pump_id, p_outdoor_temp_c, p_buy_price_czk_kwh
);
RETURN v_cost_per_kwh <= p_max_cost_threshold;
END;
$$;
COMMENT ON FUNCTION ems.fn_heat_pump_should_run(INT, TIMESTAMPTZ, NUMERIC, NUMERIC, NUMERIC, NUMERIC) IS
'Rozhodne zda má tepelné čerpadlo v daném intervalu běžet.
Logika priorit:
1. Pokud existuje override block_heat_pump → false.
2. Pokud teplota zásobníku pod tuv_min_temp_c → true (povinný ohřev).
3. Pokud zásobník nad tuv_max_temp_c → false.
4. Jinak ekonomické rozhodnutí: spustit pokud cena tepla ≤ p_max_cost_threshold Kč/kWh.
Výhodné časy jsou přes poledne v chladných měsících (vyšší venkovní teplota = lepší COP).';

View File

@@ -0,0 +1,61 @@
-- =============================================================
-- R__fn_effective_price.sql
-- EMS Platform funkce pro výpočet efektivní ceny per site
-- Repeatable migration nasazuje se při každé změně
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_effective_buy_price(
p_site_id INT,
p_interval_start TIMESTAMPTZ
)
RETURNS NUMERIC(10,6)
LANGUAGE sql
STABLE
AS $$
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
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;
$$;
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.';
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION ems.fn_effective_sell_price(
p_site_id INT,
p_interval_start TIMESTAMPTZ
)
RETURNS NUMERIC(10,6)
LANGUAGE sql
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
LIMIT 1;
$$;
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).';

View File

@@ -0,0 +1,171 @@
-- =============================================================
-- R__fn_fill_audit_interval.sql
-- EMS Platform plnění audit_interval ze skutečné telemetrie
-- Repeatable migration
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_fill_audit_interval(
p_site_id INT,
p_interval_start TIMESTAMPTZ
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_interval_end TIMESTAMPTZ := p_interval_start + INTERVAL '15 minutes';
v_run_id INT;
v_avg_pv_power_w INT;
v_avg_battery_power_w INT;
v_avg_grid_power_w INT;
v_avg_load_power_w INT;
v_last_soc NUMERIC(5,2);
v_sum_ev_power_w INT;
v_avg_hp_power_w INT;
v_plan ems.planning_interval%ROWTYPE;
v_buy_price NUMERIC;
v_sell_price NUMERIC;
v_actual_cost NUMERIC := NULL;
BEGIN
-- Najít aktivní plán pro tento interval
SELECT pi.* INTO v_plan
FROM ems.planning_interval pi
JOIN ems.planning_run pr ON pr.id = pi.run_id
WHERE pr.site_id = p_site_id
AND pi.interval_start = p_interval_start
AND pr.status IN ('active', 'superseded')
ORDER BY pr.created_at DESC
LIMIT 1;
v_run_id := v_plan.run_id;
-- Agregovat telemetrii střídače (průměr za 15min; agregace bez GROUP BY vrací vždy 1 řádek)
SELECT
AVG(pv_power_w)::INT,
AVG(battery_power_w)::INT,
AVG(grid_power_w)::INT,
AVG(load_power_w)::INT,
LAST(battery_soc_percent, measured_at)
INTO
v_avg_pv_power_w,
v_avg_battery_power_w,
v_avg_grid_power_w,
v_avg_load_power_w,
v_last_soc
FROM ems.telemetry_inverter
WHERE site_id = p_site_id
AND measured_at >= p_interval_start
AND measured_at < v_interval_end;
-- Agregovat EV nabíječky (součet průměrů po charger_id)
SELECT COALESCE(SUM(avg_power), 0)::INT
INTO v_sum_ev_power_w
FROM (
SELECT AVG(power_w) AS avg_power
FROM ems.telemetry_ev_charger
WHERE site_id = p_site_id
AND measured_at >= p_interval_start
AND measured_at < v_interval_end
GROUP BY charger_id
) sub;
-- Agregovat tepelné čerpadlo
SELECT AVG(power_w)::INT
INTO v_avg_hp_power_w
FROM ems.telemetry_heat_pump
WHERE site_id = p_site_id
AND measured_at >= p_interval_start
AND measured_at < v_interval_end;
-- Efektivní cena pro výpočet skutečných nákladů
v_buy_price := ems.fn_effective_buy_price(p_site_id, p_interval_start);
v_sell_price := ems.fn_effective_sell_price(p_site_id, p_interval_start);
-- Skutečné náklady (kladný grid = nákup, záporný = prodej)
IF v_avg_grid_power_w IS NOT NULL THEN
v_actual_cost := (v_avg_grid_power_w::NUMERIC / 1000.0 / 4.0)
* CASE WHEN v_avg_grid_power_w >= 0
THEN COALESCE(v_buy_price, 0)
ELSE COALESCE(v_sell_price, 0) END;
END IF;
-- Upsert do audit_interval
INSERT INTO ems.audit_interval (
site_id, interval_start, planning_run_id,
actual_pv_power_w, actual_battery_power_w,
actual_grid_power_w, actual_load_power_w,
actual_battery_soc_pct,
actual_ev_power_w,
actual_heat_pump_power_w,
actual_cost_czk,
deviation_grid_w,
deviation_cost_czk
) VALUES (
p_site_id, p_interval_start, v_run_id,
v_avg_pv_power_w,
v_avg_battery_power_w,
v_avg_grid_power_w,
v_avg_load_power_w,
v_last_soc,
v_sum_ev_power_w,
v_avg_hp_power_w,
ROUND(v_actual_cost, 4),
CASE WHEN v_plan.run_id IS NOT NULL
THEN v_avg_grid_power_w - v_plan.grid_setpoint_w
ELSE NULL END,
CASE WHEN v_plan.run_id IS NOT NULL
THEN ROUND(v_actual_cost - COALESCE(v_plan.expected_cost_czk, 0), 4)
ELSE NULL END
)
ON CONFLICT (site_id, interval_start) DO UPDATE SET
planning_run_id = EXCLUDED.planning_run_id,
actual_pv_power_w = EXCLUDED.actual_pv_power_w,
actual_battery_power_w = EXCLUDED.actual_battery_power_w,
actual_grid_power_w = EXCLUDED.actual_grid_power_w,
actual_load_power_w = EXCLUDED.actual_load_power_w,
actual_battery_soc_pct = EXCLUDED.actual_battery_soc_pct,
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,
deviation_grid_w = EXCLUDED.deviation_grid_w,
deviation_cost_czk = EXCLUDED.deviation_cost_czk;
END;
$$;
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.
Volat každých 15 minut pro interval který právě skončil.';
-- ============================================================
-- Hromadné plnění auditu za historické období
-- ============================================================
CREATE OR REPLACE FUNCTION ems.fn_fill_audit_range(
p_site_id INT,
p_from TIMESTAMPTZ,
p_to TIMESTAMPTZ
)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_slot TIMESTAMPTZ;
v_count INT := 0;
BEGIN
v_slot := date_trunc('hour', p_from)
+ INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM p_from) / 15);
WHILE v_slot < p_to LOOP
PERFORM ems.fn_fill_audit_interval(p_site_id, v_slot);
v_slot := v_slot + INTERVAL '15 minutes';
v_count := v_count + 1;
END LOOP;
RETURN v_count;
END;
$$;
COMMENT ON FUNCTION ems.fn_fill_audit_range(INT, TIMESTAMPTZ, TIMESTAMPTZ) IS
'Hromadně naplní audit_interval pro celé historické období.
Volá fn_fill_audit_interval pro každý 15min slot v rozsahu p_fromp_to.
Vrátí počet zpracovaných intervalů. Použít pro backfill po výpadku nebo prvním nasazení.';

View File

@@ -0,0 +1,160 @@
-- =============================================================
-- R__fn_set_mode.sql
-- EMS Platform přepínání provozních režimů
-- Repeatable migration
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_set_mode(
p_site_id INT,
p_mode_code TEXT,
p_activated_by TEXT DEFAULT 'system',
p_valid_until TIMESTAMPTZ DEFAULT NULL,
p_notes TEXT DEFAULT NULL
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_current_mode TEXT;
v_mode_exists BOOLEAN;
BEGIN
-- Ověřit že režim existuje
SELECT EXISTS(SELECT 1 FROM ems.operating_mode_def WHERE code = p_mode_code)
INTO v_mode_exists;
IF NOT v_mode_exists THEN
RAISE EXCEPTION 'Neznámý provozní režim: %', p_mode_code;
END IF;
-- Zjistit aktuální režim (pro log a previous_mode)
SELECT mode_code INTO v_current_mode
FROM ems.site_operating_mode
WHERE site_id = p_site_id;
-- Pokud se režim nemění, nic nedělat
IF v_current_mode = p_mode_code THEN
RETURN p_mode_code;
END IF;
-- Uzavřít předchozí záznam v logu
UPDATE ems.site_operating_mode_log
SET deactivated_at = now()
WHERE site_id = p_site_id
AND deactivated_at IS NULL;
-- Upsert aktivního režimu
INSERT INTO ems.site_operating_mode
(site_id, mode_code, activated_at, activated_by, valid_until, previous_mode, notes)
VALUES
(p_site_id, p_mode_code, now(), p_activated_by, p_valid_until, v_current_mode, p_notes)
ON CONFLICT (site_id) DO UPDATE SET
mode_code = EXCLUDED.mode_code,
activated_at = EXCLUDED.activated_at,
activated_by = EXCLUDED.activated_by,
valid_until = EXCLUDED.valid_until,
previous_mode = EXCLUDED.previous_mode,
notes = EXCLUDED.notes;
-- Přidat záznam do logu
INSERT INTO ems.site_operating_mode_log
(site_id, mode_code, activated_at, activated_by, notes)
VALUES
(p_site_id, p_mode_code, now(), p_activated_by, p_notes);
RETURN p_mode_code;
END;
$$;
COMMENT ON FUNCTION ems.fn_set_mode(INT, TEXT, TEXT, TIMESTAMPTZ, TEXT) IS
'Přepne provozní režim lokality. Atomicky aktualizuje site_operating_mode a zapíše do audit logu.
Ignoruje přepnutí na stejný režim. Vyhodí výjimku pro neznámý kód režimu.
Příklad: SELECT ems.fn_set_mode(1, ''SELF_SUSTAIN'', ''user:jan'', NULL, ''Odjezd na dovolenou'');';
-- ============================================================
CREATE OR REPLACE FUNCTION ems.fn_restore_previous_mode(
p_site_id INT,
p_activated_by TEXT DEFAULT 'system'
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_previous TEXT;
BEGIN
SELECT previous_mode INTO v_previous
FROM ems.site_operating_mode
WHERE site_id = p_site_id;
IF v_previous IS NULL THEN
-- Fallback na AUTO pokud není předchozí režim
v_previous := 'AUTO';
END IF;
RETURN ems.fn_set_mode(p_site_id, v_previous, p_activated_by, NULL, 'Obnova předchozího režimu');
END;
$$;
COMMENT ON FUNCTION ems.fn_restore_previous_mode(INT, TEXT) IS
'Přepne lokalitu zpět na předchozí provozní režim (uložený v previous_mode).
Pokud předchozí režim neexistuje, přepne na AUTO. Používat po skončení dočasného přepisu.';
-- ============================================================
CREATE OR REPLACE FUNCTION ems.fn_expire_modes()
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_count INT := 0;
v_rec RECORD;
BEGIN
-- Najít lokality kde vypršel valid_until a přepnout na AUTO
FOR v_rec IN
SELECT site_id, previous_mode
FROM ems.site_operating_mode
WHERE valid_until IS NOT NULL
AND valid_until <= now()
AND mode_code <> 'AUTO'
LOOP
PERFORM ems.fn_set_mode(
v_rec.site_id,
COALESCE(v_rec.previous_mode, 'AUTO'),
'system:expiry',
NULL,
'Automatické vypršení dočasného režimu'
);
v_count := v_count + 1;
END LOOP;
RETURN v_count;
END;
$$;
COMMENT ON FUNCTION ems.fn_expire_modes() IS
'Zkontroluje všechny lokality s dočasným režimem (valid_until IS NOT NULL) a přepne zpět ty s prosahlým časem.
Volat každou minutu jako scheduled task. Vrátí počet přepnutých lokalit.';
-- ============================================================
CREATE OR REPLACE FUNCTION ems.fn_update_heartbeat(
p_site_id INT,
p_status TEXT DEFAULT 'ok',
p_ems_version TEXT DEFAULT NULL
)
RETURNS VOID
LANGUAGE sql
AS $$
INSERT INTO ems.site_heartbeat (site_id, last_seen, status, ems_version)
VALUES (p_site_id, now(), p_status, p_ems_version)
ON CONFLICT (site_id) DO UPDATE SET
last_seen = now(),
status = EXCLUDED.status,
ems_version = COALESCE(EXCLUDED.ems_version, ems.site_heartbeat.ems_version);
$$;
COMMENT ON FUNCTION ems.fn_update_heartbeat(INT, TEXT, TEXT) IS
'Aktualizuje informační heartbeat záznam EMS pro danou lokalitu.
Volat každou minutu z backend service po úspěšném odeslání pulzu do Loxone.
Slouží pouze pro EMS dashboard Loxone watchdog nezávisí na této tabulce,
sleduje HTTP pulzy přímo a nezávisle na dostupnosti DB.';

View File

@@ -0,0 +1,69 @@
-- =============================================================
-- R__vw_audit_summary.sql
-- EMS Platform přehledové views pro audit a dashboard
-- Repeatable migration
-- =============================================================
-- Denní souhrn per lokalita
CREATE OR REPLACE VIEW ems.vw_audit_daily AS
SELECT
site_id,
date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague') AS day_local,
COUNT(*) AS interval_count,
-- Energie (kWh = W × 15min / 60 = W / 4 / 1000)
ROUND(SUM(GREATEST(actual_grid_power_w, 0))::NUMERIC / 4000, 3) AS import_kwh,
ROUND(SUM(ABS(LEAST(actual_grid_power_w, 0)))::NUMERIC / 4000, 3) AS export_kwh,
ROUND(SUM(GREATEST(actual_pv_power_w, 0))::NUMERIC / 4000, 3) AS pv_kwh,
ROUND(SUM(GREATEST(actual_load_power_w, 0))::NUMERIC / 4000, 3) AS load_kwh,
ROUND(SUM(GREATEST(actual_ev_power_w, 0))::NUMERIC / 4000, 3) AS ev_kwh,
ROUND(SUM(GREATEST(actual_heat_pump_power_w, 0))::NUMERIC / 4000, 3) AS hp_kwh,
-- Náklady
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
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č.
Používat pro dashboard denního přehledu a reporty.';
-- ============================================================
-- Týdenní souhrn
CREATE OR REPLACE VIEW ems.vw_audit_weekly AS
SELECT
site_id,
date_trunc('week', interval_start AT TIME ZONE 'Europe/Prague') AS week_local,
ROUND(SUM(GREATEST(actual_grid_power_w, 0))::NUMERIC / 4000, 1) AS import_kwh,
ROUND(SUM(ABS(LEAST(actual_grid_power_w, 0)))::NUMERIC / 4000, 1) AS export_kwh,
ROUND(SUM(GREATEST(actual_pv_power_w, 0))::NUMERIC / 4000, 1) AS pv_kwh,
ROUND(SUM(actual_cost_czk), 0) AS actual_cost_czk
FROM ems.audit_interval
GROUP BY site_id, date_trunc('week', interval_start AT TIME ZONE 'Europe/Prague');
COMMENT ON VIEW ems.vw_audit_weekly IS
'Týdenní souhrn auditu per lokalita.';
-- ============================================================
-- Aktuální den hourly breakdown pro dashboard graf
CREATE OR REPLACE VIEW ems.vw_audit_today_hourly AS
SELECT
site_id,
date_trunc('hour', interval_start AT TIME ZONE 'Europe/Prague') AS hour_local,
ROUND(AVG(actual_pv_power_w)::NUMERIC / 1000, 2) AS avg_pv_kw,
ROUND(AVG(actual_battery_power_w)::NUMERIC / 1000, 2) AS avg_battery_kw,
ROUND(AVG(actual_grid_power_w)::NUMERIC / 1000, 2) AS avg_grid_kw,
ROUND(AVG(actual_load_power_w)::NUMERIC / 1000, 2) AS avg_load_kw,
ROUND(AVG(actual_battery_soc_pct), 1) AS avg_soc_pct,
ROUND(SUM(actual_cost_czk), 2) AS cost_czk
FROM ems.audit_interval
WHERE interval_start >= date_trunc('day', now() AT TIME ZONE 'Europe/Prague')
AND interval_start < date_trunc('day', now() AT TIME ZONE 'Europe/Prague') + INTERVAL '1 day'
GROUP BY site_id, date_trunc('hour', interval_start AT TIME ZONE 'Europe/Prague')
ORDER BY hour_local;
COMMENT ON VIEW ems.vw_audit_today_hourly IS
'Hodinový přehled dnešního dne pro dashboard graf výkonů a nákladů.';

View File

@@ -0,0 +1,77 @@
-- =============================================================
-- R__vw_latest_telemetry.sql
-- EMS Platform aktuální stav všech zařízení per lokalita
-- Repeatable migration
-- =============================================================
CREATE OR REPLACE VIEW ems.vw_latest_inverter AS
SELECT DISTINCT ON (t.inverter_id)
t.site_id,
t.inverter_id,
inv.code AS inverter_code,
t.measured_at,
t.pv_power_w,
t.battery_soc_percent,
t.battery_power_w,
t.grid_power_w,
t.load_power_w,
t.inverter_temp_c,
t.operating_mode,
t.fault_code,
now() - t.measured_at AS data_age
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;
COMMENT ON VIEW ems.vw_latest_inverter IS
'Nejnovější telemetrická data pro každý střídač. Slouží pro real-time dashboard a health check.';
-- ------------------------------------------------------------
CREATE OR REPLACE VIEW ems.vw_latest_ev_charger AS
SELECT DISTINCT ON (t.charger_id, t.connector_id)
t.site_id,
t.charger_id,
ch.code AS charger_code,
t.connector_id,
t.measured_at,
t.status,
t.power_w,
t.energy_kwh,
t.current_a,
t.session_id,
t.error_code,
now() - t.measured_at AS data_age
FROM ems.telemetry_ev_charger t
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
ORDER BY t.charger_id, t.connector_id, t.measured_at DESC;
COMMENT ON VIEW ems.vw_latest_ev_charger IS
'Nejnovější telemetrická data pro každý konektor EV nabíječky. Slouží pro dashboard a řízení nabíjení.';
-- ------------------------------------------------------------
CREATE OR REPLACE VIEW ems.vw_latest_heat_pump AS
SELECT DISTINCT ON (t.heat_pump_id)
t.site_id,
t.heat_pump_id,
hp.code AS heat_pump_code,
t.measured_at,
t.outdoor_temp_c,
t.tuv_tank_temp_c,
t.water_outlet_temp_c,
t.power_w,
t.operating_mode,
t.cop_actual,
t.defrost_active,
t.alarm_code,
-- Odhadovaný COP pro aktuální venkovní teplotu
ems.fn_cop_estimate(t.heat_pump_id, t.outdoor_temp_c) AS cop_estimated,
now() - t.measured_at AS data_age
FROM ems.telemetry_heat_pump t
JOIN ems.asset_heat_pump hp ON hp.id = t.heat_pump_id
ORDER BY t.heat_pump_id, t.measured_at DESC;
COMMENT ON VIEW ems.vw_latest_heat_pump IS
'Nejnovější telemetrická data pro každé tepelné čerpadlo včetně odhadovaného COP.
Slouží pro real-time dashboard a rozhodovací logiku plánování.';

View File

@@ -0,0 +1,75 @@
-- =============================================================
-- R__vw_operating_mode.sql
-- EMS Platform views pro provozní režimy a heartbeat
-- Repeatable migration
-- =============================================================
-- Aktuální stav všech lokalit (pro dashboard a PostgREST)
CREATE OR REPLACE VIEW ems.vw_site_status AS
SELECT
s.id AS site_id,
s.code AS site_code,
s.name AS site_name,
m.mode_code AS active_mode,
d.name AS mode_name,
d.description AS mode_description,
d.is_autonomous,
m.activated_at,
m.activated_by,
m.valid_until,
m.previous_mode,
m.notes AS mode_notes,
-- Heartbeat
hb.last_seen AS ems_last_seen,
hb.status AS ems_status,
EXTRACT(EPOCH FROM (now() - hb.last_seen))::INT AS ems_age_seconds,
-- Varování pokud EMS dlouho nepingoval
CASE
WHEN hb.last_seen IS NULL THEN 'never_seen'
WHEN now() - hb.last_seen > INTERVAL '5 minutes' THEN 'stale'
WHEN now() - hb.last_seen > INTERVAL '2 minutes' THEN 'delayed'
ELSE 'ok'
END AS ems_heartbeat_status,
-- Aktuální telemetrie (snapshot)
li.pv_power_w,
li.battery_soc_percent,
li.battery_power_w,
li.grid_power_w,
li.load_power_w,
li.measured_at AS telemetry_at
FROM ems.site s
LEFT JOIN ems.site_operating_mode m ON m.site_id = s.id
LEFT JOIN ems.operating_mode_def d ON d.code = m.mode_code
LEFT JOIN ems.site_heartbeat hb ON hb.site_id = s.id
LEFT JOIN ems.vw_latest_inverter li ON li.site_id = s.id
WHERE s.active = true;
COMMENT ON VIEW ems.vw_site_status IS
'Kompletní stavový přehled lokality: aktivní režim, heartbeat EMS, aktuální telemetrie.
Primární view pro dashboard a health check endpoint. Jeden řádek na aktivní lokalitu.
ems_heartbeat_status slouží pro EMS vlastní alerting Loxone watchdog tuto tabulku nečte,
sleduje HTTP pulzy přímo (viz docs/loxone-integration.md).';
-- ============================================================
-- Log přepnutí režimů (pro UI historii)
CREATE OR REPLACE VIEW ems.vw_mode_log_recent AS
SELECT
l.id,
l.site_id,
s.code AS site_code,
l.mode_code,
d.name AS mode_name,
l.activated_at,
l.deactivated_at,
EXTRACT(EPOCH FROM COALESCE(l.deactivated_at, now()) - l.activated_at)::INT AS duration_sec,
l.activated_by,
l.notes
FROM ems.site_operating_mode_log l
JOIN ems.site s ON s.id = l.site_id
JOIN ems.operating_mode_def d ON d.code = l.mode_code
WHERE l.activated_at >= now() - INTERVAL '7 days'
ORDER BY l.activated_at DESC;
COMMENT ON VIEW ems.vw_mode_log_recent IS
'Posledních 7 dní přepnutí provozních režimů. Slouží pro audit log v UI.';

View File

@@ -0,0 +1,42 @@
-- =============================================================
-- R__vw_site_effective_price.sql
-- EMS Platform view efektivních cen per site
-- Repeatable migration
-- =============================================================
CREATE OR REPLACE VIEW ems.vw_site_effective_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.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);
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.';

112
docker-compose.yml Normal file
View File

@@ -0,0 +1,112 @@
services:
db:
image: timescale/timescaledb:latest-pg16
restart: unless-stopped
environment:
POSTGRES_DB: ems
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ems"]
interval: 10s
timeout: 5s
retries: 5
flyway:
image: flyway/flyway:10
depends_on:
db:
condition: service_healthy
environment:
FLYWAY_URL: jdbc:postgresql://db:5432/ems
FLYWAY_USER: ${DB_USER}
FLYWAY_PASSWORD: ${DB_PASSWORD}
FLYWAY_SCHEMAS: ems
FLYWAY_LOCATIONS: filesystem:/flyway/sql/migration,filesystem:/flyway/sql/routines,filesystem:/flyway/sql/views
FLYWAY_BASELINE_ON_MIGRATE: "false"
command: migrate
volumes:
- ./db/migration:/flyway/sql/migration
- ./db/routines:/flyway/sql/routines
- ./db/views:/flyway/sql/views
postgrest:
image: postgrest/postgrest:v12.2.3
restart: unless-stopped
depends_on:
db:
condition: service_healthy
flyway:
condition: service_completed_successfully
environment:
PGRST_DB_URI: postgres://${DB_USER}:${DB_PASSWORD}@db:5432/ems
PGRST_DB_SCHEMA: ems
PGRST_DB_EXTRA_SEARCH_PATH: ems
PGRST_DB_ANON_ROLE: ${POSTGREST_ANON_ROLE:-ems_user}
PGRST_JWT_SECRET: ${POSTGREST_JWT_SECRET}
PGRST_SERVER_PORT: 3000
PGRST_OPENAPI_SERVER_PROXY_URI: http://localhost/rest
ports:
- "127.0.0.1:3000:3000"
backend:
build: ./backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
flyway:
condition: service_completed_successfully
env_file:
- .env
environment:
DB_HOST: db
DB_PORT: "5432"
POSTGRES_HOST: db
POSTGRES_PORT: "5432"
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}
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}
PLANNING_HORIZON_HOURS: ${PLANNING_HORIZON_HOURS:-36}
PLANNING_HP_MAX_COST_CZK_KWH: ${PLANNING_HP_MAX_COST_CZK_KWH:-3.0}
LOXONE_USER: ${LOXONE_USER:-}
LOXONE_PASSWORD: ${LOXONE_PASSWORD:-}
POSTGREST_JWT_SECRET: ${POSTGREST_JWT_SECRET}
POSTGREST_ANON_ROLE: ${POSTGREST_ANON_ROLE:-ems_user}
ports:
- "127.0.0.1:8000:8000"
volumes:
- ./backend:/app
frontend:
build: ./frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
- backend
- postgrest
volumes:
db_data:
driver: local
# Explicitní podsíť vyhne se vyčerpání výchozích Docker address pools při mnoha projektech.
# Pokud 172.28.240.0/24 koliduje s jinou sítí, změň subnet/gateway.
networks:
default:
name: ems-cursor_net
driver: bridge
ipam:
config:
- subnet: 172.28.240.0/24
gateway: 172.28.240.1

62
docs/01-overview.md Normal file
View File

@@ -0,0 +1,62 @@
# EMS Platform Overview
## Co systém dělá
Energy Management System (EMS) je multi-site platforma pro optimalizaci výroby, spotřeby a obchodování s energií na objektech vybavených FVE, baterií, flexibilními spotřebiči a přístupem ke spotovému trhu OTE CZ.
Systém přebírá rozhodovací logiku od Loxone a stává se „mozkem" plánuje, optimalizuje a posílá setpointy zpět do Loxone jako exekutoru.
## Hlavní funkce
- Sběr telemetrie ze střídače Deye přes Modbus/RS485 → Waveshare IP převodník
- Sběr dat z EV nabíječek Teltonika přes API
- Stahování spotových cen OTE CZ (15min granularita)
- Predikce výroby FVE (per pole, per azimut/sklon)
- Plánování provozu baterie, EV nabíjení, TUV na základě cen a predikce
- Export setpointů do Loxone přes HTTP Virtual Inputs
- Audit skutečnosti vs plánu
- Multi-site: jeden systém, více lokalit
## Co systém není
- Není SCADA neprovádí real-time ochranné funkce (to dělá Loxone/střídač)
- Neřídí ongridový střídač (10kWp zapojený do GEN portu) ten je autonomní
- Nenahrazuje Loxone jako exekutor lokální automatizace
## Scope první instalace (site: home-01)
| Komponenta | Detail |
|---|---|
| Střídač | Deye SUN-20K-SG01LP1-EU (20kW LV, hybridní) |
| Baterie | 64 kWh LV (připojená k Deye) |
| FVE pole A | ~10 kWp (řízené Deye) |
| FVE pole B | ~10 kWp (ongridový střídač → GEN port Deye, autonomní, neplánujeme řídit) |
| EV nabíječky | 2× Teltonika TeltoCharge 22kW |
| TUV | Tepelné čerpadlo / boiler (přes Loxone) |
| Komunikace střídač | RS485 → Waveshare WS-ETH (Modbus TCP) |
| Komunikace Loxone | HTTP Web Services / Virtual Inputs |
| Trh | OTE CZ, spotové ceny, 15min intervaly |
## Technologický stack
| Vrstva | Technologie |
|---|---|
| DB | PostgreSQL 16 + TimescaleDB |
| API / BFF | PostgREST (automatické REST z DB schématu) |
| Backend logika | Python (FastAPI) plánovač, sběr dat, integrace |
| Frontend | React + TypeScript |
| Komunikace střídač | Python modbus-tcp klient |
| Kontejnerizace | Docker Compose |
| Migrace | Flyway nebo plain SQL skripty |
## Časová granularita
**Primární granularita celého systému je 15 minut.**
- Spotové ceny: 15min intervaly
- Telemetrie: ukládána po 1min, agregována na 15min pro plánování
- Plánování: 15min sloty
- Setpointy pro Loxone: 15min
- Audit skutečnost vs plán: 15min
Hodinové pohledy existují pouze jako agregovaná views nad 15min daty.

217
docs/02-architecture.md Normal file
View File

@@ -0,0 +1,217 @@
# EMS Platform Architektura
## Vrstvy systému
```
┌─────────────────────────────────────────────┐
│ React Frontend (Vite + TypeScript) │
│ Dashboard, plány, telemetrie, overrides │
└─────────────┬───────────────────────────────┘
│ HTTP/REST
┌─────────────▼───────────────────────────────┐
│ PostgREST │
│ Auto-REST API z PostgreSQL schématu ems │
│ Read: views, tabulky │
│ Write: insert/update přes API │
└─────────────┬───────────────────────────────┘
│ SQL
┌─────────────▼───────────────────────────────┐
│ PostgreSQL 16 + TimescaleDB │
│ Schéma: ems │
│ Funkce, views, hypertables │
└─────────────┬───────────────────────────────┘
┌─────────────▼───────────────────────────────┐
│ FastAPI (Python) │
Scheduled tasks (APScheduler) │
telemetry_collector (každých 60s) │
price_importer (denně 14:00) │
forecast_service (denně 14:30) │
planning_engine (denně 15:00) │
control_exporter (každých 15min) │
audit_filler (každých 15min) │
└──────┬──────────────────────────┬────────────┘
│ Modbus TCP │ HTTP
┌──────▼──────┐ ┌───────▼────────────┐
│ Waveshare │ │ Loxone Miniserver │
│ WS-ETH │ │ (setpoint přijímač)│
└──────┬──────┘ └────────────────────┘
│ RS485
┌──────┼──────────────────────────────┐
│ Deye SUN-20K │ Teltonika 2× │ Samsung TČ │
└────────────────┴────────────────────┴──────────────┘
```
---
## Komponenty
| Komponenta | Technologie | Port | Popis |
|---|---|---|---|
| `db` | PostgreSQL 16 + TimescaleDB | 5432 | Datová vrstva |
| `postgrest` | PostgREST 12 | 3000 | Auto-REST API |
| `backend` | Python 3.12 / FastAPI | 8000 | Logika, scheduled tasks |
| `frontend` | React + Vite + TypeScript | 5173 (dev) / 80 (prod) | UI |
---
## Adresář projektu
```
ems-platform/
CLAUDE.md
docker-compose.yml
docker-compose.dev.yml
.env.example
.env ← gitignore!
db/
migration/
V001__init_schema.sql
V002__timescale_hypertables.sql
V003__seed_site_home01.sql
routines/
R__fn_effective_price.sql
R__fn_cop_estimate.sql
R__fn_baseline_consumption.sql
R__fn_fill_audit_interval.sql
R__fn_plan_day.sql
R__fn_create_planning_run.sql
views/
R__vw_site_effective_price.sql
R__vw_latest_telemetry.sql
R__vw_actual_baseline.sql
R__vw_audit_summary.sql
R__vw_heat_pump_cop_history.sql
flyway.conf
backend/
Dockerfile
requirements.txt
app/
main.py ← FastAPI app + scheduler setup
config.py ← settings z env
database.py ← asyncpg connection pool
services/
telemetry_collector.py
price_importer.py
forecast_service.py
planning_engine.py ← volá ems.fn_create_planning_run()
control_exporter.py
audit_filler.py
modbus/
deye_client.py
ev_charger_client.py
heat_pump_client.py
models/
site.py
assets.py
setpoints.py
frontend/
Dockerfile
package.json
vite.config.ts
src/
main.tsx
App.tsx
api/
postgrest.ts ← PostgREST client
backend.ts ← FastAPI client
pages/
Dashboard.tsx
Planning.tsx
Telemetry.tsx
Settings.tsx
components/
PowerFlowChart.tsx
PriceChart.tsx
SocGauge.tsx
OverridePanel.tsx
docs/
01-overview.md
02-architecture.md ← tento soubor
03-data-model.md
04-modules/
market-prices.md
forecast.md
consumption.md
heat-pump.md
telemetry.md
control.md
planning.md
06-open-questions.md
```
---
## Komunikační toky
### Sběr dat (každých 60s)
```
Zařízení → Waveshare → Modbus TCP → telemetry_collector → PostgreSQL
```
### Denní plánování (15:00)
```
PostgreSQL (ceny + forecast) → fn_create_planning_run() → planning_interval
```
### Export setpointů (každých 15min)
```
PostgreSQL (planning_interval + overrides) → control_exporter
→ Modbus TCP → Waveshare → Deye / Teltonika / Samsung
→ HTTP → Loxone
```
### Frontend
```
Browser → PostgREST (čtení views/tabulek)
Browser → FastAPI (triggery: replanning, override, manual export)
```
---
## Deployment: single-site (výchozí)
Vše na jednom stroji (x86 mini PC nebo silnější RPi 5):
```
Docker Compose:
db (PostgreSQL + TimescaleDB)
postgrest (PostgREST)
backend (FastAPI + všechny services)
frontend (Nginx + React build)
flyway (migrace při startu)
```
### Minimální HW požadavky
| Parametr | Minimum | Doporučeno |
|---|---|---|
| CPU | 2 jádra | 4 jádra (x86) |
| RAM | 4 GB | 8 GB |
| Storage | SSD 64 GB | NVMe 256 GB |
| OS | Debian 12 / Ubuntu 22.04 | Ubuntu 22.04 LTS |
| Síť | 100 Mbps | Gigabit Ethernet |
> **Raspberry Pi 5 (8GB):** Použitelné pro single-site s SSD přes USB 3 nebo NVMe HAT.
> **Nedoporučovat microSD** TimescaleDB zápisy microSD rychle opotřebují.
---
## Flyway konfigurace
```properties
# db/flyway.conf
flyway.url=jdbc:postgresql://db:5432/ems
flyway.user=${DB_USER}
flyway.password=${DB_PASSWORD}
flyway.schemas=ems
flyway.locations=filesystem:/flyway/migration,filesystem:/flyway/routines,filesystem:/flyway/views
flyway.validateOnMigrate=true
flyway.outOfOrder=false
```
Flyway se spouští jako jednorázový kontejner při `docker-compose up`.

430
docs/03-data-model.md Normal file
View File

@@ -0,0 +1,430 @@
# EMS Platform Data Model
## Principy
- Vše je vztaženo ke `site` (lokalitě)
- Raw tržní data jsou sdílená, efektivní ceny se dopočítávají per site
- Telemetrie a plány mají vždy `site_id` + `interval_start`
- TimescaleDB hypertable pro časové série (telemetrie, ceny, plány)
- Primární časová granularita: **15 minut**
---
## Konfigurace lokalit
### `site`
Základní entita. Jedna instalace = jeden objekt.
```sql
CREATE TABLE site (
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL, -- např. 'home-01'
name TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Europe/Prague',
active BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
```
### `site_endpoint`
Komunikační endpointy každá lokalita může mít více.
```sql
CREATE TABLE site_endpoint (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
endpoint_type TEXT NOT NULL, -- 'modbus_tcp', 'loxone_http', 'teltonika_api'
host TEXT NOT NULL,
port INT,
protocol TEXT, -- 'modbus_tcp', 'http', 'https'
auth_reference TEXT, -- odkaz na secret / env proměnnou
enabled BOOLEAN DEFAULT true,
notes TEXT
);
```
### `site_market_config`
Obchodní konfigurace s maržemi. Platnost od-do umožňuje historické sledování změn.
```sql
CREATE TABLE site_market_config (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
purchase_pricing_mode TEXT NOT NULL DEFAULT 'spot', -- 'spot', 'fixed', 'hybrid'
sale_pricing_mode TEXT NOT NULL DEFAULT 'spot',
buy_margin_fixed_czk NUMERIC(10,4) DEFAULT 0, -- Kč/kWh
buy_margin_percent NUMERIC(6,4) DEFAULT 0,
sell_margin_fixed_czk NUMERIC(10,4) DEFAULT 0, -- Kč/kWh (záporná = srážka)
sell_margin_percent NUMERIC(6,4) DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'CZK',
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ, -- NULL = aktuálně platný
notes TEXT
);
```
### `site_grid_connection`
Síťová omezení lokality.
```sql
CREATE TABLE site_grid_connection (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id) UNIQUE,
max_import_power_w INT NOT NULL,
max_export_power_w INT NOT NULL DEFAULT 0,
no_export BOOLEAN DEFAULT false,
reserved_capacity_w INT DEFAULT 0,
notes TEXT
);
```
---
## Aktiva
### `asset_inverter`
```sql
CREATE TABLE asset_inverter (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
code TEXT NOT NULL,
manufacturer TEXT, -- 'Deye'
model TEXT, -- 'SUN-20K-SG01LP1-EU'
endpoint_id INT REFERENCES site_endpoint(id),
max_charge_power_w INT,
max_discharge_power_w INT,
max_export_power_w INT,
controllable BOOLEAN DEFAULT true, -- false = ongridový autonomní
notes TEXT
);
```
### `asset_battery`
```sql
CREATE TABLE asset_battery (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
inverter_id INT REFERENCES asset_inverter(id),
code TEXT NOT NULL,
usable_capacity_wh INT NOT NULL, -- 64000
min_soc_percent NUMERIC(5,2) DEFAULT 10,
reserve_soc_percent NUMERIC(5,2) DEFAULT 20, -- rezerva pro výpadek
max_soc_percent NUMERIC(5,2) DEFAULT 95,
charge_efficiency NUMERIC(5,4) DEFAULT 0.95,
discharge_efficiency NUMERIC(5,4) DEFAULT 0.95,
degradation_cost_czk_kwh NUMERIC(8,4) DEFAULT 0.5 -- náklad na cyklus
);
```
### `asset_pv_array`
Každé FVE pole zvlášť důležité pro predikci (azimut, sklon).
```sql
CREATE TABLE asset_pv_array (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
inverter_id INT REFERENCES asset_inverter(id),
code TEXT NOT NULL,
name TEXT,
nominal_power_wp INT NOT NULL, -- 10000
azimuth_deg NUMERIC(6,2), -- 0=S, 90=Z, -90=V
tilt_deg NUMERIC(5,2),
module_count INT,
shading_factor NUMERIC(4,3) DEFAULT 1.0,
controllable BOOLEAN DEFAULT false, -- ongridový = false
notes TEXT
);
```
### `asset_ev_charger`
EV nabíječky. Na první instalaci jsou 2× Teltonika.
```sql
CREATE TABLE asset_ev_charger (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
code TEXT NOT NULL,
manufacturer TEXT, -- 'Teltonika'
model TEXT, -- 'TeltoCharge'
endpoint_id INT REFERENCES site_endpoint(id),
max_power_w INT NOT NULL, -- 22000
min_power_w INT DEFAULT 1380, -- min pro jednofázové nabíjení
phases INT DEFAULT 3,
connector_count INT DEFAULT 1,
schedulable BOOLEAN DEFAULT true,
notes TEXT
);
```
### `asset_flexible_device`
Generická tabulka pro ostatní flexibilní spotřebiče (TUV, tepelné čerpadlo, ...).
```sql
CREATE TABLE asset_flexible_device (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
code TEXT NOT NULL,
device_type TEXT NOT NULL, -- 'tuv', 'heat_pump', 'pool', ...
control_mode TEXT DEFAULT 'loxone', -- jak se řídí
max_power_w INT,
min_power_w INT DEFAULT 0,
interruptible BOOLEAN DEFAULT true,
schedulable BOOLEAN DEFAULT true,
priority INT DEFAULT 50, -- 0=nejvyšší priorita
notes TEXT
);
```
---
## Tržní data
### `market_interval_price`
Raw spotové ceny OTE CZ. Bez vazby na lokalitu sdílené.
TimescaleDB hypertable.
```sql
CREATE TABLE market_interval_price (
market_source TEXT NOT NULL DEFAULT 'OTE_CZ',
interval_start TIMESTAMPTZ NOT NULL,
interval_end TIMESTAMPTZ NOT NULL,
buy_raw_price_czk_kwh NUMERIC(10,6), -- nákupní raw cena Kč/kWh
sell_raw_price_czk_kwh NUMERIC(10,6), -- prodejní raw cena (ref. cena)
currency TEXT DEFAULT 'CZK',
imported_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (market_source, interval_start)
);
-- SELECT create_hypertable('market_interval_price', 'interval_start');
```
### View: `market_vw_site_effective_price`
Efektivní ceny per site dopočítané z raw + marže. Neukládá se, počítá se za běhu.
```sql
CREATE VIEW market_vw_site_effective_price AS
SELECT
smc.site_id,
mip.interval_start,
mip.interval_end,
mip.buy_raw_price_czk_kwh,
mip.sell_raw_price_czk_kwh,
-- efektivní nákupní cena
mip.buy_raw_price_czk_kwh
+ smc.buy_margin_fixed_czk
+ (mip.buy_raw_price_czk_kwh * smc.buy_margin_percent / 100)
AS effective_buy_price_czk_kwh,
-- efektivní prodejní cena
mip.sell_raw_price_czk_kwh
+ smc.sell_margin_fixed_czk
+ (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100)
AS effective_sell_price_czk_kwh
FROM market_interval_price mip
CROSS JOIN site_market_config smc
WHERE smc.valid_to IS NULL -- aktuálně platná konfigurace
OR now() BETWEEN smc.valid_from AND smc.valid_to;
```
---
## Telemetrie
### `telemetry_inverter`
Raw měření ze střídače Deye (Modbus). 1min granularita, hypertable.
```sql
CREATE TABLE telemetry_inverter (
site_id INT REFERENCES site(id),
inverter_id INT REFERENCES asset_inverter(id),
measured_at TIMESTAMPTZ NOT NULL,
-- výroba
pv_power_w INT, -- celkový výkon FVE (oba stringy)
-- baterie
battery_soc_percent NUMERIC(5,2),
battery_power_w INT, -- kladné = nabíjení, záporné = vybíjení
battery_voltage_v NUMERIC(7,3),
-- síť
grid_power_w INT, -- kladné = import, záporné = export
grid_voltage_v NUMERIC(7,3),
-- spotřeba
load_power_w INT, -- celková spotřeba objektu
-- teploty
inverter_temp_c NUMERIC(5,2),
-- provozní stav
operating_mode TEXT, -- raw hodnota z Modbus registru
fault_code INT,
PRIMARY KEY (inverter_id, measured_at)
);
-- SELECT create_hypertable('telemetry_inverter', 'measured_at');
```
### `telemetry_ev_charger`
Stav EV nabíječek.
```sql
CREATE TABLE telemetry_ev_charger (
site_id INT REFERENCES site(id),
charger_id INT REFERENCES asset_ev_charger(id),
measured_at TIMESTAMPTZ NOT NULL,
connector_id INT DEFAULT 1,
status TEXT, -- 'available', 'charging', 'faulted', ...
power_w INT,
energy_kwh NUMERIC(10,3), -- kumulativní
current_a NUMERIC(7,3),
voltage_v NUMERIC(7,3),
session_id TEXT,
PRIMARY KEY (charger_id, connector_id, measured_at)
);
-- SELECT create_hypertable('telemetry_ev_charger', 'measured_at');
```
---
## Predikce výroby
### `forecast_pv_run`
Každý běh predikce je jeden záznam (kdy se spustil, jaký model, jaké pole).
```sql
CREATE TABLE forecast_pv_run (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
pv_array_id INT REFERENCES asset_pv_array(id), -- NULL = celá lokalita
forecast_source TEXT NOT NULL, -- 'open_meteo', 'solcast', 'manual'
model_params JSONB, -- parametry modelu
horizon_start TIMESTAMPTZ NOT NULL,
horizon_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
status TEXT DEFAULT 'ok' -- 'ok', 'partial', 'failed'
);
```
### `forecast_pv_interval`
Samotná predikovaná data, 15min granularita.
```sql
CREATE TABLE forecast_pv_interval (
run_id INT REFERENCES forecast_pv_run(id),
pv_array_id INT REFERENCES asset_pv_array(id),
interval_start TIMESTAMPTZ NOT NULL,
power_w INT NOT NULL, -- predikovaný výkon
irradiance_wm2 NUMERIC(8,2), -- GHI ze weather service
temp_c NUMERIC(5,2),
PRIMARY KEY (run_id, pv_array_id, interval_start)
);
-- SELECT create_hypertable('forecast_pv_interval', 'interval_start');
```
---
## Plánování
### `planning_run`
Jeden plánovací běh per site.
```sql
CREATE TABLE planning_run (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
horizon_start TIMESTAMPTZ NOT NULL,
horizon_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
status TEXT DEFAULT 'draft', -- 'draft', 'approved', 'active', 'superseded'
solver_params JSONB,
notes TEXT
);
```
### `planning_interval`
Výstup plánování jeden řádek = jeden 15min slot.
```sql
CREATE TABLE planning_interval (
run_id INT REFERENCES planning_run(id),
interval_start TIMESTAMPTZ NOT NULL,
-- baterie
battery_setpoint_w INT, -- kladné = nabíjení, záporné = vybíjení
battery_soc_target_pct NUMERIC(5,2),
-- grid
grid_setpoint_w INT, -- kladné = import, záporné = export
-- EV (agregát za všechny nabíječky na site)
ev_charge_power_w INT,
-- TUV
tuv_enabled BOOLEAN,
-- ekonomika
expected_cost_czk NUMERIC(10,4),
effective_buy_price NUMERIC(10,6),
effective_sell_price NUMERIC(10,6),
PRIMARY KEY (run_id, interval_start)
);
```
---
## Audit
### `audit_interval`
Skutečnost vs plán, 15min granularita. Plněno zpětně po dostupnosti dat.
```sql
CREATE TABLE audit_interval (
site_id INT REFERENCES site(id),
interval_start TIMESTAMPTZ NOT NULL,
planning_run_id INT REFERENCES planning_run(id),
-- skutečnost (z telemetrie, průměr/agregát za 15min)
actual_pv_power_w INT,
actual_battery_power_w INT,
actual_grid_power_w INT,
actual_load_power_w INT,
actual_battery_soc NUMERIC(5,2),
-- odchylky
deviation_grid_w INT, -- actual - planned
actual_cost_czk NUMERIC(10,4),
PRIMARY KEY (site_id, interval_start)
);
-- SELECT create_hypertable('audit_interval', 'interval_start');
```
---
## Spotřeba
### `consumption_baseline_interval`
Bazální (neflexibilní) spotřeba historická a predikovaná, 15min.
```sql
CREATE TABLE consumption_baseline_interval (
site_id INT REFERENCES site(id),
interval_start TIMESTAMPTZ NOT NULL,
data_type TEXT NOT NULL, -- 'actual', 'forecast'
power_w INT NOT NULL,
source TEXT, -- 'measured', 'model_v1', ...
PRIMARY KEY (site_id, data_type, interval_start)
);
```
### Flexibilní spotřebiče
Flexibilní spotřeba se neukládá souhrnně odvozuje se ze součtu `telemetry_ev_charger` + stavů `asset_flexible_device` per interval. Plánovaná flexibilní spotřeba je součástí `planning_interval`.
---
## Override
### `site_override`
Manuální přepisy pro provozní stavy.
```sql
CREATE TABLE site_override (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES site(id),
override_type TEXT NOT NULL, -- 'force_charge', 'force_discharge', 'block_export', 'manual_setpoint'
value_json JSONB, -- parametry přepisu
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ,
reason TEXT,
created_by TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
```

View File

@@ -0,0 +1,187 @@
# Modul: Consumption (Spotřeba)
## Členění spotřeby
Systém rozlišuje dva typy spotřeby:
### 1. Bazální (neflexibilní) spotřeba
- Spotřeba kterou nelze odložit ani řídit
- Příklady: osvětlení, elektronika, vaření, cirkulační čerpadla
- **Zdroj:** měřená telemetrie ze střídače (`load_power_w` - suma flexibilní spotřeby)
- **Použití v plánování:** jako pevný vstup (musí být pokryta)
### 2. Flexibilní spotřeba
- Spotřeba kterou lze časově přesunout nebo regulovat
- Příklady: nabíjení EV, ohřev TUV, tepelné čerpadlo (při přetopení zásobníku)
- **Zdroj:** telemetrie z konkrétních zařízení (EV nabíječky, stavové vstupy Loxone)
- **Použití v plánování:** jako optimalizovatelná proměnná
---
## Jak se měří celková spotřeba
Střídač Deye poskytuje přes Modbus registr `load_power_w` = celková okamžitá spotřeba objektu (vše za hlavním jističem na AC straně střídače).
```
load_power_w (Deye) = bazální_spotřeba + EV_nabíjení + TUV + ostatní flexibilní
```
### Odvození bazální spotřeby
```
bazální_w = load_power_w - sum(flexibilní zařízení aktuální výkon)
```
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ášť)
```
> **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í spotřeby
### Real-time telemetrie
Celková spotřeba je součástí `telemetry_inverter.load_power_w` (1min záznamy).
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í
---
## Predikce bazální spotřeby
### Metoda: historický průměr + denní profil
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
```
---
## Flexibilní zařízení detailní popis
### EV nabíječky (Teltonika TeltoCharge 22kW)
**Komunikace:** Teltonika poskytuje REST API a/nebo OCPP protokol.
| Parametr | Hodnota |
|---|---|
| Max výkon | 22 000 W (třífázové) |
| Min výkon (1 fáze) | 1 380 W |
| Počet na home-01 | 2 |
| Protokol | OCPP 1.6 nebo Teltonika REST API |
**Co systém řídí:**
- Povolení/zakázání nabíjení (smart charging on/off)
- Omezení výkonu (charge current limit v Amperech)
- Časový plán nabíjení (nastavit okno kdy smí nabíjet)
**Telemetrie (stahuje se každou minutu):**
- stav konektoru (available / charging / faulted)
- aktuální výkon [W]
- kumulativní energie [kWh]
- proud [A], napětí [V]
- session ID
**Plánování:**
- EV se nabíjí v době levné energie nebo přebytku FVE
- Respektuje požadavek uživatele: "nabitý na X % do Y hodin"
- Pokud není požadavek nastaven → nabíjí při přebytku nebo nejlevnějším spotu
> **Otevřený bod:** Teltonika API vs OCPP rozhodnout při první integraci. Doporučujeme OCPP pro standardizaci.
---
### TUV / Tepelné čerpadlo
**Komunikace:** přes Loxone (HTTP Virtual Input zapnout/vypnout)
**Co systém řídí:**
- Povolení ohřevu (Loxone přepne výstupní relé)
- Systém pošle setpoint do Loxone, Loxone provede
**Telemetrie:**
- Stav ON/OFF (čteme z Loxone HTTP výstupu nebo Virtual Output stavu)
- Teplota zásobníku (pokud je čidlo v Loxone doporučeno)
- Aktuální výkon: není přímo měřen, používáme `max_power_w` z `asset_flexible_device`
**Plánování:**
- TUV se ohřívá v době přebytku FVE nebo levného spotu
- Minimální a maximální teplota zásobníku je respektována (pokud máme čidlo)
- Nouzová priorita: pokud teplota pod minimum → ohřát bez ohledu na cenu
---
## Výpočet bazální spotřeby v auditu
```sql
-- Agregovaná skutečná bazální spotřeba za 15min interval
CREATE VIEW consumption_vw_actual_baseline AS
SELECT
t.site_id,
time_bucket('15 minutes', t.measured_at) AS interval_start,
AVG(
t.load_power_w
- COALESCE(ev1.power_w, 0)
- COALESCE(ev2.power_w, 0)
-- TUV: odečíst max_power pokud byl v daném intervalu aktivní
) AS baseline_power_w
FROM telemetry_inverter t
-- JOIN na EV telemetrii
GROUP BY t.site_id, time_bucket('15 minutes', t.measured_at);
```
---
## Konfigurace (env proměnné)
```env
CONSUMPTION_FORECAST_LOOKBACK_WEEKS=4
TELTONIKA_API_URL_1=http://192.168.x.x/api # charger 1
TELTONIKA_API_URL_2=http://192.168.x.x/api # charger 2
TELTONIKA_POLL_INTERVAL_SEC=60
TUV_DEFAULT_POWER_W=2000 # fallback pokud není měřeno
```
---
## Otevřené body
- [ ] Teltonika: OCPP vs REST API rozhodnout před implementací
- [ ] TUV teplota zásobníku: přidat čidlo do Loxone pro přesnější řízení
- [ ] Bazální spotřeba: zpřesnit odečítání TUV výkonu (ON/OFF × čas vs pevný výkon)
- [ ] Sezónní korekce predikce spotřeby (léto vs zima) fáze 2

254
docs/04-modules/control.md Normal file
View File

@@ -0,0 +1,254 @@
# Modul: Control (Export setpointů)
## Co modul dělá
- Čte aktivní plán z DB pro daný 15min interval
- Zkontroluje override záznamy
- Zapíše setpointy do Deye přes Modbus TCP
- Zapíše setpointy EV nabíječek přes Modbus TCP
- Zapíše setpointy tepelného čerpadla přes Modbus TCP
- Odešle potvrzovací setpointy do Loxone přes HTTP (Loxone jako exekutor fallback logiky)
- Loguje každý write pro audit
---
## Architektura řízení
```
DB (planning_interval + site_override)
control_exporter.py (každých 15min nebo on-demand)
├── Modbus write → Deye (baterie, grid limit)
├── Modbus write → Teltonika EV nabíječka 1
├── Modbus write → Teltonika EV nabíječka 2
├── Modbus write → Samsung TČ
└── HTTP POST → Loxone Virtual Inputs (informační setpointy)
```
**Loxone role:** Loxone dostává setpointy jako informaci a jako fallback ochranu.
Rozhodovací logika je v EMS, ne v Loxone.
---
## Spouštění
| Trigger | Čas | Popis |
|---|---|---|
| Scheduled | každých 15min (xx:00, xx:15, xx:30, xx:45) | Standardní export na začátku intervalu |
| On-demand | po vytvoření nového plánu | Okamžitý export pokud plán překrývá aktuální čas |
| On-demand | po vytvoření override | Okamžitá aplikace přepisu |
---
## Logika exportu
```python
async def export_setpoints_for_interval(site_id: int, interval_start: datetime, db):
"""
Načte plánované setpointy pro daný interval, aplikuje overrides
a zapíše do všech zařízení.
"""
# 1. Načíst aktivní plán
plan = await db.fetchrow("""
SELECT pi.*
FROM ems.planning_interval pi
JOIN ems.planning_run pr ON pr.id = pi.run_id
WHERE pr.site_id = $1
AND pi.interval_start = $2
AND pr.status = 'active'
ORDER BY pr.created_at DESC
LIMIT 1
""", site_id, interval_start)
if not plan:
logger.warning(f"No active plan for site {site_id} at {interval_start}, skipping export")
return
# 2. Načíst a aplikovat overrides
overrides = await db.fetch("""
SELECT override_type, value_json
FROM ems.site_override
WHERE site_id = $1
AND valid_from <= $2
AND (valid_to IS NULL OR valid_to > $2)
""", site_id, interval_start)
setpoints = apply_overrides(plan, overrides)
# 3. Zapsat do zařízení (paralelně)
await asyncio.gather(
write_inverter_setpoints(site_id, setpoints, db),
write_ev_charger_setpoints(site_id, setpoints, db),
write_heat_pump_setpoints(site_id, setpoints, db),
write_loxone_setpoints(site_id, setpoints, db),
return_exceptions=True
)
def apply_overrides(plan, overrides) -> Setpoints:
"""Aplikuje override záznamy na plánované setpointy. Override má vždy přednost."""
s = Setpoints.from_plan(plan)
for ov in overrides:
if ov.override_type == 'force_charge':
s.battery_setpoint_w = ov.value_json.get('power_w', 20000)
elif ov.override_type == 'force_discharge':
s.battery_setpoint_w = -abs(ov.value_json.get('power_w', 20000))
elif ov.override_type == 'block_export':
s.grid_setpoint_w = max(0, s.grid_setpoint_w) # jen import povolen
elif ov.override_type == 'block_heat_pump':
s.heat_pump_enabled = False
elif ov.override_type == 'manual_setpoint':
s = Setpoints(**ov.value_json) # plný manuální přepis
return s
```
---
## Zápis do Deye (Modbus)
```python
async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db):
inverters = await db.fetch(
"SELECT ai.*, 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", site_id
)
for inv in inverters:
async with AsyncModbusTcpClient(inv.host, port=inv.port) as client:
# Nabíjecí/vybíjecí výkon baterie
if setpoints.battery_setpoint_w >= 0:
await client.write_register(0x00F3, setpoints.battery_setpoint_w,
slave=inv.unit_id) # charge limit
await client.write_register(0x00F4, 0, slave=inv.unit_id) # discharge = 0
else:
await client.write_register(0x00F3, 0, slave=inv.unit_id)
await client.write_register(0x00F4, abs(setpoints.battery_setpoint_w),
slave=inv.unit_id)
# Export limit
export_limit = max(0, -setpoints.grid_setpoint_w) if setpoints.grid_setpoint_w < 0 else 0
await client.write_register(0x00F6, export_limit, slave=inv.unit_id)
logger.info(f"Inverter {inv.code} setpoints written: batt={setpoints.battery_setpoint_w}W")
```
---
## Zápis do Teltonika EV nabíječek (Modbus)
```python
async def write_ev_charger_setpoints(site_id: int, setpoints: Setpoints, db):
chargers = await db.fetch(
"SELECT ac.*, se.host, se.port, se.unit_id "
"FROM ems.asset_ev_charger ac "
"JOIN ems.site_endpoint se ON se.id = ac.endpoint_id "
"WHERE ac.site_id = $1 AND ac.schedulable = true", site_id
)
# Rozdělit celkový EV výkon rovnoměrně mezi aktivní nabíječky
# (nebo dle stavu session upřesnit)
active_chargers = [c for c in chargers] # TODO: filtrovat dle stavu session
power_per_charger = (setpoints.ev_charge_power_w or 0) // max(len(active_chargers), 1)
for charger in active_chargers:
current_limit_a = power_per_charger // (charger.phases * 230) # W → A
current_limit_a = max(charger.min_power_w // (charger.phases * 230),
min(32, current_limit_a)) # 632A dle IEC 61851
async with AsyncModbusTcpClient(charger.host, port=charger.port) as client:
# Zápis limitu proudu (registr dle Teltonika dokumentace)
await client.write_register(
TBD_CURRENT_LIMIT_REGISTER, current_limit_a, slave=charger.unit_id
)
# Povolení/zakázání nabíjení
enable = 1 if power_per_charger >= charger.min_power_w else 0
await client.write_register(
TBD_ENABLE_REGISTER, enable, slave=charger.unit_id
)
```
---
## Zápis do Samsung TČ (Modbus)
```python
async def write_heat_pump_setpoints(site_id: int, setpoints: Setpoints, db):
heat_pumps = await db.fetch(
"SELECT ahp.*, se.host, se.port, se.unit_id "
"FROM ems.asset_heat_pump ahp "
"JOIN ems.site_endpoint se ON se.id = ahp.endpoint_id "
"WHERE ahp.site_id = $1 AND ahp.schedulable = true", site_id
)
for hp in heat_pumps:
async with AsyncModbusTcpClient(hp.host, port=hp.port) as client:
enable = 1 if setpoints.heat_pump_enabled else 0
await client.write_register(
TBD_HP_ENABLE_REGISTER, enable, slave=hp.unit_id
)
if setpoints.heat_pump_enabled and setpoints.heat_pump_setpoint_w:
# Nastavit cílovou teplotu TUV (pokud podporuje Modbus zápis)
await client.write_register(
TBD_HP_TARGET_TEMP_REGISTER,
int(hp.tuv_target_temp_c * 10), # 0.1°C jednotky
slave=hp.unit_id
)
```
---
## Loxone HTTP Virtual Inputs
Loxone dostává setpointy jako informaci. Slouží pro:
- Zobrazení v Loxone UI
- Fallback logiku v Loxone (pokud EMS nedostupné)
- Vizualizaci plánovaného stavu
```python
async def write_loxone_setpoints(site_id: int, setpoints: Setpoints, db):
endpoint = await db.fetchrow(
"SELECT host, port, auth_reference FROM ems.site_endpoint "
"WHERE site_id = $1 AND endpoint_type = 'loxone_http'", site_id
)
if not endpoint:
return
base_url = f"http://{endpoint.host}:{endpoint.port}/dev/sps/io"
# Loxone Virtual HTTP Input každý setpoint = jeden HTTP GET/POST
# Formát: /dev/sps/io/{VirtualInputName}/{value}
async with aiohttp.ClientSession() as session:
await session.get(f"{base_url}/EMS_BatterySetpoint/{setpoints.battery_setpoint_w}")
await session.get(f"{base_url}/EMS_GridSetpoint/{setpoints.grid_setpoint_w or 0}")
await session.get(f"{base_url}/EMS_EVChargeTotal/{setpoints.ev_charge_power_w or 0}")
await session.get(f"{base_url}/EMS_HeatPumpEnable/{1 if setpoints.heat_pump_enabled else 0}")
```
> Virtual Input jména v Loxone (`EMS_BatterySetpoint` atd.) je nutné vytvořit při konfiguraci Loxone projektu.
---
## Konfigurace (env proměnné)
```env
CONTROL_EXPORT_LEAD_TIME_SEC=10 # kolik sekund před začátkem intervalu exportovat
CONTROL_MODBUS_TIMEOUT_SEC=5
LOXONE_USER=admin # nebo přes auth_reference v site_endpoint
LOXONE_PASSWORD=secret
```
---
## Otevřené body
- [ ] Doplnit Modbus write registry Deye (charge/discharge/export limit)
- [ ] Doplnit Modbus write registry Teltonika (current limit, enable)
- [ ] Doplnit Modbus write registry Samsung TČ (enable, target temp)
- [ ] Loxone Virtual Input jména dohodnout a vytvořit v Loxone projektu
- [ ] Strategie rozdělení EV výkonu mezi 2 nabíječky (rovnoměrně vs dle stavu session)
- [ ] Co dělat při selhání zápisu do jednoho zařízení (rollback ostatních?)

View File

@@ -0,0 +1,285 @@
# Modul: EV Nabíjení
## Přehled vozidel na home-01
| 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í |
---
## Klíčové principy
### 1. Přímé FVE nabíjení preferováno před průchodem přes baterii
Energie která jde FVE → baterie → EV má round-trip ztráty:
```
η_round_trip = η_charge × η_discharge ≈ 0.95 × 0.95 ≈ 0.90
```
Přímé napájení FVE → EV (nebo síť → EV) je ~10 % efektivnější.
Solver to vidí přes vyšší efektivní cenu energie procházející baterií (degradation_cost + round-trip loss).
### 2. Deadline charging
Každé vozidlo může mít nastaven:
- **cílový SoC** (%)
- **deadline** (do kdy musí být dosažen)
Solver garantuje dosažení SoC do deadline jako hard constraint.
Ekonomická optimalizace probíhá v rámci tohoto omezení.
### 3. Zoe řízení přes WB proud limit
Zoe respektuje maximální proud nastavený na WB (Teltonika Modbus).
Solver nastaví `current_limit_a` pro daný slot.
Zoe vždy nabíjí pokud je připojena a proud > 6A.
Scheduler v Zoe se nepoužívá WB proud limit je jediný řídicí prvek.
### 4. Tesla WB + volitelně Tesla API
V první fázi stejný přístup jako Zoe proud limit přes WB.
Tesla API (Tessie nebo přímé) přidáme ve fázi 2 pro:
- čtení aktuálního SoC bez dotazování WB
- čtení stavu připojení
- případné spuštění/zastavení nabíjení přímo v autě
---
## DB rozšíření EV session a deadline
### Tabulka `ems.ev_session`
```sql
CREATE TABLE ems.ev_session (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
charger_id INT NOT NULL REFERENCES ems.asset_ev_charger(id),
vehicle_id INT REFERENCES ems.asset_vehicle(id),
session_start TIMESTAMPTZ NOT NULL DEFAULT now(),
session_end TIMESTAMPTZ,
-- Stav při připojení
soc_at_connect_pct NUMERIC(5,2),
-- Deadline požadavek (nastavuje uživatel nebo API)
target_soc_pct NUMERIC(5,2),
target_deadline TIMESTAMPTZ,
-- Výsledek
soc_at_disconnect_pct NUMERIC(5,2),
energy_delivered_kwh NUMERIC(10,3),
cost_czk NUMERIC(10,4)
);
```
### Tabulka `ems.asset_vehicle`
```sql
CREATE TABLE ems.asset_vehicle (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
code TEXT NOT NULL,
name TEXT,
make TEXT, -- 'Tesla', 'Renault'
model TEXT, -- 'Model Y', 'Zoe'
battery_capacity_kwh NUMERIC(6,2), -- Tesla ~75, Zoe ~52
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'
api_reference TEXT, -- odkaz na credentials v env
default_target_soc_pct NUMERIC(5,2) DEFAULT 80,
default_deadline_hour INT DEFAULT 7 -- 7:00 ráno jako výchozí deadline
);
```
---
## Solver rozšíření EV s round-trip a deadline
### Nové proměnné pro každý slot t a každé EV e
```python
ev_direct[e][t] # W přímé napájení EV z FVE nebo sítě (bez průchodu baterií)
ev_via_bat[e][t] # W napájení EV přes baterii (vyšší efektivní cena)
# Celkový výkon EV (co jde do auta)
ev_charge[e][t] = ev_direct[e][t] + ev_via_bat[e][t]
# Co ev_via_bat stojí energeticky navíc:
# ev_via_bat musí být "nakoupeno" z baterie s round-trip ztrátou
# solver to vidí přes účelovou funkci viz níže
```
### Energetická bilance rozšířená o přímé EV
```python
# Zdroje = Spotřeba
pv_a_net[t] + pv_b[t] + grid_import[t] + batt_discharge[t]
== load_baseline[t]
+ Σ_e ev_direct[e][t] # přímá spotřeba EV
+ Σ_e ev_via_bat[e][t] # EV přes baterii (z discharge)
+ heat_pump[t]
+ batt_charge[t]
+ grid_export[t]
# Vazba: ev_via_bat[e][t] musí pokrýt batt_discharge[t]
# (solver to vyřeší sám discharge jde buď do ev_via_bat nebo do load)
```
### Účelová funkce efektivní cena EV přes baterii
```python
# Nabíjení přes baterii je dražší o round-trip ztrátu a degradaci:
EV_VIA_BAT_COST_FACTOR = 1.0 / (charge_efficiency * discharge_efficiency)
# ≈ 1.0 / (0.95 * 0.95) ≈ 1.108
# V objective function:
+ ev_via_bat[e][t] * buy_price[t] * EV_VIA_BAT_COST_FACTOR * H / 1000
+ ev_direct[e][t] * buy_price[t] * H / 1000 # přímé bez navýšení
# Solver přirozeně preferuje přímé nabíjení kde je to možné
```
### Deadline constraint
```python
# Pro každé EV e s nastaveným deadline:
if ev_session[e].target_deadline is not None:
# Kolik energie ještě potřebujeme dodat
energy_needed_wh = (
(ev_session[e].target_soc_pct - ev_session[e].current_soc_pct)
/ 100.0 * vehicle[e].battery_capacity_kwh * 1000
)
# Deadline slot index
t_deadline = slot_index_for(ev_session[e].target_deadline)
# Hard constraint: součet dodané energie do deadline musí být >= potřebná
prob += pulp.lpSum(
ev_charge[e][t] * H # Wh za 15min slot
for t in range(t_deadline + 1)
if ev_connected[e][t] # jen sloty kdy je auto připojeno
) >= energy_needed_wh
# Zoe má tvrdší deadline (menší baterie, kritičtější)
# Tesla může mít měkčí deadline nebo vyšší flexibility okno
```
### Připojení EV vstupní podmínka
```python
# ev_connected[e][t] = True/False
# Pokud auto není připojeno → ev_charge[e][t] = 0
for t in range(T):
if not ev_connected[e][t]:
prob += ev_charge[e][t] == 0
prob += ev_direct[e][t] == 0
prob += ev_via_bat[e][t] == 0
```
---
## Jak solver rozhoduje (příklady)
### Přebytek FVE přes poledne, Zoe připojena, baterie poloprázdná
```
Solver volí:
ev_direct[zoe][t] = max(min(surplus_w, zoe_max_w), 0) ← přímé z FVE
batt_charge[t] = zbývající surplus ← do baterie až pak
Protože přímé nabíjení Zoe je levnější než FVE → baterie → Zoe.
```
### Noc, Zoe má deadline 7:00 s SoC 20% (potřeba 30 kWh)
```
Solver:
- Rozloží nabíjení do nejlevnějších nočních slotů
- Garantuje dodání 30 kWh do 7:00 (hard constraint)
- Pokud jsou sloty se zápornou cenou → nabíjí naplno v těch slotech
- Vyhýbá se nabíjení přes baterii pokud není přebytek
```
### Tesla připojena, SoC 70%, deadline není nastaven
```
Solver:
- Tesla je "oportunistická" nabíjí jen při přebytku FVE nebo levné ceně
- Bez deadline = měkká optimalizace, ne hard constraint
- Nastavit default_target_soc = 80% s default_deadline = zítra 7:00
(konfigurovatelné v asset_vehicle)
```
---
## Zjištění stavu připojení
### Teltonika WB (oba vozy)
Modbus registr stavu konektoru (status):
- `available` = žádné auto
- `preparing` / `charging` = auto připojeno
Polling každou minutu z `telemetry_ev_charger.status`.
### Tesla API (fáze 2)
Přes Tessie nebo přímé Tesla API:
- SoC baterie auta
- Stav připojení (plugged_in)
- Nabíjecí stav (charging / stopped)
Uložit do `ev_session` při připojení/odpojení.
### Renault Zoe
Žádné API. Stav připojení čteme výhradně z WB Modbus (`status != 'available'`).
SoC Zoe neznáme přesně použijeme energii dodanou v session (kumulativní kWh z WB).
---
## Seed data vozidla home-01
```sql
-- V006__vehicles.sql
INSERT INTO ems.asset_vehicle
(site_id, code, name, make, model, battery_capacity_kwh,
max_charge_power_w, default_charger_id, api_type,
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
ch.id, 'none', -- Tesla API fáze 2
80, 7
FROM ems.site s
JOIN ems.asset_ev_charger ch ON ch.site_id = s.id AND ch.code = 'ev-charger-1'
WHERE s.code = 'home-01';
INSERT INTO ems.asset_vehicle
(site_id, code, name, make, model, battery_capacity_kwh,
max_charge_power_w, default_charger_id, api_type,
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
ch.id, 'none',
90, 7 -- Zoe: vyšší target SoC (menší baterie, kritičtější)
FROM ems.site s
JOIN ems.asset_ev_charger ch ON ch.site_id = s.id AND ch.code = 'ev-charger-2'
WHERE s.code = 'home-01';
```
---
## Otevřené body
- [ ] Tesla API: Tessie vs přímé API rozhodnout ve fázi 2
- [ ] Ověřit Zoe max nabíjecí výkon (7.4 kW nebo méně dle podmínek)
- [ ] Ověřit round-trip efficiency na reálných datech po prvních týdnech provozu
- [ ] UI pro nastavení deadline a target SoC uživatelem (před odjezdem)
- [ ] Notifikace pokud deadline nelze splnit (nedostatek kapacity WB nebo energie)
- [ ] Zoe SoC estimace z kumulativní energie session přesnost ověřit

180
docs/04-modules/forecast.md Normal file
View File

@@ -0,0 +1,180 @@
# Modul: Forecast (Predikce výroby FVE)
## Co modul dělá
- Stahuje meteorologická data (irradiance, teplota) pro každé FVE pole zvlášť
- Vypočítává predikovaný výkon v 15min intervalech
- Ukládá výsledek per `pv_array_id` + `run_id`
- Predikce se spouští denně a před každým plánovacím během
---
## FVE pole na první instalaci (home-01)
| Pole | Výkon | Azimut | Sklon | Střídač | Řízení |
|---|---|---|---|---|---|
| A | 10 kWp | TBD | TBD | Deye 20kW | řídíme |
| B | 10 kWp | TBD | TBD | Ongridový | autonomní, **nepredikujeme odděleně** |
> **Předpoklad:** Pole B (ongridový) je zapojeno do GEN portu Deye. Jeho výkon se projeví v `pv_power_w` telemetrie jako součást celkového výkonu. Pro plánování modelujeme jen pole A. Pole B bereme jako šum / bonus který se projeví v auditu.
> Azimuty a sklony je nutné doplnit při konfiguraci lokality do `asset_pv_array`.
---
## Zdroj meteorologických dat
**Primární: Open-Meteo (open-meteo.com)**
- Zdarma pro nekomerční použití, API bez registrace
- Poskytuje GHI (Global Horizontal Irradiance), DNI, teplotu, oblačnost
- Historická data + forecast na 716 dní dopředu
- 15min granularita nativně ✓
**Endpoint:**
```
GET https://api.open-meteo.com/v1/forecast
?latitude={lat}
&longitude={lon}
&hourly=shortwave_radiation,temperature_2m
&minutely_15=shortwave_radiation,temperature_2m
&timezone=Europe/Prague
&forecast_days=3
```
**Záložní / budoucí: Solcast**
- Přesnější pro FVE, ale placený
- Podporuje per-array predikci s azimutem a sklonem přímo
- Zatím neimplementujeme, architektura to umožňuje přes `forecast_source`
---
## Výpočet výkonu z irradiance
Jednoduchý fyzikální model (dostatečný pro plánování):
```python
def calculate_pv_power(
irradiance_wm2: float, # GHI ze weather service
temp_c: float,
nominal_power_wp: int,
azimuth_deg: float,
tilt_deg: float,
shading_factor: float = 1.0,
temp_coeff: float = -0.004 # typicky -0.4%/°C pro křemík
) -> int:
# 1. Korekce na teplotu panelu
panel_temp = temp_c + 25 # zjednodušený NOCT model
temp_correction = 1 + temp_coeff * (panel_temp - 25)
# 2. Korekce na azimut a sklon (zjednodušená, bez přesného GHI→POA)
# Přesnější model: pvlib knihovna (doporučeno pro produkci)
orientation_factor = cos_angle_of_incidence(azimuth_deg, tilt_deg)
# 3. Výsledný výkon
power_w = (irradiance_wm2 / 1000) * nominal_power_wp * temp_correction * orientation_factor * shading_factor
return max(0, int(power_w))
```
> **Doporučení pro implementaci:** Použít knihovnu `pvlib` (Python) pro přesný POA irradiance výpočet z GHI + azimut + sklon. Je to standardní nástroj, dobře dokumentovaný.
---
## Kdo spouští predikci
**Python service: `forecast_service`**
### Kdy se spouští
| 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` |
---
## Logika běhu predikce
```python
def run_forecast(site_id: int, horizon_days: int = 2):
site = db.get_site(site_id)
arrays = db.get_pv_arrays(site_id, controllable=True)
for array in arrays:
# 1. Stáhnout meteorologická data
weather = open_meteo_client.fetch(
lat=site.lat, lon=site.lon,
start=today, end=today + horizon_days
)
# 2. Vytvořit forecast_pv_run
run = db.create_forecast_run(
site_id=site_id,
pv_array_id=array.id,
forecast_source="open_meteo",
horizon_start=today_00,
horizon_end=today_end + horizon_days
)
# 3. Vypočítat a uložit intervaly (15min)
intervals = []
for slot in weather.slots_15min:
power = calculate_pv_power(
irradiance_wm2=slot.shortwave_radiation,
temp_c=slot.temperature_2m,
nominal_power_wp=array.nominal_power_wp,
azimuth_deg=array.azimuth_deg,
tilt_deg=array.tilt_deg,
shading_factor=array.shading_factor
)
intervals.append(ForecastInterval(
run_id=run.id,
pv_array_id=array.id,
interval_start=slot.time,
power_w=power,
irradiance_wm2=slot.shortwave_radiation,
temp_c=slot.temperature_2m
))
db.upsert_forecast_intervals(intervals)
db.update_forecast_run_status(run.id, "ok")
```
---
## DB struktura
Viz `03-data-model.md`:
- `forecast_pv_run` každý běh predikce
- `forecast_pv_interval` 15min výsledky per pole a běh
---
## Konfigurace (env proměnné)
```env
OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast
FORECAST_HORIZON_DAYS=3
FORECAST_MAX_AGE_HOURS=2 # plánovač odmítne starší predikci
FORECAST_RETRY_COUNT=3
```
---
## Monitoring
- Alert pokud forecast pro dnešní den + zítřek není k dispozici do 15:00
- Endpoint `GET /health/forecast?site_id=1&date=YYYY-MM-DD` → čerstvost a počet intervalů
- Log každého běhu (délka horizontu, počet intervalů, trvání, zdroj)
---
## Otevřené body
- [ ] Doplnit přesný azimut a sklon obou FVE polí při instalaci
- [ ] Rozhodnout: pvlib pro přesnější POA výpočet vs jednoduchý model doporučujeme pvlib od začátku
- [ ] Pole B (ongridový) zda vůbec modelovat nebo ignorovat v plánu a jen sledovat v auditu
- [ ] Solcast jako alternativa v budoucnu `forecast_source` to umožňuje bez DB změn

View File

@@ -0,0 +1,107 @@
# Modul: Tepelné čerpadlo (Heat Pump)
## Zařízení
**Samsung tepelné čerpadlo** s Modbus modulem pro dálkové řízení.
Komunikace: Modbus RTU → Waveshare WS-ETH → Modbus TCP.
Loxone šablona k dispozici (reference pro Modbus registry).
## Co systém řídí
- Povolení/zakázání provozu (Modbus příkaz)
- Požadovaná teplota TUV zásobníku (Modbus setpoint)
- Plánování okna provozu na základě COP a ceny elektřiny
## Co systém nečte (z Loxone šablony nebo Modbus registrů)
- Venkovní teplota čerpadla (`outdoor_temp_c`)
- Teplota zásobníku TUV (`tuv_tank_temp_c`)
- Příkon (`power_w`)
- Provozní režim (`operating_mode`)
- Alarm kód (`alarm_code`)
- Stav odmrazování (`defrost_active`)
---
## Logika řízení
### Prioritní pravidla (v pořadí)
1. **Override blokování** (`site_override.override_type = 'block_heat_pump'`) → TČ se nespouští
2. **Nouzový ohřev** teplota zásobníku pod `tuv_min_temp_c` → spustit bez ohledu na cenu
3. **Zásobník plný** teplota nad `tuv_max_temp_c` → neohřívat
4. **Ekonomické rozhodnutí** spustit pokud cena tepla ≤ prahová hodnota
Logika je implementována v PostgreSQL funkci `ems.fn_heat_pump_should_run()`.
### Výpočet COP
COP závisí primárně na **venkovní teplotě**:
- Vyšší venkovní teplota = lepší COP = levnější teplo
- V chladných měsících je přes poledne venkovní teplota nejvyšší → optimální čas pro ohřev TUV
```
COP(t_venkovní) ≈ COP_rated + (t_venkovní - t_reference) × 0.10
```
Funkce: `ems.fn_cop_estimate(heat_pump_id, outdoor_temp_c)`
Cena tepla = cena elektřiny / COP → funkce `ems.fn_heat_pump_cost_per_kwh_heat()`
### Denní provozní okno
Typický scénář v chladných měsících (říjenbřezen):
- Přes poledne (11:0014:00) je venkovní teplota nejvyšší → COP nejlepší
- Pokud je zároveň spot cena nízká nebo FVE přebytek → ideální okno
- TČ potřebuje cca 12 hodiny denně pro nahrání TUV zásobníku (závisí na objemu a teplotním rozdílu)
Plánovací horizont: TČ se plánuje v rámci standardního 15min plánování stejně jako ostatní flexibilní spotřebiče.
---
## Omezení kompresoru
| Parametr | Hodnota | Důvod |
|---|---|---|
| `min_run_duration_min` | 30 min | Ochrana před krátkými cykly |
| `min_stop_duration_min` | 15 min | Vyrovnání tlaku před restartem |
Plánování musí respektovat tato omezení nevytvářet plán s kratšími ON/OFF cykly.
---
## Modbus registry (doplnit z dokumentace Samsung)
> Konkrétní registry doplnit z Loxone šablony a Samsung Modbus dokumentace.
| Registr | Typ | Popis |
|---|---|---|
| TBD | Read | Venkovní teplota |
| TBD | Read | Teplota zásobníku TUV |
| TBD | Read | Příkon |
| TBD | Read | Provozní režim |
| TBD | Read | Alarm kód |
| TBD | Write | Povolení provozu (on/off) |
| TBD | Write | Požadovaná teplota TUV |
---
## Integrace s Loxone
Alternativní cesta (pokud Modbus přímý přístup není z nějakého důvodu vhodný):
- Loxone čte Modbus a vystavuje stav přes Virtual Outputs
- EMS posílá setpointy do Loxone přes HTTP Virtual Inputs
- Loxone přepisuje Modbus registry
Pro začátek doporučujeme **přímý Modbus TCP** (přes Waveshare) bez Loxone prostředníka pro řídící příkazy, Loxone nechat jako fallback.
---
## Otevřené body
- [ ] Doplnit konkrétní Modbus registry ze Samsung dokumentace / Loxone šablony
- [ ] Doplnit model Samsung a jmenovitý výkon do seed dat
- [ ] Ověřit `min_run_duration_min` a `min_stop_duration_min` z dokumentace
- [ ] Kalibrovat COP model na reálná historická data po prvních 46 týdnech provozu
- [ ] Rozhodnout: přímý Modbus TCP nebo přes Loxone jako prostředník
- [ ] Doplnit objem zásobníku TUV pro výpočet doby ohřevu

View File

@@ -0,0 +1,128 @@
# Modul: Market Prices (Spotové ceny OTE CZ)
## Co modul dělá
- 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í)
---
## Zdroj dat: OTE CZ
**URL:** `https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh`
OTE CZ publikuje denní ceny zpravidla **den předem (D-1)** okolo 13:0014:00 středoevropského času.
### Formát dat OTE CZ
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`
### Alternativní API
- **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
---
## Kdo stahuje data
**Python service: `price_importer`**
Samostatný modul (ne součást FastAPI, ale může být volán z ní jako task).
### 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 |
### Logika importu
```python
# Pseudologika importu (implementace v price_importer.py)
def import_prices_for_date(date: date, source: str = "OTE_CZ"):
# 1. Zkontrolovat jestli data pro daný den už existují
existing = db.query("SELECT COUNT(*) FROM market_interval_price WHERE interval_start::date = %s AND market_source = %s", date, source)
if existing > 0 and not force_reimport:
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
# 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)
# 5. Upsert do DB (idempotentní)
db.upsert_many("market_interval_price", intervals, conflict_keys=["market_source", "interval_start"])
log.info(f"Imported {len(intervals)} intervals for {date}")
```
---
## Struktura DB záznamu
Viz `03-data-model.md` → tabulka `market_interval_price`.
Klíčové body:
- `buy_raw_price_czk_kwh` a `sell_raw_price_czk_kwh` jsou **oddělené**
- Pro OTE CZ je v první verzi `sell_raw_price = buy_raw_price` (reference cena)
- `imported_at` slouží pro audit importů
---
## Efektivní ceny per site
Viz view `market_vw_site_effective_price` v `03-data-model.md`.
Marže se konfigurují v `site_market_config`:
| Parametr | Typ | Příklad |
|---|---|---|
| `buy_margin_fixed_czk` | Kč/kWh | 0.05 (5 haléřů/kWh) |
| `buy_margin_percent` | % | 2.5 |
| `sell_margin_fixed_czk` | Kč/kWh | -0.02 (srážka) |
| `sell_margin_percent` | % | 0 |
---
## Konfigurace (env proměnné)
```env
OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam
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
PRICE_IMPORT_RETRY_COUNT=3
PRICE_IMPORT_RETRY_BACKOFF_SEC=300
```
---
## Monitoring a alerting
- Alert pokud do 16:00 nejsou v DB ceny na zítřek
- Log každého importu (datum, počet intervalů, zdroj, trvání)
- Endpoint `GET /health/prices?date=YYYY-MM-DD` → vrátí počet importovaných intervalů
---
## Otevřené body
- [ ] Kurz EUR/CZK: fixní hodnota vs denní stahování z ČNB rozhodnout před implementací
- [ ] OTE nabízí i intraday ceny zatím neimplementujeme
- [ ] Sell price: OTE nemá oddělenou nákupní a prodejní raw cenu, obě = DAM cena; může se lišit u jiných zdrojů

View File

@@ -0,0 +1,132 @@
# Modul: Operating Modes (Provozní režimy)
## Koncept
EMS a Loxone komunikují přes **provozní režimy** pojmenované stavy které mají smysl pro obě strany.
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**.
```
EMS backend (každou minutu)
→ HTTP GET /dev/sps/io/EMS_Heartbeat/1 ← pulz do Loxone
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
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
```
**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.
Viz `docs/loxone-integration.md` pro kompletní popis Loxone implementace.
---
## Přehled režimů
| 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)
```
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"
}
```
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
**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`.
---
## EMS restart / reconnect
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
---
## 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

423
docs/04-modules/planning.md Normal file
View File

@@ -0,0 +1,423 @@
# Modul: Planning (LP Optimalizace)
## Přístup
**PuLP + HiGHS solver** lineární programování (LP) s uvolněním binárních proměnných.
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
---
## Klíčové předpoklady a specifika home-01
### FVE pole A (10 kWp, řízené Deye)
- Curtailment povolen přes Modbus (Output Power Limit)
- Solver může omezit výrobu pokud export nevychází a není kam ukládat
- Curtailment má nulový přímý náklad, ale ztrátu příležitosti
### FVE pole B (10 kWp, ongridový na GEN portu)
- **Nelze omezit ani řídit**
-**zelený bonus** (dotace za každé vyrobené kWh bez ohledu na cenu)
- Výroba pole B musí být vždy plně spotřebována nebo uložena
- Při záporné prodejní ceně má nejvyšší prioritu ukládání (baterie → EV → TČ)
- Solver nikdy neexportuje výrobu pole B pokud je prodejní cena záporná
### Export / import limity (home-01)
- Max export do sítě: **13.5 kW** (smlouva s distributorem)
- Max import ze sítě: dle `site_grid_connection.max_import_power_w`
- Konfigurovatelné per site v DB
---
## Energetická bilance (pro každý 15min slot t)
```
pv_a_actual[t] + pv_b[t] + grid_import[t] + battery_discharge[t]
= load_baseline[t]
+ Σ_e (ev_direct[e][t] + ev_via_bat[e][t])
+ heat_pump[t]
+ battery_charge[t] + grid_export[t] + pv_a_curtailed[t]
```
kde:
- `pv_a_actual[t]` = `pv_a_forecast[t] pv_a_curtailed[t]`
- `pv_b[t]` = predikce pole B (pevná, nekontrolovatelná)
- `grid_import[t]`, `grid_export[t]` ≥ 0 (oddělené proměnné, ne signed)
- `ev_direct[e][t]` = přímé napájení EV e ze zdrojů (FVE, síť) bez průchodu baterií
- `ev_via_bat[e][t]` = napájení EV e přes baterii (kryta z `battery_discharge[t]`)
**Round-trip efektivita:** Přímé napájení EV je ~10 % levnější než přes baterii
(η_charge × η_discharge ≈ 0.95 × 0.95 ≈ 0.90). Solver to vidí v účelové funkci.
---
## Proměnné solveru
| Proměnná | Typ | Rozsah | Popis |
|---|---|---|---|
| `grid_import[t]` | kontinuální | 0 max_import | Nákup ze sítě v W |
| `grid_export[t]` | kontinuální | 0 max_export (13500) | Prodej do sítě v W |
| `battery_charge[t]` | kontinuální | 0 max_charge | Nabíjení baterie v W |
| `battery_discharge[t]` | kontinuální | 0 max_discharge | Vybíjení baterie v W |
| `soc[t]` | kontinuální | soc_min soc_max | Stav nabití baterie v Wh |
| `pv_a_curtailed[t]` | kontinuální | 0 pv_a_forecast[t] | Omezení výroby pole A v W |
| `ev_direct[e][t]` | kontinuální | 0 min(ev_max, pv_surplus) | Přímé napájení EV e z FVE/sítě (bez průchodu baterií) |
| `ev_via_bat[e][t]` | kontinuální | 0 ev_max | Napájení EV e přes baterii (s round-trip ztrátou) |
| `heat_pump[t]` | kontinuální | 0 hp_rated | Výkon TČ v W (relaxováno z binární) |
> **TČ relaxace:** TČ je v realitě ON/OFF (binární). Pro LP ho relaxujeme na spojitou proměnnou 0rated_power. Post-processing pravidlo pak zaokrouhlí na ON/OFF a zkontroluje `min_run_duration`. V praxi výsledek LP vychází blízko binárnímu řešení.
---
## Účelová funkce (minimalizace nákladů)
```python
EV_ROUNDTRIP_FACTOR = 1.0 / (charge_efficiency * discharge_efficiency) # ≈ 1.108
minimize:
Σ_t [
# Náklady na nákup ze sítě
grid_import[t] * buy_price[t] * interval_h
# Příjem z prodeje (záporný náklad)
- grid_export[t] * sell_price[t] * interval_h
# Náklad degradace baterie (nabíjení i vybíjení)
+ (battery_charge[t] + battery_discharge[t]) * degradation_cost * interval_h
# EV přímé napájení standardní cena energie
+ Σ_e ev_direct[e][t] * buy_price[t] * interval_h
# EV přes baterii navýšeno o round-trip ztrátu + degradaci
# Solver tak přirozeně preferuje přímé nabíjení nad průchodem baterií
+ Σ_e ev_via_bat[e][t] * buy_price[t] * EV_ROUNDTRIP_FACTOR * interval_h
# Malá penalizace curtailmentu pole A (preferujeme využití FVE)
+ pv_a_curtailed[t] * CURTAILMENT_PENALTY
]
```
kde `interval_h = 0.25` (15 min = 0.25 h), ceny v Kč/kWh, výkony ve W.
---
## Omezení solveru
### Energetická bilance
```python
pv_a_forecast[t] - pv_a_curtailed[t] + pv_b[t] + grid_import[t] + battery_discharge[t]
== load_baseline[t]
+ Σ_e (ev_direct[e][t] + ev_via_bat[e][t])
+ heat_pump[t] + battery_charge[t] + grid_export[t]
```
### Vazba ev_via_bat na battery_discharge
```python
# ev_via_bat musí být kryto z vybíjení baterie
Σ_e ev_via_bat[e][t] <= battery_discharge[t]
```
### Limit výkonu EV per vozidlo
```python
# Celkový výkon do EV e nesmí překročit min(WB limit, vozidlo max)
ev_direct[e][t] + ev_via_bat[e][t] <= min(charger_max_w[e], vehicle_max_w[e])
# Pokud auto není připojeno → nula
if not ev_connected[e][t]:
ev_direct[e][t] == 0
ev_via_bat[e][t] == 0
```
### Deadline charging hard constraint
```python
# Pro každé EV e s nastaveným deadline a known SoC:
if ev_session[e].target_deadline and ev_session[e].soc_at_connect_pct is not None:
energy_needed_wh = (
(target_soc_pct - soc_at_connect_pct) / 100.0
* vehicle_capacity_wh[e]
)
t_deadline = slot_index(ev_session[e].target_deadline)
pulp.lpSum(
(ev_direct[e][t] + ev_via_bat[e][t]) * interval_h
for t in range(t_deadline + 1)
if ev_connected[e][t]
) >= energy_needed_wh
# Pro Zoe (SoC neznámý) deadline constraint na kumulativní dodanou energii:
# energy_needed = (default_target_soc - estimated_soc_from_session) * capacity
```
### SoC kontinuita
```python
soc[t] == soc[t-1]
+ battery_charge[t] * charge_efficiency * interval_h
- battery_discharge[t] / discharge_efficiency * interval_h
soc[0] == current_soc_wh # počáteční podmínka z telemetrie
```
### SoC limity
```python
soc_min_wh <= soc[t] <= soc_max_wh
# Rezerva pro výpadek sítě nikdy nesahat
soc_reserve_wh = battery.reserve_soc_percent / 100 * battery.usable_capacity_wh
soc[t] >= soc_reserve_wh # za normálních podmínek
```
### Limity výkonu
```python
0 <= battery_charge[t] <= battery.max_charge_power_w
0 <= battery_discharge[t] <= battery.max_discharge_power_w
0 <= grid_import[t] <= grid.max_import_power_w
0 <= grid_export[t] <= grid.max_export_power_w # = 13500 pro home-01
0 <= pv_a_curtailed[t] <= pv_a_forecast[t]
0 <= ev_charge[t] <= ev_max_total_w
0 <= heat_pump[t] <= heat_pump.rated_heating_power_w
```
### Nelze současně nabíjet a vybíjet baterii
```python
# Přirozeně vyplyne z optimalizace díky degradation_cost.
# Pokud ne, přidat: battery_charge[t] * battery_discharge[t] == 0
# (to by ale byl QP, ne LP raději nechat degradation_cost dělat práci)
```
### Záporná prodejní cena zákaz exportu
```python
if sell_price[t] < 0:
grid_export[t] == 0 # přidat jako constraint pro daný slot
```
### Záporná prodejní cena pole B má prioritu v ukládání
```python
# Pokud sell_price[t] < 0, výroba pole B nesmí jít do exportu.
# Formulace: grid_export[t] <= grid_import[t] + battery_discharge[t] ...
# Jednodušeji: pokud sell_price < 0, přidat constraint grid_export[t] == 0
# (export stejně zakázán výše) a solver automaticky uloží přebytek.
```
### Záporná nákupní cena nabíjet ze sítě je výhodné
```python
# Pokud buy_price[t] < 0, grid_import[t] je příjem → solver automaticky maximalizuje import.
# Omezit maximálním výkonem baterie (aby to mělo smysl):
# grid_import[t] <= battery.max_charge_power_w + ev_max_total_w + heat_pump.rated_heating_power_w
# (nechceme kupovat víc než spotřebujeme / uložíme)
```
### TUV minimální teplota nouzový ohřev vždy
```python
# Pokud aktuální teplota zásobníku < tuv_min_temp_c:
# heat_pump[t=0] >= heat_pump.rated_heating_power_w * 0.8 # minimálně 80% výkonu v prvním slotu
# Toto je tvrdé omezení nezávislé na ceně.
```
---
## Implementace (Python / PuLP)
```python
# backend/services/planning_engine.py
import pulp
from pulp import HiGHS_CMD
def solve_dispatch(
site_id: int,
slots: list[PlanningSlot], # 15min sloty s cenami, forecasty
battery: AssetBattery,
heat_pump: AssetHeatPump,
grid: SiteGridConnection,
current_soc_wh: float,
current_tuv_temp_c: float,
ev_max_total_w: int,
) -> list[DispatchResult]:
T = len(slots)
H = 0.25 # interval v hodinách
CURTAILMENT_PENALTY = 0.001 # Kč/Wh malá penalizace aby solver preferoval využití
prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)
# --- Proměnné ---
grid_import = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w) for t in range(T)]
grid_export = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)]
batt_charge = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)]
batt_discharge = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)]
soc = [pulp.LpVariable(f"soc_{t}",
battery.reserve_soc_wh,
battery.soc_max_wh) for t in range(T)]
curtail_a = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)]
ev_charge = [pulp.LpVariable(f"ev_{t}", 0, ev_max_total_w) for t in range(T)]
heat_pump_p = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)]
# --- Účelová funkce ---
prob += pulp.lpSum(
grid_import[t] * slots[t].buy_price * H / 1000 # Kč (W→kW)
- grid_export[t] * slots[t].sell_price * H / 1000
+ (batt_charge[t] + batt_discharge[t]) * battery.degradation_cost_czk_kwh * H / 1000
+ curtail_a[t] * CURTAILMENT_PENALTY
for t in range(T)
)
# --- Omezení ---
for t in range(T):
s = slots[t]
pv_a_net = s.pv_a_forecast_w - curtail_a[t]
# Energetická bilance
prob += (
pv_a_net + s.pv_b_forecast_w + grid_import[t] + batt_discharge[t]
== s.load_baseline_w + ev_charge[t] + heat_pump_p[t] + batt_charge[t] + grid_export[t]
)
# SoC kontinuita
soc_prev = current_soc_wh if t == 0 else soc[t-1]
prob += soc[t] == (
soc_prev
+ batt_charge[t] * battery.charge_efficiency * H
- batt_discharge[t] / battery.discharge_efficiency * H
)
# Záporná prodejní cena → zakázat export
if s.sell_price < 0:
prob += grid_export[t] == 0
# Záporná nákupní cena → omezit import na to co reálně spotřebujeme/uložíme
if s.buy_price < 0:
prob += grid_import[t] <= (
battery.max_charge_power_w
+ ev_max_total_w
+ heat_pump.rated_heating_power_w
)
# Nouzový ohřev TUV pokud zásobník pod minimem
if current_tuv_temp_c < heat_pump.tuv_min_temp_c:
prob += heat_pump_p[0] >= heat_pump.rated_heating_power_w * 0.8
# --- Řešení ---
solver = HiGHS_CMD(msg=False, timeLimit=10)
status = prob.solve(solver)
if pulp.LpStatus[status] != 'Optimal':
raise PlanningError(f"Solver nenašel optimální řešení: {pulp.LpStatus[status]}")
# --- Post-processing TČ: relaxovaná → ON/OFF ---
results = []
for t in range(T):
hp_raw = pulp.value(heat_pump_p[t])
hp_enabled = hp_raw > heat_pump.rated_heating_power_w * 0.3 # threshold pro ON
hp_power = heat_pump.rated_heating_power_w if hp_enabled else 0
results.append(DispatchResult(
interval_start = slots[t].interval_start,
battery_setpoint_w = round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])),
battery_soc_target = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1),
grid_setpoint_w = round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])),
ev_charge_power_w = round(pulp.value(ev_charge[t])),
heat_pump_enabled = hp_enabled,
heat_pump_setpoint_w = hp_power,
pv_a_curtailed_w = round(pulp.value(curtail_a[t])),
expected_cost_czk = round(
pulp.value(grid_import[t]) * slots[t].buy_price * H / 1000
- pulp.value(grid_export[t]) * slots[t].sell_price * H / 1000,
4
),
effective_buy_price = slots[t].buy_price,
effective_sell_price = slots[t].sell_price,
))
return results
```
---
## Scénáře které solver řeší správně
### Ráno vysoká FVE předpověď, přes poledne záporná cena
```
Solver ráno (vysoká cena):
→ vybíjí baterii do sítě (prodej při high price)
→ exportuje FVE přebytek
Přes poledne (záporná nebo nízká cena):
→ zakáže export (grid_export == 0)
→ nabíjí baterii z FVE + ze sítě (dostane zaplaceno)
→ spouští TČ a EV (spotřebovává levnou/zápornou energii)
→ případně curtailuje pole A pokud je baterie plná a není kam ukládat
```
### Pole B + záporná cena
```
Pole B vyrábí 10 kWp, sell_price < 0:
→ grid_export == 0 (constraint)
→ solver musí interně spotřebovat vše z pole B
→ prioritně: nabíjení baterie, pak EV, pak TČ
→ pokud nic nestačí → baterie je plná, EV nepřipojeno, TČ na max:
solver ukáže že zbývající výroba pole B nejde spotřebovat
→ tuto situaci logovat (přebytek nevyužit, bonus přesto inkasován)
```
### Záporná nákupní cena (platíme za odběr)
```
→ solver maximalizuje grid_import (je to příjem)
→ omezen na max_charge + ev_max + hp_rated (nechceme kupovat zbytečně)
→ nabíjí baterii na maximum
→ spouští EV a TČ naplno
```
---
## DB rozšíření planning_interval
Přidat sloupec `pv_a_curtailed_w` do tabulky:
```sql
-- V005__planning_curtailment.sql
ALTER TABLE ems.planning_interval
ADD COLUMN pv_a_curtailed_w INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS
'Plánované omezení výroby FVE pole A v W (curtailment). 0 = žádné omezení. '
'Hodnota > 0 znamená že solver rozhodl omezit výrobu pole A přes Modbus.';
```
---
## Konfigurace (env proměnné)
```env
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.
---
## Závislosti (requirements.txt)
```
pulp>=2.8.0
highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD)
```
> Preferovat `import highspy` přímý binding místo `HiGHS_CMD` shell volání výrazně rychlejší.
---
## 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)
- [ ] EV rozdělení výkonu mezi 2 nabíječky zatím řešeno jako agregát
- [ ] Curtailment pole A ověřit Modbus registr pro Output Power Limit na Deye SUN-20K
- [ ] Testovat solver na reálných datech ověřit čas výpočtu pro 36h horizont (144 slotů)

View File

@@ -0,0 +1,216 @@
# Modul: Telemetry (Sběr dat ze zařízení)
## Co modul dělá
- Čte data ze střídače Deye, EV nabíječek Teltonika a tepelného čerpadla Samsung přes Modbus TCP
- Ukládá surová měření do DB (1min granularita)
- Detekuje výpadky komunikace a loguje chyby
- Agreguje 1min data na 15min průměry pro spotřebu, audit a plánování
---
## Komponenta: `telemetry_collector` (Python service)
Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI.
### Polling intervaly
| Zařízení | Interval | Důvod |
|---|---|---|
| Deye střídač | 60 s | 1min granularita telemetrie |
| Teltonika EV nabíječka 1 | 60 s | |
| Teltonika EV nabíječka 2 | 60 s | |
| Samsung tepelné čerpadlo | 60 s | |
### Chování při chybě
- Chyba komunikace: záznam se nezapíše, chyba se loguje
- 3 po sobě jdoucí chyby = alert (log WARNING)
- 10 po sobě jdoucích chyb = log ERROR + pokus o reconnect
- Data se neinterpolují chybějící minuty zůstanou prázdné (audit to pozná)
---
## Deye SUN-20K Modbus registry
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.
| Registr (hex) | Typ | Popis | Jednotka | Přepočet |
|---|---|---|---|---|
| 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 |
**Zápis setpointů (plánování → Deye):**
| Registr (hex) | Typ | Popis | Hodnota |
|---|---|---|---|
| 0x00F3 | Write Single | Battery charge power limit | W |
| 0x00F4 | Write Single | Battery discharge power limit | W |
| 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ů.
---
## Teltonika TeltoCharge Modbus registry
Komunikace: Modbus TCP přes Waveshare, Unit ID = 1 (ověřit).
> Registry doplnit z Teltonika TeltoCharge Modbus dokumentace / Loxone šablony.
| Registr | Typ | Popis | Jednotka |
|---|---|---|---|
| TBD | Read | Stav konektoru (OCPP status enum) | enum |
| TBD | Read | Aktuální výkon | W |
| TBD | Read | Kumulativní energie session | Wh |
| TBD | Read | Proud L1/L2/L3 | 0.1A |
| TBD | Read | Napětí | 0.1V |
| TBD | Read | Session ID | uint |
| TBD | Read | Error code | uint |
| TBD | Write | Max proud (charge limit) | A (632A) |
| TBD | Write | Povolení nabíjení (on/off) | bool |
---
## Samsung tepelné čerpadlo Modbus registry
Komunikace: Modbus TCP přes Waveshare.
> Registry doplnit ze Samsung NASA Modbus dokumentace / Loxone šablony.
| Registr | Typ | Popis | Jednotka |
|---|---|---|---|
| TBD | Read | Venkovní teplota | 0.1°C |
| TBD | Read | Teplota vody vstup | 0.1°C |
| TBD | Read | Teplota vody výstup | 0.1°C |
| TBD | Read | Teplota zásobníku TUV | 0.1°C |
| TBD | Read | Příkon | W |
| TBD | Read | Provozní režim | enum |
| TBD | Read | Alarm kód | uint |
| TBD | Read | Odmrazování aktivní | bool |
| TBD | Write | Povolení provozu | bool |
| TBD | Write | Požadovaná teplota TUV | °C |
---
## 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))
```
---
## Agregace 1min → 15min
Prováděna PostgreSQL funkcí `ems.fn_fill_audit_interval()` a `ems.fn_fill_baseline_consumption()`.
Spouštěna každých 15 minut jako scheduled task (Python APScheduler nebo pg_cron).
```sql
-- Příklad agregace telemetrie na 15min průměr
-- (součást fn_fill_audit_interval)
SELECT
site_id,
time_bucket('15 minutes', measured_at) AS interval_start,
AVG(pv_power_w)::INT AS avg_pv_power_w,
AVG(battery_power_w)::INT AS avg_battery_power_w,
AVG(grid_power_w)::INT AS avg_grid_power_w,
AVG(load_power_w)::INT AS avg_load_power_w,
LAST(battery_soc_percent, measured_at) AS last_soc_pct
FROM ems.telemetry_inverter
WHERE measured_at >= $1 AND measured_at < $1 + INTERVAL '15 minutes'
AND site_id = $2
GROUP BY site_id, time_bucket('15 minutes', measured_at);
```
---
## Konfigurace (env proměnné)
```env
TELEMETRY_POLL_INTERVAL_SEC=60
TELEMETRY_ERROR_WARN_THRESHOLD=3 # počet chyb před WARNING logem
TELEMETRY_ERROR_RECONNECT_THRESHOLD=10
MODBUS_CONNECT_TIMEOUT_SEC=5
MODBUS_READ_TIMEOUT_SEC=3
```
---
## Otevřené body
- [ ] Doplnit přesné Modbus registry Deye z PDF protokolu
- [ ] 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
- [ ] Optimalizovat čtení Deye jako jeden `read_holding_registers` blok místo jednotlivých registrů

98
docs/05-todo.md Normal file
View File

@@ -0,0 +1,98 @@
# EMS konsolidovaný seznam TODO
Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulech a komentářů `TODO` / `TBD` / `[ ]` v repozitáři. Duplicitní témata jsou sloučena; u uvedených řádků jde o stav k poslední synchronizaci se soubory.
**Role „kdo řeší“:** *majitel* = vlastník/provoz objektu a smluvní údaje; *programátor* = vývoj EMS; *Loxone programátor* = konfigurace Miniserveru a integrace v Loxone.
---
## Blokující nutné před prvním spuštěním
Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smysluplný forecast nebo dokončit kritická rozhodnutí před implementací řízení.
| Popis | Kde | Kdo |
|-------|-----|-----|
| Doplnit **GPS** (`latitude`, `longitude`) pro lokalitu `home-01` vstup Open-Meteo. | `db/migration/V003__seed_site_home01.sql` ř. 1117 (`INSERT` + komentáře TODO); `docs/06-open-questions.md` ř. 1516 | majitel (souřadnice) → programátor (úprava seedu/SQL) |
| Doplnit **skutečné IP** Waveshare (Deye), obou Teltonika WB, Samsung TČ a **Loxone**; ověřit **Modbus Unit ID** u zařízení. | `db/migration/V003__seed_site_home01.sql` ř. 2730, 3336, 3941, 4446, 4952 (TODO komentáře); `docs/04-modules/telemetry.md` ř. 215 (ověření Unit ID) | majitel / instalatér (síť) → programátor (seed nebo `site_endpoint` v DB) |
| Doplnit **azimut a sklon** FVE polí A a B pro přesný výpočet predikce. | `db/migration/V003__seed_site_home01.sql` ř. 125132, 140146; `docs/06-open-questions.md` ř. 1314; `docs/04-modules/forecast.md` ř. 1617 (tabulka TBD), 177 | majitel / projektant FVE → programátor |
| Doplnit **model TČ**, **jmenovitý topný výkon (W)**, **COP rated**, **objem zásobníku TUV**, **odkaz na čidlo TUV** v seedu (`asset_heat_pump` má povinné numerické sloupce bez platných hodnot nelze konzistentně plánovat / migrovat). | `db/migration/V003__seed_site_home01.sql` ř. 182200 | majitel (datasheet) → programátor |
| **Rozhodnout Teltonika: OCPP 1.6 vs REST API** před implementací EV řízení a sběru. | `docs/06-open-questions.md` ř. 910; `docs/04-modules/consumption.md` ř. 184 | majitel + programátor |
| **Doplnit přesné Modbus registry** (čtení i zápis) pro Deye, Teltonika, Samsung bez mapy registrů nejde napsat funkční `telemetry_collector` / `control_exporter`. | `docs/04-modules/telemetry.md` ř. 63, 76105 (tabulky TBD), 212214; `docs/04-modules/heat-pump.md` ř. 7985, 102; `docs/04-modules/control.md` ř. 249251; pseudokód `TBD_*_REGISTER` ř. 166171, 192197; `docs/loxone-integration.md` ř. 259261 | majitel dodá PDF/šablony → programátor; část ověření s **Loxone programátor** |
| Ověřit **Modbus registr Output Power Limit** (curtailment pole A) na Deye SUN-20K. | `docs/04-modules/planning.md` ř. 422 | programátor (+ dokumentace od majitele) |
| Doplnit **skutečnou výši zeleného bonusu** (`green_bonus_czk_kwh`) dle smlouvy aktuálně placeholder. | `db/migration/V005__planning_curtailment.sql` ř. 4550 | majitel (smlouva) → programátor |
---
## Fáze 1 základní provoz
Potřebné pro reálný, stabilní provoz; lze část EMS otestovat bez nich (např. jen DB, část solveru).
| Popis | Kde | Kdo |
|-------|-----|-----|
| **Kurz EUR/CZK:** fixní env vs denní stahování (ČNB) ovlivní import cen. | `docs/06-open-questions.md` ř. 1112; `docs/04-modules/market-prices.md` ř. 126; `docs/04-modules/consumption.md` (související ekonomika) | majitel + programátor |
| **TUV výkon:** měřitelný příkon vs jen ON/OFF dopad na baseline a plánování. | `docs/06-open-questions.md` ř. 2122 | majitel + programátor |
| **Pole B (ongrid)** v auditu: sledovat neřízenou výrobu vs ignorovat. | `docs/06-open-questions.md` ř. 2324; `docs/04-modules/forecast.md` ř. 179 | majitel + programátor |
| Filtrovat aktivní nabíječky **dle session** při zápisu setpointů (místo všech schedulable). | `docs/04-modules/control.md` ř. 153155 (komentář TODO v pseudokódu) | programátor |
| Dohodnout **Loxone Virtual Input** názvy a vytvořit je v projektu (soulad s HTTP exportem). | `docs/04-modules/control.md` ř. 222232, 252 | Loxone programátor + programátor |
| **Strategie rozdělení výkonu** mezi 2 nabíječky; chování při **selhání zápisu** jednoho zařízení (rollback?). | `docs/04-modules/control.md` ř. 253254 | majitel + programátor |
| Ověřit **Watchdog / Timer** bloky v konkrétní verzi Loxone Config. | `docs/loxone-integration.md` ř. 258 | Loxone programátor |
| **Deye work mode** hodnoty (Self-Consumption, Grid-Charge, Backup) pro SELF_SUSTAIN / přepínání. | `docs/loxone-integration.md` ř. 259; `docs/04-modules/operating-modes.md` ř. 130 | programátor + dokumentace majitele |
| Dohodnout zdroj **SoC pro SELF_SUSTAIN** v Loxone (čtení ze střídače vs pevný práh). | `docs/loxone-integration.md` ř. 262 | majitel + Loxone programátor |
| **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 |
| Ověřit **min_run_duration / min_stop_duration** TČ z dokumentace Samsung. | `docs/04-modules/heat-pump.md` ř. 104 | programátor |
| Doplnit **objem zásobníku TUV** pro výpočet doby ohřevu (nad rámec seedu). | `docs/04-modules/heat-pump.md` ř. 107 | majitel → programátor |
| **TUV čidlo v Loxone** pro přesnější řízení / baseline. | `docs/04-modules/consumption.md` ř. 185 | Loxone programátor + programátor |
| **Bazální spotřeba:** zpřesnit odečítání výkonu TČ/TUV (ON/OFF × čas vs pevný výkon). | `docs/04-modules/consumption.md` ř. 186 | majitel + programátor |
| **PostgREST autentizace** (JWT, RLS, …) před produkcí. | `docs/06-open-questions.md` ř. 2526 | majitel + programátor |
| **Zálohování PostgreSQL** (pg_dump cron, replikace, …). | `docs/06-open-questions.md` ř. 2728 | majitel + programátor |
| OTE: poznámka k **sell vs buy raw** u jiných zdrojů než OTE. | `docs/04-modules/market-prices.md` ř. 128 | programátor |
| Ověřit **Zoe max AC výkon** (7.4 kW vs podmínky instalace). | `docs/04-modules/ev-charging.md` ř. 281 | majitel + programátor |
---
## Fáze 2 rozšíření
| Popis | Kde | Kdo |
|-------|-----|-----|
| **Tesla API:** Tessie vs přímé API. | `docs/04-modules/ev-charging.md` ř. 280 | majitel + programátor |
| **UI** pro deadline a target SoC před odjezdem. | `docs/04-modules/ev-charging.md` ř. 283 | programátor |
| **Notifikace** při nesplnitelném deadline nabíjení. | `docs/04-modules/ev-charging.md` ř. 284; `docs/04-modules/operating-modes.md` ř. 132 (stale heartbeat) | programátor |
| Ověřit **round-trip účinnost** baterie a **odhad SoC Zoe** z energie session na reálných datech. | `docs/04-modules/ev-charging.md` ř. 282, 285 | programátor |
| **Kalibrace COP** modelu TČ na 46 týdnů historie. | `docs/04-modules/heat-pump.md` ř. 105 | programátor |
| **pvlib** vs jednoduchý model FVE; **Solcast** jako alternativa k Open-Meteo. | `docs/04-modules/forecast.md` ř. 178, 180; `docs/06-open-questions.md` ř. 34 | programátor |
| **Intraday** OTE ceny. | `docs/06-open-questions.md` ř. 35; `docs/04-modules/market-prices.md` ř. 127 | programátor |
| **Sezónní korekce** predikce spotřeby. | `docs/06-open-questions.md` ř. 36; `docs/04-modules/consumption.md` ř. 187 | programátor |
| **Více lokalit** UI a správa. | `docs/06-open-questions.md` ř. 33 | programátor |
| **Mobile / PWA notifikace.** | `docs/06-open-questions.md` ř. 37 | programátor |
| **Reporting** k dodavateli elektřiny. | `docs/06-open-questions.md` ř. 38 | majitel + programátor |
---
## Architektonická rozhodnutí čekající na odpověď
Otázky vyžadující rozhodnutí majitele systému (případně ve spolupráci s integrátory).
| Popis | Kde | Kdo |
|-------|-----|-----|
| Teltonika **OCPP vs REST** (vliv na provoz, údržbu, bezpečnost). | `docs/06-open-questions.md` ř. 910 | majitel + programátor |
| **EUR/CZK** strategie (fix vs API). | `docs/06-open-questions.md` ř. 1112; `docs/04-modules/market-prices.md` ř. 126 | majitel + programátor |
| **TUV** měření vs aproximace ON/OFF. | `docs/06-open-questions.md` ř. 2122 | majitel + programátor |
| **Audit a plán:** jak nakládat s výrobou **pole B** a zeleným bonusem v reportingu. | `docs/06-open-questions.md` ř. 2324; `docs/04-modules/forecast.md` ř. 179 | majitel + programátor |
| **PostgREST / API bezpečnost** pro produkci. | `docs/06-open-questions.md` ř. 2526 | majitel + programátor |
| **Zálohy a DR** PostgreSQL. | `docs/06-open-questions.md` ř. 2728 | majitel + programátor |
| **Přímý Modbus TCP k TČ** vs řízení přes Loxone jako prostředníka. | `docs/04-modules/heat-pump.md` ř. 106 | majitel + Loxone programátor + programátor |
| **pvlib vs jednoduchý** solární model investice do přesnosti. | `docs/04-modules/forecast.md` ř. 178 | majitel + programátor |
| **Rollback / částečný selhání** zápisu setpointů napříč zařízeními. | `docs/04-modules/control.md` ř. 254 | majitel + programátor |
| **SoC zdroj** a prahy pro autonomní režimy v Loxone. | `docs/loxone-integration.md` ř. 262 | majitel + Loxone programátor |
---
## Poznámka k údržbě
Po vyřešení položky ji aktualizuj v **původním** souboru (smaž nebo přeškrtni `[ ]` / TODO) a zde v `05-todo.md` položku odstraň nebo přesuň do changelogu, ať zůstane jeden zdroj pravdy.

38
docs/06-open-questions.md Normal file
View File

@@ -0,0 +1,38 @@
# Otevřené otázky a nedořešené body
Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout před nebo během implementace.
---
## Kritické (blokují implementaci)
- [ ] **Teltonika API vs OCPP** Jaký protokol použít pro komunikaci s EV nabíječkami? OCPP 1.6 je standardní, Teltonika REST API je jednodušší. Rozhodnout před implementací `control.md` EV části.
- [ ] **Kurz EUR/CZK** Fixní hodnota v konfiguraci nebo denní stahování z ČNB API? Ovlivňuje `price_importer.py`.
- [ ] **Azimut a sklon FVE polí** Doplnit přesné hodnoty pro home-01 (pole A). Nutné pro `forecast_service.py`.
- [ ] **GPS souřadnice lokality home-01** Nutné pro Open-Meteo API (lat/lon).
---
## Důležité (neblokují, ale řeší se brzy)
- [ ] **TUV výkon** Je TUV výkon měřitelný zvlášť nebo jen ON/OFF? Pokud jen ON/OFF, použijeme `asset_flexible_device.max_power_w` jako aproximaci.
- [ ] **Pole B (ongridový)** Zahrnout do auditu jako "neřízená výroba"? Nebo ignorovat úplně? Komplikuje audit ale zpřesňuje ho.
- [ ] **PostgREST autentikace** Jaký model? JWT tokeny? Row-level security? Zatím development bez auth, produkce musí mít.
- [ ] **Backup a obnova** Jak se zálohuje PostgreSQL? pg_dump cron? Replikace? Nutné pro produkci.
---
## Fáze 2 (zatím neřešíme)
- [ ] Více lokalit multi-site UI a správa
- [ ] Solcast jako alternativa k Open-Meteo
- [ ] Intraday OTE ceny
- [ ] Sezónní korekce predikce spotřeby
- [ ] Mobile app / PWA notifikace
- [ ] Integrace s dodavatelem elektřiny pro automatický reporting

263
docs/loxone-integration.md Normal file
View File

@@ -0,0 +1,263 @@
# Loxone Integration dokumentace pro programátora
## Účel tohoto dokumentu
Popis jak nakonfigurovat Loxone Miniserver pro spolupráci s EMS platformou.
Implementaci provede Loxone programátor dle tohoto zadání.
**Klíčový princip:** Loxone je exekutor a bezpečnostní fallback.
Veškerá optimalizační logika je v EMS. Loxone:
- vykonává setpointy od EMS (v režimu AUTO)
- funguje zcela autonomně bez EMS (v ostatních režimech)
- sám detekuje výpadek EMS a přepne do bezpečného stavu
---
## 1. Virtual Inputs (EMS → Loxone)
Vytvořit jako **Virtual HTTP Input** v Loxone Config.
EMS posílá hodnoty přes HTTP GET: `/dev/sps/io/{název}/{hodnota}`
| Název VI | Typ | Rozsah | Popis |
|---|---|---|---|
| `EMS_Heartbeat` | Digital pulse | 0/1 | Minutový pulz od EMS. Základ pro watchdog. |
| `EMS_Mode` | Analog | 04 | Aktivní provozní režim (viz tabulka režimů níže). |
| `EMS_Battery_Setpoint_W` | Analog | -20000 až +20000 | Setpoint baterie ve W. Kladné = nabíjení, záporné = vybíjení. Platí jen v AUTO. |
| `EMS_Grid_Setpoint_W` | Analog | -20000 až +20000 | Setpoint sítě ve W. Kladné = import, záporné = export. Platí jen v AUTO. |
| `EMS_EV1_Power_W` | Analog | 022000 | Povolený výkon nabíječky EV č. 1 ve W. 0 = zakázat nabíjení. |
| `EMS_EV2_Power_W` | Analog | 022000 | Povolený výkon nabíječky EV č. 2 ve W. 0 = zakázat nabíjení. |
| `EMS_HeatPump_Enable` | Digital | 0/1 | Povolení provozu tepelného čerpadla. 1 = povolen, 0 = zakázán. |
> **Poznámka k setpointům:** `EMS_Battery_Setpoint_W` a `EMS_Grid_Setpoint_W` jsou informativní vstupy pro AUTO režim. Loxone je předá jako Modbus příkazy do střídače Deye. V ostatních režimech (SELF_SUSTAIN, PRESERVE, MANUAL) Loxone tyto hodnoty ignoruje a řídí se vlastní logikou.
---
## 2. Virtual Outputs (Loxone → EMS čtení)
Vytvořit jako **Virtual HTTP Output** nebo stav dostupný přes HTTP GET pro EMS backend.
| Název VO | Typ | Popis |
|---|---|---|
| `EMS_Mode_Active` | Analog | Aktuálně aktivní režim v Loxone. EMS čte při startu pro zjištění stavu. |
| `EMS_Watchdog_Triggered` | Digital | 1 pokud watchdog přepnul na SELF_SUSTAIN bez příkazu od EMS. Pro diagnostiku. |
---
## 3. Provozní režimy kódování
| Kód EMS | Loxone hodnota `EMS_Mode` | Název |
|---|---|---|
| `MANUAL` | 0 | Manuální žádná logika |
| `AUTO` | 1 | Automatický EMS řídí |
| `SELF_SUSTAIN` | 2 | Soběstačný Loxone autonomní |
| `CHARGE_CHEAP` | 3 | Nabíjení levnou cenou |
| `PRESERVE` | 4 | Ochrana baterie |
---
## 4. Watchdog detekce výpadku EMS
**Toto je nejdůležitější část Loxone implementace.**
Watchdog musí fungovat čistě v Loxone, bez závislosti na DB nebo síti mimo lokální LAN.
### Požadované chování
```
Pokud EMS_Heartbeat pulz nepřijde déle než 5 minut:
→ nastavit EMS_Mode = 2 (SELF_SUSTAIN)
→ nastavit EMS_Watchdog_Triggered = 1
→ logovat čas přepnutí (Loxone log)
Pokud EMS_Heartbeat znovu přijde po výpadku:
→ EMS_Watchdog_Triggered = 0
→ NEMĚNIT EMS_Mode zpět automaticky
(EMS si to přečte a rozhodne sám při restartu)
```
### Doporučená implementace v Loxone Config
**Varianta A Timer / Watchdog blok:**
- Použít blok `Watchdog` nebo `Timer` resetovaný příchozím pulzem `EMS_Heartbeat`
- Timeout: 300 sekund (5 minut)
- Při vypršení timeoutu: výstup spustí přepnutí do SELF_SUSTAIN
**Varianta B Pulse Counter + Time Trigger:**
- Počítat pulzy `EMS_Heartbeat` v 5min okně
- Pokud počet = 0 → přepnout režim
> Výběr varianty závisí na dostupných blocích ve verzi Loxone Config. Programátor zvolí vhodnou implementaci.
### Co Loxone watchdog NESMÍ dělat
- Číst DB nebo jiný HTTP endpoint pro rozhodnutí o watchdogu
- Automaticky přepínat zpět na AUTO při obnovení heartbeatu
- Zasahovat do Modbus komunikace EMS↔střídač (EMS píše Modbus přímo)
---
## 5. Stavový stroj režimů v Loxone
Pro každý `EMS_Mode` hodnotu definovat chování:
### Režim 1 AUTO
```
Střídač Deye:
- Battery charge limit = EMS_Battery_Setpoint_W (pokud kladné)
- Battery discharge limit = ABS(EMS_Battery_Setpoint_W) (pokud záporné)
- Grid export limit = ABS(EMS_Grid_Setpoint_W) (pokud záporné)
- Grid import limit = EMS_Grid_Setpoint_W (pokud kladné)
EV nabíječka 1:
- Max power / current = EMS_EV1_Power_W (přepočet W → A: W / (fáze × 230))
- Enable = 1 pokud EMS_EV1_Power_W > 1380, jinak 0
EV nabíječka 2:
- Stejná logika, EMS_EV2_Power_W
Tepelné čerpadlo:
- Enable = EMS_HeatPump_Enable
```
### Režim 2 SELF_SUSTAIN
```
Střídač Deye:
- Přepnout do Self-Consumption / Battery Priority modu
(konkrétní Modbus registr/hodnota dle Deye dokumentace)
- Export do sítě: zakázat (export limit = 0 W)
- Import ze sítě: povolen jen při SoC pod min_soc_percent (nouzový)
- Baterie: vybíjí do zátěže, nenabíjí ze sítě
EV nabíječky 1 + 2:
- Enable = 0 (zakázat nabíjení)
Tepelné čerpadlo:
- Enable = 0 (odstavit)
```
### Režim 3 CHARGE_CHEAP
```
Střídač Deye:
- Přepnout do Grid Charge modu
- Battery charge limit = EMS_Battery_Setpoint_W (EMS posílá max výkon)
- Export do sítě: zakázat
EV nabíječky:
- Enable = 0
Tepelné čerpadlo:
- Enable = 0
```
### Režim 4 PRESERVE
```
Střídač Deye:
- Přepnout do Self-Consumption s omezeným nabíjením/vybíjením
- Battery: drží aktuální SoC (charge limit = 0, discharge limit = 0)
- Pokrývá spotřebu z FVE, zbytek ze sítě
EV nabíječky:
- Enable = 0
Tepelné čerpadlo:
- Enable = 0
```
### Režim 0 MANUAL
```
Všechny výstupy: žádné automatické zásahy
Střídač: nechat v aktuálním stavu
EV, TČ: Enable = 0
Použít pouze pro servis a ladění
```
---
## 6. Komunikace Loxone → Deye (Modbus)
Loxone komunikuje s Deye střídačem přes **Modbus TCP** (Loxone Modbus Extension nebo přímý TCP).
> Konkrétní Modbus registry Deye SUN-20K doplnit z Deye protokolu PDF.
> Výchozí reference: Loxone šablona pro Deye (pokud existuje).
### Klíčové registry pro zápis (orientační, ověřit z dokumentace)
| Funkce | Registr | Typ | Poznámka |
|---|---|---|---|
| Battery charge power limit | 0x00F3 | Write Single | W |
| Battery discharge power limit | 0x00F4 | Write Single | W |
| Grid export power limit | 0x00F6 | Write Single | W |
| Work mode | 0x00F0 | Write Single | enum hodnoty dle Deye |
### Klíčové registry pro čtení (telemetrie pouze pokud Loxone čte, jinak EMS přes Waveshare)
> **Doporučení:** Telemetrii čte EMS přímo přes Waveshare (Modbus TCP). Loxone čte jen to co potřebuje pro vlastní logiku (SoC pro SELF_SUSTAIN rozhodování).
---
## 7. Komunikace Loxone → EV nabíječky (Teltonika)
Loxone komunikuje s Teltonika TeltoCharge přes **Modbus TCP** (Waveshare převodník).
> Konkrétní Modbus registry Teltonika TeltoCharge doplnit z dokumentace / Loxone šablony.
### Minimální potřebné registry
| Funkce | Popis |
|---|---|
| Enable charging | Povolení/zakázání nabíjení (digital) |
| Max current limit | Maximální proud v A (632A) |
| Connector status | Stav připojení (read) |
---
## 8. Komunikace Loxone → Samsung TČ (Modbus)
Loxone komunikuje se Samsung tepelným čerpadlem přes **Modbus TCP** (Waveshare převodník).
> Registry doplnit ze Samsung NASA Modbus dokumentace / Loxone šablony.
### Minimální potřebné registry
| Funkce | Popis |
|---|---|
| Enable / Disable | Povolení provozu TČ (digital) |
| DHW target temp | Cílová teplota TUV zásobníku (write) |
| DHW tank temp | Aktuální teplota zásobníku (read) pro watchdog nouzového ohřevu |
### Loxone nouzový ohřev (nezávisle na EMS)
Loxone musí implementovat vlastní minimální ochranu TUV zásobníku:
```
Pokud teplota zásobníku < 40°C (absolutní minimum):
→ spustit TČ bez ohledu na EMS_HeatPump_Enable a aktivní režim
→ logovat jako nouzový ohřev
```
Tato logika chrání zásobník i při výpadku EMS nebo přepnutí na SELF_SUSTAIN.
---
## 9. Testovací scénáře pro programátora
Po implementaci ověřit tyto scénáře:
| # | Scénář | Očekávané chování |
|---|---|---|
| 1 | EMS odešle `EMS_Mode=1`, pak každou minutu `EMS_Heartbeat=1` | Loxone v AUTO, přeposílá setpointy do střídače |
| 2 | EMS přestane posílat heartbeat na 6 minut | Loxone přepne na SELF_SUSTAIN, EMS_Watchdog_Triggered=1 |
| 3 | EMS pošle heartbeat znovu po výpadku | Watchdog_Triggered=0, Mode zůstane SELF_SUSTAIN (EMS rozhodne) |
| 4 | EMS odešle `EMS_Mode=4` (PRESERVE) | Loxone drží baterii, žádné nabíjení/vybíjení |
| 5 | Teplota TUV klesne pod 40°C v SELF_SUSTAIN | Loxone spustí TČ nouzově |
| 6 | EMS odešle `EMS_Battery_Setpoint_W=-5000` (vybíjení) | Loxone nastaví discharge limit 5000W, charge limit 0W |
| 7 | EMS odešle `EMS_EV1_Power_W=0` | Loxone zakáže nabíjení nabíječky 1 |
---
## 10. Otevřené body pro programátora
- [ ] Ověřit dostupnost Watchdog / Timer bloku v instalované verzi Loxone Config
- [ ] Zjistit konkrétní Modbus work mode hodnoty pro Deye (Self-Consumption, Grid-Charge, Backup)
- [ ] Ověřit Modbus registry Teltonika z dodané šablony/dokumentace
- [ ] Ověřit Modbus registry Samsung TČ z dodané šablony/dokumentace
- [ ] Dohodnout jestli Loxone čte SoC ze střídače pro SELF_SUSTAIN logiku (nebo pevný threshold)
- [ ] Loxone log přepnutí watchdogu jak přístupný pro EMS při restartu?

5
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
*.md
.env*

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# Stage 1 build static assets
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="cs" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EMS Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

50
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,50 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types
text/css
application/javascript
application/json
text/javascript
application/xml
application/xml+rss
text/plain;
location /api/ {
proxy_pass http://backend:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /rest/ {
proxy_pass http://postgrest:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location / {
try_files $uri $uri/ /index.html;
}
}

4551
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "ems-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.0",
"sonner": "^1.7.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.14",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"tailwindcss": "^4.0.14",
"typescript": "~5.6.3",
"vite": "^5.4.11"
},
"engines": {
"node": ">=20.0.0"
}
}

49
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { useState } from 'react'
import { Toaster } from 'sonner'
import Planning from './Planning'
import { Dashboard } from './pages/Dashboard'
import { Settings } from './pages/Settings'
type Page = 'dashboard' | 'planning' | 'settings'
export default function App() {
const [page, setPage] = useState<Page>('dashboard')
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'
}`}
>
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'
}`}
>
Plá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'
}`}
>
Nastavení
</button>
</div>
</nav>
{page === 'dashboard' ? <Dashboard /> : page === 'planning' ? <Planning /> : <Settings />}
<Toaster richColors position="top-right" theme="dark" />
</div>
)
}

457
frontend/src/Planning.tsx Normal file
View File

@@ -0,0 +1,457 @@
import { Loader2, RefreshCw } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { getCurrentPlan, postRunPlan } from './api/backend'
import { useSiteStatus } from './hooks/useSiteStatus'
import type { CurrentPlanResponse, PlanningIntervalDto } from './types/plan'
const TZ = 'Europe/Prague'
function formatLocal(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('cs-CZ', {
timeZone: TZ,
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function formatLocalTime(iso: string): string {
return new Date(iso).toLocaleTimeString('cs-CZ', {
timeZone: TZ,
hour: '2-digit',
minute: '2-digit',
})
}
function slotStartUtcMs(iso: string): number {
return new Date(iso).getTime()
}
function negPrice(i: PlanningIntervalDto): boolean {
const b = i.effective_buy_price
const s = i.effective_sell_price
return (b != null && b < 0) || (s != null && s < 0)
}
function rowHighlight(i: PlanningIntervalDto): string {
if (negPrice(i)) return 'bg-red-950/45'
if ((i.pv_a_curtailed_w ?? 0) > 0) return 'bg-amber-950/35'
return ''
}
type ChartRow = {
label: string
ts: number
pv_kw: number
baseline_kw: number
bat_charge_kw: number
bat_discharge_kw: number
price: number
raw: PlanningIntervalDto
}
export default function Planning() {
const { site, ready: siteReady } = useSiteStatus()
const siteId = site?.site_id ?? null
const [data, setData] = useState<CurrentPlanResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [replanning, setReplanning] = useState(false)
const [slotDetail, setSlotDetail] = useState<PlanningIntervalDto | null>(null)
const load = useCallback(async () => {
if (siteId == null) return
setLoading(true)
setError(null)
try {
const res = await getCurrentPlan(siteId)
setData(res)
} catch (e) {
setError(e instanceof Error ? e.message : 'Chyba načtení plánu')
setData(null)
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => {
if (siteId != null) void load()
}, [siteId, load])
const nowMs = Date.now()
const dayMs = 24 * 60 * 60 * 1000
const intervals24h = 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, dayMs])
const chartRows: ChartRow[] = useMemo(() => {
return intervals24h.map((i) => {
const bat = i.battery_setpoint_w ?? 0
const pv = i.pv_forecast_total_w ?? 0
const base = i.load_baseline_w ?? 0
const price = i.effective_buy_price ?? 0
return {
label: formatLocalTime(i.interval_start),
ts: slotStartUtcMs(i.interval_start),
pv_kw: pv / 1000,
baseline_kw: base / 1000,
bat_charge_kw: Math.max(0, bat) / 1000,
bat_discharge_kw: Math.max(0, -bat) / 1000,
price,
raw: i,
}
})
}, [intervals24h])
async function onReplan() {
if (siteId == null) return
setReplanning(true)
setError(null)
try {
await postRunPlan(siteId, 'rolling')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Přepočet selhal')
} finally {
setReplanning(false)
}
}
if (!siteReady) {
return (
<div className="flex min-h-[40vh] items-center justify-center text-slate-400">
Načítám lokalitu
</div>
)
}
if (siteId == null) {
return (
<div className="rounded-lg border border-amber-900/50 bg-amber-950/20 p-4 text-amber-200">
V PostgREST nebyla nalezena lokalita (vw_site_status). Nelze načíst plán.
</div>
)
}
const run = data?.run
const summary = data?.summary
return (
<div className="mx-auto max-w-6xl space-y-8 p-4 md:p-6">
<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 přehled dalších 24 hodin ({site?.site_name ?? 'lokalita'})
</p>
</header>
{error && (
<div className="rounded-md border border-red-900/60 bg-red-950/30 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
{/* Sekce 1 */}
<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">
Aktuální plán
</h2>
{loading && !run ? (
<div className="flex items-center gap-2 text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" /> Načítám
</div>
) : !run ? (
<p className="text-slate-400">Žádný aktivní plán v databázi.</p>
) : (
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 md:gap-x-8">
<div>
<dt className="text-slate-500">Vytvořen</dt>
<dd className="font-mono text-slate-200">{formatLocal(run.created_at)}</dd>
</div>
<div>
<dt className="text-slate-500">Typ</dt>
<dd className="capitalize text-slate-200">{run.run_type}</dd>
</div>
<div>
<dt className="text-slate-500">Korekce FVE</dt>
<dd className="font-mono text-slate-200">
{run.forecast_correction_factor != null
? run.forecast_correction_factor.toFixed(4)
: '—'}
</dd>
</div>
<div>
<dt className="text-slate-500">Čas solveru</dt>
<dd className="font-mono text-slate-200">
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
</dd>
</div>
</dl>
<button
type="button"
onClick={() => void onReplan()}
disabled={replanning}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:opacity-50"
>
{replanning ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Přeplánovat nyní
</button>
</div>
)}
{summary && run && (
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-slate-800 pt-4 text-xs text-slate-400 md:grid-cols-5">
<div>
<div className="text-slate-500">Očekávané náklady (celkem)</div>
<div className="font-mono text-slate-200">
{summary.total_expected_cost_czk.toFixed(2)}
</div>
</div>
<div>
<div className="text-slate-500">Curtailment A</div>
<div className="font-mono text-slate-200">
{summary.total_pv_curtailed_kwh.toFixed(3)} kWh
</div>
</div>
<div>
<div className="text-slate-500">Sloty nabíjení</div>
<div className="font-mono text-slate-200">{summary.charge_slots}</div>
</div>
<div>
<div className="text-slate-500">Sloty vybíjení</div>
<div className="font-mono text-slate-200">{summary.discharge_slots}</div>
</div>
<div>
<div className="text-slate-500">Sloty exportu</div>
<div className="font-mono text-slate-200">{summary.export_slots}</div>
</div>
</div>
)}
</section>
{/* 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 (24 h)
</h2>
{!chartRows.length ? (
<p className="text-sm text-slate-500">Žádná data pro graf v horizontu 24 h.</p>
) : (
<div className="h-[380px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartRows}
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
onClick={(state) => {
const p = state?.activePayload?.[0]?.payload as ChartRow | undefined
if (p?.raw) setSlotDetail(p.raw)
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis
yAxisId="left"
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b' }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'Kč/kWh', angle: 90, position: 'insideRight', fill: '#64748b' }}
/>
<Tooltip
contentStyle={{
background: '#0f172a',
border: '1px solid #334155',
borderRadius: 8,
}}
formatter={(value: number, name: string) => {
if (name === 'Cena nákup') return [`${value.toFixed(3)} Kč/kWh`, name]
return [`${value.toFixed(2)} kW`, name]
}}
/>
<Legend />
<Area
yAxisId="left"
type="monotone"
dataKey="pv_kw"
name="FVE předpověď"
stroke="#ca8a04"
fill="#eab308"
fillOpacity={0.35}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="baseline_kw"
name="Spotřeba baseline"
stroke="#3b82f6"
dot={false}
strokeWidth={2}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="bat_charge_kw"
name="Baterie nabíjení"
stroke="#22c55e"
dot={false}
strokeWidth={2}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="bat_discharge_kw"
name="Baterie vybíjení"
stroke="#f97316"
dot={false}
strokeWidth={2}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="price"
name="Cena nákup"
stroke="#94a3b8"
dot={false}
strokeWidth={2}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{slotDetail && (
<div className="mt-4 rounded-lg border border-slate-700 bg-slate-950/60 p-3 text-sm">
<div className="mb-2 flex items-center justify-between">
<span className="font-medium text-slate-200">
Slot {formatLocal(slotDetail.interval_start)}
</span>
<button
type="button"
className="text-xs text-slate-500 hover:text-slate-300"
onClick={() => setSlotDetail(null)}
>
Zavřít
</button>
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-xs text-slate-300 md:grid-cols-3">
<dt className="text-slate-500">Nákup / prodej</dt>
<dd className="col-span-1">
{slotDetail.effective_buy_price?.toFixed(4) ?? '—'} /{' '}
{slotDetail.effective_sell_price?.toFixed(4) ?? '—'}
</dd>
<dt className="text-slate-500">FVE (A+B)</dt>
<dd>{slotDetail.pv_forecast_total_w ?? '—'} W</dd>
<dt className="text-slate-500">Baseline</dt>
<dd>{slotDetail.load_baseline_w ?? '—'} W</dd>
<dt className="text-slate-500">Baterie</dt>
<dd>{slotDetail.battery_setpoint_w ?? '—'} W</dd>
<dt className="text-slate-500">SoC cíl</dt>
<dd>
{slotDetail.battery_soc_target_pct != null
? `${slotDetail.battery_soc_target_pct}%`
: '—'}
</dd>
<dt className="text-slate-500">Síť</dt>
<dd>{slotDetail.grid_setpoint_w ?? '—'} W</dd>
<dt className="text-slate-500">EV1 / EV2</dt>
<dd>
{slotDetail.ev1_setpoint_w ?? '—'} / {slotDetail.ev2_setpoint_w ?? '—'} W
</dd>
<dt className="text-slate-500"></dt>
<dd>{slotDetail.heat_pump_enabled ? 'Zapnuto' : 'Vypnuto'}</dd>
<dt className="text-slate-500">Curtailment A</dt>
<dd>{slotDetail.pv_a_curtailed_w ?? 0} W</dd>
<dt className="text-slate-500">Náklady slotu</dt>
<dd>{slotDetail.expected_cost_czk?.toFixed(4) ?? '—'} </dd>
</dl>
</div>
)}
</section>
{/* 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 (96 slotů / 24 h)
</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-xs">
<thead>
<tr className="border-b border-slate-700 text-slate-500">
<th className="py-2 pr-2 font-medium">Čas</th>
<th className="py-2 pr-2 font-medium">Nákup</th>
<th className="py-2 pr-2 font-medium">Prodej</th>
<th className="py-2 pr-2 font-medium">FVE</th>
<th className="py-2 pr-2 font-medium">Bat</th>
<th className="py-2 pr-2 font-medium">Síť</th>
<th className="py-2 pr-2 font-medium">EV1</th>
<th className="py-2 pr-2 font-medium">EV2</th>
<th className="py-2 pr-2 font-medium"></th>
<th className="py-2 font-medium">Náklady</th>
</tr>
</thead>
<tbody>
{intervals24h.map((i) => (
<tr key={i.interval_start} className={`border-b border-slate-800/80 ${rowHighlight(i)}`}>
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-300">
{formatLocalTime(i.interval_start)}
</td>
<td className="pr-2 font-mono text-slate-300">
{i.effective_buy_price?.toFixed(2) ?? '—'}
</td>
<td className="pr-2 font-mono text-slate-300">
{i.effective_sell_price?.toFixed(2) ?? '—'}
</td>
<td className="pr-2 font-mono text-slate-300">
{i.pv_forecast_total_w != null ? Math.round(i.pv_forecast_total_w) : '—'}
</td>
<td className="pr-2 font-mono text-slate-300">
{i.battery_setpoint_w ?? '—'}
</td>
<td className="pr-2 font-mono text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'Ano' : 'Ne'}</td>
<td className="font-mono text-slate-300">
{i.expected_cost_czk?.toFixed(2) ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{!intervals24h.length && !loading && (
<p className="mt-2 text-sm text-slate-500">Žádné řádky v 24h okně.</p>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import axios, { type AxiosInstance } from 'axios'
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
const client: AxiosInstance = axios.create({
baseURL: '/api/v1',
headers: { Accept: 'application/json' },
timeout: 30_000,
})
/** Příklad: health / readiness až budou v FastAPI exponované. */
export async function getBackendHealth(): Promise<unknown> {
const { data } = await client.get('/health')
return data
}
export type SetSiteModePayload = {
mode: string
notes: string | null
valid_until: string | null
}
export type SetSiteModeResponse = {
success: boolean
mode: string
activated_at: string
}
export async function postSiteMode(
siteId: number,
payload: SetSiteModePayload,
): Promise<SetSiteModeResponse> {
const { data } = await client.post<SetSiteModeResponse>(`/sites/${siteId}/mode`, payload)
return data
}
export async function getCurrentPlan(siteId: number): Promise<CurrentPlanResponse> {
const { data } = await client.get<CurrentPlanResponse>(`/sites/${siteId}/plan/current`, {
timeout: 60_000,
})
return data
}
export async function postRunPlan(
siteId: number,
planType: 'daily' | 'rolling',
): Promise<RunPlanResponse> {
const { data } = await client.post<RunPlanResponse>(
`/sites/${siteId}/plan/run`,
null,
{ params: { type: planType }, timeout: 120_000 },
)
return data
}
export { client as backendClient }

View File

@@ -0,0 +1,14 @@
import axios, { type AxiosInstance } from 'axios'
const client: AxiosInstance = axios.create({
baseURL: '/rest',
headers: { Accept: 'application/json' },
timeout: 15_000,
})
export async function getJson<T>(path: string, params?: Record<string, string>): Promise<T> {
const { data } = await client.get<T>(path, { params })
return data
}
export { client as postgrestClient }

View File

@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
import type { ModeLogRecentRow } from '../types/ems'
function modeBadgeClass(code: string): string {
const c = code.toUpperCase()
if (c === 'AUTO') return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35'
if (c === 'SELF_SUSTAIN') return 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/35'
if (c === 'CHARGE_CHEAP') return 'bg-violet-500/15 text-violet-200 ring-1 ring-violet-500/35'
if (c === 'PRESERVE') return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
if (c === 'MANUAL') return 'bg-slate-600/50 text-slate-200 ring-1 ring-slate-500/40'
return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50'
}
function num(v: string | number | null | undefined): number {
if (v == null) return NaN
const n = typeof v === 'number' ? v : Number(v)
return n
}
function formatDuration(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = Math.floor(sec % 60)
if (h > 0) return `${h} h ${m} min`
if (m > 0) return `${m} min ${s > 0 ? `${s} s` : ''}`.trim()
return `${s} s`
}
function fmtTime(iso: string): string {
try {
const d = new Date(iso)
return d.toLocaleString('cs-CZ', { dateStyle: 'short', timeStyle: 'medium' })
} catch {
return iso
}
}
type Props = {
siteId: number | null
}
export function ModeLog({ siteId }: Props) {
const [rows, setRows] = useState<ModeLogRecentRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
if (siteId == null) {
setRows([])
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const data = await getJson<ModeLogRecentRow[]>('/vw_mode_log_recent', {
site_id: `eq.${siteId}`,
order: 'activated_at.desc',
limit: '20',
})
setRows(Array.isArray(data) ? data : [])
} catch (e) {
setError(String(e))
setRows([])
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => {
void load()
}, [load])
if (siteId == null) {
return <p className="text-sm text-slate-500">Vyberte nebo načtěte lokalitu.</p>
}
if (loading) {
return <div className="h-40 animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
if (error) {
return <p className="text-sm text-red-400">Nelze načíst log: {error}</p>
}
return (
<div className="overflow-x-auto rounded-xl border border-slate-800">
<table className="w-full min-w-[640px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-slate-800 bg-slate-900/80 text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-4 py-3">Čas</th>
<th className="px-4 py-3">Režim</th>
<th className="px-4 py-3">Trvání</th>
<th className="px-4 py-3">Kdo</th>
<th className="px-4 py-3">Poznámka</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Žádné záznamy za posledních 7 dní.
</td>
</tr>
) : (
rows.map((r) => (
<tr key={r.id} className="border-b border-slate-800/80 hover:bg-slate-900/40">
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-slate-300">{fmtTime(r.activated_at)}</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-md px-2 py-0.5 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(r.mode_code)}`}
>
{r.mode_code}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-slate-400">{formatDuration(num(r.duration_sec))}</td>
<td className="max-w-[140px] truncate px-4 py-3 text-slate-400" title={r.activated_by ?? ''}>
{r.activated_by ?? '—'}
</td>
<td className="max-w-[280px] truncate px-4 py-3 text-slate-400" title={r.notes ?? ''}>
{r.notes?.trim() ? r.notes : '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,269 @@
import {
BatteryCharging,
Bot,
Car,
Check,
Home,
Shield,
Thermometer,
Wrench,
X,
} from 'lucide-react'
import axios from 'axios'
import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { postSiteMode } from '../api/backend'
export type OperatingModeCode = 'AUTO' | 'SELF_SUSTAIN' | 'CHARGE_CHEAP' | 'PRESERVE' | 'MANUAL'
type ModeDef = {
code: OperatingModeCode
title: string
description: string
ev: boolean
hp: boolean
Icon: typeof Bot
}
const MODES: ModeDef[] = [
{
code: 'AUTO',
title: 'AUTO',
description: 'EMS řídí FVE, baterii, EV a TČ podle plánu a cen.',
ev: true,
hp: true,
Icon: Bot,
},
{
code: 'SELF_SUSTAIN',
title: 'SELF_SUSTAIN',
description: 'Autonomní domácí režim bez exportu; EV a TČ zastaveny.',
ev: false,
hp: false,
Icon: Home,
},
{
code: 'CHARGE_CHEAP',
title: 'CHARGE_CHEAP',
description: 'Max. nabíjení baterie; EV a TČ vypnuty.',
ev: false,
hp: false,
Icon: BatteryCharging,
},
{
code: 'PRESERVE',
title: 'PRESERVE',
description: 'Držení SoC; EV a TČ zastaveny (dovolená / servis).',
ev: false,
hp: false,
Icon: Shield,
},
{
code: 'MANUAL',
title: 'MANUAL',
description: 'Servisní režim; žádné řízení z EMS.',
ev: false,
hp: false,
Icon: Wrench,
},
]
function modeBadgeRing(code: string): string {
const c = code.toUpperCase()
if (c === 'AUTO') return 'ring-emerald-500/50'
if (c === 'SELF_SUSTAIN') return 'ring-cyan-500/50'
if (c === 'CHARGE_CHEAP') return 'ring-violet-500/50'
if (c === 'PRESERVE') return 'ring-amber-500/50'
if (c === 'MANUAL') return 'ring-slate-500/50'
return 'ring-slate-600'
}
type Props = {
siteId: number | null
currentMode: string | null | undefined
onModeApplied?: () => void
}
export function ModeSelector({ siteId, currentMode, onModeApplied }: Props) {
const [pending, setPending] = useState<OperatingModeCode | null>(null)
const [notes, setNotes] = useState('')
const [validUntilLocal, setValidUntilLocal] = useState('')
const [optimisticMode, setOptimisticMode] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const displayMode = optimisticMode ?? currentMode ?? null
const normalizedCurrent = (displayMode ?? '').toUpperCase()
const closeModal = useCallback(() => {
setPending(null)
setNotes('')
setValidUntilLocal('')
}, [])
const confirmSwitch = useCallback(async () => {
if (siteId == null || pending == null) return
const modeCode = pending
const notePayload = notes.trim() === '' ? null : notes.trim()
const valid_until =
validUntilLocal.trim() === '' ? null : new Date(validUntilLocal).toISOString()
setSubmitting(true)
setOptimisticMode(modeCode)
closeModal()
try {
await postSiteMode(siteId, {
mode: modeCode,
notes: notePayload,
valid_until,
})
setOptimisticMode(null)
onModeApplied?.()
toast.success(`Režim ${modeCode} byl aktivován.`)
} catch (e: unknown) {
setOptimisticMode(null)
let msg = String(e)
if (axios.isAxiosError(e)) {
const d = e.response?.data as { detail?: unknown } | undefined
if (d?.detail != null) {
msg = Array.isArray(d.detail) ? d.detail.map((x) => JSON.stringify(x)).join('; ') : String(d.detail)
} else if (e.message) {
msg = e.message
}
}
toast.error('Přepnutí režimu se nezdařilo', { description: msg })
} finally {
setSubmitting(false)
}
}, [siteId, pending, notes, validUntilLocal, closeModal, onModeApplied])
const openConfirm = useCallback(
(code: OperatingModeCode) => {
if (siteId == null) {
toast.error('Chybí lokalita (site_id).')
return
}
if (code === normalizedCurrent) return
setPending(code)
setNotes('')
setValidUntilLocal('')
},
[siteId, normalizedCurrent],
)
const modalTitle = useMemo(() => {
if (!pending) return ''
const m = MODES.find((x) => x.code === pending)
return m?.title ?? pending
}, [pending])
return (
<div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{MODES.map(({ code, title, description, ev, hp, Icon }) => {
const active = normalizedCurrent === code
return (
<button
key={code}
type="button"
disabled={siteId == null || submitting}
onClick={() => openConfirm(code)}
className={[
'flex flex-col rounded-xl border p-4 text-left transition',
active
? 'border-emerald-500/70 bg-emerald-950/35 ring-2 ring-emerald-500/40'
: 'border-slate-800 bg-slate-900/40 hover:border-slate-600 hover:bg-slate-900/70',
submitting ? 'opacity-60' : '',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`flex h-9 w-9 items-center justify-center rounded-lg bg-slate-800/80 ring-1 ${modeBadgeRing(code)}`}
>
<Icon className="h-5 w-5 text-slate-200" aria-hidden />
</span>
<span className="text-sm font-semibold tracking-wide text-slate-100">{title}</span>
</div>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-snug text-slate-400">{description}</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Car className="h-3.5 w-3.5" aria-hidden />
EV
{ev ? (
<Check className="h-3.5 w-3.5 text-emerald-400" aria-label="povoleno" />
) : (
<X className="h-3.5 w-3.5 text-red-400" aria-label="zakázáno" />
)}
</span>
<span className="flex items-center gap-1">
<Thermometer className="h-3.5 w-3.5" aria-hidden />
{hp ? (
<Check className="h-3.5 w-3.5 text-emerald-400" aria-label="povoleno" />
) : (
<X className="h-3.5 w-3.5 text-red-400" aria-label="zakázáno" />
)}
</span>
</div>
</button>
)
})}
</div>
{pending ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="mode-confirm-title"
onClick={(ev) => {
if (ev.target === ev.currentTarget) closeModal()
}}
>
<div className="w-full max-w-md rounded-xl border border-slate-700 bg-slate-950 p-6 shadow-xl">
<h3 id="mode-confirm-title" className="text-lg font-semibold text-white">
Přepnout na {modalTitle}?
</h3>
<p className="mt-1 text-sm text-slate-400">Změna se zapíše do DB a odešle se signál do Loxone (je-li endpoint).</p>
<label className="mt-4 block text-xs font-medium uppercase tracking-wide text-slate-500">
Poznámka (volitelné)
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600"
placeholder="např. odjezd na víkend"
/>
</label>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-slate-500">
Platí do (volitelné, lokální čas prohlížeče)
<input
type="datetime-local"
value={validUntilLocal}
onChange={(e) => setValidUntilLocal(e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</label>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-200 hover:bg-slate-800"
>
Zrušit
</button>
<button
type="button"
disabled={submitting}
onClick={() => void confirmSwitch()}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
>
Potvrdit přepnutí
</button>
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,33 @@
import type { LucideIcon } from 'lucide-react'
function formatKw(powerW: number | null | undefined): string {
if (powerW == null || Number.isNaN(powerW)) return '—'
const kw = powerW / 1000
return `${kw.toFixed(2)} kW`
}
type Props = {
label: string
powerW: number | null | undefined
icon: LucideIcon
/** např. border-l-amber-400 */
borderClass: string
/** např. text-amber-400 */
iconClass: string
}
export function PowerFlowCard({ label, powerW, icon: Icon, borderClass, iconClass }: Props) {
return (
<div
className={`flex items-center gap-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4 pl-3 shadow-sm backdrop-blur-sm border-l-4 ${borderClass}`}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Icon className={`h-6 w-6 ${iconClass}`} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
<p className="truncate text-xl font-semibold tabular-nums text-slate-100">{formatKw(powerW)}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useState } from 'react'
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { getJson } from '../api/postgrest'
import { instantPragueDay, pragueCalendarDay } from '../lib/pragueDate'
import type { SiteEffectivePriceRow } from '../types/ems'
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
}
export type PricePoint = {
label: string
buy: number | null
sell: number | null
}
type Props = {
siteId: number | null
pollMs?: number
}
/** Efektivní nákup / prodej (Kč/kWh) pro dnešní den v Europe/Prague. */
export function PriceChart({ siteId, pollMs = 120_000 }: Props) {
const [points, setPoints] = useState<PricePoint[]>([])
const [ready, setReady] = useState(false)
const load = useCallback(async () => {
if (siteId == null) {
setPoints([])
setReady(true)
return
}
try {
const rows = await getJson<SiteEffectivePriceRow[]>('/vw_site_effective_price', {
site_id: `eq.${siteId}`,
order: 'interval_start.desc',
limit: '200',
})
const today = pragueCalendarDay()
const todayRows = Array.isArray(rows)
? rows.filter((r) => instantPragueDay(r.interval_start) === today)
: []
todayRows.sort((a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime())
const mapped: PricePoint[] = todayRows.map((r) => {
const t = new Date(r.interval_start)
return {
label: t.toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Prague',
}),
buy: parseNum(r.effective_buy_price_czk_kwh),
sell: parseNum(r.effective_sell_price_czk_kwh),
}
})
setPoints(mapped)
} catch {
setPoints([])
} finally {
setReady(true)
}
}, [siteId])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), pollMs)
return () => window.clearInterval(id)
}, [load, pollMs])
if (!ready || points.length === 0) {
return <div className="h-[280px] w-full animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
return (
<div className="h-[280px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.6} />
<XAxis dataKey="label" tick={{ fill: '#94a3b8', fontSize: 10 }} interval="preserveStartEnd" />
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'Kč/kWh', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="stepAfter" dataKey="buy" name="Nákup" stroke="#f97316" strokeWidth={2} dot={false} connectNulls />
<Line type="stepAfter" dataKey="sell" name="Prodej" stroke="#38bdf8" strokeWidth={2} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,77 @@
function clampPct(n: number): number {
return Math.max(0, Math.min(100, n))
}
function parseSoc(v: string | number | null | undefined): number | null {
if (v == null) return null
if (typeof v === 'number' && !Number.isNaN(v)) return v
const x = Number(v)
return Number.isFinite(x) ? x : null
}
type Props = {
socPercent: string | number | null | undefined
loading?: boolean
}
const R = 52
const C = 2 * Math.PI * R
const STROKE = 8
export function SocGauge({ socPercent, loading }: Props) {
if (loading) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
<div className="mt-4 h-4 w-24 animate-pulse rounded bg-slate-800/80" />
</div>
)
}
const raw = parseSoc(socPercent)
if (raw == null) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
<div className="mt-4 h-3 w-20 animate-pulse rounded bg-slate-800/80" />
</div>
)
}
const pct = clampPct(raw)
const offset = C - (pct / 100) * C
return (
<div className="flex flex-col items-center rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div className="relative">
<svg width="140" height="140" viewBox="0 0 120 120" className="-rotate-90" aria-hidden>
<circle
cx="60"
cy="60"
r={R}
fill="none"
stroke="currentColor"
strokeWidth={STROKE}
className="text-slate-800"
/>
<circle
cx="60"
cy="60"
r={R}
fill="none"
stroke="currentColor"
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={offset}
className="text-emerald-500 transition-[stroke-dashoffset] duration-500"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold tabular-nums text-slate-50">{pct.toFixed(0)}</span>
<span className="text-xs text-slate-500">% SoC</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { TelemetryChartPoint } from '../hooks/useTelemetryToday'
type Props = {
points: TelemetryChartPoint[]
loading?: boolean
}
function ChartSkeleton() {
return <div className="h-[320px] w-full animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
export function TelemetryChart({ points, loading }: Props) {
if (loading || points.length === 0) {
return <ChartSkeleton />
}
return (
<div className="h-[320px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.6} />
<XAxis dataKey="timeLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
}}
labelStyle={{ color: '#e2e8f0' }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="pv_kw" name="FVE" stroke="#facc15" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="load_kw" name="Spotřeba" stroke="#3b82f6" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="battery_kw" name="Baterie" stroke="#22c55e" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="grid_kw" name="Síť" stroke="#94a3b8" strokeWidth={2} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
import { instantPragueDay, pragueCalendarDay } from '../lib/pragueDate'
import type { AuditDailyRow } from '../types/ems'
const POLL_MS = 30_000
export function useAuditDailyToday(siteId: number | null) {
const [row, setRow] = useState<AuditDailyRow | null>(null)
const [ready, setReady] = useState(false)
const load = useCallback(async () => {
if (siteId == null) {
setRow(null)
setReady(true)
return
}
try {
const rows = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
site_id: `eq.${siteId}`,
order: 'day_local.desc',
limit: '45',
})
const today = pragueCalendarDay()
const hit = Array.isArray(rows) ? rows.find((r) => instantPragueDay(r.day_local) === today) : undefined
setRow(hit ?? null)
} catch {
setRow(null)
} finally {
setReady(true)
}
}, [siteId])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), POLL_MS)
return () => window.clearInterval(id)
}, [load])
return {
daily: row,
ready,
hasDaily: row != null && (row.interval_count ?? 0) > 0,
}
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
import type { SiteStatusRow } from '../types/ems'
const POLL_MS = 5_000
export function useSiteStatus() {
const [row, setRow] = useState<SiteStatusRow | null>(null)
const [ready, setReady] = useState(false)
const load = useCallback(async () => {
try {
const rows = await getJson<SiteStatusRow[]>('/vw_site_status')
setRow(Array.isArray(rows) && rows.length > 0 ? rows[0]! : null)
} catch {
setRow(null)
} finally {
setReady(true)
}
}, [])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), POLL_MS)
return () => window.clearInterval(id)
}, [load])
const hasTelemetry =
row != null &&
(row.pv_power_w != null ||
row.battery_power_w != null ||
row.grid_power_w != null ||
row.battery_soc_percent != null)
return {
site: row,
ready,
/** Máme řádek lokality a alespoň jednu telemetrickou hodnotu (jinak skeleton). */
hasLiveData: row != null && hasTelemetry,
reload: load,
}
}

View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
import type { AuditTodayHourlyRow } from '../types/ems'
const POLL_MS = 30_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
}
export type TelemetryChartPoint = {
timeLabel: string
ts: number
pv_kw: number | null
load_kw: number | null
battery_kw: number | null
grid_kw: number | null
}
export function useTelemetryToday(siteId: number | null) {
const [points, setPoints] = useState<TelemetryChartPoint[]>([])
const [ready, setReady] = useState(false)
const load = useCallback(async () => {
if (siteId == null) {
setPoints([])
setReady(true)
return
}
try {
const rows = await getJson<AuditTodayHourlyRow[]>('/vw_audit_today_hourly', {
site_id: `eq.${siteId}`,
order: 'hour_local.asc',
})
if (!Array.isArray(rows) || rows.length === 0) {
setPoints([])
return
}
const mapped: TelemetryChartPoint[] = rows.map((r) => {
const d = new Date(r.hour_local)
return {
ts: d.getTime(),
timeLabel: d.toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Prague',
}),
pv_kw: parseNum(r.avg_pv_kw),
load_kw: parseNum(r.avg_load_kw),
battery_kw: parseNum(r.avg_battery_kw),
grid_kw: parseNum(r.avg_grid_kw),
}
})
setPoints(mapped)
} catch {
setPoints([])
} finally {
setReady(true)
}
}, [siteId])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), POLL_MS)
return () => window.clearInterval(id)
}, [load])
return { points, ready, hasChartData: points.length > 0 }
}

8
frontend/src/index.css Normal file
View File

@@ -0,0 +1,8 @@
@import 'tailwindcss';
@config "../tailwind.config.ts";
@layer base {
body {
@apply min-h-screen bg-slate-950 text-slate-100 antialiased;
}
}

View File

@@ -0,0 +1,18 @@
/** Kalendářní den YYYY-MM-DD v časové zóně Europe/Prague. */
export function pragueCalendarDay(d = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Prague',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
export function instantPragueDay(iso: string): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Prague',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(iso))
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,176 @@
import { Battery, Sun, Zap } from 'lucide-react'
import { PowerFlowCard } from '../components/PowerFlowCard'
import { SocGauge } from '../components/SocGauge'
import { TelemetryChart } from '../components/TelemetryChart'
import { useAuditDailyToday } from '../hooks/useAuditDailyToday'
import { useSiteStatus } from '../hooks/useSiteStatus'
import { useTelemetryToday } from '../hooks/useTelemetryToday'
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 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 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 batteryStyles(powerW: number | null | undefined): { border: string; icon: string } {
if (powerW == null || Number.isNaN(powerW)) {
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
}
if (powerW >= 0) {
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
}
return { border: 'border-l-orange-500', icon: 'text-orange-400' }
}
function gridStyles(powerW: number | null | undefined): { border: string; icon: string } {
if (powerW == null || Number.isNaN(powerW)) {
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
}
if (powerW >= 0) {
return { border: 'border-l-red-500', icon: 'text-red-400' }
}
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
}
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
return (
<div className="mb-4">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
</div>
)
}
function CardSkeleton() {
return <div className="h-[88px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
function StatBlock({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
</div>
)
}
function StatSkeleton() {
return <div className="h-[76px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
export function Dashboard() {
const { site, ready: siteReady, hasLiveData } = useSiteStatus()
const siteId = site?.site_id ?? null
const { points, ready: telemetryReady, hasChartData } = useTelemetryToday(siteId)
const { daily, ready: auditReady, hasDaily } = useAuditDailyToday(siteId)
const liveSkeleton = !siteReady || !hasLiveData
const chartSkeleton = !telemetryReady || !hasChartData
const econSkeleton = !auditReady || !hasDaily
const hbOk = site?.ems_heartbeat_status === 'ok'
const bat = batteryStyles(site?.battery_power_w ?? null)
const grd = gridStyles(site?.grid_power_w ?? null)
return (
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
<div className="mx-auto max-w-7xl space-y-10">
<header className="flex flex-col gap-4 border-b border-slate-800/80 pb-6 md:flex-row md:items-center md:justify-between">
<div>
<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 a auditu</p>
</div>
{!siteReady ? (
<div className="h-10 w-56 animate-pulse rounded-lg bg-slate-800/80" />
) : site ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-slate-400">{site.site_name}</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}
>
{site.active_mode ?? '—'}
{site.mode_name ? ` · ${site.mode_name}` : ''}
</span>
<span className="flex items-center gap-2 text-xs text-slate-500">
<span className="relative flex h-2.5 w-2.5">
<span
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOk ? 'bg-emerald-500' : 'bg-red-500'}`}
title={site.ems_heartbeat_status ?? 'neznámý'}
/>
</span>
EMS
</span>
</div>
) : null}
</header>
<section>
<SectionTitle kicker="Živě" title="Aktuální stav" />
{liveSkeleton ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<CardSkeleton />
<CardSkeleton />
<div className="flex min-h-[88px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
</div>
<CardSkeleton />
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<PowerFlowCard label="FVE" powerW={site?.pv_power_w} icon={Sun} borderClass="border-l-amber-400" iconClass="text-amber-400" />
<PowerFlowCard
label="Baterie"
powerW={site?.battery_power_w}
icon={Battery}
borderClass={bat.border}
iconClass={bat.icon}
/>
<SocGauge socPercent={site?.battery_soc_percent} loading={false} />
<PowerFlowCard label="Síť" powerW={site?.grid_power_w} icon={Zap} borderClass={grd.border} iconClass={grd.icon} />
</div>
)}
</section>
<section>
<SectionTitle kicker="Dnes" title="Průběh výkonů (hodinový průměr)" />
<TelemetryChart points={points} loading={chartSkeleton} />
</section>
<section>
<SectionTitle kicker="Dnes" title="Ekonomika auditu" />
{econSkeleton ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatSkeleton />
<StatSkeleton />
<StatSkeleton />
<StatSkeleton />
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatBlock label="Import" value={fmtEnergy(daily?.import_kwh)} />
<StatBlock label="Export" value={fmtEnergy(daily?.export_kwh)} />
<StatBlock label="FVE výroba" value={fmtEnergy(daily?.pv_kwh)} />
<StatBlock label="Náklady / příjem (audit)" value={fmtMoney(daily?.actual_cost_czk)} />
</div>
)}
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { ModeLog } from '../components/ModeLog'
import { ModeSelector } from '../components/ModeSelector'
import { useSiteStatus } from '../hooks/useSiteStatus'
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
return (
<div className="mb-4">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
</div>
)
}
export function Settings() {
const { site, ready, reload } = useSiteStatus()
const siteId = site?.site_id ?? null
return (
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
<div className="mx-auto max-w-7xl space-y-12">
<header className="border-b border-slate-800/80 pb-6">
<h1 className="text-2xl font-bold tracking-tight text-white">Nastavení</h1>
<p className="mt-1 text-sm text-slate-400">Provozní režim a plánování flexibilní zátěže</p>
{ready && site ? (
<p className="mt-2 text-sm text-slate-500">
Lokalita: <span className="text-slate-300">{site.site_name}</span> ({site.site_code})
</p>
) : null}
</header>
<section>
<SectionTitle kicker="Řízení" title="Provozní režim" />
<p className="mb-4 max-w-3xl text-sm text-slate-400">
Přepnutí zapíše stav do databáze a notifikuje Loxone. U dočasného režimu lze nastavit čas návratu; po vypršení systém obnoví předchozí režim.
</p>
<ModeSelector
siteId={siteId}
currentMode={site?.active_mode}
onModeApplied={() => void reload()}
/>
<div className="mt-8">
<h3 className="mb-3 text-sm font-medium text-slate-300">Poslední přepnutí</h3>
<ModeLog siteId={siteId} />
</div>
</section>
<section>
<SectionTitle kicker="EV" title="Deadline nabíjení (připravuje se)" />
<p className="mb-4 text-sm text-slate-500">
Zatím pouze rozhraní; napojení na API a session přijde v další iteraci.
</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<p className="text-sm font-medium text-slate-200">Tesla</p>
<div className="mt-3 flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Cílové SoC %
<input
type="number"
min={0}
max={100}
placeholder="např. 80"
disabled
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
</label>
</div>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<p className="text-sm font-medium text-slate-200">Zoe</p>
<div className="mt-3 flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Cílové SoC %
<input
type="number"
min={0}
max={100}
placeholder="např. 80"
disabled
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
</label>
</div>
</div>
</div>
</section>
</div>
</div>
)
}

76
frontend/src/types/ems.ts Normal file
View File

@@ -0,0 +1,76 @@
/** ems.vw_site_status (PostgREST) */
export type SiteStatusRow = {
site_id: number
site_code: string
site_name: string
active_mode: string | null
mode_name: string | null
mode_description: string | null
is_autonomous: boolean | null
activated_at: string | null
activated_by: string | null
valid_until: string | null
previous_mode: string | null
mode_notes: string | null
ems_last_seen: string | null
ems_status: string | null
ems_age_seconds: number | null
ems_heartbeat_status: 'ok' | 'delayed' | 'stale' | 'never_seen' | null
pv_power_w: number | null
battery_soc_percent: string | number | null
battery_power_w: number | null
grid_power_w: number | null
load_power_w: number | null
telemetry_at: string | null
}
/** ems.vw_audit_today_hourly */
export type AuditTodayHourlyRow = {
site_id: number
hour_local: string
avg_pv_kw: string | number | null
avg_battery_kw: string | number | null
avg_grid_kw: string | number | null
avg_load_kw: string | number | null
avg_soc_pct: string | number | null
cost_czk: string | number | null
}
/** ems.vw_audit_daily */
export type AuditDailyRow = {
site_id: number
day_local: string
interval_count: number
import_kwh: string | number | null
export_kwh: string | number | null
pv_kwh: string | number | null
load_kwh: string | number | null
ev_kwh: string | number | null
hp_kwh: string | number | null
actual_cost_czk: string | number | null
total_deviation_czk: string | number | null
high_deviation_count: number | null
}
/** ems.vw_mode_log_recent (PostgREST) */
export type ModeLogRecentRow = {
id: number
site_id: number
site_code: string
mode_code: string
mode_name: string
activated_at: string
deactivated_at: string | null
duration_sec: number
activated_by: string | null
notes: string | null
}
/** ems.vw_site_effective_price */
export type SiteEffectivePriceRow = {
site_id: number
interval_start: string
interval_end: string
effective_buy_price_czk_kwh: string | number | null
effective_sell_price_czk_kwh: string | number | null
}

View File

@@ -0,0 +1,46 @@
/** Odpověď GET /api/v1/sites/{id}/plan/current */
export type PlanningRunDto = {
id: number
created_at: string
run_type: string
horizon_start: string
horizon_end: string
forecast_correction_factor: number | null
solver_duration_ms: number | null
}
export type PlanningIntervalDto = {
interval_start: string
battery_setpoint_w: number | null
battery_soc_target_pct: number | null
grid_setpoint_w: number | null
ev1_setpoint_w: number | null
ev2_setpoint_w: number | null
heat_pump_enabled: boolean | null
pv_a_curtailed_w: number | null
expected_cost_czk: number | null
effective_buy_price: number | null
effective_sell_price: number | null
pv_forecast_total_w: number | null
load_baseline_w: number | null
}
export type PlanningSummaryDto = {
total_expected_cost_czk: number
total_pv_curtailed_kwh: number
charge_slots: number
discharge_slots: number
export_slots: number
}
export type CurrentPlanResponse = {
run: PlanningRunDto | null
intervals: PlanningIntervalDto[]
summary: PlanningSummaryDto | null
}
export type RunPlanResponse = {
run_id: number
solver_duration_ms: number
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,10 @@
import type { Config } from 'tailwindcss'
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
} satisfies Config

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

24
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: 'dist',
assetsDir: 'assets',
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/rest': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rest/, ''),
},
},
},
})