margin 0.9.20__tar.gz → 0.9.22__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.20/margin.egg-info → margin-0.9.22}/PKG-INFO +1 -1
- {margin-0.9.20 → margin-0.9.22}/margin/__init__.py +2 -2
- {margin-0.9.20 → margin-0.9.22}/margin/observation.py +38 -1
- {margin-0.9.20 → margin-0.9.22}/margin/streaming.py +7 -3
- {margin-0.9.20 → margin-0.9.22/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.20 → margin-0.9.22}/margin.egg-info/SOURCES.txt +1 -0
- {margin-0.9.20 → margin-0.9.22}/pyproject.toml +1 -1
- margin-0.9.22/tests/test_absence.py +236 -0
- {margin-0.9.20 → margin-0.9.22}/LICENSE +0 -0
- {margin-0.9.20 → margin-0.9.22}/NOTICE +0 -0
- {margin-0.9.20 → margin-0.9.22}/README.md +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/__main__.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/algebra.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/anomaly.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/bridge.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/calibrate.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/causal.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/composite.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/confidence.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/config.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/contract.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/controller.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/correlate.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/diff.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/drift.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/events.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/fingerprint.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/forecast.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/health.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/intent.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/ledger.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/loop.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/persist.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/policy/__init__.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/policy/compose.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/policy/core.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/policy/temporal.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/policy/trace.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/policy/tuning.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/policy/validate.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/predicates.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/provenance.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/transitions.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/uncertain.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin/validity.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.20 → margin-0.9.22}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.20 → margin-0.9.22}/setup.cfg +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_algebra.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_anomaly.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_bridge.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_calibrate.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_causal.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_composite.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_confidence.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_config.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_contract.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_controller.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_correlate.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_diff.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_drift.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_events.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_fingerprint_ext.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_forecast.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_health.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_healthcare.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_intent.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_ledger.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_loop.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_neuro.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_observation.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_persist.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_pipeline_features.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_policy.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_policy_compose.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_policy_trace.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_policy_validate.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_predicates.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_printer3d.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_provenance.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_ros2.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_staleness.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_streaming.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_transformer.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_transitions.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_uncertain.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/tests/test_validity.py +0 -0
- {margin-0.9.20 → margin-0.9.22}/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.22
|
|
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
|
|
@@ -24,7 +24,7 @@ from .provenance import (
|
|
|
24
24
|
from .uncertain import UncertainValue, Source, EpistemicSource, UncertaintyMode
|
|
25
25
|
from .algebra import add, subtract, multiply, divide, scale, compare, weighted_average
|
|
26
26
|
from .health import Health, Thresholds, classify, SEVERITY
|
|
27
|
-
from .observation import Op, Observation, Correction, Expression, Parser
|
|
27
|
+
from .observation import Absence, Op, Observation, Correction, Expression, Parser
|
|
28
28
|
from .ledger import Record, Ledger
|
|
29
29
|
|
|
30
30
|
# Observability
|
|
@@ -129,7 +129,7 @@ __all__ = [
|
|
|
129
129
|
"UncertainValue", "Source",
|
|
130
130
|
"add", "subtract", "multiply", "divide", "scale", "compare", "weighted_average",
|
|
131
131
|
"Health", "Thresholds", "classify", "SEVERITY",
|
|
132
|
-
"Op", "Observation", "Correction", "Expression", "Parser",
|
|
132
|
+
"Absence", "Op", "Observation", "Correction", "Expression", "Parser",
|
|
133
133
|
"Record", "Ledger",
|
|
134
134
|
# Observability
|
|
135
135
|
"observe", "observe_many", "delta", "to_uncertain",
|
|
@@ -20,6 +20,27 @@ from .confidence import Confidence
|
|
|
20
20
|
from .health import Health, Thresholds, classify
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
class Absence(Enum):
|
|
24
|
+
"""
|
|
25
|
+
Why a value is absent. Distinct from Health — these explain missing data, not bad data.
|
|
26
|
+
|
|
27
|
+
NOT_MEASURED: no attempt to measure (sensor offline, not scheduled)
|
|
28
|
+
BELOW_DETECTION: measured but below instrument detection limit
|
|
29
|
+
ABOVE_RANGE: measured but above instrument range
|
|
30
|
+
SENSOR_FAILED: measurement attempted, hardware error
|
|
31
|
+
REDACTED: value exists but withheld (privacy, compliance)
|
|
32
|
+
NOT_APPLICABLE: measurement doesn't apply to this entity
|
|
33
|
+
PENDING: measurement in progress, not yet available
|
|
34
|
+
"""
|
|
35
|
+
NOT_MEASURED = "not_measured"
|
|
36
|
+
BELOW_DETECTION = "below_detection"
|
|
37
|
+
ABOVE_RANGE = "above_range"
|
|
38
|
+
SENSOR_FAILED = "sensor_failed"
|
|
39
|
+
REDACTED = "redacted"
|
|
40
|
+
NOT_APPLICABLE = "not_applicable"
|
|
41
|
+
PENDING = "pending"
|
|
42
|
+
|
|
43
|
+
|
|
23
44
|
class Op(Enum):
|
|
24
45
|
"""
|
|
25
46
|
What corrective operation is being performed.
|
|
@@ -61,6 +82,13 @@ class Observation:
|
|
|
61
82
|
provenance: list[str] = field(default_factory=list)
|
|
62
83
|
measured_at: Optional[datetime] = None
|
|
63
84
|
health_label: Optional[str] = None
|
|
85
|
+
absence: Optional[Absence] = None
|
|
86
|
+
absence_detail: Optional[str] = None
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def is_absent(self) -> bool:
|
|
90
|
+
"""True if this observation represents an absent value."""
|
|
91
|
+
return self.absence is not None
|
|
64
92
|
|
|
65
93
|
@property
|
|
66
94
|
def sigma(self) -> float:
|
|
@@ -91,7 +119,9 @@ class Observation:
|
|
|
91
119
|
return a <= max_age_seconds
|
|
92
120
|
|
|
93
121
|
def to_atom(self) -> str:
|
|
94
|
-
"""Compact string: NAME:HEALTH(±σ)"""
|
|
122
|
+
"""Compact string: NAME:HEALTH(±σ) or NAME:ABSENT(reason) when absent."""
|
|
123
|
+
if self.absence is not None:
|
|
124
|
+
return f"{self.name}:ABSENT({self.absence.value})"
|
|
95
125
|
display = self.health_label or self.health.value
|
|
96
126
|
if self.health == Health.OOD:
|
|
97
127
|
return f"{self.name}:{display}"
|
|
@@ -113,6 +143,10 @@ class Observation:
|
|
|
113
143
|
d["health_label"] = self.health_label
|
|
114
144
|
if self.measured_at is not None:
|
|
115
145
|
d["measured_at"] = self.measured_at.isoformat()
|
|
146
|
+
if self.absence is not None:
|
|
147
|
+
d["absence"] = self.absence.value
|
|
148
|
+
if self.absence_detail is not None:
|
|
149
|
+
d["absence_detail"] = self.absence_detail
|
|
116
150
|
return d
|
|
117
151
|
|
|
118
152
|
@classmethod
|
|
@@ -120,6 +154,7 @@ class Observation:
|
|
|
120
154
|
measured_at = None
|
|
121
155
|
if "measured_at" in d:
|
|
122
156
|
measured_at = datetime.fromisoformat(d["measured_at"])
|
|
157
|
+
absence = Absence(d["absence"]) if "absence" in d else None
|
|
123
158
|
return cls(
|
|
124
159
|
name=d["name"],
|
|
125
160
|
health=Health(d["health"]),
|
|
@@ -130,6 +165,8 @@ class Observation:
|
|
|
130
165
|
provenance=d.get("provenance", []),
|
|
131
166
|
measured_at=measured_at,
|
|
132
167
|
health_label=d.get("health_label"),
|
|
168
|
+
absence=absence,
|
|
169
|
+
absence_detail=d.get("absence_detail"),
|
|
133
170
|
)
|
|
134
171
|
|
|
135
172
|
|
|
@@ -492,8 +492,12 @@ class Monitor:
|
|
|
492
492
|
)
|
|
493
493
|
self._step += 1
|
|
494
494
|
|
|
495
|
-
# Update per-component trackers
|
|
495
|
+
# Update per-component trackers (skip absent observations —
|
|
496
|
+
# they carry no measured value and would corrupt drift/anomaly windows)
|
|
496
497
|
for obs in self._expression.observations:
|
|
498
|
+
if obs.is_absent:
|
|
499
|
+
continue
|
|
500
|
+
|
|
497
501
|
name = obs.name
|
|
498
502
|
baseline = self.parser.baselines.get(name, obs.value)
|
|
499
503
|
thresholds = self.parser._thresholds_for(name)
|
|
@@ -670,8 +674,8 @@ class Monitor:
|
|
|
670
674
|
|
|
671
675
|
Fits a simple linear regression (ordinary least squares) to the
|
|
672
676
|
values currently in the drift window for ``metric``, then extrapolates
|
|
673
|
-
``steps`` steps ahead. Returns the
|
|
674
|
-
|
|
677
|
+
``steps`` steps ahead. Returns the raw extrapolated value with no
|
|
678
|
+
clamping — the result may exceed the observed min/max.
|
|
675
679
|
|
|
676
680
|
Use cases::
|
|
677
681
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.22
|
|
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.22"
|
|
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"}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Tests for typed Absence on Observation (v0.9.22)."""
|
|
2
|
+
import pytest
|
|
3
|
+
from margin import (
|
|
4
|
+
Absence, Observation, Health, Confidence, Expression,
|
|
5
|
+
Monitor, Parser, Thresholds,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Helpers
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def _obs(name="co2", value=400.0, absence=None, absence_detail=None):
|
|
14
|
+
return Observation(
|
|
15
|
+
name=name,
|
|
16
|
+
health=Health.INTACT,
|
|
17
|
+
value=value,
|
|
18
|
+
baseline=400.0,
|
|
19
|
+
confidence=Confidence.HIGH,
|
|
20
|
+
absence=absence,
|
|
21
|
+
absence_detail=absence_detail,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Absence enum basics
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
class TestAbsenceEnum:
|
|
30
|
+
def test_all_members_exist(self):
|
|
31
|
+
assert Absence.NOT_MEASURED.value == "not_measured"
|
|
32
|
+
assert Absence.BELOW_DETECTION.value == "below_detection"
|
|
33
|
+
assert Absence.ABOVE_RANGE.value == "above_range"
|
|
34
|
+
assert Absence.SENSOR_FAILED.value == "sensor_failed"
|
|
35
|
+
assert Absence.REDACTED.value == "redacted"
|
|
36
|
+
assert Absence.NOT_APPLICABLE.value == "not_applicable"
|
|
37
|
+
assert Absence.PENDING.value == "pending"
|
|
38
|
+
|
|
39
|
+
def test_round_trip_from_string(self):
|
|
40
|
+
for member in Absence:
|
|
41
|
+
assert Absence(member.value) is member
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Observation with absence=None (existing behavior unchanged)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
class TestObservationNoAbsence:
|
|
49
|
+
def test_is_absent_false(self):
|
|
50
|
+
obs = _obs()
|
|
51
|
+
assert obs.is_absent is False
|
|
52
|
+
|
|
53
|
+
def test_absence_none_by_default(self):
|
|
54
|
+
obs = _obs()
|
|
55
|
+
assert obs.absence is None
|
|
56
|
+
assert obs.absence_detail is None
|
|
57
|
+
|
|
58
|
+
def test_sigma_unchanged(self):
|
|
59
|
+
obs = _obs(value=440.0)
|
|
60
|
+
assert obs.sigma == pytest.approx(0.1)
|
|
61
|
+
|
|
62
|
+
def test_to_dict_no_absence_keys(self):
|
|
63
|
+
d = _obs().to_dict()
|
|
64
|
+
assert "absence" not in d
|
|
65
|
+
assert "absence_detail" not in d
|
|
66
|
+
|
|
67
|
+
def test_to_atom_unchanged(self):
|
|
68
|
+
atom = _obs().to_atom()
|
|
69
|
+
assert "ABSENT" not in atom
|
|
70
|
+
assert "INTACT" in atom
|
|
71
|
+
|
|
72
|
+
def test_from_dict_old_format(self):
|
|
73
|
+
"""Old serialized JSON (no absence key) deserializes correctly."""
|
|
74
|
+
d = {
|
|
75
|
+
"name": "co2",
|
|
76
|
+
"health": "INTACT",
|
|
77
|
+
"value": 400.0,
|
|
78
|
+
"baseline": 400.0,
|
|
79
|
+
"sigma": 0.0,
|
|
80
|
+
"confidence": "high",
|
|
81
|
+
"higher_is_better": True,
|
|
82
|
+
"provenance": [],
|
|
83
|
+
}
|
|
84
|
+
obs = Observation.from_dict(d)
|
|
85
|
+
assert obs.absence is None
|
|
86
|
+
assert obs.absence_detail is None
|
|
87
|
+
assert obs.is_absent is False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Observation with absence set
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
class TestObservationWithAbsence:
|
|
95
|
+
def test_is_absent_true(self):
|
|
96
|
+
obs = _obs(absence=Absence.BELOW_DETECTION)
|
|
97
|
+
assert obs.is_absent is True
|
|
98
|
+
|
|
99
|
+
def test_absence_detail(self):
|
|
100
|
+
obs = _obs(
|
|
101
|
+
absence=Absence.BELOW_DETECTION,
|
|
102
|
+
absence_detail="detection limit: 0.001 ppm",
|
|
103
|
+
)
|
|
104
|
+
assert obs.absence_detail == "detection limit: 0.001 ppm"
|
|
105
|
+
|
|
106
|
+
def test_to_atom_shows_absence(self):
|
|
107
|
+
obs = _obs(absence=Absence.SENSOR_FAILED)
|
|
108
|
+
assert obs.to_atom() == "co2:ABSENT(sensor_failed)"
|
|
109
|
+
|
|
110
|
+
def test_to_atom_below_detection(self):
|
|
111
|
+
obs = _obs(absence=Absence.BELOW_DETECTION)
|
|
112
|
+
assert obs.to_atom() == "co2:ABSENT(below_detection)"
|
|
113
|
+
|
|
114
|
+
def test_to_dict_includes_absence(self):
|
|
115
|
+
obs = _obs(absence=Absence.REDACTED)
|
|
116
|
+
d = obs.to_dict()
|
|
117
|
+
assert d["absence"] == "redacted"
|
|
118
|
+
|
|
119
|
+
def test_to_dict_includes_absence_detail(self):
|
|
120
|
+
obs = _obs(
|
|
121
|
+
absence=Absence.REDACTED,
|
|
122
|
+
absence_detail="HIPAA",
|
|
123
|
+
)
|
|
124
|
+
d = obs.to_dict()
|
|
125
|
+
assert d["absence_detail"] == "HIPAA"
|
|
126
|
+
|
|
127
|
+
def test_to_dict_no_detail_when_none(self):
|
|
128
|
+
obs = _obs(absence=Absence.REDACTED)
|
|
129
|
+
d = obs.to_dict()
|
|
130
|
+
assert "absence_detail" not in d
|
|
131
|
+
|
|
132
|
+
def test_round_trip_to_dict_from_dict(self):
|
|
133
|
+
obs = _obs(
|
|
134
|
+
absence=Absence.BELOW_DETECTION,
|
|
135
|
+
absence_detail="LOD: 0.001",
|
|
136
|
+
)
|
|
137
|
+
d = obs.to_dict()
|
|
138
|
+
restored = Observation.from_dict(d)
|
|
139
|
+
assert restored.absence is Absence.BELOW_DETECTION
|
|
140
|
+
assert restored.absence_detail == "LOD: 0.001"
|
|
141
|
+
assert restored.is_absent is True
|
|
142
|
+
|
|
143
|
+
def test_round_trip_all_absence_types(self):
|
|
144
|
+
for member in Absence:
|
|
145
|
+
obs = _obs(absence=member)
|
|
146
|
+
restored = Observation.from_dict(obs.to_dict())
|
|
147
|
+
assert restored.absence is member
|
|
148
|
+
|
|
149
|
+
def test_value_still_accessible(self):
|
|
150
|
+
"""Even when absent, .value is still whatever was set (caller decides convention)."""
|
|
151
|
+
obs = _obs(value=0.0, absence=Absence.BELOW_DETECTION)
|
|
152
|
+
assert obs.value == 0.0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Expression with absent observations
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
class TestExpressionWithAbsence:
|
|
160
|
+
def test_to_string_shows_absent(self):
|
|
161
|
+
obs_present = _obs(name="temp", value=22.0)
|
|
162
|
+
obs_absent = _obs(name="co2", absence=Absence.SENSOR_FAILED)
|
|
163
|
+
expr = Expression(observations=[obs_present, obs_absent])
|
|
164
|
+
s = expr.to_string()
|
|
165
|
+
assert "ABSENT(sensor_failed)" in s
|
|
166
|
+
assert "temp:INTACT" in s
|
|
167
|
+
|
|
168
|
+
def test_degraded_does_not_include_absent(self):
|
|
169
|
+
"""Absent observations have health=INTACT (as constructed); they aren't degraded."""
|
|
170
|
+
obs = _obs(absence=Absence.NOT_MEASURED)
|
|
171
|
+
expr = Expression(observations=[obs])
|
|
172
|
+
assert expr.degraded() == []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Monitor skips absent observations in trackers
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
class TestMonitorAbsenceSkip:
|
|
180
|
+
def _make_monitor(self):
|
|
181
|
+
parser = Parser(
|
|
182
|
+
baselines={"co2": 400.0, "temp": 22.0},
|
|
183
|
+
thresholds=Thresholds(intact=0.8, ablated=0.2),
|
|
184
|
+
)
|
|
185
|
+
return Monitor(parser, window=50)
|
|
186
|
+
|
|
187
|
+
def test_absent_obs_not_in_drift_window(self):
|
|
188
|
+
"""Absent observations should not enter the drift tracker."""
|
|
189
|
+
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
|
+
"""
|
|
207
|
+
m = self._make_monitor()
|
|
208
|
+
# Feed some normal data
|
|
209
|
+
for _ in range(5):
|
|
210
|
+
m.update({"co2": 400.0, "temp": 22.0})
|
|
211
|
+
n_before = m._drift_trackers["co2"].n_observations
|
|
212
|
+
|
|
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,
|
|
222
|
+
)
|
|
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.
|
|
228
|
+
|
|
229
|
+
def test_normal_flow_unaffected(self):
|
|
230
|
+
"""Standard update flow still works — no regression from the guard."""
|
|
231
|
+
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
|
|
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
|