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()