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,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
|
+
}
|