Files
ems/docs/04-modules/telemetry.md
Dusan Vojacek 815a233049
Some checks failed
CI and deploy / migration-check (push) Successful in 28s
CI and deploy / deploy (push) Failing after 17m56s
feat(telemetry): idle-skip zápisů — neukládat 1min řádky idle zařízení
Slabý server: dict (tabulka, asset_id) → (signature, last_stored_at);
_idle_skip ukládá vždy při změně signature, aktivitě, po startu procesu
a heartbeat po > 840 s (každý 15min bucket má ≥ 1 řádek).

- telemetry_ev_charger: aktivní = status != 'available' nebo power > 50 W;
  signature (status, výkon na 100 W)
- telemetry_pool_pump: aktivní = is_on nebo power > 5 W (ON řádky 1/min
  kvůli on_minutes); signature (is_on, výkon na 10 W)
- telemetry_loxone_sensor: jen změna hodnoty ≥ 0.1 / heartbeat
- telemetry_heat_pump: aktivní = mode != 'off' nebo defrost; signature
  (mode, teploty na 0.2 °C)
- telemetry_inverter: beze změny — NIKDY se nepřeskakuje (audit Wh split,
  baseline, SoC plánovače)

Detekce příjezdu/odjezdu EV: previous_status přesunut z posledního řádku DB
do in-memory _EV_LAST_STATUS (po startu seed z vw_latest_ev_charger —
přechod během výpadku se pozná, prázdná DB nevystřelí falešný příjezd);
fn_ev_session_transition se volá jen při změně statusu.

