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

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?