Files
qtalker---/backend/tests/test_device_anomaly.py
Indiana 004f66171f 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>
2026-07-24 01:14:11 +00:00

256 lines
9.5 KiB
Python

import uuid
import pytest
import app.ws as ws_module
from app.device_anomaly import (
BOOLEAN_ANOMALY_MAGNITUDE,
detect_boolean_transition,
detect_sensor_spike,
frequency_for_sensor_type,
is_boolean_sensor,
process_device_reading_for_summon,
reset_state,
)
from app.models.contact_session import ContactSession
from app.models.user import User
from app.ws import SeanceState, get_active_session, register_active_session
from .conftest import TestSessionLocal
@pytest.fixture(autouse=True)
def _clear_device_anomaly_state():
reset_state()
yield
reset_state()
# ---------------------------------------------------------------------------
# detect_sensor_spike — same three-guard shape as detect_wire_spike
# ---------------------------------------------------------------------------
def test_numeric_spike_needs_history():
assert detect_sensor_spike([], 99.0, "temperature") is None
assert detect_sensor_spike([21.0, 21.1, 21.0], 30.0, "temperature") is None
def test_numeric_spike_fires_on_real_surge():
history = [21.0, 21.1, 20.9, 21.2, 21.0, 21.1, 20.8]
deviation = detect_sensor_spike(history, 30.0, "temperature")
assert deviation is not None
assert deviation > 8.0
def test_numeric_spike_ignores_normal_fluctuation():
history = [21.0, 21.1, 20.9, 21.2, 21.0, 21.1, 20.8]
assert detect_sensor_spike(history, 21.3, "temperature") is None
def test_numeric_spike_has_absolute_floor_for_quiet_sensors():
# A rock-steady baseline with a tiny wobble must never cry ghost, even
# if that wobble technically clears a 3-sigma bar against near-zero std.
history = [20.00, 20.01, 19.99, 20.00, 20.02, 19.98, 20.01]
assert detect_sensor_spike(history, 20.30, "temperature") is None
def test_numeric_spike_adapts_to_loud_baseline():
# Once the sensor is already swinging widely, the same absolute jump is
# no longer anomalous relative to its own noisy baseline.
history = [15.0, 22.0, 14.0, 23.0, 16.0, 21.0, 15.5]
assert detect_sensor_spike(history, 24.0, "temperature") is None
def test_numeric_spike_uses_known_floor_per_sensor_type():
# Humidity's floor (3.0) is looser than temperature's (0.8) — a
# deviation that would fire for temperature must not fire for humidity.
history = [45.0, 46.0, 44.5, 45.5, 45.0, 44.8, 45.2]
assert detect_sensor_spike(history, 47.6, "humidity") is None
assert detect_sensor_spike(history, 47.6, "temperature") is not None
def test_numeric_spike_unknown_sensor_type_uses_relative_fallback_floor():
# No hand-picked floor for "voltage" — falls back to a fraction of the
# rolling mean, but a genuine multi-fold surge still fires.
history = [5.0, 5.1, 4.9, 5.0, 5.05, 4.95, 5.02]
assert detect_sensor_spike(history, 5.1, "voltage") is None
assert detect_sensor_spike(history, 12.0, "voltage") is not None
# ---------------------------------------------------------------------------
# detect_boolean_transition
# ---------------------------------------------------------------------------
def test_boolean_transition_false_to_true_is_anomalous():
assert detect_boolean_transition(False, True) is True
def test_boolean_transition_no_prior_state_is_not_anomalous():
assert detect_boolean_transition(None, True) is False
assert detect_boolean_transition(None, False) is False
def test_boolean_transition_true_to_true_is_not_anomalous():
assert detect_boolean_transition(True, True) is False
def test_boolean_transition_true_to_false_is_not_anomalous():
assert detect_boolean_transition(True, False) is False
# ---------------------------------------------------------------------------
# is_boolean_sensor classification rule
# ---------------------------------------------------------------------------
def test_is_boolean_sensor_by_unit():
assert is_boolean_sensor("bool", 1) is True
assert is_boolean_sensor("BOOL", 0) is True
def test_is_boolean_sensor_by_python_bool_value():
assert is_boolean_sensor("pct", True) is True
def test_is_boolean_sensor_numeric_zero_one_with_non_bool_unit_stays_numeric():
# A duty-cycle percentage reading exactly 0 or 1 must not be
# misclassified as boolean just because its value looks bool-like.
assert is_boolean_sensor("pct", 1) is False
assert is_boolean_sensor("c", 0) is False
# ---------------------------------------------------------------------------
# frequency_for_sensor_type
# ---------------------------------------------------------------------------
def test_frequency_stable_for_known_sensor_types():
assert frequency_for_sensor_type("temperature") == frequency_for_sensor_type("temperature")
assert frequency_for_sensor_type("temperature") != frequency_for_sensor_type("humidity")
def test_frequency_stable_and_deterministic_for_unknown_sensor_type():
freq1 = frequency_for_sensor_type("cosmic_ray_flux")
freq2 = frequency_for_sensor_type("cosmic_ray_flux")
assert freq1 == freq2
assert freq1 != frequency_for_sensor_type("other_unknown_sensor")
# ---------------------------------------------------------------------------
# process_device_reading_for_summon — integration
# ---------------------------------------------------------------------------
async def _make_user_and_session(db_session) -> tuple[uuid.UUID, uuid.UUID]:
user = User(username=f"devowner-{uuid.uuid4().hex[:8]}", password_hash="x")
db_session.add(user)
await db_session.flush()
session = ContactSession(user_id=user.id)
db_session.add(session)
await db_session.commit()
await db_session.refresh(session)
return user.id, session.id
@pytest.mark.asyncio
async def test_process_reading_pushes_anomaly_into_active_session(db_session, monkeypatch):
monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal)
user_id, session_id = await _make_user_and_session(db_session)
state = SeanceState(user_id=user_id, session_id=session_id, client_ip="127.0.0.1")
register_active_session(user_id, state)
device_id = uuid.uuid4()
try:
# Warm up the baseline with unremarkable readings — none of these
# should produce an anomaly.
for value in [21.0, 21.1, 20.9, 21.2, 21.0, 21.1]:
await process_device_reading_for_summon(
user_id, device_id, "temperature", value, "c"
)
assert state.anomalies == []
# A genuine surge fires and gets pushed into state.anomalies in the
# same shape _handle_anomaly uses for the browser-based modes.
await process_device_reading_for_summon(
user_id, device_id, "temperature", 30.0, "c"
)
assert len(state.anomalies) == 1
anomaly = state.anomalies[0]
assert anomaly["source"] == "temperature"
assert anomaly["frequency"] == frequency_for_sensor_type("temperature")
assert anomaly["magnitude"] > 0
finally:
from app.ws import unregister_active_session
unregister_active_session(user_id, state)
@pytest.mark.asyncio
async def test_process_reading_boolean_presence_transition(db_session, monkeypatch):
monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal)
user_id, session_id = await _make_user_and_session(db_session)
state = SeanceState(user_id=user_id, session_id=session_id, client_ip="127.0.0.1")
register_active_session(user_id, state)
device_id = uuid.uuid4()
try:
# First reading (False) establishes state, no prior value to
# transition from either way.
await process_device_reading_for_summon(user_id, device_id, "presence", 0, "bool")
assert state.anomalies == []
# false -> true is the anomaly.
await process_device_reading_for_summon(user_id, device_id, "presence", 1, "bool")
assert len(state.anomalies) == 1
anomaly = state.anomalies[0]
assert anomaly["source"] == "presence"
assert anomaly["magnitude"] == BOOLEAN_ANOMALY_MAGNITUDE
# true -> true is not.
await process_device_reading_for_summon(user_id, device_id, "presence", 1, "bool")
assert len(state.anomalies) == 1
finally:
from app.ws import unregister_active_session
unregister_active_session(user_id, state)
@pytest.mark.asyncio
async def test_process_reading_no_active_session_is_a_noop(db_session, monkeypatch):
monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal)
user_id, _session_id = await _make_user_and_session(db_session)
device_id = uuid.uuid4()
assert get_active_session(user_id) is None
# Should not raise even though there's no active session and no
# baseline yet — feeding a single reading can't be anomalous anyway.
await process_device_reading_for_summon(user_id, device_id, "temperature", 21.0, "c")
assert get_active_session(user_id) is None
@pytest.mark.asyncio
async def test_process_reading_non_anomalous_value_does_not_touch_session(db_session, monkeypatch):
monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal)
user_id, session_id = await _make_user_and_session(db_session)
state = SeanceState(user_id=user_id, session_id=session_id, client_ip="127.0.0.1")
register_active_session(user_id, state)
device_id = uuid.uuid4()
try:
for value in [21.0, 21.1, 20.9, 21.2, 21.0, 21.1, 21.0]:
await process_device_reading_for_summon(
user_id, device_id, "temperature", value, "c"
)
assert state.anomalies == []
finally:
from app.ws import unregister_active_session
unregister_active_session(user_id, state)