Skip to content

Fix OFDM RX pilot interpolation and delay estimation axis - #5

Open
egeboy35 wants to merge 1 commit into
lkk688:mainfrom
egeboy35:fix/ofdm-pilot-axis
Open

Fix OFDM RX pilot interpolation and delay estimation axis#5
egeboy35 wants to merge 1 commit into
lkk688:mainfrom
egeboy35:fix/ofdm-pilot-axis

Conversation

@egeboy35

Copy link
Copy Markdown

Hi Prof. Liu — I've been studying this repository closely; having simulation and real SDR workflows in one place is rare and it has been a great learning resource. While reading the OFDM receiver I found a coordinate-axis issue that I think is worth fixing, and I was able to demonstrate and fix it entirely in simulation using the pipeline's own classes.

Problem

The OFDM receivers place their four pilots at raw FFT bins [1, 14, 38, 51]. In signed baseband frequency those are [+1, +14, -26, -13]not monotonic. Two receiver stages assume a monotonic frequency axis:

  1. demodulate() interpolates the per-symbol pilot channel estimate over the raw index axis, so it interpolates across the Nyquist guard band, blending the +14 pilot with the -26 pilot. About a quarter of the data subcarriers end up with a channel estimate built from opposite band edges.
  2. estimate_delay() fits the pilot phase slope over the same axis. For a fractional timing residual τ it returns roughly −0.52·τ — wrong sign and about half the magnitude, so the fine-time sync loop is steered by a meaningless estimate.

The effect is invisible in clean loopback tests: with integer-only timing and CP-circular channels the raw-index axis happens to be a valid coordinate. It shows up as soon as there is a fractional residual, which is the normal situation with independent TX and RX clocks.

Measured (pipeline's own modulator/demodulator, seeded, noiseless unless noted)

Before After
estimate_delay at a true +0.5-sample delay −0.272 (wrong sign) +0.509
QPSK BER at a 0.5-sample residual 6.6e-2 (noise-independent floor) ~0
QPSK BER at 2.0 samples, with the existing fine-sync loop 0.229 (offset left uncorrected) 0.000
Ideal timing, flat channel, AWGN only identical identical

Changes

  • sdradi/sdr_video_commv2.py, sdr_video_commv2_lab.py, sdr_video_commv3.py: _setup_carriers() now also derives signed-frequency coordinate tables (carrier_freqs, pilot_freqs, pilot_freq_order); the channel interpolation and the delay polyfit use them, while the equalizer keeps its raw-bin indexing. sdr_video_commv3.py keeps its virtual edge pilots, placed at the signed-frequency band edges.
  • Receiver-side only and wire-compatible: I verified modulate() output is byte-identical to the current code for both QPSK and 16QAM, so transmitters and existing captures are unaffected.
  • tests/test_pilot_geometry.py: three regression tests that exercise the actual pipeline classes. They fail on the current code (estimator −0.271, BER floor 0.0661) and pass with this change, in under a second, with no hardware and no torch required.

How to reproduce

python -m pytest tests/test_pilot_geometry.py -v

One deliberate scope note: 16QAM keeps a small residual BER floor at half-sample residuals, because the shipped pilot positions do not bracket the band edges. Moving pilots to the (-21, -7, 7, 21) plan already declared in OFDMConfig would remove that, but it changes the transmitted signal, so I left it out of a receiver-only change. The OTFS path in the same files has the analogous raw-index interpolation pattern — also untouched here; happy to look at either as a follow-up if you'd find it useful.

A fuller simulation study behind this (impairment sweeps, CSVs and figures, ~100k bits per point) is on a separate branch so it doesn't add noise here: https://github.com/egeboy35/AIsensing/tree/research/pilot-geometry

🤖 Generated with Claude Code

The OFDM receivers place their four pilots at raw FFT bins [1, 14, 38, 51].
In signed baseband frequency those are [+1, +14, -26, -13], which is not
monotonic, and two receiver stages depend on a monotonic frequency axis:

- demodulate() interpolates the per-symbol pilot channel estimate over the
  raw index axis, so it interpolates across the Nyquist guard band and
  blends the +14 pilot with the -26 pilot. About a quarter of the data
  subcarriers get a channel estimate built from opposite band edges.
- estimate_delay() fits the pilot phase slope over the same axis, so for a
  fractional timing residual tau it returns roughly -0.52 * tau: wrong sign
  and about half the magnitude.

Measured with the pipeline's own modulator/demodulator (deterministic,
seeded, noiseless unless stated):

  estimate_delay at a true +0.5-sample delay:  -0.272  ->  +0.509
  QPSK BER at a 0.5-sample residual:           6.6e-2  ->  ~0
  QPSK BER at 2.0 samples, with the fine-sync
  loop of the existing code path:               0.229  ->  0.000
  Ideal timing and flat channel (AWGN only):   unchanged

