Skip to content

Instantly share code, notes, and snippets.

@Dygear
Last active January 21, 2026 17:02
Show Gist options
  • Select an option

  • Save Dygear/5be491d56d7e1e8fa45a7693bbf8f554 to your computer and use it in GitHub Desktop.

Select an option

Save Dygear/5be491d56d7e1e8fa45a7693bbf8f554 to your computer and use it in GitHub Desktop.

Capture

Run ./gold_capture_1h.sh with your Ettus USRP attached. You MAY need to do some tweaks, such as turning off IRQs for the core you are running this on to ensure that you get good quality data. An overrun will put a gap into your data's I/Q file output and makes the whole sample worthless. Read along to see how I got around that.

P25 Waterfall

P25 Gold IQ Capture

Center frequency: 852.306250 MHz Sample rate: 4.000 Msps Format: SC16 (signed 16-bit IQ, little-endian) Duration: 1 hour Size: 57,600,000,000 bytes

Hardware:

  • Ettus B206-Mini
  • USB 3.0
  • External antenna (ANT 500)

Software:

  • Fedora 44 (Rawhide)
  • UHD 4.9.0.1
  • irqaffinity=0-6,8-14
  • recv_frame_size=8000
  • num_recv_frames=256

Capture integrity:

  • Zero UHD overflows reported
  • SHA256 provided in SHA256SUMS_*.txt

P25 Golden IQ Capture (SC16, 4 Msps, 1 Hour)

This repository contains a golden reference IQ capture of an active P25 Phase 1 (FDMA) trunked radio system, intended for development, testing, and validation of decoders, channelizers, and recording pipelines (e.g. GNU Radio, osmosdr, trunk-recorder).

The capture was produced with verified zero sample overflows and includes full provenance so others can reproduce the same conditions.


Capture Summary

Item Value
Center Frequency 852.306250 MHz
Sample Rate 4.000 Msps
Duration 1 hour
Format SC16 (signed 16-bit IQ, interleaved I/Q, little-endian)
File Size 57,600,000,000 bytes
Hardware Ettus B206-Mini (USB 3)
UHD Version 4.9.0.1
OS Fedora Linux
Integrity Zero UHD overflows
Verification SHA256 checksums provided

IQ layout:

I0 Q0 I1 Q1 I2 Q2 ...

Why Special Configuration Is Required

Long, lossless IQ captures are not limited by throughput, but by latency spikes. A single 20–30 ms stall is enough to cause a UHD overflow and silently corrupt a dataset.

The configuration below eliminates the known causes:

  • CPU scheduler jitter
  • NVMe / USB interrupt storms
  • Power-management latency
  • Insufficient USB receive buffering

System Configuration (Required)

1. Isolate IRQs from Capture CPUs (Critical)

Kernel boot argument:

irqaffinity=0-6,8-14

This prevents all device interrupts (NVMe, USB, etc.) from running on CPU cores 7 and 15, which are reserved exclusively for IQ capture.

Apply on Fedora:

sudo grubby --update-kernel=ALL --args="irqaffinity=0-6,8-14"
sudo reboot

Verify after reboot:

cat /proc/cmdline | tr ' ' '\n' | grep irqaffinity

2. CPU Pinning and Real-Time Scheduling

The capture process is pinned to isolated CPUs 7 and 15 and run with real-time priority:

  • CPU affinity: cores 7,15
  • Scheduler: SCHED_FIFO
  • Priority: 50

This ensures uninterrupted sample consumption.

3. Disable Power Management Latency

Set a low-latency tuning profile:

sudo tuned-adm profile latency-performance

Force CPU governor to performance:

sudo dnf install -y kernel-tools
sudo cpupower frequency-set -g performance

4. Filesystem Configuration

The capture directory was created with copy-on-write disabled to avoid btrfs metadata latency spikes:

mkdir -p ~/iq_capture
chattr +C ~/iq_capture

UHD Configuration (Critical)

The default USB receive buffering is insufficient for hour-long captures. The following device arguments were required to tolerate brief host stalls:

recv_frame_size=8000
num_recv_frames=256

These parameters dramatically reduce overflow risk without destabilizing the B200-series USB transport.

Capture Command (Reproducible)

The golden sample was produced using uhd_rx_cfile with the following command (wrapped in a monitoring script):

