Skip to content

rf_stream: pin the pilot/subcarrier axis convention (no defect found), and report a band-placement surprise - #8

Open
egeboy35 wants to merge 1 commit into
lkk688:mainfrom
egeboy35:test/rf-stream-pilot-axis-convention
Open

rf_stream: pin the pilot/subcarrier axis convention (no defect found), and report a band-placement surprise#8
egeboy35 wants to merge 1 commit into
lkk688:mainfrom
egeboy35:test/rf-stream-pilot-axis-convention

Conversation

@egeboy35

Copy link
Copy Markdown

A follow-up to #5, and partly a correction of my own reasoning. It also reports one thing I did not expect to find.

After the axis fix in the sdradi video pipeline, I suspected the same pattern in the rf_stream receivers: PILOT_BINS = [43, 57, 7, 21] sorts to [7, 21, 43, 57], which read as raw DFT bins would be the signed frequencies [+7, +21, -21, -7] — non-monotonic, exactly the shape of the #5 defect. I measured it, and the receivers are correct. This PR therefore changes no DSP; it adds a test that records why they are correct, so the misdiagnosis I nearly made cannot be applied later.

Why the axis is already right here

The transmitter builds x = ifft(ifftshift(X)) * sqrt(N) with X indexed by sc_to_bin(k) = k mod 64, and extract_ofdm_symbol reads it back with fftshift(fft(...)). That shift pair rotates the spectrum by N/2, so array position j carries physical baseband frequency j - 32, and the sorted pilot positions sit at [-25, -11, +11, +25] — strictly monotonic. The existing np.interp over the raw position axis is already an interpolation over physical frequency.

Verified rather than assumed, with the transmitter's own create_ofdm_symbol and the receiver's own channel_estimate_from_ltf / equalize_mmse / pilot_residual_correction / symbols_to_bits:

Check Result
Excite one subcarrier label at a time; locate the tone by zero-padded DFT peak and by time-domain phase slope physical frequency == (label mod 64) − 32 for all 52 used carriers, both methods agreeing
Fit the residual phase of a true 0.509-sample delay against position − 32 slope −0.04997 rad/bin, max fit residual 8.4e-08 (theory −2πτ/N = −0.04997)
Same fit against the subcarrier-label axis residuals of order 1 rad — it does not describe the data
pilot_residual_correction vs an explicit physical-frequency rewrite, 400 randomized delay/phase cases max difference 0.0e+00
Same, against a subcarrier-label rewrite 1.4e+00
Noiseless SER at pilot_weight 1.0 (step9 behaviour; step10 defaults to 0.5), QPSK at τ = ±0.509 samples, current → label axis 0.0000 → 0.1250

So at fractional residuals the "obvious fix" measurably degrades the receiver. At integer residuals the two axes coincide up to a common phase that the scalar CPE stage removes, so the difference is specific to the fractional case — which is the regime that matters with independent TX and RX clocks. pilot_residual_correction is also an exact CPE corrector (0.0000 rad for a pure common-phase error), and step 8 cannot be affected at all: scalar CPE only, no np.interp, no polyfit.

Two bounds worth knowing, neither an axis error: the pilots cover |f| ≤ 25 while the payload runs to |f| = 31, so the 12 outermost carriers get a nearest-neighbour edge hold bounded by |slope| · 6 (0.300 rad at τ = 0.509); and the np.unwrap inside the correction aliases once the phase across the 22-bin gap between the −11 and +11 pilots exceeds π, i.e. beyond about ±1.45 samples of residual. Both affect either candidate axis equally.

The unexpected part: the payload is not in the documented band

The same shift convention has a consequence I did not anticipate. README_step10_multitask.md specifies DATA_SUBCARRIERS = 48 over ±[1..26] with PILOT_SUBCARRIERS = [-21, -7, 7, 21] — the 802.11a layout with a null at DC. But sc_to_bin() produces raw DFT indices while ifftshift() expects fftshifted order, so the net rotation puts label k on the air at physical frequency (k mod 64) − 32:

Subcarrier label Transmitted at
+1 −31
+7 −25
−21 +11
−26 +6

Measured two independent ways, both agreeing exactly. The occupied band is ±[6, 31] instead of ±[1, 26], with eleven empty bins around DC instead of one.

The link is unaffected — the receiver applies the inverse rotation with the same bin tables, so TX and RX are self-consistent and everything decodes; your own test_ofdm_convention.py already establishes that consistency. So this is a spectrum-placement question rather than a decoding one. Two practical consequences: the outermost carriers sit at about 97% of Nyquist, where a radio's reconstruction and decimation filters are rolling off (I have no hardware, so I have not measured what that costs on air); and subcarrier labels used in the documentation and analysis do not correspond to physical frequencies, which makes spectral reasoning about the PHY misleading. If the rotation is deliberate, a comment near sc_to_bin would save the next reader the measurement; if not, it is a one-line change on each side — but it changes the transmitted waveform, so it is your call rather than something I would send.

The test

tests/test_rf_stream_pilot_axis.py — 10 tests, under a second, no hardware, no torch:

python -m pytest tests/test_rf_stream_pilot_axis.py -v

