Skip to content

Commit 6ebbc0b

Browse files
committed
fix(modulation): normalize DS health by channel baseline
1 parent feaf211 commit 6ebbc0b

File tree

2 files changed

+111
-6
lines changed

2 files changed

+111
-6
lines changed

app/modules/modulation/engine.py

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,36 @@ def _health_index_for_group(observations, direction, docsis_version):
106106
return round(max(0, min(100, index)), 1)
107107

108108

109+
def _health_index_for_channel_baselines(observations, channel_baselines):
110+
"""Compute health index relative to each channel's observed baseline.
111+
112+
Used for downstream groups where some channels are expected to top out at a
113+
lower QAM order (for example fixed 64QAM channels in mixed DOCSIS 3.0
114+
segments). Each observation is scored against the highest numeric QAM seen
115+
for that channel in the selected dataset.
116+
"""
117+
numeric = [
118+
(channel_id, qam)
119+
for channel_id, _, qam in observations
120+
if channel_id is not None and qam is not None
121+
]
122+
if not numeric:
123+
return None
124+
125+
scores = []
126+
for channel_id, qam in numeric:
127+
baseline_qam = channel_baselines.get(channel_id) or qam
128+
baseline_bits = math.log2(max(baseline_qam, 4))
129+
denominator = baseline_bits - 2
130+
if denominator <= 0:
131+
score = 100.0
132+
else:
133+
score = 100 * (math.log2(qam) - 2) / denominator
134+
scores.append(max(0, min(100, score)))
135+
136+
return round(sum(scores) / len(scores), 1)
137+
138+
109139
# ── Distribution helpers ─────────────────────────────────────────────
110140

111141

@@ -152,6 +182,29 @@ def _degraded_qam_threshold(direction, docsis_version, default_threshold):
152182
return DEGRADED_QAM_THRESHOLDS.get((direction, docsis_version), default_threshold)
153183

154184

185+
def _channel_identity(ch):
186+
"""Return a stable per-channel identity for modulation baselines."""
187+
return ch.get("channel_id", ch.get("frequency"))
188+
189+
190+
def _build_channel_baselines(by_date, version):
191+
"""Return highest numeric QAM observed per channel across the full range."""
192+
baselines = {}
193+
for date_groups in by_date.values():
194+
for channels in date_groups:
195+
for ch in channels:
196+
if ch.get("docsis_version", "3.0") != version:
197+
continue
198+
channel_id = _channel_identity(ch)
199+
if channel_id is None:
200+
continue
201+
_, qam = _canonical_label(_channel_modulation(ch))
202+
if qam is None:
203+
continue
204+
baselines[channel_id] = max(baselines.get(channel_id, 0), qam)
205+
return baselines
206+
207+
155208
# ── Multi-day overview (distribution v2) ─────────────────────────────
156209

157210

@@ -257,14 +310,17 @@ def _weighted_avg(values_weights):
257310
def _build_protocol_group(version, direction, by_date, sorted_dates, threshold):
258311
"""Build a single protocol group result dict."""
259312
effective_threshold = _degraded_qam_threshold(direction, version, threshold)
313+
channel_baselines = _build_channel_baselines(by_date, version)
260314

261315
# Collect observations per day, only for channels of this version
262316
all_observations = []
317+
all_health_observations = []
263318
channel_ids = set()
264319
days = []
265320

266321
for date_str in sorted_dates:
267322
day_observations = []
323+
day_health_observations = []
268324
day_sample_count = 0
269325

270326
for channels in by_date[date_str]:
@@ -273,16 +329,22 @@ def _build_protocol_group(version, direction, by_date, sorted_dates, threshold):
273329
continue
274330
day_sample_count += 1
275331
for ch in group_channels:
276-
channel_ids.add(ch.get("channel_id"))
332+
channel_id = _channel_identity(ch)
333+
channel_ids.add(channel_id)
277334
mod_str = _channel_modulation(ch)
278335
label, qam = _canonical_label(mod_str)
279336
day_observations.append((label, qam))
337+
day_health_observations.append((channel_id, label, qam))
280338

281339
if not day_observations:
282340
continue
283341

284342
all_observations.extend(day_observations)
285-
hi = _health_index_for_group(day_observations, direction, version)
343+
all_health_observations.extend(day_health_observations)
344+
if direction == "ds":
345+
hi = _health_index_for_channel_baselines(day_health_observations, channel_baselines)
346+
else:
347+
hi = _health_index_for_group(day_observations, direction, version)
286348
lq = _low_qam_pct(day_observations, effective_threshold)
287349