uhd_rx_cfile \
  --args "type=b200,recv_frame_size=8000,num_recv_frames=256" \
  --antenna TX/RX \
  --freq 852.30625e6 \
  --samp-rate 4e6 \
  --gain 30 \
  --output-shorts \
  --nsamples 14400000000 \
  -v \
  p25_ref_852306250_4Msps_1h.sc16

This corresponds to exactly:

4,000,000 samples/sec × 3600 sec = 14,400,000,000 samples

Verification and Logging

During capture:

  • IRQ activity on capture CPUs was logged per-second
  • UHD output was timestamped
  • Any overflow immediately aborted the run

Artifacts included with the dataset:

  • *.sc16 - IQ data
  • logs/*.log - UHD capture logs
  • irq_cpu7_cpu15_deltas.log - per-second IRQ deltas
  • overflow_events.log - empty (no overflows)
  • SHA256SUMS_*.txt - integrity verification

Verify integrity:

sha256sum -c SHA256SUMS_*.txt

Using the IQ File (osmosdr / trunk-recorder)

Example osmosdr source:

{
  "center": 852306250,
  "rate": 4000000,
  "driver": "osmosdr",
  "device": "file=p25_ref_852306250_4Msps_1h.sc16,freq=852306250,rate=4000000,repeat=true,throttle=true"
}

The SC16 format is natively supported — no conversion required.

Notes

This dataset contains real RF traffic and is intended strictly for technical experimentation and research. No decoding, demodulation, or content interpretation is included. If you reproduce this capture without the system configuration above, you should expect silent corruption.

Reproducibility Statement

This capture was produced with:

  • Known hardware
  • Deterministic configuration
  • Verified absence of sample loss Any deviation from the documented configuration invalidates "gold reference" status.

Spectrum Snapshot

Below is an averaged FFT of the IQ capture, generated from the raw SC16 file (using a Hann window, 262k FFT, 300 averages):

Averaged FFT

Key observations:

  • Stable noise floor across the full 4 MHz bandwidth
  • Clearly resolved 12.5 kHz P25 channels
  • Dominant control channel with expected spectral shape
  • No evidence of clipping, dropouts, or sample starvation
92adef0127f8d976afdcf3cc2542b1089b065ea7ab5e1e7caefb94501623a3a9 p25_ref_852306250_4Msps_1h_20260121T053430Z.sc16
024cf5a38abbbecd903131a33d95589cdb2e96919fa5e3f100d09fb915d423a9 logs/p25_ref_852306250_4Msps_1h_20260121T053430Z.log
c7b4fa401600a9cb75590f0ca50d5e489b7c4c14ced5b52f068e9e9192b8a3c2 irq_cpu7_cpu15_deltas.log
795b0f839885db1c48f6210233a23b72ebfbf947260d120f8a3b9ec893aa6651 overflow_events.log
#!/usr/bin/env bash
set -euo pipefail
CAPDIR="$HOME/iq_capture"
LOGDIR="$CAPDIR/logs"
# Delta IRQ log (epoch_seconds irq delta_cpu7 delta_cpu15 name)
IRQLOG="$CAPDIR/irq_cpu7_cpu15_deltas.log"
OVLOG="$CAPDIR/overflow_events.log"
mkdir -p "$LOGDIR"
mkdir -p "$CAPDIR"
# Output names
TS="$(date -u +%Y%m%dT%H%M%SZ)"
OUT="$CAPDIR/p25_ref_852306250_4Msps_1h_${TS}.sc16"
LOG="$LOGDIR/p25_ref_852306250_4Msps_1h_${TS}.log"
# Expected bytes: 4e6 samples/sec * 3600 sec * 4 bytes/sample
EXPECTED_BYTES=57600000000
# 1 hour worth of samples
NSAMPLES=14400000000
# UHD device args (buffering to tolerate brief host stalls)
UHD_ARGS='type=b200,recv_frame_size=8000,num_recv_frames=256'
now_ms() { date +%s%3N; }
echo "=== GOLD CAPTURE START (UTC $TS) ===" | tee -a "$LOG"
echo "out=$OUT" | tee -a "$LOG"
echo "expected_bytes=$EXPECTED_BYTES" | tee -a "$LOG"
echo "cpu_pin=7,15 irqaffinity=$(tr ' ' '\n' </proc/cmdline | grep -E '^irqaffinity=' || true)" | tee -a "$LOG"
echo "uhd_args=$UHD_ARGS" | tee -a "$LOG"
echo "nsamples=$NSAMPLES samp_rate=4e6 center=852.30625e6 gain=30 format=sc16" | tee -a "$LOG"
# Start delta IRQ logger
: > "$IRQLOG"
: > "$OVLOG"
if [[ -x /tmp/irqlog_delta.sh ]]; then
/tmp/irqlog_delta.sh > "$IRQLOG" &
IRQPID=$!
echo "IRQ delta logger PID=$IRQPID -> $IRQLOG" | tee -a "$LOG"
else
echo "ERROR: /tmp/irqlog_delta.sh not found/executable" >&2
exit 1
fi
cleanup() {
if [[ -n "${IRQPID:-}" ]] && kill -0 "$IRQPID" 2>/dev/null; then
kill "$IRQPID" 2>/dev/null || true
fi
}
trap cleanup EXIT
start_ms="$(now_ms)"
echo "START_MS=$start_ms" | tee -a "$OVLOG"
# Run capture; timestamp every line. Detect overflow windows.
sudo systemd-run --unit="uhd-gold-1h.scope" --scope -p AllowedCPUs=7,15 \
chrt -f 50 ionice -c1 -n0 \
uhd_rx_cfile \
--args "$UHD_ARGS" \
--antenna TX/RX \
--freq 852.30625e6 \
--samp-rate 4e6 \
--gain 30 \
--output-shorts \
--nsamples "$NSAMPLES" \
-v \
"$OUT" \
2>&1 | while IFS= read -r line; do
tms="$(now_ms)"
printf '%s %s\n' "$tms" "$line" | tee -a "$LOG"
# Example: "In the last 31235 ms, 1 overflows occurred."
if [[ "$line" =~ In\ the\ last\ ([0-9]+)\ ms,\ .*overflows?\ occurred ]]; then
window_ms="${BASH_REMATCH[1]}"
window_end_ms="$tms"
window_start_ms="$(( window_end_ms - window_ms ))"
{
echo "OVERFLOW printed_ms=$window_end_ms window_ms=$window_ms window_start_ms=$window_start_ms window_end_ms=$window_end_ms"
echo " -> correlate in IRQ delta log: seconds [$((window_start_ms/1000)) .. $((window_end_ms/1000))]"
} | tee -a "$OVLOG"
exit 99
fi
done
rc=${PIPESTATUS[1]:-0}
end_ms="$(now_ms)"
echo "END_MS=$end_ms rc=$rc" | tee -a "$OVLOG"
if [[ "$rc" -eq 99 ]]; then
echo "FAILED: overflow occurred. See $OVLOG and $LOG" >&2
exit 1
elif [[ "$rc" -ne 0 ]]; then
echo "FAILED: capture command exited rc=$rc. See $LOG" >&2
exit 1
fi
# Verify size
actual_bytes="$(stat -c %s "$OUT")"
echo "actual_bytes=$actual_bytes" | tee -a "$LOG"
if [[ "$actual_bytes" -ne "$EXPECTED_BYTES" ]]; then
echo "WARNING: file size mismatch (expected $EXPECTED_BYTES, got $actual_bytes)" | tee -a "$LOG"
fi
# Checksums
(
cd "$CAPDIR"
sha256sum \
"$(basename "$OUT")" \
"logs/$(basename "$LOG")" \
"$(basename "$IRQLOG")" \
"$(basename "$OVLOG")" \
> "SHA256SUMS_${TS}.txt"
)
echo "Wrote checksums: $CAPDIR/SHA256SUMS_${TS}.txt" | tee -a "$LOG"
echo "=== GOLD CAPTURE COMPLETE ===" | tee -a "$LOG"
#!/usr/bin/env python3
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.ticker import MultipleLocator
# ---------------- STYLE ----------------
plt.style.use("dark_background")
# ---------------- CONFIG ----------------
IQ_FILE = "p25_ref_852306250_4Msps_1h_20260121T053430Z.sc16"
CENTER_FREQ = 852.30625e6
SAMPLE_RATE = 4.0e6
FFT_SIZE = 262144
NUM_AVERAGES = 300
START_SECONDS = 600 # skip first 10 minutes
OUT_PNG = "p25_gold_fft.png"
# Optional: SCPD control channels (Hz)
CONTROL_CHANNELS = [
851_162_500,
852_425_000,
852_675_000,
852_737_500,
]
# --------------------------------------
print("Loading IQ data…")
bytes_per_sample = 4 # sc16: I(16) + Q(16)
offset_bytes = int(START_SECONDS * SAMPLE_RATE * bytes_per_sample)
with open(IQ_FILE, "rb") as f:
f.seek(offset_bytes)
raw = np.fromfile(f, dtype=np.int16, count=FFT_SIZE * NUM_AVERAGES * 2)
iq = raw.astype(np.float32).view(np.complex64)
iq /= 32768.0
print(f"Loaded {len(iq)} complex samples")
window = np.hanning(FFT_SIZE)
psd_accum = np.zeros(FFT_SIZE, dtype=np.float64)
for i in range(NUM_AVERAGES):
chunk = iq[i * FFT_SIZE : (i + 1) * FFT_SIZE]
fft = np.fft.fftshift(np.fft.fft(chunk * window))
psd_accum += np.abs(fft) ** 2
psd = psd_accum / NUM_AVERAGES
psd_db = 10 * np.log10(psd + 1e-12)
freqs = np.fft.fftshift(np.fft.fftfreq(FFT_SIZE, d=1 / SAMPLE_RATE))
freqs_mhz = (freqs + CENTER_FREQ) / 1e6
print("Rendering FFT…")
# ---------------- PLOT ----------------
fig, ax = plt.subplots(figsize=(16, 6), facecolor="black")
ax.set_facecolor("black")
ax.plot(freqs_mhz, psd_db, lw=1.0, color="#ffb000")
# Optional control channel markers
for hz in CONTROL_CHANNELS:
ax.axvline(
hz / 1e6,
color="cyan",
alpha=0.35,
lw=1.0,
linestyle="--",
)
# Axes labels & title
ax.set_xlabel("Frequency (MHz)", color="white")
ax.set_ylabel("Power (dBFS)", color="white")
ax.set_title(
"P25 Golden IQ Capture – Averaged Spectrum (4 Msps)",
color="white",
)
# Ticks
ax.tick_params(colors="white")
# Major / minor grid
ax.xaxis.set_major_locator(MultipleLocator(0.5))
ax.xaxis.set_minor_locator(MultipleLocator(0.1))
ax.yaxis.set_minor_locator(MultipleLocator(5))
ax.grid(which="major", color="gray", alpha=0.35, linewidth=0.8)
ax.grid(which="minor", color="gray", alpha=0.15, linewidth=0.5)
plt.tight_layout()
plt.savefig(OUT_PNG, dpi=218, facecolor="black")
plt.close(fig)
print(f"Saved {OUT_PNG}")
#!/usr/bin/env python3
from datetime import datetime, timezone
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.ticker import FuncFormatter, MultipleLocator
from mpl_toolkits.axes_grid1 import make_axes_locatable
# ----------------- STYLE ----------------
plt.style.use("dark_background")
# ---------------- CONFIG ----------------
iq_file = "p25_ref_852306250_4Msps_1h_20260121T053430Z.sc16"
# Overall
center_freq_hz = 852_306_250 # Hz
sample_rate = 4_000_000 # samples/sec
seconds = 3600 # 1 hour
# Band width for each control channel highlight (Hz)
cc_band_hz = 12_500
cc_band_mhz = cc_band_hz / 1e6
# FFT Settings
fft_size = 4096
dtype = np.int16
out_png = "p25_gold_waterfall_1h_annotated.png"
# Known control channels (Hz)
control_channels_hz = [851_162_500, 852_425_000, 852_675_000, 852_737_500]
# ------------------ TIME ----------------
# Parse UTC start time from filename
# Example: p25_ref_852306250_4Msps_1h_20260121T053430Z.sc16
ts_str = iq_file.split("_")[-1].replace(".sc16", "")
start_time = datetime.strptime(ts_str, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
def sec_to_hhmm(sec, pos):
t = start_time + np.timedelta64(int(sec), "s")
return t.strftime("%H:%M")
def sec_to_minutes(sec, pos):
return f"{int(sec) // 60:d}"
# ---------------------------------------
samples_per_sec = sample_rate
ffts_per_sec = samples_per_sec // fft_size
if ffts_per_sec < 1:
raise ValueError("fft_size is larger than samples_per_sec; reduce fft_size.")
# Precompute frequency axis for extent/annotations
freqs_hz = np.linspace(
center_freq_hz - sample_rate / 2,
center_freq_hz + sample_rate / 2,
fft_size,
endpoint=False,
)
freqs_mhz = freqs_hz / 1e6
waterfall = np.zeros((seconds, fft_size), dtype=np.float32)
with open(iq_file, "rb") as f:
for sec in range(seconds):
acc = np.zeros(fft_size, dtype=np.float64)
for _ in range(ffts_per_sec):
raw = np.fromfile(f, dtype=dtype, count=fft_size * 2)
if raw.size < fft_size * 2:
break
# interleaved sc16: I0 Q0 I1 Q1 ...
i = raw[0::2].astype(np.float32)
q = raw[1::2].astype(np.float32)
iq = (i + 1j * q) / 32768.0
fft = np.fft.fftshift(np.fft.fft(iq))
acc += np.abs(fft) ** 2
acc /= ffts_per_sec
waterfall[sec] = 10 * np.log10(acc + 1e-12)
if sec % 60 == 0:
print(f"Processed {sec}/{seconds} seconds")
# Normalize for display (relative dB, top = 0 dB)
waterfall -= waterfall.max()
# Plot
fig, ax = plt.subplots(figsize=(20, 10), facecolor="black")
im = plt.imshow(
waterfall,
aspect="auto",
cmap="inferno",
origin="upper", # Time Flow: `Upper` puts oldest data at the top.
extent=[freqs_mhz[0], freqs_mhz[-1], seconds, 0],
)
ax = plt.gca() # <-- define BEFORE using ax.xaxis/ax.yaxis
# --- Left Y axis: HH:MM (UTC) ---
ax.yaxis.set_major_locator(MultipleLocator(600)) # 10 minutes
ax.yaxis.set_minor_locator(MultipleLocator(300)) # 5 minutes
ax.yaxis.set_major_formatter(FuncFormatter(sec_to_hhmm))
# --- X axis ticks ---
ax.xaxis.set_major_locator(MultipleLocator(0.5)) # MHz
ax.xaxis.set_minor_locator(MultipleLocator(0.1)) # MHz
# --- Right Y axis: elapsed minutes ---
ax_r = ax.twinx()
ax_r.set_ylim(ax.get_ylim()) # keep aligned with left axis
ax_r.yaxis.set_major_locator(MultipleLocator(600)) # match left major ticks
ax_r.yaxis.set_minor_locator(MultipleLocator(300))
ax_r.yaxis.set_major_formatter(FuncFormatter(sec_to_minutes))
# --- Grid (dim gray) ---
ax.grid(which="major", color="gray", alpha=0.35, linewidth=0.8)
ax.grid(which="minor", color="gray", alpha=0.15, linewidth=0.5)
# --- Labels / title / ticks ---
ax.set_xlabel("Frequency (MHz)", color="white")
ax.set_ylabel("Time (UTC, HH:MM)", color="white")
ax_r.set_ylabel("Elapsed (minutes)", color="white")
ax.set_title(
"P25 Golden IQ – 1 Hour Waterfall (1 px/sec)\nSCPD Control Channels",
color="white",
)
ax.tick_params(colors="white")
ax_r.tick_params(colors="white")
# --- Control channel bands + labels ---
y_text = 15 # seconds from top (origin='upper')
for hz in control_channels_hz:
x_mhz = hz / 1e6
x0 = x_mhz - (cc_band_mhz / 2)
x1 = x_mhz + (cc_band_mhz / 2)
ax.axvspan(x0, x1, color="cyan", alpha=0.25, linewidth=0)
ax.text(
x_mhz,
y_text,
f"{x_mhz:.6f} MHz",
rotation=90,
va="top",
ha="center",
fontsize=9,
color="white",
bbox=dict(
boxstyle="round,pad=0.2",
facecolor="black",
edgecolor="cyan",
alpha=0.75,
),
)
# --- Colorbar in its own axes to the far-right (no collision with right Y axis) ---
divider = make_axes_locatable(ax_r) # append relative to the right-axis spine
cax = divider.append_axes(
"right", size="2.5%", pad=0.6
) # pad leaves room for elapsed axis
cbar = fig.colorbar(im, cax=cax)
cbar.set_label("Relative Power (dB)", color="white")
cbar.ax.tick_params(colors="white")
plt.setp(cbar.ax.get_yticklabels(), color="white")
fig.tight_layout()
fig.savefig(out_png, dpi=218, facecolor="black")
plt.close(fig)
print(f"Saved annotated waterfall to {out_png}")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment