Skip to content

Commit b43ce2f

Browse files
author
Dennis Braun
committed
Extract thresholds to configurable thresholds.json
- All VFKD thresholds in app/thresholds.json (per-modulation) - DS power: 256QAM, 64QAM, 1024QAM with asymmetric ranges - US power: 64QAM, 128QAM ranges - SNR/MER: 64QAM, 256QAM, 1024QAM, 4096QAM - Analyzer loads thresholds at startup, falls back to defaults - Per-channel assessment now uses actual channel modulation - Event detector uses shared thresholds from analyzer - Source: vodafonekabelforum.de/viewtopic.php?t=39941
1 parent 52a9ca8 commit b43ce2f

File tree

3 files changed

+183
-52
lines changed

3 files changed

+183
-52
lines changed

app/analyzer.py

Lines changed: 98 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,78 @@
1-
"""DOCSIS channel health analysis with configurable thresholds."""
1+
"""DOCSIS channel health analysis with configurable thresholds.
22
3+
Thresholds are loaded from thresholds.json (Vodafone VFKD guidelines).
4+
The file supports per-modulation thresholds for DS power, US power, and SNR.
5+
"""
6+
7+
import json
38
import logging
9+
import os
410

511
log = logging.getLogger("docsis.analyzer")
612

7-
# --- Reference thresholds (based on Vodafone VFKD guidelines, 256QAM) ---
8-
# Downstream Power (dBmV): asymmetric range
9-
# Regelkonform: -3.9 to 13.0, toleriert: -5.9/-4.0 / 13.1-18.0, sofort: <-8 / >20
10-
DS_POWER_LOW_GOOD = -4.0
11-
DS_POWER_HIGH_GOOD = 13.0
12-
DS_POWER_LOW_WARN = -6.0
13-
DS_POWER_HIGH_WARN = 18.0
14-
DS_POWER_LOW_CRIT = -8.0
15-
DS_POWER_HIGH_CRIT = 20.0
16-
17-
# Upstream Power (dBmV): based on VFKD 64QAM
18-
# Regelkonform: 41.1-47.0, toleriert: 38.1-41.0 / 47.1-50.0, sofort: <35 / >53
19-
US_POWER_LOW_GOOD = 41.0
20-
US_POWER_HIGH_GOOD = 47.0
21-
US_POWER_LOW_WARN = 38.0
22-
US_POWER_HIGH_WARN = 50.0
23-
US_POWER_LOW_CRIT = 35.0
24-
US_POWER_HIGH_CRIT = 53.0
25-
26-
# SNR / MER (dB): good >33, marginal 29-33, bad <29
27-
# Based on Vodafone VFKD guidelines: 256QAM regelkonform > 33.1 dB
28-
SNR_WARN = 33.0
29-
SNR_CRIT = 29.0
30-
31-
# Uncorrectable errors threshold
32-
UNCORR_ERRORS_CRIT = 10000
13+
# --- Load thresholds from JSON ---
14+
_THRESHOLDS_PATH = os.path.join(os.path.dirname(__file__), "thresholds.json")
15+
_thresholds = {}
16+
17+
18+
def _load_thresholds():
19+
"""Load thresholds from JSON file. Falls back to hardcoded defaults."""
20+
global _thresholds
21+
try:
22+
with open(_THRESHOLDS_PATH, "r") as f:
23+
_thresholds = json.load(f)
24+
log.info("Loaded thresholds from %s", _THRESHOLDS_PATH)
25+
except (FileNotFoundError, json.JSONDecodeError) as e:
26+
log.warning("Could not load thresholds.json (%s), using defaults", e)
27+
_thresholds = {}
28+
29+
30+
def _get_ds_power_thresholds(modulation=None):
31+
"""Get DS power thresholds for a given modulation."""
32+
ds = _thresholds.get("downstream_power", {})
33+
default_mod = ds.get("_default", "256QAM")
34+
mod = modulation if modulation in ds else default_mod
35+
t = ds.get(mod, {})
36+
return {
37+
"good_min": t.get("good_min", -4.0),
38+
"good_max": t.get("good_max", 13.0),
39+
"crit_min": t.get("immediate_min", -8.0),
40+
"crit_max": t.get("immediate_max", 20.0),
41+
}
42+
43+
44+
def _get_us_power_thresholds(modulation=None):
45+
"""Get US power thresholds for a given modulation."""
46+
us = _thresholds.get("upstream_power", {})
47+
default_mod = us.get("_default", "64QAM")
48+
mod = modulation if modulation in us else default_mod
49+
t = us.get(mod, {})
50+
return {
51+
"good_min": t.get("good_min", 41.0),
52+
"good_max": t.get("good_max", 47.0),
53+
"crit_min": t.get("immediate_min", 35.0),
54+
"crit_max": t.get("immediate_max", 53.0),
55+
}
56+
57+
58+
def _get_snr_thresholds(modulation=None):
59+
"""Get SNR thresholds for a given modulation."""
60+
snr = _thresholds.get("snr", {})
61+
default_mod = snr.get("_default", "256QAM")
62+
mod = modulation if modulation in snr else default_mod
63+
t = snr.get(mod, {})
64+
return {
65+
"good_min": t.get("good_min", 33.0),
66+
"crit_min": t.get("immediate_min", 29.0),
67+
}
68+
69+
70+
def _get_uncorr_threshold():
71+
return _thresholds.get("errors", {}).get("uncorrectable_threshold", 10000)
72+
73+
74+
# Load on module import
75+
_load_thresholds()
3376

