Add RF test bench tool for CW injection tests with NanoVNA + HMC472A

New tool (tools/rf_testbench.py) automates five test sequences using a
NanoVNA as a CW source and HMC472A digital attenuator (0-31.5 dB, 0.5 dB
steps via REST API) to characterize the SkyWalker-1 receiver:

- AGC linearity mapping across 64 attenuation steps
- IF band flatness sweep (950-1500 MHz)
- Frequency accuracy via peak detection
- Minimum detectable signal search
- BPSK mode 9 CW probe (Viterbi rate 1/2 K=7)

Includes SKYWALKER_MOCK=1 mode, path-loss calibration from NanoVNA S21
sweeps, and safe-state cleanup (attenuator to max on exit, LNB power
never enabled in direct-input mode).

Also adds Applications & Use Cases guide, RF Test Bench docs page, fixes
h21cm cable loss (was 3x too high), and updates sidebar.
This commit is contained in:
Ryan Malloy 2026-02-17 23:11:09 -07:00
parent 1df2be8a43
commit d117782dcf
5 changed files with 1473 additions and 2 deletions

View File

@ -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' },
],

View File

@ -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
<Tabs>
<TabItem label="Ku-Band">
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&deg;W | The FTA motherlode. ~135+ channels: Chinese, Korean, South Asian, religious, shopping, some English |
| Galaxy 16 | 99.0&deg;W | Religious programming, international |
| SES-2 | 87.0&deg;W | International, government |
| AMC-18 | 105.0&deg;W | Mixed FTA and encrypted |
Typical tuning parameters: 11836 MHz V-pol, 20770 ksps, DVB-S QPSK FEC 3/4.
</TabItem>
<TabItem label="C-Band">
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&deg;W | DCII cable distribution, some FTA |
| SES-2 | 87.0&deg;W | International, government feeds |
| Galaxy 16 | 99.0&deg;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.
</TabItem>
</Tabs>
### 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
<Aside type="caution" title="DVB-S2 is not supported">
An increasing percentage of satellite content uses **DVB-S2**, which relies on LDPC forward error correction
instead of the Reed-Solomon/Viterbi scheme the BCM4500 implements. The SkyWalker-1 can detect DVB-S2
carriers as RF energy (they show up in spectrum sweeps), but it cannot demodulate or decode them.
If a transponder listing says "DVB-S2" or "8PSK" (in the DVB-S2 sense, not Turbo 8PSK), it won't work.
</Aside>
## 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.
<CardGrid>
<Card title="DigiCipher II" icon="rocket">
Cable headend distribution format (Comcast HITS, Motorola). One of very few modern devices with DCII
support. "Zero Key" unencrypted services are directly receivable.
</Card>
<Card title="DSS" icon="star">
Digital Satellite Service — legacy DirecTV format with 127-byte transport packets (vs 188-byte DVB).
Extraordinarily rare outside DirecTV hardware.
</Card>
<Card title="Turbo 8PSK" icon="setting">
DISH Network transponder format. Encrypted content, but demodulator lock and transport stream capture
work — useful for signal analysis and protocol research.
</Card>
<Card title="Turbo QPSK" icon="open-book">
Earlier turbo-coded variant. Better spectral efficiency than standard DVB-S QPSK, still used on
some distribution paths.
</Card>
</CardGrid>
### 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.
<Aside type="tip" title="Community resource">
[Rick's Satellite Wildfeed Forum](https://www.satelliteguys.us/xen/forums/wild-feeds.42/) on SatelliteGuys
is the primary community hub for reporting and tracking wild feeds on North American satellites.
</Aside>
## 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
<Badge text="tools/h21cm.py" variant="note" />
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
<Badge text="tools/skywalker.py spectrum" variant="note" />
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
<Badge text="tools/rf_testbench.py" variant="note" />
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
<Badge text="tools/beacon_logger.py" variant="note" />
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.
<Aside type="danger" title="Hardware limitations">
The following are common requests that the SkyWalker-1 **cannot** fulfill. Understanding these
boundaries prevents wasted time and money on incompatible setups.
</Aside>
| 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&deg;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
<Tabs>
<TabItem label="By Standard">
| 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 |
</TabItem>
<TabItem label="By Receiver Use">
| 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 |
</TabItem>
</Tabs>
## 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

View File

@ -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
<CardGrid>
<Card title="Horn Antenna" icon="rocket">
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.
</Card>
<Card title="Dish + L-Band Feed" icon="star">
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.
</Card>
<Card title="Helical Antenna" icon="setting">
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.
</Card>
<Card title="Patch Antenna" icon="open-book">
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.
</Card>
</CardGrid>
<Aside type="note" title="Impedance mismatch">
The SkyWalker-1's F-connector is 75&Omega;. Most L-band antennas and amateur radio feedlines are 50&Omega;.
The resulting 1.5:1 VSWR costs about **0.2 dB** of mismatch loss — negligible for AGC power detection.
No matching network is needed. Use an SMA-to-F adapter if your antenna has an SMA connector.
</Aside>
### Cable and Connectors
Run 75&Omega; 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&Omega; 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

View File

@ -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';
<Badge text="tools/rf_testbench.py" variant="note" />
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
<Steps>
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)
</Steps>
```
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 |
<Aside type="danger" title="DC blocker is required">
The SkyWalker-1 can supply 13-18V DC through the F-connector for LNB power. Although `rf_testbench.py`
disables LNB power on startup, a bug, power glitch, or running a different tool without disconnecting
could send DC voltage backward through the signal path. The DC blocker prevents this from reaching
the HMC472A and NanoVNA.
</Aside>
### 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:
<Steps>
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`
</Steps>
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.
<Aside type="tip" title="HMC472A insertion loss">
The HMC472A adds 1.4-1.9 dB of insertion loss even at 0 dB attenuation setting. The calibration
sweep captures this automatically since the signal passes through the attenuator during the S21
measurement.
</Aside>
## 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

837
tools/rf_testbench.py Normal file
View File

@ -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 <url>")
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()