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,270 @@
1
+ """The canonical, hardware-neutral sensor observation.
2
+
3
+ Every ingestion adapter in the toolkit converts device-specific payloads into
4
+ :class:`Observation` instances. Downstream stages -- health monitoring,
5
+ fusion, state inference, baselines, alerting -- consume only this type, which
6
+ keeps scientific code independent of any particular sensor product.
7
+
8
+ An observation records *evidence*, not behaviour. It carries the metadata
9
+ needed to weight that evidence honestly: the unit it was measured in, the
10
+ quality the device reported, the confidence an upstream estimator attached to
11
+ it, and flags describing anything that was repaired during ingestion.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from collections.abc import Mapping
18
+ from dataclasses import dataclass, field, replace
19
+ from datetime import datetime, timedelta, timezone
20
+ from types import MappingProxyType
21
+ from typing import Any
22
+
23
+ from .types import EVENT_LIKE_MODALITIES, Modality, ObservationFlag, ObservationKind
24
+ from .units import Unit, to_canonical
25
+
26
+
27
+ def require_aware(value: Any, name: str) -> datetime:
28
+ """Return *value* as a timezone-aware :class:`datetime`.
29
+
30
+ Naive timestamps are rejected rather than assumed to be UTC or local: in
31
+ a longitudinal deployment that assumption silently shifts every event by
32
+ hours and quietly breaks daily-rhythm analysis across DST boundaries.
33
+ """
34
+ if isinstance(value, str):
35
+ try:
36
+ parsed = datetime.fromisoformat(value)
37
+ except ValueError as exc:
38
+ raise ValueError(f"{name} is not a valid ISO-8601 timestamp") from exc
39
+ elif isinstance(value, datetime):
40
+ parsed = value
41
+ else:
42
+ to_pydatetime = getattr(value, "to_pydatetime", None)
43
+ if to_pydatetime is None:
44
+ raise TypeError(f"{name} must be a datetime or ISO-8601 string")
45
+ parsed = to_pydatetime()
46
+
47
+ if parsed.tzinfo is None or parsed.tzinfo.utcoffset(parsed) is None:
48
+ raise ValueError(f"{name} must be timezone-aware")
49
+ return parsed
50
+
51
+
52
+ def _validate_unit_interval(value: float, name: str) -> float:
53
+ """Return *value* after checking it lies in ``[0, 1]``."""
54
+ number = float(value)
55
+ if not math.isfinite(number) or not 0.0 <= number <= 1.0:
56
+ raise ValueError(f"{name} must be a finite value in [0, 1]")
57
+ return number
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Observation:
62
+ """A single hardware-neutral sensor observation.
63
+
64
+ Parameters
65
+ ----------
66
+ timestamp
67
+ Timezone-aware instant the observation refers to.
68
+ sensor_id
69
+ Stable identifier of the reporting sensor within a deployment.
70
+ modality
71
+ What kind of physical evidence the sensor produces.
72
+ kind
73
+ Temporal semantics: event, persisting state, or periodic sample.
74
+ value
75
+ Numeric value in *unit*. Binary activations use ``0.0``/``1.0``.
76
+ unit
77
+ Unit of *value*; use :meth:`in_canonical_unit` to normalise.
78
+ quality
79
+ Device-reported measurement quality in ``[0, 1]``.
80
+ confidence
81
+ Confidence that *value* is correct, in ``[0, 1]``. Derived features
82
+ such as a radar presence probability should set this below one.
83
+ source
84
+ Identifier of the gateway, hub, or adapter that produced the record.
85
+ sampling_interval
86
+ Nominal interval between samples, for ``SAMPLE`` observations.
87
+ received_at
88
+ When the record reached the system, used to detect late arrivals.
89
+ flags
90
+ Provenance annotations added during ingestion and validation.
91
+ context
92
+ Free-form string metadata (room, placement, firmware version).
93
+ """
94
+
95
+ timestamp: datetime
96
+ sensor_id: str
97
+ modality: Modality
98
+ kind: ObservationKind
99
+ value: float
100
+ unit: Unit = Unit.NONE
101
+ quality: float = 1.0
102
+ confidence: float = 1.0
103
+ source: str = ""
104
+ sampling_interval: timedelta | None = None
105
+ received_at: datetime | None = None
106
+ flags: frozenset[ObservationFlag] = frozenset()
107
+ context: Mapping[str, str] = field(default_factory=dict, hash=False)
108
+
109
+ def __post_init__(self) -> None:
110
+ """Validate and normalise the record at the system boundary."""
111
+ set_field = object.__setattr__
112
+
113
+ set_field(self, "timestamp", require_aware(self.timestamp, "timestamp"))
114
+ if self.received_at is not None:
115
+ set_field(
116
+ self, "received_at", require_aware(self.received_at, "received_at")
117
+ )
118
+
119
+ if not isinstance(self.sensor_id, str) or not self.sensor_id.strip():
120
+ raise ValueError("sensor_id must be a non-empty string")
121
+ set_field(self, "sensor_id", self.sensor_id.strip())
122
+
123
+ set_field(self, "modality", Modality(self.modality))
124
+ set_field(self, "kind", ObservationKind(self.kind))
125
+ set_field(self, "unit", Unit(self.unit))
126
+
127
+ value = float(self.value)
128
+ if not math.isfinite(value):
129
+ raise ValueError("value must be finite; use a missing observation instead")
130
+ if self.unit is Unit.PROBABILITY and not 0.0 <= value <= 1.0:
131
+ raise ValueError("probability-valued observations must lie in [0, 1]")
132
+ if self.unit is Unit.COUNT and (value < 0 or value != int(value)):
133
+ raise ValueError("count-valued observations must be non-negative integers")
134
+ set_field(self, "value", value)
135
+
136
+ set_field(self, "quality", _validate_unit_interval(self.quality, "quality"))
137
+ set_field(
138
+ self, "confidence", _validate_unit_interval(self.confidence, "confidence")
139
+ )
140
+
141
+ if not isinstance(self.source, str):
142
+ raise TypeError("source must be a string")
143
+
144
+ if self.sampling_interval is not None:
145
+ if not isinstance(self.sampling_interval, timedelta):
146
+ raise TypeError("sampling_interval must be a timedelta")
147
+ if self.sampling_interval <= timedelta(0):
148
+ raise ValueError("sampling_interval must be positive")
149
+
150
+ set_field(self, "flags", frozenset(ObservationFlag(f) for f in self.flags))
151
+
152
+ if not isinstance(self.context, Mapping):
153
+ raise TypeError("context must be a mapping of strings to strings")
154
+ context = {str(k): str(v) for k, v in self.context.items()}
155
+ set_field(self, "context", MappingProxyType(context))
156
+
157
+ # ------------------------------------------------------------------
158
+ @property
159
+ def is_event(self) -> bool:
160
+ """Whether absence of this observation carries no information."""
161
+ return self.kind is ObservationKind.EVENT
162
+
163
+ @property
164
+ def latency(self) -> timedelta | None:
165
+ """Delay between the observation instant and its arrival, if known."""
166
+ if self.received_at is None:
167
+ return None
168
+ return self.received_at - self.timestamp
169
+
170
+ def evidence_weight(self) -> float:
171
+ """Return the combined reliability of this record in ``[0, 1]``.
172
+
173
+ Quality describes the sensing hardware and confidence describes the
174
+ value itself. Both must hold for the record to count as strong
175
+ evidence, so they combine multiplicatively.
176
+ """
177
+ return self.quality * self.confidence
178
+
179
+ def in_canonical_unit(self) -> Observation:
180
+ """Return an equivalent observation expressed in the canonical unit."""
181
+ converted, unit = to_canonical(self.value, self.unit)
182
+ if unit is self.unit:
183
+ return self
184
+ return replace(
185
+ self,
186
+ value=converted,
187
+ unit=unit,
188
+ flags=self.flags | {ObservationFlag.UNIT_CONVERTED},
189
+ )
190
+
191
+ def with_flags(self, *flags: ObservationFlag) -> Observation:
192
+ """Return a copy with additional provenance *flags* attached."""
193
+ if not flags:
194
+ return self
195
+ return replace(self, flags=self.flags | frozenset(flags))
196
+
197
+ def shifted(self, offset: timedelta, *, flag: bool = True) -> Observation:
198
+ """Return a copy whose timestamp is moved by *offset*.
199
+
200
+ Used by clock-drift correction, which records the adjustment as a
201
+ flag so that later stages can see the timestamp was not measured.
202
+ """
203
+ extra = frozenset({ObservationFlag.CLOCK_ADJUSTED}) if flag else frozenset()
204
+ return replace(
205
+ self, timestamp=self.timestamp + offset, flags=self.flags | extra
206
+ )
207
+
208
+ def identity(self) -> tuple[datetime, str, float]:
209
+ """Return the key used to recognise an exact duplicate record."""
210
+ return (self.timestamp.astimezone(timezone.utc), self.sensor_id, self.value)
211
+
212
+ def to_dict(self) -> dict[str, Any]:
213
+ """Return a JSON-serialisable representation of the observation."""
214
+ return {
215
+ "timestamp": self.timestamp.isoformat(),
216
+ "sensor_id": self.sensor_id,
217
+ "modality": self.modality.value,
218
+ "kind": self.kind.value,
219
+ "value": self.value,
220
+ "unit": self.unit.value,
221
+ "quality": self.quality,
222
+ "confidence": self.confidence,
223
+ "source": self.source,
224
+ "sampling_interval": (
225
+ self.sampling_interval.total_seconds()
226
+ if self.sampling_interval is not None
227
+ else None
228
+ ),
229
+ "received_at": (
230
+ self.received_at.isoformat() if self.received_at is not None else None
231
+ ),
232
+ "flags": sorted(f.value for f in self.flags),
233
+ "context": dict(self.context),
234
+ }
235
+
236
+ @classmethod
237
+ def from_dict(cls, payload: Mapping[str, Any]) -> Observation:
238
+ """Rebuild an observation from :meth:`to_dict` output."""
239
+ interval = payload.get("sampling_interval")
240
+ return cls(
241
+ timestamp=payload["timestamp"],
242
+ sensor_id=payload["sensor_id"],
243
+ modality=Modality(payload["modality"]),
244
+ kind=ObservationKind(payload["kind"]),
245
+ value=float(payload["value"]),
246
+ unit=Unit(payload.get("unit", Unit.NONE)),
247
+ quality=float(payload.get("quality", 1.0)),
248
+ confidence=float(payload.get("confidence", 1.0)),
249
+ source=str(payload.get("source", "")),
250
+ sampling_interval=(
251
+ timedelta(seconds=float(interval)) if interval is not None else None
252
+ ),
253
+ received_at=payload.get("received_at"),
254
+ flags=frozenset(ObservationFlag(f) for f in payload.get("flags", ()) or ()),
255
+ context=payload.get("context") or {},
256
+ )
257
+
258
+
259
+ def default_kind(modality: Modality) -> ObservationKind:
260
+ """Return the conventional :class:`ObservationKind` for *modality*.
261
+
262
+ Adapters should override this when a device genuinely reports something
263
+ else, but the defaults keep the dangerous case -- treating an event
264
+ stream as a continuously sampled signal -- from happening by accident.
265
+ """
266
+ return (
267
+ ObservationKind.EVENT
268
+ if modality in EVENT_LIKE_MODALITIES
269
+ else ObservationKind.SAMPLE
270
+ )
@@ -0,0 +1,262 @@
1
+ """Declarative descriptions of the sensors in a deployment.
2
+
3
+ A :class:`SensorRegistry` is the place where a deployment states what each
4
+ sensor *is*: its modality, its temporal semantics, the unit it reports in, the
5
+ room it observes, how often it is expected to report, and whether its
6
+ activations can be attributed to a specific person.
7
+
8
+ Inference code reads the registry instead of pattern-matching on sensor names,
9
+ which is what keeps the platform sensor-agnostic. Ingestion uses it to
10
+ normalise units and to reject records that contradict the declared contract.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import math
17
+ from collections.abc import Iterable, Iterator, Mapping
18
+ from dataclasses import dataclass, field, replace
19
+ from datetime import timedelta
20
+
21
+ from .observation import Observation, default_kind
22
+ from .types import Modality, ObservationFlag, ObservationKind
23
+ from .units import Unit, canonical_unit, convert
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SensorSpec:
30
+ """Static description of one sensor in a deployment.
31
+
32
+ Parameters
33
+ ----------
34
+ sensor_id
35
+ Stable identifier matching :attr:`Observation.sensor_id`.
36
+ modality
37
+ The kind of physical evidence produced.
38
+ kind
39
+ Temporal semantics; defaults to the convention for *modality*.
40
+ unit
41
+ Unit the sensor is expected to report in. Observations arriving in a
42
+ convertible unit are converted; incompatible units are rejected.
43
+ room
44
+ Room or zone observed, when the sensor is spatially localised.
45
+ expected_interval
46
+ Nominal reporting interval. ``None`` for purely event-driven sensors,
47
+ which cannot be checked for silence in the same way.
48
+ value_range
49
+ Inclusive ``(low, high)`` bound on plausible values in *unit*.
50
+ prior_reliability
51
+ Prior probability in ``(0, 1]`` that this sensor is working. Used as
52
+ the starting point for online health estimation.
53
+ attributable
54
+ Whether an activation identifies *who* generated it. True only for
55
+ person-bound sensing such as a worn device or a personal beacon.
56
+ redundancy_group
57
+ Name shared by sensors observing substantially the same thing. The
58
+ fusion layer treats sensors as conditionally independent given the
59
+ state, so several views of one event would otherwise be counted as
60
+ several independent pieces of evidence and drive the posterior to
61
+ false certainty. Declaring a group divides the evidence weight
62
+ across it.
63
+ description
64
+ Human-readable note recording the precise semantics of the value.
65
+ """
66
+
67
+ sensor_id: str
68
+ modality: Modality
69
+ kind: ObservationKind | None = None
70
+ unit: Unit = Unit.NONE
71
+ room: str | None = None
72
+ expected_interval: timedelta | None = None
73
+ value_range: tuple[float, float] | None = None
74
+ prior_reliability: float = 0.99
75
+ attributable: bool = False
76
+ redundancy_group: str | None = None
77
+ description: str = ""
78
+
79
+ def __post_init__(self) -> None:
80
+ """Validate the declared sensor contract."""
81
+ set_field = object.__setattr__
82
+ if not isinstance(self.sensor_id, str) or not self.sensor_id.strip():
83
+ raise ValueError("sensor_id must be a non-empty string")
84
+ set_field(self, "sensor_id", self.sensor_id.strip())
85
+ set_field(self, "modality", Modality(self.modality))
86
+ set_field(
87
+ self,
88
+ "kind",
89
+ (
90
+ default_kind(self.modality)
91
+ if self.kind is None
92
+ else ObservationKind(self.kind)
93
+ ),
94
+ )
95
+ set_field(self, "unit", Unit(self.unit))
96
+
97
+ if not 0.0 < float(self.prior_reliability) <= 1.0:
98
+ raise ValueError("prior_reliability must lie in (0, 1]")
99
+ set_field(self, "prior_reliability", float(self.prior_reliability))
100
+
101
+ if self.expected_interval is not None:
102
+ if not isinstance(self.expected_interval, timedelta):
103
+ raise TypeError("expected_interval must be a timedelta")
104
+ if self.expected_interval <= timedelta(0):
105
+ raise ValueError("expected_interval must be positive")
106
+
107
+ if self.value_range is not None:
108
+ low, high = (float(v) for v in self.value_range)
109
+ if not math.isfinite(low) or not math.isfinite(high) or low > high:
110
+ raise ValueError("value_range must be a finite (low, high) pair")
111
+ set_field(self, "value_range", (low, high))
112
+
113
+ def contains(self, value: float) -> bool:
114
+ """Whether *value* lies inside the declared plausible range."""
115
+ if self.value_range is None:
116
+ return True
117
+ low, high = self.value_range
118
+ return low <= value <= high
119
+
120
+
121
+ class UnknownSensorError(KeyError):
122
+ """Raised when an observation refers to a sensor that is not registered."""
123
+
124
+
125
+ class SensorContractError(ValueError):
126
+ """Raised when an observation contradicts its declared :class:`SensorSpec`."""
127
+
128
+
129
+ @dataclass
130
+ class SensorRegistry:
131
+ """A collection of :class:`SensorSpec` records keyed by sensor identifier."""
132
+
133
+ specs: dict[str, SensorSpec] = field(default_factory=dict)
134
+
135
+ @classmethod
136
+ def from_specs(cls, specs: Iterable[SensorSpec]) -> SensorRegistry:
137
+ """Build a registry from an iterable of specifications."""
138
+ registry = cls()
139
+ for spec in specs:
140
+ registry.add(spec)
141
+ return registry
142
+
143
+ def add(self, spec: SensorSpec) -> None:
144
+ """Register *spec*, replacing any previous entry for the same sensor."""
145
+ if spec.sensor_id in self.specs:
146
+ logger.warning("Replacing existing spec for sensor '%s'", spec.sensor_id)
147
+ self.specs[spec.sensor_id] = spec
148
+
149
+ def __contains__(self, sensor_id: object) -> bool:
150
+ return sensor_id in self.specs
151
+
152
+ def __len__(self) -> int:
153
+ return len(self.specs)
154
+
155
+ def __iter__(self) -> Iterator[SensorSpec]:
156
+ return iter(self.specs.values())
157
+
158
+ def __getitem__(self, sensor_id: str) -> SensorSpec:
159
+ try:
160
+ return self.specs[sensor_id]
161
+ except KeyError as exc:
162
+ raise UnknownSensorError(f"sensor '{sensor_id}' is not registered") from exc
163
+
164
+ def get(self, sensor_id: str) -> SensorSpec | None:
165
+ """Return the spec for *sensor_id*, or ``None`` when unregistered."""
166
+ return self.specs.get(sensor_id)
167
+
168
+ def sensor_ids(self) -> list[str]:
169
+ """Return registered sensor identifiers in insertion order."""
170
+ return list(self.specs)
171
+
172
+ def by_modality(self, modality: Modality) -> list[SensorSpec]:
173
+ """Return every spec with the given *modality*."""
174
+ return [spec for spec in self.specs.values() if spec.modality is modality]
175
+
176
+ def by_room(self, room: str) -> list[SensorSpec]:
177
+ """Return every spec observing *room*."""
178
+ return [spec for spec in self.specs.values() if spec.room == room]
179
+
180
+ def rooms(self) -> list[str]:
181
+ """Return the distinct rooms covered by the registry, sorted."""
182
+ return sorted({spec.room for spec in self.specs.values() if spec.room})
183
+
184
+ def subset(self, sensor_ids: Iterable[str]) -> SensorRegistry:
185
+ """Return a registry restricted to *sensor_ids*.
186
+
187
+ Sensor-ablation experiments use this to build a deployment with a
188
+ modality removed without touching any inference code.
189
+ """
190
+ wanted = list(sensor_ids)
191
+ missing = [sid for sid in wanted if sid not in self.specs]
192
+ if missing:
193
+ raise UnknownSensorError(f"unregistered sensors: {sorted(missing)}")
194
+ return SensorRegistry({sid: self.specs[sid] for sid in wanted})
195
+
196
+ # ------------------------------------------------------------------
197
+ def normalise(self, observation: Observation) -> Observation:
198
+ """Validate *observation* against its spec and normalise its unit.
199
+
200
+ Returns
201
+ -------
202
+ Observation
203
+ The observation expressed in the unit declared by the spec.
204
+
205
+ Raises
206
+ ------
207
+ UnknownSensorError
208
+ If the sensor is not registered. Unregistered sensors are refused
209
+ rather than guessed at, because a guessed modality would silently
210
+ change what the value is taken to mean.
211
+ SensorContractError
212
+ If the declared unit is incompatible, or the modality disagrees.
213
+ """
214
+ spec = self[observation.sensor_id]
215
+
216
+ if observation.modality is not spec.modality:
217
+ raise SensorContractError(
218
+ f"sensor '{spec.sensor_id}' is registered as {spec.modality.value} "
219
+ f"but reported {observation.modality.value}"
220
+ )
221
+
222
+ result = observation
223
+ if observation.unit is not spec.unit:
224
+ if canonical_unit(observation.unit) is not canonical_unit(spec.unit):
225
+ raise SensorContractError(
226
+ f"sensor '{spec.sensor_id}' expects {spec.unit.value} but "
227
+ f"reported {observation.unit.value}"
228
+ )
229
+ converted = convert(observation.value, observation.unit, spec.unit)
230
+ result = replace(result, value=converted, unit=spec.unit)
231
+ result = result.with_flags(ObservationFlag.UNIT_CONVERTED)
232
+
233
+ if spec.kind is not None and result.kind is not spec.kind:
234
+ result = replace(result, kind=spec.kind)
235
+ return result
236
+
237
+ def in_range(self, observation: Observation) -> bool:
238
+ """Whether *observation* falls inside its declared plausible range."""
239
+ spec = self.get(observation.sensor_id)
240
+ return True if spec is None else spec.contains(observation.value)
241
+
242
+ def to_dict(self) -> dict[str, Mapping[str, object]]:
243
+ """Return a serialisable description of the registry."""
244
+ return {
245
+ spec.sensor_id: {
246
+ "modality": spec.modality.value,
247
+ "kind": spec.kind.value if spec.kind is not None else None,
248
+ "unit": spec.unit.value,
249
+ "room": spec.room,
250
+ "expected_interval": (
251
+ spec.expected_interval.total_seconds()
252
+ if spec.expected_interval is not None
253
+ else None
254
+ ),
255
+ "value_range": list(spec.value_range) if spec.value_range else None,
256
+ "prior_reliability": spec.prior_reliability,
257
+ "attributable": spec.attributable,
258
+ "redundancy_group": spec.redundancy_group,
259
+ "description": spec.description,
260
+ }
261
+ for spec in self.specs.values()
262
+ }