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,296 @@
1
+ """Sensible default observation models derived from the sensor registry.
2
+
3
+ Hand-writing an emission model per sensor is the right thing to do for a study
4
+ that has calibration data. For everything else -- a first deployment, an
5
+ ablation sweep, the worked example -- the registry already says enough to build
6
+ a defensible model: the modality says what kind of likelihood applies, and the
7
+ room says which states should expect the sensor to fire.
8
+
9
+ Deriving the models from declarations rather than from sensor names is what
10
+ keeps the platform sensor-agnostic. Adding a new device to a deployment means
11
+ adding a :class:`~sensor_modeling.observations.registry.SensorSpec`, not
12
+ editing inference code.
13
+
14
+ The defaults are starting points and are meant to be replaced by fitted values
15
+ where data allows. They are deliberately conservative: rates differ between
16
+ states by roughly an order of magnitude rather than by several, so no single
17
+ sensor can dominate the posterior on its own.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from dataclasses import dataclass
24
+
25
+ from ..observations.registry import SensorRegistry, SensorSpec
26
+ from ..observations.types import Modality, ObservationKind
27
+ from ..observations.units import Unit
28
+ from ..states.ontology import BehaviouralState, StateOntology
29
+ from .emissions import (
30
+ BernoulliEmission,
31
+ BetaEmission,
32
+ EmissionModel,
33
+ GaussianEmission,
34
+ PoissonEventEmission,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ S = BehaviouralState
40
+
41
+
42
+ @dataclass
43
+ class EmissionDefaults:
44
+ """Rate and level assumptions used to build default emission models.
45
+
46
+ Parameters
47
+ ----------
48
+ active_rate
49
+ Activations per hour a room sensor produces when the resident is in
50
+ that room and fully active.
51
+ activity_levels
52
+ Fraction of ``active_rate`` each state actually generates. This is the
53
+ distinction between *where* a state puts the resident and *how much
54
+ they move* while there, and it is not optional: a sleeping person is
55
+ in the bedroom but almost perfectly still, so a bedroom sensor's
56
+ silence is expected during sleep rather than evidence against it.
57
+ idle_rate
58
+ Background rate: false activations, pets, draughts. Never zero, so a
59
+ single stray activation cannot rule a state out permanently.
60
+ away_rate
61
+ Rate while the resident is out. Non-zero because visitors exist.
62
+ door_rate
63
+ Rate for an entrance door in the states either side of a crossing.
64
+ wearable_levels
65
+ Expected wearable activity magnitude per state.
66
+ wearable_sigma
67
+ Standard deviation of the wearable signal.
68
+ presence_probability
69
+ Probability a personal beacon reads in-range in each home state.
70
+ bed_probability
71
+ Probability a bed sensor reads occupied in the in-bed states.
72
+ """
73
+
74
+ active_rate: float = 40.0
75
+ activity_levels: dict[BehaviouralState, float] | None = None
76
+ idle_rate: float = 0.15
77
+ away_rate: float = 0.05
78
+ door_rate: float = 4.0
79
+ wearable_levels: dict[BehaviouralState, float] | None = None
80
+ wearable_sigma: float = 0.35
81
+ presence_probability: float = 0.95
82
+ bed_probability: float = 0.97
83
+
84
+ def __post_init__(self) -> None:
85
+ """Validate the assumptions and supply per-state defaults."""
86
+ rates = (self.active_rate, self.idle_rate, self.away_rate, self.door_rate)
87
+ if any(rate < 0 for rate in rates):
88
+ raise ValueError("emission rates must be non-negative")
89
+ if self.wearable_sigma <= 0:
90
+ raise ValueError("wearable_sigma must be positive")
91
+ for name in ("presence_probability", "bed_probability"):
92
+ value = float(getattr(self, name))
93
+ if not 0.0 <= value <= 1.0:
94
+ raise ValueError(f"{name} must lie in [0, 1]")
95
+ if self.activity_levels is None:
96
+ self.activity_levels = {
97
+ S.SLEEPING: 0.02,
98
+ S.BED_AWAKE: 0.35,
99
+ S.HOME_INACTIVE: 0.25,
100
+ S.HOME_ACTIVE: 1.0,
101
+ S.KITCHEN_ACTIVITY: 1.0,
102
+ S.BATHROOM_ACTIVITY: 1.0,
103
+ S.AWAY: 0.0,
104
+ }
105
+ if any(level < 0 for level in self.activity_levels.values()):
106
+ raise ValueError("activity levels must be non-negative")
107
+ if self.wearable_levels is None:
108
+ self.wearable_levels = {
109
+ S.SLEEPING: 0.05,
110
+ S.BED_AWAKE: 0.3,
111
+ S.HOME_INACTIVE: 0.4,
112
+ S.HOME_ACTIVE: 1.4,
113
+ S.KITCHEN_ACTIVITY: 1.1,
114
+ S.BATHROOM_ACTIVITY: 0.9,
115
+ S.AWAY: 1.8,
116
+ }
117
+
118
+
119
+ def _event_rates(
120
+ spec: SensorSpec, ontology: StateOntology, defaults: EmissionDefaults
121
+ ) -> dict[BehaviouralState, float]:
122
+ """Return per-state activation rates for an event sensor.
123
+
124
+ A rate is the product of two independent things: whether the state puts
125
+ the resident within range of this sensor, and how much they move while in
126
+ that state. Conflating the two is the classic way to make a bed sensor
127
+ and a bedroom motion sensor argue with each other all night.
128
+ """
129
+ levels = defaults.activity_levels or {}
130
+ located = {ontology.room_of(state) for state in ontology.states}
131
+ # A state that names no room could put the resident in any of the rooms
132
+ # the ontology does name, plus somewhere unmonitored, so its rate for any
133
+ # one sensor is diluted across those possibilities.
134
+ spread = max(len({room for room in located if room}) + 1, 1)
135
+
136
+ rates: dict[BehaviouralState, float] = {}
137
+ for state in ontology.states:
138
+ activity = float(levels.get(state, 0.5))
139
+ room = ontology.room_of(state)
140
+ if state is S.AWAY:
141
+ rate = defaults.away_rate
142
+ elif spec.room is None:
143
+ rate = defaults.active_rate * activity
144
+ elif room == spec.room:
145
+ rate = defaults.active_rate * activity
146
+ elif room is None:
147
+ rate = defaults.active_rate * activity / spread
148
+ else:
149
+ rate = defaults.idle_rate
150
+ rates[state] = max(rate, defaults.idle_rate)
151
+ return rates
152
+
153
+
154
+ def _door_rates(
155
+ ontology: StateOntology, defaults: EmissionDefaults
156
+ ) -> dict[BehaviouralState, float]:
157
+ """Return per-state rates for an entrance door.
158
+
159
+ A door fires at the boundary between being in and being out, so it is
160
+ most informative about the states either side of a crossing rather than
161
+ about any state in isolation.
162
+ """
163
+ return {
164
+ state: (
165
+ defaults.door_rate
166
+ if state in (S.AWAY, S.HOME_ACTIVE)
167
+ else defaults.idle_rate
168
+ )
169
+ for state in ontology.states
170
+ }
171
+
172
+
173
+ def default_emission_for(
174
+ spec: SensorSpec,
175
+ ontology: StateOntology,
176
+ defaults: EmissionDefaults | None = None,
177
+ ) -> EmissionModel | None:
178
+ """Build a default observation model for one declared sensor.
179
+
180
+ Returns
181
+ -------
182
+ EmissionModel | None
183
+ ``None`` when the modality carries no information about behavioural
184
+ state. A room-occupancy count, for instance, is evidence about *who
185
+ is present* and belongs to the occupancy layer, not here.
186
+ """
187
+ settings = defaults or EmissionDefaults()
188
+ levels = settings.wearable_levels or {}
189
+
190
+ if spec.modality is Modality.DOOR:
191
+ return PoissonEventEmission(
192
+ spec.sensor_id, rates=_door_rates(ontology, settings)
193
+ )
194
+
195
+ if spec.kind is ObservationKind.EVENT:
196
+ return PoissonEventEmission(
197
+ spec.sensor_id, rates=_event_rates(spec, ontology, settings)
198
+ )
199
+
200
+ if spec.modality is Modality.BED_PRESSURE:
201
+ occupied = {
202
+ state: (
203
+ settings.bed_probability
204
+ if state in (S.SLEEPING, S.BED_AWAKE)
205
+ else 1.0 - settings.bed_probability
206
+ )
207
+ for state in ontology.states
208
+ }
209
+ return BernoulliEmission(spec.sensor_id, probabilities=occupied)
210
+
211
+ if spec.modality in (Modality.WEARABLE_MOTION, Modality.WEARABLE_PHYSIOLOGY):
212
+ return GaussianEmission(
213
+ spec.sensor_id,
214
+ means={state: levels.get(state, 0.5) for state in ontology.states},
215
+ sigmas={state: settings.wearable_sigma for state in ontology.states},
216
+ # A wearable samples far more often than a contact sensor fires.
217
+ # Without this discount its sampling rate, rather than its
218
+ # informativeness, would decide how much it counts.
219
+ weight=0.25,
220
+ )
221
+
222
+ if spec.modality is Modality.PROXIMITY:
223
+ present = {
224
+ state: (
225
+ 1.0 - settings.presence_probability
226
+ if state is S.AWAY
227
+ else settings.presence_probability
228
+ )
229
+ for state in ontology.states
230
+ }
231
+ return BernoulliEmission(spec.sensor_id, probabilities=present)
232
+
233
+ if spec.modality is Modality.RADAR:
234
+ if spec.unit is Unit.PROBABILITY:
235
+ return BetaEmission(
236
+ spec.sensor_id,
237
+ means={
238
+ state: (0.05 if state is S.AWAY else 0.9)
239
+ for state in ontology.states
240
+ },
241
+ weight=0.25,
242
+ )
243
+ # A track count is presence evidence: zero people means nobody home.
244
+ return GaussianEmission(
245
+ spec.sensor_id,
246
+ means={
247
+ state: (0.05 if state is S.AWAY else 1.0) for state in ontology.states
248
+ },
249
+ sigmas={state: 0.7 for state in ontology.states},
250
+ weight=0.25,
251
+ )
252
+
253
+ logger.debug(
254
+ "No default emission model for modality %s ('%s')",
255
+ spec.modality.value,
256
+ spec.sensor_id,
257
+ )
258
+ return None
259
+
260
+
261
+ def _group_sizes(registry: SensorRegistry) -> dict[str, int]:
262
+ """Count how many sensors share each declared redundancy group."""
263
+ sizes: dict[str, int] = {}
264
+ for spec in registry:
265
+ if spec.redundancy_group:
266
+ sizes[spec.redundancy_group] = sizes.get(spec.redundancy_group, 0) + 1
267
+ return sizes
268
+
269
+
270
+ def default_emissions(
271
+ registry: SensorRegistry,
272
+ ontology: StateOntology,
273
+ defaults: EmissionDefaults | None = None,
274
+ ) -> list[EmissionModel]:
275
+ """Build default observation models for every sensor that has one.
276
+
277
+ Sensors sharing a ``redundancy_group`` have their evidence weight divided
278
+ across the group. The filter combines sensors as conditionally
279
+ independent given the state, so without this, several views of the same
280
+ physical event accumulate as several independent observations and drive
281
+ the posterior toward certainty it has not earned.
282
+ """
283
+ sizes = _group_sizes(registry)
284
+ models: list[EmissionModel] = []
285
+ for spec in registry:
286
+ model = default_emission_for(spec, ontology, defaults)
287
+ if model is None:
288
+ continue
289
+ if spec.redundancy_group:
290
+ model.weight /= sizes[spec.redundancy_group]
291
+ models.append(model)
292
+ if not models:
293
+ raise ValueError(
294
+ "no sensor in the registry carries information about behavioural state"
295
+ )
296
+ return models
@@ -0,0 +1,339 @@
1
+ """Modality-specific observation models.
2
+
3
+ Each emission model answers one question: given that the resident is in state
4
+ ``z``, how surprising is what this sensor reported over the last interval?
5
+ Keeping that question per-sensor is what makes the fusion layer genuinely
6
+ multimodal -- modalities with different units, sampling rates and temporal
7
+ semantics each get an appropriate likelihood, and the filter combines them.
8
+
9
+ Two mechanisms are shared by every model.
10
+
11
+ *Tempering.* The raw log-likelihood is multiplied by the sensor's reliability
12
+ and by the probability that what it saw was generated by the monitored
13
+ resident. A reliability of zero contributes exactly nothing, which is the
14
+ formal statement of "a broken sensor is missing evidence, not evidence of
15
+ inactivity". This is a power-likelihood weighting in the sense of generalised
16
+ Bayesian updating, and it degrades smoothly rather than switching a sensor
17
+ abruptly on and off.
18
+
19
+ *Centring.* Log-likelihoods are shifted so the best-supported state sits at
20
+ zero. State-independent constants cancel under normalisation anyway, and
21
+ centring makes each model's output directly readable as "how much worse is
22
+ this state than the best one, according to this sensor".
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ from abc import ABC, abstractmethod
29
+ from collections.abc import Mapping, Sequence
30
+ from dataclasses import dataclass, field
31
+ from datetime import timedelta
32
+
33
+ import numpy as np
34
+
35
+ from ..observations.observation import Observation
36
+ from ..states.ontology import BehaviouralState, StateOntology
37
+
38
+ #: Smallest event rate any state may have, in events per second. A state that
39
+ #: "never" triggers a sensor still needs a positive rate, or a single spurious
40
+ #: activation would drive its posterior to exactly zero and make it
41
+ #: unrecoverable for the rest of the deployment.
42
+ MIN_RATE = 1e-9
43
+
44
+ #: Floor applied to Bernoulli and Beta parameters for the same reason.
45
+ EPSILON = 1e-6
46
+
47
+
48
+ @dataclass
49
+ class EmissionModel(ABC):
50
+ """Base class for per-sensor observation models.
51
+
52
+ Parameters
53
+ ----------
54
+ sensor_id
55
+ Sensor this model interprets.
56
+ weight
57
+ Evidence weight for the modality. Fast-sampling sensors accumulate
58
+ more log-likelihood per interval simply by reporting more often; this
59
+ multiplier lets a deployment correct that imbalance deliberately
60
+ rather than letting sampling rate decide how much a modality counts.
61
+ """
62
+
63
+ sensor_id: str
64
+ weight: float = 1.0
65
+
66
+ def __post_init__(self) -> None:
67
+ """Validate shared configuration."""
68
+ if not isinstance(self.sensor_id, str) or not self.sensor_id.strip():
69
+ raise ValueError("sensor_id must be a non-empty string")
70
+ if not math.isfinite(self.weight) or self.weight < 0.0:
71
+ raise ValueError("weight must be finite and non-negative")
72
+
73
+ @abstractmethod
74
+ def _raw_log_likelihood(
75
+ self,
76
+ ontology: StateOntology,
77
+ observations: Sequence[Observation],
78
+ elapsed: timedelta,
79
+ ) -> np.ndarray:
80
+ """Return the untempered per-state log-likelihood."""
81
+
82
+ def log_likelihood(
83
+ self,
84
+ ontology: StateOntology,
85
+ observations: Sequence[Observation],
86
+ elapsed: timedelta,
87
+ *,
88
+ reliability: float = 1.0,
89
+ attribution: float = 1.0,
90
+ ) -> np.ndarray:
91
+ """Return the tempered, centred per-state log-likelihood.
92
+
93
+ Parameters
94
+ ----------
95
+ reliability
96
+ Health-derived evidence weight in ``[0, 1]``.
97
+ attribution
98
+ Probability in ``[0, 1]`` that the monitored resident generated
99
+ what this sensor saw. Ambient sensors in a home with a visitor
100
+ should pass a value below one.
101
+ """
102
+ temper = self.weight * float(reliability) * float(attribution)
103
+ if temper <= 0.0:
104
+ return np.zeros(ontology.size)
105
+ raw = np.asarray(
106
+ self._raw_log_likelihood(ontology, observations, elapsed), dtype=float
107
+ )
108
+ if raw.shape != (ontology.size,):
109
+ raise ValueError("emission models must return one value per state")
110
+ if not np.all(np.isfinite(raw)):
111
+ raise ValueError(
112
+ f"emission model for '{self.sensor_id}' produced non-finite values"
113
+ )
114
+ tempered: np.ndarray = temper * (raw - raw.max())
115
+ return tempered
116
+
117
+ @staticmethod
118
+ def _per_state(
119
+ ontology: StateOntology,
120
+ values: Mapping[BehaviouralState, float],
121
+ default: float,
122
+ ) -> np.ndarray:
123
+ """Expand a per-state mapping into a vector in ontology order."""
124
+ return np.array(
125
+ [float(values.get(state, default)) for state in ontology.states],
126
+ dtype=float,
127
+ )
128
+
129
+
130
+ @dataclass
131
+ class PoissonEventEmission(EmissionModel):
132
+ """Event sensors modelled as a state-dependent Poisson process.
133
+
134
+ Over an interval of length ``T`` in which ``n`` activations were recorded,
135
+ a state with rate ``lambda`` has log-likelihood ``n log(lambda) -
136
+ lambda * T`` up to a constant. This is the natural model for irregular
137
+ event streams, and it handles the two hard cases correctly without
138
+ special-casing:
139
+
140
+ *Silence is graded evidence.* A long quiet interval penalises high-rate
141
+ states in proportion to how long the silence lasted, rather than being
142
+ treated as an observation of zero activity.
143
+
144
+ *Asynchrony is free.* The likelihood depends on elapsed time, so events
145
+ arriving at irregular moments need no resampling onto a grid.
146
+
147
+ Parameters
148
+ ----------
149
+ rates
150
+ Activations per hour expected in each state.
151
+ default_rate
152
+ Rate per hour for states not named in *rates*.
153
+ """
154
+
155
+ rates: Mapping[BehaviouralState, float] = field(default_factory=dict)
156
+ default_rate: float = 0.05
157
+
158
+ def __post_init__(self) -> None:
159
+ """Validate the declared rates."""
160
+ super().__post_init__()
161
+ for state, rate in self.rates.items():
162
+ if not math.isfinite(rate) or rate < 0.0:
163
+ raise ValueError(f"rate for {state} must be finite and non-negative")
164
+ if not math.isfinite(self.default_rate) or self.default_rate < 0.0:
165
+ raise ValueError("default_rate must be finite and non-negative")
166
+
167
+ def _raw_log_likelihood(
168
+ self,
169
+ ontology: StateOntology,
170
+ observations: Sequence[Observation],
171
+ elapsed: timedelta,
172
+ ) -> np.ndarray:
173
+ per_second = (
174
+ np.maximum(self._per_state(ontology, self.rates, self.default_rate), 0.0)
175
+ / 3600.0
176
+ )
177
+ per_second = np.maximum(per_second, MIN_RATE)
178
+ count = sum(1 for obs in observations if obs.value != 0.0)
179
+ duration = max(elapsed.total_seconds(), 0.0)
180
+ loglik: np.ndarray = count * np.log(per_second) - per_second * duration
181
+ return loglik
182
+
183
+
184
+ @dataclass
185
+ class GaussianEmission(EmissionModel):
186
+ """Sampled scalar signals modelled as state-dependent Gaussians.
187
+
188
+ Suitable for wearable activity counts, ambient light, or any continuously
189
+ sampled quantity whose typical level differs between states.
190
+
191
+ Parameters
192
+ ----------
193
+ means
194
+ Expected value of the signal in each state.
195
+ sigmas
196
+ Standard deviation in each state.
197
+ default_mean, default_sigma
198
+ Values used for states not named explicitly.
199
+ """
200
+
201
+ means: Mapping[BehaviouralState, float] = field(default_factory=dict)
202
+ sigmas: Mapping[BehaviouralState, float] = field(default_factory=dict)
203
+ default_mean: float = 0.0
204
+ default_sigma: float = 1.0
205
+
206
+ def __post_init__(self) -> None:
207
+ """Validate the declared moments."""
208
+ super().__post_init__()
209
+ if self.default_sigma <= 0.0 or not math.isfinite(self.default_sigma):
210
+ raise ValueError("default_sigma must be positive and finite")
211
+ for state, sigma in self.sigmas.items():
212
+ if not math.isfinite(sigma) or sigma <= 0.0:
213
+ raise ValueError(f"sigma for {state} must be positive and finite")
214
+
215
+ def _raw_log_likelihood(
216
+ self,
217
+ ontology: StateOntology,
218
+ observations: Sequence[Observation],
219
+ elapsed: timedelta,
220
+ ) -> np.ndarray:
221
+ if not observations:
222
+ return np.zeros(ontology.size)
223
+ means = self._per_state(ontology, self.means, self.default_mean)
224
+ sigmas = self._per_state(ontology, self.sigmas, self.default_sigma)
225
+ values = np.array([obs.value for obs in observations], dtype=float)
226
+ residuals = values[:, None] - means[None, :]
227
+ loglik: np.ndarray = -(
228
+ 0.5 * (residuals / sigmas[None, :]) ** 2 + np.log(sigmas)[None, :]
229
+ ).sum(axis=0)
230
+ return loglik
231
+
232
+
233
+ @dataclass
234
+ class BernoulliEmission(EmissionModel):
235
+ """Binary state sensors modelled as state-dependent Bernoulli draws.
236
+
237
+ Suitable for bed or chair occupancy, where the sensor reports a level that
238
+ persists until it changes. Only the most recent reading in the interval is
239
+ used, because repeated reports of an unchanged state are not repeated
240
+ independent measurements of it.
241
+
242
+ Parameters
243
+ ----------
244
+ probabilities
245
+ Probability the sensor reads one in each state.
246
+ default_probability
247
+ Probability for states not named explicitly.
248
+ """
249
+
250
+ probabilities: Mapping[BehaviouralState, float] = field(default_factory=dict)
251
+ default_probability: float = 0.5
252
+
253
+ def __post_init__(self) -> None:
254
+ """Validate the declared probabilities."""
255
+ super().__post_init__()
256
+ values = [*self.probabilities.values(), self.default_probability]
257
+ if any(not math.isfinite(p) or not 0.0 <= p <= 1.0 for p in values):
258
+ raise ValueError("probabilities must lie in [0, 1]")
259
+
260
+ def _raw_log_likelihood(
261
+ self,
262
+ ontology: StateOntology,
263
+ observations: Sequence[Observation],
264
+ elapsed: timedelta,
265
+ ) -> np.ndarray:
266
+ if not observations:
267
+ return np.zeros(ontology.size)
268
+ probabilities = np.clip(
269
+ self._per_state(ontology, self.probabilities, self.default_probability),
270
+ EPSILON,
271
+ 1.0 - EPSILON,
272
+ )
273
+ latest = observations[-1].value
274
+ loglik: np.ndarray = (
275
+ np.log(probabilities) if latest != 0.0 else np.log1p(-probabilities)
276
+ )
277
+ return loglik
278
+
279
+
280
+ @dataclass
281
+ class BetaEmission(EmissionModel):
282
+ """Probability-valued derived features modelled as Beta draws.
283
+
284
+ Radar and room-occupancy devices commonly report a presence probability
285
+ rather than a raw measurement. Treating that number as a Beta observation
286
+ keeps it on its natural support and lets the deployment state how much to
287
+ trust it through the concentration parameter, instead of hard-thresholding
288
+ it into a binary and discarding the uncertainty the device supplied.
289
+
290
+ Parameters
291
+ ----------
292
+ means
293
+ Expected feature value in each state, strictly inside ``(0, 1)``.
294
+ concentration
295
+ Beta concentration. Larger values mean the feature is expected to sit
296
+ tightly around its state mean.
297
+ default_mean
298
+ Mean for states not named explicitly.
299
+ """
300
+
301
+ means: Mapping[BehaviouralState, float] = field(default_factory=dict)
302
+ concentration: float = 8.0
303
+ default_mean: float = 0.5
304
+
305
+ def __post_init__(self) -> None:
306
+ """Validate the declared means and concentration."""
307
+ super().__post_init__()
308
+ if not math.isfinite(self.concentration) or self.concentration <= 0.0:
309
+ raise ValueError("concentration must be positive and finite")
310
+ values = [*self.means.values(), self.default_mean]
311
+ if any(not math.isfinite(m) or not 0.0 <= m <= 1.0 for m in values):
312
+ raise ValueError("means must lie in [0, 1]")
313
+
314
+ def _raw_log_likelihood(
315
+ self,
316
+ ontology: StateOntology,
317
+ observations: Sequence[Observation],
318
+ elapsed: timedelta,
319
+ ) -> np.ndarray:
320
+ if not observations:
321
+ return np.zeros(ontology.size)
322
+ means = np.clip(
323
+ self._per_state(ontology, self.means, self.default_mean),
324
+ EPSILON,
325
+ 1.0 - EPSILON,
326
+ )
327
+ alpha = self.concentration * means
328
+ beta = self.concentration * (1.0 - means)
329
+ values = np.clip(
330
+ np.array([obs.value for obs in observations], dtype=float),
331
+ EPSILON,
332
+ 1.0 - EPSILON,
333
+ )
334
+ log_values = np.log(values)[:, None]
335
+ log_complements = np.log1p(-values)[:, None]
336
+ loglik: np.ndarray = (
337
+ (alpha - 1.0) * log_values + (beta - 1.0) * log_complements
338
+ ).sum(axis=0)
339
+ return loglik