_setup_carriers() now also derives signed-frequency coordinate tables
(carrier_freqs, pilot_freqs, pilot_freq_order); the channel interpolation
and the delay polyfit use them, and the equalizer keeps its raw-bin
indexing. The change is receiver-side only: modulate() output is
byte-identical to before for both QPSK and 16QAM, so transmitters and
existing captures are unaffected. sdr_video_commv3.py keeps its virtual
edge pilots, placed at the signed-frequency band edges.

tests/test_pilot_geometry.py exercises the actual pipeline classes and
fails on the previous code (estimator -0.271, BER floor 0.0661) while
passing with this change, in under a second, with no hardware and no torch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@egeboy35
egeboy35 force-pushed the fix/ofdm-pilot-axis branch from 9dc5bf5 to 2cbf311 Compare August 19, 2026 12:09
@egeboy35

Copy link
Copy Markdown
Author

I re-audited my own numbers here before sending anything else, and five statements in the description above need correcting. Sorry for the noise — I would rather flag them myself than leave them for you to trip over. The code change and the fix's effect are unchanged; these are all claims about it. I have also pushed one small commit update (details at the end).

1. "The effect is invisible in clean loopback tests: with integer-only timing and CP-circular channels the raw-index axis happens to be a valid coordinate." This is wrong, and it is the sentence most likely to be tested. The raw-index axis is a valid coordinate only at exactly zero offset, not at integer offsets. Measured on the pre-fix code with integer, CP-circular delays: BER 0.042 / 0.229 / 0.573 at offsets of 1 / 2 / 3 samples. Two separate mechanisms break it there: 12 of the 48 data carriers (raw bins 52..63) lie beyond the last raw-index pilot and get clamped, and np.unwrap aliases across the 24-bin raw-index gap. What I should have written: the defect is invisible in the AWGN loopback path, where coarse sync leaves no residual and the channel is flat — at exactly zero offset the two axes agree.

2. The estimator coefficient is −0.54, not −0.52. For this pilot set the closed form is exactly −830/1538 = −0.5397, so τ = +0.5 gives −0.271 — which is what the table and the shipped test actually print. The "−0.52·τ" in the problem statement was a rounding of an earlier estimate and disagrees with my own numbers.

3. Table row 1 says −0.272; the correct value is −0.271 (−0.2715 to four places). The prose and the test output both say −0.271.

4. "About a quarter of the data subcarriers" understates it. In signed frequency, 23 of the 48 data carriers — essentially half — get a channel estimate that is either interpolated across the guard band or clamped beyond the outermost pilot.

5. The 16QAM remedy claim is only half true. I wrote that moving pilots to the (-21, -7, 7, 21) plan declared in OFDMConfig "would remove" the residual floor. Measured, it shrinks it by roughly 40× (16QAM BER at a half-sample residual 3.1e-2 → 7.6e-4) but does not eliminate it, because that plan does not bracket the band either: carriers at |f| = 22..26 are still outside the pilot span. My own stated reason predicted this, so the sentence contradicted itself.

On the OTFS note at the end of the description. I said the OTFS path "has the analogous raw-index interpolation pattern". The pattern is there, but I have since found it is unreachable: OTFSTransceiver.demodulate() raises AttributeError: 'OTFSConfig' object has no attribute 'pilot_carriers' before reaching it, because OTFSConfig declares only N_doppler, N_delay, mod_order. So the real state is a hard failure rather than a subtle axis bug, in four files (sdr_video_commv2.py, sdr_video_commv2_lab.py, sdr_video_commv3.py and the newsdr copy is untracked so I cannot check it). I am still measuring that one properly and will report it separately rather than half-report it here.

Two things I should have mentioned in the original description:

  • newsdr/ is untracked (.gitignore:188) and holds a second copy of sdr_video_commv2_lab.py. This PR patches the tracked sdradi/ copies only, so if the lab copy is the one you actually run, it would keep the old behaviour and quietly diverge.
  • At integer residuals of exactly ±1 sample the fix is marginally worse in isolation (BER 0.042 → 0.052) because the two geometries clamp different carriers; with the fine-sync loop in the loop, which is how the receiver runs, the fix wins at every offset I measured.

Commit update. I force-pushed one amendment: removed two dead # noqa: E402 directives from tests/test_pilot_geometry.py and corrected the same −0.52 / −0.26 figures in its docstring. The reason for the noqa removal is that the CI workflow I proposed in #4 runs ruff check tests/, and those two directives were the only thing making this branch fail that gate — merging #4 and #5 in either order would otherwise have shown a red check on your side through no fault of the code. No test logic changed; still 3 passed.

🤖 Generated with Claude Code

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