feat: hardware sensor anomalies feed the summon pipeline (Workstream K)
Adds backend/app/device_anomaly.py — per-(user_id, device_id, sensor_type) rolling-baseline anomaly detection for continuous numeric sensors (structurally modeled on telemetry.detect_wire_spike: min samples, an absolute floor, 3-sigma + relative threshold, with per-sensor-type floors since units vary wildly) plus a false->true state-transition detector for discrete/boolean sensors like presence. Adds a module-level active-session registry in app/ws.py (register_active_session/unregister_active_session/get_active_session) so hardware ingestion (a plain HTTP call, not a WS connection) can find a user's live SeanceState. process_device_reading_for_summon(user_id, device_id, sensor_type, value, unit) is the self-contained entry point Workstream G's ingestion handler will call into: classifies numeric vs. boolean, runs the reading through the right detector, and on a genuine anomaly pushes it into the active session via the existing _handle_anomaly path (source=sensor_type, frequency=stable per-sensor-type constant, magnitude=deviation-from- baseline or a fixed constant for boolean transitions) — reusing the full existing signature/mint/Codex pipeline, no new mint logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
294
backend/app/device_anomaly.py
Normal file
294
backend/app/device_anomaly.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Hardware sensor anomalies: the sixth summon source (spec §"Contract",
|
||||
Workstream K). Paired ESP32 devices stream readings through Workstream G's
|
||||
`POST /api/device/telemetry` ingestion endpoint; this module is the
|
||||
self-contained detection+push logic that endpoint calls into per reading.
|
||||
Nothing here talks HTTP or owns the `Device` model — it is pure detection
|
||||
state plus a thin bridge into the existing `_handle_anomaly` séance
|
||||
pipeline in `app.ws`, so hardware summons reuse 100% of the existing
|
||||
signature/mint/Codex machinery.
|
||||
|
||||
Two detectors, one per sensor shape:
|
||||
|
||||
* Continuous numeric sensors (temperature, humidity, pressure, and any
|
||||
future numeric sensor_type) get a rolling-baseline statistical detector,
|
||||
structurally the same three guards as `telemetry.detect_wire_spike`:
|
||||
minimum sample count, an absolute floor, and a 3-sigma + relative
|
||||
threshold. The exact floor/threshold numbers are adapted per sensor_type
|
||||
since units vary wildly (see `_deviation_floor` below) — a 20KB/s floor
|
||||
makes no sense for a temperature in degrees C.
|
||||
|
||||
* Discrete/boolean sensors (presence, contact switches, etc.) get a simple
|
||||
false->true state-transition detector — a spike in a 0/1 signal isn't
|
||||
"statistically anomalous" in any meaningful sense, it's just a change of
|
||||
state, and only one direction of that change (something appearing) is
|
||||
the anomaly we care about.
|
||||
|
||||
Rolling per-key history/state lives in this module as plain in-process
|
||||
dicts, matching how `detect_wire_spike`'s caller (`SeanceState.
|
||||
wire_jitter_history`) manages its own history today — no external store,
|
||||
doesn't need to survive a restart.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from statistics import pstdev
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Numeric (continuous) detector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# How many readings before a sensor's baseline is trusted. Matches
|
||||
# detect_wire_spike's default (6) — devices report roughly once/second per
|
||||
# the contract's rate cap, so 6 samples is a handful of seconds of warm-up,
|
||||
# not a long cold-start.
|
||||
DEFAULT_MIN_SAMPLES = 6
|
||||
|
||||
# How much history to retain per (user, device, sensor_type) key. Bounded so
|
||||
# a chatty device can't grow this dict without limit.
|
||||
MAX_HISTORY = 60
|
||||
|
||||
# Per-sensor-type absolute floors on |current - mean| deviation, in the
|
||||
# sensor's own units. Unlike detect_wire_spike's floor (a floor on the raw
|
||||
# value itself — near-silent byte counters should stay silent), a generic
|
||||
# numeric sensor can legitimately sit at any value including zero or
|
||||
# negative (temperature), so the floor here is on the *deviation*, not the
|
||||
# reading. These numbers are a deliberately generous noise band per sensor
|
||||
# type — reasoned from typical cheap-sensor (BME280-class) precision and
|
||||
# ordinary ambient drift, not a spec:
|
||||
# temperature (C): +-0.8 is well within a room's normal thermal drift
|
||||
# (HVAC cycling, a door opening) and BME280 datasheet noise.
|
||||
# humidity (%RH): +-3 is a normal hygrometer noise band.
|
||||
# pressure (hPa): +-1.5 is routine weather drift over the timescale of
|
||||
# a rolling baseline; BME280 itself is accurate to ~1hPa.
|
||||
_KNOWN_DEVIATION_FLOORS: dict[str, float] = {
|
||||
"temperature": 0.8,
|
||||
"humidity": 3.0,
|
||||
"pressure": 1.5,
|
||||
}
|
||||
|
||||
# For an unrecognized sensor_type (contract: sensor_type is free-form, not
|
||||
# an enum — "add all sorts of sensors... anything you can think of"), we
|
||||
# have no unit-specific noise band to reach for. Fall back to a floor
|
||||
# proportional to the rolling mean's own magnitude, with a small absolute
|
||||
# minimum so a baseline near zero doesn't collapse the floor to nothing.
|
||||
_DEFAULT_FLOOR_FRACTION = 0.15
|
||||
_DEFAULT_FLOOR_MINIMUM = 0.01
|
||||
|
||||
# The relative half of the "3-sigma + relative" guard. detect_wire_spike
|
||||
# requires the surge be >2.5x the mean; that multiplier doesn't translate
|
||||
# cleanly to arbitrary (possibly signed, possibly near-zero-mean) sensor
|
||||
# units, so here the relative check is expressed as a multiple of the
|
||||
# deviation floor instead — "not just past the noise floor, comfortably
|
||||
# past it" plays the same role as "not just above baseline, 2.5x above it".
|
||||
RELATIVE_FLOOR_MULTIPLIER = 2.5
|
||||
|
||||
|
||||
def _deviation_floor(sensor_type: str, mean: float) -> float:
|
||||
if sensor_type in _KNOWN_DEVIATION_FLOORS:
|
||||
return _KNOWN_DEVIATION_FLOORS[sensor_type]
|
||||
return max(abs(mean) * _DEFAULT_FLOOR_FRACTION, _DEFAULT_FLOOR_MINIMUM)
|
||||
|
||||
|
||||
def detect_sensor_spike(
|
||||
history: list[float],
|
||||
current: float,
|
||||
sensor_type: str,
|
||||
*,
|
||||
min_samples: int = DEFAULT_MIN_SAMPLES,
|
||||
) -> float | None:
|
||||
"""Pure detector, modeled directly on `telemetry.detect_wire_spike`.
|
||||
|
||||
Returns the deviation magnitude (in the sensor's own units) when
|
||||
`current` is anomalous vs. the rolling baseline, or None. Same three
|
||||
guards as the wire detector: enough history, a floor so sensor noise
|
||||
never cries ghost, and a combined statistical + relative threshold.
|
||||
"""
|
||||
if len(history) < min_samples:
|
||||
return None
|
||||
mean = sum(history) / len(history)
|
||||
deviation = current - mean
|
||||
abs_deviation = abs(deviation)
|
||||
|
||||
floor = _deviation_floor(sensor_type, mean)
|
||||
if abs_deviation < floor:
|
||||
return None
|
||||
|
||||
std = pstdev(history)
|
||||
if abs_deviation > 3 * std and abs_deviation > floor * RELATIVE_FLOOR_MULTIPLIER:
|
||||
return abs_deviation
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boolean / discrete (state-transition) detector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def detect_boolean_transition(previous: bool | None, current: bool) -> bool:
|
||||
"""The anomaly is a false->true transition (e.g. `presence` going from
|
||||
nothing detected to something detected). `previous=None` means we have
|
||||
no prior reading for this key yet — nothing to transition *from*, so
|
||||
it is never itself an anomaly (avoids flagging every device's very
|
||||
first reading just because it happens to be `True`)."""
|
||||
return previous is False and current is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-(user_id, device_id, sensor_type) rolling state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HistoryKey = tuple[uuid.UUID, uuid.UUID, str]
|
||||
|
||||
# In-memory only, matching detect_wire_spike's caller-managed history in
|
||||
# ws.py — does not need to survive a process restart.
|
||||
_numeric_history: dict[_HistoryKey, list[float]] = {}
|
||||
_boolean_state: dict[_HistoryKey, bool] = {}
|
||||
|
||||
|
||||
def _key(user_id: uuid.UUID, device_id: uuid.UUID, sensor_type: str) -> _HistoryKey:
|
||||
return (user_id, device_id, sensor_type)
|
||||
|
||||
|
||||
def reset_state() -> None:
|
||||
"""Test/debug helper: clear all rolling history and boolean state."""
|
||||
_numeric_history.clear()
|
||||
_boolean_state.clear()
|
||||
|
||||
|
||||
def _record_numeric(
|
||||
user_id: uuid.UUID, device_id: uuid.UUID, sensor_type: str, value: float
|
||||
) -> float | None:
|
||||
key = _key(user_id, device_id, sensor_type)
|
||||
history = _numeric_history.setdefault(key, [])
|
||||
deviation = detect_sensor_spike(history, value, sensor_type)
|
||||
history.append(value)
|
||||
del history[:-MAX_HISTORY]
|
||||
return deviation
|
||||
|
||||
|
||||
def _record_boolean(user_id: uuid.UUID, device_id: uuid.UUID, sensor_type: str, value: bool) -> bool:
|
||||
key = _key(user_id, device_id, sensor_type)
|
||||
previous = _boolean_state.get(key)
|
||||
anomalous = detect_boolean_transition(previous, value)
|
||||
_boolean_state[key] = value
|
||||
return anomalous
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Numeric vs. boolean classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Rule for telling a boolean/discrete sensor apart from a continuous
|
||||
# numeric one: the contract's own example reading marks boolean sensors
|
||||
# with `unit: "bool"` (`{"sensor_type": "presence", "value": 1, "unit":
|
||||
# "bool", ...}`), so that's the primary, explicit signal — a device author
|
||||
# opts a sensor into transition semantics by declaring its unit as such.
|
||||
# `python`/`bool` `value` types are treated the same way as a convenience
|
||||
# for callers that already have a real bool in hand (e.g. Workstream G's
|
||||
# ingestion handler after its own shape validation). A bare numeric 0/1
|
||||
# with a *non*-bool unit (e.g. a duty-cycle percentage that happens to read
|
||||
# 0 or 1) is intentionally left as numeric — guessing boolean from value
|
||||
# alone would silently reinterpret real numeric sensors whenever they
|
||||
# happened to read exactly 0 or 1.
|
||||
_BOOL_UNITS = {"bool", "boolean"}
|
||||
|
||||
|
||||
def is_boolean_sensor(unit: str, value: float | bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return True
|
||||
return unit.strip().lower() in _BOOL_UNITS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-sensor-type frequency/magnitude mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A stable per-sensor-type "frequency" so the same sensor_type always
|
||||
# fingerprints into the same signature bucket (see
|
||||
# `app.entities.signature_from_anomalies`, which buckets anomalies by the
|
||||
# digit-count of their frequency). Known sensor types get a hand-picked
|
||||
# value; anything else (sensor_type is free-form per the contract) gets a
|
||||
# deterministic hash-derived value in the same rough order of magnitude, so
|
||||
# a brand-new sensor_type nobody wrote a case for still fingerprints
|
||||
# consistently every time rather than randomly.
|
||||
_KNOWN_FREQUENCIES: dict[str, float] = {
|
||||
"presence": 66.6,
|
||||
"temperature": 111.0,
|
||||
"humidity": 222.0,
|
||||
"pressure": 333.0,
|
||||
}
|
||||
|
||||
|
||||
def frequency_for_sensor_type(sensor_type: str) -> float:
|
||||
if sensor_type in _KNOWN_FREQUENCIES:
|
||||
return _KNOWN_FREQUENCIES[sensor_type]
|
||||
digest = hashlib.sha1(sensor_type.encode()).hexdigest()[:6]
|
||||
return 50.0 + (int(digest, 16) % 950)
|
||||
|
||||
|
||||
# Fixed magnitude for a boolean-transition anomaly: there is no continuous
|
||||
# deviation to measure (the signal is 0/1), so a representative constant
|
||||
# stands in — chosen mid-range against the magnitudes the other four modes
|
||||
# typically produce (see `test_ws_session.py`'s anomaly fixtures, roughly
|
||||
# single-to-low-double digits) so a presence trip reads as a normal-sized
|
||||
# anomaly rather than a suspiciously flat or huge one.
|
||||
BOOLEAN_ANOMALY_MAGNITUDE = 8.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — called from Workstream G's ingestion handoff point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def process_device_reading_for_summon(
|
||||
user_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
sensor_type: str,
|
||||
value: float | bool,
|
||||
unit: str,
|
||||
) -> None:
|
||||
"""Run one ingested device reading through the appropriate anomaly
|
||||
detector and, if it's anomalous, push it into the owning user's live
|
||||
séance (if any) exactly the way the four browser-based modes already
|
||||
do — via `app.ws._handle_anomaly`, so hardware summons reuse the whole
|
||||
existing signature/mint/Codex pipeline with no new mint logic.
|
||||
|
||||
This is meant to be called (awaited) from Workstream G's
|
||||
`POST /api/device/telemetry` handler, once per reading in the batch,
|
||||
after that endpoint's own shape/auth validation — it does no HTTP or
|
||||
auth work of its own. If the user has no active séance session open
|
||||
(the common case — most readings arrive with no one watching), this is
|
||||
a normal no-op, not an error.
|
||||
|
||||
Imports `app.ws` lazily-at-module-level would create no cycle today
|
||||
(ws.py does not import this module), but the import is written as a
|
||||
plain top-of-function local import anyway to keep this module trivially
|
||||
importable/testable in isolation from the full ws.py dependency graph
|
||||
(DB engine, LLM service, TTS, etc.) for anyone who only wants the pure
|
||||
detector functions above.
|
||||
"""
|
||||
from app.ws import _handle_anomaly, get_active_session # noqa: PLC0415
|
||||
|
||||
if is_boolean_sensor(unit, value):
|
||||
anomalous = _record_boolean(user_id, device_id, sensor_type, bool(value))
|
||||
magnitude = BOOLEAN_ANOMALY_MAGNITUDE
|
||||
else:
|
||||
deviation = _record_numeric(user_id, device_id, sensor_type, float(value))
|
||||
anomalous = deviation is not None
|
||||
magnitude = round(deviation, 2) if deviation is not None else None
|
||||
|
||||
if not anomalous:
|
||||
return
|
||||
|
||||
state = get_active_session(user_id)
|
||||
if state is None:
|
||||
return
|
||||
|
||||
await _handle_anomaly(
|
||||
state,
|
||||
{
|
||||
"source": sensor_type,
|
||||
"frequency": frequency_for_sensor_type(sensor_type),
|
||||
"magnitude": magnitude,
|
||||
},
|
||||
)
|
||||
@@ -88,6 +88,36 @@ class SeanceState:
|
||||
last_wire_anomaly_at: float = 0.0
|
||||
|
||||
|
||||
# Active-session registry (spec: ESP32 sensor node, Workstream K): maps a
|
||||
# user to their live SeanceState so hardware ingestion (a plain HTTP
|
||||
# request, not a WS connection) can find "does this user have a séance
|
||||
# open right now" and push a hardware anomaly into it. Single
|
||||
# most-recent-session mapping — if a user somehow has two `/ws/session`
|
||||
# tabs open concurrently, the newer one wins the registry slot; that's a
|
||||
# reasonable v1 (see spec's Contract section) since a user's attention is
|
||||
# realistically in one tab at a time. Plain in-process dict, same reasoning
|
||||
# as everywhere else in this file: no external store needed at this scale,
|
||||
# doesn't need to survive a restart.
|
||||
_active_sessions: dict[uuid.UUID, "SeanceState"] = {}
|
||||
|
||||
|
||||
def register_active_session(user_id: uuid.UUID, state: "SeanceState") -> None:
|
||||
_active_sessions[user_id] = state
|
||||
|
||||
|
||||
def unregister_active_session(user_id: uuid.UUID, state: "SeanceState") -> None:
|
||||
# Only remove if it's still *this* state — guards against a rare
|
||||
# overlap where an older session's disconnect cleanup runs after a
|
||||
# newer session for the same user has already registered, which would
|
||||
# otherwise wipe out the newer (still-live) registry entry.
|
||||
if _active_sessions.get(user_id) is state:
|
||||
del _active_sessions[user_id]
|
||||
|
||||
|
||||
def get_active_session(user_id: uuid.UUID) -> "SeanceState | None":
|
||||
return _active_sessions.get(user_id)
|
||||
|
||||
|
||||
def serialize_entity(entity: Entity) -> dict:
|
||||
return {
|
||||
"id": str(entity.id),
|
||||
@@ -432,6 +462,7 @@ async def session_socket(websocket: WebSocket) -> None:
|
||||
session_id=contact_session.id,
|
||||
client_ip=_client_ip(websocket),
|
||||
)
|
||||
register_active_session(user_id, state)
|
||||
sender = asyncio.create_task(_sender(state, websocket))
|
||||
await state.send_queue.put({"type": "session", "id": str(contact_session.id)})
|
||||
|
||||
@@ -468,6 +499,7 @@ async def session_socket(websocket: WebSocket) -> None:
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
unregister_active_session(user_id, state)
|
||||
if state.ambient_task is not None:
|
||||
state.ambient_task.cancel()
|
||||
sender.cancel()
|
||||
|
||||
Reference in New Issue
Block a user