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,520 @@
|
|
|
1
|
+
"""An adaptive, non-stationary personal baseline.
|
|
2
|
+
|
|
3
|
+
Normal behaviour is not a fixed calibration window. People's routines shift
|
|
4
|
+
with the seasons, with recovery from illness, with a new medication, with a
|
|
5
|
+
grandchild moving in. A baseline frozen at enrolment slowly turns every one of
|
|
6
|
+
those into a permanent alarm, and a baseline that adapts instantly turns a real
|
|
7
|
+
decline into the new normal before anyone notices.
|
|
8
|
+
|
|
9
|
+
The model here treats behaviour as ``X_t ~ P_t(X)`` with a slowly moving
|
|
10
|
+
distribution, and separates the reasons a day can look unusual:
|
|
11
|
+
|
|
12
|
+
.. code-block:: text
|
|
13
|
+
|
|
14
|
+
ordinary variability within the personal band
|
|
15
|
+
weekly periodicity Sundays differ from Tuesdays, by design
|
|
16
|
+
temporary disturbance a few unusual days that revert
|
|
17
|
+
persistent change a shift that holds
|
|
18
|
+
gradual drift a slow monotone trend
|
|
19
|
+
abrupt change a step, located by change-point detection
|
|
20
|
+
insufficient data the apparatus was not watching
|
|
21
|
+
|
|
22
|
+
Two properties keep it defensible. The reference is *robust*: medians and MAD,
|
|
23
|
+
so a single extraordinary day cannot redefine normal. And the reference is
|
|
24
|
+
*weekday-aware*: a quiet Sunday is compared against other Sundays, not against
|
|
25
|
+
the working week, because otherwise ordinary weekly rhythm reads as change.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import statistics
|
|
32
|
+
from collections import deque
|
|
33
|
+
from collections.abc import Mapping, Sequence
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from datetime import date
|
|
36
|
+
from enum import Enum
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
|
|
41
|
+
from ..models.change_point_detection.pelt import PELTChangePointDetector
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
#: Scale factor making the median absolute deviation a consistent estimator
|
|
46
|
+
#: of the standard deviation for normally distributed data.
|
|
47
|
+
MAD_TO_SIGMA = 1.4826
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ChangeKind(str, Enum):
|
|
51
|
+
"""How an apparent deviation from baseline should be read."""
|
|
52
|
+
|
|
53
|
+
ORDINARY = "ordinary"
|
|
54
|
+
"""Within the personal band. Not a finding."""
|
|
55
|
+
|
|
56
|
+
TEMPORARY_DISTURBANCE = "temporary_disturbance"
|
|
57
|
+
"""Unusual days that have already reverted."""
|
|
58
|
+
|
|
59
|
+
PERSISTENT_CHANGE = "persistent_change"
|
|
60
|
+
"""A shift that has held long enough to be worth reporting."""
|
|
61
|
+
|
|
62
|
+
GRADUAL_DRIFT = "gradual_drift"
|
|
63
|
+
"""A slow monotone trend rather than a step."""
|
|
64
|
+
|
|
65
|
+
ABRUPT_CHANGE = "abrupt_change"
|
|
66
|
+
"""A step change located by change-point detection."""
|
|
67
|
+
|
|
68
|
+
INSUFFICIENT_DATA = "insufficient_data"
|
|
69
|
+
"""Not enough well-observed days to say anything."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class BaselineReference:
|
|
74
|
+
"""The personal reference a day is compared against."""
|
|
75
|
+
|
|
76
|
+
centre: float
|
|
77
|
+
scale: float
|
|
78
|
+
samples: int
|
|
79
|
+
weekday_samples: int
|
|
80
|
+
weekday_aware: bool
|
|
81
|
+
|
|
82
|
+
def deviation(self, value: float) -> float:
|
|
83
|
+
"""Return the robust z-score of *value* against this reference."""
|
|
84
|
+
if self.scale <= 0.0:
|
|
85
|
+
return (
|
|
86
|
+
0.0
|
|
87
|
+
if value == self.centre
|
|
88
|
+
else float(np.sign(value - self.centre)) * np.inf
|
|
89
|
+
)
|
|
90
|
+
return (value - self.centre) / self.scale
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict[str, object]:
|
|
93
|
+
"""Return a serialisable form of the reference."""
|
|
94
|
+
return {
|
|
95
|
+
"centre": self.centre,
|
|
96
|
+
"scale": self.scale,
|
|
97
|
+
"samples": self.samples,
|
|
98
|
+
"weekday_samples": self.weekday_samples,
|
|
99
|
+
"weekday_aware": self.weekday_aware,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class BehaviouralChange:
|
|
105
|
+
"""A verdict about one feature on one day.
|
|
106
|
+
|
|
107
|
+
Attributes
|
|
108
|
+
----------
|
|
109
|
+
feature
|
|
110
|
+
Name of the tracked quantity.
|
|
111
|
+
day
|
|
112
|
+
Day the verdict is about.
|
|
113
|
+
kind
|
|
114
|
+
How the deviation should be read.
|
|
115
|
+
value
|
|
116
|
+
The day's observed value.
|
|
117
|
+
reference
|
|
118
|
+
The personal reference it was compared against.
|
|
119
|
+
deviation
|
|
120
|
+
Robust z-score of the day against that reference.
|
|
121
|
+
duration_days
|
|
122
|
+
Consecutive well-observed days the deviation has held.
|
|
123
|
+
slope_per_day
|
|
124
|
+
Robust trend estimate over the recent window, in units per day.
|
|
125
|
+
trend_strength
|
|
126
|
+
Total movement across the trend window, in robust standard
|
|
127
|
+
deviations. This is what identifies a gradual drift, and it is
|
|
128
|
+
carried on the verdict so that alerting can grade a drift without
|
|
129
|
+
re-deriving it.
|
|
130
|
+
change_point
|
|
131
|
+
Day a step change was located at, when one was found.
|
|
132
|
+
detail
|
|
133
|
+
Short human-readable explanation.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
feature: str
|
|
137
|
+
day: date
|
|
138
|
+
kind: ChangeKind
|
|
139
|
+
value: float
|
|
140
|
+
reference: BaselineReference
|
|
141
|
+
deviation: float
|
|
142
|
+
duration_days: int
|
|
143
|
+
slope_per_day: float
|
|
144
|
+
trend_strength: float
|
|
145
|
+
change_point: date | None
|
|
146
|
+
detail: str
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def is_change(self) -> bool:
|
|
150
|
+
"""Whether this verdict describes a behavioural change worth acting on."""
|
|
151
|
+
return self.kind in {
|
|
152
|
+
ChangeKind.PERSISTENT_CHANGE,
|
|
153
|
+
ChangeKind.GRADUAL_DRIFT,
|
|
154
|
+
ChangeKind.ABRUPT_CHANGE,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def direction(self) -> str:
|
|
159
|
+
"""Which way the behaviour moved.
|
|
160
|
+
|
|
161
|
+
For a drift this is the sign of the trend, not of the day: a drift is
|
|
162
|
+
identified by its slope, and a single day inside a slow decline can
|
|
163
|
+
easily sit on the other side of the reference. Reading the direction
|
|
164
|
+
off the day would let a verdict announce a decrease while reporting a
|
|
165
|
+
rising slope.
|
|
166
|
+
"""
|
|
167
|
+
signal = (
|
|
168
|
+
self.slope_per_day
|
|
169
|
+
if self.kind is ChangeKind.GRADUAL_DRIFT
|
|
170
|
+
else self.deviation
|
|
171
|
+
)
|
|
172
|
+
if signal > 0:
|
|
173
|
+
return "increase"
|
|
174
|
+
return "decrease" if signal < 0 else "none"
|
|
175
|
+
|
|
176
|
+
def to_dict(self) -> dict[str, object]:
|
|
177
|
+
"""Return a serialisable form of the verdict."""
|
|
178
|
+
return {
|
|
179
|
+
"feature": self.feature,
|
|
180
|
+
"day": self.day.isoformat(),
|
|
181
|
+
"kind": self.kind.value,
|
|
182
|
+
"value": self.value,
|
|
183
|
+
"deviation": self.deviation,
|
|
184
|
+
"direction": self.direction,
|
|
185
|
+
"duration_days": self.duration_days,
|
|
186
|
+
"slope_per_day": self.slope_per_day,
|
|
187
|
+
"trend_strength": self.trend_strength,
|
|
188
|
+
"change_point": (
|
|
189
|
+
self.change_point.isoformat() if self.change_point else None
|
|
190
|
+
),
|
|
191
|
+
"reference": self.reference.to_dict(),
|
|
192
|
+
"detail": self.detail,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class BaselineConfig:
|
|
198
|
+
"""Configuration for the adaptive baseline.
|
|
199
|
+
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
history_days
|
|
203
|
+
Days of well-observed history retained. Bounds memory and defines how
|
|
204
|
+
far back "normal" reaches.
|
|
205
|
+
min_samples
|
|
206
|
+
Well-observed days required before any verdict other than
|
|
207
|
+
``INSUFFICIENT_DATA`` is issued.
|
|
208
|
+
weekday_min_samples
|
|
209
|
+
Same-weekday days required before the reference becomes weekday-aware.
|
|
210
|
+
Below this it falls back to the pooled reference rather than trusting
|
|
211
|
+
two or three Sundays.
|
|
212
|
+
deviation_threshold
|
|
213
|
+
Robust z-score beyond which a day counts as deviating.
|
|
214
|
+
persistence_days
|
|
215
|
+
Consecutive deviating days after which a disturbance is called a
|
|
216
|
+
persistent change.
|
|
217
|
+
trend_window
|
|
218
|
+
Days examined for a gradual trend.
|
|
219
|
+
trend_threshold
|
|
220
|
+
Robust standard deviations of total movement across the trend window
|
|
221
|
+
that count as drift. Kept well above one: the Theil-Sen slope of a
|
|
222
|
+
stable but noisy series still accumulates more than a standard
|
|
223
|
+
deviation of apparent movement across four weeks, so a lower
|
|
224
|
+
threshold reports noise as decline.
|
|
225
|
+
change_point_penalty
|
|
226
|
+
Penalty passed to PELT when locating a step change.
|
|
227
|
+
min_scale
|
|
228
|
+
Floor on the reference scale, in feature units. Without it a person
|
|
229
|
+
with an extremely regular routine would have every ordinary hour of
|
|
230
|
+
variation reported as an enormous deviation.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
history_days: int = 120
|
|
234
|
+
min_samples: int = 14
|
|
235
|
+
weekday_min_samples: int = 4
|
|
236
|
+
deviation_threshold: float = 3.0
|
|
237
|
+
persistence_days: int = 3
|
|
238
|
+
trend_window: int = 28
|
|
239
|
+
trend_threshold: float = 3.5
|
|
240
|
+
change_point_penalty: float = 8.0
|
|
241
|
+
min_scale: float = 0.25
|
|
242
|
+
|
|
243
|
+
def __post_init__(self) -> None:
|
|
244
|
+
"""Validate the configuration."""
|
|
245
|
+
if self.history_days < 2:
|
|
246
|
+
raise ValueError("history_days must be at least 2")
|
|
247
|
+
if not 1 <= self.min_samples <= self.history_days:
|
|
248
|
+
raise ValueError("min_samples must lie between 1 and history_days")
|
|
249
|
+
if self.weekday_min_samples < 1:
|
|
250
|
+
raise ValueError("weekday_min_samples must be at least 1")
|
|
251
|
+
if self.deviation_threshold <= 0:
|
|
252
|
+
raise ValueError("deviation_threshold must be positive")
|
|
253
|
+
if self.persistence_days < 1:
|
|
254
|
+
raise ValueError("persistence_days must be at least 1")
|
|
255
|
+
if self.trend_window < 3:
|
|
256
|
+
raise ValueError("trend_window must be at least 3")
|
|
257
|
+
if self.trend_threshold <= 0:
|
|
258
|
+
raise ValueError("trend_threshold must be positive")
|
|
259
|
+
if self.change_point_penalty <= 0:
|
|
260
|
+
raise ValueError("change_point_penalty must be positive")
|
|
261
|
+
if self.min_scale <= 0:
|
|
262
|
+
raise ValueError("min_scale must be positive")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class AdaptiveBaseline:
|
|
267
|
+
"""A robust, weekday-aware, non-stationary baseline for one feature.
|
|
268
|
+
|
|
269
|
+
Parameters
|
|
270
|
+
----------
|
|
271
|
+
feature
|
|
272
|
+
Name of the tracked quantity, used in verdicts.
|
|
273
|
+
config
|
|
274
|
+
Thresholds governing the verdicts.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
feature: str
|
|
278
|
+
config: BaselineConfig = field(default_factory=BaselineConfig)
|
|
279
|
+
_days: deque[date] = field(default_factory=deque, repr=False)
|
|
280
|
+
_values: deque[float] = field(default_factory=deque, repr=False)
|
|
281
|
+
_streak: int = field(default=0, repr=False)
|
|
282
|
+
_streak_sign: int = field(default=0, repr=False)
|
|
283
|
+
|
|
284
|
+
def __post_init__(self) -> None:
|
|
285
|
+
"""Size the bounded history from the configuration."""
|
|
286
|
+
self._days = deque(self._days, maxlen=self.config.history_days)
|
|
287
|
+
self._values = deque(self._values, maxlen=self.config.history_days)
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------
|
|
290
|
+
@property
|
|
291
|
+
def samples(self) -> int:
|
|
292
|
+
"""Number of well-observed days currently retained."""
|
|
293
|
+
return len(self._values)
|
|
294
|
+
|
|
295
|
+
def reference(self, for_day: date | None = None) -> BaselineReference:
|
|
296
|
+
"""Return the personal reference, weekday-aware where possible.
|
|
297
|
+
|
|
298
|
+
Comparing a Sunday against other Sundays is what stops the ordinary
|
|
299
|
+
weekly rhythm of a life from being reported as behavioural change.
|
|
300
|
+
The pooled reference is used until enough same-weekday history has
|
|
301
|
+
accumulated to make the weekday split meaningful.
|
|
302
|
+
"""
|
|
303
|
+
values = list(self._values)
|
|
304
|
+
if not values:
|
|
305
|
+
return BaselineReference(0.0, self.config.min_scale, 0, 0, False)
|
|
306
|
+
|
|
307
|
+
weekday_values: list[float] = []
|
|
308
|
+
if for_day is not None:
|
|
309
|
+
weekday_values = [
|
|
310
|
+
value
|
|
311
|
+
for day, value in zip(self._days, values)
|
|
312
|
+
if day.weekday() == for_day.weekday()
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
weekday_aware = len(weekday_values) >= self.config.weekday_min_samples
|
|
316
|
+
selected = weekday_values if weekday_aware else values
|
|
317
|
+
|
|
318
|
+
centre = statistics.median(selected)
|
|
319
|
+
deviations = [abs(value - centre) for value in selected]
|
|
320
|
+
scale = max(
|
|
321
|
+
MAD_TO_SIGMA * statistics.median(deviations) if deviations else 0.0,
|
|
322
|
+
self.config.min_scale,
|
|
323
|
+
)
|
|
324
|
+
return BaselineReference(
|
|
325
|
+
centre=centre,
|
|
326
|
+
scale=scale,
|
|
327
|
+
samples=len(values),
|
|
328
|
+
weekday_samples=len(weekday_values),
|
|
329
|
+
weekday_aware=weekday_aware,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
def _slope(self) -> float:
|
|
334
|
+
"""Return a robust trend over the recent window, in units per day.
|
|
335
|
+
|
|
336
|
+
Uses the Theil-Sen median of pairwise slopes, which tolerates the
|
|
337
|
+
occasional extraordinary day without letting it set the trend.
|
|
338
|
+
"""
|
|
339
|
+
window = list(self._values)[-self.config.trend_window :]
|
|
340
|
+
days = list(self._days)[-self.config.trend_window :]
|
|
341
|
+
if len(window) < 3:
|
|
342
|
+
return 0.0
|
|
343
|
+
slopes = [
|
|
344
|
+
(window[j] - window[i]) / gap
|
|
345
|
+
for i in range(len(window))
|
|
346
|
+
for j in range(i + 1, len(window))
|
|
347
|
+
if (gap := (days[j] - days[i]).days) > 0
|
|
348
|
+
]
|
|
349
|
+
return statistics.median(slopes) if slopes else 0.0
|
|
350
|
+
|
|
351
|
+
def _change_point(self) -> date | None:
|
|
352
|
+
"""Locate the most recent step change in the retained history."""
|
|
353
|
+
if len(self._values) < 2 * self.config.min_samples:
|
|
354
|
+
return None
|
|
355
|
+
detector = PELTChangePointDetector(
|
|
356
|
+
penalty=self.config.change_point_penalty, min_segment_length=3
|
|
357
|
+
)
|
|
358
|
+
located = detector.detect(np.asarray(self._values, dtype=float))
|
|
359
|
+
if not located:
|
|
360
|
+
return None
|
|
361
|
+
return list(self._days)[located[-1]]
|
|
362
|
+
|
|
363
|
+
def _update_streak(self, deviating: bool, sign: int) -> None:
|
|
364
|
+
"""Track how long a deviation in one direction has held."""
|
|
365
|
+
if deviating and sign == self._streak_sign:
|
|
366
|
+
self._streak += 1
|
|
367
|
+
elif deviating:
|
|
368
|
+
self._streak = 1
|
|
369
|
+
self._streak_sign = sign
|
|
370
|
+
else:
|
|
371
|
+
self._streak = 0
|
|
372
|
+
self._streak_sign = 0
|
|
373
|
+
|
|
374
|
+
def observe(self, day: date, value: float) -> BehaviouralChange:
|
|
375
|
+
"""Record a well-observed day and return the verdict for it.
|
|
376
|
+
|
|
377
|
+
The day is classified *before* it joins the history, so a day is never
|
|
378
|
+
compared against a reference it has already influenced.
|
|
379
|
+
"""
|
|
380
|
+
if self._days and day <= self._days[-1]:
|
|
381
|
+
raise ValueError("baseline days must be strictly increasing")
|
|
382
|
+
if not np.isfinite(value):
|
|
383
|
+
raise ValueError("baseline values must be finite")
|
|
384
|
+
|
|
385
|
+
reference = self.reference(day)
|
|
386
|
+
deviation = reference.deviation(value)
|
|
387
|
+
deviating = abs(deviation) >= self.config.deviation_threshold
|
|
388
|
+
self._update_streak(deviating, int(np.sign(deviation)) if deviating else 0)
|
|
389
|
+
|
|
390
|
+
self._days.append(day)
|
|
391
|
+
self._values.append(value)
|
|
392
|
+
|
|
393
|
+
return self._classify(day, value, reference, deviation, deviating)
|
|
394
|
+
|
|
395
|
+
def _classify(
|
|
396
|
+
self,
|
|
397
|
+
day: date,
|
|
398
|
+
value: float,
|
|
399
|
+
reference: BaselineReference,
|
|
400
|
+
deviation: float,
|
|
401
|
+
deviating: bool,
|
|
402
|
+
) -> BehaviouralChange:
|
|
403
|
+
"""Decide how a day's deviation should be read."""
|
|
404
|
+
slope = self._slope()
|
|
405
|
+
movement = (
|
|
406
|
+
abs(slope) * self.config.trend_window / reference.scale
|
|
407
|
+
if reference.scale > 0
|
|
408
|
+
else 0.0
|
|
409
|
+
)
|
|
410
|
+
change_point = None
|
|
411
|
+
kind = ChangeKind.ORDINARY
|
|
412
|
+
detail = "within the personal band"
|
|
413
|
+
|
|
414
|
+
if reference.samples < self.config.min_samples:
|
|
415
|
+
kind = ChangeKind.INSUFFICIENT_DATA
|
|
416
|
+
detail = (
|
|
417
|
+
f"only {reference.samples} well-observed days; "
|
|
418
|
+
f"{self.config.min_samples} needed"
|
|
419
|
+
)
|
|
420
|
+
elif deviating and self._streak >= self.config.persistence_days:
|
|
421
|
+
change_point = self._change_point()
|
|
422
|
+
kind = (
|
|
423
|
+
ChangeKind.ABRUPT_CHANGE
|
|
424
|
+
if change_point is not None
|
|
425
|
+
else ChangeKind.PERSISTENT_CHANGE
|
|
426
|
+
)
|
|
427
|
+
detail = (
|
|
428
|
+
f"deviation of {deviation:+.1f} robust SD held for "
|
|
429
|
+
f"{self._streak} days"
|
|
430
|
+
)
|
|
431
|
+
elif deviating:
|
|
432
|
+
kind = ChangeKind.TEMPORARY_DISTURBANCE
|
|
433
|
+
detail = (
|
|
434
|
+
f"deviation of {deviation:+.1f} robust SD on "
|
|
435
|
+
f"{self._streak} day(s), not yet persistent"
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
if len(self._values) >= self.config.trend_window and (
|
|
439
|
+
movement >= self.config.trend_threshold
|
|
440
|
+
):
|
|
441
|
+
kind = ChangeKind.GRADUAL_DRIFT
|
|
442
|
+
detail = (
|
|
443
|
+
f"trend of {slope:+.3f} per day over "
|
|
444
|
+
f"{self.config.trend_window} days "
|
|
445
|
+
f"({movement:.1f} robust SD of movement)"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return BehaviouralChange(
|
|
449
|
+
feature=self.feature,
|
|
450
|
+
day=day,
|
|
451
|
+
kind=kind,
|
|
452
|
+
value=value,
|
|
453
|
+
reference=reference,
|
|
454
|
+
deviation=deviation,
|
|
455
|
+
duration_days=self._streak,
|
|
456
|
+
slope_per_day=slope,
|
|
457
|
+
trend_strength=movement,
|
|
458
|
+
change_point=change_point,
|
|
459
|
+
detail=detail,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def skip(
|
|
463
|
+
self, day: date, reason: str = "insufficient sensor coverage"
|
|
464
|
+
) -> BehaviouralChange:
|
|
465
|
+
"""Record that a day was not observed well enough to be used.
|
|
466
|
+
|
|
467
|
+
The day is deliberately kept out of the history. Feeding a poorly
|
|
468
|
+
observed day in as a low value would let a sensor outage rewrite the
|
|
469
|
+
resident's definition of normal.
|
|
470
|
+
"""
|
|
471
|
+
logger.debug("Skipping %s for feature '%s': %s", day, self.feature, reason)
|
|
472
|
+
reference = self.reference(day)
|
|
473
|
+
return BehaviouralChange(
|
|
474
|
+
feature=self.feature,
|
|
475
|
+
day=day,
|
|
476
|
+
kind=ChangeKind.INSUFFICIENT_DATA,
|
|
477
|
+
value=float("nan"),
|
|
478
|
+
reference=reference,
|
|
479
|
+
deviation=0.0,
|
|
480
|
+
duration_days=self._streak,
|
|
481
|
+
slope_per_day=0.0,
|
|
482
|
+
trend_strength=0.0,
|
|
483
|
+
change_point=None,
|
|
484
|
+
detail=reason,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# ------------------------------------------------------------------
|
|
488
|
+
def snapshot(self) -> dict[str, object]:
|
|
489
|
+
"""Return restartable baseline state."""
|
|
490
|
+
return {
|
|
491
|
+
"feature": self.feature,
|
|
492
|
+
"days": [day.isoformat() for day in self._days],
|
|
493
|
+
"values": list(self._values),
|
|
494
|
+
"streak": self._streak,
|
|
495
|
+
"streak_sign": self._streak_sign,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
def restore(self, state: Mapping[str, Any]) -> None:
|
|
499
|
+
"""Restore baseline state produced by :meth:`snapshot`.
|
|
500
|
+
|
|
501
|
+
The payload is validated rather than trusted: a snapshot round-trips
|
|
502
|
+
through JSON on the way to and from an edge device, so it arrives as
|
|
503
|
+
untyped data.
|
|
504
|
+
"""
|
|
505
|
+
raw_days = state.get("days") or []
|
|
506
|
+
raw_values = state.get("values") or []
|
|
507
|
+
if not isinstance(raw_days, Sequence) or not isinstance(raw_values, Sequence):
|
|
508
|
+
raise TypeError("snapshot 'days' and 'values' must be sequences")
|
|
509
|
+
if len(raw_days) != len(raw_values):
|
|
510
|
+
raise ValueError("snapshot days and values must be the same length")
|
|
511
|
+
|
|
512
|
+
self._days = deque(
|
|
513
|
+
(date.fromisoformat(str(day)) for day in raw_days),
|
|
514
|
+
maxlen=self.config.history_days,
|
|
515
|
+
)
|
|
516
|
+
self._values = deque(
|
|
517
|
+
(float(value) for value in raw_values), maxlen=self.config.history_days
|
|
518
|
+
)
|
|
519
|
+
self._streak = int(state.get("streak", 0))
|
|
520
|
+
self._streak_sign = int(state.get("streak_sign", 0))
|