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,45 @@
1
+ """Unified package for sensor modeling and analysis.
2
+
3
+ The package has two layers. The original modelling core provides statistical
4
+ models, analysis routines and visualisation for behavioural sensor data. On
5
+ top of it sits a multimodal ambient-sensing pipeline that runs from raw sensor
6
+ traffic to an explained alert:
7
+
8
+ .. code-block:: text
9
+
10
+ observations -> health -> context -> fusion -> baseline -> alerts
11
+
12
+ See ``docs/ambient_architecture.md`` for the architecture and
13
+ ``docs/limitations.md`` for what the platform does not establish.
14
+ """
15
+
16
+ __version__ = "0.2.0"
17
+
18
+ #: Subpackages of the original modelling core.
19
+ CORE_MODULES = (
20
+ "models",
21
+ "analysis",
22
+ "utils",
23
+ "change_point",
24
+ "hmm",
25
+ "data",
26
+ "visualization",
27
+ )
28
+
29
+ #: Subpackages of the multimodal ambient-sensing pipeline, in the order the
30
+ #: data flows through them.
31
+ AMBIENT_MODULES = (
32
+ "observations",
33
+ "health",
34
+ "context",
35
+ "states",
36
+ "fusion",
37
+ "baseline",
38
+ "alerts",
39
+ "online",
40
+ "simulation",
41
+ "evaluation",
42
+ "interop",
43
+ )
44
+
45
+ __all__ = [*CORE_MODULES, *AMBIENT_MODULES, "CORE_MODULES", "AMBIENT_MODULES"]
@@ -0,0 +1,26 @@
1
+ """Restrained, explainable alerting.
2
+
3
+ An unusual observation is not an alert, and neither is every behavioural
4
+ change. This package applies the last filter -- magnitude, duration,
5
+ observation quality, attribution, deduplication and rate limiting -- and
6
+ keeps alerts about the sensing apparatus strictly separate from alerts about
7
+ the resident.
8
+ """
9
+
10
+ from .alert import (
11
+ Alert,
12
+ AlertEngine,
13
+ AlertKind,
14
+ AlertPolicy,
15
+ AlertSeverity,
16
+ unresolved_kinds,
17
+ )
18
+
19
+ __all__ = [
20
+ "Alert",
21
+ "AlertEngine",
22
+ "AlertKind",
23
+ "AlertPolicy",
24
+ "AlertSeverity",
25
+ "unresolved_kinds",
26
+ ]
@@ -0,0 +1,532 @@
1
+ """Structured, explainable alerts with deliberate restraint.
2
+
3
+ Four things are kept strictly separate in this codebase, and this module is
4
+ where the last boundary is drawn:
5
+
6
+ .. code-block:: text
7
+
8
+ observation a sensor reported something
9
+ state the resident is probably doing something
10
+ behavioural change that something has shifted against their own history
11
+ alert a person should look at this
12
+
13
+ An unusual observation is not an alert. A behavioural change is not
14
+ automatically an alert either. Raising one is a claim on somebody's attention,
15
+ and in this domain a stream of false alarms is not a minor annoyance -- it is
16
+ the failure mode that gets monitoring switched off entirely.
17
+
18
+ So an alert has to survive several filters. The change must be large enough,
19
+ have lasted long enough, have been observed well enough, and be attributable
20
+ to the resident rather than to a visitor. Alerts that repeat are deduplicated,
21
+ and a burst is capped rather than delivered.
22
+
23
+ Behavioural alerts and system-health alerts are separate kinds. A failing
24
+ sensor produces a system-health alert about the apparatus, never a behavioural
25
+ alert about the resident. Alerts describe observed changes in sensor-derived
26
+ behaviour; they do not diagnose.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import logging
33
+ from collections.abc import Mapping, Sequence
34
+ from dataclasses import dataclass, field
35
+ from datetime import datetime, timedelta
36
+ from enum import Enum
37
+
38
+ from ..baseline.adaptive import BehaviouralChange, ChangeKind
39
+ from ..health.monitor import SystemHealthReport
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class AlertKind(str, Enum):
45
+ """What an alert is about."""
46
+
47
+ BEHAVIOURAL_CHANGE = "behavioural_change"
48
+ """A sustained change in the resident's own behavioural pattern."""
49
+
50
+ SYSTEM_HEALTH = "system_health"
51
+ """The sensing apparatus is degraded. Says nothing about the resident."""
52
+
53
+ DATA_QUALITY = "data_quality"
54
+ """Too little was observed to say anything about behaviour at all."""
55
+
56
+
57
+ class AlertSeverity(str, Enum):
58
+ """How much attention an alert asks for."""
59
+
60
+ INFORMATION = "information"
61
+ ATTENTION = "attention"
62
+ URGENT = "urgent"
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Alert:
67
+ """A structured, explainable request for human attention.
68
+
69
+ Attributes
70
+ ----------
71
+ at
72
+ When the alert was raised.
73
+ kind
74
+ Whether this concerns behaviour, the apparatus, or data quality.
75
+ severity
76
+ How much attention is being asked for.
77
+ subject
78
+ The feature, sensor, or subsystem the alert is about.
79
+ summary
80
+ One-line description, phrased as an observation rather than a
81
+ diagnosis.
82
+ score
83
+ The graded strength that produced the severity, in ``[0, 1]``.
84
+ confidence
85
+ How well-evidenced the alert is, combining sensor coverage and
86
+ attribution.
87
+ evidence
88
+ Structured detail supporting the alert.
89
+ caveats
90
+ Reasons to treat the alert cautiously, stated explicitly rather than
91
+ left for the reader to infer.
92
+ """
93
+
94
+ at: datetime
95
+ kind: AlertKind
96
+ severity: AlertSeverity
97
+ subject: str
98
+ summary: str
99
+ score: float
100
+ confidence: float
101
+ evidence: Mapping[str, object] = field(default_factory=dict)
102
+ caveats: tuple[str, ...] = ()
103
+
104
+ @property
105
+ def dedup_key(self) -> str:
106
+ """Stable key identifying repeats of the same finding."""
107
+ return f"{self.kind.value}:{self.subject}"
108
+
109
+ @property
110
+ def identifier(self) -> str:
111
+ """Deterministic identifier for this alert instance."""
112
+ digest = hashlib.sha256(
113
+ f"{self.dedup_key}|{self.at.isoformat()}|{self.severity.value}".encode()
114
+ )
115
+ return digest.hexdigest()[:16]
116
+
117
+ def to_dict(self) -> dict[str, object]:
118
+ """Return a serialisable form of the alert."""
119
+ return {
120
+ "id": self.identifier,
121
+ "at": self.at.isoformat(),
122
+ "kind": self.kind.value,
123
+ "severity": self.severity.value,
124
+ "subject": self.subject,
125
+ "summary": self.summary,
126
+ "score": self.score,
127
+ "confidence": self.confidence,
128
+ "evidence": dict(self.evidence),
129
+ "caveats": list(self.caveats),
130
+ }
131
+
132
+
133
+ @dataclass
134
+ class AlertPolicy:
135
+ """Thresholds and restraints governing when an alert is raised.
136
+
137
+ Parameters
138
+ ----------
139
+ min_score
140
+ Graded strength below which nothing is raised at all.
141
+ attention_score, urgent_score
142
+ Strength thresholds for the two higher severities.
143
+ min_confidence
144
+ Combined coverage-and-attribution confidence required before a
145
+ behavioural alert may be raised. Below this the change is real as an
146
+ observation but not attributable well enough to act on.
147
+ importance
148
+ Per-feature multiplier. Some behavioural features matter more than
149
+ others, and the deployment says which rather than the algorithm.
150
+ cooldown
151
+ Period during which a repeat of the same finding is suppressed,
152
+ unless its severity has increased.
153
+ storm_window, max_per_window
154
+ A burst larger than this is capped, and a single summary alert is
155
+ raised in place of the flood.
156
+ health_coverage_floor
157
+ System coverage below which a system-health alert is raised.
158
+ """
159
+
160
+ min_score: float = 0.25
161
+ attention_score: float = 0.45
162
+ urgent_score: float = 0.7
163
+ min_confidence: float = 0.4
164
+ importance: Mapping[str, float] = field(default_factory=dict)
165
+ cooldown: timedelta = timedelta(hours=20)
166
+ storm_window: timedelta = timedelta(hours=24)
167
+ max_per_window: int = 6
168
+ health_coverage_floor: float = 0.5
169
+
170
+ def __post_init__(self) -> None:
171
+ """Validate the policy."""
172
+ if not 0.0 <= self.min_score < self.attention_score < self.urgent_score <= 1.0:
173
+ raise ValueError(
174
+ "scores must satisfy 0 <= min_score < attention_score < "
175
+ "urgent_score <= 1"
176
+ )
177
+ if not 0.0 <= self.min_confidence <= 1.0:
178
+ raise ValueError("min_confidence must lie in [0, 1]")
179
+ if not 0.0 <= self.health_coverage_floor <= 1.0:
180
+ raise ValueError("health_coverage_floor must lie in [0, 1]")
181
+ if self.cooldown < timedelta(0):
182
+ raise ValueError("cooldown must be non-negative")
183
+ if self.storm_window <= timedelta(0):
184
+ raise ValueError("storm_window must be positive")
185
+ if self.max_per_window < 1:
186
+ raise ValueError("max_per_window must be at least 1")
187
+ if any(float(v) < 0.0 for v in self.importance.values()):
188
+ raise ValueError("importance weights must be non-negative")
189
+
190
+ def severity_for(self, score: float) -> AlertSeverity:
191
+ """Return the severity band a graded score falls into."""
192
+ if score >= self.urgent_score:
193
+ return AlertSeverity.URGENT
194
+ if score >= self.attention_score:
195
+ return AlertSeverity.ATTENTION
196
+ return AlertSeverity.INFORMATION
197
+
198
+
199
+ class AlertEngine:
200
+ """Turn behavioural changes and health reports into restrained alerts.
201
+
202
+ The engine holds bounded state -- the last emission per finding plus a
203
+ short window of recent alerts -- so it runs indefinitely on an edge
204
+ device and survives a restart through :meth:`snapshot`.
205
+ """
206
+
207
+ def __init__(self, policy: AlertPolicy | None = None) -> None:
208
+ self.policy = policy or AlertPolicy()
209
+ self._last_emitted: dict[str, tuple[datetime, AlertSeverity]] = {}
210
+ self._recent: list[datetime] = []
211
+ self._storm_notified: datetime | None = None
212
+
213
+ # ------------------------------------------------------------------
214
+ def _grade(
215
+ self,
216
+ change: BehaviouralChange,
217
+ deviation_threshold: float,
218
+ trend_threshold: float,
219
+ ) -> float:
220
+ """Score a behavioural change on how large it is and how long it held.
221
+
222
+ Both matter and neither is sufficient: a huge one-day excursion is a
223
+ disturbance, and a marginal shift that holds for a fortnight is worth
224
+ more attention than its size alone suggests.
225
+
226
+ A gradual drift is graded differently, because it has no deviation
227
+ streak to measure -- that is precisely what distinguishes it from a
228
+ step. Grading it on the same axes would score every slow decline at
229
+ zero and make the most clinically interesting pattern in ambient
230
+ monitoring permanently unalertable. Its magnitude is the movement
231
+ that identified it, and it carries a duration credit by
232
+ construction, since a drift is only ever declared across a whole
233
+ trend window.
234
+
235
+ The offset is set so that a drift which has only just crossed the
236
+ baseline's threshold lands in the middle band rather than the top
237
+ one: qualifying as a drift is a low bar, and a slow decline that has
238
+ only just become visible does not warrant the same response as one
239
+ moving twice as fast.
240
+ """
241
+ if change.kind is ChangeKind.GRADUAL_DRIFT:
242
+ magnitude = min(change.trend_strength / (2.0 * trend_threshold), 1.0)
243
+ return 0.35 + 0.5 * magnitude
244
+ magnitude = min(abs(change.deviation) / (2.0 * deviation_threshold), 1.0)
245
+ duration = min(change.duration_days / 7.0, 1.0)
246
+ return 0.5 * magnitude + 0.5 * duration
247
+
248
+ def _suppressed(self, key: str, at: datetime, severity: AlertSeverity) -> bool:
249
+ """Whether this finding was raised too recently to raise again."""
250
+ previous = self._last_emitted.get(key)
251
+ if previous is None:
252
+ return False
253
+ last_at, last_severity = previous
254
+ if at - last_at >= self.policy.cooldown:
255
+ return False
256
+ escalated = _severity_rank(severity) > _severity_rank(last_severity)
257
+ return not escalated
258
+
259
+ def _prune(self, at: datetime) -> None:
260
+ """Drop recent-alert records that have left the storm window."""
261
+ cutoff = at - self.policy.storm_window
262
+ self._recent = [moment for moment in self._recent if moment > cutoff]
263
+
264
+ def _storming(self, at: datetime) -> bool:
265
+ """Whether the recent alert rate has exceeded the cap."""
266
+ self._prune(at)
267
+ return len(self._recent) >= self.policy.max_per_window
268
+
269
+ def _record(self, alert: Alert) -> Alert:
270
+ """Register an emitted alert for deduplication and rate control."""
271
+ self._last_emitted[alert.dedup_key] = (alert.at, alert.severity)
272
+ self._recent.append(alert.at)
273
+ return alert
274
+
275
+ # ------------------------------------------------------------------
276
+ def consider(
277
+ self,
278
+ change: BehaviouralChange,
279
+ *,
280
+ at: datetime,
281
+ coverage: float = 1.0,
282
+ attribution: float = 1.0,
283
+ deviation_threshold: float = 3.0,
284
+ trend_threshold: float = 3.5,
285
+ ) -> Alert | None:
286
+ """Decide whether a behavioural change warrants an alert.
287
+
288
+ Parameters
289
+ ----------
290
+ change
291
+ The verdict from the adaptive baseline.
292
+ at
293
+ When the decision is being made.
294
+ coverage
295
+ Mean sensor coverage behind the change, in ``[0, 1]``.
296
+ attribution
297
+ Probability the change reflects the resident's own behaviour
298
+ rather than a visitor's, in ``[0, 1]``.
299
+ deviation_threshold
300
+ The baseline's deviation threshold, used to scale magnitude.
301
+ trend_threshold
302
+ The baseline's trend threshold, used to scale a drift.
303
+
304
+ Returns
305
+ -------
306
+ Alert | None
307
+ An alert, or ``None`` when the change does not warrant one.
308
+ """
309
+ if not change.is_change:
310
+ return None
311
+
312
+ confidence = float(coverage) * float(attribution)
313
+ importance = float(self.policy.importance.get(change.feature, 1.0))
314
+ score = min(
315
+ importance
316
+ * confidence
317
+ * self._grade(change, deviation_threshold, trend_threshold),
318
+ 1.0,
319
+ )
320
+
321
+ if confidence < self.policy.min_confidence:
322
+ logger.debug(
323
+ "Change in '%s' not attributable enough to alert (confidence %.2f)",
324
+ change.feature,
325
+ confidence,
326
+ )
327
+ return None
328
+ if score < self.policy.min_score:
329
+ return None
330
+
331
+ severity = self.policy.severity_for(score)
332
+ key = f"{AlertKind.BEHAVIOURAL_CHANGE.value}:{change.feature}"
333
+ if self._suppressed(key, at, severity):
334
+ logger.debug("Suppressing repeat alert for '%s'", change.feature)
335
+ return None
336
+ if self._storming(at):
337
+ return self._storm_alert(at)
338
+
339
+ caveats = []
340
+ if coverage < 0.8:
341
+ caveats.append(f"sensor coverage was {coverage:.0%} over the period")
342
+ if attribution < 0.8:
343
+ caveats.append(
344
+ f"only {attribution:.0%} of the activity is attributable to the resident"
345
+ )
346
+ if not change.reference.weekday_aware:
347
+ caveats.append(
348
+ "compared against pooled history; not enough same-weekday days yet"
349
+ )
350
+
351
+ return self._record(
352
+ Alert(
353
+ at=at,
354
+ kind=AlertKind.BEHAVIOURAL_CHANGE,
355
+ severity=severity,
356
+ subject=change.feature,
357
+ summary=_summarise(change),
358
+ score=score,
359
+ confidence=confidence,
360
+ evidence={
361
+ "change": change.to_dict(),
362
+ "coverage": coverage,
363
+ "attribution": attribution,
364
+ },
365
+ caveats=tuple(caveats),
366
+ )
367
+ )
368
+
369
+ def consider_health(
370
+ self, report: SystemHealthReport, *, at: datetime | None = None
371
+ ) -> Alert | None:
372
+ """Decide whether the sensing apparatus warrants an alert.
373
+
374
+ This is deliberately a different kind of alert. A failing sensor is a
375
+ maintenance problem; presenting it as a behavioural finding would be
376
+ exactly the confusion the platform exists to prevent.
377
+ """
378
+ moment = at if at is not None else report.at
379
+ if report.coverage >= self.policy.health_coverage_floor:
380
+ return None
381
+
382
+ shortfall = 1.0 - report.coverage
383
+ severity = self.policy.severity_for(shortfall)
384
+ key = f"{AlertKind.SYSTEM_HEALTH.value}:deployment"
385
+ if self._suppressed(key, moment, severity):
386
+ return None
387
+
388
+ faulty = report.faulty
389
+ return self._record(
390
+ Alert(
391
+ at=moment,
392
+ kind=AlertKind.SYSTEM_HEALTH,
393
+ severity=severity,
394
+ subject="deployment",
395
+ summary=(
396
+ f"Sensor coverage has fallen to {report.coverage:.0%}; "
397
+ f"{len(faulty)} sensor(s) are not reporting usable data"
398
+ ),
399
+ score=shortfall,
400
+ confidence=1.0,
401
+ evidence={"faulty": faulty, "coverage": report.coverage},
402
+ caveats=(
403
+ "This concerns the sensing apparatus, not the resident. "
404
+ "Behavioural conclusions over this period are unreliable.",
405
+ ),
406
+ )
407
+ )
408
+
409
+ def _storm_alert(self, at: datetime) -> Alert | None:
410
+ """Replace a burst of alerts with a single notice about the burst."""
411
+ if (
412
+ self._storm_notified is not None
413
+ and at - self._storm_notified < self.policy.storm_window
414
+ ):
415
+ return None
416
+ self._storm_notified = at
417
+ logger.warning("Alert rate exceeded %d per window", self.policy.max_per_window)
418
+ return Alert(
419
+ at=at,
420
+ kind=AlertKind.DATA_QUALITY,
421
+ severity=AlertSeverity.ATTENTION,
422
+ subject="alert_rate",
423
+ summary=(
424
+ f"More than {self.policy.max_per_window} alerts were raised within "
425
+ "the review window; further alerts are being withheld"
426
+ ),
427
+ score=1.0,
428
+ confidence=1.0,
429
+ evidence={"recent_alerts": len(self._recent)},
430
+ caveats=(
431
+ "A burst of alerts usually indicates a sensing or configuration "
432
+ "problem rather than a sudden change in the resident.",
433
+ ),
434
+ )
435
+
436
+ def review(
437
+ self,
438
+ changes: Sequence[BehaviouralChange],
439
+ *,
440
+ at: datetime,
441
+ coverage: float = 1.0,
442
+ attribution: float = 1.0,
443
+ deviation_threshold: float = 3.0,
444
+ trend_threshold: float = 3.5,
445
+ ) -> list[Alert]:
446
+ """Consider several changes at once, returning the alerts raised."""
447
+ raised = []
448
+ for change in changes:
449
+ alert = self.consider(
450
+ change,
451
+ at=at,
452
+ coverage=coverage,
453
+ attribution=attribution,
454
+ deviation_threshold=deviation_threshold,
455
+ trend_threshold=trend_threshold,
456
+ )
457
+ if alert is not None:
458
+ raised.append(alert)
459
+ return raised
460
+
461
+ # ------------------------------------------------------------------
462
+ def snapshot(self) -> dict[str, object]:
463
+ """Return restartable engine state."""
464
+ return {
465
+ "last_emitted": {
466
+ key: [moment.isoformat(), severity.value]
467
+ for key, (moment, severity) in self._last_emitted.items()
468
+ },
469
+ "recent": [moment.isoformat() for moment in self._recent],
470
+ "storm_notified": (
471
+ self._storm_notified.isoformat() if self._storm_notified else None
472
+ ),
473
+ }
474
+
475
+ def restore(self, state: Mapping[str, object]) -> None:
476
+ """Restore engine state produced by :meth:`snapshot`."""
477
+ emitted = state.get("last_emitted", {})
478
+ if emitted is None:
479
+ emitted = {}
480
+ if not isinstance(emitted, Mapping):
481
+ raise TypeError("snapshot 'last_emitted' must be a mapping")
482
+ self._last_emitted = {
483
+ str(key): (
484
+ datetime.fromisoformat(str(value[0])),
485
+ AlertSeverity(str(value[1])),
486
+ )
487
+ for key, value in emitted.items()
488
+ }
489
+ recent = state.get("recent", [])
490
+ if recent is None:
491
+ recent = []
492
+ if isinstance(recent, str) or not isinstance(recent, Sequence):
493
+ raise TypeError("snapshot 'recent' must be a sequence of timestamps")
494
+ self._recent = [datetime.fromisoformat(str(moment)) for moment in recent]
495
+ notified = state.get("storm_notified")
496
+ self._storm_notified = (
497
+ datetime.fromisoformat(str(notified)) if notified else None
498
+ )
499
+
500
+
501
+ def _summarise(change: BehaviouralChange) -> str:
502
+ """Phrase a change as an observation about sensor-derived behaviour.
503
+
504
+ Deliberately descriptive: it reports what was measured against what the
505
+ resident's own history predicted, and offers no explanation for it.
506
+ """
507
+ if change.kind is ChangeKind.GRADUAL_DRIFT:
508
+ return (
509
+ f"Gradual {change.direction} in {change.feature}: trending "
510
+ f"{change.slope_per_day:+.3f} per day, {change.trend_strength:.1f} "
511
+ f"robust SD of movement against a personal reference of "
512
+ f"{change.reference.centre:.2f}"
513
+ )
514
+ return (
515
+ f"Sustained {change.direction} in {change.feature}: {change.value:.2f} "
516
+ f"against a personal reference of {change.reference.centre:.2f}, held "
517
+ f"for {change.duration_days} day(s)"
518
+ )
519
+
520
+
521
+ def _severity_rank(severity: AlertSeverity) -> int:
522
+ """Return an ordering rank for a severity band."""
523
+ return [
524
+ AlertSeverity.INFORMATION,
525
+ AlertSeverity.ATTENTION,
526
+ AlertSeverity.URGENT,
527
+ ].index(severity)
528
+
529
+
530
+ def unresolved_kinds(changes: Sequence[BehaviouralChange]) -> set[ChangeKind]:
531
+ """Return the distinct change kinds present in *changes*, for reporting."""
532
+ return {change.kind for change in changes}
@@ -0,0 +1,43 @@
1
+ """Analysis routines for sensor data."""
2
+
3
+ from .behavioral_analysis import (
4
+ detect_trends,
5
+ health_indicators,
6
+ recognize_activity_patterns,
7
+ score_anomalies,
8
+ )
9
+ from .behavioral_metrics import calculate_behavioral_metrics
10
+ from .comparison import (
11
+ cross_validate,
12
+ significance_test,
13
+ standardize_metrics,
14
+ visualize_comparison,
15
+ )
16
+ from .dependency_network import SensorDependencyNetwork
17
+ from .granger_causality import GrangerCausalityTest
18
+ from .pipeline import AnalysisPipeline
19
+ from .reporting import (
20
+ create_html_dashboard,
21
+ export_to_fhir,
22
+ generate_latex_report,
23
+ render_template,
24
+ )
25
+
26
+ __all__ = [
27
+ "GrangerCausalityTest",
28
+ "SensorDependencyNetwork",
29
+ "calculate_behavioral_metrics",
30
+ "AnalysisPipeline",
31
+ "cross_validate",
32
+ "significance_test",
33
+ "standardize_metrics",
34
+ "visualize_comparison",
35
+ "recognize_activity_patterns",
36
+ "score_anomalies",
37
+ "detect_trends",
38
+ "health_indicators",
39
+ "generate_latex_report",
40
+ "create_html_dashboard",
41
+ "export_to_fhir",
42
+ "render_template",
43
+ ]
@@ -0,0 +1,19 @@
1
+ """Shared DataFrame validation helpers for analysis routines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+
8
+ def prepare_sensor_frame(data: pd.DataFrame, *, context: str) -> pd.DataFrame:
9
+ """Return a numeric sensor frame with a datetime index."""
10
+ if data.empty:
11
+ raise ValueError(f"{context} requires at least one row")
12
+ if not isinstance(data.index, pd.DatetimeIndex):
13
+ raise TypeError(f"{context} requires a pandas DatetimeIndex")
14
+
15
+ sensor_data = data.select_dtypes(include="number")
16
+ if sensor_data.empty:
17
+ raise ValueError(f"{context} requires at least one numeric sensor column")
18
+
19
+ return sensor_data.copy()