Skip to content

Commit a31d992

Browse files
committed
feat: implement apply_spike_suppression for health score (#141)
1 parent cac9d21 commit a31d992

File tree

2 files changed

+144
-0
lines changed

2 files changed

+144
-0
lines changed

app/analyzer.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import os
1010
import re
1111

12+
from .tz import utc_now, _parse_utc
13+
1214
log = logging.getLogger("docsis.analyzer")
1315

1416
# --- Dynamic thresholds (set by module loader) ---
@@ -132,6 +134,49 @@ def _get_spike_expiry_hours():
132134
return errors.get("spike_expiry_hours", 48)
133135

134136

137+
def apply_spike_suppression(analysis, last_spike_ts):
138+
"""Suppress uncorrectable error penalization if a past spike has expired.
139+
140+
Called as a post-processing step after analyze(). If the most recent
141+
error_spike event is older than spike_expiry_hours and no new spike has
142+
occurred since, the uncorrectable error percentage and related health
143+
issues are suppressed.
144+
145+
Args:
146+
analysis: dict from analyze() — modified in place
147+
last_spike_ts: UTC timestamp string of latest error_spike, or None
148+
"""
149+
if not last_spike_ts:
150+
return
151+
152+
expiry_hours = _get_spike_expiry_hours()
153+
now = _parse_utc(utc_now())
154+
spike_dt = _parse_utc(last_spike_ts)
155+
hours_since = (now - spike_dt).total_seconds() / 3600
156+
157+
if hours_since < expiry_hours:
158+
return # Still in observation period
159+
160+
summary = analysis["summary"]
161+
summary["ds_uncorr_pct"] = 0.0
162+
summary["health_issues"] = [i for i in summary["health_issues"] if "uncorr" not in i]
163+
summary["spike_suppression"] = {
164+
"active": True,
165+
"last_spike": last_spike_ts,
166+
"hours_since_spike": round(hours_since, 1),
167+
"expiry_hours": expiry_hours,
168+
}
169+
170+
# Recalculate health from remaining issues
171+
issues = summary["health_issues"]
172+
if not issues:
173+
summary["health"] = "good"
174+
elif any("critical" in i for i in issues):
175+
summary["health"] = "poor"
176+
else:
177+
summary["health"] = "marginal"
178+
179+
135180
def _parse_qam_order(modulation_str):
136181
"""Extract QAM order from modulation string. Returns None if unparseable."""
137182
if not modulation_str:

tests/test_analyzer.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for DOCSIS channel health analyzer."""
22

33
import pytest
4+
from unittest.mock import patch
45
from app import analyzer
56
from app.analyzer import analyze, _parse_float, _parse_qam_order, _resolve_modulation, _channel_bitrate_mbps
67

@@ -771,3 +772,101 @@ def test_default_spike_expiry_hours(self):
771772
from app.analyzer import _get_spike_expiry_hours
772773
hours = _get_spike_expiry_hours()
773774
assert hours == 48
775+
776+
777+
class TestSpikeSuppression:
778+
"""Tests for apply_spike_suppression()."""
779+
780+
def _make_analysis_with_uncorr(self, uncorr_pct=86.6, health="poor",
781+
extra_issues=None):
782+
"""Build a minimal analysis dict with uncorrectable error issues."""
783+
issues = ["uncorr_errors_critical"]
784+
if extra_issues:
785+
issues.extend(extra_issues)
786+
return {
787+
"summary": {
788+
"health": health,
789+
"health_issues": issues,
790+
"ds_uncorr_pct": uncorr_pct,
791+
"ds_correctable_errors": 155000,
792+
"ds_uncorrectable_errors": 1000000,
793+
"ds_total": 33,
794+
"us_total": 4,
795+
},
796+
"ds_channels": [],
797+
"us_channels": [],
798+
}
799+
800+
def test_no_spike_no_change(self):
801+
"""No spike timestamp — analysis stays unchanged."""
802+
from app.analyzer import apply_spike_suppression
803+
analysis = self._make_analysis_with_uncorr()
804+
apply_spike_suppression(analysis, None)
805+
assert analysis["summary"]["ds_uncorr_pct"] == 86.6
806+
assert "uncorr_errors_critical" in analysis["summary"]["health_issues"]
807+
assert analysis["summary"]["health"] == "poor"
808+
assert "spike_suppression" not in analysis["summary"]
809+
810+
@patch("app.analyzer.utc_now")
811+
def test_recent_spike_no_suppression(self, mock_now):
812+
"""Spike < 48h ago — still in observation period, no suppression."""
813+
from app.analyzer import apply_spike_suppression
814+
mock_now.return_value = "2026-02-28T12:00:00Z"
815+
analysis = self._make_analysis_with_uncorr()
816+
apply_spike_suppression(analysis, "2026-02-27T14:00:00Z")
817+
assert analysis["summary"]["ds_uncorr_pct"] == 86.6
818+
assert "uncorr_errors_critical" in analysis["summary"]["health_issues"]
819+
assert analysis["summary"]["health"] == "poor"
820+
assert "spike_suppression" not in analysis["summary"]
821+
822+
@patch("app.analyzer.utc_now")
823+
def test_expired_spike_suppresses(self, mock_now):
824+
"""Spike >= 48h ago — suppression active."""
825+
from app.analyzer import apply_spike_suppression
826+
mock_now.return_value = "2026-03-01T15:00:00Z" # 72.5h after spike
827+
analysis = self._make_analysis_with_uncorr()
828+
apply_spike_suppression(analysis, "2026-02-27T14:30:00Z")
829+
assert analysis["summary"]["ds_uncorr_pct"] == 0.0
830+
assert "uncorr_errors_critical" not in analysis["summary"]["health_issues"]
831+
assert "uncorr_errors_high" not in analysis["summary"]["health_issues"]
832+
assert analysis["summary"]["health"] == "good"
833+
sup = analysis["summary"]["spike_suppression"]
834+
assert sup["active"] is True
835+
assert sup["last_spike"] == "2026-02-27T14:30:00Z"
836+
assert sup["expiry_hours"] == 48
837+
838+
@patch("app.analyzer.utc_now")
839+
def test_expired_spike_other_issues_remain(self, mock_now):
840+
"""Spike expired but other critical issues exist — health stays poor."""
841+
from app.analyzer import apply_spike_suppression
842+
mock_now.return_value = "2026-03-01T15:00:00Z"
843+
analysis = self._make_analysis_with_uncorr(
844+
extra_issues=["snr_critical"]
845+
)
846+
apply_spike_suppression(analysis, "2026-02-27T14:00:00Z")
847+
assert analysis["summary"]["ds_uncorr_pct"] == 0.0
848+
assert "uncorr_errors_critical" not in analysis["summary"]["health_issues"]
849+
assert "snr_critical" in analysis["summary"]["health_issues"]
850+
assert analysis["summary"]["health"] == "poor"
851+
assert analysis["summary"]["spike_suppression"]["active"] is True
852+
853+
@patch("app.analyzer.utc_now")
854+
def test_expired_spike_warning_issues_marginal(self, mock_now):
855+
"""Spike expired, only warning issues remain — health becomes marginal."""
856+
from app.analyzer import apply_spike_suppression
857+
mock_now.return_value = "2026-03-01T15:00:00Z"
858+
analysis = self._make_analysis_with_uncorr(
859+
extra_issues=["snr_warn"]
860+
)
861+
apply_spike_suppression(analysis, "2026-02-27T14:00:00Z")
862+
assert analysis["summary"]["health"] == "marginal"
863+
864+
@patch("app.analyzer.utc_now")
865+
def test_spike_at_exact_boundary(self, mock_now):
866+
"""Spike exactly 48h ago — suppressed (>= boundary)."""
867+
from app.analyzer import apply_spike_suppression
868+
mock_now.return_value = "2026-03-01T14:00:00Z"
869+
analysis = self._make_analysis_with_uncorr()
870+
apply_spike_suppression(analysis, "2026-02-27T14:00:00Z")
871+
assert analysis["summary"]["ds_uncorr_pct"] == 0.0
872+
assert analysis["summary"]["spike_suppression"]["active"] is True

0 commit comments

Comments
 (0)