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