sensor-modeling 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. sensor_modeling/__init__.py +45 -0
  2. sensor_modeling/alerts/__init__.py +26 -0
  3. sensor_modeling/alerts/alert.py +532 -0
  4. sensor_modeling/analysis/__init__.py +43 -0
  5. sensor_modeling/analysis/_frame.py +19 -0
  6. sensor_modeling/analysis/behavioral_analysis.py +57 -0
  7. sensor_modeling/analysis/behavioral_metrics.py +66 -0
  8. sensor_modeling/analysis/comparison.py +164 -0
  9. sensor_modeling/analysis/dependency_network.py +408 -0
  10. sensor_modeling/analysis/granger_causality.py +314 -0
  11. sensor_modeling/analysis/pipeline.py +168 -0
  12. sensor_modeling/analysis/reporting.py +109 -0
  13. sensor_modeling/baseline/__init__.py +30 -0
  14. sensor_modeling/baseline/adaptive.py +520 -0
  15. sensor_modeling/baseline/features.py +224 -0
  16. sensor_modeling/change_point/__init__.py +13 -0
  17. sensor_modeling/change_point/_validation.py +31 -0
  18. sensor_modeling/change_point/adaptive_normalization.py +55 -0
  19. sensor_modeling/change_point/embedding_cpd.py +60 -0
  20. sensor_modeling/change_point/energy_efficient.py +57 -0
  21. sensor_modeling/change_point/genetic_optimization.py +65 -0
  22. sensor_modeling/cli.py +416 -0
  23. sensor_modeling/context/__init__.py +33 -0
  24. sensor_modeling/context/occupancy.py +529 -0
  25. sensor_modeling/data/__init__.py +5 -0
  26. sensor_modeling/data/loaders.py +146 -0
  27. sensor_modeling/data/preprocessing.py +83 -0
  28. sensor_modeling/data/synthetic.py +121 -0
  29. sensor_modeling/data/validation.py +81 -0
  30. sensor_modeling/evaluation/__init__.py +92 -0
  31. sensor_modeling/evaluation/ablation.py +303 -0
  32. sensor_modeling/evaluation/attribution.py +474 -0
  33. sensor_modeling/evaluation/detection.py +297 -0
  34. sensor_modeling/evaluation/metrics.py +541 -0
  35. sensor_modeling/evaluation/provenance.py +309 -0
  36. sensor_modeling/examples/__init__.py +1 -0
  37. sensor_modeling/examples/demos/__init__.py +1 -0
  38. sensor_modeling/examples/demos/ambient_pipeline_demo.py +418 -0
  39. sensor_modeling/examples/demos/bernoulli_ar_demo.py +356 -0
  40. sensor_modeling/examples/demos/cpd_ar_demo.py +25 -0
  41. sensor_modeling/examples/demos/cpd_benchmark.py +42 -0
  42. sensor_modeling/examples/demos/hmm_granger_demo.py +30 -0
  43. sensor_modeling/examples/demos/nhpp_pelt_demo.py +80 -0
  44. sensor_modeling/examples/tutorials/__init__.py +1 -0
  45. sensor_modeling/fusion/__init__.py +46 -0
  46. sensor_modeling/fusion/defaults.py +296 -0
  47. sensor_modeling/fusion/emissions.py +339 -0
  48. sensor_modeling/fusion/estimate.py +375 -0
  49. sensor_modeling/fusion/filter.py +323 -0
  50. sensor_modeling/health/__init__.py +31 -0
  51. sensor_modeling/health/monitor.py +590 -0
  52. sensor_modeling/health/status.py +74 -0
  53. sensor_modeling/hmm/__init__.py +15 -0
  54. sensor_modeling/hmm/adaptive_hmm.py +22 -0
  55. sensor_modeling/hmm/base.py +134 -0
  56. sensor_modeling/hmm/circadian_hmm.py +22 -0
  57. sensor_modeling/hmm/heterogeneous_hmm.py +22 -0
  58. sensor_modeling/hmm/hierarchical_hmm.py +35 -0
  59. sensor_modeling/hmm/scaled_dirichlet_hmm.py +23 -0
  60. sensor_modeling/interop/__init__.py +57 -0
  61. sensor_modeling/interop/fhir.py +418 -0
  62. sensor_modeling/interop/privacy.py +308 -0
  63. sensor_modeling/models/__init__.py +12 -0
  64. sensor_modeling/models/bernoulli_ar/__init__.py +6 -0
  65. sensor_modeling/models/bernoulli_ar/base_model.py +569 -0
  66. sensor_modeling/models/bernoulli_ar/multivariate_model.py +411 -0
  67. sensor_modeling/models/change_point_detection/__init__.py +10 -0
  68. sensor_modeling/models/change_point_detection/deep.py +65 -0
  69. sensor_modeling/models/change_point_detection/pelt.py +159 -0
  70. sensor_modeling/models/nhpp_pelt/__init__.py +5 -0
  71. sensor_modeling/models/nhpp_pelt/bspline.py +96 -0
  72. sensor_modeling/models/nhpp_pelt/cli.py +243 -0
  73. sensor_modeling/models/nhpp_pelt/diagnostics.py +234 -0
  74. sensor_modeling/models/nhpp_pelt/io.py +58 -0
  75. sensor_modeling/models/nhpp_pelt/model.py +408 -0
  76. sensor_modeling/models/nhpp_pelt/optimizer.py +142 -0
  77. sensor_modeling/models/nhpp_pelt/plotting.py +218 -0
  78. sensor_modeling/models/nhpp_pelt/quad.py +72 -0
  79. sensor_modeling/models/nhpp_pelt/regularization.py +121 -0
  80. sensor_modeling/models/nhpp_pelt/utils.py +174 -0
  81. sensor_modeling/observations/__init__.py +59 -0
  82. sensor_modeling/observations/adapters.py +195 -0
  83. sensor_modeling/observations/ingest.py +269 -0
  84. sensor_modeling/observations/observation.py +270 -0
  85. sensor_modeling/observations/registry.py +262 -0
  86. sensor_modeling/observations/stream.py +342 -0
  87. sensor_modeling/observations/types.py +107 -0
  88. sensor_modeling/observations/units.py +117 -0
  89. sensor_modeling/online/__init__.py +36 -0
  90. sensor_modeling/online/benchmarks.py +242 -0
  91. sensor_modeling/online/pipeline.py +485 -0
  92. sensor_modeling/simulation/__init__.py +54 -0
  93. sensor_modeling/simulation/faults.py +191 -0
  94. sensor_modeling/simulation/household.py +862 -0
  95. sensor_modeling/states/__init__.py +23 -0
  96. sensor_modeling/states/markov.py +105 -0
  97. sensor_modeling/states/ontology.py +238 -0
  98. sensor_modeling/utils/__init__.py +41 -0
  99. sensor_modeling/utils/data_io.py +199 -0
  100. sensor_modeling/utils/logging_config.py +10 -0
  101. sensor_modeling/utils/missing.py +188 -0
  102. sensor_modeling/utils/plotting.py +98 -0
  103. sensor_modeling/utils/validation.py +117 -0
  104. sensor_modeling/visualization/__init__.py +3 -0
  105. sensor_modeling/visualization/clinical.py +67 -0
  106. sensor_modeling/visualization/interactive.py +208 -0
  107. sensor_modeling/visualization/research.py +60 -0
  108. sensor_modeling/visualization/web_app.py +137 -0
  109. sensor_modeling-0.2.0.dist-info/METADATA +683 -0
  110. sensor_modeling-0.2.0.dist-info/RECORD +114 -0
  111. sensor_modeling-0.2.0.dist-info/WHEEL +5 -0
  112. sensor_modeling-0.2.0.dist-info/entry_points.txt +18 -0
  113. sensor_modeling-0.2.0.dist-info/licenses/LICENSE +21 -0
  114. sensor_modeling-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,862 @@
