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.

Files changed (91) hide show
  1. {margin-0.9.22/margin.egg-info → margin-0.9.23}/PKG-INFO +1 -1
  2. {margin-0.9.22 → margin-0.9.23}/margin/observation.py +26 -0
  3. {margin-0.9.22 → margin-0.9.23}/margin/streaming.py +6 -0
  4. {margin-0.9.22 → margin-0.9.23/margin.egg-info}/PKG-INFO +1 -1
  5. {margin-0.9.22 → margin-0.9.23}/pyproject.toml +1 -1
  6. {margin-0.9.22 → margin-0.9.23}/tests/test_absence.py +190 -41
  7. {margin-0.9.22 → margin-0.9.23}/LICENSE +0 -0
  8. {margin-0.9.22 → margin-0.9.23}/NOTICE +0 -0
  9. {margin-0.9.22 → margin-0.9.23}/README.md +0 -0
  10. {margin-0.9.22 → margin-0.9.23}/margin/__init__.py +0 -0
  11. {margin-0.9.22 → margin-0.9.23}/margin/__main__.py +0 -0
  12. {margin-0.9.22 → margin-0.9.23}/margin/algebra.py +0 -0
  13. {margin-0.9.22 → margin-0.9.23}/margin/anomaly.py +0 -0
  14. {margin-0.9.22 → margin-0.9.23}/margin/bridge.py +0 -0
  15. {margin-0.9.22 → margin-0.9.23}/margin/calibrate.py +0 -0
  16. {margin-0.9.22 → margin-0.9.23}/margin/causal.py +0 -0
  17. {margin-0.9.22 → margin-0.9.23}/margin/composite.py +0 -0
  18. {margin-0.9.22 → margin-0.9.23}/margin/confidence.py +0 -0
  19. {margin-0.9.22 → margin-0.9.23}/margin/config.py +0 -0
  20. {margin-0.9.22 → margin-0.9.23}/margin/contract.py +0 -0
  21. {margin-0.9.22 → margin-0.9.23}/margin/controller.py +0 -0
  22. {margin-0.9.22 → margin-0.9.23}/margin/correlate.py +0 -0
  23. {margin-0.9.22 → margin-0.9.23}/margin/diff.py +0 -0
  24. {margin-0.9.22 → margin-0.9.23}/margin/drift.py +0 -0
  25. {margin-0.9.22 → margin-0.9.23}/margin/events.py +0 -0
  26. {margin-0.9.22 → margin-0.9.23}/margin/fingerprint.py +0 -0
  27. {margin-0.9.22 → margin-0.9.23}/margin/forecast.py +0 -0
  28. {margin-0.9.22 → margin-0.9.23}/margin/health.py +0 -0
  29. {margin-0.9.22 → margin-0.9.23}/margin/intent.py +0 -0
  30. {margin-0.9.22 → margin-0.9.23}/margin/ledger.py +0 -0
  31. {margin-0.9.22 → margin-0.9.23}/margin/loop.py +0 -0
  32. {margin-0.9.22 → margin-0.9.23}/margin/persist.py +0 -0
  33. {margin-0.9.22 → margin-0.9.23}/margin/policy/__init__.py +0 -0
  34. {margin-0.9.22 → margin-0.9.23}/margin/policy/compose.py +0 -0
  35. {margin-0.9.22 → margin-0.9.23}/margin/policy/core.py +0 -0
  36. {margin-0.9.22 → margin-0.9.23}/margin/policy/temporal.py +0 -0
  37. {margin-0.9.22 → margin-0.9.23}/margin/policy/trace.py +0 -0
  38. {margin-0.9.22 → margin-0.9.23}/margin/policy/tuning.py +0 -0
  39. {margin-0.9.22 → margin-0.9.23}/margin/policy/validate.py +0 -0
  40. {margin-0.9.22 → margin-0.9.23}/margin/predicates.py +0 -0
  41. {margin-0.9.22 → margin-0.9.23}/margin/provenance.py +0 -0
  42. {margin-0.9.22 → margin-0.9.23}/margin/transitions.py +0 -0
  43. {margin-0.9.22 → margin-0.9.23}/margin/uncertain.py +0 -0
  44. {margin-0.9.22 → margin-0.9.23}/margin/validity.py +0 -0
  45. {margin-0.9.22 → margin-0.9.23}/margin.egg-info/SOURCES.txt +0 -0
  46. {margin-0.9.22 → margin-0.9.23}/margin.egg-info/dependency_links.txt +0 -0
  47. {margin-0.9.22 → margin-0.9.23}/margin.egg-info/top_level.txt +0 -0
  48. {margin-0.9.22 → margin-0.9.23}/setup.cfg +0 -0
  49. {margin-0.9.22 → margin-0.9.23}/tests/test_algebra.py +0 -0
  50. {margin-0.9.22 → margin-0.9.23}/tests/test_anomaly.py +0 -0
  51. {margin-0.9.22 → margin-0.9.23}/tests/test_bridge.py +0 -0
  52. {margin-0.9.22 → margin-0.9.23}/tests/test_calibrate.py +0 -0
  53. {margin-0.9.22 → margin-0.9.23}/tests/test_causal.py +0 -0
  54. {margin-0.9.22 → margin-0.9.23}/tests/test_composite.py +0 -0
  55. {margin-0.9.22 → margin-0.9.23}/tests/test_confidence.py +0 -0
  56. {margin-0.9.22 → margin-0.9.23}/tests/test_config.py +0 -0
  57. {margin-0.9.22 → margin-0.9.23}/tests/test_contract.py +0 -0
  58. {margin-0.9.22 → margin-0.9.23}/tests/test_controller.py +0 -0
  59. {margin-0.9.22 → margin-0.9.23}/tests/test_correlate.py +0 -0
  60. {margin-0.9.22 → margin-0.9.23}/tests/test_diff.py +0 -0
  61. {margin-0.9.22 → margin-0.9.23}/tests/test_drift.py +0 -0
  62. {margin-0.9.22 → margin-0.9.23}/tests/test_events.py +0 -0
  63. {margin-0.9.22 → margin-0.9.23}/tests/test_fingerprint_ext.py +0 -0
  64. {margin-0.9.22 → margin-0.9.23}/tests/test_forecast.py +0 -0
  65. {margin-0.9.22 → margin-0.9.23}/tests/test_health.py +0 -0
  66. {margin-0.9.22 → margin-0.9.23}/tests/test_healthcare.py +0 -0
  67. {margin-0.9.22 → margin-0.9.23}/tests/test_intent.py +0 -0
  68. {margin-0.9.22 → margin-0.9.23}/tests/test_ledger.py +0 -0
  69. {margin-0.9.22 → margin-0.9.23}/tests/test_loop.py +0 -0
  70. {margin-0.9.22 → margin-0.9.23}/tests/test_neuro.py +0 -0
  71. {margin-0.9.22 → margin-0.9.23}/tests/test_observation.py +0 -0
  72. {margin-0.9.22 → margin-0.9.23}/tests/test_persist.py +0 -0
  73. {margin-0.9.22 → margin-0.9.23}/tests/test_pipeline_features.py +0 -0
  74. {margin-0.9.22 → margin-0.9.23}/tests/test_policy.py +0 -0
  75. {margin-0.9.22 → margin-0.9.23}/tests/test_policy_compose.py +0 -0
  76. {margin-0.9.22 → margin-0.9.23}/tests/test_policy_temporal.py +0 -0
  77. {margin-0.9.22 → margin-0.9.23}/tests/test_policy_trace.py +0 -0
  78. {margin-0.9.22 → margin-0.9.23}/tests/test_policy_tuning.py +0 -0
  79. {margin-0.9.22 → margin-0.9.23}/tests/test_policy_validate.py +0 -0
  80. {margin-0.9.22 → margin-0.9.23}/tests/test_predicates.py +0 -0
  81. {margin-0.9.22 → margin-0.9.23}/tests/test_printer3d.py +0 -0
  82. {margin-0.9.22 → margin-0.9.23}/tests/test_provenance.py +0 -0
  83. {margin-0.9.22 → margin-0.9.23}/tests/test_reverse_bridge.py +0 -0
  84. {margin-0.9.22 → margin-0.9.23}/tests/test_ros2.py +0 -0
  85. {margin-0.9.22 → margin-0.9.23}/tests/test_staleness.py +0 -0
  86. {margin-0.9.22 → margin-0.9.23}/tests/test_streaming.py +0 -0
  87. {margin-0.9.22 → margin-0.9.23}/tests/test_transformer.py +0 -0
  88. {margin-0.9.22 → margin-0.9.23}/tests/test_transitions.py +0 -0
  89. {margin-0.9.22 → margin-0.9.23}/tests/test_uncertain.py +0 -0
  90. {margin-0.9.22 → margin-0.9.23}/tests/test_validity.py +0 -0
  91. {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.22
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.22
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.22"
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 test_absent_obs_not_in_drift_window(self):
188
- """Absent observations should not enter the drift tracker."""
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
- # Feed 10 normal updates
191
- for _ in range(10):
192
- m.update({"co2": 400.0, "temp": 22.0})
193
- drift_n_before = m._drift_trackers["co2"].n_observations
194
-
195
- # Now manually inject an absent observation into the expression
196
- # by constructing one and passing it through update flow.
197
- # The simplest way: feed a normal update, then check count.
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
- # Manually set absence on the expression's observations after parse.
214
- # This simulates the downstream use case where a caller marks an
215
- # observation as absent before it reaches trackers.
216
- # Since Parser.parse() never sets absence, we test the guard by
217
- # constructing the observation directly and checking the skip path.
218
- from margin.observation import Absence as _Absence
219
- obs_absent = Observation(
220
- name="co2", health=Health.INTACT, value=0.0, baseline=400.0,
221
- confidence=Confidence.HIGH, absence=_Absence.SENSOR_FAILED,
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
- # Verify is_absent works
224
- assert obs_absent.is_absent is True
225
- # The guard in Monitor.update() checks obs.is_absent — we've verified
226
- # the field and property work. A full integration test would require
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 test_normal_flow_unaffected(self):
230
- """Standard update flow still works — no regression from the guard."""
255
+ def test_absent_obs_has_health_ood(self):
256
+ """Parser emits Health.OOD for absent components."""
231
257
  m = self._make_monitor()
232
- for i in range(20):
233
- expr = m.update({"co2": 400.0 + i, "temp": 22.0})
234
- assert m._drift_trackers["co2"].n_observations == 20
235
- assert m._drift_trackers["temp"].n_observations == 20
236
- assert expr is not None
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