Files
opcda-diagnozer/tests/test_detectors.py
T
2026-10-08 07:26:04 +02:00

394 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Detector tests (D4–D16) against scripted PollOutcomes with a fake clock.
Pure logic — no COM, no threads (IMPLEMENTATION_PLAN §9).
"""
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 _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()),
)
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))
)
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