1
+ """A synthetic household with controlled ground truth.
2
+
3
+ The simulator exists so that inference can be evaluated against something
4
+ known. That only works if the generative process is genuinely different from
5
+ the inference model, so this module deliberately does *not* sample from the
6
+ continuous-time chain the filter uses. It generates a **schedule**: a person
7
+ who wakes at roughly the same time each morning, makes breakfast, goes out,
8
+ comes back, and goes to bed, with stochastic timings and durations.
9
+
10
+ Inference then has to recover that schedule through a Markov model that knows
11
+ nothing about schedules. If the two shared a generator, good results would
12
+ prove only that the code can invert its own assumptions.
13
+
14
+ What the ground truth records:
15
+
16
+ .. code-block:: text
17
+
18
+ resident location and behavioural state, minute by minute
19
+ sleep periods and night-time bathroom trips
20
+ visitor arrivals, departures, and their own movements
21
+ room transitions and door crossings
22
+ which sensor activations came from whom
23
+
24
+ Sensor records are then generated *from* that truth, with per-sensor rates,
25
+ false activations, and misses. Degrading the stream further -- dropouts,
26
+ stuck sensors, a wearable left off -- is the separate concern of
27
+ :mod:`sensor_modeling.simulation.faults`, so that robustness studies can vary
28
+ faults without regenerating behaviour.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ from collections.abc import Sequence
35
+ from dataclasses import dataclass, field
36
+ from datetime import date, datetime, time, timedelta, timezone, tzinfo
37
+ from zoneinfo import ZoneInfo
38
+
39
+ import numpy as np
40
+
41
+ from ..context.occupancy import OccupancyContext
42
+ from ..observations.observation import Observation
43
+ from ..observations.registry import SensorRegistry, SensorSpec
44
+ from ..observations.types import Modality, ObservationKind
45
+ from ..observations.units import Unit
46
+ from ..states.ontology import BehaviouralState
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ S = BehaviouralState
51
+ LISBON = ZoneInfo("Europe/Lisbon")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Episode:
56
+ """One period of known resident behaviour.
57
+
58
+ Attributes
59
+ ----------
60
+ start, end
61
+ Absolute bounds of the episode.
62
+ state
63
+ The resident's true behavioural state.
64
+ room
65
+ The room they are in, or ``None`` when out of the home.
66
+ """
67
+
68
+ start: datetime
69
+ end: datetime
70
+ state: BehaviouralState
71
+ room: str | None
72
+
73
+ @property
74
+ def duration(self) -> timedelta:
75
+ """Length of the episode."""
76
+ return self.end - self.start
77
+
78
+ def contains(self, moment: datetime) -> bool:
79
+ """Whether *moment* falls inside the episode."""
80
+ return self.start <= moment < self.end
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class VisitorPeriod:
85
+ """A period during which someone other than the resident is present."""
86
+
87
+ start: datetime
88
+ end: datetime
89
+ room_sequence: tuple[str, ...]
90
+
91
+ def room_at(self, moment: datetime) -> str | None:
92
+ """Return which room the visitor occupies at *moment*."""
93
+ if not self.start <= moment < self.end:
94
+ return None
95
+ span = (self.end - self.start).total_seconds()
96
+ if span <= 0:
97
+ return self.room_sequence[0]
98
+ progress = (moment - self.start).total_seconds() / span
99
+ index = min(
100
+ int(progress * len(self.room_sequence)), len(self.room_sequence) - 1
101
+ )
102
+ return self.room_sequence[index]
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class GroundTruth:
107
+ """Everything the simulator knows and the inference layer must not see."""
108
+
109
+ episodes: tuple[Episode, ...]
110
+ visitors: tuple[VisitorPeriod, ...]
111
+ tz: tzinfo
112
+
113
+ def state_at(self, moment: datetime) -> BehaviouralState | None:
114
+ """Return the resident's true state at *moment*, if simulated."""
115
+ for episode in self.episodes:
116
+ if episode.contains(moment):
117
+ return episode.state
118
+ return None
119
+
120
+ def room_at(self, moment: datetime) -> str | None:
121
+ """Return the resident's true room at *moment*."""
122
+ for episode in self.episodes:
123
+ if episode.contains(moment):
124
+ return episode.room
125
+ return None
126
+
127
+ def visitor_at(self, moment: datetime) -> bool:
128
+ """Whether anyone other than the resident is present at *moment*."""
129
+ return any(period.start <= moment < period.end for period in self.visitors)
130
+
131
+ def context_at(self, moment: datetime) -> OccupancyContext:
132
+ """Return the true occupancy context at *moment*."""
133
+ state = self.state_at(moment)
134
+ resident_home = state is not None and state is not S.AWAY
135
+ visitor = self.visitor_at(moment)
136
+ if resident_home and visitor:
137
+ return OccupancyContext.RESIDENT_WITH_VISITOR
138
+ if resident_home:
139
+ return OccupancyContext.RESIDENT_ALONE
140
+ return OccupancyContext.VISITOR_ONLY if visitor else OccupancyContext.EMPTY
141
+
142
+ def states_at(self, moments: Sequence[datetime]) -> list[BehaviouralState | None]:
143
+ """Return the true state at each of *moments*, efficiently.
144
+
145
+ Uses a single ordered sweep rather than scanning every episode for
146
+ every moment, which matters when evaluating months of minute-level
147
+ estimates.
148
+ """
149
+ results: list[BehaviouralState | None] = []
150
+ index = 0
151
+ episodes = self.episodes
152
+ for moment in moments:
153
+ while index < len(episodes) and episodes[index].end <= moment:
154
+ index += 1
155
+ if index < len(episodes) and episodes[index].contains(moment):
156
+ results.append(episodes[index].state)
157
+ else:
158
+ results.append(None)
159
+ return results
160
+
161
+ def daily_hours(self, state: BehaviouralState) -> dict[date, float]:
162
+ """Return true hours spent in *state* per local calendar day."""
163
+ totals: dict[date, float] = {}
164
+ for episode in self.episodes:
165
+ if episode.state is not state:
166
+ continue
167
+ cursor = episode.start
168
+ while cursor < episode.end:
169
+ local = cursor.astimezone(self.tz)
170
+ next_midnight = datetime.combine(
171
+ local.date() + timedelta(days=1),
172
+ time.min,
173
+ tzinfo=local.tzinfo,
174
+ )
175
+ boundary = min(next_midnight.astimezone(timezone.utc), episode.end)
176
+ hours = (boundary - cursor).total_seconds() / 3600.0
177
+ totals[local.date()] = totals.get(local.date(), 0.0) + hours
178
+ cursor = boundary
179
+ return totals
180
+
181
+
182
+ @dataclass
183
+ class BehaviourShift:
184
+ """A persistent change in the resident's routine, injected at a known day.
185
+
186
+ Parameters
187
+ ----------
188
+ start_day
189
+ Days after the simulation begins at which the change takes effect.
190
+ sleep_delta_hours
191
+ Change in nightly sleep duration, negative for less sleep.
192
+ night_bathroom_extra
193
+ Additional expected night-time bathroom trips per night.
194
+ outing_probability_delta
195
+ Change in the probability of going out on a given day.
196
+ ramp_days
197
+ Days over which the change phases in linearly. Zero produces a step
198
+ on ``start_day``; a positive value produces a gradual decline, which
199
+ is a different detection problem: a step is visible in a single day's
200
+ deviation, whereas a slow trend never is and can only be found by
201
+ looking across a window.
202
+ """
203
+
204
+ start_day: int
205
+ sleep_delta_hours: float = 0.0
206
+ night_bathroom_extra: float = 0.0
207
+ outing_probability_delta: float = 0.0
208
+ ramp_days: int = 0
209
+
210
+ def __post_init__(self) -> None:
211
+ """Validate the injected shift."""
212
+ if self.start_day < 0:
213
+ raise ValueError("start_day must be non-negative")
214
+ if self.ramp_days < 0:
215
+ raise ValueError("ramp_days must be non-negative")
216
+
217
+ def active_on(self, day_index: int) -> bool:
218
+ """Whether the shift applies on the given simulation day."""
219
+ return day_index >= self.start_day
220
+
221
+ def strength_on(self, day_index: int) -> float:
222
+ """Return how far the change has taken effect, in ``[0, 1]``."""
223
+ if day_index < self.start_day:
224
+ return 0.0
225
+ if self.ramp_days <= 0:
226
+ return 1.0
227
+ elapsed = day_index - self.start_day
228
+ return min(elapsed / self.ramp_days, 1.0)
229
+
230
+
231
+ @dataclass
232
+ class HouseholdConfig:
233
+ """Configuration of the synthetic household.
234
+
235
+ Parameters
236
+ ----------
237
+ days
238
+ Number of days to simulate.
239
+ start
240
+ Local date the simulation begins.
241
+ tz
242
+ Timezone of the household, so routines follow the local clock.
243
+ seed
244
+ Random seed. Every run with the same configuration is identical.
245
+ wake_hour, sleep_hour
246
+ Mean local wake and bedtime, in hours.
247
+ timing_jitter_minutes
248
+ Standard deviation of daily timing variation.
249
+ night_bathroom_rate
250
+ Expected night-time bathroom trips per night.
251
+ outing_probability
252
+ Probability of leaving the home on a given day.
253
+ visitor_probability
254
+ Probability of a visitor on a given day.
255
+ carer_weekday_visits
256
+ Whether a regular carer visits on weekday mornings while the resident
257
+ may be out, which is what makes attribution non-trivial.
258
+ shift
259
+ An optional persistent behavioural change to inject.
260
+ """
261
+
262
+ days: int = 60
263
+ start: date = date(2024, 3, 4)
264
+ tz: tzinfo = LISBON
265
+ seed: int = 20240304
266
+ wake_hour: float = 7.0
267
+ sleep_hour: float = 23.0
268
+ timing_jitter_minutes: float = 35.0
269
+ night_bathroom_rate: float = 0.8
270
+ outing_probability: float = 0.55
271
+ visitor_probability: float = 0.2
272
+ carer_weekday_visits: bool = True
273
+ shift: BehaviourShift | None = None
274
+
275
+ def __post_init__(self) -> None:
276
+ """Validate the household configuration."""
277
+ if self.days < 1:
278
+ raise ValueError("days must be at least 1")
279
+ if not 0.0 <= self.wake_hour < 24.0 or not 0.0 < self.sleep_hour < 24.0:
280
+ raise ValueError("wake_hour and sleep_hour must be valid hours of day")
281
+ if self.wake_hour >= self.sleep_hour:
282
+ raise ValueError("wake_hour must precede sleep_hour within the same day")
283
+ if self.timing_jitter_minutes < 0:
284
+ raise ValueError("timing_jitter_minutes must be non-negative")
285
+ if self.night_bathroom_rate < 0:
286
+ raise ValueError("night_bathroom_rate must be non-negative")
287
+ for name in ("outing_probability", "visitor_probability"):
288
+ value = float(getattr(self, name))
289
+ if not 0.0 <= value <= 1.0:
290
+ raise ValueError(f"{name} must lie in [0, 1]")
291
+
292
+
293
+ def build_registry() -> SensorRegistry:
294
+ """Return the sensor deployment of the synthetic household.
295
+
296
+ Deliberately multimodal, so that ablation studies have something to
297
+ ablate: object contacts, room motion, a door, bed pressure, a radar
298
+ presence feature, a wearable, and a personal beacon.
299
+ """
300
+ return SensorRegistry.from_specs(
301
+ [
302
+ SensorSpec(
303
+ "front_door",
304
+ Modality.DOOR,
305
+ room="hall",
306
+ description="Contact on the entrance door; fires on any crossing.",
307
+ ),
308
+ SensorSpec(
309
+ "bedroom_motion",
310
+ Modality.MOTION,
311
+ room="bedroom",
312
+ description="PIR covering the bedroom.",
313
+ ),
314
+ SensorSpec(
315
+ "bathroom_motion",
316
+ Modality.MOTION,
317
+ room="bathroom",
318
+ description="PIR covering the bathroom.",
319
+ ),
320
+ SensorSpec(
321
+ "kitchen_motion",
322
+ Modality.MOTION,
323
+ room="kitchen",
324
+ description="PIR covering the kitchen.",
325
+ ),
326
+ SensorSpec(
327
+ "living_motion",
328
+ Modality.MOTION,
329
+ room="living",
330
+ description="PIR covering the living room.",
331
+ ),
332
+ SensorSpec(
333
+ "fridge_contact",
334
+ Modality.CONTACT,
335
+ room="kitchen",
336
+ description="Contact on the fridge door. Records the door "
337
+ "opening, which is not a record of eating.",
338
+ ),
339
+ SensorSpec(
340
+ "bed_pressure",
341
+ Modality.BED_PRESSURE,
342
+ kind=ObservationKind.STATE,
343
+ room="bedroom",
344
+ expected_interval=timedelta(minutes=5),
345
+ value_range=(0.0, 1.0),
346
+ description="Load cell reporting bed occupancy as a level.",
347
+ ),
348
+ SensorSpec(
349
+ "living_radar",
350
+ Modality.RADAR,
351
+ kind=ObservationKind.SAMPLE,
352
+ unit=Unit.COUNT,
353
+ room="living",
354
+ expected_interval=timedelta(minutes=1),
355
+ value_range=(0.0, 6.0),
356
+ description="Derived mmWave feature: number of tracked people "
357
+ "in the home. Not raw radar; no identity is inferred.",
358
+ ),
359
+ SensorSpec(
360
+ "wearable_motion",
361
+ Modality.WEARABLE_MOTION,
362
+ kind=ObservationKind.SAMPLE,
363
+ room=None,
364
+ expected_interval=timedelta(minutes=1),
365
+ value_range=(0.0, 10.0),
366
+ attributable=True,
367
+ description="Accelerometer activity magnitude from a worn "
368
+ "device. Attributable to the resident when worn.",
369
+ ),
370
+ SensorSpec(
371
+ "resident_beacon",
372
+ Modality.PROXIMITY,
373
+ kind=ObservationKind.STATE,
374
+ expected_interval=timedelta(minutes=1),
375
+ value_range=(0.0, 1.0),
376
+ attributable=True,
377
+ description="Whether the resident's personal tag is in range "
378
+ "of the home hub.",
379
+ ),
380
+ ]
381
+ )
382
+
383
+
384
+ #: Activation rate per hour of each motion-style sensor when the resident is
385
+ #: in that room and active, and when they are not. The non-zero idle rate is
386
+ #: what produces the false activations any real deployment has.
387
+ ACTIVE_RATE = 45.0
388
+ IDLE_RATE = 0.12
389
+
390
+ #: Expected wearable activity magnitude in each true state.
391
+ WEARABLE_LEVEL: dict[BehaviouralState, float] = {
392
+ S.SLEEPING: 0.03,
393
+ S.BED_AWAKE: 0.25,
394
+ S.HOME_INACTIVE: 0.35,
395
+ S.HOME_ACTIVE: 1.4,
396
+ S.KITCHEN_ACTIVITY: 1.1,
397
+ S.BATHROOM_ACTIVITY: 0.9,
398
+ S.AWAY: 1.9,
399
+ }
400
+
401
+
402
+ def _localise(day: date, hour: float, zone: tzinfo) -> datetime:
403
+ """Return the absolute instant of a local wall-clock hour on *day*.
404
+
405
+ Wall-clock arithmetic is done once, here, and everything downstream works
406
+ in absolute time. A local hour that does not exist because of a
407
+ spring-forward is nudged past the gap rather than silently resolving to
408
+ the wrong instant.
409
+ """
410
+ clamped = min(max(hour, 0.0), 23.999)
411
+ naive = datetime.combine(day, time.min) + timedelta(hours=clamped)
412
+ local = naive.replace(tzinfo=zone)
413
+ if local.astimezone(timezone.utc).astimezone(zone).hour != local.hour:
414
+ local = (naive + timedelta(hours=1)).replace(tzinfo=zone)
415
+ return local.astimezone(timezone.utc)
416
+
417
+
418
+ def _plan_day(
419
+ day_index: int,
420
+ day: date,
421
+ config: HouseholdConfig,
422
+ rng: np.random.Generator,
423
+ ) -> tuple[list[Episode], list[VisitorPeriod]]:
424
+ """Generate one day of true behaviour and visitor activity."""
425
+ zone = config.tz
426
+ jitter = config.timing_jitter_minutes / 60.0
427
+ shift = config.shift
428
+ # How far the injected change has taken effect today. A step shift is
429
+ # fully on from its start day; a ramped one phases in.
430
+ strength = shift.strength_on(day_index) if shift is not None else 0.0
431
+
432
+ weekend = day.weekday() >= 5
433
+ wake_hour = config.wake_hour + (1.2 if weekend else 0.0)
434
+ wake_hour += rng.normal(0.0, jitter)
435
+ sleep_hour = config.sleep_hour + rng.normal(0.0, jitter)
436
+ if strength > 0.0 and shift is not None:
437
+ # Less sleep is taken from the front of the night: the resident wakes
438
+ # earlier rather than going to bed later, which is the pattern most
439
+ # often reported in the ambient-monitoring literature.
440
+ wake_hour -= shift.sleep_delta_hours * strength
441
+
442
+ wake = _localise(day, wake_hour, zone)
443
+ bedtime = _localise(day, sleep_hour, zone)
444
+ midnight = _localise(day, 0.0, zone)
445
+
446
+ episodes: list[Episode] = []
447
+
448
+ # --- night: sleeping, interrupted by bathroom trips ---------------
449
+ night_rate = config.night_bathroom_rate + (
450
+ shift.night_bathroom_extra * strength if shift is not None else 0.0
451
+ )
452
+ trips = int(rng.poisson(max(night_rate, 0.0)))
453
+ trip_starts = sorted(
454
+ midnight + timedelta(hours=float(h))
455
+ for h in rng.uniform(1.0, max(wake_hour - 0.5, 1.5), size=trips)
456
+ )
457
+ cursor = midnight
458
+ for trip_start in trip_starts:
459
+ if trip_start <= cursor or trip_start >= wake:
460
+ continue
461
+ episodes.append(Episode(cursor, trip_start, S.SLEEPING, "bedroom"))
462
+ trip_end = min(trip_start + timedelta(minutes=float(rng.uniform(3, 9))), wake)
463
+ episodes.append(Episode(trip_start, trip_end, S.BATHROOM_ACTIVITY, "bathroom"))
464
+ cursor = trip_end
465
+ if cursor < wake:
466
+ episodes.append(Episode(cursor, wake, S.SLEEPING, "bedroom"))
467
+
468
+ # --- morning routine ----------------------------------------------
469
+ cursor = wake
470
+ morning_bathroom = cursor + timedelta(minutes=float(rng.uniform(8, 20)))
471
+ episodes.append(Episode(cursor, morning_bathroom, S.BED_AWAKE, "bedroom"))
472
+ cursor = morning_bathroom
473
+ after_bathroom = cursor + timedelta(minutes=float(rng.uniform(8, 18)))
474
+ episodes.append(Episode(cursor, after_bathroom, S.BATHROOM_ACTIVITY, "bathroom"))
475
+ cursor = after_bathroom
476
+ breakfast_end = cursor + timedelta(minutes=float(rng.uniform(20, 45)))
477
+ episodes.append(Episode(cursor, breakfast_end, S.KITCHEN_ACTIVITY, "kitchen"))
478
+ cursor = breakfast_end
479
+
480
+ daytime, cursor = _plan_daytime(day, cursor, bedtime, config, rng, zone, strength)
481
+ episodes.extend(daytime)
482
+
483
+ next_midnight = _localise(day + timedelta(days=1), 0.0, zone)
484
+ if next_midnight > cursor:
485
+ episodes.append(Episode(cursor, next_midnight, S.SLEEPING, "bedroom"))
486
+
487
+ return episodes, _plan_visitors(day, config, rng, zone)
488
+
489
+
490
+ def _plan_daytime(
491
+ day: date,
492
+ start: datetime,
493
+ bedtime: datetime,
494
+ config: HouseholdConfig,
495
+ rng: np.random.Generator,
496
+ zone: tzinfo,
497
+ shift_strength: float,
498
+ ) -> tuple[list[Episode], datetime]:
499
+ """Generate the waking day from after breakfast until bedtime.
500
+
501
+ Returns the episodes and the moment the day's routine finishes, which is
502
+ normally bedtime but can be earlier if the day was compressed.
503
+ """
504
+ episodes: list[Episode] = []
505
+ cursor = start
506
+ shift = config.shift
507
+
508
+ outing_probability = config.outing_probability + (
509
+ shift.outing_probability_delta * shift_strength if shift is not None else 0.0
510
+ )
511
+ if rng.random() < min(max(outing_probability, 0.0), 1.0):
512
+ out_start = cursor + timedelta(minutes=float(rng.uniform(30, 150)))
513
+ episodes.append(Episode(cursor, out_start, S.HOME_INACTIVE, "living"))
514
+ out_end = min(
515
+ out_start + timedelta(hours=float(rng.uniform(1.0, 4.0))),
516
+ bedtime - timedelta(hours=1),
517
+ )
518
+ if out_end > out_start:
519
+ episodes.append(Episode(out_start, out_end, S.AWAY, None))
520
+ cursor = max(out_end, out_start)
521
+
522
+ lunch_start = max(cursor, _localise(day, 12.5 + rng.normal(0, 0.4), zone))
523
+ if lunch_start > cursor:
524
+ episodes.append(Episode(cursor, lunch_start, S.HOME_INACTIVE, "living"))
525
+ lunch_end = lunch_start + timedelta(minutes=float(rng.uniform(25, 55)))
526
+ episodes.append(Episode(lunch_start, lunch_end, S.KITCHEN_ACTIVITY, "kitchen"))
527
+ cursor = lunch_end
528
+
529
+ afternoon_end = min(
530
+ cursor + timedelta(hours=float(rng.uniform(2.0, 4.5))),
531
+ bedtime - timedelta(hours=1.5),
532
+ )
533
+ if afternoon_end > cursor:
534
+ episodes.extend(_alternate(cursor, afternoon_end, rng))
535
+ cursor = afternoon_end
536
+
537
+ dinner_end = min(cursor + timedelta(minutes=float(rng.uniform(30, 60))), bedtime)
538
+ if dinner_end > cursor:
539
+ episodes.append(Episode(cursor, dinner_end, S.KITCHEN_ACTIVITY, "kitchen"))
540
+ cursor = dinner_end
541
+
542
+ if bedtime > cursor:
543
+ episodes.extend(_alternate(cursor, bedtime, rng))
544
+ cursor = bedtime
545
+
546
+ return episodes, cursor
547
+
548
+
549
+ def _alternate(
550
+ start: datetime, end: datetime, rng: np.random.Generator
551
+ ) -> list[Episode]:
552
+ """Fill a span with alternating active and inactive living-room blocks."""
553
+ episodes: list[Episode] = []
554
+ cursor = start
555
+ active = False
556
+ while cursor < end:
557
+ span = timedelta(minutes=float(rng.uniform(15, 60)))
558
+ block_end = min(cursor + span, end)
559
+ episodes.append(
560
+ Episode(
561
+ cursor,
562
+ block_end,
563
+ S.HOME_ACTIVE if active else S.HOME_INACTIVE,
564
+ "living",
565
+ )
566
+ )
567
+ cursor = block_end
568
+ active = not active
569
+ return episodes
570
+
571
+
572
+ def _plan_visitors(
573
+ day: date, config: HouseholdConfig, rng: np.random.Generator, zone: tzinfo
574
+ ) -> list[VisitorPeriod]:
575
+ """Generate visitor periods for one day."""
576
+ periods: list[VisitorPeriod] = []
577
+ if config.carer_weekday_visits and day.weekday() < 5:
578
+ start = _localise(day, 9.0 + rng.normal(0, 0.3), zone)
579
+ periods.append(
580
+ VisitorPeriod(
581
+ start,
582
+ start + timedelta(minutes=float(rng.uniform(30, 60))),
583
+ ("hall", "kitchen", "living", "hall"),
584
+ )
585
+ )
586
+ if rng.random() < config.visitor_probability:
587
+ start = _localise(day, 15.0 + rng.normal(0, 1.0), zone)
588
+ periods.append(
589
+ VisitorPeriod(
590
+ start,
591
+ start + timedelta(hours=float(rng.uniform(1.0, 3.0))),
592
+ ("hall", "living", "kitchen", "living", "hall"),
593
+ )
594
+ )
595
+ return periods
596
+
597
+
598
+ @dataclass(frozen=True)
599
+ class SimulationResult:
600
+ """A simulated household: what happened, and what the sensors recorded."""
601
+
602
+ registry: SensorRegistry
603
+ observations: tuple[Observation, ...]
604
+ truth: GroundTruth
605
+ config: HouseholdConfig = field(repr=False)
606
+
607
+ @property
608
+ def start(self) -> datetime:
609
+ """First instant of simulated time."""
610
+ return self.truth.episodes[0].start
611
+
612
+ @property
613
+ def end(self) -> datetime:
614
+ """Last instant of simulated time."""
615
+ return self.truth.episodes[-1].end
616
+
617
+ def observations_for(self, sensor_ids: Sequence[str]) -> tuple[Observation, ...]:
618
+ """Return only the records from *sensor_ids*, for ablation studies."""
619
+ wanted = set(sensor_ids)
620
+ return tuple(obs for obs in self.observations if obs.sensor_id in wanted)
621
+
622
+
623
+ def _emit_events(
624
+ sensor_id: str,
625
+ modality: Modality,
626
+ times: np.ndarray,
627
+ origin: datetime,
628
+ generated_by: str,
629
+ ) -> list[Observation]:
630
+ """Turn Poisson arrival offsets into activation observations."""
631
+ return [
632
+ Observation(
633
+ timestamp=origin + timedelta(seconds=float(offset)),
634
+ sensor_id=sensor_id,
635
+ modality=modality,
636
+ kind=ObservationKind.EVENT,
637
+ value=1.0,
638
+ source="sim-hub",
639
+ context={"generated_by": generated_by},
640
+ )
641
+ for offset in times
642
+ ]
643
+
644
+
645
+ def _poisson_times(
646
+ rate_per_hour: float, span: timedelta, rng: np.random.Generator
647
+ ) -> np.ndarray:
648
+ """Sample event offsets in seconds from a homogeneous Poisson process."""
649
+ seconds = span.total_seconds()
650
+ if seconds <= 0 or rate_per_hour <= 0:
651
+ return np.empty(0)
652
+ expected = rate_per_hour * seconds / 3600.0
653
+ count = int(rng.poisson(expected))
654
+ return np.sort(rng.uniform(0.0, seconds, size=count))
655
+
656
+
657
+ _ROOM_SENSORS = {
658
+ "bedroom": ("bedroom_motion", Modality.MOTION),
659
+ "bathroom": ("bathroom_motion", Modality.MOTION),
660
+ "kitchen": ("kitchen_motion", Modality.MOTION),
661
+ "living": ("living_motion", Modality.MOTION),
662
+ }
663
+
664
+
665
+ def _room_observations(
666
+ truth: GroundTruth, rng: np.random.Generator
667
+ ) -> list[Observation]:
668
+ """Generate motion and contact activations from resident and visitors."""
669
+ records: list[Observation] = []
670
+
671
+ for episode in truth.episodes:
672
+ for room, (sensor_id, modality) in _ROOM_SENSORS.items():
673
+ occupied = episode.room == room and episode.state is not S.AWAY
674
+ rate = ACTIVE_RATE if occupied else IDLE_RATE
675
+ if occupied and episode.state in (S.SLEEPING, S.HOME_INACTIVE):
676
+ rate = 6.0 if episode.state is S.HOME_INACTIVE else 0.6
677
+ offsets = _poisson_times(rate, episode.duration, rng)
678
+ records.extend(
679
+ _emit_events(sensor_id, modality, offsets, episode.start, "resident")
680
+ )
681
+
682
+ if episode.state is S.KITCHEN_ACTIVITY:
683
+ offsets = _poisson_times(9.0, episode.duration, rng)
684
+ records.extend(
685
+ _emit_events(
686
+ "fridge_contact",
687
+ Modality.CONTACT,
688
+ offsets,
689
+ episode.start,
690
+ "resident",
691
+ )
692
+ )
693
+
694
+ # Visitors trip the same ambient sensors. This is the contamination that
695
+ # makes attribution necessary rather than decorative.
696
+ for period in truth.visitors:
697
+ span = period.end - period.start
698
+ steps = max(len(period.room_sequence), 1)
699
+ slice_span = span / steps
700
+ for index, room in enumerate(period.room_sequence):
701
+ entry = period.start + slice_span * index
702
+ sensor = _ROOM_SENSORS.get(room)
703
+ if sensor is None:
704
+ continue
705
+ sensor_id, modality = sensor
706
+ offsets = _poisson_times(ACTIVE_RATE * 0.7, slice_span, rng)
707
+ records.extend(_emit_events(sensor_id, modality, offsets, entry, "visitor"))
708
+ if room == "kitchen":
709
+ offsets = _poisson_times(6.0, slice_span, rng)
710
+ records.extend(
711
+ _emit_events(
712
+ "fridge_contact",
713
+ Modality.CONTACT,
714
+ offsets,
715
+ entry,
716
+ "visitor",
717
+ )
718
+ )
719
+ return records
720
+
721
+
722
+ def _door_observations(truth: GroundTruth) -> list[Observation]:
723
+ """Generate door crossings at every genuine entry and exit."""
724
+ crossings: list[tuple[datetime, str]] = []
725
+ for previous, current in zip(truth.episodes, truth.episodes[1:]):
726
+ if (previous.state is S.AWAY) != (current.state is S.AWAY):
727
+ crossings.append((current.start, "resident"))
728
+ for period in truth.visitors:
729
+ crossings.append((period.start, "visitor"))
730
+ crossings.append((period.end, "visitor"))
731
+
732
+ return [
733
+ Observation(
734
+ timestamp=moment,
735
+ sensor_id="front_door",
736
+ modality=Modality.DOOR,
737
+ kind=ObservationKind.EVENT,
738
+ value=1.0,
739
+ source="sim-hub",
740
+ context={"generated_by": who},
741
+ )
742
+ for moment, who in sorted(crossings)
743
+ ]
744
+
745
+
746
+ def _sampled_observations(
747
+ truth: GroundTruth, rng: np.random.Generator, interval: timedelta
748
+ ) -> list[Observation]:
749
+ """Generate the periodic sampled and state-reporting sensors."""
750
+ records: list[Observation] = []
751
+ cursor = truth.episodes[0].start
752
+ end = truth.episodes[-1].end
753
+
754
+ moments: list[datetime] = []
755
+ while cursor < end:
756
+ moments.append(cursor)
757
+ cursor += interval
758
+ states = truth.states_at(moments)
759
+
760
+ for moment, state in zip(moments, states):
761
+ if state is None:
762
+ continue
763
+ in_bed = state in (S.SLEEPING, S.BED_AWAKE)
764
+ records.append(
765
+ Observation(
766
+ timestamp=moment,
767
+ sensor_id="bed_pressure",
768
+ modality=Modality.BED_PRESSURE,
769
+ kind=ObservationKind.STATE,
770
+ value=1.0 if in_bed else 0.0,
771
+ source="sim-hub",
772
+ context={"generated_by": "resident"},
773
+ )
774
+ )
775
+
776
+ # The radar reports how many people it can track. It is a derived
777
+ # feature with real uncertainty: it occasionally loses a still person
778
+ # and occasionally splits one into two.
779
+ people = (0 if state is S.AWAY else 1) + (1 if truth.visitor_at(moment) else 0)
780
+ if state in (S.SLEEPING, S.HOME_INACTIVE) and rng.random() < 0.12:
781
+ people = max(people - 1, 0)
782
+ elif rng.random() < 0.04:
783
+ people += 1
784
+ records.append(
785
+ Observation(
786
+ timestamp=moment,
787
+ sensor_id="living_radar",
788
+ modality=Modality.RADAR,
789
+ kind=ObservationKind.SAMPLE,
790
+ value=float(people),
791
+ unit=Unit.COUNT,
792
+ confidence=0.75,
793
+ source="sim-radar",
794
+ context={"generated_by": "environment"},
795
+ )
796
+ )
797
+
798
+ level = WEARABLE_LEVEL.get(state, 0.5)
799
+ records.append(
800
+ Observation(
801
+ timestamp=moment,
802
+ sensor_id="wearable_motion",
803
+ modality=Modality.WEARABLE_MOTION,
804
+ kind=ObservationKind.SAMPLE,
805
+ value=float(max(rng.normal(level, 0.22), 0.0)),
806
+ source="sim-wearable",
807
+ context={"generated_by": "resident"},
808
+ )
809
+ )
810
+ records.append(
811
+ Observation(
812
+ timestamp=moment,
813
+ sensor_id="resident_beacon",
814
+ modality=Modality.PROXIMITY,
815
+ kind=ObservationKind.STATE,
816
+ value=0.0 if state is S.AWAY else 1.0,
817
+ source="sim-wearable",
818
+ context={"generated_by": "resident"},
819
+ )
820
+ )
821
+ return records
822
+
823
+
824
+ def simulate(config: HouseholdConfig | None = None) -> SimulationResult:
825
+ """Simulate a synthetic household and its sensor record.
826
+
827
+ Every run with the same configuration produces byte-identical output, so
828
+ experiments built on it are reproducible from the seed alone.
829
+ """
830
+ settings = config or HouseholdConfig()
831
+ rng = np.random.default_rng(settings.seed)
832
+
833
+ episodes: list[Episode] = []
834
+ visitors: list[VisitorPeriod] = []
835
+ for day_index in range(settings.days):
836
+ day = settings.start + timedelta(days=day_index)
837
+ day_episodes, day_visitors = _plan_day(day_index, day, settings, rng)
838
+ episodes.extend(day_episodes)
839
+ visitors.extend(day_visitors)
840
+
841
+ episodes = [episode for episode in episodes if episode.duration > timedelta(0)]
842
+ episodes.sort(key=lambda episode: episode.start)
843
+ truth = GroundTruth(tuple(episodes), tuple(visitors), settings.tz)
844
+
845
+ records = _room_observations(truth, rng)
846
+ records.extend(_door_observations(truth))
847
+ records.extend(_sampled_observations(truth, rng, timedelta(minutes=1)))
848
+ records.sort(key=lambda obs: obs.timestamp)
849
+
850
+ logger.info(
851
+ "Simulated %d days: %d episodes, %d visitor periods, %d observations",
852
+ settings.days,
853
+ len(episodes),
854
+ len(visitors),
855
+ len(records),
856
+ )
857
+ return SimulationResult(
858
+ registry=build_registry(),
859
+ observations=tuple(records),
860
+ truth=truth,
861
+ config=settings,
862
+ )