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,418 @@
1
+ """End-to-end worked example: from raw sensor traffic to an explained alert.
2
+
3
+ Runs one reproducible experiment covering everything the platform claims to
4
+ handle at once:
5
+
6
+ .. code-block:: text
7
+
8
+ a synthetic household over several months
9
+ seven sensing modalities
10
+ a carer and occasional visitors tripping the same ambient sensors
11
+ a bed sensor that dies for three days
12
+ a wearable left off for five days
13
+ records lost, duplicated, delayed, and stamped by a drifting clock
14
+ a genuine persistent change in the resident's sleep, on a known day
15
+ probabilistic state inference with abstention
16
+ an adaptive, weekday-aware personal baseline
17
+ an explained alert, with its caveats
18
+
19
+ Everything is seeded, so two runs of the same command produce identical
20
+ numbers. The demonstration deliberately reports what the system got wrong as
21
+ well as what it got right: the point is a defensible account of the platform's
22
+ behaviour, not a favourable one.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ from collections.abc import Sequence
30
+ from datetime import date, timedelta
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from ...alerts.alert import AlertKind
35
+ from ...baseline.adaptive import ChangeKind
36
+ from ...evaluation.metrics import (
37
+ binary_metrics,
38
+ detection_metrics,
39
+ state_metrics,
40
+ transition_timing,
41
+ )
42
+ from ...online.pipeline import (
43
+ BehaviouralSensingPipeline,
44
+ PipelineConfig,
45
+ PipelineStep,
46
+ collect_alerts,
47
+ collect_changes,
48
+ daily_summaries,
49
+ )
50
+ from ...simulation.faults import DegradationConfig, degrade, dropout, not_worn
51
+ from ...simulation.household import BehaviourShift, HouseholdConfig, simulate
52
+ from ...states.ontology import BehaviouralState
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ S = BehaviouralState
57
+
58
+ #: The demonstration's fixed defaults. Changing any of these changes every
59
+ #: number the demo prints, so they are stated in one place.
60
+ DEFAULT_DAYS = 90
61
+ DEFAULT_SEED = 20240304
62
+ CHANGE_DAY_INDEX = 60
63
+
64
+
65
+ def build_scenario(
66
+ days: int = DEFAULT_DAYS, seed: int = DEFAULT_SEED
67
+ ) -> tuple[Any, list, dict[str, Any]]:
68
+ """Build the household, inject faults, and degrade the record.
69
+
70
+ Returns the simulation result, the delivered observations, and a summary
71
+ of exactly what was injected, so the demo can report the truth it is
72
+ being scored against.
73
+ """
74
+ change_day = min(CHANGE_DAY_INDEX, max(days - 20, 5))
75
+ household = HouseholdConfig(
76
+ days=days,
77
+ seed=seed,
78
+ shift=BehaviourShift(
79
+ start_day=change_day,
80
+ sleep_delta_hours=1.6,
81
+ night_bathroom_extra=1.4,
82
+ ),
83
+ )
84
+ result = simulate(household)
85
+
86
+ bed_fault_start = result.start + timedelta(days=max(days // 3, 2))
87
+ wearable_fault_start = result.start + timedelta(days=max(days // 2, 3))
88
+ faults = (
89
+ dropout("bed_pressure", bed_fault_start, timedelta(days=3)),
90
+ *not_worn(
91
+ ["wearable_motion", "resident_beacon"],
92
+ wearable_fault_start,
93
+ timedelta(days=5),
94
+ ),
95
+ )
96
+ degradation = DegradationConfig(
97
+ missing_rate=0.03,
98
+ duplication_rate=0.02,
99
+ late_rate=0.05,
100
+ late_delay=timedelta(minutes=4),
101
+ clock_drift={"sim-radar": timedelta(seconds=45)},
102
+ faults=faults,
103
+ seed=seed + 1,
104
+ )
105
+ delivered, withheld = degrade(result.observations, degradation)
106
+
107
+ injected = {
108
+ "days": days,
109
+ "seed": seed,
110
+ "change_day": (result.config.start + timedelta(days=change_day)).isoformat(),
111
+ "change_description": (
112
+ "sleep shortened by ~1.6 h and night bathroom trips increased"
113
+ ),
114
+ "faults": [fault.to_dict() for fault in faults],
115
+ "records_generated": len(result.observations),
116
+ "records_delivered": len(delivered),
117
+ "records_withheld": len(withheld),
118
+ }
119
+ return result, delivered, injected
120
+
121
+
122
+ def run_pipeline(result: Any, delivered: Sequence, step: timedelta) -> tuple:
123
+ """Run the delivered record through the online pipeline."""
124
+ pipeline = BehaviouralSensingPipeline(
125
+ result.registry, config=PipelineConfig(tz=result.config.tz, step=step)
126
+ )
127
+ steps = pipeline.run(delivered)
128
+ steps.extend(pipeline.close(result.end))
129
+ return pipeline, steps
130
+
131
+
132
+ def _transitions(values: Sequence, times: Sequence) -> list:
133
+ """Return the times at which a sequence of labels changes."""
134
+ return [
135
+ moment
136
+ for previous, current, moment in zip(values, values[1:], times[1:])
137
+ if previous is not current
138
+ ]
139
+
140
+
141
+ def evaluate(result: Any, steps: Sequence[PipelineStep]) -> dict[str, Any]:
142
+ """Score the run against the ground truth the simulator recorded."""
143
+ moments = [step.at for step in steps]
144
+ truth = result.truth.states_at(moments)
145
+
146
+ states = state_metrics(truth, [step.state for step in steps])
147
+ visitors = binary_metrics(
148
+ [result.truth.visitor_at(moment) for moment in moments],
149
+ [step.context.visitor_present for step in steps],
150
+ )
151
+ timing = transition_timing(
152
+ _transitions(truth, moments),
153
+ _transitions([step.state.state for step in steps], moments),
154
+ tolerance=timedelta(minutes=45),
155
+ )
156
+ return {"state": states, "visitor": visitors, "timing": timing}
157
+
158
+
159
+ def _fault_recognition(steps: Sequence[PipelineStep]) -> dict[str, dict[str, object]]:
160
+ """Return which sensors the health monitor judged faulty, and for how long.
161
+
162
+ ``UNKNOWN`` is excluded: every event sensor starts there before its first
163
+ report, and reporting that as a fault would make a healthy deployment
164
+ look broken.
165
+ """
166
+ faults: dict[str, set[str]] = {}
167
+ duration: dict[str, int] = {}
168
+ for step in steps:
169
+ for sensor_id, report in step.health.sensors.items():
170
+ if report.is_faulty:
171
+ faults.setdefault(sensor_id, set()).add(report.status.value)
172
+ duration[sensor_id] = duration.get(sensor_id, 0) + 1
173
+ return {
174
+ sensor: {"statuses": sorted(statuses), "steps_faulty": duration[sensor]}
175
+ for sensor, statuses in sorted(faults.items())
176
+ }
177
+
178
+
179
+ def _change_report(
180
+ steps: Sequence[PipelineStep], change_day: date, person_days: float
181
+ ) -> dict[str, Any]:
182
+ """Summarise what the baseline concluded and when it concluded it."""
183
+ changes = collect_changes(steps)
184
+ sleep_changes = [change for change in changes if change.feature == "sleeping_hours"]
185
+
186
+ # Score the alerts a carer would actually receive, not the raw verdicts.
187
+ # Deduplication and rate limiting sit between the two, and it is the
188
+ # delivered burden that decides whether a system is usable.
189
+ detected = [
190
+ alert.at.date()
191
+ for alert in collect_alerts(steps)
192
+ if alert.kind is AlertKind.BEHAVIOURAL_CHANGE
193
+ and "sleeping" in str(alert.subject)
194
+ ]
195
+ metrics = detection_metrics(
196
+ detected, [change_day], person_days=person_days, max_delay_days=21.0
197
+ )
198
+ kinds: dict[str, int] = {}
199
+ for change in changes:
200
+ kinds[change.kind.value] = kinds.get(change.kind.value, 0) + 1
201
+ return {
202
+ "verdict_counts": kinds,
203
+ "sleep_changes": len([c for c in sleep_changes if c.is_change]),
204
+ "skipped_days": len(
205
+ [c for c in sleep_changes if c.kind is ChangeKind.INSUFFICIENT_DATA]
206
+ ),
207
+ "detection": metrics,
208
+ }
209
+
210
+
211
+ def _format_alerts(steps: Sequence[PipelineStep], limit: int = 4) -> list[str]:
212
+ """Render the alerts raised, with their evidence and caveats."""
213
+ lines: list[str] = []
214
+ for alert in collect_alerts(steps)[:limit]:
215
+ lines.append(
216
+ f" [{alert.severity.value:>11}] {alert.kind.value}: {alert.summary}"
217
+ )
218
+ lines.append(
219
+ f" confidence {alert.confidence:.2f}, score {alert.score:.2f}"
220
+ )
221
+ for caveat in alert.caveats:
222
+ lines.append(f" caveat: {caveat}")
223
+ return lines
224
+
225
+
226
+ def report(
227
+ result: Any,
228
+ pipeline: BehaviouralSensingPipeline,
229
+ steps: Sequence[PipelineStep],
230
+ injected: dict[str, Any],
231
+ ) -> dict[str, Any]:
232
+ """Assemble the full structured result of the demonstration."""
233
+ scores = evaluate(result, steps)
234
+ summaries = daily_summaries(steps)
235
+ change_day = date.fromisoformat(injected["change_day"])
236
+ person_days = float(injected["days"])
237
+
238
+ return {
239
+ "scenario": injected,
240
+ "ingestion": {
241
+ **pipeline.ingestion.to_dict(),
242
+ "arrived_too_late": pipeline.too_late,
243
+ },
244
+ "state_inference": scores["state"].to_dict(),
245
+ "visitor_attribution": scores["visitor"].to_dict(),
246
+ "transition_timing": scores["timing"].to_dict(),
247
+ "sensor_health": _fault_recognition(steps),
248
+ "days_summarised": len(summaries),
249
+ "days_excluded_from_baseline": len([s for s in summaries if not s.is_usable()]),
250
+ "behavioural_change": {
251
+ **{
252
+ key: value
253
+ for key, value in _change_report(steps, change_day, person_days).items()
254
+ if key != "detection"
255
+ },
256
+ "detection": _change_report(steps, change_day, person_days)[
257
+ "detection"
258
+ ].to_dict(),
259
+ },
260
+ "alerts": [alert.to_dict() for alert in collect_alerts(steps)],
261
+ }
262
+
263
+
264
+ def render(payload: dict[str, Any], steps: Sequence[PipelineStep]) -> str:
265
+ """Render the demonstration as readable text."""
266
+ scenario = payload["scenario"]
267
+ state = payload["state_inference"]
268
+ visitor = payload["visitor_attribution"]
269
+ timing = payload["transition_timing"]
270
+ change = payload["behavioural_change"]
271
+ detection = change["detection"]
272
+
273
+ lines = [
274
+ "=" * 72,
275
+ "AMBIENT BEHAVIOURAL SENSING -- END-TO-END DEMONSTRATION",
276
+ "=" * 72,
277
+ "",
278
+ "SCENARIO (ground truth, hidden from inference)",
279
+ f" {scenario['days']} days, seed {scenario['seed']}",
280
+ f" behavioural change on {scenario['change_day']}:",
281
+ f" {scenario['change_description']}",
282
+ " injected sensor faults:",
283
+ ]
284
+ for fault in scenario["faults"]:
285
+ lines.append(
286
+ f" {fault['sensor_id']:<18} {fault['kind']:<14} "
287
+ f"from {fault['start'][:16]} to {fault['end'][:16]}"
288
+ )
289
+ lines += [
290
+ f" records generated {scenario['records_generated']}, "
291
+ f"delivered {scenario['records_delivered']}, "
292
+ f"withheld {scenario['records_withheld']}",
293
+ "",
294
+ "INGESTION",
295
+ f" accepted {payload['ingestion']['accepted']}, "
296
+ f"rejected {len(payload['ingestion']['rejected'])}, "
297
+ f"out of order {payload['ingestion']['out_of_order']}, "
298
+ f"late {payload['ingestion']['late_arrivals']}, "
299
+ f"too late to use {payload['ingestion']['arrived_too_late']}",
300
+ "",
301
+ "STATE INFERENCE (against schedule-generated truth)",
302
+ f" balanced accuracy {state['balanced_accuracy']:.3f}",
303
+ f" macro F1 {state['macro_f1']:.3f}",
304
+ f" accuracy {state['accuracy']:.3f}"
305
+ f" (abstained on {state['abstention_rate']:.1%})",
306
+ f" log loss {state['log_loss']:.3f}",
307
+ f" Brier score {state['brier']:.3f}",
308
+ f" calibration error {state['calibration_error']:.3f}",
309
+ " per-state recall:",
310
+ ]
311
+ for name, value in sorted(state["per_class_recall"].items()):
312
+ lines.append(f" {name:<20} {value:.3f}")
313
+
314
+ lines += [
315
+ "",
316
+ "OCCUPANCY ATTRIBUTION (was it the resident, or a visitor?)",
317
+ f" visitor precision {visitor['precision']:.3f}, "
318
+ f"recall {visitor['recall']:.3f}, F1 {visitor['f1']:.3f}",
319
+ f" calibration error {visitor['calibration_error']:.3f}"
320
+ f" (visitors present {visitor['positive_rate']:.1%} of the time)",
321
+ "",
322
+ "TRANSITION TIMING",
323
+ f" matched {timing['matched']} of {timing['true_transitions']} "
324
+ f"true transitions ({timing['matched_fraction']:.1%})",
325
+ f" median offset {timing['median_error_seconds'] / 60:+.1f} min",
326
+ "",
327
+ "SENSOR HEALTH (independent of any behavioural conclusion)",
328
+ ]
329
+ if payload["sensor_health"]:
330
+ for sensor, detail in payload["sensor_health"].items():
331
+ lines.append(
332
+ f" {sensor:<18} {', '.join(detail['statuses']):<34}"
333
+ f" over {detail['steps_faulty']} steps"
334
+ )
335
+ else:
336
+ lines.append(" no sensor was ever judged faulty")
337
+
338
+ lines += [
339
+ "",
340
+ "ADAPTIVE BASELINE",
341
+ f" {payload['days_summarised']} days summarised, "
342
+ f"{payload['days_excluded_from_baseline']} excluded as poorly observed",
343
+ f" verdicts: {change['verdict_counts']}",
344
+ "",
345
+ "BEHAVIOURAL CHANGE DETECTION",
346
+ f" true change on {scenario['change_day']}",
347
+ f" behavioural alerts delivered: {detection['detected']} matched of "
348
+ f"{detection['true_changes']} true change(s), "
349
+ f"median delay {detection['median_delay_days']:.0f} days",
350
+ f" unmatched behavioural alerts {detection['false_positives']} "
351
+ f"({detection['false_positives_per_person_day']:.3f} per person-day)",
352
+ "",
353
+ f"ALERTS RAISED ({len(payload['alerts'])})",
354
+ ]
355
+ lines.extend(_format_alerts(steps) or [" none"])
356
+
357
+ explained = next(
358
+ (
359
+ alert
360
+ for step in steps
361
+ for alert in step.alerts
362
+ if alert.kind is AlertKind.BEHAVIOURAL_CHANGE
363
+ ),
364
+ None,
365
+ )
366
+ if explained is not None:
367
+ evidence: dict[str, Any] = dict(explained.evidence)
368
+ change_detail: dict[str, Any] = dict(evidence.get("change") or {})
369
+ reference: dict[str, Any] = dict(change_detail.get("reference") or {})
370
+ lines += [
371
+ "",
372
+ "WORKED EXPLANATION OF THE FIRST BEHAVIOURAL ALERT",
373
+ f" {explained.summary}",
374
+ f" feature {change_detail.get('feature')}",
375
+ f" observed {change_detail.get('value', float('nan')):.2f} h",
376
+ f" reference {reference.get('centre', float('nan')):.2f} h "
377
+ f"(robust scale {reference.get('scale', float('nan')):.2f})",
378
+ f" deviation {change_detail.get('deviation', float('nan')):+.2f} "
379
+ "robust SD",
380
+ f" trend {change_detail.get('slope_per_day', float('nan')):+.3f} "
381
+ "per day",
382
+ f" held for {change_detail.get('duration_days')} days",
383
+ f" weekday-aware {reference.get('weekday_aware')}",
384
+ ]
385
+
386
+ sample = steps[len(steps) // 2]
387
+ lines += [
388
+ "",
389
+ "A SINGLE INFERENCE, EXPLAINED",
390
+ f" {sample.state.explain()}",
391
+ f" occupancy: resident home {sample.context.resident_home:.2f}, "
392
+ f"visitor {sample.context.visitor_present:.2f}, "
393
+ f"attribution {sample.context.ambient_attribution():.2f}",
394
+ "",
395
+ "=" * 72,
396
+ "This is a research toolkit. Nothing above is a clinical finding, and",
397
+ "no part of this system is a medical device.",
398
+ "=" * 72,
399
+ ]
400
+ return "\n".join(lines)
401
+
402
+
403
+ def run_demo(
404
+ days: int = DEFAULT_DAYS,
405
+ seed: int = DEFAULT_SEED,
406
+ step: timedelta = timedelta(minutes=10),
407
+ output: Path | None = None,
408
+ ) -> dict[str, Any]:
409
+ """Run the full demonstration and return its structured result."""
410
+ result, delivered, injected = build_scenario(days=days, seed=seed)
411
+ pipeline, steps = run_pipeline(result, delivered, step)
412
+ payload = report(result, pipeline, steps, injected)
413
+ print(render(payload, steps))
414
+ if output is not None:
415
+ output.parent.mkdir(parents=True, exist_ok=True)
416
+ output.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
417
+ print(f"\nStructured results written to {output}")
418
+ return payload