@@ -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):
257310def _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
0 commit comments