second version

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

View File

@@ -25,11 +25,12 @@
│ FastAPI (Python) │
Scheduled tasks (APScheduler) │
telemetry_collector (každých 60s) │
price_importer (denně 14:00)
forecast_service (denně 14:30)
price_importer (13:30, 14:00, 00:05)
forecast_service (každé 2h, minute 05)
planning_engine (denně 15:00) │
control_exporter (každých 15min) │
audit_filler (každých 15min) │
verify_modbus (každé 2 min) │
└──────┬──────────────────────────┬────────────┘
│ Modbus TCP │ HTTP
┌──────▼──────┐ ┌───────▼────────────┐
@@ -158,6 +159,14 @@ Zařízení → Waveshare → Modbus TCP → telemetry_collector → PostgreSQL
PostgreSQL (ceny + forecast) → fn_create_planning_run() → planning_interval
```
### Operátorské manuální akce (UI)
```
Browser → FastAPI:
POST /api/v1/sites/{site_id}/prices/import?date=YYYY-MM-DD
POST /api/v1/sites/{site_id}/forecast/run
POST /api/v1/sites/{site_id}/plan/run?type=daily|rolling
```
### Export setpointů (každých 15min)
```
PostgreSQL (planning_interval + overrides) → control_exporter

View File

@@ -120,7 +120,7 @@ CREATE TABLE asset_battery (
```
### `asset_pv_array`
Každé FVE pole zvlášť důležité pro predikci (azimut, sklon).
Každé FVE pole zvlášť důležité pro predikci (azimut, sklon). **Zelený bonus** (dotace za vyrobenou elektřinu) se váže na **úroveň pole**, ne na celou lokalitu: které pole má bonus, jaká je sazba Kč/kWh a platnost, určují sloupce `green_bonus_*`. Výpočet příjmu za interval probíhá funkcí `ems.fn_green_bonus_revenue` z výroby v Wh; není součástí efektivní prodejní ceny ze sítě.
```sql
CREATE TABLE asset_pv_array (
@@ -135,6 +135,11 @@ CREATE TABLE asset_pv_array (
module_count INT,
shading_factor NUMERIC(4,3) DEFAULT 1.0,
controllable BOOLEAN DEFAULT false, -- ongridový = false
-- zelený bonus (NULL = pole bez bonusu)
green_bonus_czk_kwh NUMERIC(10,6), -- sazba Kč/kWh za vyrobenou kWh
green_bonus_valid_from DATE, -- platnost od (včetně)
green_bonus_valid_to DATE, -- platnost do (exclusive), NULL = dosud
green_bonus_meter_code TEXT, -- EAN / číslo zeleného elektroměru (audit)
notes TEXT
);
```
@@ -382,6 +387,8 @@ CREATE TABLE audit_interval (
-- odchylky
deviation_grid_w INT, -- actual - planned
actual_cost_czk NUMERIC(10,4),
pv_b_production_wh NUMERIC(10,3), -- výroba bonusových polí (Wh / interval), podklad pro bonus
green_bonus_czk NUMERIC(10,4), -- příjem zeleného bonusu (fn_green_bonus_revenue), mimo actual_cost_czk
PRIMARY KEY (site_id, interval_start)
);
-- SELECT create_hypertable('audit_interval', 'interval_start');

View File

@@ -26,21 +26,25 @@ Střídač Deye poskytuje přes Modbus registr `load_power_w` = celková okamži
load_power_w (Deye) = bazální_spotřeba + EV_nabíjení + TUV + ostatní flexibilní
```
### Odvození bazální spotřeby
### Výpočet bazální spotřeby
Bazální výkon je to, co zůstane po odečtení řízených zátěží od celkové spotřeby ze střídače:
```
bazální_w = load_power_w - sum(flexibilní zařízení aktuální výkon)
bazální_w = load_power_w - ev_power_w - heat_pump_power_w
```
V praxi:
```
bazální_w = load_power_w
- ev_charger_1_power_w
- ev_charger_2_power_w
- tuv_power_w (pokud je měřitelná zvlášť)
```
- **`load_power_w`** telemetrie střídače (`telemetry_inverter`), 1min.
- **`ev_power_w`** v agregaci statistik se bere průměr výkonu ze všech nabíječek site v časovém okně ±30 s kolem měření střídače (`telemetry_ev_charger`).
- **`heat_pump_power_w`** stejně z `telemetry_heat_pump` (TČ včetně kompresoru; slouží jako proxy za měřitelný příkon TČ).
> **Předpoklad:** TUV výkon není přímo měřen, pouze víme že je ON/OFF (přes Loxone). Pokud je ON, odečítáme `asset_flexible_device.max_power_w`. Toto je zjednodušení lze zpřesnit později podružným měřením.
**Ukládání profilu:** tabulka `consumption_baseline_stats` (unikátní `(site_id, day_of_week, hour_of_day)`). Plní ji **`ems.fn_update_baseline_stats(site_id, lookback_days)`** z minutové telemetrie za posledních *N* dní; agregace po DOW a hodině (Europe/Prague). Do řádku se zapisuje jen bucket s alespoň 4 vzorky (minuty). **EMA 70/30** při `ON CONFLICT`: nový batch má váhu 30 %, existující průměr 70 % (postupné zpřesňování). Denní job v backendu: **00:30** (`fn_update_baseline_stats(..., 30)`).
**Predikce do horizontu:** **`ems.fn_get_baseline_forecast(site_id, from, to)`** generuje 15min sloty (`generate_series`), pro každý slot najde řádek podle DOW+hodiny v Praze. **`forecast_w`** = uložený průměr; **`confidence_w`** = konzervativní odhad `avg + 0.5 * COALESCE(stddev, 100)`. Pokud pro slot neexistuje statistika, fallback **`forecast_w = 500` W** (málo nebo žádná historie; prakticky odpovídá situaci před ~4 týdny kvalitních dat v jednotlivých hodinách). Směrodatná odchylka je v DB k dispozici pro budoucí použití v solveru (fáze 2).
**Solver (`planning_engine._load_slots`):** pro každý 15min interval efektivní ceny bere **`avg_power_w` z `consumption_baseline_stats`** podle DOW+hodiny slotu, jinak **500 W** nečte `consumption_baseline_interval`.
> **Poznámka:** TUV jako samostatný odečet zůstává otevřený bod, pokud není měřen zvlášť; aktuálně je TČ zahrnut v `heat_pump_power_w`.
---
@@ -52,43 +56,16 @@ Celková spotřeba je součástí `telemetry_inverter.load_power_w` (1min zázna
EV nabíječky mají vlastní tabulku `telemetry_ev_charger` s přesným výkonem.
### Agregovaná spotřeba pro plánování
Tabulka `consumption_baseline_interval` ukládá 15min průměry bazální spotřeby:
- `data_type = 'actual'` historická skutečnost (zpětně dopočítáno z telemetrie)
- `data_type = 'forecast'` predikce pro plánování
- **`consumption_baseline_stats`** primární vstup solveru: hodinový profil (DOW + hodina) z telemetrie, EMA, viz výše.
- **`consumption_baseline_interval`** volitelné 15min řady (`actual` / `forecast`) pro jiné účely; solver z ní bazál nečte.
---
## Predikce bazální spotřeby
### Metoda: historický průměr + denní profil
### Metoda: DOW + hodina + EMA
Jednoduchý model pro začátek:
```python
def forecast_baseline_consumption(site_id: int, target_date: date):
"""
Predikce bazální spotřeby na základě průměru posledních N podobných dní.
Podobnost: stejný den v týdnu, přibližně stejná roční doba.
"""
lookback_weeks = 4
day_of_week = target_date.weekday()
# Stáhnout historické bazální hodnoty pro stejné dny v týdnu
historical = db.query("""
SELECT interval_start, power_w
FROM consumption_baseline_interval
WHERE site_id = %s
AND data_type = 'actual'
AND EXTRACT(dow FROM interval_start) = %s
AND interval_start >= %s
ORDER BY interval_start
""", site_id, day_of_week, target_date - timedelta(weeks=lookback_weeks))
# Průměr per 15min slot
profile = aggregate_by_time_of_day(historical) # 96 hodnot (15min sloty)
return profile
```
Operativní predikce je v **`fn_get_baseline_forecast`** a v přímém dotazu v `_load_slots` na **`consumption_baseline_stats`**. Doplňkově lze z historie stavět 15min profily v `consumption_baseline_interval`, pokud je k tomu samostatný job není nutné pro běh LP.
---

View File

@@ -5,7 +5,7 @@
| Vozidlo | Nabíječka | Max výkon | Řízení | API |
|---|---|---|---|---|
| Tesla | ev-charger-1 (Teltonika 22kW) | 22 kW | WB proud limit + Tesla API | Zatím nerozhodnuto (Tessie nebo přímé) |
| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max ~7-11kW) | WB proud limit (Zoe respektuje) | Žádné Zoe jako fixní zátěž při připojení |
| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max 22kW) | WB proud limit (Zoe respektuje) | Žádné Zoe jako fixní zátěž při připojení |
---
@@ -82,7 +82,7 @@ CREATE TABLE ems.asset_vehicle (
name TEXT,
make TEXT, -- 'Tesla', 'Renault'
model TEXT, -- 'Model Y', 'Zoe'
battery_capacity_kwh NUMERIC(6,2), -- Tesla ~75, Zoe ~52
battery_capacity_kwh NUMERIC(6,2), -- Tesla ~58, Zoe ~22
max_charge_power_w INT, -- max přijímaný výkon vozidla
default_charger_id INT REFERENCES ems.asset_ev_charger(id),
api_type TEXT, -- 'tesla', 'none'
@@ -241,6 +241,32 @@ SoC Zoe neznáme přesně použijeme energii dodanou v session (kumulativní
---
## Statistika příjezdů
### Tabulka `ems.ev_arrival_stats`
Agregace podle `site_id`, `charger_id`, `day_of_week` (0 = neděle … 6 = sobota) a `arrival_hour` (023). Čas příjezdu se počítá v **Europe/Prague**. Unikátní klíč `(site_id, charger_id, day_of_week, arrival_hour)`; sloupec `sample_count` roste s každým zaznamenaným příjezdem.
Účel: po několika týdnech dat odhadnout typickou hodinu připojení vozidla na danou wallbox — pro notifikace („obvykle přijíždíš kolem 1718h“) a později jako měkký vstup do plánovače.
### `ems.fn_update_ev_arrival_stats(site_id, charger_id, vehicle_id, arrived_at)`
Inkrementuje statistiku pro příslušný bucket (INSERT nebo `ON CONFLICT` +1). Volá se při **detekci nového příjezdu** v `telemetry_collector`: přechod telemetrie z `available` na stav připojení (`preparing`, `charging`, …).
### `ems.fn_ev_expected_arrival(site_id, charger_id, for_date)`
Vrátí až **3 řádky**: nejčastější hodiny příjezdu pro den v týdnu odpovídající kalendářnímu datu `for_date` (typicky „zítřek“ v časové zóně lokality z backendu). Filtr `sample_count >= 2`; `confidence_pct` = podíl dané hodiny na součtu vzorků pro stejný `day_of_week` u té nabíječky.
### API
`GET /api/v1/sites/{site_id}/ev/arrival-prediction` vrátí pro každou nabíječku (klíč = `asset_ev_charger.code`) pole `tomorrow` s `{ hour, confidence_pct, samples }`. Pokud je na site méně než **5** záznamů v `ev_session` celkem, odpověď má `insufficient_data: true` (predikce se může vracet prázdné nebo řídké).
### Provozní poznámka
Historie v `ev_arrival_stats` se **nemaže** — jde o dlouhodobou agregaci. Po **4+ týdnech** reálných příjezdů má smysl UI notifikace a experimentální zapojení do solveru (soft constraint).
---
## Seed data vozidla home-01
```sql
@@ -252,7 +278,7 @@ INSERT INTO ems.asset_vehicle
default_target_soc_pct, default_deadline_hour)
SELECT
s.id, 'tesla-my', 'Tesla Model Y', 'Tesla', 'Model Y',
75.0, 11000, -- Tesla Model Y AC max ~11kW
58.0, 11000, -- Tesla Model Y AC max ~11kW
ch.id, 'none', -- Tesla API fáze 2
80, 7
FROM ems.site s
@@ -265,7 +291,7 @@ INSERT INTO ems.asset_vehicle
default_target_soc_pct, default_deadline_hour)
SELECT
s.id, 'zoe-r135', 'Renault Zoe R135', 'Renault', 'Zoe R135',
52.0, 7400, -- Zoe max 7.4kW AC
22.0, 22000, -- Zoe max 22kW AC
ch.id, 'none',
90, 7 -- Zoe: vyšší target SoC (menší baterie, kritičtější)
FROM ems.site s

View File

@@ -89,10 +89,15 @@ def calculate_pv_power(
| Trigger | Čas | Popis |
|---|---|---|
| Scheduled (cron) | každý den 14:30 CET | Po importu cen, před plánováním |
| Scheduled (cron) | každý den 06:00 CET | Aktualizace predikce na dnešní den |
| Před plánováním | automaticky | Plánovač zkontroluje čerstvost, spustí pokud starší než 2h |
| Manual trigger | na vyžádání | `POST /admin/run-forecast?site_id=1&date=YYYY-MM-DD` |
| Scheduled (cron) | každé 2 hodiny v `:05` | Průběžný refresh forecastu pro všechny aktivní site |
| Manual trigger | na vyžádání | `POST /api/v1/sites/{site_id}/forecast/run` |
### Implementované provozní změny (2026-03)
- Forecast horizont je konfigurovatelný přes `open_meteo_forecast_days`.
- Runtime guard: hodnota se clampuje do rozmezí `2..16`.
- Default je `7` dní.
- Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů.
---
@@ -153,11 +158,21 @@ Viz `03-data-model.md`:
---
## Tracking přesnosti forecastu
- **`ems.forecast_accuracy`** pro každý úspěšný `forecast_pv_run` a každý 15min slot ukládá predikovaný výkon, čas vzniku predikce, lead time (hodiny před začátkem slotu), později doplněnou skutečnost z telemetrie a odchylku (`error_w`, `error_pct`). Záznamy se **uchovávají trvale** (včetně všech historických běhů v `forecast_pv_run` / `forecast_pv_interval` ty se nemazají).
- **`ems.fn_fill_forecast_accuracy(site_id, lookback_hours)`** inkrementálně vloží nebo aktualizuje řádky z `forecast_pv_interval` + run metadata a dopočte `actual_power_w` jako průměr 1min telemetrie ve slotu (pole B: `gen_port_power_w`, pole A: `pv1_power_w` + `pv2_power_w`). Volat **každých 15 minut** (např. spolu s audit fillerem); parametr `lookback_hours` omezuje okno zpětného zpracování (např. 48 h běžně, větší hodnota pro jednorázový backfill).
- **`ems.vw_forecast_accuracy_by_lead_time`** agregace přesnosti podle bucketů lead time (06 h, …, 48 h+); noční sloty s nízkou výrobou (`actual_power_w` ≤ 100 W) se v metrikách typicky vynechávají.
- **`ems.vw_forecast_accuracy_daily`** denní součty forecast vs actual v kWh (Praha kalendářní den) a relativní odchylka dne.
- **Po 4+ týdnech dat** lze statistiky použít pro kalibraci `safety_factor` (nebo obdobných parametrů) v solveru viz plánovací modul.
---
## Konfigurace (env proměnné)
```env
OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast
FORECAST_HORIZON_DAYS=3
OPEN_METEO_FORECAST_DAYS=7
FORECAST_MAX_AGE_HOURS=2 # plánovač odmítne starší predikci
FORECAST_RETRY_COUNT=3
```

View File

@@ -5,7 +5,7 @@
- Stahuje spotové ceny elektřiny z OTE CZ
- Ukládá raw data bez vazby na lokalitu (sdílená tabulka)
- Efektivní ceny (s marží) se dopočítávají per site přes view
- Granularita: **15 minut** nativně (OTE CZ publikuje po hodinách → konvertujeme na 15min replikací)
- Granularita: **15 minut** nativně (veřejný JSON `@@chart-data` s `time_resolution=PT15M`)
---
@@ -15,18 +15,20 @@
OTE CZ publikuje denní ceny zpravidla **den předem (D-1)** okolo 13:0014:00 středoevropského času.
### Formát dat OTE CZ
### Formát dat OTE CZ (implementace)
OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky:
1. Stáhnout XML/JSON feed nebo scrape HTML tabulky
2. Převést EUR/MWh → CZK/kWh (kurz ČNB nebo fixní koeficient dle konfigurace)
3. Rozložit hodinový interval na 4× 15min sloty (stejná hodnota)
4. Uložit do `market_interval_price`
**Primární zdroj:** JSON grafu denního trhu (96 bodů na den):
### Alternativní API
`https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data?report_date=YYYY-MM-DD&time_resolution=PT15M`
- **OTE XML feed:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?date=YYYY-MM-DD&market=DAM&type=FIN`
- Autentikace: nepotřebná pro veřejná data
- Body: `data.dataLine[0].point[]` s `x` = 1…96 (15min slot), `y` = cena.
- Jednotka ceny se bere z `axis.y.legend` (typicky **EUR/MWh**); kód podporuje i CZK/MWh a CZK/kWh.
- Přepočet do `buy_raw_price_czk_kwh`: EUR/MWh → `× EUR_CZK / 1000`; CZK/MWh → `/ 1000`; CZK/kWh beze změny.
- Časové razítko slotu: půlnoc v **timezone lokality** (`site.timezone`) + (x1)×15 min → UTC.
### Legacy / alternativa
- **OTE pubapi:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?...` (hodinová data v projektu už není primární)
---
@@ -36,14 +38,26 @@ OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky:
Samostatný modul (ne součást FastAPI, ale může být volán z ní jako task).
### Implementované provozní změny (2026-03)
- Robustní HTTP fetch přes `httpx`:
- oddělené timeouty (`connect/read/write/pool`),
- retry s exponential backoff pro transient chyby,
- detailní chybové kódy (`http_status:*`, `timeout_or_connect:*`, `db_import:*`).
- API endpoint `POST /api/v1/sites/{site_id}/prices/import` vrací při chybě konkrétní důvod.
- Pokud je import volán bez `date`, importer nejdřív zkusí D+1 a při neúspěchu fallback na dnešní den.
### Kdy se spouští
| Trigger | Čas | Popis |
|---|---|---|
| Scheduled (cron) | každý den 14:00 CET | Stažení cen na zítřek (D+1) |
| Scheduled (cron) | každý den 00:05 CET | Kontrola ověření že dnešní data jsou v DB |
| Manual trigger | na vyžádání | API endpoint `POST /admin/import-prices?date=YYYY-MM-DD` |
| Retry | při chybě, 3× s backoffem | Automatický opakovaný pokus |
| Scheduled (cron) | každý den 13:30 CET | Předběžný pokus importu (D+1 + doplnění dneška) |
| Scheduled (cron) | každý den 14:00 CET | Hlavní import OTE (D+1 + doplnění dneška) |
| Scheduled (cron) | každý den 00:05 CET | Backfill kontrola úplnosti 96 slotů pro dnešek/zítřek |
| Manual trigger | na vyžádání | API endpoint `POST /api/v1/sites/{site_id}/prices/import?date=YYYY-MM-DD` |
| Retry | při chybě | Automatický opakovaný pokus v importéru |
Cronové časy v tabulce jsou v **Europe/Prague** (CET/CEST): `AsyncIOScheduler` v `app/main.py``timezone=ZoneInfo("Europe/Prague")`; kontejner backendu má `TZ=Europe/Prague` v `docker-compose.yml`. Bez toho by se hodiny/minuty v cronu vyhodnocovaly v **UTC** a např. „13:30 CET“ by odpovídalo jinému okamžiku na hodinách.
### Logika importu
@@ -57,15 +71,11 @@ def import_prices_for_date(date: date, source: str = "OTE_CZ"):
log.info("Data already exist, skipping")
return
# 2. Stáhnout z OTE API
raw_data = ote_client.fetch_dam_prices(date) # vrátí list hodinových cen v EUR/MWh
# 2. Stáhnout chart-data (96× 15 min)
raw_points = ote_client.fetch_chart_data_15m(date)
# 3. Konvertovat EUR/MWh → CZK/kWh
eur_czk_rate = get_exchange_rate() # z konfigurace nebo ČNB API
czk_per_kwh = [(price_eur_mwh * eur_czk_rate) / 1000 for price in raw_data]
# 4. Rozložit na 15min intervaly (1 hodina = 4 sloty se stejnou cenou)
intervals = expand_hourly_to_15min(czk_per_kwh, date)
# 3. Detekovat jednotku (EUR/MWh, CZK/MWh, …) a převést na CZK/kWh
intervals = convert_points_to_czk_kwh(raw_points, date, site_tz)
# 5. Upsert do DB (idempotentní)
db.upsert_many("market_interval_price", intervals, conflict_keys=["market_source", "interval_start"])
@@ -98,12 +108,25 @@ Marže se konfigurují v `site_market_config`:
| `sell_margin_fixed_czk` | Kč/kWh | -0.02 (srážka) |
| `sell_margin_percent` | % | 0 |
**Zelený bonus** není součástí `fn_effective_sell_price` ani view efektivní prodejní ceny jde o samostatný příjem z výroby, viz níže.
---
## Zelený bonus
- Konfigurace je na **`ems.asset_pv_array`** (konkrétní FVE pole), ne na `site` ani na střídači. Sloupce: `green_bonus_czk_kwh`, `green_bonus_valid_from`, `green_bonus_valid_to`, `green_bonus_meter_code`.
- Sazba se typicky mění **ročně**; verzování je přes `valid_from` / `valid_to` (při změně uzavři staré období a zadej novou sazbu s novým `valid_from`).
- **Výpočet příjmu** za interval: `ems.fn_green_bonus_revenue(pv_array_id, interval_start, production_wh)` kde `production_wh` je skutečná nebo odhadnutá výroba pole v daném 15min slotu (Wh). Bonus platí z **celkové výroby** pole (interní spotřeba, baterie, EV, TČ i export) nejde o prodejní cenu.
- **Nezahrnovat do** `fn_effective_sell_price` spot + prodejní marže jsou odděleně od dotace; v auditu se bonus ukládá do `audit_interval.green_bonus_czk` (plní `fn_fill_audit_interval`).
Historicky mohou v DB zůstat sloupce `green_bonus_*` na `site_market_config`; efektivní prodejní cena je z nich se nepočítá.
---
## Konfigurace (env proměnné)
```env
OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam
OTE_API_URL=https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data
OTE_IMPORT_HOUR=14 # hodina kdy se spouští denní import
EUR_CZK_RATE=25.0 # fallback kurz pokud ČNB API nedostupné
CNB_API_URL=https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml

View File

@@ -0,0 +1,59 @@
# Modbus command journal
## Účel
Každý zápis na Modbus TCP (Deye a později další aktiva) se ukládá do tabulky `ems.modbus_command` jako samostatný řádek: cílový registr, hodnota, endpoint, vazba na `site_id` a volitelně na `planning_run_id`. Po zápisu má řádek stav `written`; samostatný **verifikační job** (každé 2 minuty) nebo ruční **GET** `/api/v1/sites/{site_id}/control/verify` přečte registr zpět a nastaví `value_verified` a stav `verified` nebo `mismatch`.
## Schéma `ems.modbus_command`
| Sloupec | Význam |
|---------|--------|
| `asset_type` / `asset_id` / `asset_code` | Typ aktiva (`inverter`, …), FK logicky na příslušnou tabulku, čitelný kód |
| `device_*` | Host, port, Modbus unit ID |
| `register` | Číslo registru (decimal); v logu též hex |
| `register_name` | Např. `charge_limit`, `export_limit` |
| `value_to_write` / `value_written` / `value_verified` | Požadavek, potvrzený zápis, ověření čtením |
| `status` | `pending`, `written`, `verified`, `failed`, `mismatch`, `retrying` |
| `planning_run_id` | Volitelná vazba na aktivní plán |
| `deye_physical_mode` | U zápisů z `write_inverter_setpoints`: **PASSIVE** / **SELL** / **CHARGE** (stejná hodnota na všech řádcích daného běhu exportu); jinak NULL |
| `attempt_count` | Počet pokusů o zápis (pro limity retry) |
Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending` / `retrying`.
## Verifikace a bezpečnost
1. Po `mismatch` se odešle **Discord** alert (`notification_service.send_discord` / `notify_modbus_mismatch`), pokud je nastaven `DISCORD_WEBHOOK_URL`.
2. **Retry** zápisu max. **3×** (počítáno přes `attempt_count` po zápisech).
3. Po třech neúspěšných cyklech: přepnutí lokality na **SELF_SUSTAIN** přes `ems.fn_set_mode` (`activated_by` = `system:mismatch`, poznámka = důvod) a **kritický** Discord alert (`notify_self_sustain_activated`).
Implementace: `services/control_exporter.py``verify_modbus_commands`, `_switch_to_self_sustain`.
## Střídač (Deye)
`write_inverter_setpoints` přidá do journalu mimo **6264** (čas) a **time pointy 148177** také řádky pro **108** (max charge A), **109** (max discharge A), **141** (energy mode, vždy 0), **142** (limit control), **178** (pevné hodnoty 32 / 48 podle fyzického režimu, bez read-modify-write), **143** (export limit W). Každý řádek daného exportního běhu má vyplněný **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**) pro audit přepínání. **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu baterie na proud: `battery_watts_to_amps` viz `modbus-registers.md`. Všechny zápisy journalu jdou přes **`write_registers`** (FC **0x10**), ne FC 0x06. Detail režimů a registrů: `docs/04-modules/modbus-registers.md`.
## APScheduler
| Job | Frekvence | Popis |
|-----|-----------|--------|
| `verify_modbus` | každé **2 min** | Pro každou aktivní site vybere `written` příkazy s `written_at` v posledních **10 min** a zavolá `verify_modbus_commands`. |
## Ruční API
`GET /api/v1/sites/{site_id}/control/verify?minutes=10`
Vrátí počty `checked` / `verified` / `mismatch` a seznam dotčených příkazů s aktuálním stavem po verifikaci.
## `ems.cutoff_switch_log`
Tabulka pro budoucí logování **cut-off** přepínačů (mikroinvertory / GEN při záporné prodejní ceně). Záznam při **změně** stavu: `asset_code`, `new_state`, `previous_state`, `reason`, `sell_price_czk`, `triggered_by`. Zatím jen schéma; logika napojení v `control_exporter` je v TODO.
## Konfigurace
- `.env`: `DISCORD_WEBHOOK_URL` — prázdné = notifikace vypnuté (jen log).
## Související soubory
- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`
- Backend: `backend/services/control_exporter.py`, `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py`
- Registry Deye: `docs/04-modules/modbus-registers.md`

View File

@@ -0,0 +1,184 @@
# Deye Modbus Registry EMS řízení
## Důležité pravidlo
- Registry **60499**: POUZE **FC 0x10** (`write_registers`)
- Registry **059**: FC 0x03 čtení, FC 0x06 zápis
- Registry **500+**: FC 0x03 pouze čtení
EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_registers`** (FC 0x10), nikdy `write_register` (FC 0x06) pro rozsah 60499.
## Řídící registry (R/W, FC 0x10)
| Reg | Název | Rozsah | Jednotka | Použití v EMS |
|-----|-------|--------|----------|---------------|
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit nabíjení baterie; horní mez není napříč modely stejná (nižší výkonové řady mívají jiný strop než např. SUN-20K) |
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit vybíjení baterie; viz výše |
| 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě |
| 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě |
| 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) |
| 142 | Limit control | 0/1/2 | — | **0** = selling first, **1** = zero export (built-in CT); EMS přepíná export vs. idle/nabíjení |
| 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` |
| 178 | Grid peak shaving switch | bitmask | — | EMS zapisuje **pevnou** hodnotu (bez read-modify-write kvůli kolizím s paralelním čtením z Loxone): **32** (`0b00100000`, bit45 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit45 = **11**) v **PASSIVE** a **CHARGE**. |
| 190 | GEN peak shaving | 016000 | 1 W | Peak shaving na GEN portu |
| 191 | Grid peak shaving power | 016000 | 1 W | **EMS NEZAPISUJE** nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. |
`control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal + verifikace) po řadě: **6264** (čas), **time points 148177**, **108, 109, 141, 142, 178, 143**. Popisné názvy registrů v DB bere `DEYE_REGISTER_NAMES` v `control_exporter.py`. **Reg 191** do journalu nepatří EMS ho nezapisuje.
### Reg 191 (výkon grid peak shaving)
- **EMS NEZAPISUJE** nastavit **manuálně v SolarmanApp**.
- Hodnota určuje výkon peak shavingu v **W** (typicky 016 000).
### Reg 178 hodnoty podle fyzického režimu
- **SELL:** **32** bit45 = **10**, grid peak shaving **disable** (export do sítě).
- **PASSIVE** a **CHARGE:** **48** bit45 = **11**, grid peak shaving **enable**.
EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může způsobit nesoulad).
## Klíčové registry podle fyzického režimu Deye
Provozní režimy EMS (AUTO, SELF_SUSTAIN, SELL, …) se mapují na **tři fyzické režimy** střídače: **PASSIVE**, **SELL**, **CHARGE**. Ostatní je politika solveru / EMS, ne samostatný „režim“ invertoru.
| Reg | PASSIVE | SELL | CHARGE |
|-----|---------|------|--------|
| 142 | 1 (zero export to load) | 0 (selling first) | 1 |
| 108 | `max_charge_a` z DB | `max_charge_a` z DB | `battery_watts_to_amps(battery_w, max_charge_a)` |
| 109 | `max_discharge_a` z DB | `max_discharge_a` z DB | 0 |
| 178 | 48 | 32 | 48 |
| 143 | max export W z DB | max export W z DB | max export W z DB |
| 141 | 0 | 0 | 0 |
**Důležité:** V **PASSIVE** i **SELL** jsou registry **108** a **109** vždy na **plném limitu z DB**. Deye si tok energie reguluje sám; snížení 108/109 pod maximum brání reakci na nepředvídatelnou spotřebu nebo přebytky FVE.
### Detekce fyzického režimu (`get_deye_mode` v `control_exporter.py`)
Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivní plán / politika EMS), ne z telemetrie.
| Režim | Podmínka |
|-------|----------|
| **SELL** | `grid_setpoint_w` < 200 |
| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 |
| **PASSIVE** | vše ostatní (včetně SELF_SUSTAIN, IDLE, …) |
Režim **CHARGE_CHEAP** v EMS nastaví `grid_setpoint_w` tak, aby platila podmínka importu (> 200 W), jinak by fyzicky zůstal PASSIVE.
Všechny limity (`max_charge_a`, `max_discharge_a`, `max_export_power_w` / reg 143) pocházejí **výhradně z DB** (`_load_inverter_config`).
## Time Points řízení podle fyzického režimu
Deye má 6 časových bloků. EMS přepisuje **bloky 12** při každém `control_export` (cron např. :14, :29, :44, :59).
**Výběr aktivního segmentu na invertoru:** platí poslední časový bod, jehož **HH:MM ≤ aktuálnímu času** na hodinách střídače (po synchronizaci 6264). Proto **nesmí** zůstat jako jediný „minulý“ bod např. **00:00** s pasivním profilem, zatímco profil s nabíjením ze sítě je až u budoucího času mezi půlnocí a tím budoucím časem by invertor celou dobu používal špatný segment.
| Blok | Čas (HHMM, Europe/Prague) | Zdroj plánu | Účel | SOC min | Grid charge |
|------|---------------------------|-------------|------|---------|-------------|
| 1 | **`current_slot_hhmm()`** začátek **probíhajícího** 15min slotu | `planning_interval` pro **aktuální** slot (`_fetch_plan_row_for_slot_offset(..., 0)`) | PASSIVE / SELL / CHARGE dle `_deye_tou_params` | viz tabulka níže | viz tabulka níže |
| 2 | **`next_slot_hhmm()`** začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže |
| 36 | 23:59 | — | Neaktivní (rezerva) | `reserve_soc` (DB) | NE |
**Registry 108 / 109 / 142 / 178 / 143** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 12 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`).
Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** mezi 14:15 a 14:29 je aktivní segment z bloku 1 (sladěný s plánem pro 14:1514:30), po 14:30 blok 2 (plán 14:3014:45). Po dalším exportu se oba časy posunou (např. 14:30 / 14:45).
### Fyzické režimy Deye parametry jednoho time pointu (bloky 12)
| Režim | Výkon (W) | SOC min | Grid charge |
|-------|-----------|---------|-------------|
| **PASSIVE** | `max_discharge_a × 51,2` | rezerva z DB | NE |
| **SELL** | `max_discharge_a × 51,2` | rezerva z DB | NE |
| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO |
Bloky 36 zůstávají na **23:59** s pasivním profilem (`reserve_soc`, grid charge = NE).
### Synchronizace času
Registry **6264** se při každém `control_export` nastaví na aktuální čas v **Europe/Prague**:
- reg **62:** `(rok - 2000) << 8 | měsíc`
- reg **63:** `den << 8 | hodina`
- reg **64:** `minuta << 8 | sekunda`
Zápis time pointů i systémového času prochází stejným **`modbus_command`** journal jako registry 108 / 109 / 141 / 142 / 178 / 143 (FC 0x10 po jednom registru).
### Mapování registrů (time point *i*, i = 0…5)
| Účel | Adresa |
|------|--------|
| Čas HHMM | 148 + *i* |
| Výkon (W) | 154 + *i* |
| Min. SOC % | 166 + *i* |
| Grid charge enable 0/1 | 172 + *i* |
Limity nabíjení/vybíjení v ampérech a export z **site_grid_connection** / **asset_inverter** / **asset_battery** načítá `_load_inverter_config()` (`max_charge_a` / `max_discharge_a` jako `LEAST(BMS, střídač) / 51,2`). Python **neřeže** na univerzální číslo hodnoty v DB mají odpovídat **skutečnému modelu** střídače a BMS (maximální povolená hodnota v registru se liší podle typu; není to všude např. 185 A). Ověřit v dokumentaci k danému SUN-*K.
## Telemetrické registry (R only, FC 0x03)
| Reg | Název | Jednotka | Poznámka |
|-----|-------|----------|----------|
| 500 | Run state | — | 0 = standby, 2 = normal |
| 588 | Battery SOC | 1 % | |
| 590 | Battery power | 1 W S16 | + vybíjení / nabíjení |
| 625 | Grid total power | 1 W S16 | + import / export |
| 653 | Load total power | 1 W S16 | |
| 667 | GEN port power | 1 W | FVE pole B |
| 672 | PV1 power | 1 W | |
| 673 | PV2 power | 1 W | |
## Přepočty
- Výkon baterie → proud (LV 51,2 V): `battery_watts_to_amps(power_w, max_amps) = min(max(0, max_amps), max(0, round(|power_w| / 51.2)))`, kde `max_amps` je z DB
- `max_export_power_w` / `max_import_power_w` / limity baterie berou se z DB (`_load_inverter_config`), ne z natvrdo v Pythonu
- Export do registru **143** = `site_grid_connection.max_export_power_w` (např. home-01 / SUN-20K **13 500 W**)
## Ověření (Modbus + DB)
```bash
docker compose up -d --build backend
```
```python
import asyncio
from pymodbus.client import AsyncModbusTcpClient
async def check():
c = AsyncModbusTcpClient('172.16.1.10', port=502, timeout=5)
await c.connect()
times = await c.read_holding_registers(148, count=2)
for i in range(2):
h, m = divmod(times.registers[i], 100)
print(f'Time point {i+1}: {h:02d}:{m:02d}')
for name, reg in [
('Limit control', 142),
('Peak sw (bit4-5)', 178),
('Export limit', 143),
('Discharge A', 109),
('Grid power', 625),
]:
r = await c.read_holding_registers(reg, count=1)
raw = r.registers[0]
signed = raw - 65536 if raw > 32767 else raw
print(f'{name} ({reg}): {signed}')
c.close()
asyncio.run(check())
```
```bash
docker compose exec db psql -U ems_user -d ems -c "
SELECT register_name, value_to_write, status,
created_at AT TIME ZONE 'Europe/Prague' AS cas
FROM ems.modbus_command
WHERE site_id=2 AND register IN (108, 109, 142)
ORDER BY created_at DESC LIMIT 9;"
```
## Související
- `docs/04-modules/modbus-command-journal.md` journal a verifikace
- `backend/services/control_exporter.py` zápisy
- `backend/services/modbus_client.py` `write_registers` (FC 0x10)

View File

@@ -1,132 +1,65 @@
# Modul: Operating Modes (Provozní režimy)
# Provozní režimy EMS
## Koncept
## Přehled
EMS a Loxone komunikují přes **provozní režimy** pojmenované stavy které mají smysl pro obě strany.
| Mode | Solver constraints | Deye fyzický režim | Baterie |
|------|-------------------|-------------------|---------|
| AUTO | žádné | PASSIVE/SELL/CHARGE dle plánu | dle plánu |
| SELF_SUSTAIN | no_export, min_import | vždy PASSIVE | plné limity |
| CHARGE_CHEAP | no_export, no_discharge | CHARGE | nabíjení max |
| PRESERVE | no_charge, no_discharge | PASSIVE | lock (0/0) |
| MANUAL | solver neběží | EMS nezapisuje | — |
EMS rozhoduje a přepíná režimy. Loxone dostane kód režimu jako číslo přes Virtual Input a ví jak se v daném režimu chovat **autonomně a nezávisle na EMS**.
Implementace: omezení LP v `planning_engine.solve_dispatch()` podle `mode_code` z `ems.site_operating_mode`; zápis Deye v `control_exporter.write_inverter_setpoints()` (včetně `lock_battery` u PRESERVE).
```
EMS backend (každou minutu)
→ HTTP GET /dev/sps/io/EMS_Heartbeat/1 ← pulz do Loxone
## Fyzické režimy Deye (výstup control_exporteru)
EMS backend (při přepnutí režimu)
→ ems.fn_set_mode(site_id, 'SELF_SUSTAIN') ← zapsat do DB
→ HTTP GET /dev/sps/io/EMS_Mode/2 ← informovat Loxone
Detekce z `battery_w` a `grid_setpoint_w` (`get_deye_mode`):
Loxone (zcela nezávisle na EMS)
→ sleduje přítomnost EMS_Heartbeat pulzů
→ pokud 5min žádný pulz → sám přepne na SELF_SUSTAIN
→ řídí střídač dle aktivního režimu bez čekání na setpointy
```
- **PASSIVE:** `grid_setpoint_w >= -200` → reg142=1, reg178=48, 108/109=max z DB (nebo 0/0 při `lock_battery`)
- **SELL:** `grid_setpoint_w < -200` → reg142=0, reg178=32, 108/109=max
- **CHARGE:** `grid_setpoint_w > 200` **a** `battery_w > 500` → reg142=1, reg178=48
**Klíčový princip:** Loxone watchdog nečte DB. Sleduje pouze HTTP pulzy přímo.
Pokud padne celý server (RPi, Docker, síť) Loxone to pozná sám a přepne bezpečný režim.
`battery_w = None` (SELF_SUSTAIN Deye řídí sám) ⇒ pro detekci režimu se bere jako 0 ⇒ při `grid_setpoint_w = 0` je výsledek **PASSIVE**; registry 108/109 se nastaví na **plné limity z DB** (ne na nulu).
Viz `docs/loxone-integration.md` pro kompletní popis Loxone implementace.
## EMS politiky (nejsou fyzické stavy Deye)
- **PV_SELL_ONLY:** AUTO + constraint solveru `max_discharge_from_pv`
- **BLOCK_EXPORT:** AUTO + záporná sell_price → `ge[t]=0`
- **NEGATIVE_HARVEST:** AUTO + záporná buy_price → max charge/load
- **PROTECT:** SELF_SUSTAIN s konzervativními limity
Tyto politiky jsou parametrizace AUTO/SELF_SUSTAIN, ne samostatné fyzické stavy.
---
## Přehled režimů
## Loxone a UI (shrnutí)
| Kód | Loxone int | EV | TČ | Baterie | Síť | Loxone autonomní |
|---|---|---|---|---|---|---|
| `AUTO` | 1 | dle plánu | dle plánu | dle plánu | dle plánu | **ne** čeká na setpointy |
| `SELF_SUSTAIN` | 2 | ❌ stop | ❌ stop | vybíjí do domu | bez exportu | **ano** |
| `CHARGE_CHEAP` | 3 | ❌ stop | ❌ stop | max nabíjení | import ok | **ne** EMS posílá výkon |
| `PRESERVE` | 4 | ❌ stop | ❌ stop | drží SoC | import ok | **ano** |
| `MANUAL` | 0 | ❌ stop | ❌ stop | žádné akce | žádné akce | **ano** |
### `AUTO`
Normální provoz. EMS posílá přesné setpointy W každých 15 minut.
Loxone je čistý exekutor přijme číslo a zapíše do střídače.
Pokud setpoint nepřijde (výpadek EMS) → Loxone watchdog přepne na `SELF_SUSTAIN`.
### `SELF_SUSTAIN` ← výchozí stav + fallback
Aktivuje se:
- automaticky watchdogem při výpadku EMS (5min bez pulzu)
- manuálně uživatelem z UI (dovolená, odchod z domu)
- při prvním startu systému (seed data)
Loxone sám bez EMS:
- FVE pokrývá spotřebu
- baterie vybíjí do domu (ne do sítě)
- blokuje export do sítě
- zastavuje EV nabíjení a TČ
### `CHARGE_CHEAP`
Manuální přepis. EMS posílá max charge setpoint.
Použít při levné ceně nebo přetoku FVE ze sousedství (pokud víš o levné ceně dopředu).
### `PRESERVE`
Dovolená / servis. Loxone drží baterii na aktuálním SoC, žádné optimalizace.
Autonomní Loxone nevyžaduje setpointy od EMS.
### `MANUAL`
Technické práce. Žádná logika neřídí střídač. Pouze pro servis.
---
## Přepínání z UI (React)
EMS a Loxone sdílí pojmenované provozní režimy; Loxone dostává číslo režimu přes Virtual Input a může fungovat autonomně (watchdog při výpadku EMS).
```
POST /api/sites/{site_id}/mode
{
"mode": "SELF_SUSTAIN",
"valid_until": null, // nebo "2025-03-15T06:00:00+01:00" pro dočasný přepis
"notes": "Odjezd na dovolenou"
"valid_until": null,
"notes": ""
}
```
Backend při přepnutí:
1. Zavolá `ems.fn_set_mode(site_id, mode, 'user:'+username)` → zápis do DB + log
2. Okamžitě odešle HTTP do Loxone: `/dev/sps/io/EMS_Mode/{loxone_mode_value}`
3. Pokud `CHARGE_CHEAP` nebo návrat na `AUTO` → spustí replanning
Backend: `ems.fn_set_mode` + HTTP na Loxone `/dev/sps/io/EMS_Mode/{loxone_mode_value}`. Dočasné přepisy s `valid_until` ruší `fn_expire_modes()`.
**Dočasný přepis s automatickým návratem:**
`fn_expire_modes()` běží každou minutu a přepíná zpět lokality s prosahlým `valid_until`.
**Klíčový princip:** Loxone watchdog nečte DB sleduje pulzy `EMS_Heartbeat`. Detail: `docs/loxone-integration.md`.
---
### Tabulka režimů (Loxone / zátěže)
## EMS restart / reconnect
| Kód | Loxone int | EV | TČ | Poznámka |
|-----|------------|----|----|----------|
| `AUTO` | 1 | dle plánu | dle plánu | setpointy z plánu |
| `SELF_SUSTAIN` | 2 | stop | stop | fallback / výpadek EMS |
| `CHARGE_CHEAP` | 3 | stop | stop | max nabíjení ze sítě |
| `PRESERVE` | 4 | stop | stop | baterie uzamčena (Modbus 0/0) |
| `MANUAL` | 0 | stop | stop | servis, EMS neexportuje |
Při startu backendu:
1. Přečíst z Loxone aktuální `EMS_Mode_Active` (Virtual Output) přes HTTP GET
2. Porovnat s `ems.site_operating_mode` v DB
3. Pokud Loxone přepnul na `SELF_SUSTAIN` během výpadku → logovat, informovat, spustit nový plán
4. Přepnout na `AUTO` a začít posílat setpointy + heartbeat pulzy
### Otevřené body
---
## Heartbeat v DB pouze informační
Tabulka `ems.site_heartbeat` zaznamenává kdy EMS naposledy úspěšně odeslal pulz do Loxone.
Slouží pro EMS dashboard (`vw_site_status.ems_heartbeat_status`) a případný alerting.
**Neplní funkci watchdogu** to je čistě na Loxone straně.
```python
# backend/services/control_exporter.py každou minutu
async def send_heartbeat(site_id: int, loxone_endpoint, db):
try:
await loxone_http.get(f"/dev/sps/io/EMS_Heartbeat/1")
await db.execute(
"SELECT ems.fn_update_heartbeat($1, 'ok', $2)",
site_id, EMS_VERSION
)
except Exception as e:
logger.error(f"Heartbeat failed for site {site_id}: {e}")
await db.execute(
"SELECT ems.fn_update_heartbeat($1, 'error', $2)",
site_id, EMS_VERSION
)
# EMS nemůže nic dělat Loxone watchdog to vyřeší sám
```
---
## Otevřené body
- [ ] Ověřit Deye Modbus registry pro přepnutí Self-Consumption / Grid-First modu (pro SELF_SUSTAIN)
- [ ] Implementace Loxone watchdog viz `docs/loxone-integration.md`
- [ ] Alert notifikace (email / push) pokud `ems_heartbeat_status = 'stale'` déle než 10 minut
- [ ] Doplnit alerty při `ems_heartbeat_status = 'stale'`

View File

@@ -0,0 +1,190 @@
# Rozšířený horizont plánování (96h)
## Motivace
OTE publikuje ceny max 36h dopředu. FVE forecast je dostupný na 7 dní.
Rozšířením horizontu solver vidí vzdálené příležitosti (záporné ceny, levná okna)
a může optimálně připravit baterii, TUV zásobník a EV nabíjení.
Klíčový princip: solver nepotřebuje explicitní "šetři baterii před zápornou cenou"
constraint. Pokud dostane správné (odhadované) ceny pro celých 96h, sám pozná
že je výhodnější počkat na zápornou cenu než vybíjet dnes za průměrnou.
## Datové zdroje pro predikci cen za horizont OTE
### Vrstva 1 Sezónní průměr z historických OTE dat
Tabulka `ems.market_price_stats` (analogie `consumption_baseline_stats`):
```sql
SELECT
EXTRACT(DOW FROM interval_start AT TIME ZONE 'Europe/Prague') AS dow,
EXTRACT(HOUR FROM interval_start AT TIME ZONE 'Europe/Prague') AS hour,
AVG(buy_raw_price_czk_kwh) AS avg_price,
STDDEV(buy_raw_price_czk_kwh) AS stddev_price,
PERCENTILE_CONT(0.25) WITHIN GROUP (
ORDER BY buy_raw_price_czk_kwh) AS p25,
PERCENTILE_CONT(0.75) WITHIN GROUP (
ORDER BY buy_raw_price_czk_kwh) AS p75
FROM ems.market_interval_price
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
AND interval_start >= now() - INTERVAL '6 months'
GROUP BY dow, hour
```
Plnit denně po importu OTE. Min. 3 měsíce dat pro smysluplné průměry.
### Vrstva 2 Korekce počasím (proxy)
Záporné/nízké ceny korelují s vysokou FVE výrobou v celé síti CZ.
```
predicted_irradiance > historical_avg * 1.3 → cena * 0.70 (30% sleva)
predicted_irradiance < historical_avg * 0.5 → cena * 1.20 (20% přirážka)
```
Korelaci ověřit po 3+ měsících dat. Zatím použít konzervativní korekci ±15%.
### Kombinovaná predikce s uncertainty margin
```
predicted_price[t] = seasonal_avg[dow, hour]
× weather_correction_factor[t]
× (1 ± uncertainty_margin[t])
uncertainty_margin roste s horizontem:
0-36h: 0% (přesné OTE ceny, žádná predikce)
36-72h: 20%
72-96h: 35%
```
## Uncertainty weighting v objective function
Vzdálenější sloty mají nižší váhu solver je konzervativnější:
```python
def slot_weight(t: int, now_index: int) -> float:
hours_ahead = (t - now_index) * 0.25
if hours_ahead <= 36: return 1.0 # přesné OTE ceny
if hours_ahead <= 72: return 0.7 # predikce, střední jistota
return 0.4 # predikce, nízká jistota
# V objective function:
prob += pulp.lpSum(
slot_weight(t, now_index) * (
gi[t] * slots[t].buy_price * INTERVAL_H / 1000
- ge[t] * slots[t].sell_price * INTERVAL_H / 1000
+ ...
)
for t in range(T)
)
```
## TUV predikce potřeby
### Princip
TUV zásobník drží teplo ~24h. Solver může ohřát vodu v levném okně
před očekávanou spotřebou. Potřebuje vědět:
- Aktuální teplotu zásobníku (z telemetrie)
- Kdy typicky klesá teplota (statistika per DOW+hodina)
- Minimální přijatelnou teplotu (tuv_min_temp_c)
### Tabulka `ems.tuv_usage_stats`
Analogie `consumption_baseline_stats` pro TUV zásobník:
```sql
-- Průměrný pokles teploty zásobníku per DOW+hodina
-- (záporné = zásobník se ochladil, kladné = TČ ohřívalo)
SELECT
EXTRACT(DOW FROM measured_at AT TIME ZONE 'Europe/Prague') AS dow,
EXTRACT(HOUR FROM measured_at AT TIME ZONE 'Europe/Prague') AS hour,
AVG(temp_delta_c) AS avg_temp_delta, -- průměrná změna za hodinu
STDDEV(temp_delta_c) AS stddev_temp_delta
FROM (
SELECT
measured_at,
tuv_tank_temp_c - LAG(tuv_tank_temp_c) OVER (
PARTITION BY site_id ORDER BY measured_at
) AS temp_delta_c
FROM ems.telemetry_heat_pump
WHERE site_id = $1
AND measured_at >= now() - INTERVAL '30 days'
) sub
WHERE temp_delta_c IS NOT NULL
AND ABS(temp_delta_c) < 5 -- filtruj extrémní skoky (start TČ)
GROUP BY dow, hour
```
### Použití v solveru
```python
# Pro každý slot t zjisti predikovanou teplotu zásobníku:
tuv_predicted[t] = tuv_current + SUM(avg_temp_delta[dow, hour]
for slots before t)
# Pokud tuv_predicted[t] < tuv_min_temp + safety_margin:
# → solver musí naplánovat ohřev před tímto slotem
# → heat_pump_enabled[t-N] = True (kde N = počet slotů potřebných pro ohřev)
# Potřebný čas ohřevu (orientační):
# delta_temp = tuv_target - tuv_current
# time_h = delta_temp × volume_l × 1.163 / (cop × hp_power_w / 1000)
```
## EV v rozšířeném horizontu
### Tesla (s API fáze 2)
```
Vstup: aktuální SoC z Tesla API, nastavený deadline uživatelem
Solver: deadline constraint přes celých 96h
nabij nejlevněji v rámci časového okna
```
### Zoe (bez API)
```
Vstup: ev_arrival_stats (statistika příjezdů per DOW+hodina)
energy_delivered_wh z aktuální session (odhad SoC)
Solver: soft constraint pravděpodobnost příjezdu jako váha
pokud P(příjezd v slot t) > 60%: rezervuj nabíjecí kapacitu
```
### Predikce příjezdu v solveru
```python
# Pro každý slot t kde P(příjezd) > 0.4:
arrival_prob = ev_arrival_stats[dow, hour] / total_arrivals_this_dow
# Soft constraint (ne hard auto nemusí přijet):
# Přidej "expected EV consumption" jako součást load_baseline
ev_expected_w[t] = arrival_prob * ev_charge_power_typical
```
## Implementační plán
### Fáze 3a Historické průměry cen (hotovo)
1. Tabulka `ems.market_price_stats` migrace **V022__extended_planning.sql**
2. `fn_update_market_price_stats()` `db/routines/R__fn_extended_planning.sql`, APScheduler **14:45** (`main.py`)
3. Solver: slotová páteř `generate_series` + `COALESCE(effective_*, fn_get_predicted_price(...))` v `_load_slots`
### Fáze 3b TUV statistika potřeby (hotovo)
1. Tabulka `ems.tuv_usage_stats` V022
2. `fn_update_tuv_usage_stats()` repeatable výše, job **00:45**
3. Solver: look-ahead simulace teploty + součet `hp` v okně 9 slotů (`solve_dispatch`)
### Fáze 3c Rozšíření solveru na 96h (hotovo)
1. `HORIZON_HOURS = 96`, `slot_weight()` váhy **1,0 / 0,7 / 0,4** v účelové funkci
2. Příznak `PlanningSlot.is_predicted_price` (z SQL `(ep.effective_buy IS NULL)`)
### Fáze 3d EV v rozšířeném horizontu (závisí na Tesla API)
1. Pravděpodobnostní příjezd ze statistiky
2. Deadline constraint přes celých 96h
3. Tesla API integrace
### Fáze 3e Korekce cen počasím
Po nasbírání 3+ měsíců korelačních dat rozšířit `fn_get_predicted_price` (viz vrstva 2 výše).
## Prerekvizity
- Min. 3 měsíce historických OTE dat pro smysluplné průměry
- Min. 1 měsíc telemetrie TUV pro tuv_usage_stats
- Stabilní základní provoz (Modbus zápis, telemetrie)

View File

@@ -4,6 +4,24 @@
**PuLP + HiGHS solver** lineární programování (LP) s uvolněním binárních proměnných.
### Implementované provozní změny (2026-03)
- **Strict price fail-safe:**
- pokud v prvních 36h chybí OTE data (sloty jsou predikované), solver zapíná fail-safe režim,
- v predikovaných slotech (`is_predicted_price=true`) je zakázán export do sítě,
- baterie se ale dál používá standardně pro interní spotřebu (nabíjení i vybíjení do domu je povoleno).
- **Runtime guard v exportu setpointů:**
- při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování.
- **Ekonomika baterie:**
- `reserve_soc_percent` naladěn na 10 %,
- `degradation_cost_czk_kwh` naladěn na 0.1500,
- penalizace cyklu je v objective symetrická (`0.5*(charge+discharge)`).
- **PV-aware nejistota:**
- objective používá `pv_scarcity_factor` (0.65..1.0), odvozený z forecastu slunce,
- při slabém slunci je plán ochotnější držet energii v baterii.
- **SoC buffer bez hard pravidel:**
- místo explicitních pravidel se používá ekonomická penalizace deficitu vůči bezpečnostnímu SoC cíli na konci 24h horizontu.
Solver optimalizuje celý horizont (typicky 36h) najednou, čímž přirozeně zvládá:
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
- kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu
@@ -394,12 +412,9 @@ PLANNING_HORIZON_HOURS=36
PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout
PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE
PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu
PV_B_GREEN_BONUS_CZK_KWH=1.20 # zelený bonus Kč/kWh (informativní, do účelové funkce přidat pokud chceš)
```
> **Zelený bonus v účelové funkci:** Pokud chceš bonus explicitně zahrnout, přidat do objective function:
> `- pv_b[t] * GREEN_BONUS_CZK_KWH * H / 1000` jako konstantní příjem (pole B vždy vyrábí).
> Protože je to konstanta, neovlivní optimalizaci ale správně zobrazí ekonomiku v auditu.
> **Zelený bonus:** Sazba a platnost jsou v `ems.asset_pv_array` (`green_bonus_*`). Bonus **není** v objective function LP solveru jako aditivní konstanta k nákladům by optimalizaci stejně neměnil. Příjem z bonusu se počítá v **`fn_fill_audit_interval`** přes `ems.fn_green_bonus_revenue()` a ukládá se do `audit_interval.green_bonus_czk`; v přehledech (např. `vw_audit_daily`) je samostatná položka příjmů vedle nákladů ze sítě. Viz `docs/04-modules/market-prices.md` → sekce Zelený bonus.
---
@@ -417,7 +432,7 @@ highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD)
## Otevřené body
- [ ] Post-processing min_run_duration pro TČ po LP výsledku zkontrolovat a upravit krátké ON/OFF sekvence
- [ ] Zelený bonus zahrnout do auditního výpočtu nákladů (ne jen do objective)
- [x] Zelený bonus v auditu (`fn_fill_audit_interval`, `green_bonus_czk`) mimo solver
- [ ] EV rozdělení výkonu mezi 2 nabíječky zatím řešeno jako agregát
- [ ] Curtailment pole A ověřit Modbus registr pro Output Power Limit na Deye SUN-20K
- [ ] Testovat solver na reálných datech ověřit čas výpočtu pro 36h horizont (144 slotů)

View File

@@ -35,21 +35,23 @@ Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI.
Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
> Registry jsou specifické pro Deye SUN-20K-SG01LP1-EU.
> Finální hodnoty ověřit z Deye Modbus protokolu / Loxone šablony.
> Mapování v kódu: `backend/services/telemetry_collector.py` (holding registry, decimal adresa = offset pro `read_holding_registers`).
| Registr (hex) | Typ | Popis | Jednotka | Přepočet |
| Dec (hex) | Typ | Popis | Jednotka | Poznámka |
|---|---|---|---|---|
| 0x0215 | Read Holding | PV celkový výkon | W | ×1 |
| 0x0103 | Read Holding | Battery SoC | % | ×1 |
| 0x0105 | Read Holding | Battery power | W | signed, kladné=nabíjení |
| 0x0101 | Read Holding | Battery voltage | 0.1V | ×0.1 |
| 0x0169 | Read Holding | Grid power | W | signed, kladné=import |
| 0x016F | Read Holding | Grid voltage L1 | 0.1V | ×0.1 |
| 0x0213 | Read Holding | Load power | W | ×1 |
| 0x0220 | Read Holding | Inverter temperature | 0.1°C | ×0.1 |
| 0x0168 | Read Holding | Operating mode | enum | viz tabulka módů |
| 0x0180 | Read Holding | Fault code | bitfield | 0=ok |
| 500 (0x01F4) | uint16 | Provozní stav střídače | enum | raw do `run_state`, ladění |
| 514 (0x0202) | uint16 | Dnešní nabití baterie | Wh | `batt_charge_today_wh` |
| 515 (0x0203) | uint16 | Dnešní vybití baterie | Wh | `batt_discharge_today_wh` |
| 588 (0x024C) | uint16 | Battery SoC | % | `battery_soc_percent` |
| 590 (0x024E) | int16 | Tok výkonu baterie | W | signed: **+ vybíjení, nabíjení** |
| 625 (0x0271) | int16 | Výkon sítě | W | signed: **+ import, export** |
| 653 (0x028D) | uint16 | Celková spotřeba | W | `load_power_w` |
| 667 (0x029B) | uint16 | Výkon GEN portu (FVE pole B) | W | `gen_port_power_w`, nelze curtailovat |
| 672 (0x02A0) | uint16 | Výkon PV1 | W | `pv1_power_w` |
| 673 (0x02A1) | uint16 | Výkon PV2 | W | `pv2_power_w` |
`pv_power_w` v DB = **PV1 + PV2 + GEN port** (celková výroba na instalaci home-01).
`gen_port_power_w` zůstává i nadále uložen samostatně pro audit a detailní diagnostiku.
**Zápis setpointů (plánování → Deye):**
@@ -60,8 +62,7 @@ Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
| 0x00F6 | Write Single | Grid export power limit | W |
| 0x00F0 | Write Single | Work mode | enum (viz tabulka) |
> **TODO:** Přesné registry doplnit z Deye SUN-20K Modbus protokolu PDF.
> Loxone šablona pro Deye je dobrý výchozí bod pro mapování registrů.
Rychlá kontrola komunikace: `scripts/test_modbus_deye.py`.
---
@@ -108,66 +109,7 @@ Komunikace: Modbus TCP přes Waveshare.
## Kód telemetrie (Python)
```python
# backend/services/telemetry_collector.py
import asyncio
from pymodbus.client import AsyncModbusTcpClient
from datetime import datetime, timezone
async def poll_inverter(site_id: int, inverter: AssetInverter, endpoint: SiteEndpoint, db):
"""Přečte všechny registry Deye a uloží záznam do telemetry_inverter."""
async with AsyncModbusTcpClient(endpoint.host, port=endpoint.port) as client:
try:
# Čtení bloku registrů (optimalizovat jako jeden read multiple)
pv_power = await read_register(client, 0x0215, endpoint.unit_id)
batt_soc = await read_register(client, 0x0103, endpoint.unit_id)
batt_power = await read_register_signed(client, 0x0105, endpoint.unit_id)
batt_voltage = await read_register(client, 0x0101, endpoint.unit_id) / 10.0
grid_power = await read_register_signed(client, 0x0169, endpoint.unit_id)
grid_voltage = await read_register(client, 0x016F, endpoint.unit_id) / 10.0
load_power = await read_register(client, 0x0213, endpoint.unit_id)
inv_temp = await read_register(client, 0x0220, endpoint.unit_id) / 10.0
op_mode = await read_register(client, 0x0168, endpoint.unit_id)
fault_code = await read_register(client, 0x0180, endpoint.unit_id)
await db.execute("""
INSERT INTO ems.telemetry_inverter
(site_id, inverter_id, measured_at,
pv_power_w, battery_soc_percent, battery_power_w, battery_voltage_v,
grid_power_w, grid_voltage_v, load_power_w,
inverter_temp_c, operating_mode, fault_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (inverter_id, measured_at) DO NOTHING
""",
site_id, inverter.id, datetime.now(timezone.utc),
pv_power, batt_soc, batt_power, batt_voltage,
grid_power, grid_voltage, load_power,
inv_temp, str(op_mode), fault_code
)
except Exception as e:
logger.warning(f"Inverter poll failed [{inverter.code}]: {e}")
raise
async def run_collector(db):
"""Hlavní smyčka každých 60s sbírá data ze všech aktivních zařízení."""
while True:
start = asyncio.get_event_loop().time()
sites = await db.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
await asyncio.gather(
poll_all_inverters(site.id, db),
poll_all_ev_chargers(site.id, db),
poll_all_heat_pumps(site.id, db),
return_exceptions=True # jeden výpadek nezastaví ostatní
)
elapsed = asyncio.get_event_loop().time() - start
await asyncio.sleep(max(0, 60 - elapsed))
```
Implementace: `backend/services/telemetry_collector.py``poll_inverter()` používá konstanty `DEYE_REG_*` a třídu `ModbusDevice`; hlavní smyčka je `run_telemetry_loop` / `run_telemetry_loop_wrapper`.
---
@@ -209,7 +151,7 @@ MODBUS_READ_TIMEOUT_SEC=3
## Otevřené body
- [ ] Doplnit přesné Modbus registry Deye z PDF protokolu
- [x] Základní mapování Deye (holding registry 500673) v `telemetry_collector.py`
- [ ] Doplnit Modbus registry Teltonika z dokumentace / Loxone šablony
- [ ] Doplnit Modbus registry Samsung z dokumentace / Loxone šablony
- [ ] Ověřit Unit ID všech zařízení při instalaci

View File

@@ -6,7 +6,31 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec
---
## Blokující nutné před prvním spuštěním
## Vyřešeno
| Popis |
|-------|
| **Zelený bonus:** přesunuto na `asset_pv_array` (`green_bonus_*`), výpočet `fn_green_bonus_revenue()`, audit_filler (`fn_fill_audit_interval`) počítá bonus z výroby pole; legacy sloupce odstraněny ze `site_market_config` (V018). |
| **Rozšířený horizont plánování 96h** (fáze 3a+3b+3c): tabulky `market_price_stats`, `tuv_usage_stats`, funkce `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price` (V022 + `R__fn_extended_planning.sql`), solver váhy 1,0 / 0,7 / 0,4, joby 14:45 / 00:45 v `main.py`. |
| **Import OTE robustní provoz:** timeouty + retry/backoff v `price_importer.py`, detailní error kódy v API, fallback D+1 → dnešek, scheduler importů 13:30 / 14:00 / 00:05. |
| **Fail-safe bez OTE dat:** při predikovaných cenách v kritickém okně je zákaz exportu; vybíjení baterie omezeno jen v predikovaných slotech; runtime guard v `control_exporter.py` brání SELL v nejistém stavu. |
| **Forecast provoz:** refresh každé 2 hodiny (`:05`), konfigurovatelný Open-Meteo horizont (`OPEN_METEO_FORECAST_DAYS`, default 7, clamp 2..16), endpoint pro UI vrací latest-run bez duplicit slotů. |
| **Telemetry výroba FVE:** `pv_power_w` je součet `pv1 + pv2 + gen_port`, takže dashboard reflektuje obě pole i GEN větev instalace home-01. |
| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. |
| **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. |
---
## Fáze 3d rozšířený horizont (zbývá)
| Popis | Kde | Kdo |
|-------|-----|-----|
| **EV v rozšířeném horizontu** (pravděpodobnost příjezdu, deadline přes 96h) závisí na Tesla API / rozšíření modelu. | `docs/04-modules/planning-extended-horizon.md` | programátor |
| **Korekce predikce cen počasím** potřeba 3+ měsíce korelačních dat. | stejný modul | programátor |
---
## Blokující nutné před prvním reálným provozem
Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smysluplný forecast nebo dokončit kritická rozhodnutí před implementací řízení.
@@ -19,7 +43,10 @@ Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smyslu
| **Rozhodnout Teltonika: OCPP 1.6 vs REST API** před implementací EV řízení a sběru. | `docs/06-open-questions.md` ř. 910; `docs/04-modules/consumption.md` ř. 184 | majitel + programátor |
| **Doplnit přesné Modbus registry** (čtení i zápis) pro Deye, Teltonika, Samsung bez mapy registrů nejde napsat funkční `telemetry_collector` / `control_exporter`. | `docs/04-modules/telemetry.md` ř. 63, 76105 (tabulky TBD), 212214; `docs/04-modules/heat-pump.md` ř. 7985, 102; `docs/04-modules/control.md` ř. 249251; pseudokód `TBD_*_REGISTER` ř. 166171, 192197; `docs/loxone-integration.md` ř. 259261 | majitel dodá PDF/šablony → programátor; část ověření s **Loxone programátor** |
| Ověřit **Modbus registr Output Power Limit** (curtailment pole A) na Deye SUN-20K. | `docs/04-modules/planning.md` ř. 422 | programátor (+ dokumentace od majitele) |
| Doplnit **skutečnou výši zeleného bonusu** (`green_bonus_czk_kwh`) dle smlouvy aktuálně placeholder. | `db/migration/V005__planning_curtailment.sql` ř. 4550 | majitel (smlouva) → programátor |
| Doplnit **skutečnou sazbu zeleného bonusu** do `asset_pv_array.green_bonus_czk_kwh` pro `pv-b` (aktuální placeholder: **7.135** Kč/kWh ověřit ze smlouvy s EG.D). | `db/migration/V017__green_bonus.sql` (seed `pv-b`) | majitel (smlouva) → programátor |
| Doplnit **`green_bonus_meter_code`** (EAN zeleného elektroměru) pro `pv-b` v `asset_pv_array`. | `db/migration/V017__green_bonus.sql` / přímá úprava DB | majitel → programátor |
| Nastavit **`DISCORD_WEBHOOK_URL`** pro produkční alerty (Modbus mismatch, přepnutí SELF_SUSTAIN). | `.env` / `backend/app/config.py` | majitel → programátor |
| **Cut-off přepínač** pro mikroinvertory (druhá instalace) napojit logiku na `ems.cutoff_switch_log` a řízení. | `docs/04-modules/modbus-command-journal.md` | programátor |
---
@@ -41,7 +68,6 @@ Potřebné pro reálný, stabilní provoz; lze část EMS otestovat bez nich (na
| **Přístup k logu** přepnutí watchdogu pro EMS po restartu. | `docs/loxone-integration.md` ř. 263 | Loxone programátor + programátor |
| Implementace **Loxone watchdog** dle integračního dokumentu. | `docs/04-modules/operating-modes.md` ř. 131; celý `docs/loxone-integration.md` | Loxone programátor + programátor |
| **Post-processing min_run/min_stop** TČ po výstupu LP (krátké ON/OFF). | `docs/04-modules/planning.md` ř. 419 | programátor |
| **Zelený bonus** započítat do **auditního** výpočtu nákladů, ne jen do optimalizace. | `docs/04-modules/planning.md` ř. 420 | programátor |
| **EV:** přesnější než agregát sladit s `ev1_setpoint_w` / `ev2_setpoint_w` v DB a solveru. | `docs/04-modules/planning.md` ř. 421 | programátor |
| **Test solveru** na reálných datech (výkon pro 36h / 144 slotů). | `docs/04-modules/planning.md` ř. 423 | programátor |
| **Optimalizace čtení Deye** jeden blok `read_holding_registers`. | `docs/04-modules/telemetry.md` ř. 216 | programátor |