Skip to content

Commit 52a9ca8

Browse files
author
Dennis Braun
committed
Align all thresholds with Vodafone VFKD guidelines
DS Power: asymmetric range -4/+13 good, -8/+20 critical (was ±7/±10) US Power: 41-47 good, 35-53 critical (was 35-49/54) SNR: 33 warn, 29 critical (already done in previous commit) Based on Vodafone internal Pegeltabelle NE4 (confirmed by technician) and Vodafone Kabelforum VFKD guidelines for 256QAM.
1 parent c978828 commit 52a9ca8

File tree

3 files changed

+94
-69
lines changed

3 files changed

+94
-69
lines changed

app/analyzer.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,24 @@
44

55
log = logging.getLogger("docsis.analyzer")
66

7-
# --- Reference thresholds ---
8-
# Downstream Power (dBmV): ideal 0, good -7..+7, marginal -10..+10
9-
DS_POWER_WARN = 7.0
10-
DS_POWER_CRIT = 10.0
11-
12-
# Upstream Power (dBmV): good 35-49, marginal 50-54, bad >54
13-
US_POWER_WARN = 50.0
14-
US_POWER_CRIT = 54.0
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
1525

1626
# SNR / MER (dB): good >33, marginal 29-33, bad <29
1727
# Based on Vodafone VFKD guidelines: 256QAM regelkonform > 33.1 dB
@@ -50,9 +60,9 @@ def _assess_ds_channel(ch, docsis_ver):
5060
issues = []
5161
power = _parse_float(ch.get("powerLevel"))
5262

53-
if abs(power) > DS_POWER_CRIT:
63+
if power < DS_POWER_LOW_CRIT or power > DS_POWER_HIGH_CRIT:
5464
issues.append("power critical")
55-
elif abs(power) > DS_POWER_WARN:
65+
elif power < DS_POWER_LOW_GOOD or power > DS_POWER_HIGH_GOOD:
5666
issues.append("power warning")
5767

5868
if docsis_ver == "3.0" and ch.get("mse"):
@@ -76,9 +86,9 @@ def _assess_us_channel(ch):
7686
issues = []
7787
power = _parse_float(ch.get("powerLevel"))
7888

79-
if power > US_POWER_CRIT:
89+
if power < US_POWER_LOW_CRIT or power > US_POWER_HIGH_CRIT:
8090
issues.append("power critical")
81-
elif power > US_POWER_WARN:
91+
elif power < US_POWER_LOW_GOOD or power > US_POWER_HIGH_GOOD:
8292
issues.append("power warning")
8393

8494
return _channel_health(issues), _health_detail(issues)
@@ -191,11 +201,13 @@ def analyze(data: dict) -> dict:
191201

192202
# --- Overall health ---
193203
issues = []
194-
if ds_powers and (min(ds_powers) < -DS_POWER_CRIT or max(ds_powers) > DS_POWER_CRIT):
204+
if ds_powers and (min(ds_powers) < DS_POWER_LOW_CRIT or max(ds_powers) > DS_POWER_HIGH_CRIT):
195205
issues.append("ds_power_critical")
196-
if us_powers and max(us_powers) > US_POWER_CRIT:
206+
elif ds_powers and (min(ds_powers) < DS_POWER_LOW_GOOD or max(ds_powers) > DS_POWER_HIGH_GOOD):
207+
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):
197209
issues.append("us_power_critical")
198-
elif us_powers and max(us_powers) > US_POWER_WARN:
210+
elif us_powers and (min(us_powers) < US_POWER_LOW_GOOD or max(us_powers) > US_POWER_HIGH_GOOD):
199211
issues.append("us_power_warn")
200212
if ds_snrs and min(ds_snrs) < SNR_CRIT:
201213
issues.append("snr_critical")

app/templates/index.html

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -965,20 +965,22 @@
965965

966966
{# ── Per-card health ── #}
967967
{% set signal_health = 'crit' if 'ds_power_critical' in s.health_issues or 'snr_critical' in s.health_issues
968-
else ('warn' if 'snr_warn' in s.health_issues else 'good') %}
968+
else ('warn' if 'ds_power_warn' in s.health_issues or 'snr_warn' in s.health_issues else 'good') %}
969969
{% set us_health = 'crit' if 'us_power_critical' in s.health_issues
970970
else ('warn' if 'us_power_warn' in s.health_issues else 'good') %}
971971
{% set error_health = 'crit' if 'uncorr_errors_high' in s.health_issues else 'good' %}
972972

