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,195 @@
|
|
|
1
|
+
"""Converting legacy tabular sensor data into canonical observations.
|
|
2
|
+
|
|
3
|
+
The original toolkit represents sensor data as a :class:`~pandas.DataFrame`
|
|
4
|
+
indexed by timestamp with one column per sensor. That form carries no
|
|
5
|
+
modality, no unit, no quality, and -- most consequentially -- no distinction
|
|
6
|
+
between an event stream and a sampled signal.
|
|
7
|
+
|
|
8
|
+
These adapters bridge the two, but they cannot invent the missing information.
|
|
9
|
+
A caller must supply a :class:`~sensor_modeling.observations.SensorRegistry`
|
|
10
|
+
saying what each column actually is. That requirement is deliberate: guessing
|
|
11
|
+
a column's modality from its name is exactly the hardware-specific coupling
|
|
12
|
+
the canonical model exists to remove, and guessing its *kind* would decide,
|
|
13
|
+
invisibly, whether a gap in the data means "nothing happened" or "nothing was
|
|
14
|
+
measured".
|
|
15
|
+
|
|
16
|
+
Conversion runs in one direction only. Canonical observations can be framed
|
|
17
|
+
back into tables through :class:`~sensor_modeling.observations.ObservationStream`,
|
|
18
|
+
so the older models keep working unchanged, but nothing above the observation
|
|
19
|
+
layer consumes tables.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from collections.abc import Iterator, Mapping
|
|
26
|
+
from datetime import datetime, timezone, tzinfo
|
|
27
|
+
|
|
28
|
+
import pandas as pd
|
|
29
|
+
|
|
30
|
+
from .observation import Observation
|
|
31
|
+
from .registry import SensorRegistry, UnknownSensorError
|
|
32
|
+
from .types import ObservationKind
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LegacyConversionError(ValueError):
|
|
38
|
+
"""Raised when a legacy frame cannot be converted without guessing."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _localise(index: pd.DatetimeIndex, tz: tzinfo | None) -> pd.DatetimeIndex:
|
|
42
|
+
"""Return *index* as timezone-aware, without inventing an offset.
|
|
43
|
+
|
|
44
|
+
A naive legacy frame carries no timezone, so one must be supplied. It is
|
|
45
|
+
never assumed, because assuming UTC or local silently shifts every record
|
|
46
|
+
by hours and breaks daily-rhythm analysis across DST boundaries.
|
|
47
|
+
"""
|
|
48
|
+
if index.tz is not None:
|
|
49
|
+
return index
|
|
50
|
+
if tz is None:
|
|
51
|
+
raise LegacyConversionError(
|
|
52
|
+
"legacy frame has naive timestamps; pass tz= to say which timezone "
|
|
53
|
+
"they were recorded in rather than having one assumed"
|
|
54
|
+
)
|
|
55
|
+
return index.tz_localize(tz)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def observations_from_frame(
|
|
59
|
+
frame: pd.DataFrame,
|
|
60
|
+
registry: SensorRegistry,
|
|
61
|
+
*,
|
|
62
|
+
tz: tzinfo | None = None,
|
|
63
|
+
source: str = "legacy-frame",
|
|
64
|
+
skip_unregistered: bool = False,
|
|
65
|
+
) -> Iterator[Observation]:
|
|
66
|
+
"""Yield canonical observations from a legacy sensor frame.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
frame
|
|
71
|
+
Timestamp-indexed frame with one column per sensor.
|
|
72
|
+
registry
|
|
73
|
+
Declarations for the columns. Every column must be registered, so that
|
|
74
|
+
modality, unit and temporal semantics come from the deployment rather
|
|
75
|
+
than from a guess.
|
|
76
|
+
tz
|
|
77
|
+
Timezone of a naive index. Required when the index is naive.
|
|
78
|
+
source
|
|
79
|
+
Value recorded as the observation's ``source``.
|
|
80
|
+
skip_unregistered
|
|
81
|
+
When true, unregistered columns are logged and skipped instead of
|
|
82
|
+
raising. Useful for frames carrying derived columns alongside sensors.
|
|
83
|
+
|
|
84
|
+
Yields
|
|
85
|
+
------
|
|
86
|
+
Observation
|
|
87
|
+
One record per non-null cell, in timestamp order.
|
|
88
|
+
|
|
89
|
+
Notes
|
|
90
|
+
-----
|
|
91
|
+
Zero-valued cells are emitted for ``STATE`` and ``SAMPLE`` sensors, whose
|
|
92
|
+
zeros are genuine measurements, but **not** for ``EVENT`` sensors, whose
|
|
93
|
+
zeros are the absence of a record rather than an observation of nothing.
|
|
94
|
+
Emitting them would fabricate exactly the evidence the canonical model
|
|
95
|
+
exists to withhold.
|
|
96
|
+
"""
|
|
97
|
+
if not isinstance(frame.index, pd.DatetimeIndex):
|
|
98
|
+
raise LegacyConversionError("legacy frame must be indexed by timestamp")
|
|
99
|
+
|
|
100
|
+
index = _localise(frame.index, tz)
|
|
101
|
+
columns = []
|
|
102
|
+
for column in frame.columns:
|
|
103
|
+
spec = registry.get(str(column))
|
|
104
|
+
if spec is None:
|
|
105
|
+
if skip_unregistered:
|
|
106
|
+
logger.debug("Skipping unregistered column '%s'", column)
|
|
107
|
+
continue
|
|
108
|
+
raise UnknownSensorError(
|
|
109
|
+
f"column '{column}' is not registered; declare it in the "
|
|
110
|
+
"registry so its modality and temporal semantics are explicit"
|
|
111
|
+
)
|
|
112
|
+
columns.append((str(column), spec))
|
|
113
|
+
|
|
114
|
+
if not columns:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
for position, moment in enumerate(index):
|
|
118
|
+
timestamp = moment.to_pydatetime()
|
|
119
|
+
for name, spec in columns:
|
|
120
|
+
value = frame.iloc[position][name]
|
|
121
|
+
if pd.isna(value):
|
|
122
|
+
continue
|
|
123
|
+
numeric = float(value)
|
|
124
|
+
if spec.kind is ObservationKind.EVENT and numeric == 0.0:
|
|
125
|
+
continue
|
|
126
|
+
yield Observation(
|
|
127
|
+
timestamp=timestamp,
|
|
128
|
+
sensor_id=name,
|
|
129
|
+
modality=spec.modality,
|
|
130
|
+
kind=spec.kind or ObservationKind.SAMPLE,
|
|
131
|
+
value=numeric,
|
|
132
|
+
unit=spec.unit,
|
|
133
|
+
source=source,
|
|
134
|
+
context={"origin": "legacy-frame"},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def observations_from_dataset(
|
|
139
|
+
dataset: object,
|
|
140
|
+
registry: SensorRegistry,
|
|
141
|
+
**kwargs: object,
|
|
142
|
+
) -> Iterator[Observation]:
|
|
143
|
+
"""Yield canonical observations from a legacy ``SensorDataset``.
|
|
144
|
+
|
|
145
|
+
Accepts anything exposing ``to_dataframe()``, which keeps this adapter
|
|
146
|
+
from importing the older data layer and creating a cycle.
|
|
147
|
+
"""
|
|
148
|
+
to_dataframe = getattr(dataset, "to_dataframe", None)
|
|
149
|
+
if to_dataframe is None:
|
|
150
|
+
raise LegacyConversionError("dataset must expose to_dataframe()")
|
|
151
|
+
return observations_from_frame(to_dataframe(), registry, **kwargs) # type: ignore[arg-type]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def observations_from_records(
|
|
155
|
+
records: object,
|
|
156
|
+
registry: SensorRegistry,
|
|
157
|
+
*,
|
|
158
|
+
timestamp_key: str = "timestamp",
|
|
159
|
+
sensor_key: str = "sensor_id",
|
|
160
|
+
value_key: str = "value",
|
|
161
|
+
source: str = "legacy-records",
|
|
162
|
+
) -> Iterator[Observation]:
|
|
163
|
+
"""Yield canonical observations from long-format records.
|
|
164
|
+
|
|
165
|
+
Long format -- one row per reading, with a sensor column -- is what most
|
|
166
|
+
event-driven gateways export, and it carries event semantics correctly
|
|
167
|
+
because an absent row is simply an absent row.
|
|
168
|
+
"""
|
|
169
|
+
for record in records: # type: ignore[attr-defined]
|
|
170
|
+
if not isinstance(record, Mapping):
|
|
171
|
+
raise LegacyConversionError("each record must be a mapping")
|
|
172
|
+
sensor_id = str(record[sensor_key])
|
|
173
|
+
spec = registry[sensor_id]
|
|
174
|
+
yield Observation(
|
|
175
|
+
timestamp=record[timestamp_key],
|
|
176
|
+
sensor_id=sensor_id,
|
|
177
|
+
modality=spec.modality,
|
|
178
|
+
kind=spec.kind or ObservationKind.SAMPLE,
|
|
179
|
+
value=float(record[value_key]),
|
|
180
|
+
unit=spec.unit,
|
|
181
|
+
source=str(record.get("source", source)),
|
|
182
|
+
context={"origin": "legacy-records"},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def naive_utc(moment: datetime) -> datetime:
|
|
187
|
+
"""Attach UTC to a naive datetime, explicitly and visibly.
|
|
188
|
+
|
|
189
|
+
Provided so that a caller who genuinely knows their legacy data is UTC can
|
|
190
|
+
say so at the call site, rather than having the assumption buried in a
|
|
191
|
+
conversion routine.
|
|
192
|
+
"""
|
|
193
|
+
if moment.tzinfo is not None:
|
|
194
|
+
return moment
|
|
195
|
+
return moment.replace(tzinfo=timezone.utc)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Boundary validation and repair for incoming observations.
|
|
2
|
+
|
|
3
|
+
:class:`ObservationIngestor` is the single door through which sensor records
|
|
4
|
+
enter the platform. It is deliberately narrow in scope: it checks that a
|
|
5
|
+
record is *structurally* valid and consistent with its declared contract,
|
|
6
|
+
repairs what can be repaired deterministically, and records what it did.
|
|
7
|
+
|
|
8
|
+
Judgements about whether a sensor is *behaving* -- stuck, drifting, dead --
|
|
9
|
+
belong to :mod:`sensor_modeling.health`, which observes the ingested stream
|
|
10
|
+
over time. Keeping the two apart means a malfunctioning sensor never looks
|
|
11
|
+
like a malformed message, and vice versa.
|
|
12
|
+
|
|
13
|
+
The ingestor holds bounded state (one timestamp plus a short latency window
|
|
14
|
+
per source), so it is usable unchanged in a long-running edge deployment.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import statistics
|
|
21
|
+
from collections import deque
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timedelta, timezone
|
|
25
|
+
|
|
26
|
+
from .observation import Observation
|
|
27
|
+
from .registry import SensorContractError, SensorRegistry, UnknownSensorError
|
|
28
|
+
from .types import ObservationFlag
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RejectedObservation:
|
|
35
|
+
"""A record that could not be admitted, with the reason why."""
|
|
36
|
+
|
|
37
|
+
reason: str
|
|
38
|
+
sensor_id: str
|
|
39
|
+
detail: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class IngestionReport:
|
|
44
|
+
"""Counters describing what happened to a batch of incoming records."""
|
|
45
|
+
|
|
46
|
+
accepted: int = 0
|
|
47
|
+
duplicates: int = 0
|
|
48
|
+
out_of_order: int = 0
|
|
49
|
+
late_arrivals: int = 0
|
|
50
|
+
clock_adjusted: int = 0
|
|
51
|
+
unit_converted: int = 0
|
|
52
|
+
rejected: list[RejectedObservation] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def received(self) -> int:
|
|
56
|
+
"""Total number of records offered to the ingestor."""
|
|
57
|
+
return self.accepted + self.duplicates + len(self.rejected)
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict[str, object]:
|
|
60
|
+
"""Return a serialisable summary of the report."""
|
|
61
|
+
return {
|
|
62
|
+
"received": self.received,
|
|
63
|
+
"accepted": self.accepted,
|
|
64
|
+
"duplicates": self.duplicates,
|
|
65
|
+
"out_of_order": self.out_of_order,
|
|
66
|
+
"late_arrivals": self.late_arrivals,
|
|
67
|
+
"clock_adjusted": self.clock_adjusted,
|
|
68
|
+
"unit_converted": self.unit_converted,
|
|
69
|
+
"rejected": [
|
|
70
|
+
{"reason": r.reason, "sensor_id": r.sensor_id, "detail": r.detail}
|
|
71
|
+
for r in self.rejected
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ClockOffsetEstimator:
|
|
78
|
+
"""Estimate a per-source clock offset from observed arrival latencies.
|
|
79
|
+
|
|
80
|
+
For a source whose clock is ahead of the receiving system by ``delta``,
|
|
81
|
+
every reported timestamp is ``delta`` too large, so the measured latency
|
|
82
|
+
``received_at - timestamp`` is ``delta`` too small. Transport delay is
|
|
83
|
+
bounded below by some minimum, so the smallest latency seen from a source
|
|
84
|
+
estimates ``minimum_delay - delta``, and the correction to apply to its
|
|
85
|
+
timestamps is ``min_latency - reference_delay``.
|
|
86
|
+
|
|
87
|
+
This is the same minimum-filtering idea used by network time protocols.
|
|
88
|
+
It is intentionally simple and inspectable: it assumes the receiving clock
|
|
89
|
+
is the reference and that each source occasionally delivers a record with
|
|
90
|
+
close to the minimum transport delay.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
window
|
|
95
|
+
Number of recent latencies retained per source.
|
|
96
|
+
min_samples
|
|
97
|
+
Latencies required before an offset is reported at all.
|
|
98
|
+
tolerance
|
|
99
|
+
Offsets smaller than this are treated as noise and not applied.
|
|
100
|
+
reference_delay
|
|
101
|
+
Assumed minimum transport delay shared by all sources.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
window: int = 64
|
|
105
|
+
min_samples: int = 8
|
|
106
|
+
tolerance: timedelta = timedelta(seconds=30)
|
|
107
|
+
reference_delay: timedelta = timedelta(0)
|
|
108
|
+
_latencies: dict[str, deque[float]] = field(default_factory=dict, repr=False)
|
|
109
|
+
|
|
110
|
+
def __post_init__(self) -> None:
|
|
111
|
+
"""Validate estimator configuration."""
|
|
112
|
+
if self.window < 1:
|
|
113
|
+
raise ValueError("window must be at least 1")
|
|
114
|
+
if self.min_samples < 1:
|
|
115
|
+
raise ValueError("min_samples must be at least 1")
|
|
116
|
+
if self.min_samples > self.window:
|
|
117
|
+
raise ValueError("min_samples must not exceed window")
|
|
118
|
+
if self.tolerance < timedelta(0):
|
|
119
|
+
raise ValueError("tolerance must be non-negative")
|
|
120
|
+
|
|
121
|
+
def observe(self, source: str, latency: timedelta) -> None:
|
|
122
|
+
"""Record an observed arrival *latency* for *source*."""
|
|
123
|
+
samples = self._latencies.setdefault(source, deque(maxlen=self.window))
|
|
124
|
+
samples.append(latency.total_seconds())
|
|
125
|
+
|
|
126
|
+
def offset(self, source: str) -> timedelta:
|
|
127
|
+
"""Return the timestamp correction to apply to records from *source*."""
|
|
128
|
+
samples = self._latencies.get(source)
|
|
129
|
+
if samples is None or len(samples) < self.min_samples:
|
|
130
|
+
return timedelta(0)
|
|
131
|
+
estimate = timedelta(seconds=min(samples)) - self.reference_delay
|
|
132
|
+
if abs(estimate) < self.tolerance:
|
|
133
|
+
return timedelta(0)
|
|
134
|
+
return estimate
|
|
135
|
+
|
|
136
|
+
def offsets(self) -> dict[str, float]:
|
|
137
|
+
"""Return the current offset per source in seconds, for reporting."""
|
|
138
|
+
return {
|
|
139
|
+
source: self.offset(source).total_seconds() for source in self._latencies
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def median_latency(self, source: str) -> float | None:
|
|
143
|
+
"""Return the median observed latency in seconds, if known."""
|
|
144
|
+
samples = self._latencies.get(source)
|
|
145
|
+
if not samples:
|
|
146
|
+
return None
|
|
147
|
+
return statistics.median(samples)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class ObservationIngestor:
|
|
152
|
+
"""Validate, normalise, and repair observations at the system boundary.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
registry
|
|
157
|
+
Declared sensors. Records from unregistered sensors are rejected
|
|
158
|
+
rather than guessed at.
|
|
159
|
+
late_threshold
|
|
160
|
+
Arrival delay beyond which a record is flagged as a late arrival.
|
|
161
|
+
correct_clock_drift
|
|
162
|
+
Whether to apply the estimated per-source clock offset. Corrections
|
|
163
|
+
are always flagged so downstream stages know the timestamp was
|
|
164
|
+
adjusted rather than measured.
|
|
165
|
+
clock_estimator
|
|
166
|
+
Estimator used when ``correct_clock_drift`` is enabled.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
registry: SensorRegistry
|
|
170
|
+
late_threshold: timedelta = timedelta(minutes=5)
|
|
171
|
+
correct_clock_drift: bool = False
|
|
172
|
+
clock_estimator: ClockOffsetEstimator = field(default_factory=ClockOffsetEstimator)
|
|
173
|
+
_newest: datetime | None = field(default=None, repr=False)
|
|
174
|
+
|
|
175
|
+
def __post_init__(self) -> None:
|
|
176
|
+
"""Validate ingestor configuration."""
|
|
177
|
+
if self.late_threshold <= timedelta(0):
|
|
178
|
+
raise ValueError("late_threshold must be positive")
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def newest_timestamp(self) -> datetime | None:
|
|
182
|
+
"""The latest observation timestamp admitted so far."""
|
|
183
|
+
return self._newest
|
|
184
|
+
|
|
185
|
+
def ingest(
|
|
186
|
+
self, observation: Observation, report: IngestionReport | None = None
|
|
187
|
+
) -> Observation | None:
|
|
188
|
+
"""Admit a single observation.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
Observation | None
|
|
193
|
+
The normalised observation, or ``None`` when it was rejected.
|
|
194
|
+
Rejections are recorded in *report* rather than raised, so that a
|
|
195
|
+
single malformed record cannot stop a live stream.
|
|
196
|
+
"""
|
|
197
|
+
outcome = report if report is not None else IngestionReport()
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
normalised = self.registry.normalise(observation)
|
|
201
|
+
except UnknownSensorError as exc:
|
|
202
|
+
outcome.rejected.append(
|
|
203
|
+
RejectedObservation("unknown_sensor", observation.sensor_id, str(exc))
|
|
204
|
+
)
|
|
205
|
+
return None
|
|
206
|
+
except SensorContractError as exc:
|
|
207
|
+
outcome.rejected.append(
|
|
208
|
+
RejectedObservation("contract", observation.sensor_id, str(exc))
|
|
209
|
+
)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
if ObservationFlag.UNIT_CONVERTED in normalised.flags:
|
|
213
|
+
outcome.unit_converted += 1
|
|
214
|
+
|
|
215
|
+
latency = normalised.latency
|
|
216
|
+
if latency is not None:
|
|
217
|
+
self.clock_estimator.observe(normalised.source, latency)
|
|
218
|
+
if latency > self.late_threshold:
|
|
219
|
+
normalised = normalised.with_flags(ObservationFlag.LATE_ARRIVAL)
|
|
220
|
+
outcome.late_arrivals += 1
|
|
221
|
+
|
|
222
|
+
if self.correct_clock_drift:
|
|
223
|
+
offset = self.clock_estimator.offset(normalised.source)
|
|
224
|
+
if offset:
|
|
225
|
+
normalised = normalised.shifted(offset)
|
|
226
|
+
outcome.clock_adjusted += 1
|
|
227
|
+
|
|
228
|
+
if self._newest is not None and normalised.timestamp < self._newest:
|
|
229
|
+
normalised = normalised.with_flags(ObservationFlag.OUT_OF_ORDER)
|
|
230
|
+
outcome.out_of_order += 1
|
|
231
|
+
else:
|
|
232
|
+
self._newest = normalised.timestamp
|
|
233
|
+
|
|
234
|
+
outcome.accepted += 1
|
|
235
|
+
return normalised
|
|
236
|
+
|
|
237
|
+
def ingest_many(
|
|
238
|
+
self, observations: Iterable[Observation]
|
|
239
|
+
) -> tuple[list[Observation], IngestionReport]:
|
|
240
|
+
"""Admit many observations and return the survivors with a report."""
|
|
241
|
+
report = IngestionReport()
|
|
242
|
+
admitted = [
|
|
243
|
+
result
|
|
244
|
+
for result in (self.ingest(obs, report) for obs in observations)
|
|
245
|
+
if result is not None
|
|
246
|
+
]
|
|
247
|
+
if report.rejected:
|
|
248
|
+
logger.warning(
|
|
249
|
+
"Rejected %d of %d observations during ingestion",
|
|
250
|
+
len(report.rejected),
|
|
251
|
+
report.received,
|
|
252
|
+
)
|
|
253
|
+
return admitted, report
|
|
254
|
+
|
|
255
|
+
def snapshot(self) -> dict[str, object]:
|
|
256
|
+
"""Return restartable ingestor state."""
|
|
257
|
+
return {
|
|
258
|
+
"newest": self._newest.isoformat() if self._newest else None,
|
|
259
|
+
"clock_offsets": self.clock_estimator.offsets(),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
def restore(self, state: dict[str, object]) -> None:
|
|
263
|
+
"""Restore the newest-timestamp watermark from :meth:`snapshot`."""
|
|
264
|
+
newest = state.get("newest")
|
|
265
|
+
self._newest = (
|
|
266
|
+
datetime.fromisoformat(str(newest)).astimezone(timezone.utc)
|
|
267
|
+
if newest
|
|
268
|
+
else None
|
|
269
|
+
)
|