VIPIR: Vertical Incidence Pulsed Ionospheric Radar¶
RIQ File Format, SCT/PCT Structures, and Ionogram Analysis
VIPIR is a pulsed HF radar system for vertical-incidence ionospheric sounding. Pynasonde reads its binary RIQ files and exposes the Sounding Control Table, Pulse Configuration Table, and raw IQ samples as Python data structures.
VIPIR was developed by Scion Associates under a SBIR grant from AFRL. The first installation was at NASA Wallops Island Flight Facility in 2008; 15 instruments have since been deployed worldwide. Version 2 (2015) introduced improved dynamic range and multi-channel digital down-conversion. Pynasonde supports both generations.
Reference: Grubb et al. (2011), Radio Science.
RIQ File Structure¶
VIPIR output is stored in Raw In-phase and Quadrature (RIQ) files. Each file consists of a fixed header block followed by one record per transmitted pulse:
┌─────────────────────────────┐
│ Sounding Control Table │ (SCT) — instrument configuration
│ (variable size) │
├─────────────────────────────┤
│ Pulse Configuration Table │ (PCT) — per-pulse metadata + IQ data
│ repeated N_pulses times │
└─────────────────────────────┘
Python usage¶
from pynasonde.vipir.riq.parsers.read_riq import RiqReader
reader = RiqReader("station_20230101.riq")
reader.read()
sct = reader.sct # SoundingControlTable dataclass
pcts = reader.pcts # list of PulseConfigTable records
Sounding Control Table (SCT)¶
The SCT describes the complete instrument configuration for a sounding. Pynasonde maps it to a Python dataclass (format version 1.20).
Format compatibility
C uses null-filled strings; FORTRAN uses space-filled strings.
Both are supported. 64-bit C code must use packed struct alignment.
Top-level SCT fields¶
| Field | Type | Size | Description |
|---|---|---|---|
magic |
int32 |
4 | 0x51495200 (\0RIQ) — byte-order check |
sounding_table_size |
int32 |
4 | Bytes in SCT structure |
pulse_table_size |
int32 |
4 | Bytes in PCT structure |
raw_data_size |
int32 |
4 | Bytes in raw data block (one PRI) |
struct_version |
float64 |
8 | Format version (currently 1.20) |
start_year |
int32 |
4 | Ionogram start year |
start_daynumber |
int32 |
4 | Day of year |
start_month |
int32 |
4 | Month |
start_day |
int32 |
4 | Day of month |
start_hour |
int32 |
4 | Hour (UT) |
start_minute |
int32 |
4 | Minute |
start_second |
int32 |
4 | Second |
start_epoch |
int32 |
4 | UNIX epoch of measurement start |
readme |
str |
16 | Operator comment |
decimation_method |
int32 |
4 | 0 = raw (no decimation) |
decimation_threshold |
float64 |
8 | Threshold for decimation method |
station |
StationType |
variable | Station geometry substructure |
timing |
TimingType |
variable | Radar timing substructure |
frequency |
FrequencyType |
variable | Frequency sweep substructure |
receiver |
ReceiverType |
variable | DDC receiver settings |
exciter |
ExciterType |
variable | Transmitter settings |
monitor |
MonitorType |
variable | Built-in test values |
Station substructure (StationType)¶
| Field | Type | Description |
|---|---|---|
rx_name |
str |
Receive station name |
rx_latitude |
float64 |
Rx latitude (deg N) |
rx_longitude |
float64 |
Rx longitude (deg E) |
rx_altitude |
float64 |
Rx altitude (m MSL) |
rx_count |
int32 |
Number of receive antennas |
rx_position |
[float64] |
(East, North, Up) position of each Rx (m) |
rx_direction |
[float64] |
(East, North, Up) direction of each Rx |
tx_latitude |
float64 |
Tx latitude (deg N) |
tx_longitude |
float64 |
Tx longitude (deg E) |
drive_band_bounds |
[float64] |
Drive band start/stop (kHz) |
ref_type |
str |
Reference oscillator type |
clock_type |
str |
UT timing source |
Timing substructure (TimingType)¶
| Field | Type | Description |
|---|---|---|
pri |
float64 |
Pulse Repetition Interval (µs) |
pri_count |
int32 |
Number of PRIs per ionogram |
gate_count |
int32 |
Number of range gates |
gate_start / gate_end |
float64 |
Range gate placement (µs) |
gate_step |
float64 |
Range gate delta (µs) |
data_baud_count |
int32 |
Baud count in transmitted pulse |
data_baud |
[float64] |
Waveform baud pattern (up to 1024) |
Frequency substructure (FrequencyType)¶
| Field | Type | Description |
|---|---|---|
base_start / base_end |
float64 |
Frequency sweep range |
base_steps |
int32 |
Number of sweep frequencies |
tune_type |
int32 |
1=log, 2=linear, 3=table, 4=log+shuffle |
base_table |
[float64] |
Up to 8192 nominal frequencies |
linear_step |
float64 |
Linear step (kHz) |
log_step |
float64 |
Log step (percent) |
Pulse Configuration Table (PCT)¶
One PCT record exists per transmitted pulse. It captures pulse metadata and the raw IQ samples for all range gates and receiver channels.
PCT fields¶
| Field | Type | Description |
|---|---|---|
record_id |
int32 |
Pulse sequence number |
pri_ut |
float64 |
UT of this pulse |
frequency |
float64 |
Sounding frequency (kHz) |
pa_forward_power |
float64 |
Amplifier forward power |
pa_reflected_power |
float64 |
Reflected power |
pa_vswr |
float64 |
Voltage Standing Wave Ratio |
proc_noise_level |
float64 |
Estimated noise floor for this PRI |
pulse_i |
2D array |
In-phase samples (gates × channels) |
pulse_q |
2D array |
Quadrature samples (gates × channels) |
pulse_i and pulse_q have shape (gate_count, rx_count). Combine them
as signal = pulse_i + 1j * pulse_q to get the complex baseband voltage.
Reconstruct ionogram power¶
import numpy as np
signal = np.array(pct.pulse_i) + 1j * np.array(pct.pulse_q)
power_db = 20 * np.log10(np.abs(signal) + 1e-12) # shape: (gates, channels)
Ionogram Analysis Workflow¶
RiqReader.read()
│
├─ sct → station geometry, timing, frequency sweep
│
└─ pcts → per-pulse IQ → coherent integration
│
├─ ionogram power(f, h')
├─ Doppler analysis
└─ Ne(h) inversion
Example: load and inspect¶
from pynasonde.vipir.riq.parsers.read_riq import RiqReader
reader = RiqReader("station_20230101.riq")
reader.read()
print(f"Station: {reader.sct.station.rx_name}")
print(f"Frequencies: {reader.sct.frequency.base_steps}")
print(f"Gate count: {reader.sct.timing.gate_count}")
print(f"Pulses read: {len(reader.pcts)}")
Ionogram Analysis¶
The pynasonde.vipir.analysis sub-package provides physics algorithms that
operate on filtered echo DataFrames or raw IQ cubes:
| Class | Purpose |
|---|---|
EsCaponImager |
High-resolution Es layer imaging (single RIQ file) |
RiqAggregator |
Multi-file Es imager: per-file RTI or moving-average RTI |
AbsorptionAnalyzer |
LOF index, differential O/X SNR, absorption profile |
TrueHeightInversion |
Virtual height → true height; outputs N(h) |
PolarizationClassifier |
O/X mode separation via PP chirality |
SpreadFAnalyzer |
Spread-F detection (range/frequency/mixed) |
IonogramScaler |
Automatic foF2, foE, h′F, MUF scaling |
High-resolution Es layer imaging¶
from pynasonde.vipir.analysis import EsCaponImager
imager = EsCaponImager(
n_subbands=100, # Z — Capon subbands
resolution_factor=10, # K — 10× finer range grid
gate_spacing_km=1.499, # VIPIR native gate width r₀
gate_start_km=90.0,
)
result = imager.fit(iq_cube) # iq_cube: (pulses, gates[, rx])
print(result.summary())
# EsImagingResult: Z=100 K=10 r₀=1.499 km → Δr=0.150 km
result.plot()
Use RiqAggregator to combine multiple files into a high-SNR RTI. Every
(pulse, Rx) pair is stacked as an independent snapshot, so the Capon
covariance is averaged over L profiles before a single matrix inversion —
dramatically improving sensitivity to weak Es echoes.
from pynasonde.vipir.analysis import RiqAggregator
# Per-file RTI — L = n_pulse × n_rx = 32 snapshots per column
agg = RiqAggregator(n_subbands=100, resolution_factor=10,
output_mode="per_file")
result = agg.fit(file_list, freq_target_khz=3500.0, vipir_version_idx=1)
result.plot()
# Moving-average RTI — L = window × n_pulse × n_rx = 256 snapshots per column
agg = RiqAggregator(n_subbands=100, resolution_factor=10,
output_mode="moving_avg", window=8, step=1)
result = agg.fit(file_list, freq_target_khz=3500.0, vipir_version_idx=1)
result.plot()
See the Es Imaging Example and Analysis API for full details.