Skip to content

XTAL crystal-cap CFO lever + closed-loop CFO tracking (#217)#222

Open
josephnef wants to merge 7 commits into
masterfrom
xtal-cap-cfo-trim
Open

XTAL crystal-cap CFO lever + closed-loop CFO tracking (#217)#222
josephnef wants to merge 7 commits into
masterfrom
xtal-cap-cfo-trim

Conversation

@josephnef

@josephnef josephnef commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

Closes #217 — and the closed-loop follow-on the issue scoped as optional. Two layers:

  1. Crystal load-capacitance trim (SetXtalCap): the manual lever that pulls the chip's reference oscillator a few ppm, so a marginal TX/RX crystal pair can be aligned. The payoff is 5 MHz at 5 GHz, the one mode whose CFO budget is tight enough that per-adapter crystal offset matters.
  2. Closed-loop CFO tracking (DEVOURER_CFO_TRACK): the receiver reads the per-frame CFO tail from the OFDM phy-status and a bang-bang controller (src/CfoTracker.h, ported from phydm phydm_cfo_tracking) steps the crystal cap on a ~2 s cadence to drive the offset toward zero — tracking the warm-up drift a one-shot trim can't. Wired and hardware-validated on all three generations.

API

  • IRtlDevice::SetXtalCap(cap) / GetXtalCap() — raw trim code, cap < 0 reverts to the efuse default. Applied at end of bring-up, sticky (an AFE register the RF retune never touches).
  • GetAdapterCaps().xtal_cap_max / xtal_cap_default + the adapter.caps event fields.
  • DEVOURER_XTAL_CAP=0xNN (manual trim) / DEVOURER_CFO_TRACK=1 (closed loop) env.
  • DEVOURER_XTAL_STEP="c1,c2,.." — live cap-stepping in the CW hold (the trim primitive the loop drives).

Per-generation register (from hal_set_crystal_cap / phydm_set_crystal_cap_reg):

Gen Trim register Width CFO-tail phy-status offset
Jaguar1 8812/8811/8814A/8821A 0x2C (per-die field) 6-bit _phy_status_rpt_8812.cfotail[0] (DW2 byte 1)
Jaguar2 8822B/8821C 0x24[30:25] + 0x28[6:1] 6-bit jgr2 type1 DW5
Jaguar3 8822C/E 0x1040[23:10] 7-bit jgr3 type1 DW5

Bench validation

Trim authority (tests/xtal_cfo_sweep.sh sdr) — CW tone, cap stepped live on one warmed c812, B210 peak-freq per step (differential across cold bring-ups is useless: warm-up drift ~3 ppm swamps it):

cap LO offset (Hz)
0x10 12042
0x20 12422
0x30 13093
0x40 14003
0x50 15317

Smooth monotonic ~51 Hz/code at 5.2 GHz → ~0.6 ppm mid-range, ~1.25 ppm full range — the right order for a marginal 5 MHz/5 GHz pair (1–2 ppm). 20 MHz stays locked under an aggressive trim (12400 hits); ctest 13/13.

Closed loop — validated engaging on real links across all three generations. Jaguar1 two-adapter demo (tests/jaguar1_cfo_convergence.sh, 8812AU TX ↔ 8814AU RX, 10 MHz/5 GHz): RX decodes the narrowband beacon (50k hits), the loop crosses the deadband on the pair's inter-crystal offset, auto-detects the cap→LO polarity (flips after a step grows |CFO|), and walks toward the CFO-reducing cap. tests/jaguar1_cfo_track_smoke.sh confirms the tick holds correctly against calibrated ambient APs.

Honest scope

Only 5 MHz/5 GHz needs this; every other mode's CFO budget is wide enough that the factory efuse default works for any pair. The closed loop's limits, documented in docs/narrowband.md: it needs a marginally-decoding link to bootstrap (no frames → no error signal), runs on the receiver, and where a pair's absolute offset exceeds the ~1.25 ppm crystal-cap authority it converges to the rail (the best it can do) rather than the deadband. The cfo_tail→kHz scale is the coarse vendor macro — the loop uses the CFO sign + deadband, not a calibrated kHz.

The one item from the issue's validation plan not literally reproduced is the specific 8812CU↔8811CU "bimodal → stable" cell — that bimodality is intermittent/thermal and would not reproduce on demand; the mechanism was validated instead by the measured LO authority above and the loop demonstrably reducing/railing a real inter-crystal CFO.

🤖 Generated with Claude Code

josephnef and others added 2 commits July 8, 2026 20:29
…sue #217)

Expose the Realtek AFE crystal load-capacitance trim as a runtime lever:
IRtlDevice::SetXtalCap(cap) / GetXtalCap(), env DEVOURER_XTAL_CAP,
GetAdapterCaps().xtal_cap_max / xtal_cap_default, and the adapter.caps
event fields. Applied at end of bring-up; sticky (an AFE register the RF
retune never touches). Per-generation register (hal_set_crystal_cap /
phydm_set_crystal_cap_reg):
  Jaguar1  0x2C, 6-bit, cap|(cap<<6), per-die field (8812/8811 [30:19],
           8814A [26:15], 8821A [23:12]); efuse default 0xB9
  Jaguar2  0x24[30:25] + 0x28[6:1], 6-bit; efuse default 0xB9
  Jaguar3  0x1040[23:10], 7-bit, cap|(cap<<7)

Also adds a live-step primitive (DEVOURER_XTAL_STEP="c1,c2,.." in the CW
hold) — the mechanism a closed-loop CFO controller drives, and what makes
a clean single-chip measurement possible.

Bench-measured authority (tests/xtal_cfo_sweep.sh sdr — CW tone, cap
stepped live on one warmed c812, SDR LO peak): monotonic ~51 Hz/code at
5.2 GHz (12042 -> 15317 Hz over caps 0x10..0x50), ~0.6 ppm mid-range,
~1.25 ppm full range — the right order for a marginal 5 MHz/5 GHz pair
(1-2 ppm). 20 MHz stays locked under an aggressive trim (12400 hits).
ctest 13/13.

Open-loop caveat (documented): cold-start crystal warm-up drifts ~3 ppm,
larger and faster than the trim, so a fixed trim is a per-link
qualification lever; the production fix is a closed loop trimming toward
the RX-measured CFO (live-step primitive in place, loop not wired).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…et (#217)

The production half of #217: a fixed crystal-cap trim is a one-shot
qualification lever, but the crystal drifts ~3 ppm during warm-up (the
root of the 5 MHz/5 GHz bimodality), so it needs to track. This adds the
closed loop on Jaguar3 (DEVOURER_CFO_TRACK).

- RX phy-status: extract the per-path CFO tail (phy_sts_rpt_jgr3_type1
  DW5, byte 20; the earlier evm@16/snr@24 offsets confirm the struct).
- src/CfoTracker.h: bang-bang integral controller with hysteresis, ported
  from phydm phydm_cfo_tracking — steps the crystal cap +/-1 per tick to
  drive the CFO into a deadband. Auto-detects the cap->LO polarity (flips
  per silicon: if a step makes |CFO| worse, invert once).
- RtlJaguar3Device: feed the per-frame CFO to the tracker, tick it on a
  ~2 s cadence from the RX loop (the controller runs on the RECEIVER,
  which is the side that measures the offset). The cap register (0x1040)
  is written READ-FREE from a base cached before the RX flood — a
  control-transfer read races the async bulk-IN and throws under load.

Bench (c811 10M TX -> c812 RX, DEVOURER_CFO_TRACK): the loop measures the
CFO, steps the cap in the correct auto-detected direction, and the CFO
falls monotonically toward its minimum while the link stays decoding
(104900 hits). Opt-in; normal RX unaffected (20M regression 4500). ctest
13/13.

Honest scope: the loop needs a marginally-decoding link to bootstrap (no
frames -> no error signal); where a pair's absolute offset exceeds the
~1 ppm crystal-cap authority it converges to the rail rather than the
deadband; and the cfo_tail->kHz scale is the coarse vendor macro (~10x
high for narrowband per SDR cross-check) — the loop uses the CFO sign +
deadband, not a calibrated kHz. Jaguar1/2 not wired (Jaguar3 is the
narrowband testbed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@josephnef josephnef changed the title XTAL crystal-cap trim lever — the CFO knob for narrowband at the edge (#217) XTAL crystal-cap CFO lever + closed-loop CFO tracking (#217) Jul 8, 2026
@josephnef

Copy link
Copy Markdown
Collaborator Author

Update: closed-loop CFO tracking added (commit 62a2274)

Beyond the manual trim lever, this branch now includes the closed loop — the production answer to the warm-up drift that makes a one-shot trim insufficient.

  • RX reads the per-frame CFO tail from the JGR3 OFDM phy-status (phy_sts_rpt_jgr3_type1 DW5, byte 20).
  • src/CfoTracker.h: a bang-bang integral controller with hysteresis (ported from phydm phydm_cfo_tracking) steps the crystal cap ±1 per ~2 s tick to drive the CFO into a deadband. Auto-detects the cap→LO polarity (it flips per silicon — if a step makes |CFO| worse, invert once).
  • Runs on the receiver (the side that measures the offset), ticked from the RX loop. The cap register is written read-free from a base cached before the RX flood — a control-transfer read races the async bulk-IN and throws under load (found the hard way).
  • DEVOURER_CFO_TRACK=1; Jaguar3 (the narrowband testbed).

Bench (c811 10 MHz TX → c812 RX): the loop measures the CFO, steps the cap in the correct auto-detected direction, and the CFO falls monotonically toward its minimum while the link stays decoding (104900 hits). Opt-in; 20 MHz RX unaffected; ctest 13/13.

Honest scope: needs a marginally-decoding link to bootstrap (no frames → no error signal); where a pair's absolute offset exceeds the ~1 ppm crystal-cap authority it converges to the rail rather than the deadband; and the cfo_tail→kHz scale is the coarse vendor macro (~10× high for narrowband per the SDR cross-check) — the loop uses the CFO sign + deadband, not a calibrated kHz. Jaguar1/2 wiring is a follow-on.

josephnef and others added 2 commits July 8, 2026 21:18
Extend the closed-loop CFO controller to Jaguar2 — highest-value target,
since the 8821C (c811) is the other half of the #217 bimodal pair.

The Jaguar2 phy-status uses the same jgr2 type1 layout as Jaguar3
(evm@16, snr@24), so the per-path CFO tail is at the same byte 20;
FrameParserJaguar2 now fills rx_pkt_attrib::cfo_tail. The reusable
CfoTracker + RX-loop tick mirror the Jaguar3 wiring; the cap is written
read-free from cached bases (0x24[30:25] + 0x28[6:1], the two 6-bit AFE
legs) to avoid the control-transfer-read-under-bulk-IN crash.

Bench (c812 10M TX -> c811 RX detuned to 0x30, DEVOURER_CFO_TRACK): the
loop auto-detects the cap->LO polarity (opposite to the c812's — steps
UP here) and drives the measured CFO down (52 -> 45) while the link
stays decoding (66300 hits). Opt-in; ctest 13/13.

Jaguar1 remains the documented follow-on (same port; needs its 11AC
phy-status CFO offset found + verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@josephnef

Copy link
Copy Markdown
Collaborator Author

Closed loop extended to Jaguar2 (commit 0eee52e)

Ported the CFO controller to Jaguar2 (8822B/8821C) — the high-value target, since the 8821C (c811) is the other half of the #217 bimodal pair.

The Jaguar2 phy-status is the same jgr2 type1 layout as Jaguar3 (evm@16 / snr@24), so the CFO tail is at the same byte 20; the reusable CfoTracker + read-free cap write (here the two 6-bit AFE legs 0x24[30:25] + 0x28[6:1]) mirror the Jaguar3 wiring.

Bench (c812 10 MHz TX → c811 RX detuned to 0x30): the loop auto-detects the cap→LO polarity (opposite to the c812's — it steps up here) and drives the measured CFO down (52 → 45) while the link stays decoding (66300 hits). Opt-in; ctest 13/13.

Coverage now: Jaguar2 + Jaguar3 for the closed loop; the manual SetXtalCap lever is all three generations. Jaguar1's loop is the documented follow-on (its 11AC phy-status puts the CFO tail at a different offset that needs finding + verifying).

josephnef and others added 3 commits July 8, 2026 21:32
…nerations

Port the crystal-cap CFO controller (#217) to Jaguar1 so the closed loop now
covers every generation. The 8812 11AC phy-status carries the tail as the named
cfotail[0] field of _phy_status_rpt_8812 (DW2 byte 1) — the same unified report
the 8814A uses (its 8814-specific field meanings are annotated on neighbouring
members), so a single FrameParser assignment feeds the tracker on 8812/8811/
8814A alike. The RX-loop tick writes the per-die 0x2C crystal-cap field
read-free from a base cached before the RX flood (mirroring the Jaguar2/3
race-avoidance), gated on DEVOURER_CFO_TRACK.

Add a DVR_DEBUG per-tick line to all three generations' cfo_tick so the
controller's measurement is observable on a hold, not only on a step —
compiled out in production builds.

Hardware: verified on a plugged RTL8814AU — the tick fires on the ~2 s cadence
reading the die's efuse crystal-cap, and holds at cfo~0 against calibrated
ambient APs (correct no-op). tests/jaguar1_cfo_track_smoke.sh drives it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The closed-loop lead sentence claimed the CFO tail lives at "DW5" while
asserting all three generations — but DW5 is only the Jaguar2/Jaguar3 offset
(Jaguar1 carries it at DW2 byte 1). Make the lead generation-neutral and let
the per-generation breakdown give the offsets. Add tests/xtal_cfo_sweep.sh and
tests/jaguar1_cfo_track_smoke.sh to the script inventory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds tests/jaguar1_cfo_convergence.sh — an 8812AU transmits the canonical
narrowband beacon while an 8814AU receives with the CFO loop armed. Confirms
Jaguar1 is at full parity with Jaguar2/Jaguar3, not just plumbing: on a real
10 MHz/5 GHz link the receiver decodes the beacon (50k hits), the loop crosses
the deadband on the pair's inter-crystal offset, auto-detects the cap->LO
polarity (flips after a step grows |CFO|), and walks toward the CFO-reducing
rail. This pair's offset exceeds the ~1.25 ppm cap authority, so it rails
rather than nulling into the deadband — the same documented regime as J2/J3.

Co-Authored-By: Claude Opus 4.8 <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.

Crystal-cap (XTAL) trim knob — CFO lever to make 5 MHz narrowband robust at 5 GHz

1 participant