Skip to content

Commit 441073e

Browse files
committed
fix: correct DOCSIS 3.1 modulation charts
1 parent 00c9b47 commit 441073e

File tree

4 files changed

+101
-11
lines changed

4 files changed

+101
-11
lines changed

app/modules/modulation/engine.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ def _weighted_avg(values_weights):
250250

251251
def _build_protocol_group(version, direction, by_date, sorted_dates, threshold):
252252
"""Build a single protocol group result dict."""
253+
effective_threshold = _degraded_qam_threshold(direction, version, threshold)
254+
253255
# Collect observations per day, only for channels of this version
254256
all_observations = []
255257
channel_ids = set()
@@ -275,14 +277,14 @@ def _build_protocol_group(version, direction, by_date, sorted_dates, threshold):
275277

276278
all_observations.extend(day_observations)
277279
hi = _health_index_for_group(day_observations, direction, version)
278-
lq = _low_qam_pct(day_observations, threshold)
280+
lq = _low_qam_pct(day_observations, effective_threshold)
279281

280282
# Count degraded channels for this day
281283
degraded = _count_degraded_channels_day(
282284
by_date[date_str],
283285
version,
284286
direction,
285-
_degraded_qam_threshold(direction, version, threshold),
287+
effective_threshold,
286288
)
287289

