Skip to content

Commit 8a907a5

Browse files
KMX415javastraat
andcommitted
Downsample telemetry and signal history into time buckets.
Keeps node charts bounded by limit without silently dropping the newest samples. From javastraat/meshpoint. Co-Authored-By: Albert Einstein <javastraat@hotmail.com>
1 parent dfea6fb commit 8a907a5

4 files changed

Lines changed: 145 additions & 6 deletions

File tree

src/storage/packet_repository.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from src.models.packet import Packet, PacketType, Protocol
88
from src.models.signal import SignalMetrics
99
from src.storage.database import DatabaseManager
10+
from src.storage.time_bucket import bucket_seconds
1011

1112
logger = logging.getLogger(__name__)
1213

@@ -63,19 +64,31 @@ async def get_signal_history(
6364
limit: int = 500,
6465
hours: float | None = 24,
6566
) -> list[dict]:
66-
"""RSSI/SNR samples from any packet by this node, oldest-first."""
67+
"""RSSI/SNR samples from any packet by this node, oldest-first.
68+
69+
When ``hours`` is set, samples are averaged into at most ``limit``
70+
time buckets so newest points are not dropped by a plain LIMIT.
71+
"""
6772
if hours is not None and hours > 0:
6873
since = (
6974
datetime.now(timezone.utc) - timedelta(hours=hours)
7075
).isoformat()
76+
span_row = await self._db.fetch_one(
77+
"SELECT MIN(timestamp) AS lo, MAX(timestamp) AS hi FROM packets "
78+
"WHERE source_id = ? AND rssi IS NOT NULL AND timestamp >= ?",
79+
(source_id, since),
80+
)
81+
bucket_secs = bucket_seconds(span_row, limit, hours)
7182
rows = await self._db.fetch_all(
7283
"""
73-
SELECT timestamp, rssi, snr FROM packets
84+
SELECT MIN(timestamp) AS timestamp, AVG(rssi) AS rssi, AVG(snr) AS snr
85+
FROM packets
7486
WHERE source_id = ? AND rssi IS NOT NULL AND timestamp >= ?
87+
GROUP BY CAST(strftime('%s', timestamp) AS INTEGER) / ?
7588
ORDER BY timestamp ASC
7689
LIMIT ?
7790
""",
78-
(source_id, since, limit),
91+
(source_id, since, bucket_secs, limit),
7992
)
8093
else:
8194
rows = await self._db.fetch_all(

src/storage/telemetry_repository.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from src.models.telemetry import Telemetry
77
from src.storage.database import DatabaseManager
8+
from src.storage.time_bucket import bucket_seconds
89

910
logger = logging.getLogger(__name__)
1011

@@ -49,19 +50,42 @@ async def get_history(
4950
limit: int = 300,
5051
hours: float | None = None,
5152
) -> list[Telemetry]:
52-
"""Return telemetry oldest-first for charting (ASC)."""
53+
"""Return telemetry oldest-first for charting (ASC).
54+
55+
When ``hours`` is set, rows are averaged into at most ``limit``
56+
time buckets across the real data span so newest samples are not
57+
silently dropped by a plain LIMIT.
58+
"""
5359
if hours is not None and hours > 0:
5460
since = (
5561
datetime.now(timezone.utc) - timedelta(hours=hours)
5662
).isoformat()
63+
span_row = await self._db.fetch_one(
64+
"SELECT MIN(timestamp) AS lo, MAX(timestamp) AS hi "
65+
"FROM telemetry WHERE node_id = ? AND timestamp >= ?",
66+
(node_id, since),
67+
)
68+
bucket_secs = bucket_seconds(span_row, limit, hours)
5769
rows = await self._db.fetch_all(
5870
"""
59-
SELECT * FROM telemetry
71+
SELECT
72+
node_id,
73+
AVG(battery_level) AS battery_level,
74+
AVG(voltage) AS voltage,
75+
AVG(temperature) AS temperature,
76+
AVG(humidity) AS humidity,
77+
AVG(barometric_pressure) AS barometric_pressure,
78+
AVG(channel_utilization) AS channel_utilization,
79+
AVG(air_util_tx) AS air_util_tx,
80+
AVG(uptime_seconds) AS uptime_seconds,
81+
MIN(timestamp) AS timestamp
82+
FROM telemetry
6083
WHERE node_id = ? AND timestamp >= ?
84+
GROUP BY CAST(strftime('%s', timestamp) AS INTEGER) / ?
6185
ORDER BY timestamp ASC
6286
LIMIT ?
6387
""",
64-
(node_id, since, limit),
88+
(node_id, since, bucket_secs, limit),
6589
)
6690
else:
6791
rows = await self._db.fetch_all(

src/storage/time_bucket.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Shared time-bucketing helper for downsampled history queries.
2+
3+
Used by TelemetryRepository.get_history() and
4+
PacketRepository.get_signal_history() so a long-lived node's chart data
5+
stays bounded to roughly ``limit`` points instead of a plain LIMIT
6+
silently dropping the newest rows.
7+
8+
Credit: javastraat/meshpoint ``b10610a``.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from datetime import datetime
14+
15+
16+
def bucket_seconds(span_row: dict | None, limit: int, hours: float) -> int:
17+
"""Bucket width in seconds for a downsampled history query.
18+
19+
Derived from the actual span of matching data (``lo``/``hi``) rather
20+
than the requested ``hours`` window, so over-sized request windows
21+
do not crush a short real history into coarse buckets. Floored at
22+
60 seconds.
23+
"""
24+
limit = max(limit, 1)
25+
lo = span_row.get("lo") if span_row else None
26+
hi = span_row.get("hi") if span_row else None
27+
if lo and hi:
28+
span = (
29+
datetime.fromisoformat(hi) - datetime.fromisoformat(lo)
30+
).total_seconds()
31+
if span > 0:
32+
return max(60, int(span / limit))
33+
return max(60, int((hours * 3600) / limit))

tests/test_time_bucket.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Unit tests for history time-bucket width helper.
2+
3+
Credit: javastraat/meshpoint ``b10610a``.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import unittest
9+
from datetime import datetime, timedelta, timezone
10+
11+
from src.models.telemetry import Telemetry
12+
from src.storage.database import DatabaseManager
13+
from src.storage.telemetry_repository import TelemetryRepository
14+
from src.storage.time_bucket import bucket_seconds
15+
16+
17+
class BucketSecondsTest(unittest.TestCase):
18+
def test_uses_actual_span_not_requested_hours(self):
19+
lo = datetime(2026, 1, 1, tzinfo=timezone.utc)
20+
hi = lo + timedelta(hours=2)
21+
secs = bucket_seconds(
22+
{"lo": lo.isoformat(), "hi": hi.isoformat()},
23+
limit=10,
24+
hours=100000,
25+
)
26+
self.assertEqual(secs, max(60, int((2 * 3600) / 10)))
27+
28+
def test_falls_back_to_hours_when_no_span(self):
29+
self.assertEqual(bucket_seconds(None, limit=10, hours=1), 360)
30+
31+
def test_floor_at_sixty_seconds(self):
32+
lo = datetime(2026, 1, 1, tzinfo=timezone.utc)
33+
hi = lo + timedelta(seconds=30)
34+
self.assertEqual(
35+
bucket_seconds(
36+
{"lo": lo.isoformat(), "hi": hi.isoformat()},
37+
limit=100,
38+
hours=1,
39+
),
40+
60,
41+
)
42+
43+
44+
class TelemetryHistoryBucketTest(unittest.IsolatedAsyncioTestCase):
45+
async def asyncSetUp(self):
46+
self.db = DatabaseManager(":memory:")
47+
await self.db.connect()
48+
self.repo = TelemetryRepository(self.db)
49+
50+
async def asyncTearDown(self):
51+
await self.db.disconnect()
52+
53+
async def test_hours_path_keeps_newest_when_over_limit(self):
54+
now = datetime.now(timezone.utc)
55+
for i in range(20):
56+
await self.repo.insert(
57+
Telemetry(
58+
node_id="n1",
59+
temperature=float(i),
60+
timestamp=now - timedelta(minutes=19 - i),
61+
)
62+
)
63+
rows = await self.repo.get_history("n1", limit=5, hours=24)
64+
self.assertLessEqual(len(rows), 5)
65+
self.assertGreaterEqual(rows[-1].temperature, 15.0)
66+
67+
68+
if __name__ == "__main__":
69+
unittest.main()

0 commit comments

Comments
 (0)