Skip to content

iridium: decode real off-air wideband captures (noise seed + DDC + ITL)#127

Merged
kevinelliott merged 1 commit into
masterfrom
iridium-wideband-realcap
Jun 13, 2026
Merged

iridium: decode real off-air wideband captures (noise seed + DDC + ITL)#127
kevinelliott merged 1 commit into
masterfrom
iridium-wideband-realcap

Conversation

@kevinelliott

@kevinelliott kevinelliott commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

What

The wideband Iridium hunter detected real bursts in a 6 MS/s off-air capture (SAWbird+IR / Maxtena PN100) but decoded 0 frames. This makes it decode them, with three isolated, unit-tested fixes plus a missing frame type.

Root causes & fixes

  1. Channelization — boxcar → DDC. Each detected burst was downmixed and decimated with a boxcar-of-decim averager, a poor anti-alias filter: its sinc sidelobes fold ~8 dB of wideband noise into the 250 kHz channel (measured peak/noise 8.5 dB vs 16.6 dB through a real FIR on the same burst). Now uses xng_dsp::Ddc (the two-stage windowed-sinc the single-channel path already trusts), 50 kHz one-sided passband so the demod's ±30 kHz tone-CFO search can recover off-center detections.

  2. Seed the demod noise floor — the decisive fix. The demod's asymmetric noise EMA starts at 1.0 and needs ~1400 quiet samples to converge. A continuous stream gives it that; an isolated wideband-extracted burst has only ~1000 channel samples of pre-roll, so the floor froze ~18 dB too high when the burst arrived and the acquisition gate (noise·8) sat above the signal → 0 energetic windows, UW fit never attempted. The front end now estimates channel noise (20th-percentile power) and seed_noise()s the demod. This alone took the capture 0 → decoding (clean UW costs 0.003–0.04).

  3. Stop over-rejecting in ECC/classify. ecc_blocks now trusts a weight-1 BCH correction even when the separate even-parity bit is also flipped (unambiguous on this d=5 code; the parity flip is a harmless second error), and classify accepts BCH-correctable RA headers, not only zero-syndrome ones.

  4. Add the ITL ("TL", Time-Location) frame type, classified before IRA. Its 96-bit 11+0… header is a valid degenerate all-zero BCH codeword, so without an explicit ITL check it fell through to the IRA classifier and mis-decoded as a ring alert with an all-zero satellite/position. The real captures are ITL-dominated; they now report kind:"itl" (full satellite/plane PRS decode via the toolkit's itl.py tables is TODO).

Result

  • Real off-air capture: 0 → 2 ITL frames decoded, correctly typed (no false all-zero ring alerts).
  • All 283 workspace tests pass, including new guards: itl_header_classifies_as_itl_not_ra, ira_header_still_classifies_as_ra, ecc_blocks_tolerates_flipped_parity_bit.
  • Adds XNG_IRIDIUM_DEBUG-gated acquisition/detection diagnostics for future field work.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added optional debug diagnostics mode for demodulation analysis.
    • Added support for Time-Location (ITL) frame type detection.
    • Improved noise floor estimation for enhanced demodulation performance.
  • Bug Fixes

    • Enhanced frame classification logic to better distinguish frame types.
    • Refined error correction handling for improved frame validation.
  • Documentation

    • Updated wideband decoder documentation with latest improvements.

The full-band hunter detected real bursts but decoded 0 frames from a
6 MS/s off-air capture. Three isolated fixes, each unit-tested:

- Channelize each burst with xng_dsp::Ddc (two-stage windowed-sinc anti-
  alias) instead of a boxcar-of-decim averager. The boxcar's sinc
  sidelobes fold ~8 dB of wideband noise into the 250 kHz channel
  (measured peak/noise 8.5 vs 16.6 dB on the same burst). Passband 50 kHz
  one-sided so the demod's ±30 kHz tone-CFO search can recover off-center
  detections.

- Seed the demod noise floor from the channel (decisive fix). The
  asymmetric noise EMA starts at 1.0 and needs ~1400 quiet samples to
  converge; an isolated extracted burst has only ~1000 pre-roll samples,
  so the floor froze ~18 dB high and the acquisition gate (noise*8) sat
  above the signal -> 0 energetic windows, UW fit never attempted. The
  front end now estimates noise (20th-percentile power) and seeds the
  demod. This alone took the capture 0 -> decoding (UW costs 0.003-0.04).

- Stop over-rejecting in the ECC/classify gates: ecc_blocks trusts a
  weight-1 BCH correction even when the separate even-parity bit is also
  flipped (unambiguous on this d=5 code; the parity flip is a harmless
  second error), and classify accepts BCH-correctable RA headers, not
  only zero-syndrome ones.

- Add the ITL ("TL", Time-Location) frame type, classified before IRA.
  Its 96-bit `11`+0... header is a valid degenerate all-zero BCH
  codeword, so without an explicit ITL check it fell through to the IRA
  classifier and mis-decoded as an all-zero ring alert. The real captures
  are ITL-dominated; they now report kind:"itl" (full PRS sat/plane
  decode is TODO).

Result: the real capture decodes 2 ITL frames (was 0); all 283 workspace
tests pass, including new guards for ITL classification, IRA-still-works,
and the ecc_blocks parity tolerance. Adds XNG_IRIDIUM_DEBUG-gated
acquisition/detection diagnostics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ab2a7b44-f0ce-4b94-9353-43785d3b77bd

📥 Commits

Reviewing files that changed from the base of the PR and between b4f6077 and 8280d2c.

📒 Files selected for processing (5)
  • crates/xng-mode-iridium/src/demod.rs
  • crates/xng-mode-iridium/src/frame.rs
  • crates/xng-mode-iridium/src/lib.rs
  • crates/xng-mode-iridium/src/wideband.rs
  • docs/notes/IRIDIUM.md

📝 Walkthrough

Walkthrough

This PR enhances the Iridium wideband (wave 2) decoder to handle real off-air bursts reliably. It introduces demodulator debug diagnostics and noise-floor seeding, adds a new ITL (Time-Location) frame type with refined classification heuristics, and replaces boxcar decimation with DDC-based downmix and robust noise estimation.

Changes

Wideband Demodulation and Frame Classification

Layer / File(s) Summary
Demodulator Debug & Noise Seeding
crates/xng-mode-iridium/src/demod.rs
Adds optional debug mode via XNG_IRIDIUM_DEBUG environment variable; implements seed_noise() public method to set initial noise-floor estimate; tracks UW acquisition counters (dbg_best, dbg_energetic) during process() and emits diagnostic eprintln! when debug enabled, reporting timing, noise/gate parameters, coarse CFO, and sync statistics without altering demod selection criteria.
Frame Classification and ECC Heuristics
crates/xng-mode-iridium/src/frame.rs
Adds FrameKind::Itl variant for Time-Location 96-bit headers; introduces tolerant ITL classifier that checks header pattern against a small bit-error tolerance; rewrites RA (ring alert) classifier to accept BCH-correctable headers by deinterleaving three BCH components and checking syndrome-clean counts and correction budgets; tightens ECC parity/truncation logic in ecc_blocks to require both odd overall parity and at least 2-bit corrections. Includes unit tests covering ITL classification, IRA still classifying as Ra, and ecc_blocks tolerance.
ITL Frame Decoding Output
crates/xng-mode-iridium/src/lib.rs
decode_bits recognizes FrameKind::Itl and constructs an ira::IridiumFrame with kind: "itl", payload bit count, and original raw_bits, deferring satellite/plane PRS decoding.
Wideband DDC Downmix and Noise Estimation
crates/xng-mode-iridium/src/wideband.rs
Replaces custom sinusoidal mixer and boxcar decimator with xng_dsp::Ddc using WIDEBAND_PASSBAND_HZ constant to support CFO search under spectral leakage; removes stored decim field; computes robust noise-floor estimate from channel power (20th percentile statistic) and passes it to IridiumDemod::seed_noise() to improve UW acquisition; adds optional debug flag (from XNG_IRIDIUM_DEBUG) for burst/channel diagnostic output.
Documentation of Wideband Pipeline
docs/notes/IRIDIUM.md
Introduces "Wideband (wave 2) — decoding real off-air bursts" section documenting the full burst-hunting approach, three concrete fixes (DDC-based downmix, noise seeding, adjusted BCH/classification gating), and ITL-before-IRA classification ordering to prevent ITL's degenerate all-zero BCH header from being mis-decoded as a ring alert.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • airframesio/xng#29: Both PRs modify the new crates/xng-mode-iridium decoding core—this PR extends demod.rs/frame.rs/lib.rs logic (e.g., debug/noise seeding and ITL + adjusted RA/ECC heuristics) on top of the ring-alert/broadcast core introduced by #29.

Poem

🐰 A rabbit hops through signals bright,
ITL frames now shine in light,
Noise is tamed, the DDC flows,
Debug breadcrumbs mark the road.
Off-air bursts can find their way!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the three main technical improvements (noise seed, DDC, ITL frame type) that enable decoding of real off-air wideband Iridium captures.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch iridium-wideband-realcap

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8280d2c035

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +206 to +210
let diff = (data[0] == 0) as u32
+ (data[1] == 0) as u32
+ data[2..96].iter().map(|&b| b as u32).sum::<u32>();
if diff <= 3 {
return FrameKind::Itl;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep ITL matching from preempting broadcasts

When the first 96 post-access bits are all zero, this early ITL check returns before the Bc branch below. That pattern is not just noise: the existing Bc predicate accepts a type-0 000000 HDR_POLY header followed by zero BCH blocks, so decode_bits now emits kind:"itl" for those broadcast frames instead of broadcast. Since the new guard only needs to stop near-zero headers from reaching the IRA classifier, run it after Bc/LCW or require the 11 prefix more strictly.

Useful? React with 👍 / 👎.

Comment on lines +321 to +323
let k = powers.len() / 5;
powers.select_nth_unstable_by(k, |a, b| a.total_cmp(b));
powers[k]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed noise from the quiet margins, not the whole burst

For long bursts, the extracted segment is only PRE_S + POST_S (16 ms) of quiet around as much as MAX_BURST_S (92 ms) of signal, so the quiet samples can be less than 20% of chan. In that case powers.len() / 5 selects a burst-power sample rather than the noise floor; seed_noise() then sets the acquisition gate to about 8× the signal power and the demodulator can find zero energetic UW windows for the longest frames. Estimate from the known pre/post regions or use a lower percentile tied to the quiet fraction.

Useful? React with 👍 / 👎.

@kevinelliott
kevinelliott merged commit 05f7197 into master Jun 13, 2026
3 checks passed
@kevinelliott
kevinelliott deleted the iridium-wideband-realcap branch June 13, 2026 21:38
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