Skip to content

Commit d12832f

Browse files
committed
Add ATDMA pre-eq group delay support
- Wire channel width into pre-eq parsing for group delay - Add group delay to equalizer model and optional outputs - Update ATDMA pre-equalization docs for new response fields - Add tests for group delay inclusion behavior - Refresh review bundle with updated files
1 parent 51dfdc1 commit d12832f

8 files changed

Lines changed: 3664 additions & 39 deletions

File tree

docs/api/fast-api/single/us/atdma/chan/pre-equalization.md

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ TFTP parameters are not required.
1616
This endpoint returns the standard envelope described in [Common → Response](../../../../common/response.md) (`mac_address`, `status`, `message`, `data`).
1717

1818
`data` is an **object** keyed by the **SNMP table index** of each upstream channel.
19-
Each value contains decoded tap configuration and coefficient arrays.
19+
Each value contains decoded tap configuration, coefficient arrays, and optional group delay.
2020

2121
### Abbreviated Example
2222

@@ -28,29 +28,32 @@ Each value contains decoded tap configuration and coefficient arrays.
2828
"data": {
2929
"80": {
3030
"main_tap_location": 8,
31-
"forward_taps_per_symbol": 1,
32-
"num_forward_taps": 24,
33-
"num_reverse_taps": 0,
34-
"forward_coefficients": [
35-
{ "real": 0, "imag": 4, "magnitude": 4.0, "magnitude_power_dB": 12.04 },
36-
{ "real": 2, "imag": -15425, "magnitude": 15425.0, "magnitude_power_dB": 83.76 },
37-
{ "real": -15426, "imag": 1, "magnitude": 15426.0, "magnitude_power_dB": 83.77 }
38-
/* ... taps elided ... */
31+
"taps_per_symbol": 1,
32+
"num_taps": 24,
33+
"reserved": 0,
34+
"header_hex": "08 01 18 00",
35+
"payload_hex": "08 01 18 00 FE FF FE FF 03 00 FF FF 00 00 01 00",
36+
"payload_preview_hex": "08 01 18 00 FE FF FE FF 03 00 FF FF 00 00 01 00",
37+
"taps": [
38+
{ "real": -257, "imag": -257, "magnitude": 363.45, "magnitude_power_dB": 51.21, "real_hex": "FEFF", "imag_hex": "FEFF" },
39+
{ "real": 768, "imag": -1, "magnitude": 768.0, "magnitude_power_dB": 57.71, "real_hex": "0300", "imag_hex": "FFFF" }
3940
],
40-
"reverse_coefficients": []
41-
},
42-
"81": {
43-
"main_tap_location": 8,
44-
"forward_taps_per_symbol": 1,
45-
"num_forward_taps": 24,
46-
"num_reverse_taps": 0,
47-
"forward_coefficients": [
48-
{ "real": -15425, "imag": -15425, "magnitude": 21814.24, "magnitude_power_dB": 86.77 },
49-
{ "real": 1, "imag": 3, "magnitude": 3.16, "magnitude_power_dB": 10.0 },
50-
{ "real": 1, "imag": -15425, "magnitude": 15425.0, "magnitude_power_dB": 83.76 }
51-
/* ... taps elided ... */
52-
],
53-
"reverse_coefficients": []
41+
"metrics": {
42+
"main_tap_energy": 4190209.0,
43+
"total_tap_energy": 4190741.0,
44+
"main_tap_ratio": 38.96
45+
},
46+
"group_delay": {
47+
"channel_width_hz": 1600000,
48+
"rolloff": 0.25,
49+
"taps_per_symbol": 1,
50+
"symbol_rate": 1280000.0,
51+
"symbol_time_us": 0.78125,
52+
"sample_period_us": 0.78125,
53+
"fft_size": 24,
54+
"delay_samples": [0.1, 0.2, 0.3],
55+
"delay_us": [0.08, 0.16, 0.23]
56+
}
5457
}
5558
/* ... other upstream channel indices elided ... */
5659
}
@@ -65,14 +68,18 @@ Each value contains decoded tap configuration and coefficient arrays.
6568

6669
## Channel-Level Fields
6770

68-
| Field | Type | Description |
69-
| ------------------------- | ------- | ----------------------------------------------------------- |
70-
| `main_tap_location` | integer | Location of the main tap (typically near the filter center) |
71-
| `forward_taps_per_symbol` | integer | Number of forward taps per symbol |
72-
| `num_forward_taps` | integer | Total forward equalizer taps |
73-
| `num_reverse_taps` | integer | Total reverse equalizer taps (often `0` for ATDMA) |
74-
| `forward_coefficients` | array | Complex tap coefficients applied in forward direction |
75-
| `reverse_coefficients` | array | Complex tap coefficients applied in reverse direction |
71+
| Field | Type | Description |
72+
| ------------------- | ------- | ----------------------------------------------------------- |
73+
| `main_tap_location` | integer | Location of the main tap (typically near the filter center) |
74+
| `taps_per_symbol` | integer | Taps per symbol from the pre-EQ header |
75+
| `num_taps` | integer | Total number of taps |
76+
| `reserved` | integer | Reserved header byte |
77+
| `header_hex` | string | Header bytes in hex |
78+
| `payload_hex` | string | Full payload hex |
79+
| `payload_preview_hex` | string | Header plus a preview window of taps in hex |
80+
| `taps` | array | Complex tap coefficients (real/imag pairs) |
81+
| `metrics` | object | ATDMA pre-equalization key metrics when available |
82+
| `group_delay` | object | Group delay results when channel bandwidth is available |
7683

7784
## Coefficient Object Fields
7885

@@ -82,10 +89,12 @@ Each value contains decoded tap configuration and coefficient arrays.
8289
| `imag` | int || Imaginary part of the coefficient |
8390
| `magnitude` | float || Magnitude of the complex tap |
8491
| `magnitude_power_dB` | float | dB | Power of the tap in dB |
92+
| `real_hex` | string || Raw 2-byte real coefficient (hex) |
93+
| `imag_hex` | string || Raw 2-byte imag coefficient (hex) |
8594

8695
## Notes
8796

8897
* Each top-level key under `data` is the DOCSIS **SNMP index** for an upstream SC-QAM (ATDMA) channel.
89-
* Forward taps pre-compensate the channel (handling pre-echo/echo paths); reverse taps are uncommon in ATDMA.
98+
* Group delay is included only when the upstream channel bandwidth is available.
9099
* Use tap shapes and main-tap offset to infer echo path delay and alignment health.
91100
* Tap coefficients are signed integers; convert to floating-point as needed for analysis.

src/pypnm/api/routes/docs/if30/us/atdma/chan/stats/service.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# SPDX-License-Identifier: Apache-2.0
2-
# Copyright (c) 2025 Maurice Garcia
2+
# Copyright (c) 2025-2026 Maurice Garcia
33

44
from __future__ import annotations
55

@@ -9,7 +9,7 @@
99
from pypnm.docsis.cable_modem import CableModem
1010
from pypnm.lib.inet import Inet
1111
from pypnm.lib.mac_address import MacAddress
12-
from pypnm.lib.types import InetAddressStr, MacAddressStr
12+
from pypnm.lib.types import BandwidthHz, InetAddressStr, MacAddressStr
1313
from pypnm.pnm.data_type.DocsEqualizerData import DocsEqualizerData
1414

1515

@@ -54,5 +54,16 @@ async def get_upstream_pre_equalizations(self) -> dict[int, dict]:
5454
List[dict]: A dictionary containing per-channel equalizer data with real, imag,
5555
magnitude, and power (dB) for each tap.
5656
"""
57-
pre_eq_data: DocsEqualizerData = await self.cm.getDocsIf3CmStatusUsEqData()
57+
entries = await self.get_upstream_entries()
58+
channel_widths: dict[int, BandwidthHz] = {}
59+
for entry in entries:
60+
index = entry.get("index")
61+
entry_data = entry.get("entry") or {}
62+
channel_width = entry_data.get("docsIfUpChannelWidth")
63+
if isinstance(index, int) and isinstance(channel_width, int) and channel_width > 0:
64+
channel_widths[index] = BandwidthHz(channel_width)
65+
66+
pre_eq_data: DocsEqualizerData = await self.cm.getDocsIf3CmStatusUsEqData(
67+
channel_widths=channel_widths
68+
)
5869
return pre_eq_data.to_dict()

src/pypnm/docsis/cm_snmp_operation.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
from pypnm.lib.inet import Inet
6868
from pypnm.lib.inet_utils import InetGenerate
6969
from pypnm.lib.mac_address import MacAddress
70-
from pypnm.lib.types import ChannelId, EntryIndex, FrequencyHz, InterfaceIndex
70+
from pypnm.lib.types import BandwidthHz, ChannelId, EntryIndex, FrequencyHz, InterfaceIndex
7171
from pypnm.lib.utils import Generate
7272
from pypnm.pnm.data_type.DocsEqualizerData import DocsEqualizerData
7373
from pypnm.pnm.data_type.DocsIf3CmSpectrumAnalysisCtrlCmd import (
@@ -2396,7 +2396,10 @@ async def setDocsPnmCmDsOfdmSymTrig(self, ofdm_idx: int, symbol_trig_file_name:
23962396
self.logger.exception(f'Exception occurred while setting OFDM Downstream Symbol Capture for index {ofdm_idx}: {e}')
23972397
return False
23982398

2399-
async def getDocsIf3CmStatusUsEqData(self) -> DocsEqualizerData:
2399+
async def getDocsIf3CmStatusUsEqData(
2400+
self,
2401+
channel_widths: dict[int, BandwidthHz] | None = None,
2402+
) -> DocsEqualizerData:
24002403
"""
24012404
Retrieve and parse DOCSIS 3.0/3.1 upstream equalizer data via Snmp_v2c.
24022405
@@ -2432,7 +2435,8 @@ async def getDocsIf3CmStatusUsEqData(self) -> DocsEqualizerData:
24322435
if not eq_bytes:
24332436
continue
24342437
self.logger.debug(f'idx: {us_idx} -> eq-data bytes: ({len(eq_bytes)})')
2435-
ded.add_from_bytes(us_idx, eq_bytes)
2438+
channel_width_hz = channel_widths.get(us_idx) if channel_widths else None
2439+
ded.add_from_bytes(us_idx, eq_bytes, channel_width_hz=channel_width_hz)
24362440

24372441
except ValueError as e:
24382442
self.logger.error(f"Failed to parse equalizer data. Error: {e}")

src/pypnm/lib/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141

4242
CableTypes: TypeAlias = Literal["RG6", "RG59", "RG11"]
4343

44+
DOCSIS_ROLL_OFF_FACTOR: Final[float] = 0.25
45+
4446
# Velocity Factor (VF) by cable type (fraction of c0)
4547
CABLE_VF: Final[dict[CableTypes, float]] = {
4648
"RG6": 0.87,
@@ -95,6 +97,7 @@ class MediaType(StringEnum):
9597
STATUS_NOK:STATUS = False
9698

9799
__all__ = [
100+
"DOCSIS_ROLL_OFF_FACTOR",
98101
"STATUS_OK", "STATUS_NOK",
99102
"DEFAULT_SSH_PORT",
100103
"HZ", "KHZ", "MHZ", "GHZ",
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright (c) 2025-2026 Maurice Garcia
3+
4+
from __future__ import annotations
5+
6+
import math
7+
from dataclasses import dataclass
8+
9+
import numpy as np
10+
from numpy.typing import NDArray
11+
from pydantic import BaseModel, Field
12+
13+
from pypnm.lib.types import (
14+
BandwidthHz,
15+
FloatSeries,
16+
Microseconds,
17+
PreEqAtdmaCoefficients,
18+
)
19+
20+
from pypnm.lib.constants import DOCSIS_ROLL_OFF_FACTOR
21+
22+
MIN_CHANNEL_WIDTH_HZ: BandwidthHz = BandwidthHz(0)
23+
MIN_TAPS_PER_SYMBOL: int = 0
24+
MIN_ROLLOFF: float = 0.0
25+
ONE: float = 1.0
26+
TWO_PI: float = 2.0 * math.pi
27+
MICROSECONDS_PER_SECOND: float = 1_000_000.0
28+
29+
30+
class GroupDelayModel(BaseModel):
31+
"""Immutable ATDMA group delay results derived from pre-equalization taps.
32+
33+
Stores derived timing and delay series used for analysis and reporting.
34+
"""
35+
36+
channel_width_hz: BandwidthHz = Field(..., description="ATDMA channel width in Hz.")
37+
rolloff: float = Field(..., description=f"RRC roll-off factor α (typical DOCSIS = {DOCSIS_ROLL_OFF_FACTOR}).")
38+
taps_per_symbol: int = Field(..., description="Taps per symbol from the pre-EQ header.")
39+
symbol_rate: float = Field(..., description="Derived symbol rate (sym/s): BW / (1 + rolloff).")
40+
symbol_time_us: Microseconds = Field(..., description="Derived symbol time in microseconds (µs): 1/symbol_rate.")
41+
sample_period_us: Microseconds = Field(..., description="Sample period in microseconds (µs): Tsym / taps_per_symbol.")
42+
fft_size: int = Field(..., description="FFT size used to evaluate the frequency response (N taps).")
43+
delay_samples: FloatSeries = Field(..., description="Group delay in samples per FFT bin (tap-period units).")
44+
delay_us: FloatSeries = Field(..., description="Group delay in microseconds per FFT bin.")
45+
model_config = {"frozen": True}
46+
47+
48+
@dataclass(frozen=True, slots=True)
49+
class GroupDelayCalculator:
50+
"""Compute ATDMA group delay from upstream pre-equalization coefficients.
51+
52+
This calculator derives **group delay** (the negative slope of the unwrapped
53+
phase response) from a 24-tap ATDMA upstream FIR equalizer. The input taps are
54+
complex coefficients (real, imag) taken from `docsIf3CmStatusUsEqData.*`
55+
after decoding to signed integers (your existing `DocsEqualizerData` class
56+
already handles endianness + 12/16-bit interpretation and yields taps).
57+
58+
Conceptually, the equalizer taps represent a discrete-time FIR filter:
59+
60+
h[n] = re[n] + j·im[n] for n = 0..N-1
61+
62+
The processing steps are:
63+
64+
1) **Time → Frequency conversion**
65+
Compute the N-point FFT to obtain the complex frequency response:
66+
67+
H[k] = FFT{ h[n] } , k = 0..N-1
68+
69+
2) **Phase extraction and unwrap**
70+
Extract the phase angle of each bin and unwrap it to remove 2π discontinuities:
71+
72+
φ[k] = unwrap(angle(H[k]))
73+
74+
3) **Group delay in samples**
75+
Group delay is defined as:
76+
77+
τ(ω) = - dφ(ω) / dω
78+
79+
With FFT bins, ω[k] = 2π·k/N. We approximate the derivative numerically,
80+
resulting in group delay measured in **tap-sample periods** (i.e., "samples").
81+
82+
4) **Convert delay from samples → microseconds**
83+
To express delay in time units, we need the tap sample period.
84+
85+
For DOCSIS ATDMA upstream channels, symbol rate is typically derived from
86+
channel width and roll-off (root-raised cosine shaping):
87+
88+
Rs = BW / (1 + α)
89+
90+
Then:
91+
92+
Tsym = 1 / Rs
93+
Tsamp = Tsym / taps_per_symbol
94+
95+
Finally:
96+
97+
delay_us[k] = delay_samples[k] · Tsamp(µs)
98+
99+
Notes and expectations:
100+
101+
- This class does **not** assume the main tap location is centered; it reports
102+
the group delay implied by the taps as provided.
103+
- The FFT size is set to **N = number of taps** by default. If you later want
104+
a smoother curve, you can zero-pad (e.g., 128/256 points) without changing
105+
the underlying physics—only the sampling density in frequency.
106+
- `taps_per_symbol` comes from the pre-EQ header byte (often 1).
107+
- `channel_width_hz` must be provided to compute absolute time units (µs).
108+
Without it, you can still compute delay in samples, but not in seconds.
109+
110+
Attributes:
111+
channel_width_hz: ATDMA upstream channel width in Hz (e.g., 1_600_000).
112+
taps_per_symbol: Tap sampling density per symbol from the pre-EQ header.
113+
Used to convert symbol time to tap-sample time.
114+
rolloff: DOCSIS shaping roll-off factor α. Typical default is 0.25.
115+
116+
Returns:
117+
A `GroupDelayModel` containing:
118+
- derived symbol rate/time and sample period
119+
- group delay arrays per FFT bin in samples and microseconds
120+
"""
121+
122+
channel_width_hz: BandwidthHz
123+
taps_per_symbol: int
124+
rolloff: float = DOCSIS_ROLL_OFF_FACTOR
125+
126+
def __post_init__(self) -> None:
127+
if int(self.channel_width_hz) <= MIN_CHANNEL_WIDTH_HZ:
128+
raise ValueError("channel_width_hz must be > 0.")
129+
if self.taps_per_symbol <= MIN_TAPS_PER_SYMBOL:
130+
raise ValueError("taps_per_symbol must be > 0.")
131+
if not math.isfinite(self.rolloff):
132+
raise ValueError("rolloff must be finite.")
133+
if self.rolloff < MIN_ROLLOFF:
134+
raise ValueError("rolloff must be >= 0.")
135+
136+
@staticmethod
137+
def _to_complex_array(coefficients: list[PreEqAtdmaCoefficients]) -> NDArray[np.complex128]:
138+
taps: NDArray[np.complex128] = np.empty(len(coefficients), dtype=np.complex128)
139+
for i, (re, im) in enumerate(coefficients):
140+
taps[i] = complex(float(re), float(im))
141+
return taps
142+
143+
def symbol_rate(self) -> float:
144+
bw = float(int(self.channel_width_hz))
145+
return bw / (ONE + self.rolloff)
146+
147+
def symbol_time_us(self) -> Microseconds:
148+
sr = self.symbol_rate()
149+
ts = ONE / sr
150+
return Microseconds(ts * MICROSECONDS_PER_SECOND)
151+
152+
def sample_period_us(self) -> Microseconds:
153+
tsym_us = float(self.symbol_time_us())
154+
return Microseconds(tsym_us / float(self.taps_per_symbol))
155+
156+
def compute(self, coefficients: list[PreEqAtdmaCoefficients]) -> GroupDelayModel:
157+
if len(coefficients) == 0:
158+
raise ValueError("coefficients cannot be empty.")
159+
160+
h_time = self._to_complex_array(coefficients)
161+
162+
n = int(h_time.shape[0])
163+
h_freq = np.fft.fft(h_time, n=n)
164+
165+
phase = np.unwrap(np.angle(h_freq))
166+
omega = TWO_PI * (np.arange(n, dtype=np.float64) / float(n))
167+
168+
dphi_domega = np.gradient(phase, omega)
169+
delay_samples = -dphi_domega
170+
171+
tsamp_us = float(self.sample_period_us())
172+
delay_us = delay_samples * tsamp_us
173+
174+
delay_samples_list: FloatSeries = [float(x) for x in delay_samples.tolist()]
175+
delay_us_list: FloatSeries = [float(x) for x in delay_us.tolist()]
176+
177+
sr = self.symbol_rate()
178+
tsym_us = float(self.symbol_time_us())
179+
tsamp = float(self.sample_period_us())
180+
181+
return GroupDelayModel(
182+
channel_width_hz = BandwidthHz(int(self.channel_width_hz)),
183+
rolloff = float(self.rolloff),
184+
taps_per_symbol = int(self.taps_per_symbol),
185+
symbol_rate = float(sr),
186+
symbol_time_us = Microseconds(tsym_us),
187+
sample_period_us = Microseconds(tsamp),
188+
fft_size = int(n),
189+
delay_samples = delay_samples_list,
190+
delay_us = delay_us_list,
191+
)

0 commit comments

Comments
 (0)