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,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()
|