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: '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' },
|
||||
],
|
||||
|
||||
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.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
|
||||
The `h21cm.py` tool turns the SkyWalker-1 into a hydrogen line radiometer. Neutral hydrogen
|
||||
atoms emit radiation at **1420.405 MHz** when the electron's spin flips relative to the proton —
|
||||
the most fundamental spectral line in radio astronomy, and it falls directly in the IF range.
|
||||
|
||||
No LNB is needed. Connect an L-band antenna directly to the F-connector.
|
||||
## Antenna Setup
|
||||
|
||||
The SkyWalker-1 normally receives satellite TV through an LNB (Low Noise Block downconverter) mounted
|
||||
at the focal point of a dish. For hydrogen line work, the LNB must be **removed or bypassed entirely** —
|
||||
it would block the signal. An LNB's waveguide feed is dimensioned for Ku-band wavelengths (~2.5 cm)
|
||||
and its internal filters reject everything outside the 10.7-12.75 GHz range. At 1420 MHz (wavelength
|
||||
~21 cm), nothing gets through.
|
||||
|
||||
Instead, connect an L-band antenna directly to the SkyWalker-1's F-connector with coaxial cable.
|
||||
The tool disables LNB power automatically, so there's no voltage on the cable.
|
||||
|
||||
### Antenna Options
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Horn Antenna" icon="rocket">
|
||||
The classic radio astronomy choice. A tin-can "cantenna" or sheet-metal pyramidal horn provides
|
||||
10-15 dBi gain with predictable, calculable performance. Easy to build from hardware store materials.
|
||||
A circular waveguide horn from a ~15 cm diameter can works well at 1420 MHz.
|
||||
</Card>
|
||||
<Card title="Dish + L-Band Feed" icon="star">
|
||||
Reuse your satellite dish — replace the LNB with a 1420 MHz feed (dipole + reflector, or a small
|
||||
horn at the focal point). The dish surface accuracy matters less at 21 cm wavelength than at Ku-band,
|
||||
so even mesh dishes work fine. This gives the highest gain of any option here.
|
||||
</Card>
|
||||
<Card title="Helical Antenna" icon="setting">
|
||||
A helical antenna is circularly polarized and offers good gain in a compact package. Hydrogen emission
|
||||
is unpolarized, so a circularly-polarized antenna captures half the power (~3 dB penalty vs linear),
|
||||
but helix construction is forgiving and well-documented for L-band.
|
||||
</Card>
|
||||
<Card title="Patch Antenna" icon="open-book">
|
||||
Commercial L-band patch antennas (GPS antennas at 1575 MHz are close) are compact and cheap. Lower
|
||||
gain than the other options (~5-7 dBi), and narrow bandwidth may not cover the full hydrogen emission
|
||||
profile. Fine for a first detection attempt.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
<Aside type="note" title="Impedance mismatch">
|
||||
The SkyWalker-1's F-connector is 75Ω. 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
|
||||
|
||||
|
||||
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