973973
{# ── DS Power health ── #}
974-
{% set ds_pwr_health = 'crit' if 'ds_power_critical' in s.health_issues else 'good' %}
974+
{% set ds_pwr_health = 'crit' if 'ds_power_critical' in s.health_issues else ('warn' if 'ds_power_warn' in s.health_issues else 'good') %}
975975
{# ── SNR health ── #}
976976
{% set snr_health = 'crit' if 'snr_critical' in s.health_issues else ('warn' if 'snr_warn' in s.health_issues else 'good') %}
977977

978978
{# ── Range marker positions (clamped 2-98%) ── #}
979-
{% set ds_pwr_marker = [2, [98, ((s.ds_power_avg + 12) / 24 * 100)|round|int]|min]|max %}
979+
{# DS Power: range -12 to +25 = 37 units #}
980+
{% set ds_pwr_marker = [2, [98, ((s.ds_power_avg + 12) / 37 * 100)|round|int]|min]|max %}
980981
{% set snr_marker = [2, [98, ((s.ds_snr_avg - 15) / 30 * 100)|round|int]|min]|max %}
981-
{% set us_pwr_marker = [2, [98, ((s.us_power_avg - 25) / 35 * 100)|round|int]|min]|max %}
982+
{# US Power: range 30 to 60 = 30 units #}
983+
{% set us_pwr_marker = [2, [98, ((s.us_power_avg - 30) / 30 * 100)|round|int]|min]|max %}
982984

983985
<div class="metric-cards">
984986
{# ── Card 1: Downstream ── #}
@@ -994,14 +996,14 @@
994996
<div class="metric-value val-{{ ds_pwr_health }}">{{ s.ds_power_avg }}<span class="metric-unit">dBmV</span></div>
995997
<div class="metric-label">{{ t.card_ds_power }}<span class="info-tip"><i class="tip-icon" tabindex="0">&#9432;</i><span class="tip-text">{{ t.tip_ds_power }}</span></span></div>
996998
<div class="range-indicator">
997-
<div class="range-zone range-crit" style="width:8.3%"></div>
998-
<div class="range-zone range-warn" style="width:12.5%"></div>
999-
<div class="range-zone range-good" style="width:58.3%"></div>
1000-
<div class="range-zone range-warn" style="width:12.5%"></div>
1001-
<div class="range-zone range-crit" style="width:8.4%"></div>
999+
<div class="range-zone range-crit" style="width:10.8%"></div>
1000+
<div class="range-zone range-warn" style="width:10.8%"></div>
1001+
<div class="range-zone range-good" style="width:45.9%"></div>
1002+
<div class="range-zone range-warn" style="width:18.9%"></div>
1003+
<div class="range-zone range-crit" style="width:13.6%"></div>
10021004
<div class="range-marker val-{{ ds_pwr_health }}" style="left:{{ ds_pwr_marker }}%"></div>
10031005
</div>
1004-
<div class="range-labels"><span>-12</span><span>0</span><span>+12</span></div>
1006+
<div class="range-labels"><span>-8</span><span>+13</span><span>+20</span></div>
10051007
</div>
10061008
<div class="metric-item">
10071009
<div class="metric-value val-{{ snr_health }}">{{ s.ds_snr_avg }}<span class="metric-unit">dB</span></div>
@@ -1021,7 +1023,7 @@
10211023
<tr><td>{{ t.card_power_range }}</td><td>{{ s.ds_power_min }} ... {{ s.ds_power_max }} dBmV</td></tr>
10221024
<tr><td>SNR {{ t.card_power_range }}</td><td>{{ s.ds_snr_min }} ... {{ s.ds_snr_avg }} dB</td></tr>
10231025
<tr><td>{{ t.channels }}</td><td>{{ s.ds_total }} {{ t.downstream|lower }}</td></tr>
1024-
<tr><td>{{ t.card_ideal_range }}</td><td>-7...+7 dBmV / SNR &gt;33 dB</td></tr>
1026+
<tr><td>{{ t.card_ideal_range }}</td><td>-4...+13 dBmV / SNR &gt;33 dB</td></tr>
10251027
</table>
10261028
</div>
10271029
</div>
@@ -1039,20 +1041,22 @@
10391041
<div class="metric-value val-{{ us_health }}">{{ s.us_power_avg }}<span class="metric-unit">dBmV</span></div>
10401042
<div class="metric-label">{{ t.card_transmit_power }}<span class="info-tip"><i class="tip-icon" tabindex="0">&#9432;</i><span class="tip-text">{{ t.tip_transmit_power }}</span></span></div>
10411043
<div class="range-indicator">
1042-
<div class="range-zone range-good" style="width:68.6%"></div>
1043-
<div class="range-zone range-warn" style="width:14.3%"></div>
1044-
<div class="range-zone range-crit" style="width:17.1%"></div>
1044+
<div class="range-zone range-crit" style="width:16.7%"></div>
1045+
<div class="range-zone range-warn" style="width:20%"></div>
1046+
<div class="range-zone range-good" style="width:20%"></div>
1047+
<div class="range-zone range-warn" style="width:20%"></div>
1048+
<div class="range-zone range-crit" style="width:23.3%"></div>
10451049
<div class="range-marker val-{{ us_health }}" style="left:{{ us_pwr_marker }}%"></div>
10461050
</div>
1047-
<div class="range-labels"><span>25</span><span>35</span><span>49</span><span>54</span><span>60</span></div>
1051+
<div class="range-labels"><span>30</span><span>41</span><span>47</span><span>53</span><span>60</span></div>
10481052
</div>
10491053
</div>
10501054
</div>
10511055
<div class="metric-card-detail">
10521056
<table class="detail-table">
10531057
<tr><td>{{ t.card_power_range }}</td><td>{{ s.us_power_min }} ... {{ s.us_power_max }} dBmV</td></tr>
10541058
<tr><td>{{ t.channels }}</td><td>{{ s.us_total }} {{ t.upstream|lower }}</td></tr>
1055-
<tr><td>{{ t.card_ideal_range }}</td><td>35 ... 49 dBmV</td></tr>
1059+
<tr><td>{{ t.card_ideal_range }}</td><td>41 ... 47 dBmV</td></tr>
10561060
</table>
10571061
</div>
10581062
</div>
@@ -1189,7 +1193,7 @@ <h2 class="section-title">{{ t.downstream }} ({{ ds|length }} {{ t.channels }})<
11891193
</td>
11901194
<td data-sort="{{ ch.channel_id }}">{{ ch.channel_id }}</td>
11911195
<td>{{ ch.frequency }}</td>
1192-
<td data-sort="{{ ch.power if ch.power is not none else 0 }}" class="{% if ch.power is not none %}{% if ch.power|abs > 10 %}val-crit{% elif ch.power|abs > 7 %}val-warn{% else %}val-good{% endif %}{% endif %}">{{ ch.power }}</td>
1196+
<td data-sort="{{ ch.power if ch.power is not none else 0 }}" class="{% if ch.power is not none %}{% if ch.power < -8 or ch.power > 20 %}val-crit{% elif ch.power < -4 or ch.power > 13 %}val-warn{% else %}val-good{% endif %}{% endif %}">{{ ch.power }}</td>
11931197
<td data-sort="{{ ch.snr if ch.snr is not none else 0 }}" class="{% if ch.snr is not none %}{% if ch.snr < 29 %}val-crit{% elif ch.snr < 33 %}val-warn{% else %}val-good{% endif %}{% endif %}">{{ ch.snr if ch.snr is not none else "-" }}</td>
11941198
<td>{{ ch.modulation }}</td>
11951199
<td data-sort="{{ ch.correctable_errors }}">{{ ch.correctable_errors|fmt_k }}</td>
@@ -1223,7 +1227,7 @@ <h2 class="section-title">{{ t.upstream }} ({{ us|length }} {{ t.channels }})</h
12231227
</td>
12241228
<td data-sort="{{ ch.channel_id }}">{{ ch.channel_id }}</td>
12251229
<td>{{ ch.frequency }}</td>
1226-
<td data-sort="{{ ch.power }}" class="{% if ch.power > 54 %}val-crit{% elif ch.power > 50 %}val-warn{% else %}val-good{% endif %}">{{ ch.power }}</td>
1230+
<td data-sort="{{ ch.power }}" class="{% if ch.power < 35 or ch.power > 53 %}val-crit{% elif ch.power < 41 or ch.power > 47 %}val-warn{% else %}val-good{% endif %}">{{ ch.power }}</td>
12271231
<td>{{ ch.modulation }}</td>
12281232
<td>{{ ch.multiplex }}</td>
12291233
</tr>
@@ -2105,12 +2109,12 @@ <h2>{{ t.export_title }}</h2>
21052109

21062110
/* ── Trend Charts ── */
21072111
var DS_POWER_ZONES = [
2108-
{min: -15, max: -10, color: "rgba(244,67,54,0.35)"},
2109-
{min: -10, max: -7, color: "rgba(255,152,0,0.30)"},
2110-
{min: -7, max: 7, color: "rgba(76,175,80,0.25)"},
2111-
{min: 7, max: 10, color: "rgba(255,152,0,0.30)"},
2112-
{min: 10, max: 15, color: "rgba(244,67,54,0.35)"},
2113-
{yMin: -12, yMax: 12}
2112+
{min: -15, max: -8, color: "rgba(244,67,54,0.35)"},
2113+
{min: -8, max: -4, color: "rgba(255,152,0,0.30)"},
2114+
{min: -4, max: 13, color: "rgba(76,175,80,0.25)"},
2115+
{min: 13, max: 20, color: "rgba(255,152,0,0.30)"},
2116+
{min: 20, max: 25, color: "rgba(244,67,54,0.35)"},
2117+
{yMin: -12, yMax: 22}
21142118
];
21152119
var DS_SNR_ZONES = [
21162120
{min: 15, max: 29, color: "rgba(244,67,54,0.35)"},
@@ -2119,12 +2123,12 @@ <h2>{{ t.export_title }}</h2>
21192123
{yMin: 25, yMax: 45}
21202124
];
21212125
var US_POWER_ZONES = [
2122-
{min: 20, max: 30, color: "rgba(244,67,54,0.35)"},
2123-
{min: 30, max: 35, color: "rgba(255,152,0,0.30)"},
2124-
{min: 35, max: 50, color: "rgba(76,175,80,0.25)"},
2125-
{min: 50, max: 55, color: "rgba(255,152,0,0.30)"},
2126-
{min: 55, max: 65, color: "rgba(244,67,54,0.35)"},
2127-
{yMin: 25, yMax: 60}
2126+
{min: 20, max: 35, color: "rgba(244,67,54,0.35)"},
2127+
{min: 35, max: 41, color: "rgba(255,152,0,0.30)"},
2128+
{min: 41, max: 47, color: "rgba(76,175,80,0.25)"},
2129+
{min: 47, max: 53, color: "rgba(255,152,0,0.30)"},
2130+
{min: 53, max: 65, color: "rgba(244,67,54,0.35)"},
2131+
{yMin: 30, yMax: 60}
21282132
];
21292133

21302134
function loadTrends(range, date) {

tests/test_analyzer.py

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@ def test_all_normal(self):
8585
assert result["summary"]["health_issues"] == []
8686

8787
def test_power_at_boundary(self):
88-
"""Power exactly at 7.0 is still good."""
88+
"""Power exactly at 13.0 is still good (VFKD regelkonform)."""
8989
data = _make_data(
90-
ds30=[_make_ds30(1, power=7.0, mse="-35")],
91-
us30=[_make_us30(1, power=49.0)],
90+
ds30=[_make_ds30(1, power=13.0, mse="-35")],
91+
us30=[_make_us30(1, power=44.0)],
9292
)
9393
result = analyze(data)
9494
assert result["summary"]["health"] == "good"
@@ -98,21 +98,20 @@ def test_power_at_boundary(self):
9898

9999
class TestHealthMarginal:
100100
def test_ds_power_warning(self):
101-
"""DS power 8 dBmV is marginal (>7, <10)."""
101+
"""DS power 15 dBmV is marginal (>13, <20)."""
102102
data = _make_data(
103-
ds30=[_make_ds30(1, power=8.0, mse="-35")],
104-
us30=[_make_us30(1, power=42.0)],
103+
ds30=[_make_ds30(1, power=15.0, mse="-35")],
104+
us30=[_make_us30(1, power=44.0)],
105105
)
106106
result = analyze(data)
107-
# DS power between 7-10 doesn't trigger issue (only >10 does)
108-
# But SNR is fine, so health depends on power assessment
109-
assert result["summary"]["health"] in ("good", "marginal")
107+
assert result["summary"]["health"] == "marginal"
108+
assert "ds_power_warn" in result["summary"]["health_issues"]
110109

111110
def test_us_power_warning(self):
112-
"""US power 52 dBmV triggers marginal."""
111+
"""US power 49 dBmV triggers marginal (>47, <53)."""
113112
data = _make_data(
114113
ds30=[_make_ds30(1, power=2.0, mse="-35")],
115-
us30=[_make_us30(1, power=52.0)],
114+
us30=[_make_us30(1, power=49.0)],
116115
)
117116
result = analyze(data)
118117
assert result["summary"]["health"] == "marginal"
@@ -133,30 +132,40 @@ def test_snr_warning(self):
133132

134133
class TestHealthPoor:
135134
def test_ds_power_critical(self):
136-
"""DS power 12 dBmV is critical (>10)."""
135+
"""DS power 21 dBmV is critical (>20)."""
137136
data = _make_data(
138-
ds30=[_make_ds30(1, power=12.0, mse="-35")],
139-
us30=[_make_us30(1, power=42.0)],
137+
ds30=[_make_ds30(1, power=21.0, mse="-35")],
138+
us30=[_make_us30(1, power=44.0)],
140139
)
141140
result = analyze(data)
142141
assert result["summary"]["health"] == "poor"
143142
assert "ds_power_critical" in result["summary"]["health_issues"]
144143

145144
def test_ds_power_critical_negative(self):
146-
"""DS power -11 dBmV is also critical."""
145+
"""DS power -9 dBmV is also critical (<-8)."""
147146
data = _make_data(
148-
ds30=[_make_ds30(1, power=-11.0, mse="-35")],
149-
us30=[_make_us30(1, power=42.0)],
147+
ds30=[_make_ds30(1, power=-9.0, mse="-35")],
148+
us30=[_make_us30(1, power=44.0)],
150149
)
151150
result = analyze(data)
152151
assert result["summary"]["health"] == "poor"
153152
assert "ds_power_critical" in result["summary"]["health_issues"]
154153

155-
def test_us_power_critical(self):
156-
"""US power 56 dBmV is critical (>54)."""
154+
def test_us_power_critical_high(self):
155+
"""US power 55 dBmV is critical (>53)."""
156+
data = _make_data(
157+
ds30=[_make_ds30(1, power=2.0, mse="-35")],
158+
us30=[_make_us30(1, power=55.0)],
159+
)
160+
result = analyze(data)
161+
assert result["summary"]["health"] == "poor"
162+
assert "us_power_critical" in result["summary"]["health_issues"]
163+
164+
def test_us_power_critical_low(self):
165+
"""US power 33 dBmV is critical (<35)."""
157166
data = _make_data(
158167
ds30=[_make_ds30(1, power=2.0, mse="-35")],
159-
us30=[_make_us30(1, power=56.0)],
168+
us30=[_make_us30(1, power=33.0)],
160169
)
161170
result = analyze(data)
162171
assert result["summary"]["health"] == "poor"
@@ -185,8 +194,8 @@ def test_uncorrectable_errors(self):
185194
def test_multiple_issues(self):
186195
"""Multiple issues can coexist."""
187196
data = _make_data(
188-
ds30=[_make_ds30(1, power=12.0, mse="-22", uncorr=20000)],
189-
us30=[_make_us30(1, power=56.0)],
197+
ds30=[_make_ds30(1, power=21.0, mse="-27", uncorr=20000)],
198+
us30=[_make_us30(1, power=55.0)],
190199
)
191200
result = analyze(data)
192201
assert result["summary"]["health"] == "poor"
@@ -246,8 +255,8 @@ def test_per_channel_health(self):
246255
data = _make_data(
247256
ds30=[
248257
_make_ds30(1, power=2.0, mse="-35"), # good
249-
_make_ds30(2, power=12.0, mse="-35"), # power critical
250-
_make_ds30(3, power=2.0, mse="-22"), # snr critical
258+
_make_ds30(2, power=21.0, mse="-35"), # power critical
259+
_make_ds30(3, power=2.0, mse="-27"), # snr critical
251260
],
252261
us30=[_make_us30(1)],
253262
)

0 commit comments

Comments
 (0)