feat(telemetry): idle-skip zápisů — neukládat 1min řádky idle zařízení
Some checks failed
CI and deploy / migration-check (push) Successful in 28s
CI and deploy / deploy (push) Failing after 17m56s

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>
This commit is contained in:
Dusan Vojacek
2026-06-12 19:06:41 +02:00
parent f71bc944b4
commit 815a233049
5 changed files with 405 additions and 30 deletions

View File

@@ -21,6 +21,57 @@ logger = logging.getLogger(__name__)
#: příjezdu EV). Nastavuje run_telemetry_loop_wrapper.
_BG_POOL: asyncpg.Pool | None = None
# ---------------------------------------------------------------------------
# Idle-skip zápisů telemetrie (slabý server)
# ---------------------------------------------------------------------------
#: Heartbeat: max. rozestup uložených vzorků při idle. 840 s < 900 s → každý
#: 15min bucket má ≥1 řádek (latest view, TUV teplota, teplota bazénu).
IDLE_SKIP_MAX_GAP_S = 840.0
#: klíč (tabulka, asset_id) → (signature, last_stored_at epoch s)
_IDLE_SKIP_STATE: dict[tuple[str, int], tuple[object, float]] = {}
def _idle_skip(
key: tuple[str, int],
signature: object,
is_active: bool,
now: float,
max_gap_s: float = IDLE_SKIP_MAX_GAP_S,
) -> bool:
"""True = vzorek PŘESKOČIT (idle, signature beze změny, heartbeat neuplynul).
Ukládá se vždy když: klíč je po startu procesu neznámý; zařízení je aktivní;
signature se změnila; nebo od posledního uložení uplynulo > max_gap_s.
Čtecí dotazy nad takto řidšími tabulkami musí používat sumy / gapfill,
ne avg přes přítomné řádky — viz docs/04-modules/telemetry.md (Idle-skip).
Střídač (telemetry_inverter) se NIKDY nepřeskakuje.
"""
state = _IDLE_SKIP_STATE.get(key)
if (
state is None
or is_active
or signature != state[0]
or now - state[1] > max_gap_s
):
_IDLE_SKIP_STATE[key] = (signature, now)
return False
return True
def _sig_round(value: float | None, step: float) -> float | None:
"""Kvantizace hodnoty do idle-skip signature (None propouští)."""
if value is None:
return None
return round(round(value / step) * step, 3)
#: In-memory poslední pozorovaný status EV konektoru (charger_id, connector_id).
#: Detekce příjezdu/odjezdu nesmí stát na posledním ŘÁDKU v DB — idle-skip řádky
#: ředí. Po startu procesu se seeduje z vw_latest_ev_charger (přechod během
#: výpadku backendu se pozná; prázdná DB → žádný falešný příjezd).
_EV_LAST_STATUS: dict[tuple[int, int], str] = {}
async def _on_ev_arrival(site_id: int, charger_code: str) -> None:
"""Okamžitý replan + export po příjezdu EV (jinak by se čekalo až na */15).
@@ -377,31 +428,43 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
code, frame["error_bits"], frame["warning_bits"],
)
previous_status = await db.fetchval(
"""
select status
from ems.telemetry_ev_charger
where charger_id = $1 and connector_id = $2
order by measured_at desc
limit 1
""",
charger_id,
connector_id,
)
state_key = (int(charger_id), connector_id)
previous_status = _EV_LAST_STATUS.get(state_key)
if previous_status is None:
# Start procesu: seed z posledního uloženého řádku (stejná sémantika
# jako dřívější čtení z telemetry_ev_charger; read přes view).
previous_status = await db.fetchval(
"""
select status from ems.vw_latest_ev_charger
where charger_id = $1 and connector_id = $2
""",
charger_id,
connector_id,
)
await db.execute(
"select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8, $8::float8)",
site_id,
charger_id,
measured_at,
connector_id,
current_status,
int(frame["power_w"]),
float(frame["session_energy_kwh"]),
float(frame["current_a"]),
)
power_w = int(frame["power_w"])
is_active = current_status != "available" or power_w > 50
signature = (current_status, round(power_w / 100.0) * 100)
if not _idle_skip(
("telemetry_ev_charger", int(charger_id)),
signature,
is_active,
measured_at.timestamp(),
):
await db.execute(
"select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8, $8::float8)",
site_id,
charger_id,
measured_at,
connector_id,
current_status,
power_w,
float(frame["session_energy_kwh"]),
float(frame["current_a"]),
)
if previous_status is not None:
_EV_LAST_STATUS[state_key] = current_status
if previous_status is not None and str(previous_status) != current_status:
await db.fetchval(
"select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
site_id,
@@ -496,6 +559,30 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
)
continue
water_out_c = _mim_temp_c(iu[MIM_OFF_WATER_OUT])
dhw_temp_c = _mim_temp_c(iu[MIM_OFF_DHW_TEMP])
water_in_c = _mim_temp_c(iu[MIM_OFF_WATER_IN])
room_temp_c = _mim_temp_c(iu[MIM_OFF_ROOM_TEMP])
defrost = bool(defrost_raw)
# Idle-skip: TČ vypnuté a teploty (na 0.2 °C) beze změny → heartbeat;
# pomalý drift teplot zachytí heartbeat 840 s (TUV delta se normalizuje
# per minutu ve fn_update_tuv_usage_stats).
is_active = defrost or mode_txt not in ("off",)
signature = (
mode_txt,
_sig_round(water_out_c, 0.2),
_sig_round(dhw_temp_c, 0.2),
_sig_round(water_in_c, 0.2),
_sig_round(room_temp_c, 0.2),
)
if _idle_skip(
("telemetry_heat_pump", int(row["id"])),
signature,
is_active,
measured_at.timestamp(),
):
continue
await db.execute(
"select ems.fn_telemetry_heat_pump_sample("
"$1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8,"
@@ -505,12 +592,12 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
measured_at,
None, # příkon: MIM neměří — doplní elektroměr (Shelly/Chint)
None, # venkovní teplota: v MIM mapě není
_mim_temp_c(iu[MIM_OFF_WATER_OUT]),
_mim_temp_c(iu[MIM_OFF_DHW_TEMP]),
water_out_c,
dhw_temp_c,
mode_txt,
_mim_temp_c(iu[MIM_OFF_WATER_IN]),
_mim_temp_c(iu[MIM_OFF_ROOM_TEMP]),
bool(defrost_raw),
water_in_c,
room_temp_c,
defrost,
error_code,
)
if error_code:
@@ -561,6 +648,14 @@ async def poll_loxone_sensors(site_id: int, db: asyncpg.Connection) -> None:
except Exception as e:
logger.warning("Loxone sensor %s read failed: %s", r["loxone_name"], e)
continue
# Idle-skip: čidlo nemá „aktivitu" — ukládá se změna ≥ 0.1 / heartbeat.
if _idle_skip(
("telemetry_loxone_sensor", int(r["id"])),
round(value, 1),
False,
measured_at.timestamp(),
):
continue
await db.execute(
"""
insert into ems.telemetry_loxone_sensor (sensor_id, measured_at, value)
@@ -786,6 +881,23 @@ async def poll_pool_pumps(site_id: int, db: asyncpg.Connection) -> None:
logger.warning("pool pump %s (%s) read failed: %s", code, base, e)
continue
# Idle-skip: zapnuté čerpadlo (is_on) se ukládá KAŽDOU minutu —
# vw_pool_pump_day_energy.on_minutes počítá ON řádky; vypnuté jen
# při změně / heartbeatu.
power_w = status.apower_w
is_active = bool(status.output) or (power_w is not None and power_w > 5)
signature = (
bool(status.output),
None if power_w is None else int(round(power_w / 10.0)) * 10,
)
if _idle_skip(
("telemetry_pool_pump", int(row["id"])),
signature,
is_active,
measured_at.timestamp(),
):
continue
await db.execute(
"select ems.fn_telemetry_pool_pump_sample($1::int, $2::int, $3::timestamptz, $4::boolean, $5::int, $6::bigint)",
site_id,

View File

@@ -0,0 +1,189 @@
"""Idle-skip zápisů telemetrie: _idle_skip + detekce příjezdu/odjezdu EV přes skip."""
from __future__ import annotations
import asyncio
import unittest
from unittest.mock import AsyncMock, patch
import services.telemetry_collector as tc
from services.telemetry_collector import (
IDLE_SKIP_MAX_GAP_S,
TELTO_REG_BLOCK_COUNT,
_idle_skip,
_sig_round,
)
class IdleSkipTests(unittest.TestCase):
KEY = ("telemetry_test", 1)
def setUp(self) -> None:
tc._IDLE_SKIP_STATE.clear()
def test_first_sample_after_start_is_stored(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
def test_unchanged_idle_sample_is_skipped(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1060.0))
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1120.0))
def test_signature_change_is_stored(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
self.assertFalse(_idle_skip(self.KEY, ("a", 100), False, 1060.0))
def test_active_device_is_always_stored(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), True, 1000.0))
self.assertFalse(_idle_skip(self.KEY, ("a", 0), True, 1060.0))
def test_heartbeat_after_max_gap(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
# přesně na hranici se ještě přeskakuje (> max_gap_s, ne >=)
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S))
self.assertFalse(
_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S + 1.0)
)
# heartbeat resetuje last_stored_at → další idle vzorek se zase přeskočí
self.assertTrue(
_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S + 61.0)
)
def test_keys_are_independent(self) -> None:
other = ("telemetry_test", 2)
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
self.assertFalse(_idle_skip(other, ("a", 0), False, 1060.0))
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1060.0))
def test_sig_round(self) -> None:
self.assertIsNone(_sig_round(None, 0.2))
self.assertEqual(_sig_round(47.31, 0.2), 47.4)
self.assertEqual(_sig_round(47.29, 0.2), 47.2)
self.assertEqual(_sig_round(-3.1, 0.2), -3.2)
def _frame_regs(status_raw: int, power_w: int = 0) -> list[int]:
regs = [0] * TELTO_REG_BLOCK_COUNT
regs[0] = 230
regs[6] = status_raw # 7 = available, 0 = charging
regs[38] = power_w
return regs
class _FakeBatch:
def __init__(self, regs: list[int]) -> None:
self._regs = regs
async def __aenter__(self) -> "_FakeBatch":
return self
async def __aexit__(self, *args: object) -> bool:
return False
async def read_holding_registers(self, start: int, count: int) -> list[int]:
return self._regs
class _FakeModbusClient:
def __init__(self) -> None:
self.regs: list[int] = _frame_regs(7)
def batch(self, unit_id: int) -> _FakeBatch:
return _FakeBatch(self.regs)
class _FakeDB:
"""Min. asyncpg.Connection náhrada pro poll_ev_chargers."""
def __init__(self, latest_status: str | None) -> None:
self.latest_status = latest_status
self.inserts: list[tuple] = []
self.transitions: list[tuple[str, str]] = []
async def fetch(self, query: str, *args: object) -> list[dict]:
return [{"id": 7, "code": "ev-charger-1", "host": "h", "port": 502, "unit_id": 1}]
async def fetchval(self, query: str, *args: object):
if "vw_latest_ev_charger" in query:
return self.latest_status
if "fn_ev_session_transition" in query:
self.transitions.append((str(args[2]), str(args[3])))
return None
raise AssertionError(f"unexpected fetchval: {query}")
async def execute(self, query: str, *args: object) -> None:
assert "fn_telemetry_ev_charger_sample" in query
self.inserts.append(args)
class EvArrivalSurvivesIdleSkipTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
tc._IDLE_SKIP_STATE.clear()
tc._EV_LAST_STATUS.clear()
async def _poll(self, db: _FakeDB, client: _FakeModbusClient) -> None:
with (
patch.object(tc, "get_modbus_client", AsyncMock(return_value=client)),
patch.object(tc, "_on_ev_arrival", AsyncMock()) as arrival,
patch.object(tc, "_on_ev_departure", AsyncMock()) as departure,
):
await tc.poll_ev_chargers(1, db) # type: ignore[arg-type]
await asyncio.sleep(0) # nechat doběhnout create_task
self.arrival_called = arrival.await_count > 0
self.departure_called = departure.await_count > 0
async def test_arrival_detected_after_skipped_idle_samples(self) -> None:
db = _FakeDB(latest_status=None)
client = _FakeModbusClient()
# 1. tick po startu: available → uloží se (prázdný stav), žádný příjezd
await self._poll(db, client)
self.assertEqual(len(db.inserts), 1)
self.assertFalse(self.arrival_called)
self.assertEqual(db.transitions, [])
# 2.3. tick: idle beze změny → řádky se přeskočí
await self._poll(db, client)
await self._poll(db, client)
self.assertEqual(len(db.inserts), 1)
# 4. tick: EV se připojí (charging) → insert + transition + arrival hook
client.regs = _frame_regs(0, power_w=11000)
await self._poll(db, client)
self.assertEqual(len(db.inserts), 2)
self.assertEqual(db.transitions, [("available", "charging")])
self.assertTrue(self.arrival_called)
# 5. tick: nabíjí dál (aktivní) → ukládá se každou minutu, bez transition
await self._poll(db, client)
self.assertEqual(len(db.inserts), 3)
self.assertEqual(len(db.transitions), 1)
# 6. tick: odpojení → departure
client.regs = _frame_regs(7)
await self._poll(db, client)
self.assertEqual(db.transitions[-1], ("charging", "available"))
self.assertTrue(self.departure_called)
async def test_no_false_arrival_after_restart(self) -> None:
# restart procesu: in-memory stav prázdný, DB má poslední řádek 'charging',
# nabíječka stále nabíjí → žádný falešný příjezd
db = _FakeDB(latest_status="charging")
client = _FakeModbusClient()
client.regs = _frame_regs(0, power_w=11000)
await self._poll(db, client)
self.assertFalse(self.arrival_called)
self.assertEqual(db.transitions, [])
async def test_transition_across_restart_detected(self) -> None:
# během výpadku backendu EV přijelo: DB 'available', teď 'charging'
db = _FakeDB(latest_status="available")
client = _FakeModbusClient()
client.regs = _frame_regs(0, power_w=11000)
await self._poll(db, client)
self.assertEqual(db.transitions, [("available", "charging")])
self.assertTrue(self.arrival_called)
if __name__ == "__main__":
unittest.main()