It passes as written and also passes ruff check tests/ (the lint gate proposed in #4). In a scratch tree where I re-sorted the pilots by subcarrier label, two tests fail with explicit numbers (0.123 rad against a 1e-05 tolerance; 1.371 rad against the 0.300 rad edge-hold bound). Transmitter and receiver sources are byte-identical to main, and I checked the TX output bitwise as well: identical sha256 over 8312 samples for BPSK, QPSK and QAM16.

If you would rather not carry a test for a non-bug, feel free to close it — the parts worth keeping either way are the note that the shift convention is load-bearing, and the band-placement measurement above.

One unrelated observation while reading: --kp / --ki are documented in README_step8_step9.md as a coarse-CFO PI loop and are parsed into RxConfig in step8/step9/step10, but never read by the DSP in those three receivers. rf_stream_rx_step5phy_v2.py does use them, so the documentation matches that earlier file rather than the newer ones.

🤖 Generated with Claude Code

The pilot residual correction in rf_stream_rx_step10phy.py and
rf_stream_rx_step9phy.py was reported to interpolate over a non-monotonic
frequency axis -- the defect fixed for sdradi/sdr_video_commv2.py in PR lkk688#5.
Measurement does not support that here, so this commit changes no DSP. It does
record one thing the measurements turned up that is worth knowing: the
transmitted spectrum is not placed where the documentation describes.

Why the axis is already correct. PILOT_BINS is [43, 57, 7, 21] and sorting it
gives [7, 21, 43, 57], which read as raw DFT bins would be the signed
frequencies [+7, +21, -21, -7]. But the transmitter builds
x = ifft(ifftshift(X)) * sqrt(N) with X indexed by sc_to_bin(k) = k mod 64, and
extract_ofdm_symbol reads it back with fftshift(fft(...)). The shift pair
rotates the spectrum by N/2, so array position j carries physical baseband
frequency j - 32, and the sorted pilot positions sit at [-25, -11, +11, +25]:
strictly monotonic. The existing np.interp over the raw position axis is
therefore already an interpolation over physical frequency.

Measured with the transmitter's own create_ofdm_symbol and the receiver's own
channel_estimate_from_ltf / equalize_mmse / pilot_residual_correction /
symbols_to_bits (deterministic, seeded, no hardware):

  Exciting one subcarrier label at a time and locating the tone two ways (a
  zero-padded DFT peak and a time-domain phase-slope fit): physical frequency
  == (label mod 64) - 32 for all 52 used carriers, both methods agreeing.

  Residual phase of a true 0.509-sample bandlimited delay, fitted against the
  physical axis (position - 32): slope -0.04997 rad/bin, max fit residual
  8.4e-08, against a theoretical -2*pi*tau/N = -0.04997 rad/bin. The same fit
  against the subcarrier-label axis leaves residuals of order 1 rad, i.e. it
  does not describe the data.

  pilot_residual_correction output vs an explicit physical-frequency rewrite,
  400 randomized delay/phase cases: max difference 0.0e+00. Same comparison
  against a subcarrier-label rewrite: 1.4e+00.

  Residual phase error left by the current code under a pure timing residual:
  0.0000 rad for every subcarrier inside the pilot span (|f| <= 25), for |tau|
  below about 1.45 samples. Beyond that the np.unwrap in the correction aliases
  across the 22-bin gap between the -11 and +11 pilots (1.43 rad at tau = 1.5),
  which affects both candidate axes equally. Inside that range the only
  remaining error is the nearest-neighbour edge hold on the 12 subcarriers at
  |f| = 26..31, bounded by |slope| * 6 (0.300 rad at tau = 0.509 samples). Pure
  common-phase error is corrected to 0.0000 rad.

  Noiseless symbol-error rate at pilot_weight 1.0 (step9 behaviour; step10
  defaults to 0.5), current code vs the proposed label axis:
    QPSK,  tau = +-0.509 samples:  0.0000  ->  0.1250
    QAM16, tau = -0.509 samples:   ~0.01   ->  ~0.19
    pure CPE 0.40 rad, both mods:  0.0000  ->  0.0000
  At integer residuals the two axes coincide up to a common phase that the
  scalar CPE stage removes, so the difference is specific to fractional
  residuals -- which is the regime that matters with independent clocks.

Step 8 is unaffected by construction: it applies a scalar common-phase
correction only and contains no np.interp or polyfit. No rf_stream receiver has
a pilot-phase-slope delay estimator, so PR lkk688#5's second defect site has no
analogue here.

Separately surfaced, and the reason for the extra test: the same shift
convention places the payload somewhere other than the documented band.
README_step10_multitask.md specifies DATA_SUBCARRIERS = 48 over +-[1..26] with
pilots at [-21, -7, 7, 21] -- the 802.11a layout, null at DC. Because
sc_to_bin() produces raw DFT indices while ifftshift() expects fftshifted
order, label k is transmitted at physical frequency (k mod 64) - 32: label +1
goes out at -31 and label -21 at +11. The occupied band is +-[6, 31] rather
than +-[1, 26], with eleven empty bins around DC instead of one. The link is
unaffected because the receiver applies the inverse rotation, so this is a
placement question rather than a decoding one, but the outermost carriers end
up at about 97% of Nyquist and the subcarrier labels used throughout the docs
do not correspond to physical frequencies.

tests/test_rf_stream_pilot_axis.py pins all of this down: 10 tests, under a
second, no hardware and no torch, ruff clean. It passes on this tree and fails
when the pilots are re-sorted by subcarrier label
(test_correction_is_exact_inside_the_pilot_span 0.123 rad against a 1e-05
tolerance; test_only_the_outermost_subcarriers_are_extrapolated 1.371 rad
against the 0.300 rad edge-hold bound), so the misdiagnosis cannot be applied
silently. Receiver and transmitter sources are byte-identical to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant