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