View File

@@ -127,6 +127,57 @@ Implementace: `backend/services/telemetry_collector.py` — `poll_inverter()` po
---
## 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

View File

@@ -5,6 +5,28 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
---
## 2026-06-12 — idle-skip telemetrie: TUV delta normalizovaná na °C/min
**Problém:** telemetry_collector nově přeskakuje 1min zápisy idle zařízení
(heartbeat 840 s — viz `telemetry.md`, sekce Idle-skip zápisů). Vstupy
plánovače čtené z těchto tabulek nesmí předpokládat hustou 1min řadu.
**Mechanismus:** `fn_update_tuv_usage_stats` (R__018) počítá deltu TUV jako
`(temp lag(temp)) / gap_min` (°C/min, mezery > 30 min vyloučeny) — pro
hustá 1min data numericky identické s původním per-row LAG; po idle-skip bez
až 14× nadhodnocení delty. Ostatní vstupy solveru (poslední TUV teplota v
`fn_planning_site_context`, poslední EV status v `fn_load_planning_slots_full`,
baseline stats) pokrývá heartbeat beze změny. Audit: EV/TČ `sum/15` v R__019.
**Soubory:** `telemetry_collector.py`, `R__018_fn_extended_planning.sql`,
`R__019_fn_fill_audit_interval.sql`, `R__097_vw_pool_pump.sql`, `PoolCard.tsx`,
`docs/04-modules/telemetry.md`.
**Ověření:** `tests/test_telemetry_idle_skip.py` (změna/aktivita/heartbeat/
start; EV arrival přežije skip i restart procesu); celá sada 303 passed.
---
## 2026-06-12 — v2 AKTIVNÍ v produkci + robustnostní trojice „nejistota jako cena"
**Přepnutí (847015f):** `PLANNING_ENGINE_VERSION` default **v2** v deploy compose; v1 běží

View File

@@ -51,9 +51,10 @@ export function PoolCard({ siteId }: { siteId: number }) {
if (!latest) return null
// Telemetrie je idle-skip: vypnuté čerpadlo zapisuje jen heartbeat po 14 min.
const stale =
latest.measured_at != null &&
Date.now() - new Date(latest.measured_at).getTime() > 5 * 60_000
Date.now() - new Date(latest.measured_at).getTime() > 16 * 60_000
const running = latest.is_on === true
const today = days[0]
@@ -67,7 +68,7 @@ export function PoolCard({ siteId }: { siteId: number }) {
className={`inline-block h-2.5 w-2.5 rounded-full ${
stale ? 'bg-slate-600' : running ? 'bg-emerald-400' : 'bg-slate-500'
}`}
title={stale ? 'telemetrie >5 min stará' : running ? 'běží' : 'stojí'}
title={stale ? 'telemetrie >16 min stará' : running ? 'běží' : 'stojí'}
/>
</div>
<span className="text-xs text-slate-400">