Spaces:
Sleeping
Sleeping
File size: 7,773 Bytes
24b683e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | """
telemetry_buffer.py
Per-robot-per-metric rolling buffers used by the agent graph's robot
poll loop. Each robot poll writes one reading into each metric's buffer;
when a buffer reaches WINDOW_SIZE, it becomes eligible for an EML
symbolic-law fit.
Also caches the most recent successful fit per (robot, metric) so that
graph nodes created from telemetry can carry the law as an attribute.
Design notes
------------
- Buffer is a deque(maxlen=WINDOW_SIZE), so old samples are evicted
automatically — no manual trimming.
- We rate-limit fits to at most one per FIT_COOLDOWN_SEC per
(robot, metric). A 32-sample window at 30 s polling is ~16 minutes of
data; refitting more often than every 10 minutes is wasteful.
- A fit is only eligible if the buffer is full AND the cooldown has
elapsed AND at least REFIT_NEW_SAMPLES new readings have arrived
since the last fit. This prevents busy-loops on constant signals.
- All timestamps are monotonic seconds (time.monotonic() from the
process start), which is sufficient for EML's relative-time inputs
and avoids wall-clock skew issues.
"""
from __future__ import annotations
import logging
import time
from collections import deque
from dataclasses import dataclass, field
from threading import Lock
from typing import Iterable
log = logging.getLogger("telemetry_buffer")
# ---------------------------------------------------------------------------
# Tunables
# ---------------------------------------------------------------------------
WINDOW_SIZE = 32 # readings per metric before a fit is triggered
FIT_COOLDOWN_SEC = 600 # 10 minutes between fits on the same (robot, metric)
REFIT_NEW_SAMPLES = 8 # require N new samples since last fit before next
# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------
@dataclass
class Reading:
"""A single polled telemetry value with its monotonic timestamp."""
t: float
v: float
@dataclass
class Law:
"""The most recent successful EML fit for a (robot, metric) pair."""
expression: str # simplified form ("0.5*log(t) + 10")
raw_expression: str # raw eml(...) form
final_loss: float
tree_size: int
depth_used: int
fit_at: float # monotonic time when fit was recorded
n_samples_when_fit: int # how many readings had been seen when fit
@dataclass
class _BufferEntry:
"""Per-(robot, metric) state."""
readings: deque = field(default_factory=lambda: deque(maxlen=WINDOW_SIZE))
last_fit_at: float = 0.0
samples_at_last_fit: int = 0
total_samples: int = 0 # lifetime count for refit gating
law: Law | None = None
# ---------------------------------------------------------------------------
# Main registry
# ---------------------------------------------------------------------------
class TelemetryBuffers:
"""Thread-safe registry of per-(robot, metric) buffers.
Mutations happen on the poll loop's thread (asyncio single-threaded
in this Space), but reads may happen from other loops (expand, WS
broadcast). The lock is cheap insurance.
"""
def __init__(self) -> None:
self._entries: dict[tuple[str, str], _BufferEntry] = {}
self._lock = Lock()
# -- mutations --------------------------------------------------------
def add(self, robot_id: str, metric: str, value: float | None) -> None:
"""Append one reading. Silently ignores None values."""
if value is None:
return
key = (str(robot_id), str(metric))
now = time.monotonic()
with self._lock:
entry = self._entries.setdefault(key, _BufferEntry())
entry.readings.append(Reading(t=now, v=float(value)))
entry.total_samples += 1
def record_fit(
self,
robot_id: str,
metric: str,
*,
expression: str,
raw_expression: str,
final_loss: float,
tree_size: int,
depth_used: int,
) -> None:
"""Cache a successful EML fit on the buffer."""
key = (str(robot_id), str(metric))
now = time.monotonic()
with self._lock:
entry = self._entries.get(key)
if entry is None:
return
entry.law = Law(
expression=expression,
raw_expression=raw_expression,
final_loss=final_loss,
tree_size=tree_size,
depth_used=depth_used,
fit_at=now,
n_samples_when_fit=entry.total_samples,
)
entry.last_fit_at = now
entry.samples_at_last_fit = entry.total_samples
# -- gating queries ---------------------------------------------------
def should_fit(self, robot_id: str, metric: str) -> bool:
"""Is this (robot, metric) eligible for a new fit right now?"""
key = (str(robot_id), str(metric))
now = time.monotonic()
with self._lock:
entry = self._entries.get(key)
if entry is None:
return False
if len(entry.readings) < WINDOW_SIZE:
return False
if now - entry.last_fit_at < FIT_COOLDOWN_SEC and entry.last_fit_at > 0:
return False
new_since_fit = entry.total_samples - entry.samples_at_last_fit
if entry.last_fit_at > 0 and new_since_fit < REFIT_NEW_SAMPLES:
return False
return True
# -- reads ------------------------------------------------------------
def snapshot(
self, robot_id: str, metric: str
) -> tuple[list[float], list[float]] | None:
"""Return (timestamps, values) for the current buffer contents."""
key = (str(robot_id), str(metric))
with self._lock:
entry = self._entries.get(key)
if entry is None or not entry.readings:
return None
ts = [r.t for r in entry.readings]
vs = [r.v for r in entry.readings]
# Normalise time to start at 0 for the fitter — keeps inputs small.
t0 = ts[0]
return [t - t0 for t in ts], vs
def get_law(self, robot_id: str, metric: str) -> Law | None:
"""Return the most recent cached law for this (robot, metric), if any."""
key = (str(robot_id), str(metric))
with self._lock:
entry = self._entries.get(key)
return entry.law if entry else None
def all_laws_for_robot(self, robot_id: str) -> dict[str, Law]:
"""All known laws for a robot, keyed by metric name."""
out: dict[str, Law] = {}
rid = str(robot_id)
with self._lock:
for (r, m), entry in self._entries.items():
if r == rid and entry.law:
out[m] = entry.law
return out
# -- maintenance ------------------------------------------------------
def prune_robot(self, robot_id: str) -> None:
"""Forget all state for a robot (e.g. when it goes offline)."""
rid = str(robot_id)
with self._lock:
for key in [k for k in self._entries if k[0] == rid]:
del self._entries[key]
def tracked_metrics(self, robot_id: str) -> Iterable[str]:
rid = str(robot_id)
with self._lock:
return [m for (r, m) in self._entries if r == rid]
# Module-level singleton (matches the pattern used elsewhere in the Space).
telemetry_buffers = TelemetryBuffers()
__all__ = [
"TelemetryBuffers",
"telemetry_buffers",
"Law",
"Reading",
"WINDOW_SIZE",
"FIT_COOLDOWN_SEC",
"REFIT_NEW_SAMPLES",
]
|