diff --git a/site/astro.config.mjs b/site/astro.config.mjs
index 656d417..b7ef0fd 100644
--- a/site/astro.config.mjs
+++ b/site/astro.config.mjs
@@ -122,6 +122,7 @@ export default defineConfig({
{ label: 'TS Analyzer', slug: 'tools/ts-analyzer' },
{ label: 'Spectrum Analysis', slug: 'tools/spectrum-analysis' },
{ label: 'Hydrogen 21 cm', slug: 'tools/h21cm' },
+ { label: 'RF Test Bench', slug: 'tools/rf-testbench' },
{ label: 'Beacon Logger', slug: 'tools/beacon-logger' },
{ label: 'Arc Survey', slug: 'tools/arc-survey' },
{ label: 'MCP Server', slug: 'tools/mcp-server' },
@@ -131,6 +132,7 @@ export default defineConfig({
{
label: 'Guides',
items: [
+ { label: 'Applications & Use Cases', slug: 'guides/applications' },
{ label: 'QO-100 DATV Reception', slug: 'guides/qo100-datv' },
{ label: "Experimenter's Roadmap", slug: 'guides/experimenter-roadmap' },
],
diff --git a/site/src/content/docs/guides/applications.mdx b/site/src/content/docs/guides/applications.mdx
new file mode 100644
index 0000000..4f5a4ef
--- /dev/null
+++ b/site/src/content/docs/guides/applications.mdx
@@ -0,0 +1,322 @@
+---
+title: Applications & Use Cases
+description: What can the SkyWalker-1 actually do? Satellite TV, multi-standard signal analysis, radio astronomy, RF measurement, and more.
+---
+
+import { Aside, Badge, Card, CardGrid, Tabs, TabItem } from '@astrojs/starlight/components';
+
+The SkyWalker-1 shipped as a DVB-S satellite TV receiver. With [custom firmware](/firmware/custom-v305/) and
+the reverse-engineered USB/I2C interface, it becomes something more interesting: a programmable RF instrument
+covering 950-2150 MHz with ten demodulation modes, 256 Ksps to 30 Msps symbol rates, and full Python control.
+
+Here's what you can actually do with it.
+
+## Satellite TV Reception
+
+The obvious one. The SkyWalker-1 receives free-to-air (FTA) DVB-S content — unencrypted satellite television
+and radio that anyone with a dish can watch.
+
+### What's Up There
+
+
+
+ Most FTA content in North America lives on Ku-band satellites. A standard 30-36 inch dish and
+ universal LNB is all you need.
+
+ | Satellite | Position | What's On It |
+ |-----------|----------|-------------|
+ | Galaxy 19 | 97.0°W | The FTA motherlode. ~135+ channels: Chinese, Korean, South Asian, religious, shopping, some English |
+ | Galaxy 16 | 99.0°W | Religious programming, international |
+ | SES-2 | 87.0°W | International, government |
+ | AMC-18 | 105.0°W | Mixed FTA and encrypted |
+
+ Typical tuning parameters: 11836 MHz V-pol, 20770 ksps, DVB-S QPSK FEC 3/4.
+
+
+ C-band requires a larger dish (6-12 feet) and a C-band LNB, but carries content that never made it
+ to Ku-band — including DigiCipher II muxes that the SkyWalker-1 uniquely supports.
+
+ | Satellite | Position | What's On It |
+ |-----------|----------|-------------|
+ | AMC-18 | 105.0°W | DCII cable distribution, some FTA |
+ | SES-2 | 87.0°W | International, government feeds |
+ | Galaxy 16 | 99.0°W | Mixed distribution |
+
+ The FCC C-band transition compressed services into the upper 3.98-4.2 GHz range. Additional
+ spectrum auctions are proposed for 2027 — C-band FTA is on borrowed time.
+
+
+
+### FTA Resources
+
+Current channel listings change frequently. These sites track what's active:
+
+- [LyngSat](https://www.lyngsat.com/) — comprehensive transponder and channel database
+- [SatExpat](https://www.satexpat.com/) — FTA channel listings with satellite footprints
+- [FTAList](https://ftalist.com/) — North American FTA community and channel guide
+
+
+
+## Multi-Standard Signal Analysis
+
+This is where the SkyWalker-1 becomes genuinely rare hardware. The BCM4500 demodulates standards that are
+nearly extinct in available consumer equipment — standards that are still actively broadcasting.
+
+
+
+ Cable headend distribution format (Comcast HITS, Motorola). One of very few modern devices with DCII
+ support. "Zero Key" unencrypted services are directly receivable.
+
+
+ Digital Satellite Service — legacy DirecTV format with 127-byte transport packets (vs 188-byte DVB).
+ Extraordinarily rare outside DirecTV hardware.
+
+
+ DISH Network transponder format. Encrypted content, but demodulator lock and transport stream capture
+ work — useful for signal analysis and protocol research.
+
+
+ Earlier turbo-coded variant. Better spectral efficiency than standard DVB-S QPSK, still used on
+ some distribution paths.
+
+
+
+### Why This Matters
+
+These standards are still active on-air, but the hardware to receive them is disappearing. Off-the-shelf
+satellite receivers dropped DCII and DSS support years ago. The SkyWalker-1, through its BCM4500 demodulator,
+retains these capabilities — making it a **preservation and research tool** for signal formats that will
+eventually go silent.
+
+The [TS Analyzer](/tools/ts-analyzer/) can parse transport streams from all supported modulation types,
+making it possible to compare DVB-S, DCII, and DSS packet structures side by side.
+
+See [BCM4500 Demodulator](/bcm4500/demodulator/) for register-level details on how each modulation type
+is configured.
+
+## Wild Feed & Backhaul Hunting
+
+Satellite transponders carry more than scheduled programming. Temporary unencrypted uplinks — "wild feeds" —
+appear and disappear throughout the day:
+
+- **Live news remotes**: Raw camera feeds from field reporters, unedited and uncensored
+- **Sports backhauls**: Stadium camera feeds before production mixing
+- **Network distribution**: Programs fed to affiliates before air time
+- **Event coverage**: Press conferences, hearings, launches
+
+The SkyWalker-1's blind scan capability and wide symbol rate range (256 Ksps - 30 Msps) make it well-suited
+for finding these transient signals. The [Carrier Survey](/tools/survey/) tool automates the sweep-and-lock
+cycle across a full satellite.
+
+
+
+## Radio Astronomy
+
+The 950-2150 MHz IF range — or, without an LNB, the direct input range — overlaps with several
+astrophysically interesting frequencies. The BCM4500's AGC registers respond to any RF energy at the
+tuned frequency, regardless of whether it carries a demodulatable signal.
+
+### Hydrogen 21 cm
+
+
+
+Neutral hydrogen emits at **1420.405 MHz** — directly in the IF range with no LNB. Connect an L-band
+antenna (patch, helical, or horn) to the F-connector and the SkyWalker-1 becomes a hydrogen line
+radiometer. The velocity-dispersed emission from the Milky Way's spiral arms is detectable even
+with the BCM4500's ~346 kHz resolution bandwidth.
+
+See [Hydrogen 21 cm Radiometer](/tools/h21cm/) for the full tool reference.
+
+### Ku-Band Solar Observation
+
+Point a standard satellite dish + LNB at the Sun. At 10-12 GHz, solar thermal emission produces a
+detectable **6+ dB rise** above the cold-sky background. Solar flares produce wideband bursts that
+are even more dramatic.
+
+The SkyWalker-1's advantage over an RTL-SDR here is bandwidth: the 30 Msps sweep capability covers
+a much wider swath of spectrum (~30 MHz effective) compared to the RTL-SDR's ~2.4 MHz, making it
+easier to detect and characterize broadband solar events.
+
+Use the [Spectrum Analysis](/tools/spectrum-analysis/) sweep mode to build solar emission profiles.
+
+### Moon Thermal Emission
+
+The Moon is a calibrated thermal source at microwave frequencies. Measuring its emission relative to
+cold sky provides a reference point for system noise temperature estimation — a standard radio
+astronomy calibration technique.
+
+## RF Test & Measurement
+
+The custom firmware turns the SkyWalker-1 into a basic but useful L-band test instrument.
+
+### L-Band Spectrum Analyzer
+
+
+
+Sweep 950-2150 MHz in configurable steps, recording AGC power at each frequency. Not calibrated
+to absolute dBm, but relative measurements are consistent enough for transponder identification,
+interference detection, and comparative analysis.
+
+See [Spectrum Analysis](/tools/spectrum-analysis/) for sweep techniques and interpretation.
+
+### CW Injection Test Bench
+
+
+
+Connect a NanoVNA as a CW source through an [HMC472A digital attenuator](https://hmc472.l.zmesh.systems/)
+to the SkyWalker-1's F-connector. The `rf_testbench.py` tool automates five test sequences:
+AGC linearity mapping, IF band flatness, frequency accuracy, minimum detectable signal, and
+BPSK mode 9 probing. The HMC472A provides 0-31.5 dB of programmable attenuation in 0.5 dB
+steps via its REST API, giving precision level control without swapping fixed pads.
+
+See [RF Test Bench](/tools/rf-testbench/) for hardware setup, calibration, and test descriptions.
+
+### LNB Characterization
+
+Measure gain flatness across the IF band by sweeping a known satellite's transponder plan and
+comparing received power levels. Track LO drift over temperature by monitoring a stable carrier's
+frequency offset over 24 hours with the [Beacon Logger](/tools/beacon-logger/).
+
+The I2C-exposed tuner and demodulator registers make internal signal chain parameters directly
+readable — something most consumer receivers hide completely.
+
+### Transponder Fingerprinting
+
+Each satellite transponder has unique RF characteristics: center frequency, symbol rate, rolloff,
+power level, modulation type. The [Carrier Survey](/tools/survey/) tool builds a catalog of these
+parameters. Over time, this creates a fingerprint database useful for satellite identification
+and change detection.
+
+### 5G Interference Monitoring
+
+The FCC's C-band auction reallocated 3.7-3.98 GHz to 5G operators. Spillover from 5G base stations
+into the satellite C-band (3.98-4.2 GHz) is an increasing concern for satellite operators and
+earth station licensees. With a C-band LNB, the SkyWalker-1 can sweep the IF band and detect
+interference signatures.
+
+## Propagation Science & Weather
+
+Long-duration signal monitoring produces datasets that map directly to atmospheric physics.
+
+### Rain Fade Analysis
+
+
+
+Lock onto a stable Ku-band transponder and log SNR at 1 Hz for days or weeks. Ku-band signals
+attenuate predictably in rain — the ITU-R P.618 model describes the relationship between rainfall
+rate and attenuation at specific frequencies. Real measurement data validates (or challenges)
+these models for your specific location and dish geometry.
+
+### Diurnal Thermal Effects
+
+LNB gain varies with temperature. A 24-hour beacon log correlated with ambient temperature data
+reveals the thermal gain coefficient of your specific LNB — useful for separating real propagation
+events from equipment drift.
+
+### Link Budget Validation
+
+Compare long-term average received signal levels against calculated link budgets (EIRP, free space
+loss, atmospheric absorption, antenna gain, system noise temperature). The gap between prediction
+and measurement is where engineering meets reality.
+
+See [Beacon Logger](/tools/beacon-logger/) for unattended multi-day logging with auto-relock.
+
+## Education & Research
+
+The SkyWalker-1 exposes the complete satellite signal chain from RF input to MPEG-2 transport stream
+output, with every intermediate stage accessible over I2C.
+
+### University Lab Platform
+
+A single SkyWalker-1 + dish + LNB covers a semester of satellite communications topics with
+live signals:
+
+| Topic | What's Observable |
+|-------|------------------|
+| QPSK/8PSK demodulation | Lock status, constellation quality via SNR |
+| Forward error correction | Viterbi, Reed-Solomon, Turbo code — switchable by modulation type |
+| Link budgets | Real measurements vs. theoretical calculations |
+| MPEG-2 transport streams | Live PSI/SI table parsing, PID analysis |
+| Spectrum analysis | Transponder identification from raw power sweeps |
+| Antenna pointing | Signal strength vs. azimuth/elevation in real time |
+
+### Transport Stream Protocol Research
+
+The SkyWalker-1's multi-standard support makes it uniquely suited for comparative protocol analysis:
+
+- **DVB-S**: 188-byte MPEG-2 TS packets, standard PID structure
+- **DigiCipher II**: Motorola proprietary transport, conditional access
+- **DSS**: 127-byte packets — shorter than DVB, different header format
+
+Tools like [TSDuck](https://tsduck.io/) and dvbsnoop can parse captured streams. The [TS Analyzer](/tools/ts-analyzer/)
+handles the initial capture and PSI extraction.
+
+### Accessible Signal Chain
+
+The I2C bus provides direct read access to tuner, demodulator, and FEC status registers. Students can
+observe the AGC settling, watch the demodulator acquire lock, and read error correction statistics —
+the internal workings of the signal chain, visible in real time. See [I2C Bus Architecture](/i2c/bus-architecture/)
+and [Signal Monitoring](/bcm4500/signal-monitoring/) for register details.
+
+## What's NOT Compatible
+
+Setting honest expectations is more valuable than overselling.
+
+
+
+| Signal / Application | Why Not |
+|---------------------|---------|
+| **DVB-S2** | Incompatible FEC — uses LDPC instead of Reed-Solomon/Viterbi. This is a growing percentage of satellite content. |
+| **GOES weather satellite imagery** | LRIT uses BPSK/CCSDS (not DVB-S), GRB uses DVB-S2. Cannot decode imagery. However, the BCM4500's BPSK mode 9 uses the same inner FEC (Viterbi rate 1/2 K=7) as LRIT — the signal chain gets four stages deep before breaking at RS decoder block size and CCSDS framing. The LRIT carrier at 1694.1 MHz is within the [direct input range](/tools/h21cm/#antenna-setup) and can be used for antenna alignment and propagation monitoring. See [RF Test Bench](/tools/rf-testbench/) for BPSK mode 9 probing. |
+| **QO-100 from North America** | Es'hail-2 is at 25.9°E — visible from Europe, Africa, and the Middle East, but not North America. See [QO-100 DATV Reception](/guides/qo100-datv/) for coverage details. |
+| **Military/government feeds** | Encrypted and increasingly DVB-S2 or proprietary modulation. |
+| **ATSC / DVB-T terrestrial** | Completely different modulation family (OFDM), different frequency band. |
+| **Analog satellite TV** | The BCM4500 is a digital demodulator. Analog satellite is also effectively extinct. |
+
+## Modulation Support Reference
+
+
+
+ | Modulation | Standard | Typical Use | FTA Content? |
+ |-----------|----------|-------------|-------------|
+ | DVB-S QPSK | DVB-S EN 300 421 | Free-to-air satellite TV worldwide | Yes — most FTA content |
+ | Turbo QPSK | Proprietary (Comstream) | Distribution, some DISH | Rare |
+ | Turbo 8PSK | Proprietary | DISH Network | No — encrypted |
+ | DCII Combo | Motorola DigiCipher II | Cable headend distribution | Some ("Zero Key") |
+ | DCII Split I | Motorola DigiCipher II | Cable headend distribution | Some |
+ | DCII Split Q | Motorola DigiCipher II | Cable headend distribution | Some |
+ | DCII Offset QPSK | Motorola DigiCipher II | Cable headend distribution | Some |
+ | DSS QPSK | Hughes DSS | Legacy DirecTV | No — service winding down |
+
+
+ | What You Want to Do | Modulation to Select | Symbol Rate Range |
+ |--------------------|--------------------|------------------|
+ | Watch FTA satellite TV | DVB-S QPSK | 2-30 Msps |
+ | Analyze DISH Network signals | Turbo 8PSK | 20-30 Msps |
+ | Receive DCII cable distribution | DCII Combo/Split/Offset | 2-30 Msps |
+ | Study DSS transport format | DSS QPSK | 20 Msps typical |
+ | Hydrogen 21 cm (no LNB) | N/A — AGC power only | Any (for carrier lock attempt) |
+ | Spectrum sweep / signal detection | N/A — AGC power only | Set during tune, not critical |
+
+
+
+## See Also
+
+- [RF Specifications](/hardware/rf-specifications/) — frequency range, symbol rate limits, LNB power
+- [BCM4500 Demodulator](/bcm4500/demodulator/) — register-level modulation configuration
+- [Spectrum Analysis](/tools/spectrum-analysis/) — sweep techniques and transponder scanning
+- [RF Test Bench](/tools/rf-testbench/) — CW injection testing with NanoVNA + HMC472A
+- [Experimenter's Roadmap](/guides/experimenter-roadmap/) — future experiment tiers and creative applications
+- [MCP Server](/tools/mcp-server/) — programmatic access to all hardware functions
diff --git a/site/src/content/docs/tools/h21cm.mdx b/site/src/content/docs/tools/h21cm.mdx
index 266a858..972963d 100644
--- a/site/src/content/docs/tools/h21cm.mdx
+++ b/site/src/content/docs/tools/h21cm.mdx
@@ -3,13 +3,63 @@ title: Hydrogen 21 cm Radiometer
description: Detect neutral hydrogen emission at 1420.405 MHz using the SkyWalker-1 as an L-band radiometer.
---
-import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
+import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
The `h21cm.py` tool turns the SkyWalker-1 into a hydrogen line radiometer. Neutral hydrogen
atoms emit radiation at **1420.405 MHz** when the electron's spin flips relative to the proton —
the most fundamental spectral line in radio astronomy, and it falls directly in the IF range.
-No LNB is needed. Connect an L-band antenna directly to the F-connector.
+## Antenna Setup
+
+The SkyWalker-1 normally receives satellite TV through an LNB (Low Noise Block downconverter) mounted
+at the focal point of a dish. For hydrogen line work, the LNB must be **removed or bypassed entirely** —
+it would block the signal. An LNB's waveguide feed is dimensioned for Ku-band wavelengths (~2.5 cm)
+and its internal filters reject everything outside the 10.7-12.75 GHz range. At 1420 MHz (wavelength
+~21 cm), nothing gets through.
+
+Instead, connect an L-band antenna directly to the SkyWalker-1's F-connector with coaxial cable.
+The tool disables LNB power automatically, so there's no voltage on the cable.
+
+### Antenna Options
+
+
+
+ The classic radio astronomy choice. A tin-can "cantenna" or sheet-metal pyramidal horn provides
+ 10-15 dBi gain with predictable, calculable performance. Easy to build from hardware store materials.
+ A circular waveguide horn from a ~15 cm diameter can works well at 1420 MHz.
+
+
+ Reuse your satellite dish — replace the LNB with a 1420 MHz feed (dipole + reflector, or a small
+ horn at the focal point). The dish surface accuracy matters less at 21 cm wavelength than at Ku-band,
+ so even mesh dishes work fine. This gives the highest gain of any option here.
+
+
+ A helical antenna is circularly polarized and offers good gain in a compact package. Hydrogen emission
+ is unpolarized, so a circularly-polarized antenna captures half the power (~3 dB penalty vs linear),
+ but helix construction is forgiving and well-documented for L-band.
+
+
+ Commercial L-band patch antennas (GPS antennas at 1575 MHz are close) are compact and cheap. Lower
+ gain than the other options (~5-7 dBi), and narrow bandwidth may not cover the full hydrogen emission
+ profile. Fine for a first detection attempt.
+
+
+
+
+
+### Cable and Connectors
+
+Run 75Ω coax (RG-6 is standard satellite TV cable) from the antenna to the SkyWalker-1. At 1420 MHz,
+cable loss matters more than impedance mismatch — keep runs under 10 meters if possible. RG-6 loses
+roughly **0.2 dB per meter** at 1.4 GHz (about 6 dB per 100 feet), so a 10m run costs ~2 dB.
+Shorter is better.
+
+If your antenna uses 50Ω connectors (SMA, N-type), a simple adapter to F-type is fine. The 0.2 dB
+impedance mismatch is far less than a meter of extra cable.
## Quick Start
diff --git a/site/src/content/docs/tools/rf-testbench.mdx b/site/src/content/docs/tools/rf-testbench.mdx
new file mode 100644
index 0000000..cbf4bf3
--- /dev/null
+++ b/site/src/content/docs/tools/rf-testbench.mdx
@@ -0,0 +1,260 @@
+---
+title: RF Test Bench
+description: Automated CW injection testing with NanoVNA, HMC472A digital attenuator, and SkyWalker-1 receiver.
+---
+
+import { Aside, Badge, Card, CardGrid, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
+
+
+
+The `rf_testbench.py` tool turns a NanoVNA, an HMC472A digital attenuator, and the SkyWalker-1 into
+an automated RF test bench. It injects CW signals at known frequencies and power levels, then
+measures the receiver's response — characterizing AGC linearity, IF band flatness, frequency
+accuracy, sensitivity, and BPSK mode 9 behavior.
+
+## Hardware Setup
+
+
+1. **NanoVNA CH0 output** (SMA) connects to a **DC blocker** (SMA inline, required)
+2. **DC blocker output** connects to the **HMC472A RF IN** (SMA)
+3. **HMC472A RF OUT** (SMA) connects via **SMA-to-F adapter** to the **SkyWalker-1 F-connector**
+4. **HMC472A ESP32-S2 controller** connected to your network (WiFi) — reachable at `http://attenuator.local`
+5. **NanoVNA** connected via USB (for mcnanovna automation) or operated via touchscreen (manual mode)
+
+
+```
+NanoVNA CH0 ──→ DC Blocker ──→ HMC472A (0-31.5 dB) ──→ SMA-to-F ──→ SkyWalker-1
+ (SMA) (SMA) REST API control adapter (F-type)
+ http://attenuator.local
+```
+
+### Components
+
+| Component | Purpose | Notes |
+|-----------|---------|-------|
+| NanoVNA-H (9 kHz-1.5 GHz) | CW signal source | Output ~-15 dBm at max power. Overlaps SkyWalker-1 IF band at 950-1500 MHz |
+| DC Blocker (SMA inline) | Protect NanoVNA from LNB voltage | Required — even though the tool disables LNB power, this prevents accidental damage |
+| HMC472A attenuator module | Precision level control | 0-31.5 dB in 0.5 dB steps, controlled via ESP32-S2 REST API |
+| SMA-to-F adapter | Connector transition | 50-to-75 ohm mismatch is ~0.2 dB — negligible |
+
+
+
+### HMC472A Attenuator
+
+The [HMC472A digital attenuator](https://hmc472.l.zmesh.systems/) provides programmable signal level
+control via its ESP32-S2 REST API:
+
+- **Range**: 0 to 31.5 dB in 0.5 dB steps (64 discrete settings)
+- **Bandwidth**: DC to 3.8 GHz (covers the full SkyWalker-1 IF range)
+- **Insertion loss**: 1.4-1.9 dB typical
+- **Control**: HTTP REST — `POST /set {"attenuation_db": 10.5}`
+- **Switching speed**: 60 ns (faster than any measurement cycle)
+
+The tool communicates with the attenuator at `http://attenuator.local` by default. Override with
+`--attenuator http://10.0.0.50` if your device has a different address.
+
+### NanoVNA Frequency Overlap
+
+The NanoVNA-H (HW3.7) covers 9 kHz to 1.5 GHz. The SkyWalker-1's IF range is 950-2150 MHz.
+The **overlapping usable range is 950-1500 MHz** — the lower portion of the IF band. This is
+sufficient for characterizing the tuner and AGC, and includes the 1420 MHz hydrogen line region.
+
+For testing above 1500 MHz, a different signal source (bladeRF, signal generator) would be needed.
+
+## Calibration
+
+Before running quantitative tests, characterize the signal path loss:
+
+
+1. Disconnect the SkyWalker-1 end of the cable
+2. Connect: **NanoVNA CH0** → DC blocker → HMC472A (set to 0 dB) → cable → **NanoVNA CH1**
+3. Run an S21 sweep from 950 to 1500 MHz using mcnanovna or the NanoVNA touchscreen
+4. Export as CSV with columns `freq_mhz` and `s21_db` (or `frequency_hz` and `loss_db`)
+5. Pass to `rf_testbench.py` with `--cal path_loss.csv`
+
+
+The tool interpolates the measured path loss at each test frequency and subtracts it from AGC
+readings. Without a calibration file, raw AGC values are still reported — useful for relative
+measurements but not calibrated to absolute power.
+
+
+
+## Prerequisites
+
+- **SkyWalker-1** with [custom firmware v3.02+](/firmware/custom-v302/) (for `tune_monitor` command)
+- **HMC472A** attenuator with ESP32-S2 controller on the network
+- **NanoVNA-H** (manual mode works with any VNA; auto mode requires [mcnanovna](https://git.supported.systems/rf/mcnanovna))
+- **Python 3.10+** with `pyusb` installed
+- **DC blocker** (SMA inline)
+- **SMA-to-F adapter**
+
+## Test Descriptions
+
+### AGC Power Linearity
+
+```bash
+python tools/rf_testbench.py agc-linearity --freq 1200
+```
+
+Injects CW at a fixed frequency while sweeping the HMC472A from 0 to 31.5 dB in 0.5 dB steps.
+At each attenuation level, the SkyWalker-1 reports AGC1, AGC2, and derived power. This maps the
+**AGC transfer function** — how the receiver's automatic gain control responds to known changes
+in input power.
+
+The output shows whether the AGC is linear, where it saturates, and its effective dynamic range.
+With 64 measurement points across 31.5 dB, the resolution is high enough to reveal nonlinearities
+in the BCM3440 tuner's gain control loop.
+
+### IF Band Flatness
+
+```bash
+python tools/rf_testbench.py band-flatness --start 950 --stop 1500 --step 10
+```
+
+Sweeps the NanoVNA CW frequency across the IF band while keeping the HMC472A at a fixed
+attenuation (10 dB default). At each frequency, the SkyWalker-1 tunes and reads AGC power.
+
+The result reveals:
+- **Tuner gain slope**: the BCM3440 may have more gain at some frequencies than others
+- **Passband ripple**: resonances or nulls in the IF filter chain
+- **Cable/path frequency response**: if a calibration file is loaded, this is subtracted out
+
+### Frequency Accuracy
+
+```bash
+python tools/rf_testbench.py freq-accuracy --freqs 1000,1100,1200,1300,1400
+```
+
+At each test frequency, the NanoVNA injects CW while the SkyWalker-1 runs a narrow spectrum
+sweep (+/- 5 MHz) around the expected frequency. The detected power peak is compared against the
+injected frequency.
+
+This characterizes the **BCM3440 tuner's frequency accuracy** — how much the actual tuned
+frequency differs from the commanded frequency. The error may be systematic (constant offset)
+or frequency-dependent.
+
+### Minimum Detectable Signal
+
+```bash
+python tools/rf_testbench.py mds --freq 1200
+```
+
+First measures the noise floor with maximum attenuation (31.5 dB). Then injects CW and steps
+the HMC472A from 0 dB upward in 1 dB increments until the signal drops below 3-sigma above
+the noise floor.
+
+The attenuation level where the signal disappears, combined with the NanoVNA output power
+(~-15 dBm), gives an approximate **minimum detectable signal level** in dBm.
+
+### BPSK Mode 9 CW Probe
+
+```bash
+python tools/rf_testbench.py bpsk-probe --freq 1200
+```
+
+An exploratory test that tunes the SkyWalker-1 in **BPSK mode (index 9)** — the same Viterbi
+rate 1/2 K=7 inner FEC used by GOES LRIT. A CW carrier has no modulation, so the demodulator
+shouldn't acquire lock, but the AGC and carrier recovery behavior is informative.
+
+Tests several symbol rates (293,883 sps matching LRIT, plus 500K, 1M, and 5M) and compares
+against QPSK mode 0 at the same frequency. This establishes a baseline for what mode 9 reports
+with an unmodulated carrier — useful context for future modulated-signal experiments with a
+bladeRF.
+
+## Options
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `--attenuator` | `http://attenuator.local` | HMC472A REST API base URL |
+| `--nanovna` | `auto` | NanoVNA control: `auto` (mcnanovna) or `manual` (prompted) |
+| `--cal` | — | Path loss calibration CSV file |
+| `--settle` | 200 | Settle time in ms after changing attenuation |
+| `--output` / `-o` | — | CSV output file |
+| `--verbose` / `-v` | — | Show raw USB traffic |
+
+### Per-Test Options
+
+| Test | Flag | Default | Description |
+|------|------|---------|-------------|
+| `agc-linearity` | `--freq` | 1200 | Test frequency in MHz |
+| `band-flatness` | `--start` | 950 | Start frequency in MHz |
+| `band-flatness` | `--stop` | 1500 | Stop frequency in MHz |
+| `band-flatness` | `--step` | 10 | Frequency step in MHz |
+| `freq-accuracy` | `--freqs` | 1000,1100,1200,1300,1400 | Comma-separated test frequencies |
+| `mds` | `--freq` | 1200 | Test frequency in MHz |
+| `bpsk-probe` | `--freq` | 1200 | Test frequency in MHz |
+
+## CSV Output Format
+
+All tests write the same CSV format when `--output` is specified:
+
+| Column | Description |
+|--------|-------------|
+| `timestamp` | ISO 8601 UTC timestamp |
+| `test_name` | Test identifier (agc_linearity, band_flatness, freq_accuracy, mds, bpsk_probe) |
+| `freq_mhz` | Frequency in MHz |
+| `atten_db` | HMC472A attenuation setting in dB |
+| `agc1` | BCM3440 AGC1 register value |
+| `agc2` | BCM3440 AGC2 register value |
+| `power_db` | Derived power estimate in dB (relative) |
+| `snr_raw` | Raw SNR register value |
+| `snr_db` | SNR in dB |
+| `locked` | Demodulator lock status |
+| `lock_raw` | Raw lock status byte |
+| `status` | Status byte |
+| `notes` | Test-specific metadata |
+
+## Interpreting Results
+
+### AGC Linearity Curves
+
+A well-behaved AGC should show a roughly linear relationship between attenuation (dB) and AGC
+register value. Look for:
+
+- **Linear region**: Where AGC tracks input power changes 1:1 in dB — this is the useful
+ measurement range
+- **Saturation**: Where adding more signal doesn't change AGC — the tuner's front end is
+ compressing
+- **Noise floor**: Where reducing signal doesn't change AGC — the receiver's internal noise
+ dominates
+
+### Band Flatness
+
+Ideal response is flat across the band. In practice:
+- **1-3 dB variation** across 950-1500 MHz is typical for a consumer-grade tuner
+- **Sharp dips** may indicate cable resonances or connector issues
+- **Systematic slope** (gain increasing or decreasing with frequency) is common and can be
+ corrected in post-processing
+
+### Frequency Error
+
+Consumer satellite tuners typically have **50-200 kHz frequency accuracy**. A consistent offset
+suggests LO error in the BCM3440. Frequency-dependent error suggests tuning nonlinearity.
+
+## Mock Mode
+
+Run with `SKYWALKER_MOCK=1` for testing without hardware:
+
+```bash
+SKYWALKER_MOCK=1 python tools/rf_testbench.py agc-linearity --freq 1200 --nanovna manual
+```
+
+Mock mode uses built-in simulated responses for the SkyWalker-1 and HMC472A. The NanoVNA prompts
+are skipped. Useful for verifying command structure and CSV output format.
+
+## See Also
+
+- [Spectrum Analysis](/tools/spectrum-analysis/) — frequency sweep techniques
+- [Hydrogen 21 cm](/tools/h21cm/) — direct L-band input mode (same RF path concept)
+- [Signal Monitoring](/bcm4500/signal-monitoring/) — AGC and SNR register details
+- [HMC472A Documentation](https://hmc472.l.zmesh.systems/) — attenuator module reference
+- [Applications & Use Cases](/guides/applications/) — RF test and measurement context
diff --git a/tools/rf_testbench.py b/tools/rf_testbench.py
new file mode 100644
index 0000000..f12d92b
--- /dev/null
+++ b/tools/rf_testbench.py
@@ -0,0 +1,837 @@
+#!/usr/bin/env python3
+"""
+RF Test Bench — CW injection tests with NanoVNA + HMC472A + SkyWalker-1.
+
+Injects CW signals from a NanoVNA-H through a programmable HMC472A digital
+attenuator into the SkyWalker-1 receiver. Runs automated test sequences to
+characterize AGC linearity, IF band flatness, frequency accuracy, minimum
+detectable signal, and BPSK mode 9 behavior.
+
+Hardware setup:
+ NanoVNA CH0 → DC Blocker → HMC472A → SMA-to-F → SkyWalker-1
+
+The HMC472A (0-31.5 dB, 0.5 dB steps) is controlled via its ESP32-S2 REST
+API. The NanoVNA provides CW at a fixed frequency, controlled either via
+mcnanovna or manually. The SkyWalker-1 measures AGC, SNR, and lock status.
+
+Usage:
+ python rf_testbench.py agc-linearity --freq 1200
+ python rf_testbench.py band-flatness --start 950 --stop 1500 --step 10
+ python rf_testbench.py freq-accuracy --freqs 1000,1200,1400
+ python rf_testbench.py mds --freq 1200
+ python rf_testbench.py bpsk-probe --freq 1200
+ python rf_testbench.py --help
+"""
+
+import sys
+import os
+import argparse
+import csv
+import json
+import time
+from datetime import datetime, timezone
+from urllib.request import urlopen, Request
+from urllib.error import URLError
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from skywalker_lib import SkyWalker1, MODULATIONS
+
+
+# --- HMC472A REST client ---
+
+class HMC472A:
+ """Control the HMC472A digital attenuator via its ESP32-S2 REST API."""
+
+ def __init__(self, base_url: str = "http://attenuator.local"):
+ self.base_url = base_url.rstrip("/")
+
+ def _get(self, path: str, retries: int = 3) -> dict:
+ url = f"{self.base_url}{path}"
+ req = Request(url)
+ last_err: OSError = OSError("no attempts made")
+ for attempt in range(retries):
+ try:
+ with urlopen(req, timeout=5) as resp:
+ return json.loads(resp.read())
+ except (URLError, OSError) as e:
+ last_err = e
+ if attempt < retries - 1:
+ time.sleep(0.2 * (attempt + 1))
+ raise last_err
+
+ def _post(self, path: str, data: dict, retries: int = 3) -> dict:
+ url = f"{self.base_url}{path}"
+ body = json.dumps(data).encode()
+ req = Request(url, data=body, method="POST",
+ headers={"Content-Type": "application/json"})
+ last_err: OSError = OSError("no attempts made")
+ for attempt in range(retries):
+ try:
+ with urlopen(req, timeout=5) as resp:
+ return json.loads(resp.read())
+ except (URLError, OSError) as e:
+ last_err = e
+ if attempt < retries - 1:
+ time.sleep(0.2 * (attempt + 1))
+ raise last_err
+
+ def status(self) -> dict:
+ return self._get("/status")
+
+ def set_db(self, attenuation_db: float) -> dict:
+ clamped = max(0.0, min(31.5, attenuation_db))
+ rounded = round(clamped * 2) / 2 # Snap to 0.5 dB steps
+ return self._post("/set", {"attenuation_db": rounded})
+
+ def config(self) -> dict:
+ return self._get("/config")
+
+
+class MockSkyWalker1:
+ """Lightweight mock SkyWalker-1 for rf_testbench testing."""
+
+ def __init__(self, verbose=False):
+ self.verbose = verbose
+ self._freq_khz = 0
+
+ def open(self):
+ pass
+
+ def close(self):
+ pass
+
+ def ensure_booted(self):
+ pass
+
+ def start_intersil(self, on=True):
+ pass
+
+ def tune_monitor(self, symbol_rate_sps=1000000, freq_khz=1200000,
+ mod_index=0, fec_index=5, dwell_ms=10):
+ self._freq_khz = freq_khz
+ # Simulate AGC response: higher freq → slightly lower power
+ base_agc1 = 1200 + (freq_khz - 1200000) // 100
+ return {
+ "snr_raw": 180, "snr_db": 7.8, "snr_pct": 39.0,
+ "agc1": max(100, base_agc1), "agc2": 750,
+ "power_db": -46.1 - (freq_khz - 1200000) / 500000,
+ "locked": False, "lock": 0x00, "status": 0x01,
+ "dwell_ms": dwell_ms,
+ }
+
+ def signal_monitor(self):
+ return {
+ "snr_raw": 200, "snr_db": 8.5, "snr_pct": 42.5,
+ "agc1": 1200, "agc2": 800, "power_db": -45.3,
+ "locked": False, "lock": 0x00, "status": 0x01,
+ }
+
+ def sweep_spectrum(self, start_mhz, stop_mhz, step_mhz=5.0,
+ dwell_ms=15, sr_ksps=1000, mod_index=0, fec_index=5):
+ n = int((stop_mhz - start_mhz) / step_mhz) + 1
+ freqs = [start_mhz + i * step_mhz for i in range(n)]
+ powers = [-50.0 + 3.0 * (1.0 - abs(f - 1200) / 300) for f in freqs]
+ raw = [{"agc1": 1200, "agc2": 750, "power_db": p,
+ "snr_raw": 0, "snr_db": 0, "locked": False,
+ "lock": 0, "status": 0} for p in powers]
+ return freqs, powers, raw
+
+
+def _make_mock_skywalker(verbose=False):
+ sw = MockSkyWalker1(verbose=verbose)
+ sw.open()
+ sw.ensure_booted()
+ return sw
+
+
+class MockHMC472A:
+ """Mock attenuator for testing without hardware."""
+
+ def __init__(self, base_url: str = "http://mock.local"):
+ self.base_url = base_url
+ self._db = 0.0
+
+ def status(self) -> dict:
+ step = int(self._db * 2)
+ return {"attenuation_db": self._db, "step": step, "version": "mock"}
+
+ def set_db(self, attenuation_db: float) -> dict:
+ self._db = max(0.0, min(31.5, round(attenuation_db * 2) / 2))
+ return self.status()
+
+ def config(self) -> dict:
+ return {"db_min": 0.0, "db_max": 31.5, "db_step": 0.5,
+ "version": "mock", "hostname": "mock-attenuator"}
+
+
+# --- NanoVNA control ---
+
+def try_import_nanovna():
+ """Try to import mcnanovna for automated NanoVNA control."""
+ try:
+ from mcnanovna.nanovna import NanoVNA
+ return NanoVNA
+ except ImportError:
+ return None
+
+
+class MockNanoVNA:
+ """Mock NanoVNA for testing without hardware."""
+
+ def cw(self, frequency_hz: int = 0, power: int = 3):
+ pass
+
+
+def manual_nanovna_set(freq_mhz: float, power: int = 3) -> None:
+ """Prompt the user to manually set NanoVNA CW frequency."""
+ print(f"\n >>> Set NanoVNA to CW at {freq_mhz:.3f} MHz, power={power}")
+ input(" Press Enter when ready...")
+
+
+# --- CSV output ---
+
+CSV_COLUMNS = [
+ "timestamp", "test_name", "freq_mhz", "atten_db",
+ "agc1", "agc2", "power_db", "snr_raw", "snr_db",
+ "locked", "lock_raw", "status", "notes",
+]
+
+
+def open_csv(path: str):
+ f = open(path, "w", newline="")
+ writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS)
+ writer.writeheader()
+ return f, writer
+
+
+def write_row(writer, csv_file, test_name: str, freq_mhz: float,
+ atten_db: float, result: dict, notes: str = ""):
+ writer.writerow({
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "test_name": test_name,
+ "freq_mhz": f"{freq_mhz:.3f}",
+ "atten_db": f"{atten_db:.1f}",
+ "agc1": result.get("agc1", 0),
+ "agc2": result.get("agc2", 0),
+ "power_db": f"{result.get('power_db', 0):.2f}",
+ "snr_raw": result.get("snr_raw", 0),
+ "snr_db": f"{result.get('snr_db', 0):.2f}",
+ "locked": result.get("locked", False),
+ "lock_raw": f"0x{result.get('lock', 0):02X}",
+ "status": f"0x{result.get('status', 0):02X}",
+ "notes": notes,
+ })
+ if csv_file:
+ csv_file.flush()
+
+
+# --- Calibration ---
+
+def load_cal_file(path: str) -> dict:
+ """Load a NanoVNA S21 path-loss calibration CSV.
+
+ Expects columns: frequency_hz (or freq_mhz), s21_db (or loss_db).
+ Returns dict mapping freq_mhz -> loss_db (positive = loss).
+ """
+ cal = {}
+ with open(path) as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ if "freq_mhz" in row:
+ freq = float(row["freq_mhz"])
+ elif "frequency_hz" in row:
+ freq = float(row["frequency_hz"]) / 1e6
+ else:
+ continue
+
+ if "loss_db" in row:
+ loss = float(row["loss_db"])
+ elif "s21_db" in row:
+ loss = -float(row["s21_db"]) # S21 is negative, loss is positive
+ else:
+ continue
+ cal[freq] = loss
+ return cal
+
+
+def interpolate_loss(cal: dict, freq_mhz: float) -> float:
+ """Interpolate path loss at a frequency from cal data."""
+ if not cal:
+ return 0.0
+ freqs = sorted(cal.keys())
+ if freq_mhz <= freqs[0]:
+ return cal[freqs[0]]
+ if freq_mhz >= freqs[-1]:
+ return cal[freqs[-1]]
+ for i in range(len(freqs) - 1):
+ if freqs[i] <= freq_mhz <= freqs[i + 1]:
+ f0, f1 = freqs[i], freqs[i + 1]
+ t = (freq_mhz - f0) / (f1 - f0)
+ return cal[f0] + t * (cal[f1] - cal[f0])
+ return 0.0
+
+
+# --- Test: AGC Power Linearity ---
+
+def test_agc_linearity(sw, atten, nanovna, freq_mhz: float,
+ writer, csv_file, cal: dict, settle_ms: int) -> list:
+ """Sweep attenuator from 0 to 31.5 dB and record AGC at each step.
+
+ Maps the AGC transfer function: how AGC register values respond to
+ known changes in input power.
+ """
+ print(f"\n=== AGC Linearity Test at {freq_mhz:.1f} MHz ===")
+ results = []
+ path_loss = interpolate_loss(cal, freq_mhz)
+ if path_loss > 0:
+ print(f" Calibrated path loss: {path_loss:.1f} dB")
+
+ # Set NanoVNA to CW at the test frequency
+ if nanovna:
+ nanovna.cw(frequency_hz=int(freq_mhz * 1e6), power=3)
+ print(f" NanoVNA CW: {freq_mhz:.3f} MHz, power=3")
+ else:
+ manual_nanovna_set(freq_mhz, power=3)
+
+ # Tune SkyWalker-1 to the frequency
+ freq_khz = int(freq_mhz * 1000)
+
+ print(f"\n {'Atten dB':>9} {'AGC1':>6} {'AGC2':>6} {'Power dB':>9} "
+ f"{'SNR raw':>8} {'Lock':>5}")
+ print(f" {'─' * 9} {'─' * 6} {'─' * 6} {'─' * 9} {'─' * 8} {'─' * 5}")
+
+ # Sweep in 0.5 dB steps from 0 to 31.5 dB (64 steps)
+ # Use integer step counter to avoid IEEE 754 float accumulation drift
+ for step in range(64): # 0, 1, 2, ... 63 → 0.0, 0.5, 1.0, ... 31.5
+ atten_db = step * 0.5
+ atten.set_db(atten_db)
+ time.sleep(settle_ms / 1000.0)
+
+ result = sw.tune_monitor(
+ symbol_rate_sps=1000000, freq_khz=freq_khz,
+ mod_index=0, fec_index=5, dwell_ms=50
+ )
+
+ locked = "Y" if result.get("locked") else "N"
+ print(f" {atten_db:9.1f} {result['agc1']:6d} {result['agc2']:6d} "
+ f"{result['power_db']:9.2f} {result['snr_raw']:8d} {locked:>5}")
+
+ effective_atten = atten_db + path_loss
+ note = f"effective_atten={effective_atten:.1f}dB"
+ if writer:
+ write_row(writer, csv_file, "agc_linearity", freq_mhz, atten_db,
+ result, note)
+
+ results.append({"atten_db": atten_db, **result})
+
+ # Summary
+ if results:
+ agc1_min = min(r["agc1"] for r in results)
+ agc1_max = max(r["agc1"] for r in results)
+ print(f"\n AGC1 range: {agc1_min} - {agc1_max} "
+ f"(delta={agc1_max - agc1_min}) over 31.5 dB sweep")
+
+ return results
+
+
+# --- Test: IF Band Flatness ---
+
+def test_band_flatness(sw, atten, nanovna, start_mhz: float,
+ stop_mhz: float, step_mhz: float,
+ writer, csv_file, cal: dict, settle_ms: int) -> list:
+ """Sweep CW across the IF band and record AGC at each frequency.
+
+ Reveals tuner gain slope, passband ripple, and the IF filter response.
+ """
+ atten_db = 10.0 # Fixed attenuation — mid-range for good dynamic range
+ print(f"\n=== IF Band Flatness: {start_mhz:.0f}-{stop_mhz:.0f} MHz, "
+ f"step={step_mhz:.1f} MHz ===")
+ print(f" HMC472A fixed at {atten_db:.1f} dB")
+
+ atten.set_db(atten_db)
+ results = []
+
+ # Use integer step counter to avoid float accumulation drift
+ n_steps = int(round((stop_mhz - start_mhz) / step_mhz)) + 1
+
+ print(f"\n {'Step':>5} {'Freq MHz':>9} {'AGC1':>6} {'AGC2':>6} "
+ f"{'Power dB':>9} {'PathLoss':>9} {'Corr dB':>8}")
+ print(f" {'─' * 5} {'─' * 9} {'─' * 6} {'─' * 6} "
+ f"{'─' * 9} {'─' * 9} {'─' * 8}")
+
+ for step_num in range(n_steps):
+ freq_mhz = start_mhz + step_num * step_mhz
+
+ # Set NanoVNA CW
+ if nanovna:
+ nanovna.cw(frequency_hz=int(freq_mhz * 1e6), power=3)
+ else:
+ manual_nanovna_set(freq_mhz, power=3)
+
+ time.sleep(settle_ms / 1000.0)
+
+ # Tune SkyWalker-1
+ freq_khz = int(freq_mhz * 1000)
+ result = sw.tune_monitor(
+ symbol_rate_sps=1000000, freq_khz=freq_khz,
+ mod_index=0, fec_index=5, dwell_ms=50
+ )
+
+ path_loss = interpolate_loss(cal, freq_mhz)
+ corrected = result["power_db"] + path_loss
+
+ print(f" {step_num + 1:5d} {freq_mhz:9.1f} {result['agc1']:6d} "
+ f"{result['agc2']:6d} {result['power_db']:9.2f} "
+ f"{path_loss:9.1f} {corrected:8.2f}")
+
+ note = f"path_loss={path_loss:.1f}dB corrected={corrected:.2f}dB"
+ if writer:
+ write_row(writer, csv_file, "band_flatness", freq_mhz, atten_db,
+ result, note)
+
+ results.append({"freq_mhz": freq_mhz, "corrected_db": corrected, **result})
+
+ # Summary
+ if results:
+ powers = [r["corrected_db"] for r in results]
+ ripple = max(powers) - min(powers)
+ print(f"\n Band flatness: {ripple:.2f} dB ripple "
+ f"(min={min(powers):.2f}, max={max(powers):.2f})")
+
+ return results
+
+
+# --- Test: Frequency Accuracy ---
+
+def test_freq_accuracy(sw, atten, nanovna, test_freqs: list,
+ writer, csv_file, settle_ms: int) -> list:
+ """Inject CW at known frequencies, sweep SkyWalker-1 around each one.
+
+ Compares detected peak vs. injected frequency to characterize the
+ BCM3440 tuner's frequency accuracy.
+ """
+ print(f"\n=== Frequency Accuracy Test ===")
+ atten_db = 10.0
+ atten.set_db(atten_db)
+
+ results = []
+ sweep_span_mhz = 10.0 # Sweep +/- 5 MHz around each test freq
+ sweep_step_mhz = 1.0
+
+ for inject_freq in test_freqs:
+ print(f"\n Injecting CW at {inject_freq:.3f} MHz...")
+ if nanovna:
+ nanovna.cw(frequency_hz=int(inject_freq * 1e6), power=3)
+ else:
+ manual_nanovna_set(inject_freq, power=3)
+
+ time.sleep(settle_ms / 1000.0)
+
+ # Sweep around the expected frequency
+ sweep_start = inject_freq - sweep_span_mhz / 2
+ sweep_stop = inject_freq + sweep_span_mhz / 2
+
+ freqs, powers, raw = sw.sweep_spectrum(
+ sweep_start, sweep_stop,
+ step_mhz=sweep_step_mhz, dwell_ms=50,
+ sr_ksps=1000, mod_index=0, fec_index=5,
+ )
+
+ # Find peak
+ if powers:
+ peak_idx = max(range(len(powers)), key=lambda i: powers[i])
+ peak_freq = freqs[peak_idx]
+ peak_power = powers[peak_idx]
+ error_mhz = peak_freq - inject_freq
+ error_khz = error_mhz * 1000
+
+ print(f" Injected: {inject_freq:.3f} MHz "
+ f"Detected peak: {peak_freq:.3f} MHz "
+ f"Error: {error_khz:+.0f} kHz")
+
+ result_entry = {
+ "inject_freq_mhz": inject_freq,
+ "peak_freq_mhz": peak_freq,
+ "error_khz": error_khz,
+ "peak_power_db": peak_power,
+ }
+ results.append(result_entry)
+
+ if writer:
+ peak_result = raw[peak_idx] if isinstance(raw[peak_idx], dict) else {
+ "agc1": 0, "agc2": 0, "power_db": peak_power,
+ "snr_raw": 0, "snr_db": 0,
+ "locked": False, "lock": 0, "status": 0,
+ }
+ write_row(writer, csv_file, "freq_accuracy", inject_freq,
+ atten_db, peak_result,
+ f"peak={peak_freq:.3f}MHz error={error_khz:+.0f}kHz")
+
+ # Summary
+ if results:
+ errors = [r["error_khz"] for r in results]
+ mean_err = sum(errors) / len(errors)
+ max_err = max(abs(e) for e in errors)
+ print(f"\n Mean frequency error: {mean_err:+.0f} kHz")
+ print(f" Max absolute error: {max_err:.0f} kHz")
+
+ return results
+
+
+# --- Test: Minimum Detectable Signal ---
+
+def test_mds(sw, atten, nanovna, freq_mhz: float,
+ writer, csv_file, settle_ms: int) -> dict:
+ """Find the minimum detectable signal level.
+
+ Measures noise floor with NanoVNA off (or max attenuation), then
+ increases attenuation from 0 until the CW signal is indistinguishable
+ from noise.
+ """
+ print(f"\n=== Minimum Detectable Signal at {freq_mhz:.1f} MHz ===")
+ freq_khz = int(freq_mhz * 1000)
+
+ # Step 1: measure noise floor (max attenuation)
+ print(" Measuring noise floor (31.5 dB attenuation)...")
+ atten.set_db(31.5)
+ time.sleep(0.2)
+
+ noise_readings = []
+ for _ in range(10):
+ r = sw.tune_monitor(1000000, freq_khz, 0, 5, 50)
+ noise_readings.append(r["power_db"])
+ time.sleep(0.05)
+
+ noise_floor = sum(noise_readings) / len(noise_readings)
+ noise_std = (sum((x - noise_floor) ** 2 for x in noise_readings)
+ / len(noise_readings)) ** 0.5
+ threshold = noise_floor + max(3.0 * noise_std, 1.0) # 3-sigma above noise
+ print(f" Noise floor: {noise_floor:.2f} dB (std={noise_std:.3f})")
+ print(f" Detection threshold: {threshold:.2f} dB (noise + 3sigma)")
+
+ # Step 2: inject CW and increase attenuation until signal disappears
+ if nanovna:
+ nanovna.cw(frequency_hz=int(freq_mhz * 1e6), power=3)
+ print(f" NanoVNA CW: {freq_mhz:.3f} MHz, power=3")
+ else:
+ manual_nanovna_set(freq_mhz, power=3)
+
+ print(f"\n {'Atten dB':>9} {'Power dB':>9} {'Above noise':>12} {'Detected':>9}")
+ print(f" {'─' * 9} {'─' * 9} {'─' * 12} {'─' * 9}")
+
+ mds_atten = None
+ # 1 dB steps: 0, 1, 2, ... 31 (32 steps)
+ for step in range(32):
+ atten_db = float(step)
+ atten.set_db(atten_db)
+ time.sleep(settle_ms / 1000.0)
+
+ # Average 5 readings for stability
+ readings = []
+ r = sw.tune_monitor(1000000, freq_khz, 0, 5, 50)
+ readings.append(r["power_db"])
+ for _ in range(4):
+ time.sleep(0.02)
+ r = sw.tune_monitor(1000000, freq_khz, 0, 5, 50)
+ readings.append(r["power_db"])
+
+ avg_power = sum(readings) / len(readings)
+ above_noise = avg_power - noise_floor
+ detected = avg_power > threshold
+
+ marker = "YES" if detected else "---"
+ print(f" {atten_db:9.1f} {avg_power:9.2f} {above_noise:+12.2f} "
+ f"{marker:>9}")
+
+ if writer:
+ # Use averaged power instead of last single reading
+ avg_result = dict(r)
+ avg_result["power_db"] = avg_power
+ write_row(writer, csv_file, "mds", freq_mhz, atten_db,
+ avg_result,
+ f"avg={avg_power:.2f} noise={noise_floor:.2f} "
+ f"detected={'Y' if detected else 'N'}")
+
+ if not detected and mds_atten is None:
+ mds_atten = atten_db
+
+ result = {
+ "freq_mhz": freq_mhz,
+ "noise_floor_db": noise_floor,
+ "noise_std": noise_std,
+ "threshold_db": threshold,
+ "mds_atten_db": mds_atten,
+ }
+
+ if mds_atten is not None:
+ print(f"\n Signal lost at {mds_atten:.1f} dB attenuation")
+ print(f" (NanoVNA output ~-15 dBm minus {mds_atten:.1f} dB path = "
+ f"~{-15 - mds_atten:.0f} dBm at receiver)")
+ else:
+ print(f"\n Signal detected at all attenuation levels (0-31.5 dB)")
+ print(f" Need more attenuation to find MDS")
+
+ return result
+
+
+# --- Test: BPSK Mode 9 CW Probe ---
+
+def test_bpsk_probe(sw, atten, nanovna, freq_mhz: float,
+ writer, csv_file, settle_ms: int) -> dict:
+ """Probe BPSK mode 9 response to an unmodulated CW carrier.
+
+ BPSK mode (index 9) uses Viterbi rate 1/2 K=7 — the same inner FEC
+ as GOES LRIT. A CW carrier has no modulation, so the demodulator
+ shouldn't lock, but the AGC and carrier recovery behavior reveals
+ how mode 9 handles a clean carrier.
+ """
+ print(f"\n=== BPSK Mode 9 CW Probe at {freq_mhz:.1f} MHz ===")
+ bpsk_index = MODULATIONS["bpsk"][0] # Mode 9
+ freq_khz = int(freq_mhz * 1000)
+ atten_db = 10.0
+ atten.set_db(atten_db)
+
+ if nanovna:
+ nanovna.cw(frequency_hz=int(freq_mhz * 1e6), power=3)
+ else:
+ manual_nanovna_set(freq_mhz, power=3)
+
+ time.sleep(settle_ms / 1000.0)
+
+ # Test with different symbol rates typical of LRIT-like signals
+ test_rates = [293883, 500000, 1000000, 5000000]
+
+ print(f"\n {'SR (sps)':>10} {'AGC1':>6} {'AGC2':>6} {'Power dB':>9} "
+ f"{'SNR raw':>8} {'SNR dB':>7} {'Lock':>6} {'Status':>8}")
+ print(f" {'─' * 10} {'─' * 6} {'─' * 6} {'─' * 9} "
+ f"{'─' * 8} {'─' * 7} {'─' * 6} {'─' * 8}")
+
+ results = []
+ for sr in test_rates:
+ # FEC 1/2 (index 0) for BPSK mode
+ result = sw.tune_monitor(sr, freq_khz, bpsk_index, 0, dwell_ms=100)
+
+ locked = "Y" if result.get("locked") else "N"
+ print(f" {sr:10d} {result['agc1']:6d} {result['agc2']:6d} "
+ f"{result['power_db']:9.2f} {result['snr_raw']:8d} "
+ f"{result['snr_db']:7.2f} {locked:>6} "
+ f"0x{result.get('status', 0):02X}")
+
+ if writer:
+ write_row(writer, csv_file, "bpsk_probe", freq_mhz, atten_db,
+ result, f"mode=bpsk sr={sr} fec=1/2")
+
+ results.append({"symbol_rate": sr, **result})
+
+ # Compare with QPSK mode 0 at same settings
+ print(f"\n Reference: QPSK mode 0 at same frequency")
+ ref = sw.tune_monitor(1000000, freq_khz, 0, 5, dwell_ms=100)
+ ref_locked = "Y" if ref.get("locked") else "N"
+ print(f" {'1000000':>10} {ref['agc1']:6d} {ref['agc2']:6d} "
+ f"{ref['power_db']:9.2f} {ref['snr_raw']:8d} "
+ f"{ref['snr_db']:7.2f} {ref_locked:>6} "
+ f"0x{ref.get('status', 0):02X}")
+
+ return {"bpsk_results": results, "qpsk_reference": ref}
+
+
+# --- Main ---
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="rf_testbench.py",
+ description="CW injection test bench: NanoVNA + HMC472A + SkyWalker-1",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""\
+examples:
+ %(prog)s agc-linearity --freq 1200
+ %(prog)s band-flatness --start 950 --stop 1500 --step 10
+ %(prog)s freq-accuracy --freqs 1000,1200,1400
+ %(prog)s mds --freq 1200
+ %(prog)s bpsk-probe --freq 1200
+ %(prog)s band-flatness --nanovna auto --attenuator http://10.0.0.50
+
+hardware setup:
+ NanoVNA CH0 → DC Blocker → HMC472A (0-31.5 dB) → SMA-to-F → SkyWalker-1
+
+ The HMC472A is controlled via its ESP32-S2 REST API.
+ The NanoVNA provides CW, controlled via mcnanovna or manually.
+ LNB power is disabled (direct L-band input mode).
+""",
+ )
+
+ parser.add_argument("-v", "--verbose", action="store_true",
+ help="Show raw USB traffic")
+ parser.add_argument("-o", "--output", type=str, default=None,
+ help="CSV output file path")
+ parser.add_argument("--cal", type=str, default=None,
+ help="Path loss calibration CSV (NanoVNA S21 sweep)")
+ parser.add_argument("--attenuator", type=str,
+ default="http://attenuator.local",
+ help="HMC472A REST API base URL "
+ "(default: http://attenuator.local)")
+ parser.add_argument("--nanovna", choices=["auto", "manual"],
+ default="auto",
+ help="NanoVNA control mode (default: auto via mcnanovna)")
+ parser.add_argument("--settle", type=int, default=200,
+ help="Settle time in ms after changing attenuation "
+ "(default: 200)")
+
+ sub = parser.add_subparsers(dest="test", required=True)
+
+ # AGC linearity
+ p_agc = sub.add_parser("agc-linearity",
+ help="Sweep attenuation at fixed freq, map AGC curve")
+ p_agc.add_argument("--freq", type=float, default=1200.0,
+ help="Test frequency in MHz (default: 1200)")
+
+ # Band flatness
+ p_band = sub.add_parser("band-flatness",
+ help="Sweep CW across IF band, measure AGC response")
+ p_band.add_argument("--start", type=float, default=950.0,
+ help="Start frequency in MHz (default: 950)")
+ p_band.add_argument("--stop", type=float, default=1500.0,
+ help="Stop frequency in MHz (default: 1500)")
+ p_band.add_argument("--step", type=float, default=10.0,
+ help="Frequency step in MHz (default: 10)")
+
+ # Frequency accuracy
+ p_freq = sub.add_parser("freq-accuracy",
+ help="Inject CW at known freqs, measure error")
+ p_freq.add_argument("--freqs", type=str, default="1000,1100,1200,1300,1400",
+ help="Comma-separated test frequencies in MHz")
+
+ # Minimum detectable signal
+ p_mds = sub.add_parser("mds",
+ help="Find minimum detectable signal level")
+ p_mds.add_argument("--freq", type=float, default=1200.0,
+ help="Test frequency in MHz (default: 1200)")
+
+ # BPSK mode 9 probe
+ p_bpsk = sub.add_parser("bpsk-probe",
+ help="Probe BPSK mode 9 with CW carrier")
+ p_bpsk.add_argument("--freq", type=float, default=1200.0,
+ help="Test frequency in MHz (default: 1200)")
+
+ return parser
+
+
+def main():
+ parser = build_parser()
+ args = parser.parse_args()
+
+ # Mock mode for testing without hardware
+ mock_mode = os.environ.get("SKYWALKER_MOCK")
+
+ # Set up HMC472A attenuator
+ if mock_mode:
+ atten = MockHMC472A()
+ print("HMC472A: mock mode")
+ else:
+ atten = HMC472A(args.attenuator)
+ try:
+ cfg = atten.config()
+ print(f"HMC472A: connected ({cfg.get('hostname', '?')}, "
+ f"v{cfg.get('version', '?')})")
+ except (URLError, OSError) as e:
+ print(f"HMC472A: cannot reach {args.attenuator} ({e})")
+ print(" Check network connection or use --attenuator ")
+ sys.exit(1)
+
+ # Set up NanoVNA
+ nanovna = None
+ if mock_mode:
+ nanovna = MockNanoVNA()
+ print("NanoVNA: mock mode")
+ elif args.nanovna == "auto":
+ NanoVNA = try_import_nanovna()
+ if NanoVNA:
+ try:
+ nanovna = NanoVNA()
+ print(f"NanoVNA: auto mode (mcnanovna)")
+ except Exception as e:
+ print(f"NanoVNA: mcnanovna failed ({e}), falling back to manual")
+ else:
+ print("NanoVNA: mcnanovna not installed, using manual mode")
+ print(" Install: uv pip install -e /path/to/mcnanovna")
+ else:
+ print("NanoVNA: manual mode (you'll be prompted to set frequencies)")
+
+ # Load calibration
+ cal = {}
+ if args.cal:
+ cal = load_cal_file(args.cal)
+ print(f"Calibration: loaded {len(cal)} points from {args.cal}")
+
+ # Open CSV output
+ csv_file = None
+ writer = None
+ if args.output:
+ csv_file, writer = open_csv(args.output)
+
+ # Open SkyWalker-1
+ # SAFETY: Boot demodulator WITHOUT enabling LNB power. ensure_booted()
+ # transiently enables LNB voltage (13-18V on the F-connector), which
+ # would travel backward through the attenuator toward the NanoVNA.
+ # The DC blocker protects against this, but code should never rely on
+ # external protection it cannot verify.
+ if mock_mode:
+ sw = _make_mock_skywalker(args.verbose)
+ print("SkyWalker-1: mock mode")
+ else:
+ sw = SkyWalker1(verbose=args.verbose)
+ sw.open()
+ # Ensure LNB power is OFF before booting demodulator
+ sw.start_intersil(on=False)
+ status = sw.get_config()
+ if not (status & 0x01):
+ sw.boot(on=True)
+ time.sleep(0.5)
+ status = sw.get_config()
+ if not (status & 0x01):
+ print("ERROR: Device failed to start")
+ sys.exit(1)
+ print("SkyWalker-1: booted (LNB power kept OFF)")
+
+ # Confirm LNB power disabled — direct input mode
+ sw.start_intersil(on=False)
+ print("LNB power disabled (direct input mode)")
+ print()
+
+ try:
+ if args.test == "agc-linearity":
+ test_agc_linearity(sw, atten, nanovna, args.freq,
+ writer, csv_file, cal, args.settle)
+ elif args.test == "band-flatness":
+ test_band_flatness(sw, atten, nanovna, args.start, args.stop,
+ args.step, writer, csv_file, cal, args.settle)
+ elif args.test == "freq-accuracy":
+ freqs = [float(f) for f in args.freqs.split(",")]
+ test_freq_accuracy(sw, atten, nanovna, freqs,
+ writer, csv_file, args.settle)
+ elif args.test == "mds":
+ test_mds(sw, atten, nanovna, args.freq,
+ writer, csv_file, args.settle)
+ elif args.test == "bpsk-probe":
+ test_bpsk_probe(sw, atten, nanovna, args.freq,
+ writer, csv_file, args.settle)
+ except KeyboardInterrupt:
+ print("\n\nInterrupted by operator.")
+ finally:
+ # Safe state: maximum attenuation before releasing hardware
+ try:
+ atten.set_db(31.5)
+ print("Attenuator set to 31.5 dB (safe state)")
+ except Exception:
+ pass # best-effort on cleanup path
+ if csv_file:
+ csv_file.flush()
+ csv_file.close()
+ print(f"\nData saved to {args.output}")
+ if not mock_mode:
+ sw.close()
+
+
+if __name__ == "__main__":
+ main()