Skip to content

Commit b44e861

Browse files
committed
Harden OFDM CFO path and add deterministic CFO verification tools
1 parent fb86bec commit b44e861

14 files changed

Lines changed: 1021 additions & 69 deletions

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,23 @@ cd build
207207
ctest
208208
```
209209

210+
**CFO Verification (Internal Pre/Post Correction Chain):**
211+
212+
```bash
213+
# From repository root (one-command gate)
214+
./tests/verify_cfo_chain.sh --cfo 50 --channel awgn --snr 20 --seed 42
215+
216+
# Optional: capture raw TX/RX audio for offline analysis
217+
./build/cli_simulator --snr 20 --channel awgn --waveform ofdm_chirp \
218+
--mod dqpsk --rate r1_2 --tx-cfo 50 \
219+
--save-signals 1 --save-prefix /tmp/cfo_probe --test
220+
```
221+
222+
Useful `cli_simulator` flags for CFO diagnostics:
223+
- `--tx-cfo` (alias `--cfo`) injects TX CFO in Hz across the full transmitted signal.
224+
- `--save-signals [N]` writes raw TX/RX captures for up to `N` messages.
225+
- `--save-prefix <path>` and `--save-max-samples <N>` control capture naming and size.
226+
210227
**Manual Modulation Selection:**
211228

212229
The `--mod` flag allows testing specific modulations:

docs/CFO_CORRECTION_FLOW.md

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
# CFO Correction Flow - Complete Reference
22

3-
This document describes the complete CFO (Carrier Frequency Offset) correction flow, including the fading channel correction and feedback loop added 2026-02-03.
3+
This document describes the complete CFO (Carrier Frequency Offset) correction flow, including the fading-channel correction loop and the 2026-02-12 verification tooling updates.
44

55
**Status:** Working and verified (100% CW on AWGN, 100% CW on good fading, ~83% on moderate)
66

77
---
88

99
## Overview
1010

11-
CFO correction has THREE stages:
11+
CFO correction has THREE runtime stages:
1212
1. **Chirp-based coarse estimation** — during handshake (dual chirp preamble)
1313
2. **LTS-based residual correction** — per frame (training symbol phase comparison)
1414
3. **Pilot-based tracking** — per symbol (pilot carrier phase differences)
@@ -181,6 +181,61 @@ Frame N+1 processing:
181181

182182
---
183183

184+
## Stage 5: Simulator TX CFO Injection and Internal Chain Proof
185+
186+
The simulator now supports deterministic CFO stress testing and direct internal validation of the correction path.
187+
188+
### TX CFO injection (simulator)
189+
190+
**File:** `tools/cli_simulator.cpp`
191+
192+
- `--tx-cfo <Hz>` (alias `--cfo`) injects a transmitter CFO shift in the simulator path.
193+
- Injection is applied with analytic-signal single-sideband shifting and per-direction phase continuity.
194+
- This avoids image artifacts and preserves realistic stream behavior for long transfers.
195+
196+
Example:
197+
198+
```bash
199+
./build/cli_simulator --snr 20 --channel awgn --waveform ofdm_chirp \
200+
--mod dqpsk --rate r1_2 --tx-cfo 50 --seed 42 --test
201+
```
202+
203+
### Internal pre/post correction dumps
204+
205+
**File:** `src/ofdm/channel_equalizer.cpp` (inside `toBaseband()`)
206+
207+
Set environment variables before running simulator:
208+
209+
```bash
210+
ULTRA_DUMP_CFO_PREFIX=/tmp/cfo_chain_dump
211+
ULTRA_DUMP_CFO_CALLS=6
212+
```
213+
214+
For each dump index `i`, the demod path writes:
215+
- `<prefix>_<i>_pre.cf32` (after mixer/downconversion, before CFO correction)
216+
- `<prefix>_<i>_post.cf32` (after CFO correction)
217+
- `<prefix>_<i>_meta.txt` (sample rate, CFO, phase info)
218+
219+
### One-command verification harness
220+
221+
**Files:**
222+
- `tests/verify_cfo_chain.sh`
223+
- `tools/verify_cfo_chain_dump.py`
224+
225+
Run:
226+
227+
```bash
228+
./tests/verify_cfo_chain.sh --cfo 50 --channel awgn --snr 20 --seed 42
229+
```
230+
231+
The Python verifier estimates applied correction from:
232+
- `post * conj(pre)` phase slope
233+
234+
Expected result:
235+
- Applied correction near `-expected_cfo` (within tolerance), for all dumped frames.
236+
237+
---
238+
184239
## StreamingDecoder CFO Drift Limiting
185240

186241
**File:** `src/gui/modem/streaming_decoder.cpp` (lines 425-440)
@@ -273,6 +328,9 @@ diff = eq_data × conj(ref) = TX_data × e^{-jφ} × conj(e^{-jφ}) = TX_data
273328
| `src/waveform/ofdm_chirp_waveform.cpp` | `process()` CFO feedback, initial phase calculation |
274329
| `src/gui/modem/streaming_decoder.cpp` | `last_cfo_` caching, drift limiting, feedback update |
275330
| `src/ofdm/ofdm_sync.cpp` | `estimateCFOFromTraining()` (fallback when no chirp) |
331+
| `tools/cli_simulator.cpp` | TX CFO injection (`--tx-cfo`) and signal capture flags |
332+
| `tests/verify_cfo_chain.sh` | End-to-end CFO verification gate command |
333+
| `tools/verify_cfo_chain_dump.py` | Numeric verification of internal pre/post CFO correction |
276334
277335
---
278336
@@ -284,6 +342,7 @@ diff = eq_data × conj(ref) = TX_data × e^{-jφ} × conj(e^{-jφ}) = TX_data
284342
4. **Feedback loop must update cached CFO** — After demodulation, `cfo_hz_` and `last_cfo_` in the waveform AND `last_cfo_` in StreamingDecoder must be updated with the pilot-corrected value.
285343
5. **Drift limiting threshold: 1 Hz** — Reject chirp CFO measurements that differ by >1 Hz from cached value when connected. Real oscillator drift is slow.
286344
6. **Re-process training after CFO correction** — If LTS residual correction changes `freq_offset_hz`, the training symbols must be re-processed to get a clean channel estimate.
345+
7. **Pilot-CFO feedback is OFDM-only** — Do not apply OFDM pilot-based CFO updates to MC-DPSK frames.
287346
288347
---
289348
@@ -301,4 +360,5 @@ diff = eq_data × conj(ref) = TX_data × e^{-jφ} × conj(e^{-jφ}) = TX_data
301360
302361
*Document created: 2026-01-26*
303362
*Major update: 2026-02-03 — Added fading CFO correction, LTS residual estimation, feedback loop*
363+
*Major update: 2026-02-12 — Added simulator TX CFO injection, internal pre/post dump hooks, and one-command verification harness*
304364
*Verified: 100% CW on good fading (3/3 runs, 120/120 CWs), 100% on AWGN*

docs/CFO_PHASE_HARDENING_PLAN.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# CFO Phase Hardening Plan (Pre-Demod Correction Path)
2+
3+
Date: 2026-02-12
4+
Status: Completed (Phase 1, Phase 2, and Phase 3 implemented and validated)
5+
Owner: DSP/Modem path
6+
7+
## Objective
8+
9+
Harden CFO correction for connected OFDM flows so chirp/LTS-derived CFO is applied with the correct phase reference before demodulation, and maintain consistency when residual CFO triggers LTS reprocessing.
10+
11+
Primary target: reduce sync-induced decode failures and control-path ACK loss driven by CFO/phase mismatch on fading channels.
12+
13+
## Current Findings (Code Reality)
14+
15+
1. CFO is already corrected before demod in OFDM paths:
16+
- `OFDMChirpWaveform::process()` calls `setFrequencyOffsetWithPhase()` before `processPresynced()`.
17+
- `OFDMDemodulator::Impl::toBaseband()` applies per-sample phase rotation using `freq_offset_hz` and `freq_correction_phase`.
18+
19+
2. The absolute training-position hook exists but is not wired:
20+
- `IWaveform::setAbsoluteTrainingPosition(size_t)` exists.
21+
- `StreamingDecoder` never calls it.
22+
- OFDM waveforms currently compute initial phase from buffer-relative training offsets.
23+
24+
3. Residual CFO reprocess path resets phase origin to zero:
25+
- In `estimateChannelFromLTS()`, if residual CFO is detected, code reprocesses training after `mixer.reset(); freq_correction_phase = 0.0f;`
26+
- This can lose the initial phase baseline for the current frame.
27+
28+
## Implementation Plan
29+
30+
### Phase 1: Absolute Training Position Plumbing (Implement now)
31+
32+
1. `StreamingDecoder`:
33+
- On successful sync, compute absolute sample index for `sync_position_`.
34+
- Call `waveform_->setAbsoluteTrainingPosition(abs_sync_training_start)`.
35+
- Keep this per-frame and independent from ring-buffer wrap.
36+
37+
2. `OFDMChirpWaveform` and `OFDMNvisWaveform`:
38+
- Override `setAbsoluteTrainingPosition(size_t)`.
39+
- Store absolute training start for phase initialization.
40+
- In `process()`, use absolute training sample for initial CFO phase.
41+
- Fallback to legacy relative offset only if absolute position is not provided.
42+
43+
Acceptance:
44+
- Build succeeds.
45+
- Logs show absolute phase reference being used in OFDM process path.
46+
Implementation note (2026-02-12):
47+
- Done. `StreamingDecoder` now maps ring index -> absolute sample index at sync and calls `waveform_->setAbsoluteTrainingPosition(...)`.
48+
- Done. `OFDMChirpWaveform` and `OFDMNvisWaveform` now consume absolute training position for initial CFO phase.
49+
50+
### Phase 2: Residual CFO Reprocess Phase Baseline (Implement now)
51+
52+
1. `channel_equalizer.cpp`:
53+
- Capture `freq_correction_phase` at training-start before first LTS pass.
54+
- If residual CFO correction triggers reprocess, reset mixer and restore that captured phase baseline (not zero).
55+
56+
Acceptance:
57+
- Build succeeds.
58+
- Residual CFO correction path preserves phase continuity assumptions.
59+
Implementation note (2026-02-12):
60+
- Done. LTS residual CFO reprocess now restores the captured training-start phase baseline instead of forcing phase=0.
61+
62+
### Phase 3: Connected LTS Sync Metric Upgrade (Next step)
63+
64+
1. Replace/augment current real-only training autocorrelation in `detectDataSync()` with CFO-aware metric (complex/phase-compensated), rather than cosine-only normalization.
65+
2. Validate detection robustness under ±20..30 Hz CFO and fading.
66+
67+
Acceptance:
68+
- Lower sync reject streaks on fading + nonzero CFO.
69+
- No regression in clean AWGN runs.
70+
Implementation note (2026-02-12):
71+
- Done. `OFDMChirpWaveform::detectDataSync()` now uses Hilbert/analytic complex correlation for LTS detection instead of real-only autocorrelation.
72+
- Done. Burst-marker sign detection now uses CFO-phase-compensated complex correlation (`best_p * exp(-j*phi_cfo)`), improving robustness when CFO is non-zero.
73+
- Quick sanity validation (5 seeds, SNR=20, good fading, R2/3): 5/5 PASS.
74+
75+
## Validation Matrix (Post-Implementation)
76+
77+
1. Simulator (connected OFDM-CHIRP):
78+
- SNR 20, fading good/moderate, rate DQPSK R2/3, seeds 30.
79+
- Add nonzero CFO scenario (fixed and random, if exposed in harness).
80+
81+
2. Metrics to compare:
82+
- First-attempt frame success.
83+
- ACK reception success.
84+
- Retransmissions/timeouts.
85+
- Sync reject streak count.
86+
- Logged CFO evolution (chirp -> residual -> pilot corrected).
87+
88+
3. OTA sanity check:
89+
- Confirm `[MODE]` / logs do not show unstable CFO jumps frame-to-frame under stable channel.
90+
91+
## Risks / Notes
92+
93+
1. Do not apply a second external pre-rotation in front of OFDM demod unless carefully gated; OFDM demod already performs CFO correction internally.
94+
2. MC-DPSK path is separate and should not be changed by this phase plan.
95+
3. Keep behavior backward-compatible for paths that do not provide absolute training position.
96+
97+
## Completion Evidence (2026-02-12)
98+
99+
1. Deterministic chain verification:
100+
- `./tests/verify_cfo_chain.sh --cfo 50 --channel awgn --snr 20 --seed 42`
101+
- Result: pass, with applied correction matching expected `-CFO` within tolerance across all dumps.
102+
2. Regression sanity:
103+
- `./build/cli_simulator --snr 20 --fading good --rate r1_2 --seed 42 --test`
104+
- Result: pass (no regression from hardening changes).
105+
3. Internal proof path:
106+
- `ULTRA_DUMP_CFO_PREFIX` + `ULTRA_DUMP_CFO_CALLS` dump pre/post corrected samples from `toBaseband()` for direct offline validation.

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This folder now separates **active, authoritative docs** from **historical refer
1717
- `docs/GUI_ARCHITECTURE.md`: GUI structure and components.
1818
- `docs/INVARIANTS.md`: Critical engineering invariants.
1919
- `docs/CFO_CORRECTION_FLOW.md`: CFO handling pipeline details.
20+
- `docs/CFO_PHASE_HARDENING_PLAN.md`: CFO hardening implementation and validation notes.
2021
- `docs/ADDING_NEW_WAVEFORM.md`: Guide for adding waveform implementations.
2122
- `docs/RESEARCH_DIRECTIONS.md`: Long-term R&D ideas (non-blocking).
2223

src/gui/modem/streaming_decoder.cpp

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,22 @@ void StreamingDecoder::searchForSync() {
501501

502502
sync_position_ = (search_start + sync_result.start_sample) % MAX_BUFFER_SAMPLES;
503503

504+
// Convert ring-buffer index to absolute sample index.
505+
// Needed so waveform CFO phase starts from true stream time, not local window offset.
506+
auto ringPosToAbsolute = [this](size_t ring_pos) -> size_t {
507+
if (total_fed_ < MAX_BUFFER_SAMPLES) {
508+
// Buffer has not wrapped yet: ring index == absolute sample index.
509+
return ring_pos;
510+
}
511+
512+
const size_t oldest_abs = total_fed_ - MAX_BUFFER_SAMPLES;
513+
const size_t oldest_pos = write_pos_; // write_pos_ points to oldest sample after wrap
514+
const size_t offset = (ring_pos >= oldest_pos)
515+
? (ring_pos - oldest_pos)
516+
: (MAX_BUFFER_SAMPLES - oldest_pos + ring_pos);
517+
return oldest_abs + offset;
518+
};
519+
504520
// Anti-replay: reject sync at same position as last decoded frame (circular distance)
505521
if (last_decoded_sync_pos_ != SIZE_MAX) {
506522
size_t d1 = (sync_position_ >= last_decoded_sync_pos_)
@@ -516,6 +532,12 @@ void StreamingDecoder::searchForSync() {
516532
}
517533
}
518534

535+
// Provide absolute training position to waveform so initial CFO phase is aligned.
536+
if (waveform_) {
537+
const size_t abs_training_pos = ringPosToAbsolute(sync_position_);
538+
waveform_->setAbsoluteTrainingPosition(abs_training_pos);
539+
}
540+
519541
// CFO handling: On fading channels, chirp-based CFO measurement can be corrupted
520542
// by multipath (peaks shift differently for up vs down chirp).
521543
// When connected, trust the established CFO and limit drift.
@@ -939,26 +961,29 @@ void StreamingDecoder::decodeCurrentFrame() {
939961
return; // processBuffer() will call accumulateBurstFrames() on next iteration
940962
}
941963

942-
// Feed back pilot-corrected CFO to cached value
943-
float corrected_cfo = waveform_->estimatedCFO();
944-
float current_cfo = last_cfo_.load();
964+
// Feed back pilot-corrected CFO to cached value (OFDM only).
965+
// MC-DPSK does not have pilot-based tracking; keep chirp-derived CFO.
966+
if (is_ofdm) {
967+
float corrected_cfo = waveform_->estimatedCFO();
968+
float current_cfo = last_cfo_.load();
945969

946-
if (connected_) {
947-
constexpr float MAX_PILOT_CFO_DRIFT_HZ = 2.0f;
948-
float drift = corrected_cfo - current_cfo;
949-
if (std::abs(drift) > MAX_PILOT_CFO_DRIFT_HZ) {
950-
LOG_MODEM(WARN, "[%s] Pilot CFO drift clamped: %.2f → %.2f Hz (drift=%.2f, max=%.1f)",
951-
log_prefix_.c_str(), current_cfo, corrected_cfo, drift, MAX_PILOT_CFO_DRIFT_HZ);
952-
corrected_cfo = current_cfo + std::copysign(MAX_PILOT_CFO_DRIFT_HZ, drift);
970+
if (connected_) {
971+
constexpr float MAX_PILOT_CFO_DRIFT_HZ = 2.0f;
972+
float drift = corrected_cfo - current_cfo;
973+
if (std::abs(drift) > MAX_PILOT_CFO_DRIFT_HZ) {
974+
LOG_MODEM(WARN, "[%s] Pilot CFO drift clamped: %.2f → %.2f Hz (drift=%.2f, max=%.1f)",
975+
log_prefix_.c_str(), current_cfo, corrected_cfo, drift, MAX_PILOT_CFO_DRIFT_HZ);
976+
corrected_cfo = current_cfo + std::copysign(MAX_PILOT_CFO_DRIFT_HZ, drift);
977+
}
953978
}
954-
}
955979

956-
if (std::abs(corrected_cfo - current_cfo) > 0.1f) {
957-
LOG_MODEM(INFO, "[%s] CFO updated: %.2f → %.2f Hz (pilot-corrected)",
958-
log_prefix_.c_str(), current_cfo, corrected_cfo);
980+
if (std::abs(corrected_cfo - current_cfo) > 0.1f) {
981+
LOG_MODEM(INFO, "[%s] CFO updated: %.2f → %.2f Hz (pilot-corrected)",
982+
log_prefix_.c_str(), current_cfo, corrected_cfo);
983+
}
984+
last_cfo_.store(corrected_cfo);
985+
sync_cfo_ = corrected_cfo;
959986
}
960-
last_cfo_.store(corrected_cfo);
961-
sync_cfo_ = corrected_cfo;
962987

963988
constexpr size_t LDPC_BLOCK = v2::LDPC_CODEWORD_BITS;
964989
CodeRate rate = connected_ ? code_rate_ : CodeRate::R1_4;

0 commit comments

Comments
 (0)