margin 0.9.22__tar.gz → 0.9.23__tar.gz
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.
Potentially problematic release.
This version of margin might be problematic. Click here for more details.
- {margin-0.9.22/margin.egg-info → margin-0.9.23}/PKG-INFO +1 -1
- {margin-0.9.22 → margin-0.9.23}/margin/observation.py +26 -0
- {margin-0.9.22 → margin-0.9.23}/margin/streaming.py +6 -0
- {margin-0.9.22 → margin-0.9.23/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.22 → margin-0.9.23}/pyproject.toml +1 -1
- {margin-0.9.22 → margin-0.9.23}/tests/test_absence.py +190 -41
- {margin-0.9.22 → margin-0.9.23}/LICENSE +0 -0
- {margin-0.9.22 → margin-0.9.23}/NOTICE +0 -0
- {margin-0.9.22 → margin-0.9.23}/README.md +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/__init__.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/__main__.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/algebra.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/anomaly.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/bridge.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/calibrate.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/causal.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/composite.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/confidence.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/config.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/contract.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/controller.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/correlate.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/diff.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/drift.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/events.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/fingerprint.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/forecast.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/health.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/intent.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/ledger.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/loop.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/persist.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/policy/__init__.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/policy/compose.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/policy/core.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/policy/temporal.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/policy/trace.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/policy/tuning.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/policy/validate.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/predicates.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/provenance.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/transitions.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/uncertain.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin/validity.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.22 → margin-0.9.23}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.22 → margin-0.9.23}/setup.cfg +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_algebra.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_anomaly.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_bridge.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_calibrate.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_causal.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_composite.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_confidence.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_config.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_contract.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_controller.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_correlate.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_diff.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_drift.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_events.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_fingerprint_ext.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_forecast.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_health.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_healthcare.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_intent.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_ledger.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_loop.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_neuro.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_observation.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_persist.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_pipeline_features.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_policy.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_policy_compose.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_policy_trace.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_policy_validate.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_predicates.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_printer3d.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_provenance.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_ros2.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_staleness.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_streaming.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_transformer.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_transitions.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_uncertain.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_validity.py +0 -0
- {margin-0.9.22 → margin-0.9.23}/tests/test_windowing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.23
|
|
4
4
|
Summary: Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened.
|
|
5
5
|
Author: Cope Labs LLC
|
|
6
6
|
License: MIT
|
|
@@ -272,6 +272,10 @@ class Expression:
|
|
|
272
272
|
def intact(self) -> list[Observation]:
|
|
273
273
|
return [o for o in self.observations if o.health == Health.INTACT]
|
|
274
274
|
|
|
275
|
+
def absent(self) -> list[Observation]:
|
|
276
|
+
"""Return observations that represent absent values."""
|
|
277
|
+
return [o for o in self.observations if o.is_absent]
|
|
278
|
+
|
|
275
279
|
def to_string(self) -> str:
|
|
276
280
|
"""Compact bracket notation."""
|
|
277
281
|
groups: dict[str, dict] = {}
|
|
@@ -391,6 +395,7 @@ class Parser:
|
|
|
391
395
|
label: str = "",
|
|
392
396
|
step: Optional[int] = None,
|
|
393
397
|
provenance: Optional[list[str]] = None,
|
|
398
|
+
absences: Optional[dict[str, Absence]] = None,
|
|
394
399
|
) -> Expression:
|
|
395
400
|
"""
|
|
396
401
|
Parse a set of component measurements into a typed Expression.
|
|
@@ -402,12 +407,20 @@ class Parser:
|
|
|
402
407
|
label: tag for this expression (e.g. step identifier)
|
|
403
408
|
step: step index (set automatically by Monitor)
|
|
404
409
|
provenance: provenance tags attached to all observations
|
|
410
|
+
absences: {component_name: Absence} — components listed here
|
|
411
|
+
get an Observation with health=OOD, value=baseline,
|
|
412
|
+
and the typed absence reason. Components in both
|
|
413
|
+
``values`` and ``absences`` are treated as absent
|
|
414
|
+
(absence wins).
|
|
405
415
|
"""
|
|
406
416
|
confidences = confidences or {}
|
|
407
417
|
provenance = provenance or []
|
|
418
|
+
absences = absences or {}
|
|
408
419
|
|
|
409
420
|
observations = []
|
|
410
421
|
for name, val in values.items():
|
|
422
|
+
if name in absences:
|
|
423
|
+
continue # handled below with the other absences
|
|
411
424
|
if name not in self.baselines:
|
|
412
425
|
warnings.warn(
|
|
413
426
|
f"Parser.parse: component {name!r} has no baseline — "
|
|
@@ -427,6 +440,19 @@ class Parser:
|
|
|
427
440
|
health_label=ct.label_for(h) if ct.labels else None,
|
|
428
441
|
))
|
|
429
442
|
|
|
443
|
+
# Emit absent observations for components listed in absences
|
|
444
|
+
for name, reason in absences.items():
|
|
445
|
+
baseline = self.baselines.get(name, 0.0)
|
|
446
|
+
ct = self._thresholds_for(name)
|
|
447
|
+
conf = confidences.get(name, Confidence.INDETERMINATE)
|
|
448
|
+
observations.append(Observation(
|
|
449
|
+
name=name, health=Health.OOD, value=baseline, baseline=baseline,
|
|
450
|
+
confidence=conf, higher_is_better=ct.higher_is_better,
|
|
451
|
+
provenance=list(provenance),
|
|
452
|
+
health_label=ct.label_for(Health.OOD) if ct.labels else None,
|
|
453
|
+
absence=reason,
|
|
454
|
+
))
|
|
455
|
+
|
|
430
456
|
corrections = []
|
|
431
457
|
if values:
|
|
432
458
|
target, op = self._classify_op(values, correction_magnitude)
|
|
@@ -470,10 +470,15 @@ class Monitor:
|
|
|
470
470
|
now: Optional[datetime] = None,
|
|
471
471
|
confidences=None,
|
|
472
472
|
provenance=None,
|
|
473
|
+
absences=None,
|
|
473
474
|
) -> Expression:
|
|
474
475
|
"""
|
|
475
476
|
Process new measurements: classify health, update drift, check anomalies.
|
|
476
477
|
|
|
478
|
+
absences: optional {component: Absence} for components that could not
|
|
479
|
+
be measured this step. Absent observations are included in
|
|
480
|
+
the Expression but skipped in drift/anomaly trackers.
|
|
481
|
+
|
|
477
482
|
Returns the current Expression.
|
|
478
483
|
"""
|
|
479
484
|
now = now or datetime.now()
|
|
@@ -489,6 +494,7 @@ class Monitor:
|
|
|
489
494
|
self._expression = self.parser.parse(
|
|
490
495
|
values, label=label, step=self._step,
|
|
491
496
|
confidences=confidences, provenance=provenance,
|
|
497
|
+
absences=absences,
|
|
492
498
|
)
|
|
493
499
|
self._step += 1
|
|
494
500
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.23
|
|
4
4
|
Summary: Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened.
|
|
5
5
|
Author: Cope Labs LLC
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "margin"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.23"
|
|
8
8
|
description = "Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -171,6 +171,30 @@ class TestExpressionWithAbsence:
|
|
|
171
171
|
expr = Expression(observations=[obs])
|
|
172
172
|
assert expr.degraded() == []
|
|
173
173
|
|
|
174
|
+
def test_absent_helper(self):
|
|
175
|
+
obs_present = _obs(name="temp", value=22.0)
|
|
176
|
+
obs_absent = _obs(name="co2", absence=Absence.SENSOR_FAILED)
|
|
177
|
+
expr = Expression(observations=[obs_present, obs_absent])
|
|
178
|
+
result = expr.absent()
|
|
179
|
+
assert len(result) == 1
|
|
180
|
+
assert result[0].name == "co2"
|
|
181
|
+
assert result[0].absence is Absence.SENSOR_FAILED
|
|
182
|
+
|
|
183
|
+
def test_absent_empty_when_all_present(self):
|
|
184
|
+
obs = _obs(name="temp", value=22.0)
|
|
185
|
+
expr = Expression(observations=[obs])
|
|
186
|
+
assert expr.absent() == []
|
|
187
|
+
|
|
188
|
+
def test_absent_multiple(self):
|
|
189
|
+
obs1 = _obs(name="co2", absence=Absence.SENSOR_FAILED)
|
|
190
|
+
obs2 = _obs(name="ph", absence=Absence.BELOW_DETECTION)
|
|
191
|
+
obs3 = _obs(name="temp", value=22.0)
|
|
192
|
+
expr = Expression(observations=[obs1, obs2, obs3])
|
|
193
|
+
result = expr.absent()
|
|
194
|
+
assert len(result) == 2
|
|
195
|
+
names = {o.name for o in result}
|
|
196
|
+
assert names == {"co2", "ph"}
|
|
197
|
+
|
|
174
198
|
|
|
175
199
|
# ---------------------------------------------------------------------------
|
|
176
200
|
# Monitor skips absent observations in trackers
|
|
@@ -184,53 +208,178 @@ class TestMonitorAbsenceSkip:
|
|
|
184
208
|
)
|
|
185
209
|
return Monitor(parser, window=50)
|
|
186
210
|
|
|
187
|
-
def
|
|
188
|
-
"""
|
|
211
|
+
def test_normal_flow_unaffected(self):
|
|
212
|
+
"""Standard update flow still works — no regression from the guard."""
|
|
189
213
|
m = self._make_monitor()
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
m.update({"co2": 400.0, "temp": 22.0})
|
|
199
|
-
drift_n_after = m._drift_trackers["co2"].n_observations
|
|
200
|
-
assert drift_n_after == drift_n_before + 1 # normal obs counted
|
|
201
|
-
|
|
202
|
-
def test_absent_obs_excluded_from_drift(self):
|
|
203
|
-
"""
|
|
204
|
-
When an observation has absence set, Monitor.update() should skip it
|
|
205
|
-
in drift/anomaly trackers.
|
|
206
|
-
"""
|
|
214
|
+
for i in range(20):
|
|
215
|
+
expr = m.update({"co2": 400.0 + i, "temp": 22.0})
|
|
216
|
+
assert m._drift_trackers["co2"].n_observations == 20
|
|
217
|
+
assert m._drift_trackers["temp"].n_observations == 20
|
|
218
|
+
assert expr is not None
|
|
219
|
+
|
|
220
|
+
def test_absent_skipped_in_drift_tracker(self):
|
|
221
|
+
"""Absent observations via absences= should not enter drift tracker."""
|
|
207
222
|
m = self._make_monitor()
|
|
208
|
-
# Feed some normal data
|
|
209
223
|
for _ in range(5):
|
|
210
224
|
m.update({"co2": 400.0, "temp": 22.0})
|
|
211
225
|
n_before = m._drift_trackers["co2"].n_observations
|
|
212
226
|
|
|
213
|
-
#
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
#
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
# co2 absent this step — drift tracker should not advance
|
|
228
|
+
m.update({"temp": 22.0}, absences={"co2": Absence.SENSOR_FAILED})
|
|
229
|
+
assert m._drift_trackers["co2"].n_observations == n_before
|
|
230
|
+
# temp still counted
|
|
231
|
+
assert m._drift_trackers["temp"].n_observations == 6
|
|
232
|
+
|
|
233
|
+
def test_absent_skipped_in_anomaly_tracker(self):
|
|
234
|
+
"""Absent observations should not enter anomaly tracker."""
|
|
235
|
+
m = self._make_monitor()
|
|
236
|
+
for _ in range(5):
|
|
237
|
+
m.update({"co2": 400.0, "temp": 22.0})
|
|
238
|
+
n_before = m._anomaly_trackers["co2"].n_values
|
|
239
|
+
|
|
240
|
+
m.update({"temp": 22.0}, absences={"co2": Absence.NOT_MEASURED})
|
|
241
|
+
assert m._anomaly_trackers["co2"].n_values == n_before
|
|
242
|
+
|
|
243
|
+
def test_absent_appears_in_expression(self):
|
|
244
|
+
"""Even though skipped in trackers, absent obs is in the Expression."""
|
|
245
|
+
m = self._make_monitor()
|
|
246
|
+
expr = m.update(
|
|
247
|
+
{"temp": 22.0},
|
|
248
|
+
absences={"co2": Absence.SENSOR_FAILED},
|
|
222
249
|
)
|
|
223
|
-
|
|
224
|
-
assert
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
# Parser to set absence, which is a future feature.
|
|
250
|
+
assert len(expr.observations) == 2
|
|
251
|
+
assert len(expr.absent()) == 1
|
|
252
|
+
assert expr.absent()[0].name == "co2"
|
|
253
|
+
assert expr.absent()[0].absence is Absence.SENSOR_FAILED
|
|
228
254
|
|
|
229
|
-
def
|
|
230
|
-
"""
|
|
255
|
+
def test_absent_obs_has_health_ood(self):
|
|
256
|
+
"""Parser emits Health.OOD for absent components."""
|
|
231
257
|
m = self._make_monitor()
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
258
|
+
expr = m.update(
|
|
259
|
+
{"temp": 22.0},
|
|
260
|
+
absences={"co2": Absence.BELOW_DETECTION},
|
|
261
|
+
)
|
|
262
|
+
co2 = [o for o in expr.observations if o.name == "co2"][0]
|
|
263
|
+
assert co2.health is Health.OOD
|
|
264
|
+
assert co2.is_absent is True
|
|
265
|
+
|
|
266
|
+
def test_mixed_present_and_absent_over_time(self):
|
|
267
|
+
"""co2 comes and goes — drift tracker only counts present steps."""
|
|
268
|
+
m = self._make_monitor()
|
|
269
|
+
# 3 present
|
|
270
|
+
for _ in range(3):
|
|
271
|
+
m.update({"co2": 400.0, "temp": 22.0})
|
|
272
|
+
# 2 absent
|
|
273
|
+
for _ in range(2):
|
|
274
|
+
m.update({"temp": 22.0}, absences={"co2": Absence.PENDING})
|
|
275
|
+
# 3 more present
|
|
276
|
+
for _ in range(3):
|
|
277
|
+
m.update({"co2": 405.0, "temp": 22.0})
|
|
278
|
+
assert m._drift_trackers["co2"].n_observations == 6 # 3+3, not 8
|
|
279
|
+
assert m._drift_trackers["temp"].n_observations == 8
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
# Parser.parse() with absences parameter
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
class TestParserAbsences:
|
|
287
|
+
def _make_parser(self):
|
|
288
|
+
return Parser(
|
|
289
|
+
baselines={"co2": 400.0, "temp": 22.0, "ph": 7.0},
|
|
290
|
+
thresholds=Thresholds(intact=0.8, ablated=0.2),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def test_no_absences_unchanged(self):
|
|
294
|
+
p = self._make_parser()
|
|
295
|
+
expr = p.parse({"co2": 400.0, "temp": 22.0})
|
|
296
|
+
assert len(expr.observations) == 2
|
|
297
|
+
assert all(not o.is_absent for o in expr.observations)
|
|
298
|
+
|
|
299
|
+
def test_one_absent_component(self):
|
|
300
|
+
p = self._make_parser()
|
|
301
|
+
expr = p.parse(
|
|
302
|
+
{"temp": 22.0},
|
|
303
|
+
absences={"co2": Absence.SENSOR_FAILED},
|
|
304
|
+
)
|
|
305
|
+
assert len(expr.observations) == 2
|
|
306
|
+
co2 = [o for o in expr.observations if o.name == "co2"][0]
|
|
307
|
+
assert co2.is_absent is True
|
|
308
|
+
assert co2.absence is Absence.SENSOR_FAILED
|
|
309
|
+
assert co2.health is Health.OOD
|
|
310
|
+
assert co2.value == 400.0 # baseline used as value
|
|
311
|
+
|
|
312
|
+
def test_absent_value_equals_baseline(self):
|
|
313
|
+
p = self._make_parser()
|
|
314
|
+
expr = p.parse(
|
|
315
|
+
{"temp": 22.0},
|
|
316
|
+
absences={"ph": Absence.BELOW_DETECTION},
|
|
317
|
+
)
|
|
318
|
+
ph = [o for o in expr.observations if o.name == "ph"][0]
|
|
319
|
+
assert ph.value == 7.0 # baseline
|
|
320
|
+
assert ph.sigma == pytest.approx(0.0) # value == baseline → sigma 0
|
|
321
|
+
|
|
322
|
+
def test_absent_confidence_defaults_to_indeterminate(self):
|
|
323
|
+
p = self._make_parser()
|
|
324
|
+
expr = p.parse(
|
|
325
|
+
{"temp": 22.0},
|
|
326
|
+
absences={"co2": Absence.NOT_MEASURED},
|
|
327
|
+
)
|
|
328
|
+
co2 = [o for o in expr.observations if o.name == "co2"][0]
|
|
329
|
+
assert co2.confidence is Confidence.INDETERMINATE
|
|
330
|
+
|
|
331
|
+
def test_absent_confidence_overridable(self):
|
|
332
|
+
p = self._make_parser()
|
|
333
|
+
expr = p.parse(
|
|
334
|
+
{"temp": 22.0},
|
|
335
|
+
absences={"co2": Absence.NOT_MEASURED},
|
|
336
|
+
confidences={"co2": Confidence.LOW},
|
|
337
|
+
)
|
|
338
|
+
co2 = [o for o in expr.observations if o.name == "co2"][0]
|
|
339
|
+
assert co2.confidence is Confidence.LOW
|
|
340
|
+
|
|
341
|
+
def test_absence_wins_over_value(self):
|
|
342
|
+
"""If a component appears in both values and absences, absence wins."""
|
|
343
|
+
p = self._make_parser()
|
|
344
|
+
expr = p.parse(
|
|
345
|
+
{"co2": 999.0, "temp": 22.0},
|
|
346
|
+
absences={"co2": Absence.REDACTED},
|
|
347
|
+
)
|
|
348
|
+
co2 = [o for o in expr.observations if o.name == "co2"][0]
|
|
349
|
+
assert co2.is_absent is True
|
|
350
|
+
assert co2.value == 400.0 # baseline, not the 999.0 that was passed
|
|
351
|
+
|
|
352
|
+
def test_all_absent(self):
|
|
353
|
+
p = self._make_parser()
|
|
354
|
+
expr = p.parse(
|
|
355
|
+
{},
|
|
356
|
+
absences={
|
|
357
|
+
"co2": Absence.SENSOR_FAILED,
|
|
358
|
+
"temp": Absence.NOT_MEASURED,
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
assert len(expr.observations) == 2
|
|
362
|
+
assert all(o.is_absent for o in expr.observations)
|
|
363
|
+
|
|
364
|
+
def test_absent_unknown_component(self):
|
|
365
|
+
"""Absent component not in baselines still emits an observation."""
|
|
366
|
+
p = self._make_parser()
|
|
367
|
+
expr = p.parse(
|
|
368
|
+
{"temp": 22.0},
|
|
369
|
+
absences={"humidity": Absence.NOT_APPLICABLE},
|
|
370
|
+
)
|
|
371
|
+
hum = [o for o in expr.observations if o.name == "humidity"][0]
|
|
372
|
+
assert hum.is_absent is True
|
|
373
|
+
assert hum.value == 0.0 # no baseline → 0.0
|
|
374
|
+
|
|
375
|
+
def test_round_trip_absent_observation(self):
|
|
376
|
+
p = self._make_parser()
|
|
377
|
+
expr = p.parse(
|
|
378
|
+
{"temp": 22.0},
|
|
379
|
+
absences={"co2": Absence.BELOW_DETECTION},
|
|
380
|
+
)
|
|
381
|
+
d = expr.to_dict()
|
|
382
|
+
restored = Expression.from_dict(d)
|
|
383
|
+
co2 = [o for o in restored.observations if o.name == "co2"][0]
|
|
384
|
+
assert co2.absence is Absence.BELOW_DETECTION
|
|
385
|
+
assert co2.is_absent is True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|