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,375 @@
1
+ """The output of multimodal fusion: a state belief with its justification.
2
+
3
+ A :class:`StateEstimate` is deliberately more than an argmax. It carries the
4
+ full posterior, how confident that posterior is, how much of the sensing
5
+ apparatus was actually contributing, which sensors supported the conclusion
6
+ and which contradicted it, and which supplied nothing at all.
7
+
8
+ It can also decline. When confidence is low or too little evidence was
9
+ available, :attr:`StateEstimate.state` is
10
+ :attr:`~sensor_modeling.states.BehaviouralState.UNKNOWN` -- an abstention,
11
+ not a prediction of an eighth behaviour.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from collections.abc import Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from datetime import datetime
20
+
21
+ import numpy as np
22
+
23
+ from ..observations.types import Modality
24
+ from ..states.ontology import BehaviouralState, StateOntology
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class EvidenceContribution:
29
+ """What one sensor contributed to the conclusion.
30
+
31
+ Attributes
32
+ ----------
33
+ sensor_id
34
+ Sensor the contribution came from.
35
+ modality
36
+ Modality of that sensor.
37
+ support
38
+ Log-likelihood margin the sensor gives the winning state over the
39
+ best alternative. Positive means the sensor favours the conclusion,
40
+ negative means it favours something else, zero means it is
41
+ indifferent between them.
42
+ reliability
43
+ Health-derived evidence weight applied to the sensor.
44
+ attribution
45
+ Probability that what the sensor saw was generated by the monitored
46
+ resident rather than someone else.
47
+ observations
48
+ Number of records this sensor supplied over the interval.
49
+ """
50
+
51
+ sensor_id: str
52
+ modality: Modality
53
+ support: float
54
+ reliability: float
55
+ attribution: float
56
+ observations: int
57
+
58
+ @property
59
+ def supports(self) -> bool:
60
+ """Whether the sensor favours the reported state."""
61
+ return self.support > 0.0
62
+
63
+ @property
64
+ def contradicts(self) -> bool:
65
+ """Whether the sensor favours some other state."""
66
+ return self.support < 0.0
67
+
68
+ @property
69
+ def reported(self) -> bool:
70
+ """Whether the sensor produced any records over the interval."""
71
+ return self.observations > 0
72
+
73
+ @property
74
+ def informative(self) -> bool:
75
+ """Whether the sensor's likelihood was applied at all.
76
+
77
+ A trusted event sensor that stayed quiet is still informative: under
78
+ a Poisson observation model its silence actively penalises the
79
+ states that would have triggered it. Only a sensor discounted to
80
+ zero reliability contributes nothing.
81
+ """
82
+ return self.reliability > 0.0
83
+
84
+ def to_dict(self) -> dict[str, object]:
85
+ """Return a serialisable form of the contribution."""
86
+ return {
87
+ "sensor_id": self.sensor_id,
88
+ "modality": self.modality.value,
89
+ "support": self.support,
90
+ "reliability": self.reliability,
91
+ "attribution": self.attribution,
92
+ "observations": self.observations,
93
+ }
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Explanation:
98
+ """Why an estimate reached its conclusion, as addressable parts.
99
+
100
+ Four fields carry information that a bare probability cannot, and each
101
+ answers a question a reader would otherwise have to guess at:
102
+
103
+ ``supporting`` / ``contradicting``
104
+ Which sensors agreed and which pointed elsewhere. Disagreement is
105
+ surfaced rather than averaged away.
106
+ ``silent``
107
+ Trusted sensors that reported nothing. Their silence *did* inform the
108
+ posterior -- under a Poisson model it penalises the states that would
109
+ have triggered them.
110
+ ``missing``
111
+ Sensors discounted to zero reliability. These contributed nothing at
112
+ all, and the distinction from ``silent`` is the difference between a
113
+ quiet room and a broken sensor.
114
+ """
115
+
116
+ at: datetime
117
+ state: BehaviouralState
118
+ probability: float
119
+ abstained: bool
120
+ reason: str | None
121
+ supporting: tuple[str, ...]
122
+ contradicting: tuple[str, ...]
123
+ silent: tuple[str, ...]
124
+ missing: tuple[str, ...]
125
+ completeness: float
126
+
127
+ def render(self) -> str:
128
+ """Render the explanation as an indented, human-readable block."""
129
+ lines = [
130
+ f"State: {self.state.value}",
131
+ f"Probability: {self.probability:.2f}",
132
+ f"Sensor coverage: {self.completeness:.2f}",
133
+ ]
134
+ if self.abstained and self.reason:
135
+ lines.append(f"Declined because: {self.reason}")
136
+ lines.append(f"Supporting evidence: {', '.join(self.supporting) or 'none'}")
137
+ lines.append(
138
+ f"Contradictory: {', '.join(self.contradicting) or 'none'}"
139
+ )
140
+ lines.append(f"Quiet but working: {', '.join(self.silent) or 'none'}")
141
+ lines.append(f"No evidence from: {', '.join(self.missing) or 'none'}")
142
+ return "\n".join(lines)
143
+
144
+ def to_dict(self) -> dict[str, object]:
145
+ """Return a serialisable form of the explanation."""
146
+ return {
147
+ "at": self.at.isoformat(),
148
+ "state": self.state.value,
149
+ "probability": self.probability,
150
+ "abstained": self.abstained,
151
+ "reason": self.reason,
152
+ "supporting": list(self.supporting),
153
+ "contradicting": list(self.contradicting),
154
+ "silent": list(self.silent),
155
+ "missing": list(self.missing),
156
+ "completeness": self.completeness,
157
+ }
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class StateEstimate:
162
+ """A posterior over latent behavioural states, with its justification."""
163
+
164
+ at: datetime
165
+ ontology: StateOntology
166
+ belief: np.ndarray
167
+ evidence: tuple[EvidenceContribution, ...]
168
+ completeness: float
169
+ min_confidence: float
170
+ min_completeness: float
171
+
172
+ def __post_init__(self) -> None:
173
+ """Validate the belief vector."""
174
+ belief = np.asarray(self.belief, dtype=float)
175
+ if belief.shape != (self.ontology.size,):
176
+ raise ValueError("belief must have one entry per ontology state")
177
+ if not np.all(np.isfinite(belief)) or belief.min() < 0.0:
178
+ raise ValueError("belief must be finite and non-negative")
179
+ total = belief.sum()
180
+ if total <= 0.0:
181
+ raise ValueError("belief must contain at least some probability mass")
182
+ object.__setattr__(self, "belief", belief / total)
183
+
184
+ # ------------------------------------------------------------------
185
+ @property
186
+ def probabilities(self) -> dict[BehaviouralState, float]:
187
+ """Posterior probability of each latent state."""
188
+ return dict(zip(self.ontology.states, (float(p) for p in self.belief)))
189
+
190
+ @property
191
+ def most_likely(self) -> BehaviouralState:
192
+ """The highest-posterior state, regardless of whether it is credible."""
193
+ return self.ontology.states[int(np.argmax(self.belief))]
194
+
195
+ @property
196
+ def confidence(self) -> float:
197
+ """Posterior mass on the most likely state."""
198
+ return float(self.belief.max())
199
+
200
+ @property
201
+ def margin(self) -> float:
202
+ """Gap between the two highest posterior probabilities."""
203
+ if self.belief.size < 2:
204
+ return self.confidence
205
+ ordered = np.sort(self.belief)
206
+ return float(ordered[-1] - ordered[-2])
207
+
208
+ @property
209
+ def entropy(self) -> float:
210
+ """Shannon entropy of the posterior in nats."""
211
+ mass = self.belief[self.belief > 0.0]
212
+ return float(-(mass * np.log(mass)).sum())
213
+
214
+ @property
215
+ def normalised_entropy(self) -> float:
216
+ """Entropy scaled to ``[0, 1]``; one means completely undecided."""
217
+ if self.ontology.size < 2:
218
+ return 0.0
219
+ return self.entropy / math.log(self.ontology.size)
220
+
221
+ @property
222
+ def abstained(self) -> bool:
223
+ """Whether the estimate declines to name a state.
224
+
225
+ Two independent reasons force an abstention: the posterior is too
226
+ flat to justify a claim, or too little of the sensing apparatus was
227
+ contributing for any posterior to be trustworthy.
228
+ """
229
+ return (
230
+ self.confidence < self.min_confidence
231
+ or self.completeness < self.min_completeness
232
+ )
233
+
234
+ @property
235
+ def state(self) -> BehaviouralState:
236
+ """The reported state, or ``UNKNOWN`` when the estimate abstains."""
237
+ return BehaviouralState.UNKNOWN if self.abstained else self.most_likely
238
+
239
+ # ------------------------------------------------------------------
240
+ @property
241
+ def supporting(self) -> tuple[EvidenceContribution, ...]:
242
+ """Sensors favouring the most likely state, strongest first."""
243
+ return tuple(
244
+ sorted(
245
+ (c for c in self.evidence if c.supports),
246
+ key=lambda c: c.support,
247
+ reverse=True,
248
+ )
249
+ )
250
+
251
+ @property
252
+ def contradicting(self) -> tuple[EvidenceContribution, ...]:
253
+ """Sensors favouring some other state, strongest first."""
254
+ return tuple(
255
+ sorted((c for c in self.evidence if c.contradicts), key=lambda c: c.support)
256
+ )
257
+
258
+ @property
259
+ def missing(self) -> tuple[str, ...]:
260
+ """Sensors discounted to zero reliability, contributing nothing."""
261
+ return tuple(c.sensor_id for c in self.evidence if not c.informative)
262
+
263
+ @property
264
+ def silent(self) -> tuple[str, ...]:
265
+ """Trusted sensors that reported nothing over the interval.
266
+
267
+ Their silence still informed the posterior; this is a record of what
268
+ was quiet, not of what was broken.
269
+ """
270
+ return tuple(
271
+ c.sensor_id for c in self.evidence if c.informative and not c.reported
272
+ )
273
+
274
+ def explanation(self, limit: int = 3) -> Explanation:
275
+ """Return the justification for this estimate as structured data.
276
+
277
+ Separate from :meth:`explain` because the two have different
278
+ consumers: a report renders the sentence, while an interface or an
279
+ export needs the parts addressable. Both are built from the same
280
+ evidence, so they cannot drift apart.
281
+ """
282
+ return Explanation(
283
+ at=self.at,
284
+ state=self.state,
285
+ probability=self.confidence,
286
+ abstained=self.abstained,
287
+ reason=(
288
+ None
289
+ if not self.abstained
290
+ else (
291
+ "insufficient sensor coverage"
292
+ if self.completeness < self.min_completeness
293
+ else "no state was clearly indicated"
294
+ )
295
+ ),
296
+ supporting=tuple(c.sensor_id for c in self.supporting[:limit]),
297
+ contradicting=tuple(c.sensor_id for c in self.contradicting[:limit]),
298
+ silent=self.silent,
299
+ missing=self.missing,
300
+ completeness=self.completeness,
301
+ )
302
+
303
+ def explain(self, limit: int = 3) -> str:
304
+ """Return a short human-readable justification for the estimate.
305
+
306
+ The wording is intentionally cautious. It reports what the sensors
307
+ indicated and how confident the model is, and makes no clinical claim.
308
+ """
309
+ if self.abstained:
310
+ reason = (
311
+ "insufficient sensor coverage"
312
+ if self.completeness < self.min_completeness
313
+ else "no state was clearly indicated"
314
+ )
315
+ return (
316
+ f"State unknown at {self.at.isoformat()}: {reason} "
317
+ f"(confidence {self.confidence:.2f}, "
318
+ f"coverage {self.completeness:.2f})."
319
+ )
320
+
321
+ parts = [
322
+ f"{self.state.value} at {self.at.isoformat()} "
323
+ f"with probability {self.confidence:.2f}"
324
+ ]
325
+ supporting = self.supporting[:limit]
326
+ if supporting:
327
+ named = ", ".join(f"{c.sensor_id} (+{c.support:.2f})" for c in supporting)
328
+ parts.append(f"supported by {named}")
329
+ contradicting = self.contradicting[:limit]
330
+ if contradicting:
331
+ named = ", ".join(f"{c.sensor_id} ({c.support:.2f})" for c in contradicting)
332
+ parts.append(f"contradicted by {named}")
333
+ if self.missing:
334
+ parts.append(f"no evidence from {', '.join(self.missing)}")
335
+ if self.silent:
336
+ parts.append(f"nothing reported by {', '.join(self.silent)}")
337
+ return "; ".join(parts) + "."
338
+
339
+ def to_dict(self) -> dict[str, object]:
340
+ """Return a serialisable form of the estimate."""
341
+ return {
342
+ "at": self.at.isoformat(),
343
+ "state": self.state.value,
344
+ "most_likely": self.most_likely.value,
345
+ "abstained": self.abstained,
346
+ "confidence": self.confidence,
347
+ "margin": self.margin,
348
+ "normalised_entropy": self.normalised_entropy,
349
+ "completeness": self.completeness,
350
+ "probabilities": {
351
+ state.value: probability
352
+ for state, probability in self.probabilities.items()
353
+ },
354
+ "evidence": [c.to_dict() for c in self.evidence],
355
+ "missing": list(self.missing),
356
+ "silent": list(self.silent),
357
+ "explanation_parts": self.explanation().to_dict(),
358
+ "explanation": self.explain(),
359
+ }
360
+
361
+
362
+ def belief_from_mapping(
363
+ ontology: StateOntology, probabilities: Mapping[BehaviouralState, float]
364
+ ) -> np.ndarray:
365
+ """Build a belief vector from a mapping, filling absent states with zero."""
366
+ return np.array(
367
+ [float(probabilities.get(state, 0.0)) for state in ontology.states], dtype=float
368
+ )
369
+
370
+
371
+ def belief_matrix(estimates: Sequence[StateEstimate]) -> np.ndarray:
372
+ """Stack the beliefs of several estimates into a ``(n, n_states)`` array."""
373
+ if not estimates:
374
+ return np.zeros((0, 0))
375
+ return np.vstack([estimate.belief for estimate in estimates])
@@ -0,0 +1,323 @@
1
+ """Recursive multimodal fusion into a latent behavioural state.
2
+
3
+ :class:`MultimodalBayesFilter` maintains ``P(Z_t | O_1:t)`` -- the posterior
4
+ over latent states given every observation so far, from every modality.
5
+
6
+ It is a forward filter over a continuous-time Markov chain, which is what lets
7
+ it accept the traffic an ambient deployment actually produces: observations
8
+ arriving asynchronously, at different rates per modality, with modalities
9
+ appearing and disappearing as sensors fail and recover. Between updates the
10
+ belief is propagated by the chain's transition operator for exactly the
11
+ elapsed interval; at each update every sensor that had something to say
12
+ contributes a tempered log-likelihood.
13
+
14
+ Three properties follow from the construction rather than from special cases:
15
+
16
+ *A failed sensor is silent, not negative.* Reliability enters as a tempering
17
+ exponent, so a sensor with reliability zero contributes a flat likelihood and
18
+ leaves the belief entirely to the other modalities.
19
+
20
+ *Absence of evidence widens the posterior.* With nothing to condition on, the
21
+ prediction step relaxes the belief toward the chain's stationary distribution,
22
+ confidence falls, and the estimate eventually abstains instead of coasting on
23
+ a stale conclusion.
24
+
25
+ *Contradiction is preserved, not resolved.* Sensors pointing at different
26
+ states pull the posterior apart, which shows up as a lower confidence and as
27
+ explicitly recorded contradicting evidence, rather than being averaged away.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from collections.abc import Iterable, Mapping, Sequence
34
+ from dataclasses import dataclass
35
+ from datetime import datetime
36
+
37
+ import numpy as np
38
+ from scipy.special import logsumexp
39
+
40
+ from ..observations.observation import Observation, require_aware
41
+ from ..observations.registry import SensorRegistry
42
+ from ..observations.types import Modality
43
+ from ..states.ontology import StateOntology
44
+ from .emissions import EmissionModel
45
+ from .estimate import EvidenceContribution, StateEstimate
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ class NonMonotonicUpdateError(ValueError):
51
+ """Raised when a filter update would move backwards in time.
52
+
53
+ The filter is causal by construction. Reordering late-arriving records is
54
+ the job of the surrounding pipeline, which owns a buffer and can decide
55
+ how much lateness to tolerate; silently folding a stale record into the
56
+ current belief would corrupt it without any trace.
57
+ """
58
+
59
+
60
+ @dataclass
61
+ class FusionConfig:
62
+ """Thresholds governing when the filter declines to name a state.
63
+
64
+ Parameters
65
+ ----------
66
+ min_confidence
67
+ Posterior mass the leading state must hold to be reported.
68
+ min_completeness
69
+ Mean sensor reliability required before any posterior is trusted.
70
+ evidence_floor
71
+ Reliability below which a sensor is recorded as supplying nothing.
72
+ """
73
+
74
+ min_confidence: float = 0.35
75
+ min_completeness: float = 0.25
76
+ evidence_floor: float = 0.05
77
+
78
+ def __post_init__(self) -> None:
79
+ """Validate the abstention thresholds."""
80
+ for name in ("min_confidence", "min_completeness", "evidence_floor"):
81
+ value = float(getattr(self, name))
82
+ if not 0.0 <= value <= 1.0:
83
+ raise ValueError(f"{name} must lie in [0, 1]")
84
+ setattr(self, name, value)
85
+
86
+
87
+ class MultimodalBayesFilter:
88
+ """Forward filtering of latent behavioural state over asynchronous evidence.
89
+
90
+ Parameters
91
+ ----------
92
+ ontology
93
+ Latent states and their continuous-time dynamics.
94
+ emissions
95
+ One observation model per sensor that should inform the state.
96
+ registry
97
+ Sensor declarations, used to label evidence with its modality.
98
+ config
99
+ Abstention thresholds.
100
+ prior
101
+ Initial belief. Defaults to the ontology's stationary distribution,
102
+ which is the most defensible starting point before any evidence.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ ontology: StateOntology,
108
+ emissions: Iterable[EmissionModel],
109
+ registry: SensorRegistry | None = None,
110
+ config: FusionConfig | None = None,
111
+ prior: np.ndarray | None = None,
112
+ ) -> None:
113
+ self.ontology = ontology
114
+ self.registry = registry
115
+ self.config = config or FusionConfig()
116
+ self.emissions: dict[str, EmissionModel] = {}
117
+ for emission in emissions:
118
+ if emission.sensor_id in self.emissions:
119
+ raise ValueError(
120
+ f"duplicate emission model for sensor '{emission.sensor_id}'"
121
+ )
122
+ self.emissions[emission.sensor_id] = emission
123
+ if not self.emissions:
124
+ raise ValueError("at least one emission model is required")
125
+
126
+ self._prior: np.ndarray = self._validated_prior(prior)
127
+ self._belief: np.ndarray = self._prior.copy()
128
+ self._at: datetime | None = None
129
+
130
+ def _validated_prior(self, prior: np.ndarray | None) -> np.ndarray:
131
+ """Return a normalised prior belief vector."""
132
+ if prior is None:
133
+ return self.ontology.stationary()
134
+ vector = np.asarray(prior, dtype=float)
135
+ if vector.shape != (self.ontology.size,):
136
+ raise ValueError("prior must have one entry per ontology state")
137
+ if not np.all(np.isfinite(vector)) or vector.min() < 0.0 or vector.sum() <= 0.0:
138
+ raise ValueError("prior must be finite, non-negative, and non-zero")
139
+ normalised: np.ndarray = vector / vector.sum()
140
+ return normalised
141
+
142
+ # ------------------------------------------------------------------
143
+ @property
144
+ def at(self) -> datetime | None:
145
+ """Time of the most recent update, if the filter has been used."""
146
+ return self._at
147
+
148
+ @property
149
+ def belief(self) -> np.ndarray:
150
+ """A copy of the current posterior over latent states."""
151
+ current: np.ndarray = self._belief.copy()
152
+ return current
153
+
154
+ def reset(self, prior: np.ndarray | None = None) -> None:
155
+ """Return the filter to its initial belief and clear its clock."""
156
+ self._prior = self._validated_prior(prior)
157
+ self._belief = self._prior.copy()
158
+ self._at = None
159
+
160
+ # ------------------------------------------------------------------
161
+ @staticmethod
162
+ def _weight_for(
163
+ weights: Mapping[str, float] | float | None, sensor_id: str, default: float
164
+ ) -> float:
165
+ """Resolve a per-sensor weight from a mapping, scalar, or default."""
166
+ if weights is None:
167
+ return default
168
+ if isinstance(weights, Mapping):
169
+ return float(weights.get(sensor_id, default))
170
+ return float(weights)
171
+
172
+ def _modality_of(self, sensor_id: str) -> Modality:
173
+ """Return the modality of *sensor_id*, or ``OTHER`` when unknown."""
174
+ if self.registry is None:
175
+ return Modality.OTHER
176
+ spec = self.registry.get(sensor_id)
177
+ return spec.modality if spec is not None else Modality.OTHER
178
+
179
+ def update(
180
+ self,
181
+ now: datetime,
182
+ observations: Sequence[Observation] = (),
183
+ *,
184
+ reliabilities: Mapping[str, float] | float | None = None,
185
+ attribution: Mapping[str, float] | float | None = None,
186
+ ) -> StateEstimate:
187
+ """Advance the filter to *now* and fold in the interval's evidence.
188
+
189
+ Parameters
190
+ ----------
191
+ now
192
+ Timezone-aware instant to advance to. Must not precede the
193
+ previous update.
194
+ observations
195
+ Records covering the interval since the previous update. Records
196
+ from sensors without an emission model are ignored.
197
+ reliabilities
198
+ Per-sensor evidence weights from the health monitor. A missing
199
+ entry defaults to full reliability.
200
+ attribution
201
+ Per-sensor probability that the monitored resident generated the
202
+ observation. A missing entry defaults to full attribution.
203
+
204
+ Returns
205
+ -------
206
+ StateEstimate
207
+ The posterior with its supporting and contradicting evidence.
208
+ """
209
+ moment = require_aware(now, "now")
210
+ if self._at is not None and moment < self._at:
211
+ raise NonMonotonicUpdateError(
212
+ f"update at {moment.isoformat()} precedes the filter clock at "
213
+ f"{self._at.isoformat()}"
214
+ )
215
+
216
+ elapsed = moment - self._at if self._at is not None else moment - moment
217
+ predicted = self._belief @ self.ontology.transition(elapsed)
218
+
219
+ grouped: dict[str, list[Observation]] = {}
220
+ for observation in observations:
221
+ if observation.sensor_id in self.emissions:
222
+ grouped.setdefault(observation.sensor_id, []).append(observation)
223
+ else:
224
+ logger.debug(
225
+ "No emission model for sensor '%s'; observation ignored",
226
+ observation.sensor_id,
227
+ )
228
+
229
+ log_belief = np.log(np.maximum(predicted, 1e-300))
230
+ likelihoods: dict[str, np.ndarray] = {}
231
+ weights: dict[str, tuple[float, float]] = {}
232
+ reliability_total = 0.0
233
+
234
+ for sensor_id, emission in self.emissions.items():
235
+ reliability = self._weight_for(reliabilities, sensor_id, 1.0)
236
+ share = self._weight_for(attribution, sensor_id, 1.0)
237
+ reliability_total += min(max(reliability, 0.0), 1.0)
238
+ weights[sensor_id] = (reliability, share)
239
+ likelihoods[sensor_id] = emission.log_likelihood(
240
+ self.ontology,
241
+ grouped.get(sensor_id, []),
242
+ elapsed,
243
+ reliability=reliability,
244
+ attribution=share,
245
+ )
246
+ log_belief = log_belief + likelihoods[sensor_id]
247
+
248
+ self._belief = np.exp(log_belief - logsumexp(log_belief))
249
+ self._at = moment
250
+
251
+ # Support is measured against the state the posterior actually
252
+ # settled on, so a sensor pointing somewhere else is recorded as
253
+ # contradicting the conclusion rather than as backing its own guess.
254
+ winner = int(np.argmax(self._belief))
255
+ contributions = tuple(
256
+ EvidenceContribution(
257
+ sensor_id=sensor_id,
258
+ modality=self._modality_of(sensor_id),
259
+ support=_support_for(likelihoods[sensor_id], winner),
260
+ reliability=(
261
+ weights[sensor_id][0]
262
+ if weights[sensor_id][0] >= self.config.evidence_floor
263
+ else 0.0
264
+ ),
265
+ attribution=weights[sensor_id][1],
266
+ observations=len(grouped.get(sensor_id, [])),
267
+ )
268
+ for sensor_id in self.emissions
269
+ )
270
+
271
+ return StateEstimate(
272
+ at=moment,
273
+ ontology=self.ontology,
274
+ belief=self._belief.copy(),
275
+ evidence=contributions,
276
+ completeness=reliability_total / len(self.emissions),
277
+ min_confidence=self.config.min_confidence,
278
+ min_completeness=self.config.min_completeness,
279
+ )
280
+
281
+ # ------------------------------------------------------------------
282
+ def snapshot(self) -> dict[str, object]:
283
+ """Return restartable filter state."""
284
+ return {
285
+ "belief": self._belief.tolist(),
286
+ "at": self._at.isoformat() if self._at else None,
287
+ "states": self.ontology.labels(),
288
+ }
289
+
290
+ def restore(self, state: Mapping[str, object]) -> None:
291
+ """Restore filter state produced by :meth:`snapshot`.
292
+
293
+ The stored state labels are checked against the current ontology, so
294
+ a belief saved under a different state set is rejected rather than
295
+ silently reinterpreted position by position.
296
+ """
297
+ labels = state.get("states")
298
+ if labels is not None and list(labels) != self.ontology.labels(): # type: ignore[call-overload]
299
+ raise ValueError("snapshot was taken under a different state ontology")
300
+ belief = np.asarray(state["belief"], dtype=float)
301
+ if belief.shape != (self.ontology.size,):
302
+ raise ValueError("snapshot belief does not match the ontology size")
303
+ total = belief.sum()
304
+ if not np.all(np.isfinite(belief)) or belief.min() < 0.0 or total <= 0.0:
305
+ raise ValueError(
306
+ "snapshot belief must be finite, non-negative, and non-zero"
307
+ )
308
+ self._belief = belief / total
309
+ moment = state.get("at")
310
+ self._at = datetime.fromisoformat(str(moment)) if moment else None
311
+
312
+
313
+ def _support_for(likelihood: np.ndarray, winner: int) -> float:
314
+ """Return how much a sensor favours *winner* over the best alternative.
315
+
316
+ Positive means the sensor backs the reported state, negative means it
317
+ points elsewhere, and zero means it is indifferent -- which is what an
318
+ uninformative or fully discounted sensor produces.
319
+ """
320
+ if likelihood.size < 2:
321
+ return 0.0
322
+ others = np.delete(likelihood, winner)
323
+ return float(likelihood[winner] - others.max())