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:
parent
1df2be8a43
commit
d117782dcf
@ -122,6 +122,7 @@ export default defineConfig({
|
|||||||
{ label: 'TS Analyzer', slug: 'tools/ts-analyzer' },
|
{ label: 'TS Analyzer', slug: 'tools/ts-analyzer' },
|
||||||
{ label: 'Spectrum Analysis', slug: 'tools/spectrum-analysis' },
|
{ label: 'Spectrum Analysis', slug: 'tools/spectrum-analysis' },
|
||||||
{ label: 'Hydrogen 21 cm', slug: 'tools/h21cm' },
|
{ label: 'Hydrogen 21 cm', slug: 'tools/h21cm' },
|
||||||
|
{ label: 'RF Test Bench', slug: 'tools/rf-testbench' },
|
||||||
{ label: 'Beacon Logger', slug: 'tools/beacon-logger' },
|
{ label: 'Beacon Logger', slug: 'tools/beacon-logger' },
|
||||||
{ label: 'Arc Survey', slug: 'tools/arc-survey' },
|
{ label: 'Arc Survey', slug: 'tools/arc-survey' },
|
||||||
{ label: 'MCP Server', slug: 'tools/mcp-server' },
|
{ label: 'MCP Server', slug: 'tools/mcp-server' },
|
||||||
@ -131,6 +132,7 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
label: 'Guides',
|
label: 'Guides',
|
||||||
items: [
|
items: [
|
||||||
|
{ label: 'Applications & Use Cases', slug: 'guides/applications' },
|
||||||
{ label: 'QO-100 DATV Reception', slug: 'guides/qo100-datv' },
|
{ label: 'QO-100 DATV Reception', slug: 'guides/qo100-datv' },
|
||||||
{ label: "Experimenter's Roadmap", slug: 'guides/experimenter-roadmap' },
|
{ label: "Experimenter's Roadmap", slug: 'guides/experimenter-roadmap' },
|
||||||
],
|
],
|
||||||
|
|||||||
322
site/src/content/docs/guides/applications.mdx
Normal file
322
site/src/content/docs/guides/applications.mdx
Normal 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°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.
|
||||||
|
</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°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.
|
||||||
|
</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°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
|
||||||
@ -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.
|
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
|
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 —
|
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.
|
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Ω. Most L-band antennas and amateur radio feedlines are 50Ω.
|
||||||
|
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Ω 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
|
## Quick Start
|
||||||
|
|
||||||
|
|||||||
260
site/src/content/docs/tools/rf-testbench.mdx
Normal file
260
site/src/content/docs/tools/rf-testbench.mdx
Normal 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
837
tools/rf_testbench.py
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user