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,485 @@
1
+ """The incremental end-to-end pipeline.
2
+
3
+ .. code-block:: text
4
+
5
+ observation arrives
6
+ v
7
+ validate at the boundary
8
+ v
9
+ update sensor health
10
+ v
11
+ update person context
12
+ v
13
+ update latent behavioural state
14
+ v
15
+ close the day, update the personal baseline
16
+ v
17
+ evaluate behavioural change
18
+ v
19
+ emit a structured, explainable result
20
+
21
+ Every stage keeps bounded state, so the pipeline runs indefinitely on an edge
22
+ device, and every stage can be snapshotted and restored, so a restart or an
23
+ intermittent uplink does not lose the resident's accumulated history.
24
+
25
+ The one piece of machinery that only exists because streams are real is the
26
+ lateness buffer. Observations arrive out of order; the filter is causal and
27
+ refuses to move backwards. So the pipeline holds records behind a watermark
28
+ long enough to reorder them, releases them in timestamp order, and counts
29
+ anything later than the tolerance rather than quietly folding a stale record
30
+ into the current belief.
31
+
32
+ This module owns orchestration only. It contains no inference of its own, and
33
+ nothing here knows about files, HTTP, dashboards or storage.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import bisect
39
+ import logging
40
+ from collections.abc import Iterable, Mapping, Sequence
41
+ from dataclasses import dataclass
42
+ from datetime import date, datetime, time, timedelta, tzinfo
43
+ from typing import Any
44
+
45
+ from ..alerts.alert import Alert, AlertEngine, AlertPolicy
46
+ from ..baseline.adaptive import AdaptiveBaseline, BaselineConfig, BehaviouralChange
47
+ from ..baseline.features import DailySummary, summarise_days
48
+ from ..context.occupancy import ContextConfig, ContextEstimate, ResidentContextEstimator
49
+ from ..fusion.defaults import default_emissions
50
+ from ..fusion.emissions import EmissionModel
51
+ from ..fusion.estimate import StateEstimate
52
+ from ..fusion.filter import FusionConfig, MultimodalBayesFilter
53
+ from ..health.monitor import HealthConfig, SensorHealthMonitor, SystemHealthReport
54
+ from ..observations.ingest import IngestionReport, ObservationIngestor
55
+ from ..observations.observation import Observation
56
+ from ..observations.registry import SensorRegistry
57
+ from ..states.ontology import BehaviouralState, StateOntology
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ @dataclass
63
+ class PipelineConfig:
64
+ """Configuration of the online pipeline.
65
+
66
+ Parameters
67
+ ----------
68
+ tz
69
+ Timezone whose calendar days bound the baseline. Defaults to the
70
+ registry's deployment timezone when not given.
71
+ step
72
+ How often the state filter advances. Between steps, observations
73
+ accumulate and are applied together.
74
+ lateness_tolerance
75
+ How far behind real time the processing watermark sits. Records that
76
+ arrive later than this are counted and discarded rather than folded
77
+ into an already-advanced belief.
78
+ features
79
+ Behavioural states whose daily hours get an adaptive baseline.
80
+ min_day_coverage, min_day_observed
81
+ Quality a day must reach before it may inform the baseline.
82
+ attribute_activity
83
+ Whether ambient evidence is discounted by the probability that the
84
+ monitored resident generated it. Setting this false makes the
85
+ pipeline attribute every ambient event to the resident, which is the
86
+ naive behaviour most ambient monitoring assumes. It exists so that
87
+ assumption can be *measured* against the occupancy-aware default
88
+ rather than argued about.
89
+ """
90
+
91
+ tz: tzinfo | None = None
92
+ step: timedelta = timedelta(minutes=5)
93
+ lateness_tolerance: timedelta = timedelta(minutes=10)
94
+ features: tuple[BehaviouralState, ...] = (
95
+ BehaviouralState.SLEEPING,
96
+ BehaviouralState.KITCHEN_ACTIVITY,
97
+ BehaviouralState.BATHROOM_ACTIVITY,
98
+ BehaviouralState.AWAY,
99
+ )
100
+ min_day_coverage: float = 0.5
101
+ min_day_observed: float = 0.6
102
+ attribute_activity: bool = True
103
+
104
+ def __post_init__(self) -> None:
105
+ """Validate the pipeline configuration."""
106
+ if self.step <= timedelta(0):
107
+ raise ValueError("step must be positive")
108
+ if self.lateness_tolerance < timedelta(0):
109
+ raise ValueError("lateness_tolerance must be non-negative")
110
+ if not self.features:
111
+ raise ValueError("at least one baseline feature is required")
112
+ for name in ("min_day_coverage", "min_day_observed"):
113
+ value = float(getattr(self, name))
114
+ if not 0.0 <= value <= 1.0:
115
+ raise ValueError(f"{name} must lie in [0, 1]")
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class PipelineStep:
120
+ """Everything the pipeline concluded at one moment."""
121
+
122
+ at: datetime
123
+ state: StateEstimate
124
+ context: ContextEstimate
125
+ health: SystemHealthReport
126
+ alerts: tuple[Alert, ...] = ()
127
+ changes: tuple[BehaviouralChange, ...] = ()
128
+ day_closed: DailySummary | None = None
129
+
130
+ def to_dict(self) -> dict[str, Any]:
131
+ """Return a serialisable form of the step."""
132
+ return {
133
+ "at": self.at.isoformat(),
134
+ "state": self.state.to_dict(),
135
+ "context": self.context.to_dict(),
136
+ "health": self.health.to_dict(),
137
+ "alerts": [alert.to_dict() for alert in self.alerts],
138
+ "changes": [change.to_dict() for change in self.changes],
139
+ "day_closed": (
140
+ self.day_closed.to_dict() if self.day_closed is not None else None
141
+ ),
142
+ }
143
+
144
+
145
+ class BehaviouralSensingPipeline:
146
+ """Incremental orchestration of the full inference chain.
147
+
148
+ Parameters
149
+ ----------
150
+ registry
151
+ Declared sensors.
152
+ ontology
153
+ Latent behavioural states and their dynamics.
154
+ emissions
155
+ Observation models. Defaults are derived from the registry.
156
+ config
157
+ Pipeline configuration.
158
+ fusion_config, health_config, context_config, baseline_config, alert_policy
159
+ Configuration for each stage.
160
+ """
161
+
162
+ def __init__(
163
+ self,
164
+ registry: SensorRegistry,
165
+ ontology: StateOntology | None = None,
166
+ emissions: Iterable[EmissionModel] | None = None,
167
+ config: PipelineConfig | None = None,
168
+ *,
169
+ fusion_config: FusionConfig | None = None,
170
+ health_config: HealthConfig | None = None,
171
+ context_config: ContextConfig | None = None,
172
+ baseline_config: BaselineConfig | None = None,
173
+ alert_policy: AlertPolicy | None = None,
174
+ ) -> None:
175
+ self.registry = registry
176
+ self.ontology = ontology or StateOntology()
177
+ self.config = config or PipelineConfig()
178
+
179
+ self.ingestor = ObservationIngestor(registry)
180
+ self.health = SensorHealthMonitor(registry, health_config)
181
+ self.context = ResidentContextEstimator(registry, context_config)
182
+ self.filter = MultimodalBayesFilter(
183
+ self.ontology,
184
+ (
185
+ emissions
186
+ if emissions is not None
187
+ else default_emissions(registry, self.ontology)
188
+ ),
189
+ registry=registry,
190
+ config=fusion_config,
191
+ )
192
+ self.alerts = AlertEngine(alert_policy)
193
+ self.baselines = {
194
+ state.value: AdaptiveBaseline(
195
+ f"{state.value}_hours", baseline_config or BaselineConfig()
196
+ )
197
+ for state in self.config.features
198
+ }
199
+ self._baseline_config = baseline_config or BaselineConfig()
200
+
201
+ self._buffer: list[Observation] = []
202
+ self._pending: list[Observation] = []
203
+ self._day_estimates: list[StateEstimate] = []
204
+ self._last_context: ContextEstimate | None = None
205
+ self._current_day: date | None = None
206
+ self._next_step: datetime | None = None
207
+ self.ingestion = IngestionReport()
208
+ self.too_late = 0
209
+
210
+ # ------------------------------------------------------------------
211
+ @property
212
+ def tz(self) -> tzinfo | None:
213
+ """Timezone whose calendar days bound the baseline."""
214
+ return self.config.tz
215
+
216
+ def _local_day(self, moment: datetime) -> date:
217
+ """Return the local calendar date of *moment*."""
218
+ zone = self.config.tz
219
+ return (moment.astimezone(zone) if zone is not None else moment).date()
220
+
221
+ def push(self, observation: Observation) -> bool:
222
+ """Admit one observation into the lateness buffer.
223
+
224
+ Returns
225
+ -------
226
+ bool
227
+ ``True`` when the record was buffered, ``False`` when it was
228
+ rejected at the boundary or arrived beyond the lateness
229
+ tolerance. Rejections are counted, never raised: one malformed
230
+ record must not stop a live stream.
231
+ """
232
+ admitted = self.ingestor.ingest(observation, self.ingestion)
233
+ if admitted is None:
234
+ return False
235
+ if (
236
+ self._next_step is not None
237
+ and admitted.timestamp < self._next_step - self.config.step
238
+ ):
239
+ self.too_late += 1
240
+ logger.debug(
241
+ "Discarding record from '%s' that arrived after its window closed",
242
+ admitted.sensor_id,
243
+ )
244
+ return False
245
+ bisect.insort(self._buffer, admitted, key=lambda obs: obs.timestamp)
246
+ return True
247
+
248
+ def _release(self, watermark: datetime) -> list[Observation]:
249
+ """Release buffered records at or before *watermark*, in time order."""
250
+ cut = bisect.bisect_right([obs.timestamp for obs in self._buffer], watermark)
251
+ released, self._buffer = self._buffer[:cut], self._buffer[cut:]
252
+ return released
253
+
254
+ # ------------------------------------------------------------------
255
+ def advance(self, now: datetime) -> list[PipelineStep]:
256
+ """Advance the pipeline to *now*, returning one result per step.
257
+
258
+ Observations are released from the buffer behind the lateness
259
+ watermark, so a record that arrived out of order still reaches the
260
+ filter in the right place provided it was not later than the
261
+ tolerance.
262
+ """
263
+ watermark = now - self.config.lateness_tolerance
264
+ self._pending.extend(self._release(watermark))
265
+ self._pending.sort(key=lambda obs: obs.timestamp)
266
+
267
+ if self._next_step is None:
268
+ if not self._pending:
269
+ return []
270
+ self._next_step = self._pending[0].timestamp
271
+
272
+ steps: list[PipelineStep] = []
273
+ while self._next_step is not None and self._next_step <= watermark:
274
+ boundary = self._next_step
275
+ cut = bisect.bisect_right(
276
+ [obs.timestamp for obs in self._pending], boundary
277
+ )
278
+ batch, self._pending = self._pending[:cut], self._pending[cut:]
279
+ steps.append(self._step(boundary, batch))
280
+ self._next_step = boundary + self.config.step
281
+ return steps
282
+
283
+ def _step(self, moment: datetime, batch: Sequence[Observation]) -> PipelineStep:
284
+ """Run one pipeline step over the observations of a single interval."""
285
+ self.health.observe_many(batch)
286
+ health = self.health.report(moment)
287
+ reliabilities = health.reliabilities()
288
+
289
+ context = self.context.update(moment, batch, reliabilities=reliabilities)
290
+ self._last_context = context
291
+ attribution = (
292
+ self.context.attribution(context)
293
+ if self.config.attribute_activity
294
+ else None
295
+ )
296
+
297
+ estimate = self.filter.update(
298
+ moment, batch, reliabilities=reliabilities, attribution=attribution
299
+ )
300
+
301
+ day = self._local_day(moment)
302
+ closed: DailySummary | None = None
303
+ changes: tuple[BehaviouralChange, ...] = ()
304
+ alerts: tuple[Alert, ...] = ()
305
+
306
+ if self._current_day is None:
307
+ self._current_day = day
308
+ elif day != self._current_day:
309
+ closed, changes, alerts = self._close_day(
310
+ self._current_day, moment, context
311
+ )
312
+ self._current_day = day
313
+ self._day_estimates = []
314
+
315
+ self._day_estimates.append(estimate)
316
+
317
+ health_alert = self.alerts.consider_health(health, at=moment)
318
+ if health_alert is not None:
319
+ alerts = alerts + (health_alert,)
320
+
321
+ return PipelineStep(
322
+ at=moment,
323
+ state=estimate,
324
+ context=context,
325
+ health=health,
326
+ alerts=alerts,
327
+ changes=changes,
328
+ day_closed=closed,
329
+ )
330
+
331
+ def _close_day(
332
+ self, day: date, moment: datetime, context: ContextEstimate
333
+ ) -> tuple[DailySummary | None, tuple[BehaviouralChange, ...], tuple[Alert, ...]]:
334
+ """Summarise a completed day and update the baselines from it."""
335
+ summaries = summarise_days(
336
+ self._day_estimates, tz=self.config.tz, max_interval=self.config.step * 3
337
+ )
338
+ summary = next((item for item in summaries if item.day == day), None)
339
+ if summary is None:
340
+ return None, (), ()
341
+
342
+ usable = summary.is_usable(
343
+ self.config.min_day_coverage, self.config.min_day_observed
344
+ )
345
+ changes = []
346
+ for state in self.config.features:
347
+ baseline = self.baselines[state.value]
348
+ if usable:
349
+ changes.append(baseline.observe(day, summary.hours_in(state)))
350
+ else:
351
+ changes.append(
352
+ baseline.skip(
353
+ day,
354
+ f"day observed at {summary.observed:.0%} with "
355
+ f"{summary.coverage:.0%} sensor coverage",
356
+ )
357
+ )
358
+
359
+ alerts = self.alerts.review(
360
+ changes,
361
+ at=moment,
362
+ coverage=summary.coverage,
363
+ attribution=context.ambient_attribution(),
364
+ deviation_threshold=self._baseline_config.deviation_threshold,
365
+ trend_threshold=self._baseline_config.trend_threshold,
366
+ )
367
+ return summary, tuple(changes), tuple(alerts)
368
+
369
+ # ------------------------------------------------------------------
370
+ def run(
371
+ self, observations: Iterable[Observation], *, until: datetime | None = None
372
+ ) -> list[PipelineStep]:
373
+ """Process a whole record offline, in arrival order.
374
+
375
+ A convenience wrapper over :meth:`push` and :meth:`advance` for batch
376
+ experiments. It drives exactly the same online code path, so results
377
+ match what a live deployment would have produced.
378
+ """
379
+ steps: list[PipelineStep] = []
380
+ latest: datetime | None = None
381
+ for observation in observations:
382
+ arrival = observation.received_at or observation.timestamp
383
+ self.push(observation)
384
+ if latest is None or arrival > latest:
385
+ latest = arrival
386
+ steps.extend(self.advance(arrival))
387
+ if latest is not None:
388
+ end = (
389
+ until if until is not None else latest + self.config.lateness_tolerance
390
+ )
391
+ steps.extend(self.advance(end + self.config.lateness_tolerance))
392
+ return steps
393
+
394
+ def close(self, now: datetime) -> tuple[PipelineStep, ...]:
395
+ """Flush the buffer and close the final partial day at *now*.
396
+
397
+ The final day is usually incomplete, so its summary will often fail
398
+ the coverage test and be recorded as unusable rather than entering
399
+ the baseline as an unusually quiet day.
400
+ """
401
+ steps = tuple(self.advance(now + self.config.lateness_tolerance * 2))
402
+ if self._current_day is None or not self._day_estimates:
403
+ return steps
404
+
405
+ last = self._day_estimates[-1]
406
+ context = self._last_context
407
+ if context is None: # pragma: no cover - close() before any step
408
+ return steps
409
+
410
+ summary, changes, alerts = self._close_day(self._current_day, last.at, context)
411
+ self._day_estimates = []
412
+ self._current_day = None
413
+ if summary is None:
414
+ return steps
415
+ return steps + (
416
+ PipelineStep(
417
+ at=last.at,
418
+ state=last,
419
+ context=context,
420
+ health=self.health.report(last.at),
421
+ alerts=alerts,
422
+ changes=changes,
423
+ day_closed=summary,
424
+ ),
425
+ )
426
+
427
+ # ------------------------------------------------------------------
428
+ def snapshot(self) -> dict[str, Any]:
429
+ """Return restartable state for every stage.
430
+
431
+ The buffer is deliberately not included. Buffered records have not
432
+ been folded into any belief yet, so re-delivering them after a
433
+ restart is correct; persisting them here would risk counting them
434
+ twice.
435
+ """
436
+ return {
437
+ "ingestor": self.ingestor.snapshot(),
438
+ "health": self.health.snapshot(),
439
+ "context": self.context.snapshot(),
440
+ "filter": self.filter.snapshot(),
441
+ "alerts": self.alerts.snapshot(),
442
+ "baselines": {
443
+ name: baseline.snapshot() for name, baseline in self.baselines.items()
444
+ },
445
+ "current_day": self._current_day.isoformat() if self._current_day else None,
446
+ "next_step": self._next_step.isoformat() if self._next_step else None,
447
+ }
448
+
449
+ def restore(self, state: Mapping[str, Any]) -> None:
450
+ """Restore pipeline state produced by :meth:`snapshot`."""
451
+ self.ingestor.restore(state["ingestor"])
452
+ self.health.restore(state["health"])
453
+ self.context.restore(state["context"])
454
+ self.filter.restore(state["filter"])
455
+ self.alerts.restore(state["alerts"])
456
+ for name, payload in (state.get("baselines") or {}).items():
457
+ baseline = self.baselines.get(name)
458
+ if baseline is not None:
459
+ baseline.restore(payload)
460
+ current_day = state.get("current_day")
461
+ self._current_day = date.fromisoformat(current_day) if current_day else None
462
+ next_step = state.get("next_step")
463
+ self._next_step = datetime.fromisoformat(next_step) if next_step else None
464
+ self._day_estimates = []
465
+
466
+
467
+ def local_midnight(day: date, zone: tzinfo | None) -> datetime:
468
+ """Return the instant local midnight begins on *day*."""
469
+ naive = datetime.combine(day, time.min)
470
+ return naive.replace(tzinfo=zone) if zone is not None else naive
471
+
472
+
473
+ def collect_alerts(steps: Iterable[PipelineStep]) -> list[Alert]:
474
+ """Return every alert raised across a run, in order."""
475
+ return [alert for step in steps for alert in step.alerts]
476
+
477
+
478
+ def collect_changes(steps: Iterable[PipelineStep]) -> list[BehaviouralChange]:
479
+ """Return every baseline verdict produced across a run, in order."""
480
+ return [change for step in steps for change in step.changes]
481
+
482
+
483
+ def daily_summaries(steps: Iterable[PipelineStep]) -> list[DailySummary]:
484
+ """Return the day summaries closed across a run, in order."""
485
+ return [step.day_closed for step in steps if step.day_closed is not None]
@@ -0,0 +1,54 @@
1
+ """Synthetic households with controlled ground truth.
2
+
3
+ Behaviour is generated from a stochastic daily *schedule*, deliberately not
4
+ from the continuous-time chain the inference layer uses. Sharing a generative
5
+ model between simulator and estimator would make good results prove only that
6
+ the code can invert its own assumptions.
7
+
8
+ Sensor faults are injected separately from behaviour, so a robustness study
9
+ can hold the resident fixed and vary only what went wrong with the apparatus.
10
+ """
11
+
12
+ from .faults import (
13
+ DegradationConfig,
14
+ Fault,
15
+ FaultKind,
16
+ degrade,
17
+ dropout,
18
+ not_worn,
19
+ stuck,
20
+ )
21
+ from .household import (
22
+ ACTIVE_RATE,
23
+ IDLE_RATE,
24
+ WEARABLE_LEVEL,
25
+ BehaviourShift,
26
+ Episode,
27
+ GroundTruth,
28
+ HouseholdConfig,
29
+ SimulationResult,
30
+ VisitorPeriod,
31
+ build_registry,
32
+ simulate,
33
+ )
34
+
35
+ __all__ = [
36
+ "ACTIVE_RATE",
37
+ "IDLE_RATE",
38
+ "WEARABLE_LEVEL",
39
+ "BehaviourShift",
40
+ "DegradationConfig",
41
+ "Episode",
42
+ "Fault",
43
+ "FaultKind",
44
+ "GroundTruth",
45
+ "HouseholdConfig",
46
+ "SimulationResult",
47
+ "VisitorPeriod",
48
+ "build_registry",
49
+ "degrade",
50
+ "dropout",
51
+ "not_worn",
52
+ "simulate",
53
+ "stuck",
54
+ ]