288350
# Count degraded channels for this day
@@ -303,7 +365,10 @@ def _build_protocol_group(version, direction, by_date, sorted_dates, threshold):
303365

304366
max_qam = MAX_QAM.get((direction, version), 4096)
305367
max_qam_label = f"{max_qam}QAM"
306-
overall_hi = _health_index_for_group(all_observations, direction, version)
368+
if direction == "ds":
369+
overall_hi = _health_index_for_channel_baselines(all_health_observations, channel_baselines)
370+
else:
371+
overall_hi = _health_index_for_group(all_observations, direction, version)
307372
overall_lq = _low_qam_pct(all_observations, effective_threshold)
308373
overall_dist = _distribution_pct(all_observations)
309374
dominant = max(overall_dist, key=overall_dist.get) if overall_dist else None
@@ -424,9 +489,16 @@ def compute_intraday(snapshots, direction, tz_name, date_str, low_qam_threshold=
424489
for cid, cdata in sorted(by_version[version], key=lambda x: x[0]):
425490
timeline = cdata["timeline"]
426491
periods = _modulation_periods(timeline)
427-
hi = _health_index_for_group(
428-
[(l, q) for _, l, q in timeline], direction, version
429-
)
492+
if direction == "ds":
493+
channel_baseline = max((q for _, _, q in timeline if q is not None), default=None)
494+
hi = _health_index_for_channel_baselines(
495+
[(cid, l, q) for _, l, q in timeline],
496+
{cid: channel_baseline} if channel_baseline is not None else {},
497+
)
498+
else:
499+
hi = _health_index_for_group(
500+
[(l, q) for _, l, q in timeline], direction, version
501+
)
430502
degraded_threshold = _degraded_qam_threshold(direction, version, low_qam_threshold)
431503
degraded_events = _build_degraded_events(periods, degraded_threshold)
432504
degraded = len(degraded_events) > 0

tests/test_modulation_engine.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,38 @@ def test_health_index_ds30_correct(self):
571571
pg = result["protocol_groups"][0]
572572
assert pg["health_index"] == 100.0
573573

574+
def test_health_index_ds30_fixed_64qam_channels_stay_healthy(self):
575+
"""Fixed 64QAM DS channels should not lower the v2 health index."""
576+
ds_channels = [
577+
{"channel_id": 1, "modulation": "256QAM", "docsis_version": "3.0"},
578+
{"channel_id": 2, "modulation": "64QAM", "docsis_version": "3.0"},
579+
{"channel_id": 3, "modulation": "64QAM", "docsis_version": "3.0"},
580+
]
581+
snaps = [
582+
_make_snapshot("2026-03-01T10:00:00Z", ds_channels=ds_channels),
583+
_make_snapshot("2026-03-01T14:00:00Z", ds_channels=ds_channels),
584+
]
585+
result = compute_distribution_v2(snaps, "ds", "UTC")
586+
pg = result["protocol_groups"][0]
587+
assert pg["health_index"] == 100.0
588+
assert pg["days"][0]["health_index"] == 100.0
589+
590+
def test_health_index_ds30_still_drops_when_channel_falls_below_its_baseline(self):
591+
"""A DS channel that drops from 256QAM to 64QAM should still lower health."""
592+
snaps = [
593+
_make_snapshot(
594+
"2026-03-01T10:00:00Z",
595+
ds_channels=[{"channel_id": 1, "modulation": "256QAM", "docsis_version": "3.0"}],
596+
),
597+
_make_snapshot(
598+
"2026-03-01T14:00:00Z",
599+
ds_channels=[{"channel_id": 1, "modulation": "64QAM", "docsis_version": "3.0"}],
600+
),
601+
]
602+
result = compute_distribution_v2(snaps, "ds", "UTC")
603+
pg = result["protocol_groups"][0]
604+
assert pg["health_index"] == 83.3
605+
574606
def test_degraded_channel_count(self):
575607
us_channels = [
576608
{"channel_id": 1, "modulation": "64QAM", "docsis_version": "3.0"},
@@ -696,6 +728,7 @@ def test_ds_64qam_not_degraded_intraday(self):
696728
result = compute_intraday(snaps, "ds", "UTC", "2026-03-01")
697729
ch = result["protocol_groups"][0]["channels"][0]
698730
assert ch["degraded"] is False
731+
assert ch["health_index"] == 100.0
699732
assert ch["summary"] == ""
700733

701734
def test_multi_protocol_groups(self):

0 commit comments

Comments
 (0)