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>
This commit is contained in:
@@ -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,
|
||||
|
||||
189
backend/tests/test_telemetry_idle_skip.py
Normal file
189
backend/tests/test_telemetry_idle_skip.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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ěží
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user