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
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
sdradivideo pipeline, I suspected the same pattern in therf_streamreceivers: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)withXindexed bysc_to_bin(k) = k mod 64, andextract_ofdm_symbolreads it back withfftshift(fft(...)). That shift pair rotates the spectrum by N/2, so array positionjcarries physical baseband frequencyj - 32, and the sorted pilot positions sit at[-25, -11, +11, +25]— strictly monotonic. The existingnp.interpover the raw position axis is already an interpolation over physical frequency.Verified rather than assumed, with the transmitter's own
create_ofdm_symboland the receiver's ownchannel_estimate_from_ltf/equalize_mmse/pilot_residual_correction/symbols_to_bits:== (label mod 64) − 32for all 52 used carriers, both methods agreeingposition − 32−2πτ/N= −0.04997)pilot_residual_correctionvs an explicit physical-frequency rewrite, 400 randomized delay/phase casespilot_weight1.0 (step9 behaviour; step10 defaults to 0.5), QPSK at τ = ±0.509 samples, current → label axisSo 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_correctionis 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, nonp.interp, nopolyfit.Two bounds worth knowing, neither an axis error: the pilots cover
|f| ≤ 25while 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 thenp.unwrapinside the correction aliases once the phase across the 22-bin gap between the−11and+11pilots 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.mdspecifiesDATA_SUBCARRIERS = 48over±[1..26]withPILOT_SUBCARRIERS = [-21, -7, 7, 21]— the 802.11a layout with a null at DC. Butsc_to_bin()produces raw DFT indices whileifftshift()expects fftshifted order, so the net rotation puts labelkon the air at physical frequency(k mod 64) − 32: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.pyalready 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 nearsc_to_binwould 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: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 tomain, 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/--kiare documented inREADME_step8_step9.mdas a coarse-CFO PI loop and are parsed intoRxConfigin step8/step9/step10, but never read by the DSP in those three receivers.rf_stream_rx_step5phy_v2.pydoes use them, so the documentation matches that earlier file rather than the newer ones.🤖 Generated with Claude Code