3477

3578
def _parse_float(val, default=0.0):
@@ -59,23 +102,25 @@ def _assess_ds_channel(ch, docsis_ver):
59102
"""Assess a single downstream channel. Returns (health, health_detail)."""
60103
issues = []
61104
power = _parse_float(ch.get("powerLevel"))
105+
modulation = (ch.get("modulation") or ch.get("type") or "").upper().replace("-", "")
62106

63-
if power < DS_POWER_LOW_CRIT or power > DS_POWER_HIGH_CRIT:
107+
pt = _get_ds_power_thresholds(modulation)
108+
if power < pt["crit_min"] or power > pt["crit_max"]:
64109
issues.append("power critical")
65-
elif power < DS_POWER_LOW_GOOD or power > DS_POWER_HIGH_GOOD:
110+
elif power < pt["good_min"] or power > pt["good_max"]:
66111
issues.append("power warning")
67112

113+
snr_val = None
68114
if docsis_ver == "3.0" and ch.get("mse"):
69-
snr = abs(_parse_float(ch["mse"]))
70-
if snr < SNR_CRIT:
71-
issues.append("snr critical")
72-
elif snr < SNR_WARN:
73-
issues.append("snr warning")
115+
snr_val = abs(_parse_float(ch["mse"]))
74116
elif docsis_ver == "3.1" and ch.get("mer"):
75-
snr = _parse_float(ch["mer"])
76-
if snr < SNR_CRIT:
117+
snr_val = _parse_float(ch["mer"])
118+
119+
if snr_val is not None:
120+
st = _get_snr_thresholds(modulation)
121+
if snr_val < st["crit_min"]:
77122
issues.append("snr critical")
78-
elif snr < SNR_WARN:
123+
elif snr_val < st["good_min"]:
79124
issues.append("snr warning")
80125

81126
return _channel_health(issues), _health_detail(issues)
@@ -85,10 +130,12 @@ def _assess_us_channel(ch):
85130
"""Assess a single upstream channel. Returns (health, health_detail)."""
86131
issues = []
87132
power = _parse_float(ch.get("powerLevel"))
133+
modulation = (ch.get("modulation") or ch.get("type") or "").upper().replace("-", "")
88134

89-
if power < US_POWER_LOW_CRIT or power > US_POWER_HIGH_CRIT:
135+
pt = _get_us_power_thresholds(modulation)
136+
if power < pt["crit_min"] or power > pt["crit_max"]:
90137
issues.append("power critical")
91-
elif power < US_POWER_LOW_GOOD or power > US_POWER_HIGH_GOOD:
138+
elif power < pt["good_min"] or power > pt["good_max"]:
92139
issues.append("power warning")
93140

94141
return _channel_health(issues), _health_detail(issues)
@@ -201,19 +248,24 @@ def analyze(data: dict) -> dict:
201248

202249
# --- Overall health ---
203250
issues = []
204-
if ds_powers and (min(ds_powers) < DS_POWER_LOW_CRIT or max(ds_powers) > DS_POWER_HIGH_CRIT):
251+
# Summary uses default (256QAM) thresholds for overall health
252+
ds_pt = _get_ds_power_thresholds()
253+
us_pt = _get_us_power_thresholds()
254+
snr_t = _get_snr_thresholds()
255+
256+
if ds_powers and (min(ds_powers) < ds_pt["crit_min"] or max(ds_powers) > ds_pt["crit_max"]):
205257
issues.append("ds_power_critical")
206-
elif ds_powers and (min(ds_powers) < DS_POWER_LOW_GOOD or max(ds_powers) > DS_POWER_HIGH_GOOD):
258+
elif ds_powers and (min(ds_powers) < ds_pt["good_min"] or max(ds_powers) > ds_pt["good_max"]):
207259
issues.append("ds_power_warn")
208-
if us_powers and (min(us_powers) < US_POWER_LOW_CRIT or max(us_powers) > US_POWER_HIGH_CRIT):
260+
if us_powers and (min(us_powers) < us_pt["crit_min"] or max(us_powers) > us_pt["crit_max"]):
209261
issues.append("us_power_critical")
210-
elif us_powers and (min(us_powers) < US_POWER_LOW_GOOD or max(us_powers) > US_POWER_HIGH_GOOD):
262+
elif us_powers and (min(us_powers) < us_pt["good_min"] or max(us_powers) > us_pt["good_max"]):
211263
issues.append("us_power_warn")
212-
if ds_snrs and min(ds_snrs) < SNR_CRIT:
264+
if ds_snrs and min(ds_snrs) < snr_t["crit_min"]:
213265
issues.append("snr_critical")
214-
elif ds_snrs and min(ds_snrs) < SNR_WARN:
266+
elif ds_snrs and min(ds_snrs) < snr_t["good_min"]:
215267
issues.append("snr_warn")
216-
if total_uncorr > UNCORR_ERRORS_CRIT:
268+
if total_uncorr > _get_uncorr_threshold():
217269
issues.append("uncorr_errors_high")
218270

219271
if not issues:

app/event_detector.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77

88
# Thresholds for event detection
99
POWER_SHIFT_THRESHOLD = 2.0 # dBmV shift to trigger power_change
10-
SNR_WARN_THRESHOLD = 33.0
11-
SNR_CRIT_THRESHOLD = 29.0
1210
UNCORR_SPIKE_THRESHOLD = 1000
1311

12+
# Import SNR thresholds from analyzer (loaded from thresholds.json)
13+
from app.analyzer import _get_snr_thresholds as _snr_thresholds
14+
1415
# QAM hierarchy: higher value = better modulation
1516
QAM_ORDER = {
1617
"QPSK": 1, "4QAM": 1,
@@ -134,22 +135,26 @@ def _check_snr(self, events, ts, cur, prev):
134135
if snr_cur == snr_prev:
135136
return
136137

138+
st = _snr_thresholds()
139+
snr_crit = st["crit_min"]
140+
snr_warn = st["good_min"]
141+
137142
# Crossed critical threshold
138-
if snr_cur < SNR_CRIT_THRESHOLD and snr_prev >= SNR_CRIT_THRESHOLD:
143+
if snr_cur < snr_crit and snr_prev >= snr_crit:
139144
events.append({
140145
"timestamp": ts,
141146
"severity": "critical",
142147
"event_type": "snr_change",
143-
"message": f"DS SNR min dropped to {snr_cur} dB (critical threshold: {SNR_CRIT_THRESHOLD})",
148+
"message": f"DS SNR min dropped to {snr_cur} dB (critical threshold: {snr_crit})",
144149
"details": {"prev": snr_prev, "current": snr_cur, "threshold": "critical"},
145150
})
146151
# Crossed warning threshold
147-
elif snr_cur < SNR_WARN_THRESHOLD and snr_prev >= SNR_WARN_THRESHOLD:
152+
elif snr_cur < snr_warn and snr_prev >= snr_warn:
148153
events.append({
149154
"timestamp": ts,
150155
"severity": "warning",
151156
"event_type": "snr_change",
152-
"message": f"DS SNR min dropped to {snr_cur} dB (warning threshold: {SNR_WARN_THRESHOLD})",
157+
"message": f"DS SNR min dropped to {snr_cur} dB (warning threshold: {snr_warn})",
153158
"details": {"prev": snr_prev, "current": snr_cur, "threshold": "warning"},
154159
})
155160

app/thresholds.json

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{
2+
"_source": "Vodafone Kabel Deutschland (VFKD) guidelines, vodafonekabelforum.de/viewtopic.php?t=39941",
3+
"_note": "All power values in dBmV (as shown by Fritz!Box). SNR/MER in dB.",
4+
5+
"downstream_power": {
6+
"256QAM": {
7+
"good_min": -3.9, "good_max": 13.0,
8+
"tolerated_min": -5.9, "tolerated_max": 18.0,
9+
"monthly_min": -7.9, "monthly_max": 20.0,
10+
"immediate_min": -8.0, "immediate_max": 20.0
11+
},
12+
"64QAM": {
13+
"good_min": -9.9, "good_max": 7.0,
14+
"tolerated_min": -11.9, "tolerated_max": 12.0,
15+
"monthly_min": -13.9, "monthly_max": 14.0,
16+
"immediate_min": -14.0, "immediate_max": 14.0
17+
},
18+
"1024QAM": {
19+
"good_min": -1.9, "good_max": 15.0,
20+
"tolerated_min": -3.9, "tolerated_max": 20.0,
21+
"monthly_min": -5.9, "monthly_max": 22.0,
22+
"immediate_min": -6.0, "immediate_max": 22.0
23+
},
24+
"_default": "256QAM"
25+
},
26+
27+
"upstream_power": {
28+
"64QAM": {
29+
"good_min": 41.1, "good_max": 47.0,
30+
"tolerated_min": 38.1, "tolerated_max": 50.0,
31+
"monthly_min": 35.1, "monthly_max": 53.0,
32+
"immediate_min": 35.0, "immediate_max": 53.0
33+
},
34+
"128QAM": {
35+
"good_min": 44.1, "good_max": 47.0,
36+
"tolerated_min": 41.1, "tolerated_max": 50.0,
37+
"monthly_min": 38.1, "monthly_max": 53.0,
38+
"immediate_min": 38.0, "immediate_max": 53.0
39+
},
40+
"_default": "64QAM"
41+
},
42+
43+
"snr": {
44+
"256QAM": {
45+
"good_min": 33.1,
46+
"tolerated_min": 31.1,
47+
"monthly_min": 29.1,
48+
"immediate_min": 29.0
49+
},
50+
"64QAM": {
51+
"good_min": 27.1,
52+
"tolerated_min": 25.1,
53+
"monthly_min": 23.1,
54+
"immediate_min": 23.0
55+
},
56+
"1024QAM": {
57+
"good_min": 37.1,
58+
"tolerated_min": 35.1,
59+
"monthly_min": 33.1,
60+
"immediate_min": 33.0
61+
},
62+
"4096QAM": {
63+
"good_min": 45.1,
64+
"tolerated_min": 43.1,
65+
"monthly_min": 41.1,
66+
"immediate_min": 41.0
67+
},
68+
"_default": "256QAM"
69+
},
70+
71+
"errors": {
72+
"uncorrectable_threshold": 10000
73+
}
74+
}

0 commit comments

Comments
 (0)