PoolCard: staleness práh 5 → 16 min (> heartbeat 840 s).
Docs: telemetry.md sekce „Idle-skip zápisů" (pravidla pro nové čtecí dotazy:
sumy/gapfill, ne avg přes řádky), planning-changelog (TUV °C/min).
Testy: tests/test_telemetry_idle_skip.py — _idle_skip jednotkově + EV
arrival/departure přežije skip i restart procesu (303 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:06:41 +02:00

241 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Modul: Telemetry (Sběr dat ze zařízení)
## Co modul dělá
- Čte data ze střídače Deye přes Modbus TCP
- EV nabíječky Teltonika a tepelné čerpadlo Samsung mají zatím placeholder
vzorky; konkrétní registry jsou TODO
- Ukládá surová měření do DB (1min granularita)
- Detekuje výpadky komunikace a loguje chyby
- Agreguje 1min data na 15min průměry pro spotřebu, audit a plánování
---
## Komponenta: `telemetry_collector` (Python service)
Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI.
### Polling intervaly
| Zařízení | Interval | Důvod |
|---|---|---|
| Deye střídač | 60 s | 1min granularita telemetrie |
| Teltonika EV nabíječka 1 | 60 s | zatím placeholder `available`, 0 W |
| Teltonika EV nabíječka 2 | 60 s | zatím placeholder `available`, 0 W |
| Samsung tepelné čerpadlo | 60 s | zatím placeholder hodnoty |
### Chování při chybě
- Chyba komunikace: záznam se nezapíše, chyba se loguje
- Kód zatím nedrží počítadlo po sobě jdoucích chyb podle zařízení; chyby se logují
při jednotlivých poll pokusech
- Data se neinterpolují chybějící minuty zůstanou prázdné (audit to pozná)
---
## Deye SUN-20K Modbus registry
Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
> Mapování v kódu: `backend/services/telemetry_collector.py` (holding registry, decimal adresa = offset pro `read_holding_registers`).
| Dec (hex) | Typ | Popis | Jednotka | Poznámka |
|---|---|---|---|---|
| 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 z Deye; v DB `battery_power_w` platí **+ nabíjení, vybíjení** |
| 625 (0x0271) | int16 | Výkon sítě | W | signed: **+ import, export** |
| 653 (0x028D) | uint16 | Celková spotřeba | W | `load_power_w` |
| 667 (0x029B) | int16 | Výkon GEN portu (FVE pole B) | W (signed) | `gen_port_power_w`; záporné při zpětném toku / bez výroby — **číst signed** |
| 672 (0x02A0) | int16 | Výkon PV1 | W (signed) | `pv1_power_w`; při špatném unsigned čtení se záporné hodnoty jeví jako desítky kW |
| 673 (0x02A1) | int16 | Výkon PV2 | W (signed) | `pv2_power_w` |
`pv1_power_w` / `pv2_power_w` / `gen_port_power_w` v DB = **signed W** z Modbus (mohou být záporné).
`pv_power_w` = **max(0, PV1) + max(0, PV2) + max(0, GEN)** — okamžitá **kladná** výroba FVE pro dashboard a souhrny; záporné kanály do součtu nepatří (typicky večer při exportu z baterie do sítě).
`gen_port_power_w` zůstává uložen samostatně pro audit zeleného bonusu a diagnostiku.
**Ověření po změně sběru:** za denního SVitu zkontrolovat, že `pv_power_w` a jednotlivé kanály odpovídají očekávanému max. výkonu instalace (logika: `aggregate_pv_production_w` v `telemetry_collector.py`, unit testy `tests/test_telemetry_pv_signed.py`).
**Zápis setpointů (plánování → Deye):**
Aktuální řízení Deye je popsané v [`control.md`](control.md) a
[`modbus-registers.md`](modbus-registers.md). Nepoužívá starý `write_register`
model, ale journal `ems.modbus_command` a FC 0x10 (`write_registers`) pro
registry 108/109/141/142/143/145/178/340 + TOU bloky.
Historická orientační mapa níže neplatí jako implementační kontrakt:
| Registr (hex) | Typ | Popis | Hodnota |
|---|---|---|---|
| 0x00F3 | Write Single | Battery charge power limit | W |
| 0x00F4 | Write Single | Battery discharge power limit | W |
| 0x00F6 | Write Single | Grid export power limit | W |
| 0x00F0 | Write Single | Work mode | enum (viz tabulka) |
Rychlá kontrola komunikace: `scripts/test_modbus_deye.py`.
---
## Teltonika TeltoCharge Modbus registry
Komunikace: Modbus TCP přes Waveshare, Unit ID = 1 (ověřit).
> Registry doplnit z Teltonika TeltoCharge Modbus dokumentace / Loxone šablony.
| Registr | Typ | Popis | Jednotka |
|---|---|---|---|
| TBD | Read | Stav konektoru (OCPP status enum) | enum |
| TBD | Read | Aktuální výkon | W |
| TBD | Read | Kumulativní energie session | Wh |
| TBD | Read | Proud L1/L2/L3 | 0.1A |
| TBD | Read | Napětí | 0.1V |
| TBD | Read | Session ID | uint |
| TBD | Read | Error code | uint |
| TBD | Write | Max proud (charge limit) | A (632A) |
| TBD | Write | Povolení nabíjení (on/off) | bool |
---
## Samsung tepelné čerpadlo Modbus registry
Komunikace: Modbus TCP přes Waveshare.
> Registry doplnit ze Samsung NASA Modbus dokumentace / Loxone šablony.
| Registr | Typ | Popis | Jednotka |
|---|---|---|---|
| TBD | Read | Venkovní teplota | 0.1°C |
| TBD | Read | Teplota vody vstup | 0.1°C |
| TBD | Read | Teplota vody výstup | 0.1°C |
| TBD | Read | Teplota zásobníku TUV | 0.1°C |
| TBD | Read | Příkon | W |
| TBD | Read | Provozní režim | enum |
| TBD | Read | Alarm kód | uint |
| TBD | Read | Odmrazování aktivní | bool |
| TBD | Write | Povolení provozu | bool |
| TBD | Write | Požadovaná teplota TUV | °C |
---
## Kód telemetrie (Python)
Implementace: `backend/services/telemetry_collector.py``poll_inverter()` používá konstanty `DEYE_REG_*` a sdíleného Modbus klienta z `services.modbus_client`; hlavní smyčka je `run_telemetry_loop` / `run_telemetry_loop_wrapper`.
---
## Idle-skip zápisů (úspora zápisů na slabém serveru)
Zařízení v klidu negeneruje nový 1min řádek — vzorek se zahodí, pokud je
zařízení **idle** a **signature** (kvantizovaný stav) se nezměnila. Mechanismus:
`_idle_skip(key, signature, is_active, now, max_gap_s=840)` v
`telemetry_collector.py`, modulový stav `(tabulka, asset_id) → (signature,
last_stored_at)`.
**Uloží se vždy, když:** signature se změnila; zařízení je aktivní; od
posledního uložení uplynulo **> 840 s** (heartbeat — každý 15min bucket má
≥ 1 řádek); nebo jde o první vzorek po startu procesu.
| Tabulka | Aktivní = ukládá se 1/min | Signature |
|---|---|---|
| `telemetry_ev_charger` | `status != 'available'` nebo `power_w > 50` | (status, výkon na 100 W) |
| `telemetry_pool_pump` | `is_on` nebo `power_w > 5` | (is_on, výkon na 10 W) |
| `telemetry_loxone_sensor` | nikdy (čidlo) — jen změna/heartbeat | hodnota na 0.1 |
| `telemetry_heat_pump` | `operating_mode != 'off'` nebo defrost | (mode, teploty na 0.2 °C) |
| `telemetry_inverter` | **vždy** — NIKDY se nepřeskakuje | — |
Střídač se nepřeskakuje: je vstupem auditu (per-minute Wh split, 7 směrových
toků), baseline a SoC plánovače — hustá řada je nutná.
**Detekce příjezdu/odjezdu EV** už nečte předchozí status z posledního řádku
DB (ten je při idle-skip řidší), ale z in-memory `_EV_LAST_STATUS`; po startu
procesu se seeduje z `vw_latest_ev_charger` (přechod během výpadku backendu se
pozná, prázdná DB nevystřelí falešný příjezd). `fn_ev_session_transition` se
volá jen při změně statusu.
**Důsledky pro čtecí dotazy (POVINNÉ pravidlo):** nad idle-skip tabulkami
nesmí nový dotaz počítat `avg(power)` přes přítomné řádky — chybějící minuta
znamená „zařízení idle ≈ 0 W“ a avg by aktivitu části okna nadhodnotil.
Správně: **suma / počet minut okna** (`sum(power_w) / 15.0` pro 15min slot),
`time_bucket_gapfill`, nebo delta čítače energie. Poslední hodnota
(`vw_latest_*`, TUV teplota, teplota bazénu) je díky heartbeatu max. ~14 min
stará — staleness prahy musí být > 840 s (PoolCard používá 16 min).
Přizpůsobené čtecí cesty:
- `fn_fill_audit_interval` (R__019): EV a TČ `sum(power_w)/15` místo avg.
- `fn_update_tuv_usage_stats` (R__018): delta TUV normalizovaná na **°C/min**
délkou mezery mezi řádky (`gap_min`), mezery > 30 min vyloučeny.
- `fn_update_baseline_stats` (R__003): beze změny — `coalesce(avg, 0)`
v okně ±30 s; chybějící řádek = 0 W, což při idle-skip platí.
- `vw_pool_pump_day_energy` (R__097): `on_minutes` počítá ON řádky — drží,
protože zapnuté čerpadlo se ukládá každou minutu; kWh je delta čítače.
- `fn_pool_daily_runtime_min`, `fn_planning_site_context` (TUV),
`fn_load_planning_slots_full` (EV status): poslední hodnota — heartbeat stačí.
---
## Agregace 1min → 15min
Prováděna PostgreSQL funkcí `ems.fn_fill_audit_interval()` a navazujícími
funkcemi pro baseline/accuracy. Spouští ji Python APScheduler: audit filler v
minutách `:01,:16,:31,:46`, forecast accuracy v `:02,:17,:32,:47`.
```sql
-- Příklad agregace telemetrie na 15min průměr
-- (součást fn_fill_audit_interval)
SELECT
site_id,
time_bucket('15 minutes', measured_at) AS interval_start,
AVG(pv_power_w)::INT AS avg_pv_power_w,
AVG(battery_power_w)::INT AS avg_battery_power_w,
AVG(grid_power_w)::INT AS avg_grid_power_w,
AVG(load_power_w)::INT AS avg_load_power_w,
LAST(battery_soc_percent, measured_at) AS last_soc_pct
FROM ems.telemetry_inverter
WHERE measured_at >= $1 AND measured_at < $1 + INTERVAL '15 minutes'
AND site_id = $2
GROUP BY site_id, time_bucket('15 minutes', measured_at);
```
---
## Timescale continuous aggregates (střídač → dashboard)
Nad `ems.telemetry_inverter` běží dva **continuous aggregate** (TimescaleDB); oba se periodicky obnovují (řádově každých 15 minut). Definice CA je ve **verzovaných** migracích (`V011`, `V039`); **view** nad CA držíme v **repeatable** souborech (`db/views/R__*.sql`), aby šla měnit jedna aktuální definice bez nové V migrace.
| Objekt | Bucket | View pro PostgREST / UI | Poznámka |
|--------|--------|-------------------------|----------|
| `ems.telemetry_inverter_hourly` | 1 hodina | `ems.vw_telemetry_hourly_7d` | CA a view v **V011**; `security_invoker` v **V031**. Hodinové trendy. |
| `ems.telemetry_inverter_15m` | 15 minut | `ems.vw_telemetry_15m_7d` | **`db/views/R__071_vw_telemetry_15m_7d.sql`** posledních 7 dní, zarovnání s 15min sloty přehledu. |
**Frontend přehled** (`frontend/src/hooks/useDashboardData.ts`): skutečné výkony a SoC po slotech bere z **`/vw_telemetry_15m_7d`** (klíč slotu = začátek 15min intervalu v UTC, stejně jako `floorSlotUtcMs` v grafu). Horní karty a **aktuální SoC** v grafu jsou dál z **`vw_site_status`** (poslední 1min vzorek) a z WebSocketu `/ws/telemetry`, aby „teď“ odpovídalo boxu i po refreshi agregátu.
**Plánovač** počáteční SoC nečte z těchto view bere poslední řádek z `ems.telemetry_inverter` (`planning_engine._load_site_context`).
---
## Konfigurace (env proměnné)
```env
TELEMETRY_POLL_INTERVAL_SEC=60
MODBUS_CONNECT_TIMEOUT_SEC=5
MODBUS_READ_TIMEOUT_SEC=3
```
`TELEMETRY_POLL_INTERVAL_SEC` a chybové prahy zatím nejsou v kódu používány;
smyčka běží každých 60 s přímo v `run_telemetry_loop_wrapper`.
---
## Otevřené body
- [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
- [ ] Optimalizovat čtení Deye jako jeden `read_holding_registers` blok místo jednotlivých registrů