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.
- sensor_modeling/__init__.py +45 -0
- sensor_modeling/alerts/__init__.py +26 -0
- sensor_modeling/alerts/alert.py +532 -0
- sensor_modeling/analysis/__init__.py +43 -0
- sensor_modeling/analysis/_frame.py +19 -0
- sensor_modeling/analysis/behavioral_analysis.py +57 -0
- sensor_modeling/analysis/behavioral_metrics.py +66 -0
- sensor_modeling/analysis/comparison.py +164 -0
- sensor_modeling/analysis/dependency_network.py +408 -0
- sensor_modeling/analysis/granger_causality.py +314 -0
- sensor_modeling/analysis/pipeline.py +168 -0
- sensor_modeling/analysis/reporting.py +109 -0
- sensor_modeling/baseline/__init__.py +30 -0
- sensor_modeling/baseline/adaptive.py +520 -0
- sensor_modeling/baseline/features.py +224 -0
- sensor_modeling/change_point/__init__.py +13 -0
- sensor_modeling/change_point/_validation.py +31 -0
- sensor_modeling/change_point/adaptive_normalization.py +55 -0
- sensor_modeling/change_point/embedding_cpd.py +60 -0
- sensor_modeling/change_point/energy_efficient.py +57 -0
- sensor_modeling/change_point/genetic_optimization.py +65 -0
- sensor_modeling/cli.py +416 -0
- sensor_modeling/context/__init__.py +33 -0
- sensor_modeling/context/occupancy.py +529 -0
- sensor_modeling/data/__init__.py +5 -0
- sensor_modeling/data/loaders.py +146 -0
- sensor_modeling/data/preprocessing.py +83 -0
- sensor_modeling/data/synthetic.py +121 -0
- sensor_modeling/data/validation.py +81 -0
- sensor_modeling/evaluation/__init__.py +92 -0
- sensor_modeling/evaluation/ablation.py +303 -0
- sensor_modeling/evaluation/attribution.py +474 -0
- sensor_modeling/evaluation/detection.py +297 -0
- sensor_modeling/evaluation/metrics.py +541 -0
- sensor_modeling/evaluation/provenance.py +309 -0
- sensor_modeling/examples/__init__.py +1 -0
- sensor_modeling/examples/demos/__init__.py +1 -0
- sensor_modeling/examples/demos/ambient_pipeline_demo.py +418 -0
- sensor_modeling/examples/demos/bernoulli_ar_demo.py +356 -0
- sensor_modeling/examples/demos/cpd_ar_demo.py +25 -0
- sensor_modeling/examples/demos/cpd_benchmark.py +42 -0
- sensor_modeling/examples/demos/hmm_granger_demo.py +30 -0
- sensor_modeling/examples/demos/nhpp_pelt_demo.py +80 -0
- sensor_modeling/examples/tutorials/__init__.py +1 -0
- sensor_modeling/fusion/__init__.py +46 -0
- sensor_modeling/fusion/defaults.py +296 -0
- sensor_modeling/fusion/emissions.py +339 -0
- sensor_modeling/fusion/estimate.py +375 -0
- sensor_modeling/fusion/filter.py +323 -0
- sensor_modeling/health/__init__.py +31 -0
- sensor_modeling/health/monitor.py +590 -0
- sensor_modeling/health/status.py +74 -0
- sensor_modeling/hmm/__init__.py +15 -0
- sensor_modeling/hmm/adaptive_hmm.py +22 -0
- sensor_modeling/hmm/base.py +134 -0
- sensor_modeling/hmm/circadian_hmm.py +22 -0
- sensor_modeling/hmm/heterogeneous_hmm.py +22 -0
- sensor_modeling/hmm/hierarchical_hmm.py +35 -0
- sensor_modeling/hmm/scaled_dirichlet_hmm.py +23 -0
- sensor_modeling/interop/__init__.py +57 -0
- sensor_modeling/interop/fhir.py +418 -0
- sensor_modeling/interop/privacy.py +308 -0
- sensor_modeling/models/__init__.py +12 -0
- sensor_modeling/models/bernoulli_ar/__init__.py +6 -0
- sensor_modeling/models/bernoulli_ar/base_model.py +569 -0
- sensor_modeling/models/bernoulli_ar/multivariate_model.py +411 -0
- sensor_modeling/models/change_point_detection/__init__.py +10 -0
- sensor_modeling/models/change_point_detection/deep.py +65 -0
- sensor_modeling/models/change_point_detection/pelt.py +159 -0
- sensor_modeling/models/nhpp_pelt/__init__.py +5 -0
- sensor_modeling/models/nhpp_pelt/bspline.py +96 -0
- sensor_modeling/models/nhpp_pelt/cli.py +243 -0
- sensor_modeling/models/nhpp_pelt/diagnostics.py +234 -0
- sensor_modeling/models/nhpp_pelt/io.py +58 -0
- sensor_modeling/models/nhpp_pelt/model.py +408 -0
- sensor_modeling/models/nhpp_pelt/optimizer.py +142 -0
- sensor_modeling/models/nhpp_pelt/plotting.py +218 -0
- sensor_modeling/models/nhpp_pelt/quad.py +72 -0
- sensor_modeling/models/nhpp_pelt/regularization.py +121 -0
- sensor_modeling/models/nhpp_pelt/utils.py +174 -0
- sensor_modeling/observations/__init__.py +59 -0
- sensor_modeling/observations/adapters.py +195 -0
- sensor_modeling/observations/ingest.py +269 -0
- sensor_modeling/observations/observation.py +270 -0
- sensor_modeling/observations/registry.py +262 -0
- sensor_modeling/observations/stream.py +342 -0
- sensor_modeling/observations/types.py +107 -0
- sensor_modeling/observations/units.py +117 -0
- sensor_modeling/online/__init__.py +36 -0
- sensor_modeling/online/benchmarks.py +242 -0
- sensor_modeling/online/pipeline.py +485 -0
- sensor_modeling/simulation/__init__.py +54 -0
- sensor_modeling/simulation/faults.py +191 -0
- sensor_modeling/simulation/household.py +862 -0
- sensor_modeling/states/__init__.py +23 -0
- sensor_modeling/states/markov.py +105 -0
- sensor_modeling/states/ontology.py +238 -0
- sensor_modeling/utils/__init__.py +41 -0
- sensor_modeling/utils/data_io.py +199 -0
- sensor_modeling/utils/logging_config.py +10 -0
- sensor_modeling/utils/missing.py +188 -0
- sensor_modeling/utils/plotting.py +98 -0
- sensor_modeling/utils/validation.py +117 -0
- sensor_modeling/visualization/__init__.py +3 -0
- sensor_modeling/visualization/clinical.py +67 -0
- sensor_modeling/visualization/interactive.py +208 -0
- sensor_modeling/visualization/research.py +60 -0
- sensor_modeling/visualization/web_app.py +137 -0
- sensor_modeling-0.2.0.dist-info/METADATA +683 -0
- sensor_modeling-0.2.0.dist-info/RECORD +114 -0
- sensor_modeling-0.2.0.dist-info/WHEEL +5 -0
- sensor_modeling-0.2.0.dist-info/entry_points.txt +18 -0
- sensor_modeling-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|