This commit is contained in:
Jonas Bengtsson
2026-10-08 07:26:04 +02:00
parent a1af8d68c9
commit 406fdc61ac
13 changed files with 1750 additions and 89 deletions
+387 -13
View File
@@ -1,20 +1,394 @@
"""Detector tests (SPECIFICATION §6). Detector implementations land in Phase 1."""
"""Detector tests (D4–D16) against scripted PollOutcomes with a fake clock.
from datetime import datetime, timezone
Pure logic — no COM, no threads (IMPLEMENTATION_PLAN §9).
"""
from opcda_diagnozer.monitor.detectors import PollOutcome
from datetime import datetime, timedelta, timezone
import pytest
from opcda_diagnozer.config import (
Config,
DeviceEntry,
LatencyConfig,
OpcConfig,
StaleConfig,
TagEntry,
)
from opcda_diagnozer.events import EventKind, Severity
from opcda_diagnozer.monitor.detectors import (
MASS_BAD_FRACTION,
Detection,
DetectorSuite,
PollOutcome,
)
from opcda_diagnozer.opc.base import ReadResult
from opcda_diagnozer.quality import status_text
T0 = datetime(2026, 10, 7, 12, 0, 0, tzinfo=timezone.utc)
def test_poll_outcome_holds_one_cycle() -> None:
result = ReadResult(
tag="Channel1.Device1.Level", value=1.5, quality_raw=0xC0, device_time=None
def _config(**overrides) -> Config:
"""A minimal valid config; overrides replace whole sections when given."""
return Config(
opc=OpcConfig(server="Fake.Server.1"),
tags=(TagEntry(name="T.Good", expect_change=False), TagEntry(name="T.Spin", expect_change=True)),
devices=(DeviceEntry(name="Pump A", tag="T.PumpConnected", ok_values=("true",)),),
latency=overrides.pop("latency", LatencyConfig()),
stale=overrides.pop("stale", StaleConfig()),
)
outcome = PollOutcome(
started_at=datetime.now(timezone.utc),
rtt_ms=12.5,
results=(result,),
def _read(tag: str, quality: int = 0xC0, value=0, at: datetime = T0, error: str | None = None) -> ReadResult:
return ReadResult(tag=tag, value=value, quality_raw=quality, device_time=at, error=error)
def _outcome(
rtt_ms: float = 10.0,
results: tuple[ReadResult, ...] = (),
error: str | None = None,
at: datetime = T0,
) -> PollOutcome:
return PollOutcome(started_at=at, rtt_ms=rtt_ms, results=results, error=error)
# --------------------------------------------------------------------------- #
# Detection basics
# --------------------------------------------------------------------------- #
def test_heartbeat_and_connected_are_not_anomalies() -> None:
assert Detection(EventKind.HEARTBEAT, Severity.INFO).is_anomaly is False
assert Detection(EventKind.CONNECTED, Severity.INFO).is_anomaly is False
assert Detection(EventKind.QUALITY_BAD, Severity.WARN).is_anomaly is True
def test_status_text_clear_text_with_code_in_parens() -> None:
assert status_text(0xC0) == "GOOD (0xC0)"
assert status_text(0x18) == "BAD / COMM FAILURE (0x18)"
# --------------------------------------------------------------------------- #
# D4/D5/D6 — latency
# --------------------------------------------------------------------------- #
def test_d4_slow_poll_spike() -> None:
suite = DetectorSuite(_config())
detections = suite.observe(_outcome(rtt_ms=1500.0)) # > warn_ms 1000
kinds = [d.kind for d in detections]
assert EventKind.SLOW_POLL in kinds
spike = next(d for d in detections if d.kind is EventKind.SLOW_POLL)
assert spike.severity is Severity.WARN
assert spike.detail["rtt_ms"] == 1500.0
def test_d4_no_spike_below_threshold() -> None:
suite = DetectorSuite(_config())
assert all(
d.kind is not EventKind.SLOW_POLL for d in suite.observe(_outcome(rtt_ms=999.9))
)
assert outcome.rtt_ms == 12.5
assert outcome.error is None
assert outcome.results[0].tag == "Channel1.Device1.Level"
def test_d5_streak_after_consecutive_slow_polls() -> None:
suite = DetectorSuite(_config())
for _ in range(4): # streak_limit 5: not yet
suite.observe(_outcome(rtt_ms=1200.0))
detections = suite.observe(_outcome(rtt_ms=1200.0)) # 5th consecutive
streak = next(d for d in detections if d.kind is EventKind.SLOW_POLL_STREAK)
assert streak.severity is Severity.CRITICAL
assert streak.detail["streak"] == 5
# one fast poll resets the streak
suite.observe(_outcome(rtt_ms=10.0))
suite.observe(_outcome(rtt_ms=1200.0))
detections = suite.observe(_outcome(rtt_ms=1200.0))
assert all(d.kind is not EventKind.SLOW_POLL_STREAK for d in detections)
def test_d6_p95_trend_over_window() -> None:
suite = DetectorSuite(_config())
# window_polls=60: fill with fast polls plus a few slow outliers
for _ in range(56):
suite.observe(_outcome(rtt_ms=50.0))
for _ in range(4):
suite.observe(_outcome(rtt_ms=2000.0))
detections = suite.observe(_outcome(rtt_ms=50.0))
trend = next(d for d in detections if d.kind is EventKind.LATENCY_TREND)
assert trend.detail["rtt_p95_ms"] == 2000.0
def test_failed_poll_resets_streak_but_keeps_window() -> None:
suite = DetectorSuite(_config())
for _ in range(3):
suite.observe(_outcome(rtt_ms=1200.0))
# a failed poll carries no RTT evidence: streak resets, no spike events
detections = suite.observe(_outcome(rtt_ms=99999.0, error="RPC call failed"))
assert all(d.kind not in (EventKind.SLOW_POLL, EventKind.SLOW_POLL_STREAK) for d in detections)
suite.observe(_outcome(rtt_ms=1200.0))
assert all(d.kind is not EventKind.SLOW_POLL_STREAK for d in suite.observe(_outcome(rtt_ms=1200.0)))
# --------------------------------------------------------------------------- #
# D8/D9/D10/D11 — quality
# --------------------------------------------------------------------------- #
def test_d8_bad_transition_with_clear_text_status() -> None:
suite = DetectorSuite(_config())
suite.observe(_outcome(results=(_read("T.Good", 0xC0),)))
detections = suite.observe(_outcome(results=(_read("T.Good", 0x18),)))
bad = next(d for d in detections if d.kind is EventKind.QUALITY_BAD)
assert bad.severity is Severity.WARN
assert bad.detail["tag"] == "T.Good"
assert bad.detail["quality"] == "BAD / COMM FAILURE (0x18)"
assert bad.detail["previous_quality"] == "GOOD"
def test_d9_uncertain_transition_is_info() -> None:
suite = DetectorSuite(_config())
suite.observe(_outcome(results=(_read("T.Good", 0xC0),)))
detections = suite.observe(_outcome(results=(_read("T.Good", 0x44),)))
uncertain = next(d for d in detections if d.kind is EventKind.QUALITY_UNCERTAIN)
assert uncertain.severity is Severity.INFO
def test_no_synthetic_transition_on_first_reading() -> None:
"""A tag that starts BAD reports no transition until it first recovers."""
suite = DetectorSuite(_config())
detections = suite.observe(_outcome(results=(_read("T.Good", 0x00),)))
assert all(d.kind is not EventKind.QUALITY_BAD for d in detections)
def test_d10_persistent_bad_escalates_after_threshold() -> None:
suite = DetectorSuite(_config()) # persistent_bad_s 30
for second in range(0, 29, 2): # 15 polls < 30 s: not yet
suite.observe(_outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=second)))
if second:
continue
# exactly at 30 s
detections = suite.observe(
_outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=30))
)
persistent = next(d for d in detections if d.kind is EventKind.QUALITY_BAD_PERSISTENT)
assert persistent.severity is Severity.WARN
assert persistent.detail["bad_for_s"] == 30.0
# reported once, not every poll
detections = suite.observe(
_outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=32))
)
assert all(d.kind is not EventKind.QUALITY_BAD_PERSISTENT for d in detections)
# recovery resets the persistence latch
suite.observe(_outcome(results=(_read("T.Good", 0xC0),), at=T0 + timedelta(seconds=34)))
suite.observe(_outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=36)))
detections = suite.observe(
_outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=70))
)
assert any(d.kind is EventKind.QUALITY_BAD_PERSISTENT for d in detections)
def test_d11_flapping_quality() -> None:
suite = DetectorSuite(_config()) # >5 transitions per 600 s window
t = T0
for quality in (0xC0, 0x18, 0xC0, 0x18, 0xC0, 0x18):
t += timedelta(seconds=5)
suite.observe(_outcome(results=(_read("T.Good", quality),), at=t))
# 6 transitions happened; the 6th poll reports flapping
detections = suite.quality.observe(_outcome(results=(_read("T.Good", 0xC0),), at=t))
assert any(d.kind is EventKind.QUALITY_FLAPPING for d in detections)
# --------------------------------------------------------------------------- #
# D12/D13 — stale & frozen
# --------------------------------------------------------------------------- #
def test_d12_stale_timestamp_while_good() -> None:
suite = DetectorSuite(_config()) # max_age_s 120
stale_device_time = T0 - timedelta(seconds=200)
detections = suite.observe(
_outcome(
results=(_read("T.Good", 0xC0, at=stale_device_time),),
at=T0,
)
)
stale = next(d for d in detections if d.kind is EventKind.STALE_TIMESTAMP)
assert stale.detail["age_s"] == 200.0
assert stale.detail["max_age_s"] == 120
def test_d12_ignores_stale_when_quality_not_good() -> None:
suite = DetectorSuite(_config())
stale_device_time = T0 - timedelta(seconds=200)
detections = suite.observe(
_outcome(results=(_read("T.Good", 0x18, at=stale_device_time),), at=T0)
)
assert all(d.kind is not EventKind.STALE_TIMESTAMP for d in detections)
def test_d13_frozen_value_on_expect_change_tag() -> None:
suite = DetectorSuite(_config()) # T.Spin has expect_change
frozen_time = T0 - timedelta(seconds=150)
first = suite.observe(_outcome(results=(_read("T.Spin", 0xC0, value=7, at=frozen_time),), at=T0))
second = suite.observe(
_outcome(results=(_read("T.Spin", 0xC0, value=7, at=frozen_time),), at=T0 + timedelta(seconds=1))
)
assert all(d.kind is not EventKind.FROZEN_VALUE for d in first) # needs history
frozen = next(d for d in second if d.kind is EventKind.FROZEN_VALUE)
assert frozen.detail["tag"] == "T.Spin"
assert frozen.detail["value"] == 7
def test_d13_not_frozen_when_device_time_advances() -> None:
suite = DetectorSuite(_config())
t_device = T0 - timedelta(seconds=100)
suite.observe(_outcome(results=(_read("T.Spin", 0xC0, value=7, at=t_device),), at=T0))
detections = suite.observe(
_outcome(
results=(_read("T.Spin", 0xC0, value=7, at=t_device + timedelta(seconds=10)),),
at=T0 + timedelta(seconds=1),
)
)
assert all(d.kind is not EventKind.FROZEN_VALUE for d in detections)
# --------------------------------------------------------------------------- #
# D14 — mass bad
# --------------------------------------------------------------------------- #
def test_d14_mass_bad_when_half_or_more_bad() -> None:
config = _config()
suite = DetectorSuite(config)
tags = ("A", "B", "C", "D")
results = tuple(_read(t, 0x18) for t in tags) # 100% BAD
detections = suite.observe(_outcome(results=results))
mass = next(d for d in detections if d.kind is EventKind.CONTROL_LINK_DEGRADED)
assert mass.severity is Severity.CRITICAL
assert mass.detail == {"tags_bad": 4, "tags_total": 4}
# latched: not re-emitted while still degraded
assert all(
d.kind is not EventKind.CONTROL_LINK_DEGRADED
for d in suite.observe(_outcome(results=results))
)
# recovery re-arms the latch
good = tuple(_read(t, 0xC0) for t in tags)
suite.observe(_outcome(results=good))
assert any(
d.kind is EventKind.CONTROL_LINK_DEGRADED
for d in suite.observe(_outcome(results=results))
)
def test_d14_below_half_is_not_mass_bad() -> None:
suite = DetectorSuite(_config())
results = (_read("A", 0x18), _read("B", 0xC0), _read("C", 0xC0), _read("D", 0xC0))
assert all(
d.kind is not EventKind.CONTROL_LINK_DEGRADED
for d in suite.observe(_outcome(results=results))
)
def test_mass_bad_fraction_is_half() -> None:
assert MASS_BAD_FRACTION == 0.5
# --------------------------------------------------------------------------- #
# D16 — device link indicators
# --------------------------------------------------------------------------- #
def _pump(at: datetime = T0, quality: int = 0xC0, value=True, error: str | None = None) -> ReadResult:
return _read("T.PumpConnected", quality, value, at, error)
def test_d16_false_value_is_down() -> None:
suite = DetectorSuite(_config())
suite.observe(_outcome(results=(_pump(),))) # normal first
detections = suite.observe(_outcome(results=(_pump(value=False),)))
down = next(d for d in detections if d.kind is EventKind.DEVICE_LINK_DOWN)
assert down.severity is Severity.CRITICAL
assert down.detail["device"] == "Pump A"
assert down.detail["reason"] == "read 'false', expected one of ['true']"
assert suite.devices_down() == 1
def test_d16_non_good_quality_is_down_regardless_of_value() -> None:
suite = DetectorSuite(_config())
detections = suite.observe(_outcome(results=(_pump(quality=0x08),))) # BAD not connected
down = next(d for d in detections if d.kind is EventKind.DEVICE_LINK_DOWN)
assert "quality BAD / NOT CONNECTED (0x08)" in down.detail["reason"]
def test_d16_read_error_is_down() -> None:
suite = DetectorSuite(_config())
detections = suite.observe(
_outcome(results=(_pump(error="cannot add tag 'T.PumpConnected'"),))
)
assert any(d.kind is EventKind.DEVICE_LINK_DOWN for d in detections)
def test_d16_recovery_edge_level_no_spam() -> None:
suite = DetectorSuite(_config())
suite.observe(_outcome(results=(_pump(),)))
suite.observe(_outcome(results=(_pump(value=False),))) # DOWN
# staying down: exactly one DOWN so far, no repeats
down_count = sum(
1
for events in (
suite.observe(_outcome(results=(_pump(value=False),))),
suite.observe(_outcome(results=(_pump(value=False),))),
)
for d in events
if d.kind is EventKind.DEVICE_LINK_DOWN
)
assert down_count == 0
# recovery
detections = suite.observe(_outcome(results=(_pump(),)))
up = next(d for d in detections if d.kind is EventKind.DEVICE_LINK_UP)
assert up.severity is Severity.INFO
assert up.detail["device"] == "Pump A"
assert suite.devices_down() == 0
def test_d16_rendering_matches_config_normalization() -> None:
"""The comparison string matches the config's ok_values rendering (True->'true')."""
config = _config()
suite = DetectorSuite(config)
detections = suite.observe(_outcome(results=(_pump(),))) # Python True
assert all(d.kind is not EventKind.DEVICE_LINK_DOWN for d in detections)
detections = suite.observe(_outcome(results=(_read("T.PumpConnected", 0xC0, "true"),)))
assert all(d.kind is not EventKind.DEVICE_LINK_DOWN for d in detections)
def test_d16_first_reading_already_down_reports_once() -> None:
"""No synthetic 'was up' baseline: a device born down reports immediately."""
suite = DetectorSuite(_config())
detections = suite.observe(_outcome(results=(_pump(value=False),)))
assert any(d.kind is EventKind.DEVICE_LINK_DOWN for d in detections)
# --------------------------------------------------------------------------- #
# suite helpers (heartbeat context)
# --------------------------------------------------------------------------- #
def test_suite_helpers_expose_heartbeat_inputs() -> None:
suite = DetectorSuite(_config())
suite.observe(_outcome(results=(_read("T.Good", 0x18),)))
assert suite.tags_bad() == 1
assert suite.devices_down() == 0
assert suite.rtt_p95_ms() == 10.0
@pytest.mark.parametrize(
"raw, expected",
[
(0xC0, "GOOD (0xC0)"),
(0xD8, "GOOD / LOCAL OVERRIDE (0xD8)"),
(0x04, "BAD / CONFIG ERROR (0x04)"),
(0x44, "UNCERTAIN / LAST USABLE (0x44)"),
(0x01, "BAD / 0x01 (0x01)"), # unknown subcode: class + raw label
],
)
def test_status_text_table(raw: int, expected: str) -> None:
assert status_text(raw) == expected