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,485 @@
|
|
|
1
|
+
"""The incremental end-to-end pipeline.
|
|
2
|
+
|
|
3
|
+
.. code-block:: text
|
|
4
|
+
|
|
5
|
+
observation arrives
|
|
6
|
+
v
|
|
7
|
+
validate at the boundary
|
|
8
|
+
v
|
|
9
|
+
update sensor health
|
|
10
|
+
v
|
|
11
|
+
update person context
|
|
12
|
+
v
|
|
13
|
+
update latent behavioural state
|
|
14
|
+
v
|
|
15
|
+
close the day, update the personal baseline
|
|
16
|
+
v
|
|
17
|
+
evaluate behavioural change
|
|
18
|
+
v
|
|
19
|
+
emit a structured, explainable result
|
|
20
|
+
|
|
21
|
+
Every stage keeps bounded state, so the pipeline runs indefinitely on an edge
|
|
22
|
+
device, and every stage can be snapshotted and restored, so a restart or an
|
|
23
|
+
intermittent uplink does not lose the resident's accumulated history.
|
|
24
|
+
|
|
25
|
+
The one piece of machinery that only exists because streams are real is the
|
|
26
|
+
lateness buffer. Observations arrive out of order; the filter is causal and
|
|
27
|
+
refuses to move backwards. So the pipeline holds records behind a watermark
|
|
28
|
+
long enough to reorder them, releases them in timestamp order, and counts
|
|
29
|
+
anything later than the tolerance rather than quietly folding a stale record
|
|
30
|
+
into the current belief.
|
|
31
|
+
|
|
32
|
+
This module owns orchestration only. It contains no inference of its own, and
|
|
33
|
+
nothing here knows about files, HTTP, dashboards or storage.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import bisect
|
|
39
|
+
import logging
|
|
40
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from datetime import date, datetime, time, timedelta, tzinfo
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from ..alerts.alert import Alert, AlertEngine, AlertPolicy
|
|
46
|
+
from ..baseline.adaptive import AdaptiveBaseline, BaselineConfig, BehaviouralChange
|
|
47
|
+
from ..baseline.features import DailySummary, summarise_days
|
|
48
|
+
from ..context.occupancy import ContextConfig, ContextEstimate, ResidentContextEstimator
|
|
49
|
+
from ..fusion.defaults import default_emissions
|
|
50
|
+
from ..fusion.emissions import EmissionModel
|
|
51
|
+
from ..fusion.estimate import StateEstimate
|
|
52
|
+
from ..fusion.filter import FusionConfig, MultimodalBayesFilter
|
|
53
|
+
from ..health.monitor import HealthConfig, SensorHealthMonitor, SystemHealthReport
|
|
54
|
+
from ..observations.ingest import IngestionReport, ObservationIngestor
|
|
55
|
+
from ..observations.observation import Observation
|
|
56
|
+
from ..observations.registry import SensorRegistry
|
|
57
|
+
from ..states.ontology import BehaviouralState, StateOntology
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class PipelineConfig:
|
|
64
|
+
"""Configuration of the online pipeline.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
tz
|
|
69
|
+
Timezone whose calendar days bound the baseline. Defaults to the
|
|
70
|
+
registry's deployment timezone when not given.
|
|
71
|
+
step
|
|
72
|
+
How often the state filter advances. Between steps, observations
|
|
73
|
+
accumulate and are applied together.
|
|
74
|
+
lateness_tolerance
|
|
75
|
+
How far behind real time the processing watermark sits. Records that
|
|
76
|
+
arrive later than this are counted and discarded rather than folded
|
|
77
|
+
into an already-advanced belief.
|
|
78
|
+
features
|
|
79
|
+
Behavioural states whose daily hours get an adaptive baseline.
|
|
80
|
+
min_day_coverage, min_day_observed
|
|
81
|
+
Quality a day must reach before it may inform the baseline.
|
|
82
|
+
attribute_activity
|
|
83
|
+
Whether ambient evidence is discounted by the probability that the
|
|
84
|
+
monitored resident generated it. Setting this false makes the
|
|
85
|
+
pipeline attribute every ambient event to the resident, which is the
|
|
86
|
+
naive behaviour most ambient monitoring assumes. It exists so that
|
|
87
|
+
assumption can be *measured* against the occupancy-aware default
|
|
88
|
+
rather than argued about.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
tz: tzinfo | None = None
|
|
92
|
+
step: timedelta = timedelta(minutes=5)
|
|
93
|
+
lateness_tolerance: timedelta = timedelta(minutes=10)
|
|
94
|
+
features: tuple[BehaviouralState, ...] = (
|
|
95
|
+
BehaviouralState.SLEEPING,
|
|
96
|
+
BehaviouralState.KITCHEN_ACTIVITY,
|
|
97
|
+
BehaviouralState.BATHROOM_ACTIVITY,
|
|
98
|
+
BehaviouralState.AWAY,
|
|
99
|
+
)
|
|
100
|
+
min_day_coverage: float = 0.5
|
|
101
|
+
min_day_observed: float = 0.6
|
|
102
|
+
attribute_activity: bool = True
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
"""Validate the pipeline configuration."""
|
|
106
|
+
if self.step <= timedelta(0):
|
|
107
|
+
raise ValueError("step must be positive")
|
|
108
|
+
if self.lateness_tolerance < timedelta(0):
|
|
109
|
+
raise ValueError("lateness_tolerance must be non-negative")
|
|
110
|
+
if not self.features:
|
|
111
|
+
raise ValueError("at least one baseline feature is required")
|
|
112
|
+
for name in ("min_day_coverage", "min_day_observed"):
|
|
113
|
+
value = float(getattr(self, name))
|
|
114
|
+
if not 0.0 <= value <= 1.0:
|
|
115
|
+
raise ValueError(f"{name} must lie in [0, 1]")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class PipelineStep:
|
|
120
|
+
"""Everything the pipeline concluded at one moment."""
|
|
121
|
+
|
|
122
|
+
at: datetime
|
|
123
|
+
state: StateEstimate
|
|
124
|
+
context: ContextEstimate
|
|
125
|
+
health: SystemHealthReport
|
|
126
|
+
alerts: tuple[Alert, ...] = ()
|
|
127
|
+
changes: tuple[BehaviouralChange, ...] = ()
|
|
128
|
+
day_closed: DailySummary | None = None
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict[str, Any]:
|
|
131
|
+
"""Return a serialisable form of the step."""
|
|
132
|
+
return {
|
|
133
|
+
"at": self.at.isoformat(),
|
|
134
|
+
"state": self.state.to_dict(),
|
|
135
|
+
"context": self.context.to_dict(),
|
|
136
|
+
"health": self.health.to_dict(),
|
|
137
|
+
"alerts": [alert.to_dict() for alert in self.alerts],
|
|
138
|
+
"changes": [change.to_dict() for change in self.changes],
|
|
139
|
+
"day_closed": (
|
|
140
|
+
self.day_closed.to_dict() if self.day_closed is not None else None
|
|
141
|
+
),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class BehaviouralSensingPipeline:
|
|
146
|
+
"""Incremental orchestration of the full inference chain.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
registry
|
|
151
|
+
Declared sensors.
|
|
152
|
+
ontology
|
|
153
|
+
Latent behavioural states and their dynamics.
|
|
154
|
+
emissions
|
|
155
|
+
Observation models. Defaults are derived from the registry.
|
|
156
|
+
config
|
|
157
|
+
Pipeline configuration.
|
|
158
|
+
fusion_config, health_config, context_config, baseline_config, alert_policy
|
|
159
|
+
Configuration for each stage.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
registry: SensorRegistry,
|
|
165
|
+
ontology: StateOntology | None = None,
|
|
166
|
+
emissions: Iterable[EmissionModel] | None = None,
|
|
167
|
+
config: PipelineConfig | None = None,
|
|
168
|
+
*,
|
|
169
|
+
fusion_config: FusionConfig | None = None,
|
|
170
|
+
health_config: HealthConfig | None = None,
|
|
171
|
+
context_config: ContextConfig | None = None,
|
|
172
|
+
baseline_config: BaselineConfig | None = None,
|
|
173
|
+
alert_policy: AlertPolicy | None = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
self.registry = registry
|
|
176
|
+
self.ontology = ontology or StateOntology()
|
|
177
|
+
self.config = config or PipelineConfig()
|
|
178
|
+
|
|
179
|
+
self.ingestor = ObservationIngestor(registry)
|
|
180
|
+
self.health = SensorHealthMonitor(registry, health_config)
|
|
181
|
+
self.context = ResidentContextEstimator(registry, context_config)
|
|
182
|
+
self.filter = MultimodalBayesFilter(
|
|
183
|
+
self.ontology,
|
|
184
|
+
(
|
|
185
|
+
emissions
|
|
186
|
+
if emissions is not None
|
|
187
|
+
else default_emissions(registry, self.ontology)
|
|
188
|
+
),
|
|
189
|
+
registry=registry,
|
|
190
|
+
config=fusion_config,
|
|
191
|
+
)
|
|
192
|
+
self.alerts = AlertEngine(alert_policy)
|
|
193
|
+
self.baselines = {
|
|
194
|
+
state.value: AdaptiveBaseline(
|
|
195
|
+
f"{state.value}_hours", baseline_config or BaselineConfig()
|
|
196
|
+
)
|
|
197
|
+
for state in self.config.features
|
|
198
|
+
}
|
|
199
|
+
self._baseline_config = baseline_config or BaselineConfig()
|
|
200
|
+
|
|
201
|
+
self._buffer: list[Observation] = []
|
|
202
|
+
self._pending: list[Observation] = []
|
|
203
|
+
self._day_estimates: list[StateEstimate] = []
|
|
204
|
+
self._last_context: ContextEstimate | None = None
|
|
205
|
+
self._current_day: date | None = None
|
|
206
|
+
self._next_step: datetime | None = None
|
|
207
|
+
self.ingestion = IngestionReport()
|
|
208
|
+
self.too_late = 0
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
@property
|
|
212
|
+
def tz(self) -> tzinfo | None:
|
|
213
|
+
"""Timezone whose calendar days bound the baseline."""
|
|
214
|
+
return self.config.tz
|
|
215
|
+
|
|
216
|
+
def _local_day(self, moment: datetime) -> date:
|
|
217
|
+
"""Return the local calendar date of *moment*."""
|
|
218
|
+
zone = self.config.tz
|
|
219
|
+
return (moment.astimezone(zone) if zone is not None else moment).date()
|
|
220
|
+
|
|
221
|
+
def push(self, observation: Observation) -> bool:
|
|
222
|
+
"""Admit one observation into the lateness buffer.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
bool
|
|
227
|
+
``True`` when the record was buffered, ``False`` when it was
|
|
228
|
+
rejected at the boundary or arrived beyond the lateness
|
|
229
|
+
tolerance. Rejections are counted, never raised: one malformed
|
|
230
|
+
record must not stop a live stream.
|
|
231
|
+
"""
|
|
232
|
+
admitted = self.ingestor.ingest(observation, self.ingestion)
|
|
233
|
+
if admitted is None:
|
|
234
|
+
return False
|
|
235
|
+
if (
|
|
236
|
+
self._next_step is not None
|
|
237
|
+
and admitted.timestamp < self._next_step - self.config.step
|
|
238
|
+
):
|
|
239
|
+
self.too_late += 1
|
|
240
|
+
logger.debug(
|
|
241
|
+
"Discarding record from '%s' that arrived after its window closed",
|
|
242
|
+
admitted.sensor_id,
|
|
243
|
+
)
|
|
244
|
+
return False
|
|
245
|
+
bisect.insort(self._buffer, admitted, key=lambda obs: obs.timestamp)
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
def _release(self, watermark: datetime) -> list[Observation]:
|
|
249
|
+
"""Release buffered records at or before *watermark*, in time order."""
|
|
250
|
+
cut = bisect.bisect_right([obs.timestamp for obs in self._buffer], watermark)
|
|
251
|
+
released, self._buffer = self._buffer[:cut], self._buffer[cut:]
|
|
252
|
+
return released
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
def advance(self, now: datetime) -> list[PipelineStep]:
|
|
256
|
+
"""Advance the pipeline to *now*, returning one result per step.
|
|
257
|
+
|
|
258
|
+
Observations are released from the buffer behind the lateness
|
|
259
|
+
watermark, so a record that arrived out of order still reaches the
|
|
260
|
+
filter in the right place provided it was not later than the
|
|
261
|
+
tolerance.
|
|
262
|
+
"""
|
|
263
|
+
watermark = now - self.config.lateness_tolerance
|
|
264
|
+
self._pending.extend(self._release(watermark))
|
|
265
|
+
self._pending.sort(key=lambda obs: obs.timestamp)
|
|
266
|
+
|
|
267
|
+
if self._next_step is None:
|
|
268
|
+
if not self._pending:
|
|
269
|
+
return []
|
|
270
|
+
self._next_step = self._pending[0].timestamp
|
|
271
|
+
|
|
272
|
+
steps: list[PipelineStep] = []
|
|
273
|
+
while self._next_step is not None and self._next_step <= watermark:
|
|
274
|
+
boundary = self._next_step
|
|
275
|
+
cut = bisect.bisect_right(
|
|
276
|
+
[obs.timestamp for obs in self._pending], boundary
|
|
277
|
+
)
|
|
278
|
+
batch, self._pending = self._pending[:cut], self._pending[cut:]
|
|
279
|
+
steps.append(self._step(boundary, batch))
|
|
280
|
+
self._next_step = boundary + self.config.step
|
|
281
|
+
return steps
|
|
282
|
+
|
|
283
|
+
def _step(self, moment: datetime, batch: Sequence[Observation]) -> PipelineStep:
|
|
284
|
+
"""Run one pipeline step over the observations of a single interval."""
|
|
285
|
+
self.health.observe_many(batch)
|
|
286
|
+
health = self.health.report(moment)
|
|
287
|
+
reliabilities = health.reliabilities()
|
|
288
|
+
|
|
289
|
+
context = self.context.update(moment, batch, reliabilities=reliabilities)
|
|
290
|
+
self._last_context = context
|
|
291
|
+
attribution = (
|
|
292
|
+
self.context.attribution(context)
|
|
293
|
+
if self.config.attribute_activity
|
|
294
|
+
else None
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
estimate = self.filter.update(
|
|
298
|
+
moment, batch, reliabilities=reliabilities, attribution=attribution
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
day = self._local_day(moment)
|
|
302
|
+
closed: DailySummary | None = None
|
|
303
|
+
changes: tuple[BehaviouralChange, ...] = ()
|
|
304
|
+
alerts: tuple[Alert, ...] = ()
|
|
305
|
+
|
|
306
|
+
if self._current_day is None:
|
|
307
|
+
self._current_day = day
|
|
308
|
+
elif day != self._current_day:
|
|
309
|
+
closed, changes, alerts = self._close_day(
|
|
310
|
+
self._current_day, moment, context
|
|
311
|
+
)
|
|
312
|
+
self._current_day = day
|
|
313
|
+
self._day_estimates = []
|
|
314
|
+
|
|
315
|
+
self._day_estimates.append(estimate)
|
|
316
|
+
|
|
317
|
+
health_alert = self.alerts.consider_health(health, at=moment)
|
|
318
|
+
if health_alert is not None:
|
|
319
|
+
alerts = alerts + (health_alert,)
|
|
320
|
+
|
|
321
|
+
return PipelineStep(
|
|
322
|
+
at=moment,
|
|
323
|
+
state=estimate,
|
|
324
|
+
context=context,
|
|
325
|
+
health=health,
|
|
326
|
+
alerts=alerts,
|
|
327
|
+
changes=changes,
|
|
328
|
+
day_closed=closed,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def _close_day(
|
|
332
|
+
self, day: date, moment: datetime, context: ContextEstimate
|
|
333
|
+
) -> tuple[DailySummary | None, tuple[BehaviouralChange, ...], tuple[Alert, ...]]:
|
|
334
|
+
"""Summarise a completed day and update the baselines from it."""
|
|
335
|
+
summaries = summarise_days(
|
|
336
|
+
self._day_estimates, tz=self.config.tz, max_interval=self.config.step * 3
|
|
337
|
+
)
|
|
338
|
+
summary = next((item for item in summaries if item.day == day), None)
|
|
339
|
+
if summary is None:
|
|
340
|
+
return None, (), ()
|
|
341
|
+
|
|
342
|
+
usable = summary.is_usable(
|
|
343
|
+
self.config.min_day_coverage, self.config.min_day_observed
|
|
344
|
+
)
|
|
345
|
+
changes = []
|
|
346
|
+
for state in self.config.features:
|
|
347
|
+
baseline = self.baselines[state.value]
|
|
348
|
+
if usable:
|
|
349
|
+
changes.append(baseline.observe(day, summary.hours_in(state)))
|
|
350
|
+
else:
|
|
351
|
+
changes.append(
|
|
352
|
+
baseline.skip(
|
|
353
|
+
day,
|
|
354
|
+
f"day observed at {summary.observed:.0%} with "
|
|
355
|
+
f"{summary.coverage:.0%} sensor coverage",
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
alerts = self.alerts.review(
|
|
360
|
+
changes,
|
|
361
|
+
at=moment,
|
|
362
|
+
coverage=summary.coverage,
|
|
363
|
+
attribution=context.ambient_attribution(),
|
|
364
|
+
deviation_threshold=self._baseline_config.deviation_threshold,
|
|
365
|
+
trend_threshold=self._baseline_config.trend_threshold,
|
|
366
|
+
)
|
|
367
|
+
return summary, tuple(changes), tuple(alerts)
|
|
368
|
+
|
|
369
|
+
# ------------------------------------------------------------------
|
|
370
|
+
def run(
|
|
371
|
+
self, observations: Iterable[Observation], *, until: datetime | None = None
|
|
372
|
+
) -> list[PipelineStep]:
|
|
373
|
+
"""Process a whole record offline, in arrival order.
|
|
374
|
+
|
|
375
|
+
A convenience wrapper over :meth:`push` and :meth:`advance` for batch
|
|
376
|
+
experiments. It drives exactly the same online code path, so results
|
|
377
|
+
match what a live deployment would have produced.
|
|
378
|
+
"""
|
|
379
|
+
steps: list[PipelineStep] = []
|
|
380
|
+
latest: datetime | None = None
|
|
381
|
+
for observation in observations:
|
|
382
|
+
arrival = observation.received_at or observation.timestamp
|
|
383
|
+
self.push(observation)
|
|
384
|
+
if latest is None or arrival > latest:
|
|
385
|
+
latest = arrival
|
|
386
|
+
steps.extend(self.advance(arrival))
|
|
387
|
+
if latest is not None:
|
|
388
|
+
end = (
|
|
389
|
+
until if until is not None else latest + self.config.lateness_tolerance
|
|
390
|
+
)
|
|
391
|
+
steps.extend(self.advance(end + self.config.lateness_tolerance))
|
|
392
|
+
return steps
|
|
393
|
+
|
|
394
|
+
def close(self, now: datetime) -> tuple[PipelineStep, ...]:
|
|
395
|
+
"""Flush the buffer and close the final partial day at *now*.
|
|
396
|
+
|
|
397
|
+
The final day is usually incomplete, so its summary will often fail
|
|
398
|
+
the coverage test and be recorded as unusable rather than entering
|
|
399
|
+
the baseline as an unusually quiet day.
|
|
400
|
+
"""
|
|
401
|
+
steps = tuple(self.advance(now + self.config.lateness_tolerance * 2))
|
|
402
|
+
if self._current_day is None or not self._day_estimates:
|
|
403
|
+
return steps
|
|
404
|
+
|
|
405
|
+
last = self._day_estimates[-1]
|
|
406
|
+
context = self._last_context
|
|
407
|
+
if context is None: # pragma: no cover - close() before any step
|
|
408
|
+
return steps
|
|
409
|
+
|
|
410
|
+
summary, changes, alerts = self._close_day(self._current_day, last.at, context)
|
|
411
|
+
self._day_estimates = []
|
|
412
|
+
self._current_day = None
|
|
413
|
+
if summary is None:
|
|
414
|
+
return steps
|
|
415
|
+
return steps + (
|
|
416
|
+
PipelineStep(
|
|
417
|
+
at=last.at,
|
|
418
|
+
state=last,
|
|
419
|
+
context=context,
|
|
420
|
+
health=self.health.report(last.at),
|
|
421
|
+
alerts=alerts,
|
|
422
|
+
changes=changes,
|
|
423
|
+
day_closed=summary,
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# ------------------------------------------------------------------
|
|
428
|
+
def snapshot(self) -> dict[str, Any]:
|
|
429
|
+
"""Return restartable state for every stage.
|
|
430
|
+
|
|
431
|
+
The buffer is deliberately not included. Buffered records have not
|
|
432
|
+
been folded into any belief yet, so re-delivering them after a
|
|
433
|
+
restart is correct; persisting them here would risk counting them
|
|
434
|
+
twice.
|
|
435
|
+
"""
|
|
436
|
+
return {
|
|
437
|
+
"ingestor": self.ingestor.snapshot(),
|
|
438
|
+
"health": self.health.snapshot(),
|
|
439
|
+
"context": self.context.snapshot(),
|
|
440
|
+
"filter": self.filter.snapshot(),
|
|
441
|
+
"alerts": self.alerts.snapshot(),
|
|
442
|
+
"baselines": {
|
|
443
|
+
name: baseline.snapshot() for name, baseline in self.baselines.items()
|
|
444
|
+
},
|
|
445
|
+
"current_day": self._current_day.isoformat() if self._current_day else None,
|
|
446
|
+
"next_step": self._next_step.isoformat() if self._next_step else None,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
def restore(self, state: Mapping[str, Any]) -> None:
|
|
450
|
+
"""Restore pipeline state produced by :meth:`snapshot`."""
|
|
451
|
+
self.ingestor.restore(state["ingestor"])
|
|
452
|
+
self.health.restore(state["health"])
|
|
453
|
+
self.context.restore(state["context"])
|
|
454
|
+
self.filter.restore(state["filter"])
|
|
455
|
+
self.alerts.restore(state["alerts"])
|
|
456
|
+
for name, payload in (state.get("baselines") or {}).items():
|
|
457
|
+
baseline = self.baselines.get(name)
|
|
458
|
+
if baseline is not None:
|
|
459
|
+
baseline.restore(payload)
|
|
460
|
+
current_day = state.get("current_day")
|
|
461
|
+
self._current_day = date.fromisoformat(current_day) if current_day else None
|
|
462
|
+
next_step = state.get("next_step")
|
|
463
|
+
self._next_step = datetime.fromisoformat(next_step) if next_step else None
|
|
464
|
+
self._day_estimates = []
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def local_midnight(day: date, zone: tzinfo | None) -> datetime:
|
|
468
|
+
"""Return the instant local midnight begins on *day*."""
|
|
469
|
+
naive = datetime.combine(day, time.min)
|
|
470
|
+
return naive.replace(tzinfo=zone) if zone is not None else naive
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def collect_alerts(steps: Iterable[PipelineStep]) -> list[Alert]:
|
|
474
|
+
"""Return every alert raised across a run, in order."""
|
|
475
|
+
return [alert for step in steps for alert in step.alerts]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def collect_changes(steps: Iterable[PipelineStep]) -> list[BehaviouralChange]:
|
|
479
|
+
"""Return every baseline verdict produced across a run, in order."""
|
|
480
|
+
return [change for step in steps for change in step.changes]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def daily_summaries(steps: Iterable[PipelineStep]) -> list[DailySummary]:
|
|
484
|
+
"""Return the day summaries closed across a run, in order."""
|
|
485
|
+
return [step.day_closed for step in steps if step.day_closed is not None]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Synthetic households with controlled ground truth.
|
|
2
|
+
|
|
3
|
+
Behaviour is generated from a stochastic daily *schedule*, deliberately not
|
|
4
|
+
from the continuous-time chain the inference layer uses. Sharing a generative
|
|
5
|
+
model between simulator and estimator would make good results prove only that
|
|
6
|
+
the code can invert its own assumptions.
|
|
7
|
+
|
|
8
|
+
Sensor faults are injected separately from behaviour, so a robustness study
|
|
9
|
+
can hold the resident fixed and vary only what went wrong with the apparatus.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .faults import (
|
|
13
|
+
DegradationConfig,
|
|
14
|
+
Fault,
|
|
15
|
+
FaultKind,
|
|
16
|
+
degrade,
|
|
17
|
+
dropout,
|
|
18
|
+
not_worn,
|
|
19
|
+
stuck,
|
|
20
|
+
)
|
|
21
|
+
from .household import (
|
|
22
|
+
ACTIVE_RATE,
|
|
23
|
+
IDLE_RATE,
|
|
24
|
+
WEARABLE_LEVEL,
|
|
25
|
+
BehaviourShift,
|
|
26
|
+
Episode,
|
|
27
|
+
GroundTruth,
|
|
28
|
+
HouseholdConfig,
|
|
29
|
+
SimulationResult,
|
|
30
|
+
VisitorPeriod,
|
|
31
|
+
build_registry,
|
|
32
|
+
simulate,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ACTIVE_RATE",
|
|
37
|
+
"IDLE_RATE",
|
|
38
|
+
"WEARABLE_LEVEL",
|
|
39
|
+
"BehaviourShift",
|
|
40
|
+
"DegradationConfig",
|
|
41
|
+
"Episode",
|
|
42
|
+
"Fault",
|
|
43
|
+
"FaultKind",
|
|
44
|
+
"GroundTruth",
|
|
45
|
+
"HouseholdConfig",
|
|
46
|
+
"SimulationResult",
|
|
47
|
+
"VisitorPeriod",
|
|
48
|
+
"build_registry",
|
|
49
|
+
"degrade",
|
|
50
|
+
"dropout",
|
|
51
|
+
"not_worn",
|
|
52
|
+
"simulate",
|
|
53
|
+
"stuck",
|
|
54
|
+
]
|