BioPatch / backend /core /simulator.py
Jerry
BioPatch backend (clean state, history rewritten)
10795db
Raw History Blame Contribute Delete
4.99 kB
"""Dev-only sensor simulator.
Drives the production ingest pipeline (`ingest_and_broadcast`) so that the
dashboard has live data without real IoT hardware. Disabled in production
via the ENABLE_SIMULATOR setting.
The simulator deliberately drifts one patient toward an SSI signature so the
'critical' UI states are exercisable on first launch.
"""
from __future__ import annotations
import asyncio
import logging
import random
from dataclasses import dataclass
from sqlalchemy import select
from core.database import SessionLocal
from core.realtime import ingest_and_broadcast
from models import Patient, Sensor
logger = logging.getLogger(__name__)
@dataclass
class _Stream:
patient_id: int
sensor_id: int | None
temperature_c: float
ph: float
humidity: float
# Slow drifts that determine the trajectory
temp_drift: float
ph_drift: float
humidity_drift: float
# Battery + signal state
battery_pct: float
signal_dbm: float
def _seed_streams() -> list[_Stream]:
"""Build one stream per patient using the seeded sensors. The first
patient is given a clearly deteriorating trajectory; the rest stay near
healing baselines with small noise."""
streams: list[_Stream] = []
with SessionLocal() as db:
patients = list(db.scalars(select(Patient).where(Patient.discharged.is_(False))).all())
for idx, p in enumerate(patients):
sensor = db.scalar(select(Sensor).where(Sensor.patient_id == p.id).limit(1))
if idx == 0:
# Deteriorating patient — slow drift toward infection signature
streams.append(
_Stream(
patient_id=p.id,
sensor_id=sensor.id if sensor else None,
temperature_c=37.1,
ph=7.05,
humidity=78.0,
temp_drift=+0.012,
ph_drift=+0.010,
humidity_drift=+0.05,
battery_pct=88.0,
signal_dbm=-62.0,
)
)
else:
streams.append(
_Stream(
patient_id=p.id,
sensor_id=sensor.id if sensor else None,
temperature_c=random.uniform(33.6, 34.8),
ph=random.uniform(5.8, 6.4),
humidity=random.uniform(70.0, 80.0),
temp_drift=0.0,
ph_drift=0.0,
humidity_drift=0.0,
battery_pct=random.uniform(85.0, 99.0),
signal_dbm=random.uniform(-70.0, -50.0),
)
)
return streams
def _step(stream: _Stream) -> None:
stream.temperature_c += stream.temp_drift + random.gauss(0.0, 0.05)
stream.ph += stream.ph_drift + random.gauss(0.0, 0.015)
stream.humidity += stream.humidity_drift + random.gauss(0.0, 0.5)
# Clamp to physically plausible ranges
stream.temperature_c = max(30.0, min(41.0, stream.temperature_c))
stream.ph = max(4.5, min(9.5, stream.ph))
stream.humidity = max(35.0, min(99.0, stream.humidity))
# Slow battery drain + tiny signal jitter
stream.battery_pct = max(5.0, stream.battery_pct - random.uniform(0.0, 0.05))
stream.signal_dbm = max(-90.0, min(-40.0, stream.signal_dbm + random.gauss(0.0, 1.2)))
async def run_simulator(interval_seconds: float = 6.0) -> None:
streams = _seed_streams()
if not streams:
logger.info("Simulator: no patients to drive, exiting")
return
logger.info("Simulator: driving %d streams every %.1fs", len(streams), interval_seconds)
try:
while True:
for stream in streams:
_step(stream)
with SessionLocal() as db:
sensor = db.get(Sensor, stream.sensor_id) if stream.sensor_id else None
if sensor is not None:
sensor.battery_pct = stream.battery_pct
sensor.signal_dbm = stream.signal_dbm
try:
patient = db.get(Patient, stream.patient_id)
await ingest_and_broadcast(
db,
patient_id=stream.patient_id,
temperature_c=stream.temperature_c,
ph=stream.ph,
humidity=stream.humidity,
sensor=sensor,
patient=patient,
)
except Exception:
logger.exception("Simulator ingest failed for patient %s", stream.patient_id)
await asyncio.sleep(interval_seconds)
except asyncio.CancelledError:
logger.info("Simulator: cancelled, exiting")
raise