second version
This commit is contained in:
@@ -26,21 +26,25 @@ Střídač Deye poskytuje přes Modbus registr `load_power_w` = celková okamži
|
||||
load_power_w (Deye) = bazální_spotřeba + EV_nabíjení + TUV + ostatní flexibilní
|
||||
```
|
||||
|
||||
### Odvození bazální spotřeby
|
||||
### Výpočet bazální spotřeby
|
||||
|
||||
Bazální výkon je to, co zůstane po odečtení řízených zátěží od celkové spotřeby ze střídače:
|
||||
|
||||
```
|
||||
bazální_w = load_power_w - sum(flexibilní zařízení aktuální výkon)
|
||||
bazální_w = load_power_w - ev_power_w - heat_pump_power_w
|
||||
```
|
||||
|
||||
V praxi:
|
||||
```
|
||||
bazální_w = load_power_w
|
||||
- ev_charger_1_power_w
|
||||
- ev_charger_2_power_w
|
||||
- tuv_power_w (pokud je měřitelná zvlášť)
|
||||
```
|
||||
- **`load_power_w`** – telemetrie střídače (`telemetry_inverter`), 1min.
|
||||
- **`ev_power_w`** – v agregaci statistik se bere průměr výkonu ze všech nabíječek site v časovém okně ±30 s kolem měření střídače (`telemetry_ev_charger`).
|
||||
- **`heat_pump_power_w`** – stejně z `telemetry_heat_pump` (TČ včetně kompresoru; slouží jako proxy za měřitelný příkon TČ).
|
||||
|
||||
> **Předpoklad:** TUV výkon není přímo měřen, pouze víme že je ON/OFF (přes Loxone). Pokud je ON, odečítáme `asset_flexible_device.max_power_w`. Toto je zjednodušení – lze zpřesnit později podružným měřením.
|
||||
**Ukládání profilu:** tabulka `consumption_baseline_stats` (unikátní `(site_id, day_of_week, hour_of_day)`). Plní ji **`ems.fn_update_baseline_stats(site_id, lookback_days)`** z minutové telemetrie za posledních *N* dní; agregace po DOW a hodině (Europe/Prague). Do řádku se zapisuje jen bucket s alespoň 4 vzorky (minuty). **EMA 70/30** při `ON CONFLICT`: nový batch má váhu 30 %, existující průměr 70 % (postupné zpřesňování). Denní job v backendu: **00:30** (`fn_update_baseline_stats(..., 30)`).
|
||||
|
||||
**Predikce do horizontu:** **`ems.fn_get_baseline_forecast(site_id, from, to)`** generuje 15min sloty (`generate_series`), pro každý slot najde řádek podle DOW+hodiny v Praze. **`forecast_w`** = uložený průměr; **`confidence_w`** = konzervativní odhad `avg + 0.5 * COALESCE(stddev, 100)`. Pokud pro slot neexistuje statistika, fallback **`forecast_w = 500` W** (málo nebo žádná historie; prakticky odpovídá situaci před ~4 týdny kvalitních dat v jednotlivých hodinách). Směrodatná odchylka je v DB k dispozici pro budoucí použití v solveru (fáze 2).
|
||||
|
||||
**Solver (`planning_engine._load_slots`):** pro každý 15min interval efektivní ceny bere **`avg_power_w` z `consumption_baseline_stats`** podle DOW+hodiny slotu, jinak **500 W** – nečte `consumption_baseline_interval`.
|
||||
|
||||
> **Poznámka:** TUV jako samostatný odečet zůstává otevřený bod, pokud není měřen zvlášť; aktuálně je TČ zahrnut v `heat_pump_power_w`.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,43 +56,16 @@ Celková spotřeba je součástí `telemetry_inverter.load_power_w` (1min zázna
|
||||
EV nabíječky mají vlastní tabulku `telemetry_ev_charger` s přesným výkonem.
|
||||
|
||||
### Agregovaná spotřeba pro plánování
|
||||
Tabulka `consumption_baseline_interval` ukládá 15min průměry bazální spotřeby:
|
||||
|
||||
- `data_type = 'actual'` – historická skutečnost (zpětně dopočítáno z telemetrie)
|
||||
- `data_type = 'forecast'` – predikce pro plánování
|
||||
- **`consumption_baseline_stats`** – primární vstup solveru: hodinový profil (DOW + hodina) z telemetrie, EMA, viz výše.
|
||||
- **`consumption_baseline_interval`** – volitelné 15min řady (`actual` / `forecast`) pro jiné účely; solver z ní bazál nečte.
|
||||
|
||||
---
|
||||
|
||||
## Predikce bazální spotřeby
|
||||
|
||||
### Metoda: historický průměr + denní profil
|
||||
### Metoda: DOW + hodina + EMA
|
||||
|
||||
Jednoduchý model pro začátek:
|
||||
|
||||
```python
|
||||
def forecast_baseline_consumption(site_id: int, target_date: date):
|
||||
"""
|
||||
Predikce bazální spotřeby na základě průměru posledních N podobných dní.
|
||||
Podobnost: stejný den v týdnu, přibližně stejná roční doba.
|
||||
"""
|
||||
lookback_weeks = 4
|
||||
day_of_week = target_date.weekday()
|
||||
|
||||
# Stáhnout historické bazální hodnoty pro stejné dny v týdnu
|
||||
historical = db.query("""
|
||||
SELECT interval_start, power_w
|
||||
FROM consumption_baseline_interval
|
||||
WHERE site_id = %s
|
||||
AND data_type = 'actual'
|
||||
AND EXTRACT(dow FROM interval_start) = %s
|
||||
AND interval_start >= %s
|
||||
ORDER BY interval_start
|
||||
""", site_id, day_of_week, target_date - timedelta(weeks=lookback_weeks))
|
||||
|
||||
# Průměr per 15min slot
|
||||
profile = aggregate_by_time_of_day(historical) # 96 hodnot (15min sloty)
|
||||
return profile
|
||||
```
|
||||
Operativní predikce je v **`fn_get_baseline_forecast`** a v přímém dotazu v `_load_slots` na **`consumption_baseline_stats`**. Doplňkově lze z historie stavět 15min profily v `consumption_baseline_interval`, pokud je k tomu samostatný job – není nutné pro běh LP.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| Vozidlo | Nabíječka | Max výkon | Řízení | API |
|
||||
|---|---|---|---|---|
|
||||
| Tesla | ev-charger-1 (Teltonika 22kW) | 22 kW | WB proud limit + Tesla API | Zatím nerozhodnuto (Tessie nebo přímé) |
|
||||
| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max ~7-11kW) | WB proud limit (Zoe respektuje) | Žádné – Zoe jako fixní zátěž při připojení |
|
||||
| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max 22kW) | WB proud limit (Zoe respektuje) | Žádné – Zoe jako fixní zátěž při připojení |
|
||||
|
||||
---
|
||||
|
||||
@@ -82,7 +82,7 @@ CREATE TABLE ems.asset_vehicle (
|
||||
name TEXT,
|
||||
make TEXT, -- 'Tesla', 'Renault'
|
||||
model TEXT, -- 'Model Y', 'Zoe'
|
||||
battery_capacity_kwh NUMERIC(6,2), -- Tesla ~75, Zoe ~52
|
||||
battery_capacity_kwh NUMERIC(6,2), -- Tesla ~58, Zoe ~22
|
||||
max_charge_power_w INT, -- max přijímaný výkon vozidla
|
||||
default_charger_id INT REFERENCES ems.asset_ev_charger(id),
|
||||
api_type TEXT, -- 'tesla', 'none'
|
||||
@@ -241,6 +241,32 @@ SoC Zoe neznáme přesně – použijeme energii dodanou v session (kumulativní
|
||||
|
||||
---
|
||||
|
||||
## Statistika příjezdů
|
||||
|
||||
### Tabulka `ems.ev_arrival_stats`
|
||||
|
||||
Agregace podle `site_id`, `charger_id`, `day_of_week` (0 = neděle … 6 = sobota) a `arrival_hour` (0–23). Čas příjezdu se počítá v **Europe/Prague**. Unikátní klíč `(site_id, charger_id, day_of_week, arrival_hour)`; sloupec `sample_count` roste s každým zaznamenaným příjezdem.
|
||||
|
||||
Účel: po několika týdnech dat odhadnout typickou hodinu připojení vozidla na danou wallbox — pro notifikace („obvykle přijíždíš kolem 17–18h“) a později jako měkký vstup do plánovače.
|
||||
|
||||
### `ems.fn_update_ev_arrival_stats(site_id, charger_id, vehicle_id, arrived_at)`
|
||||
|
||||
Inkrementuje statistiku pro příslušný bucket (INSERT nebo `ON CONFLICT` +1). Volá se při **detekci nového příjezdu** v `telemetry_collector`: přechod telemetrie z `available` na stav připojení (`preparing`, `charging`, …).
|
||||
|
||||
### `ems.fn_ev_expected_arrival(site_id, charger_id, for_date)`
|
||||
|
||||
Vrátí až **3 řádky**: nejčastější hodiny příjezdu pro den v týdnu odpovídající kalendářnímu datu `for_date` (typicky „zítřek“ v časové zóně lokality z backendu). Filtr `sample_count >= 2`; `confidence_pct` = podíl dané hodiny na součtu vzorků pro stejný `day_of_week` u té nabíječky.
|
||||
|
||||
### API
|
||||
|
||||
`GET /api/v1/sites/{site_id}/ev/arrival-prediction` vrátí pro každou nabíječku (klíč = `asset_ev_charger.code`) pole `tomorrow` s `{ hour, confidence_pct, samples }`. Pokud je na site méně než **5** záznamů v `ev_session` celkem, odpověď má `insufficient_data: true` (predikce se může vracet prázdné nebo řídké).
|
||||
|
||||
### Provozní poznámka
|
||||
|
||||
Historie v `ev_arrival_stats` se **nemaže** — jde o dlouhodobou agregaci. Po **4+ týdnech** reálných příjezdů má smysl UI notifikace a experimentální zapojení do solveru (soft constraint).
|
||||
|
||||
---
|
||||
|
||||
## Seed data – vozidla home-01
|
||||
|
||||
```sql
|
||||
@@ -252,7 +278,7 @@ INSERT INTO ems.asset_vehicle
|
||||
default_target_soc_pct, default_deadline_hour)
|
||||
SELECT
|
||||
s.id, 'tesla-my', 'Tesla Model Y', 'Tesla', 'Model Y',
|
||||
75.0, 11000, -- Tesla Model Y AC max ~11kW
|
||||
58.0, 11000, -- Tesla Model Y AC max ~11kW
|
||||
ch.id, 'none', -- Tesla API fáze 2
|
||||
80, 7
|
||||
FROM ems.site s
|
||||
@@ -265,7 +291,7 @@ INSERT INTO ems.asset_vehicle
|
||||
default_target_soc_pct, default_deadline_hour)
|
||||
SELECT
|
||||
s.id, 'zoe-r135', 'Renault Zoe R135', 'Renault', 'Zoe R135',
|
||||
52.0, 7400, -- Zoe max 7.4kW AC
|
||||
22.0, 22000, -- Zoe max 22kW AC
|
||||
ch.id, 'none',
|
||||
90, 7 -- Zoe: vyšší target SoC (menší baterie, kritičtější)
|
||||
FROM ems.site s
|
||||
|
||||
@@ -89,10 +89,15 @@ def calculate_pv_power(
|
||||
|
||||
| Trigger | Čas | Popis |
|
||||
|---|---|---|
|
||||
| Scheduled (cron) | každý den 14:30 CET | Po importu cen, před plánováním |
|
||||
| Scheduled (cron) | každý den 06:00 CET | Aktualizace predikce na dnešní den |
|
||||
| Před plánováním | automaticky | Plánovač zkontroluje čerstvost, spustí pokud starší než 2h |
|
||||
| Manual trigger | na vyžádání | `POST /admin/run-forecast?site_id=1&date=YYYY-MM-DD` |
|
||||
| Scheduled (cron) | každé 2 hodiny v `:05` | Průběžný refresh forecastu pro všechny aktivní site |
|
||||
| Manual trigger | na vyžádání | `POST /api/v1/sites/{site_id}/forecast/run` |
|
||||
|
||||
### Implementované provozní změny (2026-03)
|
||||
|
||||
- Forecast horizont je konfigurovatelný přes `open_meteo_forecast_days`.
|
||||
- Runtime guard: hodnota se clampuje do rozmezí `2..16`.
|
||||
- Default je `7` dní.
|
||||
- Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů.
|
||||
|
||||
---
|
||||
|
||||
@@ -153,11 +158,21 @@ Viz `03-data-model.md`:
|
||||
|
||||
---
|
||||
|
||||
## Tracking přesnosti forecastu
|
||||
|
||||
- **`ems.forecast_accuracy`** – pro každý úspěšný `forecast_pv_run` a každý 15min slot ukládá predikovaný výkon, čas vzniku predikce, lead time (hodiny před začátkem slotu), později doplněnou skutečnost z telemetrie a odchylku (`error_w`, `error_pct`). Záznamy se **uchovávají trvale** (včetně všech historických běhů v `forecast_pv_run` / `forecast_pv_interval` – ty se nemazají).
|
||||
- **`ems.fn_fill_forecast_accuracy(site_id, lookback_hours)`** – inkrementálně vloží nebo aktualizuje řádky z `forecast_pv_interval` + run metadata a dopočte `actual_power_w` jako průměr 1min telemetrie ve slotu (pole B: `gen_port_power_w`, pole A: `pv1_power_w` + `pv2_power_w`). Volat **každých 15 minut** (např. spolu s audit fillerem); parametr `lookback_hours` omezuje okno zpětného zpracování (např. 48 h běžně, větší hodnota pro jednorázový backfill).
|
||||
- **`ems.vw_forecast_accuracy_by_lead_time`** – agregace přesnosti podle bucketů lead time (0–6 h, …, 48 h+); noční sloty s nízkou výrobou (`actual_power_w` ≤ 100 W) se v metrikách typicky vynechávají.
|
||||
- **`ems.vw_forecast_accuracy_daily`** – denní součty forecast vs actual v kWh (Praha kalendářní den) a relativní odchylka dne.
|
||||
- **Po 4+ týdnech dat** lze statistiky použít pro kalibraci `safety_factor` (nebo obdobných parametrů) v solveru – viz plánovací modul.
|
||||
|
||||
---
|
||||
|
||||
## Konfigurace (env proměnné)
|
||||
|
||||
```env
|
||||
OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast
|
||||
FORECAST_HORIZON_DAYS=3
|
||||
OPEN_METEO_FORECAST_DAYS=7
|
||||
FORECAST_MAX_AGE_HOURS=2 # plánovač odmítne starší predikci
|
||||
FORECAST_RETRY_COUNT=3
|
||||
```
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- Stahuje spotové ceny elektřiny z OTE CZ
|
||||
- Ukládá raw data bez vazby na lokalitu (sdílená tabulka)
|
||||
- Efektivní ceny (s marží) se dopočítávají per site přes view
|
||||
- Granularita: **15 minut** nativně (OTE CZ publikuje po hodinách → konvertujeme na 15min replikací)
|
||||
- Granularita: **15 minut** nativně (veřejný JSON `@@chart-data` s `time_resolution=PT15M`)
|
||||
|
||||
---
|
||||
|
||||
@@ -15,18 +15,20 @@
|
||||
|
||||
OTE CZ publikuje denní ceny zpravidla **den předem (D-1)** okolo 13:00–14:00 středoevropského času.
|
||||
|
||||
### Formát dat OTE CZ
|
||||
### Formát dat OTE CZ (implementace)
|
||||
|
||||
OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky:
|
||||
1. Stáhnout XML/JSON feed nebo scrape HTML tabulky
|
||||
2. Převést EUR/MWh → CZK/kWh (kurz ČNB nebo fixní koeficient dle konfigurace)
|
||||
3. Rozložit hodinový interval na 4× 15min sloty (stejná hodnota)
|
||||
4. Uložit do `market_interval_price`
|
||||
**Primární zdroj:** JSON grafu denního trhu (96 bodů na den):
|
||||
|
||||
### Alternativní API
|
||||
`https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data?report_date=YYYY-MM-DD&time_resolution=PT15M`
|
||||
|
||||
- **OTE XML feed:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?date=YYYY-MM-DD&market=DAM&type=FIN`
|
||||
- Autentikace: nepotřebná pro veřejná data
|
||||
- Body: `data.dataLine[0].point[]` s `x` = 1…96 (15min slot), `y` = cena.
|
||||
- Jednotka ceny se bere z `axis.y.legend` (typicky **EUR/MWh**); kód podporuje i CZK/MWh a CZK/kWh.
|
||||
- Přepočet do `buy_raw_price_czk_kwh`: EUR/MWh → `× EUR_CZK / 1000`; CZK/MWh → `/ 1000`; CZK/kWh beze změny.
|
||||
- Časové razítko slotu: půlnoc v **timezone lokality** (`site.timezone`) + (x−1)×15 min → UTC.
|
||||
|
||||
### Legacy / alternativa
|
||||
|
||||
- **OTE pubapi:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?...` (hodinová data – v projektu už není primární)
|
||||
|
||||
---
|
||||
|
||||
@@ -36,14 +38,26 @@ OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky:
|
||||
|
||||
Samostatný modul (ne součást FastAPI, ale může být volán z ní jako task).
|
||||
|
||||
### Implementované provozní změny (2026-03)
|
||||
|
||||
- Robustní HTTP fetch přes `httpx`:
|
||||
- oddělené timeouty (`connect/read/write/pool`),
|
||||
- retry s exponential backoff pro transient chyby,
|
||||
- detailní chybové kódy (`http_status:*`, `timeout_or_connect:*`, `db_import:*`).
|
||||
- API endpoint `POST /api/v1/sites/{site_id}/prices/import` vrací při chybě konkrétní důvod.
|
||||
- Pokud je import volán bez `date`, importer nejdřív zkusí D+1 a při neúspěchu fallback na dnešní den.
|
||||
|
||||
### Kdy se spouští
|
||||
|
||||
| Trigger | Čas | Popis |
|
||||
|---|---|---|
|
||||
| Scheduled (cron) | každý den 14:00 CET | Stažení cen na zítřek (D+1) |
|
||||
| Scheduled (cron) | každý den 00:05 CET | Kontrola – ověření že dnešní data jsou v DB |
|
||||
| Manual trigger | na vyžádání | API endpoint `POST /admin/import-prices?date=YYYY-MM-DD` |
|
||||
| Retry | při chybě, 3× s backoffem | Automatický opakovaný pokus |
|
||||
| Scheduled (cron) | každý den 13:30 CET | Předběžný pokus importu (D+1 + doplnění dneška) |
|
||||
| Scheduled (cron) | každý den 14:00 CET | Hlavní import OTE (D+1 + doplnění dneška) |
|
||||
| Scheduled (cron) | každý den 00:05 CET | Backfill kontrola úplnosti 96 slotů pro dnešek/zítřek |
|
||||
| Manual trigger | na vyžádání | API endpoint `POST /api/v1/sites/{site_id}/prices/import?date=YYYY-MM-DD` |
|
||||
| Retry | při chybě | Automatický opakovaný pokus v importéru |
|
||||
|
||||
Cronové časy v tabulce jsou v **Europe/Prague** (CET/CEST): `AsyncIOScheduler` v `app/main.py` má `timezone=ZoneInfo("Europe/Prague")`; kontejner backendu má `TZ=Europe/Prague` v `docker-compose.yml`. Bez toho by se hodiny/minuty v cronu vyhodnocovaly v **UTC** a např. „13:30 CET“ by odpovídalo jinému okamžiku na hodinách.
|
||||
|
||||
### Logika importu
|
||||
|
||||
@@ -57,15 +71,11 @@ def import_prices_for_date(date: date, source: str = "OTE_CZ"):
|
||||
log.info("Data already exist, skipping")
|
||||
return
|
||||
|
||||
# 2. Stáhnout z OTE API
|
||||
raw_data = ote_client.fetch_dam_prices(date) # vrátí list hodinových cen v EUR/MWh
|
||||
# 2. Stáhnout chart-data (96× 15 min)
|
||||
raw_points = ote_client.fetch_chart_data_15m(date)
|
||||
|
||||
# 3. Konvertovat EUR/MWh → CZK/kWh
|
||||
eur_czk_rate = get_exchange_rate() # z konfigurace nebo ČNB API
|
||||
czk_per_kwh = [(price_eur_mwh * eur_czk_rate) / 1000 for price in raw_data]
|
||||
|
||||
# 4. Rozložit na 15min intervaly (1 hodina = 4 sloty se stejnou cenou)
|
||||
intervals = expand_hourly_to_15min(czk_per_kwh, date)
|
||||
# 3. Detekovat jednotku (EUR/MWh, CZK/MWh, …) a převést na CZK/kWh
|
||||
intervals = convert_points_to_czk_kwh(raw_points, date, site_tz)
|
||||
|
||||
# 5. Upsert do DB (idempotentní)
|
||||
db.upsert_many("market_interval_price", intervals, conflict_keys=["market_source", "interval_start"])
|
||||
@@ -98,12 +108,25 @@ Marže se konfigurují v `site_market_config`:
|
||||
| `sell_margin_fixed_czk` | Kč/kWh | -0.02 (srážka) |
|
||||
| `sell_margin_percent` | % | 0 |
|
||||
|
||||
**Zelený bonus** není součástí `fn_effective_sell_price` ani view efektivní prodejní ceny – jde o samostatný příjem z výroby, viz níže.
|
||||
|
||||
---
|
||||
|
||||
## Zelený bonus
|
||||
|
||||
- Konfigurace je na **`ems.asset_pv_array`** (konkrétní FVE pole), ne na `site` ani na střídači. Sloupce: `green_bonus_czk_kwh`, `green_bonus_valid_from`, `green_bonus_valid_to`, `green_bonus_meter_code`.
|
||||
- Sazba se typicky mění **ročně**; verzování je přes `valid_from` / `valid_to` (při změně uzavři staré období a zadej novou sazbu s novým `valid_from`).
|
||||
- **Výpočet příjmu** za interval: `ems.fn_green_bonus_revenue(pv_array_id, interval_start, production_wh)` kde `production_wh` je skutečná nebo odhadnutá výroba pole v daném 15min slotu (Wh). Bonus platí z **celkové výroby** pole (interní spotřeba, baterie, EV, TČ i export) – nejde o prodejní cenu.
|
||||
- **Nezahrnovat do** `fn_effective_sell_price` – spot + prodejní marže jsou odděleně od dotace; v auditu se bonus ukládá do `audit_interval.green_bonus_czk` (plní `fn_fill_audit_interval`).
|
||||
|
||||
Historicky mohou v DB zůstat sloupce `green_bonus_*` na `site_market_config`; efektivní prodejní cena je z nich se nepočítá.
|
||||
|
||||
---
|
||||
|
||||
## Konfigurace (env proměnné)
|
||||
|
||||
```env
|
||||
OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam
|
||||
OTE_API_URL=https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data
|
||||
OTE_IMPORT_HOUR=14 # hodina kdy se spouští denní import
|
||||
EUR_CZK_RATE=25.0 # fallback kurz pokud ČNB API nedostupné
|
||||
CNB_API_URL=https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml
|
||||
|
||||
59
docs/04-modules/modbus-command-journal.md
Normal file
59
docs/04-modules/modbus-command-journal.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Modbus command journal
|
||||
|
||||
## Účel
|
||||
|
||||
Každý zápis na Modbus TCP (Deye a později další aktiva) se ukládá do tabulky `ems.modbus_command` jako samostatný řádek: cílový registr, hodnota, endpoint, vazba na `site_id` a volitelně na `planning_run_id`. Po zápisu má řádek stav `written`; samostatný **verifikační job** (každé 2 minuty) nebo ruční **GET** `/api/v1/sites/{site_id}/control/verify` přečte registr zpět a nastaví `value_verified` a stav `verified` nebo `mismatch`.
|
||||
|
||||
## Schéma `ems.modbus_command`
|
||||
|
||||
| Sloupec | Význam |
|
||||
|---------|--------|
|
||||
| `asset_type` / `asset_id` / `asset_code` | Typ aktiva (`inverter`, …), FK logicky na příslušnou tabulku, čitelný kód |
|
||||
| `device_*` | Host, port, Modbus unit ID |
|
||||
| `register` | Číslo registru (decimal); v logu též hex |
|
||||
| `register_name` | Např. `charge_limit`, `export_limit` |
|
||||
| `value_to_write` / `value_written` / `value_verified` | Požadavek, potvrzený zápis, ověření čtením |
|
||||
| `status` | `pending`, `written`, `verified`, `failed`, `mismatch`, `retrying` |
|
||||
| `planning_run_id` | Volitelná vazba na aktivní plán |
|
||||
| `deye_physical_mode` | U zápisů z `write_inverter_setpoints`: **PASSIVE** / **SELL** / **CHARGE** (stejná hodnota na všech řádcích daného běhu exportu); jinak NULL |
|
||||
| `attempt_count` | Počet pokusů o zápis (pro limity retry) |
|
||||
|
||||
Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending` / `retrying`.
|
||||
|
||||
## Verifikace a bezpečnost
|
||||
|
||||
1. Po `mismatch` se odešle **Discord** alert (`notification_service.send_discord` / `notify_modbus_mismatch`), pokud je nastaven `DISCORD_WEBHOOK_URL`.
|
||||
2. **Retry** zápisu max. **3×** (počítáno přes `attempt_count` po zápisech).
|
||||
3. Po třech neúspěšných cyklech: přepnutí lokality na **SELF_SUSTAIN** přes `ems.fn_set_mode` (`activated_by` = `system:mismatch`, poznámka = důvod) a **kritický** Discord alert (`notify_self_sustain_activated`).
|
||||
|
||||
Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_switch_to_self_sustain`.
|
||||
|
||||
## Střídač (Deye)
|
||||
|
||||
`write_inverter_setpoints` přidá do journalu mimo **62–64** (čas) a **time pointy 148–177** také řádky pro **108** (max charge A), **109** (max discharge A), **141** (energy mode, vždy 0), **142** (limit control), **178** (pevné hodnoty 32 / 48 podle fyzického režimu, bez read-modify-write), **143** (export limit W). Každý řádek daného exportního běhu má vyplněný **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**) pro audit přepínání. **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu baterie na proud: `battery_watts_to_amps` viz `modbus-registers.md`. Všechny zápisy journalu jdou přes **`write_registers`** (FC **0x10**), ne FC 0x06. Detail režimů a registrů: `docs/04-modules/modbus-registers.md`.
|
||||
|
||||
## APScheduler
|
||||
|
||||
| Job | Frekvence | Popis |
|
||||
|-----|-----------|--------|
|
||||
| `verify_modbus` | každé **2 min** | Pro každou aktivní site vybere `written` příkazy s `written_at` v posledních **10 min** a zavolá `verify_modbus_commands`. |
|
||||
|
||||
## Ruční API
|
||||
|
||||
`GET /api/v1/sites/{site_id}/control/verify?minutes=10`
|
||||
|
||||
Vrátí počty `checked` / `verified` / `mismatch` a seznam dotčených příkazů s aktuálním stavem po verifikaci.
|
||||
|
||||
## `ems.cutoff_switch_log`
|
||||
|
||||
Tabulka pro budoucí logování **cut-off** přepínačů (mikroinvertory / GEN při záporné prodejní ceně). Záznam při **změně** stavu: `asset_code`, `new_state`, `previous_state`, `reason`, `sell_price_czk`, `triggered_by`. Zatím jen schéma; logika napojení v `control_exporter` je v TODO.
|
||||
|
||||
## Konfigurace
|
||||
|
||||
- `.env`: `DISCORD_WEBHOOK_URL` — prázdné = notifikace vypnuté (jen log).
|
||||
|
||||
## Související soubory
|
||||
|
||||
- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`
|
||||
- Backend: `backend/services/control_exporter.py`, `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py`
|
||||
- Registry Deye: `docs/04-modules/modbus-registers.md`
|
||||
184
docs/04-modules/modbus-registers.md
Normal file
184
docs/04-modules/modbus-registers.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Deye Modbus Registry – EMS řízení
|
||||
|
||||
## Důležité pravidlo
|
||||
|
||||
- Registry **60–499**: POUZE **FC 0x10** (`write_registers`)
|
||||
- Registry **0–59**: FC 0x03 čtení, FC 0x06 zápis
|
||||
- Registry **500+**: FC 0x03 pouze čtení
|
||||
|
||||
EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_registers`** (FC 0x10), nikdy `write_register` (FC 0x06) pro rozsah 60–499.
|
||||
|
||||
## Řídící registry (R/W, FC 0x10)
|
||||
|
||||
| Reg | Název | Rozsah | Jednotka | Použití v EMS |
|
||||
|-----|-------|--------|----------|---------------|
|
||||
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit nabíjení baterie; horní mez není napříč modely stejná (nižší výkonové řady mívají jiný strop než např. SUN-20K) |
|
||||
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit vybíjení baterie; viz výše |
|
||||
| 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě |
|
||||
| 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě |
|
||||
| 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) |
|
||||
| 142 | Limit control | 0/1/2 | — | **0** = selling first, **1** = zero export (built-in CT); EMS přepíná export vs. idle/nabíjení |
|
||||
| 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` |
|
||||
| 178 | Grid peak shaving switch | bitmask | — | EMS zapisuje **pevnou** hodnotu (bez read-modify-write kvůli kolizím s paralelním čtením z Loxone): **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE** a **CHARGE**. |
|
||||
| 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu |
|
||||
| 191 | Grid peak shaving power | 0–16000 | 1 W | **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. |
|
||||
|
||||
`control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal + verifikace) po řadě: **62–64** (čas), **time points 148–177**, **108, 109, 141, 142, 178, 143**. Popisné názvy registrů v DB bere `DEYE_REGISTER_NAMES` v `control_exporter.py`. **Reg 191** do journalu nepatří – EMS ho nezapisuje.
|
||||
|
||||
### Reg 191 (výkon grid peak shaving)
|
||||
|
||||
- **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**.
|
||||
- Hodnota určuje výkon peak shavingu v **W** (typicky 0–16 000).
|
||||
|
||||
### Reg 178 – hodnoty podle fyzického režimu
|
||||
|
||||
- **SELL:** **32** – bit4–5 = **10**, grid peak shaving **disable** (export do sítě).
|
||||
- **PASSIVE** a **CHARGE:** **48** – bit4–5 = **11**, grid peak shaving **enable**.
|
||||
|
||||
EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může způsobit nesoulad).
|
||||
|
||||
## Klíčové registry podle fyzického režimu Deye
|
||||
|
||||
Provozní režimy EMS (AUTO, SELF_SUSTAIN, SELL, …) se mapují na **tři fyzické režimy** střídače: **PASSIVE**, **SELL**, **CHARGE**. Ostatní je politika solveru / EMS, ne samostatný „režim“ invertoru.
|
||||
|
||||
| Reg | PASSIVE | SELL | CHARGE |
|
||||
|-----|---------|------|--------|
|
||||
| 142 | 1 (zero export to load) | 0 (selling first) | 1 |
|
||||
| 108 | `max_charge_a` z DB | `max_charge_a` z DB | `battery_watts_to_amps(battery_w, max_charge_a)` |
|
||||
| 109 | `max_discharge_a` z DB | `max_discharge_a` z DB | 0 |
|
||||
| 178 | 48 | 32 | 48 |
|
||||
| 143 | max export W z DB | max export W z DB | max export W z DB |
|
||||
| 141 | 0 | 0 | 0 |
|
||||
|
||||
**Důležité:** V **PASSIVE** i **SELL** jsou registry **108** a **109** vždy na **plném limitu z DB**. Deye si tok energie reguluje sám; snížení 108/109 pod maximum brání reakci na nepředvídatelnou spotřebu nebo přebytky FVE.
|
||||
|
||||
### Detekce fyzického režimu (`get_deye_mode` v `control_exporter.py`)
|
||||
|
||||
Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivní plán / politika EMS), ne z telemetrie.
|
||||
|
||||
| Režim | Podmínka |
|
||||
|-------|----------|
|
||||
| **SELL** | `grid_setpoint_w` < −200 |
|
||||
| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 |
|
||||
| **PASSIVE** | vše ostatní (včetně SELF_SUSTAIN, IDLE, …) |
|
||||
|
||||
Režim **CHARGE_CHEAP** v EMS nastaví `grid_setpoint_w` tak, aby platila podmínka importu (> 200 W), jinak by fyzicky zůstal PASSIVE.
|
||||
|
||||
Všechny limity (`max_charge_a`, `max_discharge_a`, `max_export_power_w` / reg 143) pocházejí **výhradně z DB** (`_load_inverter_config`).
|
||||
|
||||
## Time Points – řízení podle fyzického režimu
|
||||
|
||||
Deye má 6 časových bloků. EMS přepisuje **bloky 1–2** při každém `control_export` (cron např. :14, :29, :44, :59).
|
||||
|
||||
**Výběr aktivního segmentu na invertoru:** platí poslední časový bod, jehož **HH:MM ≤ aktuálnímu času** na hodinách střídače (po synchronizaci 62–64). Proto **nesmí** zůstat jako jediný „minulý“ bod např. **00:00** s pasivním profilem, zatímco profil s nabíjením ze sítě je až u budoucího času – mezi půlnocí a tím budoucím časem by invertor celou dobu používal špatný segment.
|
||||
|
||||
| Blok | Čas (HHMM, Europe/Prague) | Zdroj plánu | Účel | SOC min | Grid charge |
|
||||
|------|---------------------------|-------------|------|---------|-------------|
|
||||
| 1 | **`current_slot_hhmm()`** – začátek **probíhajícího** 15min slotu | `planning_interval` pro **aktuální** slot (`_fetch_plan_row_for_slot_offset(..., 0)`) | PASSIVE / SELL / CHARGE dle `_deye_tou_params` | viz tabulka níže | viz tabulka níže |
|
||||
| 2 | **`next_slot_hhmm()`** – začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže |
|
||||
| 3–6 | 23:59 | — | Neaktivní (rezerva) | `reserve_soc` (DB) | NE |
|
||||
|
||||
**Registry 108 / 109 / 142 / 178 / 143** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 1–2 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`).
|
||||
|
||||
Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** – mezi 14:15 a 14:29 je aktivní segment z bloku 1 (sladěný s plánem pro 14:15–14:30), po 14:30 blok 2 (plán 14:30–14:45). Po dalším exportu se oba časy posunou (např. 14:30 / 14:45).
|
||||
|
||||
### Fyzické režimy Deye – parametry jednoho time pointu (bloky 1–2)
|
||||
|
||||
| Režim | Výkon (W) | SOC min | Grid charge |
|
||||
|-------|-----------|---------|-------------|
|
||||
| **PASSIVE** | `max_discharge_a × 51,2` | rezerva z DB | NE |
|
||||
| **SELL** | `max_discharge_a × 51,2` | rezerva z DB | NE |
|
||||
| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO |
|
||||
|
||||
Bloky 3–6 zůstávají na **23:59** s pasivním profilem (`reserve_soc`, grid charge = NE).
|
||||
|
||||
### Synchronizace času
|
||||
|
||||
Registry **62–64** se při každém `control_export` nastaví na aktuální čas v **Europe/Prague**:
|
||||
|
||||
- reg **62:** `(rok - 2000) << 8 | měsíc`
|
||||
- reg **63:** `den << 8 | hodina`
|
||||
- reg **64:** `minuta << 8 | sekunda`
|
||||
|
||||
Zápis time pointů i systémového času prochází stejným **`modbus_command`** journal jako registry 108 / 109 / 141 / 142 / 178 / 143 (FC 0x10 po jednom registru).
|
||||
|
||||
### Mapování registrů (time point *i*, i = 0…5)
|
||||
|
||||
| Účel | Adresa |
|
||||
|------|--------|
|
||||
| Čas HHMM | 148 + *i* |
|
||||
| Výkon (W) | 154 + *i* |
|
||||
| Min. SOC % | 166 + *i* |
|
||||
| Grid charge enable 0/1 | 172 + *i* |
|
||||
|
||||
Limity nabíjení/vybíjení v ampérech a export z **site_grid_connection** / **asset_inverter** / **asset_battery** načítá `_load_inverter_config()` (`max_charge_a` / `max_discharge_a` jako `LEAST(BMS, střídač) / 51,2`). Python **neřeže** na univerzální číslo – hodnoty v DB mají odpovídat **skutečnému modelu** střídače a BMS (maximální povolená hodnota v registru se liší podle typu; není to všude např. 185 A). Ověřit v dokumentaci k danému SUN-*K.
|
||||
|
||||
## Telemetrické registry (R only, FC 0x03)
|
||||
|
||||
| Reg | Název | Jednotka | Poznámka |
|
||||
|-----|-------|----------|----------|
|
||||
| 500 | Run state | — | 0 = standby, 2 = normal |
|
||||
| 588 | Battery SOC | 1 % | |
|
||||
| 590 | Battery power | 1 W S16 | + vybíjení / − nabíjení |
|
||||
| 625 | Grid total power | 1 W S16 | + import / − export |
|
||||
| 653 | Load total power | 1 W S16 | |
|
||||
| 667 | GEN port power | 1 W | FVE pole B |
|
||||
| 672 | PV1 power | 1 W | |
|
||||
| 673 | PV2 power | 1 W | |
|
||||
|
||||
## Přepočty
|
||||
|
||||
- Výkon baterie → proud (LV 51,2 V): `battery_watts_to_amps(power_w, max_amps) = min(max(0, max_amps), max(0, round(|power_w| / 51.2)))`, kde `max_amps` je z DB
|
||||
- `max_export_power_w` / `max_import_power_w` / limity baterie berou se z DB (`_load_inverter_config`), ne z natvrdo v Pythonu
|
||||
- Export do registru **143** = `site_grid_connection.max_export_power_w` (např. home-01 / SUN-20K **13 500 W**)
|
||||
|
||||
## Ověření (Modbus + DB)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build backend
|
||||
```
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from pymodbus.client import AsyncModbusTcpClient
|
||||
|
||||
async def check():
|
||||
c = AsyncModbusTcpClient('172.16.1.10', port=502, timeout=5)
|
||||
await c.connect()
|
||||
|
||||
times = await c.read_holding_registers(148, count=2)
|
||||
for i in range(2):
|
||||
h, m = divmod(times.registers[i], 100)
|
||||
print(f'Time point {i+1}: {h:02d}:{m:02d}')
|
||||
|
||||
for name, reg in [
|
||||
('Limit control', 142),
|
||||
('Peak sw (bit4-5)', 178),
|
||||
('Export limit', 143),
|
||||
('Discharge A', 109),
|
||||
('Grid power', 625),
|
||||
]:
|
||||
r = await c.read_holding_registers(reg, count=1)
|
||||
raw = r.registers[0]
|
||||
signed = raw - 65536 if raw > 32767 else raw
|
||||
print(f'{name} ({reg}): {signed}')
|
||||
|
||||
c.close()
|
||||
|
||||
asyncio.run(check())
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U ems_user -d ems -c "
|
||||
SELECT register_name, value_to_write, status,
|
||||
created_at AT TIME ZONE 'Europe/Prague' AS cas
|
||||
FROM ems.modbus_command
|
||||
WHERE site_id=2 AND register IN (108, 109, 142)
|
||||
ORDER BY created_at DESC LIMIT 9;"
|
||||
```
|
||||
|
||||
## Související
|
||||
|
||||
- `docs/04-modules/modbus-command-journal.md` – journal a verifikace
|
||||
- `backend/services/control_exporter.py` – zápisy
|
||||
- `backend/services/modbus_client.py` – `write_registers` (FC 0x10)
|
||||
@@ -1,132 +1,65 @@
|
||||
# Modul: Operating Modes (Provozní režimy)
|
||||
# Provozní režimy EMS
|
||||
|
||||
## Koncept
|
||||
## Přehled
|
||||
|
||||
EMS a Loxone komunikují přes **provozní režimy** – pojmenované stavy které mají smysl pro obě strany.
|
||||
| Mode | Solver constraints | Deye fyzický režim | Baterie |
|
||||
|------|-------------------|-------------------|---------|
|
||||
| AUTO | žádné | PASSIVE/SELL/CHARGE dle plánu | dle plánu |
|
||||
| SELF_SUSTAIN | no_export, min_import | vždy PASSIVE | plné limity |
|
||||
| CHARGE_CHEAP | no_export, no_discharge | CHARGE | nabíjení max |
|
||||
| PRESERVE | no_charge, no_discharge | PASSIVE | lock (0/0) |
|
||||
| MANUAL | solver neběží | EMS nezapisuje | — |
|
||||
|
||||
EMS rozhoduje a přepíná režimy. Loxone dostane kód režimu jako číslo přes Virtual Input a ví jak se v daném režimu chovat **autonomně a nezávisle na EMS**.
|
||||
Implementace: omezení LP v `planning_engine.solve_dispatch()` podle `mode_code` z `ems.site_operating_mode`; zápis Deye v `control_exporter.write_inverter_setpoints()` (včetně `lock_battery` u PRESERVE).
|
||||
|
||||
```
|
||||
EMS backend (každou minutu)
|
||||
→ HTTP GET /dev/sps/io/EMS_Heartbeat/1 ← pulz do Loxone
|
||||
## Fyzické režimy Deye (výstup control_exporteru)
|
||||
|
||||
EMS backend (při přepnutí režimu)
|
||||
→ ems.fn_set_mode(site_id, 'SELF_SUSTAIN') ← zapsat do DB
|
||||
→ HTTP GET /dev/sps/io/EMS_Mode/2 ← informovat Loxone
|
||||
Detekce z `battery_w` a `grid_setpoint_w` (`get_deye_mode`):
|
||||
|
||||
Loxone (zcela nezávisle na EMS)
|
||||
→ sleduje přítomnost EMS_Heartbeat pulzů
|
||||
→ pokud 5min žádný pulz → sám přepne na SELF_SUSTAIN
|
||||
→ řídí střídač dle aktivního režimu bez čekání na setpointy
|
||||
```
|
||||
- **PASSIVE:** `grid_setpoint_w >= -200` → reg142=1, reg178=48, 108/109=max z DB (nebo 0/0 při `lock_battery`)
|
||||
- **SELL:** `grid_setpoint_w < -200` → reg142=0, reg178=32, 108/109=max
|
||||
- **CHARGE:** `grid_setpoint_w > 200` **a** `battery_w > 500` → reg142=1, reg178=48
|
||||
|
||||
**Klíčový princip:** Loxone watchdog nečte DB. Sleduje pouze HTTP pulzy přímo.
|
||||
Pokud padne celý server (RPi, Docker, síť) – Loxone to pozná sám a přepne bezpečný režim.
|
||||
`battery_w = None` (SELF_SUSTAIN – Deye řídí sám) ⇒ pro detekci režimu se bere jako 0 ⇒ při `grid_setpoint_w = 0` je výsledek **PASSIVE**; registry 108/109 se nastaví na **plné limity z DB** (ne na nulu).
|
||||
|
||||
Viz `docs/loxone-integration.md` pro kompletní popis Loxone implementace.
|
||||
## EMS politiky (nejsou fyzické stavy Deye)
|
||||
|
||||
- **PV_SELL_ONLY:** AUTO + constraint solveru `max_discharge_from_pv`
|
||||
- **BLOCK_EXPORT:** AUTO + záporná sell_price → `ge[t]=0`
|
||||
- **NEGATIVE_HARVEST:** AUTO + záporná buy_price → max charge/load
|
||||
- **PROTECT:** SELF_SUSTAIN s konzervativními limity
|
||||
|
||||
Tyto politiky jsou parametrizace AUTO/SELF_SUSTAIN, ne samostatné fyzické stavy.
|
||||
|
||||
---
|
||||
|
||||
## Přehled režimů
|
||||
## Loxone a UI (shrnutí)
|
||||
|
||||
| Kód | Loxone int | EV | TČ | Baterie | Síť | Loxone autonomní |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `AUTO` | 1 | dle plánu | dle plánu | dle plánu | dle plánu | **ne** – čeká na setpointy |
|
||||
| `SELF_SUSTAIN` | 2 | ❌ stop | ❌ stop | vybíjí do domu | bez exportu | **ano** |
|
||||
| `CHARGE_CHEAP` | 3 | ❌ stop | ❌ stop | max nabíjení | import ok | **ne** – EMS posílá výkon |
|
||||
| `PRESERVE` | 4 | ❌ stop | ❌ stop | drží SoC | import ok | **ano** |
|
||||
| `MANUAL` | 0 | ❌ stop | ❌ stop | žádné akce | žádné akce | **ano** |
|
||||
|
||||
### `AUTO`
|
||||
Normální provoz. EMS posílá přesné setpointy W každých 15 minut.
|
||||
Loxone je čistý exekutor – přijme číslo a zapíše do střídače.
|
||||
Pokud setpoint nepřijde (výpadek EMS) → Loxone watchdog přepne na `SELF_SUSTAIN`.
|
||||
|
||||
### `SELF_SUSTAIN` ← výchozí stav + fallback
|
||||
Aktivuje se:
|
||||
- automaticky watchdogem při výpadku EMS (5min bez pulzu)
|
||||
- manuálně uživatelem z UI (dovolená, odchod z domu)
|
||||
- při prvním startu systému (seed data)
|
||||
|
||||
Loxone sám bez EMS:
|
||||
- FVE pokrývá spotřebu
|
||||
- baterie vybíjí do domu (ne do sítě)
|
||||
- blokuje export do sítě
|
||||
- zastavuje EV nabíjení a TČ
|
||||
|
||||
### `CHARGE_CHEAP`
|
||||
Manuální přepis. EMS posílá max charge setpoint.
|
||||
Použít při levné ceně nebo přetoku FVE ze sousedství (pokud víš o levné ceně dopředu).
|
||||
|
||||
### `PRESERVE`
|
||||
Dovolená / servis. Loxone drží baterii na aktuálním SoC, žádné optimalizace.
|
||||
Autonomní – Loxone nevyžaduje setpointy od EMS.
|
||||
|
||||
### `MANUAL`
|
||||
Technické práce. Žádná logika neřídí střídač. Pouze pro servis.
|
||||
|
||||
---
|
||||
|
||||
## Přepínání z UI (React)
|
||||
EMS a Loxone sdílí pojmenované provozní režimy; Loxone dostává číslo režimu přes Virtual Input a může fungovat autonomně (watchdog při výpadku EMS).
|
||||
|
||||
```
|
||||
POST /api/sites/{site_id}/mode
|
||||
{
|
||||
"mode": "SELF_SUSTAIN",
|
||||
"valid_until": null, // nebo "2025-03-15T06:00:00+01:00" pro dočasný přepis
|
||||
"notes": "Odjezd na dovolenou"
|
||||
"valid_until": null,
|
||||
"notes": "…"
|
||||
}
|
||||
```
|
||||
|
||||
Backend při přepnutí:
|
||||
1. Zavolá `ems.fn_set_mode(site_id, mode, 'user:'+username)` → zápis do DB + log
|
||||
2. Okamžitě odešle HTTP do Loxone: `/dev/sps/io/EMS_Mode/{loxone_mode_value}`
|
||||
3. Pokud `CHARGE_CHEAP` nebo návrat na `AUTO` → spustí replanning
|
||||
Backend: `ems.fn_set_mode` + HTTP na Loxone `/dev/sps/io/EMS_Mode/{loxone_mode_value}`. Dočasné přepisy s `valid_until` ruší `fn_expire_modes()`.
|
||||
|
||||
**Dočasný přepis s automatickým návratem:**
|
||||
`fn_expire_modes()` běží každou minutu a přepíná zpět lokality s prosahlým `valid_until`.
|
||||
**Klíčový princip:** Loxone watchdog nečte DB – sleduje pulzy `EMS_Heartbeat`. Detail: `docs/loxone-integration.md`.
|
||||
|
||||
---
|
||||
### Tabulka režimů (Loxone / zátěže)
|
||||
|
||||
## EMS restart / reconnect
|
||||
| Kód | Loxone int | EV | TČ | Poznámka |
|
||||
|-----|------------|----|----|----------|
|
||||
| `AUTO` | 1 | dle plánu | dle plánu | setpointy z plánu |
|
||||
| `SELF_SUSTAIN` | 2 | stop | stop | fallback / výpadek EMS |
|
||||
| `CHARGE_CHEAP` | 3 | stop | stop | max nabíjení ze sítě |
|
||||
| `PRESERVE` | 4 | stop | stop | baterie uzamčena (Modbus 0/0) |
|
||||
| `MANUAL` | 0 | stop | stop | servis, EMS neexportuje |
|
||||
|
||||
Při startu backendu:
|
||||
1. Přečíst z Loxone aktuální `EMS_Mode_Active` (Virtual Output) přes HTTP GET
|
||||
2. Porovnat s `ems.site_operating_mode` v DB
|
||||
3. Pokud Loxone přepnul na `SELF_SUSTAIN` během výpadku → logovat, informovat, spustit nový plán
|
||||
4. Přepnout na `AUTO` a začít posílat setpointy + heartbeat pulzy
|
||||
### Otevřené body
|
||||
|
||||
---
|
||||
|
||||
## Heartbeat v DB – pouze informační
|
||||
|
||||
Tabulka `ems.site_heartbeat` zaznamenává kdy EMS naposledy úspěšně odeslal pulz do Loxone.
|
||||
Slouží pro EMS dashboard (`vw_site_status.ems_heartbeat_status`) a případný alerting.
|
||||
|
||||
**Neplní funkci watchdogu** – to je čistě na Loxone straně.
|
||||
|
||||
```python
|
||||
# backend/services/control_exporter.py – každou minutu
|
||||
async def send_heartbeat(site_id: int, loxone_endpoint, db):
|
||||
try:
|
||||
await loxone_http.get(f"/dev/sps/io/EMS_Heartbeat/1")
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_heartbeat($1, 'ok', $2)",
|
||||
site_id, EMS_VERSION
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat failed for site {site_id}: {e}")
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_heartbeat($1, 'error', $2)",
|
||||
site_id, EMS_VERSION
|
||||
)
|
||||
# EMS nemůže nic dělat – Loxone watchdog to vyřeší sám
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Ověřit Deye Modbus registry pro přepnutí Self-Consumption / Grid-First modu (pro SELF_SUSTAIN)
|
||||
- [ ] Implementace Loxone watchdog – viz `docs/loxone-integration.md`
|
||||
- [ ] Alert notifikace (email / push) pokud `ems_heartbeat_status = 'stale'` déle než 10 minut
|
||||
- [ ] Doplnit alerty při `ems_heartbeat_status = 'stale'`
|
||||
|
||||
190
docs/04-modules/planning-extended-horizon.md
Normal file
190
docs/04-modules/planning-extended-horizon.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Rozšířený horizont plánování (96h)
|
||||
|
||||
## Motivace
|
||||
|
||||
OTE publikuje ceny max 36h dopředu. FVE forecast je dostupný na 7 dní.
|
||||
Rozšířením horizontu solver vidí vzdálené příležitosti (záporné ceny, levná okna)
|
||||
a může optimálně připravit baterii, TUV zásobník a EV nabíjení.
|
||||
|
||||
Klíčový princip: solver nepotřebuje explicitní "šetři baterii před zápornou cenou"
|
||||
constraint. Pokud dostane správné (odhadované) ceny pro celých 96h, sám pozná
|
||||
že je výhodnější počkat na zápornou cenu než vybíjet dnes za průměrnou.
|
||||
|
||||
## Datové zdroje pro predikci cen za horizont OTE
|
||||
|
||||
### Vrstva 1 – Sezónní průměr z historických OTE dat
|
||||
|
||||
Tabulka `ems.market_price_stats` (analogie `consumption_baseline_stats`):
|
||||
```sql
|
||||
SELECT
|
||||
EXTRACT(DOW FROM interval_start AT TIME ZONE 'Europe/Prague') AS dow,
|
||||
EXTRACT(HOUR FROM interval_start AT TIME ZONE 'Europe/Prague') AS hour,
|
||||
AVG(buy_raw_price_czk_kwh) AS avg_price,
|
||||
STDDEV(buy_raw_price_czk_kwh) AS stddev_price,
|
||||
PERCENTILE_CONT(0.25) WITHIN GROUP (
|
||||
ORDER BY buy_raw_price_czk_kwh) AS p25,
|
||||
PERCENTILE_CONT(0.75) WITHIN GROUP (
|
||||
ORDER BY buy_raw_price_czk_kwh) AS p75
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
|
||||
AND interval_start >= now() - INTERVAL '6 months'
|
||||
GROUP BY dow, hour
|
||||
```
|
||||
|
||||
Plnit denně po importu OTE. Min. 3 měsíce dat pro smysluplné průměry.
|
||||
|
||||
### Vrstva 2 – Korekce počasím (proxy)
|
||||
|
||||
Záporné/nízké ceny korelují s vysokou FVE výrobou v celé síti CZ.
|
||||
```
|
||||
predicted_irradiance > historical_avg * 1.3 → cena * 0.70 (30% sleva)
|
||||
predicted_irradiance < historical_avg * 0.5 → cena * 1.20 (20% přirážka)
|
||||
```
|
||||
|
||||
Korelaci ověřit po 3+ měsících dat. Zatím použít konzervativní korekci ±15%.
|
||||
|
||||
### Kombinovaná predikce s uncertainty margin
|
||||
```
|
||||
predicted_price[t] = seasonal_avg[dow, hour]
|
||||
× weather_correction_factor[t]
|
||||
× (1 ± uncertainty_margin[t])
|
||||
|
||||
uncertainty_margin roste s horizontem:
|
||||
0-36h: 0% (přesné OTE ceny, žádná predikce)
|
||||
36-72h: 20%
|
||||
72-96h: 35%
|
||||
```
|
||||
|
||||
## Uncertainty weighting v objective function
|
||||
|
||||
Vzdálenější sloty mají nižší váhu – solver je konzervativnější:
|
||||
```python
|
||||
def slot_weight(t: int, now_index: int) -> float:
|
||||
hours_ahead = (t - now_index) * 0.25
|
||||
if hours_ahead <= 36: return 1.0 # přesné OTE ceny
|
||||
if hours_ahead <= 72: return 0.7 # predikce, střední jistota
|
||||
return 0.4 # predikce, nízká jistota
|
||||
|
||||
# V objective function:
|
||||
prob += pulp.lpSum(
|
||||
slot_weight(t, now_index) * (
|
||||
gi[t] * slots[t].buy_price * INTERVAL_H / 1000
|
||||
- ge[t] * slots[t].sell_price * INTERVAL_H / 1000
|
||||
+ ...
|
||||
)
|
||||
for t in range(T)
|
||||
)
|
||||
```
|
||||
|
||||
## TUV predikce potřeby
|
||||
|
||||
### Princip
|
||||
|
||||
TUV zásobník drží teplo ~24h. Solver může ohřát vodu v levném okně
|
||||
před očekávanou spotřebou. Potřebuje vědět:
|
||||
- Aktuální teplotu zásobníku (z telemetrie)
|
||||
- Kdy typicky klesá teplota (statistika per DOW+hodina)
|
||||
- Minimální přijatelnou teplotu (tuv_min_temp_c)
|
||||
|
||||
### Tabulka `ems.tuv_usage_stats`
|
||||
|
||||
Analogie `consumption_baseline_stats` pro TUV zásobník:
|
||||
```sql
|
||||
-- Průměrný pokles teploty zásobníku per DOW+hodina
|
||||
-- (záporné = zásobník se ochladil, kladné = TČ ohřívalo)
|
||||
SELECT
|
||||
EXTRACT(DOW FROM measured_at AT TIME ZONE 'Europe/Prague') AS dow,
|
||||
EXTRACT(HOUR FROM measured_at AT TIME ZONE 'Europe/Prague') AS hour,
|
||||
AVG(temp_delta_c) AS avg_temp_delta, -- průměrná změna za hodinu
|
||||
STDDEV(temp_delta_c) AS stddev_temp_delta
|
||||
FROM (
|
||||
SELECT
|
||||
measured_at,
|
||||
tuv_tank_temp_c - LAG(tuv_tank_temp_c) OVER (
|
||||
PARTITION BY site_id ORDER BY measured_at
|
||||
) AS temp_delta_c
|
||||
FROM ems.telemetry_heat_pump
|
||||
WHERE site_id = $1
|
||||
AND measured_at >= now() - INTERVAL '30 days'
|
||||
) sub
|
||||
WHERE temp_delta_c IS NOT NULL
|
||||
AND ABS(temp_delta_c) < 5 -- filtruj extrémní skoky (start TČ)
|
||||
GROUP BY dow, hour
|
||||
```
|
||||
|
||||
### Použití v solveru
|
||||
```python
|
||||
# Pro každý slot t zjisti predikovanou teplotu zásobníku:
|
||||
tuv_predicted[t] = tuv_current + SUM(avg_temp_delta[dow, hour]
|
||||
for slots before t)
|
||||
|
||||
# Pokud tuv_predicted[t] < tuv_min_temp + safety_margin:
|
||||
# → solver musí naplánovat ohřev před tímto slotem
|
||||
# → heat_pump_enabled[t-N] = True (kde N = počet slotů potřebných pro ohřev)
|
||||
|
||||
# Potřebný čas ohřevu (orientační):
|
||||
# delta_temp = tuv_target - tuv_current
|
||||
# time_h = delta_temp × volume_l × 1.163 / (cop × hp_power_w / 1000)
|
||||
```
|
||||
|
||||
## EV v rozšířeném horizontu
|
||||
|
||||
### Tesla (s API – fáze 2)
|
||||
```
|
||||
Vstup: aktuální SoC z Tesla API, nastavený deadline uživatelem
|
||||
Solver: deadline constraint přes celých 96h
|
||||
nabij nejlevněji v rámci časového okna
|
||||
```
|
||||
|
||||
### Zoe (bez API)
|
||||
```
|
||||
Vstup: ev_arrival_stats (statistika příjezdů per DOW+hodina)
|
||||
energy_delivered_wh z aktuální session (odhad SoC)
|
||||
Solver: soft constraint – pravděpodobnost příjezdu jako váha
|
||||
pokud P(příjezd v slot t) > 60%: rezervuj nabíjecí kapacitu
|
||||
```
|
||||
|
||||
### Predikce příjezdu v solveru
|
||||
```python
|
||||
# Pro každý slot t kde P(příjezd) > 0.4:
|
||||
arrival_prob = ev_arrival_stats[dow, hour] / total_arrivals_this_dow
|
||||
|
||||
# Soft constraint (ne hard – auto nemusí přijet):
|
||||
# Přidej "expected EV consumption" jako součást load_baseline
|
||||
ev_expected_w[t] = arrival_prob * ev_charge_power_typical
|
||||
```
|
||||
|
||||
## Implementační plán
|
||||
|
||||
### Fáze 3a – Historické průměry cen (hotovo)
|
||||
|
||||
1. Tabulka `ems.market_price_stats` – migrace **V022__extended_planning.sql**
|
||||
2. `fn_update_market_price_stats()` – `db/routines/R__fn_extended_planning.sql`, APScheduler **14:45** (`main.py`)
|
||||
3. Solver: slotová páteř `generate_series` + `COALESCE(effective_*, fn_get_predicted_price(...))` v `_load_slots`
|
||||
|
||||
### Fáze 3b – TUV statistika potřeby (hotovo)
|
||||
|
||||
1. Tabulka `ems.tuv_usage_stats` – V022
|
||||
2. `fn_update_tuv_usage_stats()` – repeatable výše, job **00:45**
|
||||
3. Solver: look-ahead simulace teploty + součet `hp` v okně 9 slotů (`solve_dispatch`)
|
||||
|
||||
### Fáze 3c – Rozšíření solveru na 96h (hotovo)
|
||||
|
||||
1. `HORIZON_HOURS = 96`, `slot_weight()` – váhy **1,0 / 0,7 / 0,4** v účelové funkci
|
||||
2. Příznak `PlanningSlot.is_predicted_price` (z SQL `(ep.effective_buy IS NULL)`)
|
||||
|
||||
### Fáze 3d – EV v rozšířeném horizontu (závisí na Tesla API)
|
||||
|
||||
1. Pravděpodobnostní příjezd ze statistiky
|
||||
2. Deadline constraint přes celých 96h
|
||||
3. Tesla API integrace
|
||||
|
||||
### Fáze 3e – Korekce cen počasím
|
||||
|
||||
Po nasbírání 3+ měsíců korelačních dat rozšířit `fn_get_predicted_price` (viz vrstva 2 výše).
|
||||
|
||||
## Prerekvizity
|
||||
|
||||
- Min. 3 měsíce historických OTE dat pro smysluplné průměry
|
||||
- Min. 1 měsíc telemetrie TUV pro tuv_usage_stats
|
||||
- Stabilní základní provoz (Modbus zápis, telemetrie)
|
||||
@@ -4,6 +4,24 @@
|
||||
|
||||
**PuLP + HiGHS solver** – lineární programování (LP) s uvolněním binárních proměnných.
|
||||
|
||||
### Implementované provozní změny (2026-03)
|
||||
|
||||
- **Strict price fail-safe:**
|
||||
- pokud v prvních 36h chybí OTE data (sloty jsou predikované), solver zapíná fail-safe režim,
|
||||
- v predikovaných slotech (`is_predicted_price=true`) je zakázán export do sítě,
|
||||
- baterie se ale dál používá standardně pro interní spotřebu (nabíjení i vybíjení do domu je povoleno).
|
||||
- **Runtime guard v exportu setpointů:**
|
||||
- při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování.
|
||||
- **Ekonomika baterie:**
|
||||
- `reserve_soc_percent` naladěn na 10 %,
|
||||
- `degradation_cost_czk_kwh` naladěn na 0.1500,
|
||||
- penalizace cyklu je v objective symetrická (`0.5*(charge+discharge)`).
|
||||
- **PV-aware nejistota:**
|
||||
- objective používá `pv_scarcity_factor` (0.65..1.0), odvozený z forecastu slunce,
|
||||
- při slabém slunci je plán ochotnější držet energii v baterii.
|
||||
- **SoC buffer bez hard pravidel:**
|
||||
- místo explicitních pravidel se používá ekonomická penalizace deficitu vůči bezpečnostnímu SoC cíli na konci 24h horizontu.
|
||||
|
||||
Solver optimalizuje celý horizont (typicky 36h) najednou, čímž přirozeně zvládá:
|
||||
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
|
||||
- kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu
|
||||
@@ -394,12 +412,9 @@ PLANNING_HORIZON_HOURS=36
|
||||
PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout
|
||||
PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE
|
||||
PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu
|
||||
PV_B_GREEN_BONUS_CZK_KWH=1.20 # zelený bonus Kč/kWh (informativní, do účelové funkce přidat pokud chceš)
|
||||
```
|
||||
|
||||
> **Zelený bonus v účelové funkci:** Pokud chceš bonus explicitně zahrnout, přidat do objective function:
|
||||
> `- pv_b[t] * GREEN_BONUS_CZK_KWH * H / 1000` jako konstantní příjem (pole B vždy vyrábí).
|
||||
> Protože je to konstanta, neovlivní optimalizaci – ale správně zobrazí ekonomiku v auditu.
|
||||
> **Zelený bonus:** Sazba a platnost jsou v `ems.asset_pv_array` (`green_bonus_*`). Bonus **není** v objective function LP solveru – jako aditivní konstanta k nákladům by optimalizaci stejně neměnil. Příjem z bonusu se počítá v **`fn_fill_audit_interval`** přes `ems.fn_green_bonus_revenue()` a ukládá se do `audit_interval.green_bonus_czk`; v přehledech (např. `vw_audit_daily`) je samostatná položka příjmů vedle nákladů ze sítě. Viz `docs/04-modules/market-prices.md` → sekce Zelený bonus.
|
||||
|
||||
---
|
||||
|
||||
@@ -417,7 +432,7 @@ highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD)
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Post-processing min_run_duration pro TČ – po LP výsledku zkontrolovat a upravit krátké ON/OFF sekvence
|
||||
- [ ] Zelený bonus zahrnout do auditního výpočtu nákladů (ne jen do objective)
|
||||
- [x] Zelený bonus v auditu (`fn_fill_audit_interval`, `green_bonus_czk`) – mimo solver
|
||||
- [ ] EV rozdělení výkonu mezi 2 nabíječky – zatím řešeno jako agregát
|
||||
- [ ] Curtailment pole A – ověřit Modbus registr pro Output Power Limit na Deye SUN-20K
|
||||
- [ ] Testovat solver na reálných datech – ověřit čas výpočtu pro 36h horizont (144 slotů)
|
||||
|
||||
@@ -35,21 +35,23 @@ Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI.
|
||||
|
||||
Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
|
||||
|
||||
> Registry jsou specifické pro Deye SUN-20K-SG01LP1-EU.
|
||||
> Finální hodnoty ověřit z Deye Modbus protokolu / Loxone šablony.
|
||||
> Mapování v kódu: `backend/services/telemetry_collector.py` (holding registry, decimal adresa = offset pro `read_holding_registers`).
|
||||
|
||||
| Registr (hex) | Typ | Popis | Jednotka | Přepočet |
|
||||
| Dec (hex) | Typ | Popis | Jednotka | Poznámka |
|
||||
|---|---|---|---|---|
|
||||
| 0x0215 | Read Holding | PV celkový výkon | W | ×1 |
|
||||
| 0x0103 | Read Holding | Battery SoC | % | ×1 |
|
||||
| 0x0105 | Read Holding | Battery power | W | signed, kladné=nabíjení |
|
||||
| 0x0101 | Read Holding | Battery voltage | 0.1V | ×0.1 |
|
||||
| 0x0169 | Read Holding | Grid power | W | signed, kladné=import |
|
||||
| 0x016F | Read Holding | Grid voltage L1 | 0.1V | ×0.1 |
|
||||
| 0x0213 | Read Holding | Load power | W | ×1 |
|
||||
| 0x0220 | Read Holding | Inverter temperature | 0.1°C | ×0.1 |
|
||||
| 0x0168 | Read Holding | Operating mode | enum | viz tabulka módů |
|
||||
| 0x0180 | Read Holding | Fault code | bitfield | 0=ok |
|
||||
| 500 (0x01F4) | uint16 | Provozní stav střídače | enum | raw do `run_state`, ladění |
|
||||
| 514 (0x0202) | uint16 | Dnešní nabití baterie | Wh | `batt_charge_today_wh` |
|
||||
| 515 (0x0203) | uint16 | Dnešní vybití baterie | Wh | `batt_discharge_today_wh` |
|
||||
| 588 (0x024C) | uint16 | Battery SoC | % | `battery_soc_percent` |
|
||||
| 590 (0x024E) | int16 | Tok výkonu baterie | W | signed: **+ vybíjení, − nabíjení** |
|
||||
| 625 (0x0271) | int16 | Výkon sítě | W | signed: **+ import, − export** |
|
||||
| 653 (0x028D) | uint16 | Celková spotřeba | W | `load_power_w` |
|
||||
| 667 (0x029B) | uint16 | Výkon GEN portu (FVE pole B) | W | `gen_port_power_w`, nelze curtailovat |
|
||||
| 672 (0x02A0) | uint16 | Výkon PV1 | W | `pv1_power_w` |
|
||||
| 673 (0x02A1) | uint16 | Výkon PV2 | W | `pv2_power_w` |
|
||||
|
||||
`pv_power_w` v DB = **PV1 + PV2 + GEN port** (celková výroba na instalaci home-01).
|
||||
`gen_port_power_w` zůstává i nadále uložen samostatně pro audit a detailní diagnostiku.
|
||||
|
||||
**Zápis setpointů (plánování → Deye):**
|
||||
|
||||
@@ -60,8 +62,7 @@ Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
|
||||
| 0x00F6 | Write Single | Grid export power limit | W |
|
||||
| 0x00F0 | Write Single | Work mode | enum (viz tabulka) |
|
||||
|
||||
> **TODO:** Přesné registry doplnit z Deye SUN-20K Modbus protokolu PDF.
|
||||
> Loxone šablona pro Deye je dobrý výchozí bod pro mapování registrů.
|
||||
Rychlá kontrola komunikace: `scripts/test_modbus_deye.py`.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,66 +109,7 @@ Komunikace: Modbus TCP přes Waveshare.
|
||||
|
||||
## Kód telemetrie (Python)
|
||||
|
||||
```python
|
||||
# backend/services/telemetry_collector.py
|
||||
|
||||
import asyncio
|
||||
from pymodbus.client import AsyncModbusTcpClient
|
||||
from datetime import datetime, timezone
|
||||
|
||||
async def poll_inverter(site_id: int, inverter: AssetInverter, endpoint: SiteEndpoint, db):
|
||||
"""Přečte všechny registry Deye a uloží záznam do telemetry_inverter."""
|
||||
async with AsyncModbusTcpClient(endpoint.host, port=endpoint.port) as client:
|
||||
try:
|
||||
# Čtení bloku registrů (optimalizovat jako jeden read multiple)
|
||||
pv_power = await read_register(client, 0x0215, endpoint.unit_id)
|
||||
batt_soc = await read_register(client, 0x0103, endpoint.unit_id)
|
||||
batt_power = await read_register_signed(client, 0x0105, endpoint.unit_id)
|
||||
batt_voltage = await read_register(client, 0x0101, endpoint.unit_id) / 10.0
|
||||
grid_power = await read_register_signed(client, 0x0169, endpoint.unit_id)
|
||||
grid_voltage = await read_register(client, 0x016F, endpoint.unit_id) / 10.0
|
||||
load_power = await read_register(client, 0x0213, endpoint.unit_id)
|
||||
inv_temp = await read_register(client, 0x0220, endpoint.unit_id) / 10.0
|
||||
op_mode = await read_register(client, 0x0168, endpoint.unit_id)
|
||||
fault_code = await read_register(client, 0x0180, endpoint.unit_id)
|
||||
|
||||
await db.execute("""
|
||||
INSERT INTO ems.telemetry_inverter
|
||||
(site_id, inverter_id, measured_at,
|
||||
pv_power_w, battery_soc_percent, battery_power_w, battery_voltage_v,
|
||||
grid_power_w, grid_voltage_v, load_power_w,
|
||||
inverter_temp_c, operating_mode, fault_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
||||
""",
|
||||
site_id, inverter.id, datetime.now(timezone.utc),
|
||||
pv_power, batt_soc, batt_power, batt_voltage,
|
||||
grid_power, grid_voltage, load_power,
|
||||
inv_temp, str(op_mode), fault_code
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Inverter poll failed [{inverter.code}]: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def run_collector(db):
|
||||
"""Hlavní smyčka – každých 60s sbírá data ze všech aktivních zařízení."""
|
||||
while True:
|
||||
start = asyncio.get_event_loop().time()
|
||||
|
||||
sites = await db.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
await asyncio.gather(
|
||||
poll_all_inverters(site.id, db),
|
||||
poll_all_ev_chargers(site.id, db),
|
||||
poll_all_heat_pumps(site.id, db),
|
||||
return_exceptions=True # jeden výpadek nezastaví ostatní
|
||||
)
|
||||
|
||||
elapsed = asyncio.get_event_loop().time() - start
|
||||
await asyncio.sleep(max(0, 60 - elapsed))
|
||||
```
|
||||
Implementace: `backend/services/telemetry_collector.py` — `poll_inverter()` používá konstanty `DEYE_REG_*` a třídu `ModbusDevice`; hlavní smyčka je `run_telemetry_loop` / `run_telemetry_loop_wrapper`.
|
||||
|
||||
---
|
||||
|
||||
@@ -209,7 +151,7 @@ MODBUS_READ_TIMEOUT_SEC=3
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Doplnit přesné Modbus registry Deye z PDF protokolu
|
||||
- [x] Základní mapování Deye (holding registry 500–673) v `telemetry_collector.py`
|
||||
- [ ] Doplnit Modbus registry Teltonika z dokumentace / Loxone šablony
|
||||
- [ ] Doplnit Modbus registry Samsung z dokumentace / Loxone šablony
|
||||
- [ ] Ověřit Unit ID všech zařízení při instalaci
|
||||
|
||||
Reference in New Issue
Block a user