288290
days.append({
@@ -296,7 +298,7 @@ def _build_protocol_group(version, direction, by_date, sorted_dates, threshold):
296298
max_qam = MAX_QAM.get((direction, version), 4096)
297299
max_qam_label = f"{max_qam}QAM"
298300
overall_hi = _health_index_for_group(all_observations, direction, version)
299-
overall_lq = _low_qam_pct(all_observations, threshold)
301+
overall_lq = _low_qam_pct(all_observations, effective_threshold)
300302
overall_dist = _distribution_pct(all_observations)
301303
dominant = max(overall_dist, key=overall_dist.get) if overall_dist else None
302304

@@ -306,7 +308,7 @@ def _build_protocol_group(version, direction, by_date, sorted_dates, threshold):
306308
sorted_dates,
307309
version,
308310
direction,
309-
_degraded_qam_threshold(direction, version, threshold),
311+
effective_threshold,
310312
)
311313

312314
return {

app/modules/modulation/static/main.js

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ var _modIntradayCharts = [];
88
/* QAM color scheme */
99
var QAM_COLORS = {
1010
'4QAM': '#ef4444',
11+
'8QAM': '#fb7185',
1112
'16QAM': '#f97316',
13+
'32QAM': '#f59e0b',
1214
'64QAM': '#eab308',
1315
'128QAM': '#84cc16',
1416
'256QAM': '#22c55e',
@@ -20,6 +22,22 @@ var QAM_COLORS = {
2022
'Unknown': '#6b7280'
2123
};
2224

25+
var MODULATION_LEVELS = [
26+
'4QAM',
27+
'8QAM',
28+
'16QAM',
29+
'32QAM',
30+
'64QAM',
31+
'128QAM',
32+
'256QAM',
33+
'512QAM',
34+
'1024QAM',
35+
'4096QAM',
36+
'OFDM',
37+
'OFDMA',
38+
'Unknown'
39+
];
40+
2341
/* ── Direction tabs ── */
2442
var dirTabs = document.querySelectorAll('#modulation-direction-tabs .trend-tab');
2543
dirTabs.forEach(function(btn) {
@@ -206,6 +224,9 @@ function renderProtocolGroups(data) {
206224
barDiv.style.height = '100%';
207225
barWrap.appendChild(barDiv);
208226
barCard.appendChild(barWrap);
227+
var legendDiv = _el('div', 'modulation-custom-legend');
228+
legendDiv.id = 'mod-dist-legend-' + idx;
229+
barCard.appendChild(legendDiv);
209230
chartsGrid.appendChild(barCard);
210231

211232
// Trend line chart card
@@ -245,8 +266,10 @@ function _buildMiniKPI(label, value, cls) {
245266

246267
function renderGroupDistChart(pg, idx) {
247268
var container = document.getElementById('mod-dist-chart-' + idx);
269+
var legendContainer = document.getElementById('mod-dist-legend-' + idx);
248270
if (!container) return;
249271
container.textContent = '';
272+
if (legendContainer) legendContainer.textContent = '';
250273

251274
var days = pg.days || [];
252275
var labels = days.map(function(d) { return d.date.slice(5); }); /* MM-DD, drop year */
@@ -269,8 +292,10 @@ function renderGroupDistChart(pg, idx) {
269292
/* Build cumulative sums for each modulation layer */
270293
var cumData = xData.map(function() { return 0; });
271294
var layerData = [];
295+
var rawSeriesByMod = {};
272296
modKeys.forEach(function(mod) {
273297
var raw = days.map(function(d) { return (d.distribution || {})[mod] || 0; });
298+
rawSeriesByMod[mod] = raw;
274299
var stacked = raw.map(function(v, j) { cumData[j] += v; return cumData[j]; });
275300
layerData.push({ mod: mod, data: stacked });
276301
});
@@ -291,6 +316,10 @@ function renderGroupDistChart(pg, idx) {
291316
});
292317
}
293318

319+
if (legendContainer) {
320+
renderDistributionLegend(legendContainer, modKeys);
321+
}
322+
294323
var w = container.offsetWidth || 400;
295324
var h = container.offsetHeight || 300;
296325
var chart = new uPlot({
@@ -321,8 +350,13 @@ function renderGroupDistChart(pg, idx) {
321350
],
322351
series: uSeries,
323352
cursor: { show: true, x: true, y: false, points: { show: false } },
324-
legend: { show: true, live: false },
325-
plugins: [tooltipPlugin(labels)]
353+
legend: { show: false, live: false },
354+
plugins: [tooltipPlugin(labels, function(ctx) {
355+
var mod = ctx.dataset.label;
356+
var rawSeries = rawSeriesByMod[mod] || [];
357+
var raw = rawSeries[ctx.dataIndex] || 0;
358+
return mod + ': ' + raw.toFixed(1) + '%';
359+
})]
326360
}, uData, container);
327361
_modCharts.push(chart);
328362
}
@@ -523,7 +557,7 @@ function renderChannelTimeline(canvasId, timeline) {
523557
var dataPoints = timeline.map(function(t) { return modSortOrder(t.modulation); });
524558
var n = labels.length;
525559
var textColor = _cssVar('--text-secondary') || '#9ca3af';
526-
var qamLabels = ['4QAM', '16QAM', '64QAM', '128QAM', '256QAM', '512QAM', '1024QAM', '4096QAM', 'OFDM', 'OFDMA', 'Unknown'];
560+
var qamLabels = MODULATION_LEVELS;
527561

528562
var xData = [];
529563
for (var i = 0; i < n; i++) xData.push(i);
@@ -535,7 +569,7 @@ function renderChannelTimeline(canvasId, timeline) {
535569
height: h,
536570
scales: {
537571
x: { time: false, range: function() { return [-0.5, n - 0.5]; } },
538-
y: { range: [-0.5, 10.5] }
572+
y: { range: [-0.5, qamLabels.length - 0.5] }
539573
},
540574
axes: [
541575
{
@@ -556,7 +590,9 @@ function renderChannelTimeline(canvasId, timeline) {
556590
scale: 'y',
557591
stroke: textColor,
558592
grid: { stroke: 'rgba(255,255,255,0.04)', width: 1 },
559-
splits: function() { return [0,1,2,3,4,5,6,7,8,9,10]; },
593+
splits: function() {
594+
return qamLabels.map(function(_, idx) { return idx; });
595+
},
560596
values: function(u, vals) { return vals.map(function(v) { return qamLabels[v] || ''; }); },
561597
font: '10px system-ui',
562598
size: 60
@@ -609,8 +645,22 @@ function densityClass(v) {
609645
return 'critical';
610646
}
611647
function modSortOrder(mod) {
612-
var order = { '4QAM': 0, '16QAM': 1, '64QAM': 2, '128QAM': 3, '256QAM': 4, '512QAM': 5, '1024QAM': 6, '4096QAM': 7, 'OFDM': 8, 'OFDMA': 9, 'Unknown': 10 };
613-
return order[mod] !== undefined ? order[mod] : 11;
648+
var order = {};
649+
MODULATION_LEVELS.forEach(function(label, idx) {
650+
order[label] = idx;
651+
});
652+
return order[mod] !== undefined ? order[mod] : (MODULATION_LEVELS.length - 1);
653+
}
654+
655+
function renderDistributionLegend(container, modKeys) {
656+
modKeys.forEach(function(mod) {
657+
var item = _el('div', 'modulation-custom-legend-item');
658+
var swatch = _el('span', 'modulation-custom-legend-swatch');
659+
swatch.style.background = QAM_COLORS[mod] || '#6b7280';
660+
item.appendChild(swatch);
661+
item.appendChild(_el('span', 'modulation-custom-legend-label', mod));
662+
container.appendChild(item);
663+
});
614664
}
615665

616666
function destroyCharts() {

app/modules/modulation/static/style.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,29 @@
180180
font-size: 0.8em;
181181
}
182182

183+
.modulation-custom-legend {
184+
display: flex;
185+
flex-wrap: wrap;
186+
gap: 8px 16px;
187+
margin-top: 12px;
188+
font-size: 0.75em;
189+
color: var(--text-secondary, var(--muted));
190+
}
191+
192+
.modulation-custom-legend-item {
193+
display: inline-flex;
194+
align-items: center;
195+
gap: 6px;
196+
white-space: nowrap;
197+
}
198+
199+
.modulation-custom-legend-swatch {
200+
width: 10px;
201+
height: 10px;
202+
border-radius: 2px;
203+
flex-shrink: 0;
204+
}
205+
183206
@media (max-width: 768px) {
184207
.modulation-kpi-row {
185208
grid-template-columns: 1fr;

tests/test_modulation_engine.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ def test_us31_128qam_counts_as_degraded(self):
467467
pg = result["protocol_groups"][0]
468468
assert pg["docsis_version"] == "3.1"
469469
assert pg["degraded_channel_count"] == 1
470+
assert pg["low_qam_pct"] == 100.0
470471

471472
def test_us31_512qam_not_counted_as_degraded(self):
472473
us_channels = [
@@ -478,6 +479,20 @@ def test_us31_512qam_not_counted_as_degraded(self):
478479
assert pg["docsis_version"] == "3.1"
479480
assert pg["degraded_channel_count"] == 0
480481

482+
def test_us31_low_qam_pct_tracks_protocol_threshold(self):
483+
snaps = [
484+
_make_snapshot("2026-03-01T10:00:00Z",
485+
us_channels=[{"channel_id": 41, "modulation": "1024QAM", "docsis_version": "3.1"}]),
486+
_make_snapshot("2026-03-01T14:00:00Z",
487+
us_channels=[{"channel_id": 41, "modulation": "128QAM", "docsis_version": "3.1"}]),
488+
_make_snapshot("2026-03-01T18:00:00Z",
489+
us_channels=[{"channel_id": 41, "modulation": "512QAM", "docsis_version": "3.1"}]),
490+
]
491+
result = compute_distribution_v2(snaps, "us", "UTC")
492+
pg = result["protocol_groups"][0]
493+
assert pg["low_qam_pct"] == 33.3
494+
assert pg["days"][0]["low_qam_pct"] == 33.3
495+
481496
def test_per_day_data(self):
482497
snaps = [
483498
_make_snapshot("2026-03-01T10:00:00Z", us_channels=_make_channels(["64QAM"])),

0 commit comments

Comments
 (0)