Skip to content

Commit 8129132

Browse files
committed
quality check and example updates
1 parent 517e5a9 commit 8129132

29 files changed

Lines changed: 286 additions & 64 deletions

‎.github/workflows/tests.yml‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,10 @@ jobs:
5959
run: uv sync
6060

6161
- name: Run test suite (hardware tests deselected)
62-
# 85% floor, same as hackrfpy. The tree is at ~93%; the gate is there
63-
# so a large untested module cannot land unnoticed.
64-
run: uv run pytest -m "not hardware" --cov=sdr_dsp --cov-report=xml --cov-report=term-missing --cov-fail-under=85
62+
# 95% floor. The tree is at ~99.6% line coverage; a floor far below
63+
# that lets a whole untested module land unnoticed, which is exactly
64+
# what the gate exists to catch. Raise it again if coverage climbs.
65+
run: uv run pytest -m "not hardware" --cov=sdr_dsp --cov-report=xml --cov-report=term-missing --cov-fail-under=95
6566

6667
oldest-deps:
6768
name: pytest (ubuntu, py3.11, oldest declared numpy/scipy)
@@ -141,7 +142,10 @@ jobs:
141142
run: uv sync
142143

143144
- name: Ruff lint
144-
run: uv run ruff check .
145+
# The two maintenance scripts in docs/dev_handbook/ live outside the
146+
# package directory ruff is run from, so they are named explicitly;
147+
# left to the default they were never checked and drifted.
148+
run: uv run ruff check . ../docs/dev_handbook
145149

146150
- name: Mypy
147151
# Blocking. Not the strict gate hackrfpy runs: sdr_dsp DOES ship a

‎CHANGELOG.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,28 @@ was measured, why a design went the way it did) lives in `docs/LOG.md`.
1717

1818
### Changed
1919
- Package description and README links prepared for PyPI (absolute links to
20-
the docs; "personal" dropped from the one-line description).
20+
the docs; "personal" dropped from the one-line description). The README's
21+
"hooks that aren't implemented" line is gone (nothing exported is a stub)
22+
and its quick-start now calls the `tune_to_baseband` it imports.
2123
- Python 3.14 added to the CI matrix and the trove classifiers.
24+
- `zip()` calls carry an explicit `strict=` throughout (bugbear B905):
25+
`strict=True` wherever the sequences must match, which turns a silent
26+
truncation into an error.
27+
- `ssb_receiver.py` builds its test signal with `ssb_modulate`;
28+
`spectrum_analyzer.py` draws on axes from `plot_spectrum`; `cfo_demo.py`
29+
gains `--fm` for `estimate_fm_cfo` / `correct_fm_cfo`.
30+
31+
### Quality gates
32+
- ruff: bugbear (`B`) enabled; `docs/dev_handbook/*.py` now linted in CI.
33+
- Coverage floor raised from 85% to 95%.
34+
- `test_public_surface.py` also requires a docstring on every module.
2235

2336
### Added
37+
- `examples/gain_search_demo.py`: the device-agnostic `GainModel` /
38+
`search_gain_model` engine run on three simulated radios (HackRF, RTL-SDR
39+
and a hand-built two-stage model). `docs/EXAMPLES.md` also catalogs the four
40+
scripts it was missing (`transmit_examples`, `fm_band_explorer`,
41+
`collect_sample_data`, `sdr_adapter_template`).
2442
- 89 edge-case tests (`tests/test_core_edges.py`, `test_io_link_edges.py`,
2543
`test_stream_sources_edges.py`) covering empty-input guards, argument
2644
validation, every streaming stage's `reset()`, and alternative branches

‎docs/EXAMPLES.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ The TX arc, in build order — each script is one phase of "the library can now
9191
| `packet_loopback.py` | Phase B: send a verifiable *packet* — `[preamble][sync][length][payload][CRC]` — through frame → modulate → channel → demod → `find_frames`, with the CRC deciding whether it survived. | |
9292
| `channel_sweep.py` | Phase C: send a framed packet through worsening simulated channels (SNR sweep, optional CFO) and report where the link breaks. | |
9393
| `two_station_link.py` | Phase D, the capstone: two stations exchanging acked messages over the ARQ link protocol, with a forced frame drop showing a retransmit — all in software. The template to copy for bench work. | |
94+
| `transmit_examples.py` | Across the sink seam: for each of OOK / FSK / BPSK / QPSK, build a real frame, modulate it, key it out through a `TXSink` (the loopback sink by default), read it back and decode it. Proves `demod(modulate(x)) == x` through the same protocol boundary a real radio sits behind. | |
9495

9596
## Measurement & detection
9697

@@ -100,6 +101,7 @@ The TX arc, in build order — each script is one phase of "the library can now
100101
| `annotate_bursts.py` | The detect → label → save → reload workflow: detected bursts become SigMF annotations in the recording's sidecar, readable by any SigMF-aware tool. | |
101102
| `cfo_demo.py` | Measure a carrier frequency offset — and only correct it if you ask. Embodies the measure-don't-auto-apply principle. | |
102103
| `power_calibration.py` | Turn dBFS into absolute dBm via a one-time calibration against a known reference (opt-in, advanced; only valid for the gain/frequency measured). | |
104+
| `gain_search_demo.py` | Auto-gain a radio the library has never seen by describing its knobs as a `GainModel`: the same `search_gain_model` engine runs on a simulated HackRF (LNA + amp + VGA), an RTL-SDR (one knob) and a hand-built two-stage radio, choosing the front end by measured pilot quality and the level stage by ADC fill. `--antenna` sets signal strength; `--trace` shows every probe. | |
103105

104106
## Impairment modeling & extraction
105107

@@ -128,6 +130,9 @@ library; see §13 of [`sdr_dsp_REFERENCE.md`](sdr_dsp_REFERENCE.md).)
128130
| `live_433_capture.py` | Capture a 433 MHz burst (e.g. a key fob) and save it for the OOK/FSK decoders to run on offline. | HW |
129131
| `live_fm_listen.py` | Tune an FM station and play it through your speakers in real time. | HW, audio |
130132
| `live_spectrum.py` | Live spectrum display from the HackRF — "what's on this frequency right now?" | HW, plot |
133+
| `fm_band_explorer.py` | Interactive: sweep the FM band, click a station, tune it live and listen. Built for answering "why am I not hearing anything?" — the sweep, the detected peaks and the tuned channel's pilot check are on screen at once. | HW, plot, audio |
134+
| `collect_sample_data.py` | The read-only collection tool behind `sample_data/`: records real captures with SigMF sidecars and provenance so the file-based examples have genuine signals to run against. Receive and sweep only; never transmits. | HW |
135+
| `sdr_adapter_template.py` | A fill-in-the-blanks adapter for a *new* SDR: copy it, complete the four marked spots, and you have an `IQSource` (and optionally a `TXSink`) the core drives with no change to `src/`. Not run directly; the modularity claim made concrete. | |
131136

132137
---
133138

‎docs/dev_handbook/generate_api_handbook.py‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,14 @@
1010
"""
1111
import importlib
1212
import inspect
13-
import sys
14-
import textwrap
1513

1614
# the public surface, in the order we want it documented
1715
MODULES = [
1816
("sdr_dsp", "Top-level package", "Lazily-loaded submodules and the flattened DSP API."),
1917
("sdr_dsp.core", "core — pure DSP", "Every signal-processing primitive. numpy + scipy only."),
2018
("sdr_dsp.core.demod", "core.demod — demodulators", "Recover bits/audio/symbols from IQ."),
21-
("sdr_dsp.core.modulate", "core.modulate — modulators", "Inverse of demod: bits/symbols -> IQ."),
19+
("sdr_dsp.core.modulate", "core.modulate — modulators",
20+
"Inverse of demod: bits/symbols -> IQ."),
2221
("sdr_dsp.sources", "sources — receive seam", "IQSource protocol and concrete sources."),
2322
("sdr_dsp.sinks", "sinks — output & transmit seam", "TXSink protocol, file and plot sinks."),
2423
("sdr_dsp.io", "io — file formats", "SigMF load/save, annotations, metadata."),
@@ -162,7 +161,7 @@ def main():
162161

163162
# table of contents
164163
print("## Modules\n")
165-
for modname, title, _ in MODULES:
164+
for _modname, title, _ in MODULES:
166165
anchor = title.lower().replace(" — ", "--").replace(" ", "-").replace("—", "")
167166
print(f"- [{title}](#{anchor})")
168167
print("\n---\n")
@@ -207,4 +206,4 @@ def main():
207206

208207

209208
if __name__ == "__main__":
210-
main()
209+
main()

‎docs/dev_handbook/tree_map.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env python3
22
"""Generate an ASCII file tree of the dcdash project."""
33

4-
import os
54
import sys
65
from pathlib import Path
76

@@ -65,4 +64,4 @@ def main():
6564

6665

6766
if __name__ == '__main__':
68-
main()
67+
main()

‎sdr_dsp/README.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ can't do, and why it's a good development platform.
1313

1414
## Status
1515

16-
Pre-release; there is no Version 1 yet, and it is heavily under development.
17-
Some features have hooks that aren't implemented. What is proven where:
16+
Pre-release; there is no Version 1 yet, and the API may still move between
17+
minor versions. Everything exported is implemented and tested; what differs
18+
is how much of it has met a real radio. What is proven where:
1819

1920
- **FM receive** — validated on real, ear-verified over-the-air captures.
2021
- **Other demodulators (AM/SSB/CW, OOK/ASK, FSK, the PSK family, QAM-16)** —
@@ -42,7 +43,11 @@ from sdr_dsp.io import load_iq
4243

4344
iq, meta = load_iq("sample_data/fm_2Msps.iq")
4445
fs = meta["global"]["core:sample_rate"]
45-
# (already at baseband here; tune_to_baseband handles off-center captures)
46+
# A direct-conversion SDR puts a DC spike at its tuned frequency, so real
47+
# captures are often made with the radio tuned a little to one side and the
48+
# station brought back to 0 Hz in software. This capture is centered, so the
49+
# offset is 0; see docs/DC_SPIKE.md for the recipe.
50+
iq = tune_to_baseband(iq, offset_hz=0.0, sample_rate=fs)
4651
audio = fm_demod(fir_apply(iq, design_lowpass(100e3, fs)),
4752
deviation_hz=75e3, sample_rate=fs)
4853
```

‎sdr_dsp/examples/cfo_demo.py‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
Usage:
1717
python examples/cfo_demo.py
1818
python examples/cfo_demo.py --offset 35000 --correct
19+
python examples/cfo_demo.py capture.iq --fm --correct
1920
"""
2021
import argparse
2122
import sys
2223

2324
import numpy as np
2425

25-
from sdr_dsp.core import estimate_cfo, frequency_shift
26+
from sdr_dsp.core import (estimate_cfo, estimate_fm_cfo, correct_fm_cfo,
27+
frequency_shift)
2628

2729
def main():
2830
p = argparse.ArgumentParser(description="Measure/correct carrier offset.")
@@ -32,6 +34,11 @@ def main():
3234
help="synth: put the signal this far off center (Hz)")
3335
p.add_argument("--correct", action="store_true",
3436
help="actually apply the correction (default: measure only)")
37+
p.add_argument("--fm", action="store_true",
38+
help="treat the input as broadcast FM: channelize to the FM "
39+
"channel before measuring (estimate_fm_cfo / "
40+
"correct_fm_cfo), so out-of-channel energy cannot "
41+
"pull the estimate")
3542
args = p.parse_args()
3643

3744
if args.iq_file:
@@ -48,8 +55,18 @@ def main():
4855
print(f"[*] synthetic signal placed {args.offset/1e3:g} kHz off center")
4956

5057
# MEASURE -- this does not change the data
51-
cfo = estimate_cfo(iq, fs)
52-
print(f"[*] measured carrier offset: {cfo/1e3:+.2f} kHz")
58+
if args.fm:
59+
cfo = estimate_fm_cfo(iq, fs)
60+
if cfo is None:
61+
print("[!] estimate_fm_cfo declined: the capture is too short or "
62+
"the rate too low to channelize to the FM channel",
63+
file=sys.stderr)
64+
return 1
65+
print(f"[*] measured FM carrier offset: {cfo/1e3:+.2f} kHz "
66+
"(mean instantaneous frequency inside the channel)")
67+
else:
68+
cfo = estimate_cfo(iq, fs)
69+
print(f"[*] measured carrier offset: {cfo/1e3:+.2f} kHz")
5370

5471
if not args.correct:
5572
before = iq.copy()
@@ -61,8 +78,15 @@ def main():
6178
return 0
6279

6380
# CORRECT -- your explicit choice
64-
corrected = frequency_shift(iq, -cfo, fs)
65-
residual = estimate_cfo(corrected, fs)
81+
if args.fm:
82+
# correct_fm_cfo measures and shifts in one call, and returns the
83+
# offset it applied so the correction is never hidden from you
84+
corrected, applied = correct_fm_cfo(iq, fs)
85+
assert applied == cfo
86+
residual = estimate_fm_cfo(corrected, fs) or 0.0
87+
else:
88+
corrected = frequency_shift(iq, -cfo, fs)
89+
residual = estimate_cfo(corrected, fs)
6690
print(f"[*] corrected by {-cfo/1e3:+.2f} kHz; residual offset now "
6791
f"{residual/1e3:+.2f} kHz")
6892
print("[*] signal is now centered (residual ~ one FFT bin)")

‎sdr_dsp/examples/channelizer.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def main():
6666
f"({'oversampled' if args.oversample else 'critically sampled'})")
6767
print(f"\n {'ch':>3} {'center (kHz)':>14} {'power':>10}")
6868
print(" " + "-" * 32)
69-
for i, (f, c) in enumerate(zip(freqs, chans)):
69+
for i, (f, c) in enumerate(zip(freqs, chans, strict=True)):
7070
pw = power_dbfs(c)
7171
mark = " <-- signal" if pw > -20 else ""
7272
print(f" {i:>3} {f/1e3:>14.1f} {pw:>7.1f} dB{mark}")

‎sdr_dsp/examples/cw_decoder.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
def synth_cw(text, fs, dit_samples, tone_hz=800, noise=0.03):
3636
"""Build a complex tone keyed with the Morse for `text`."""
3737
on = [] # list of (is_on, units)
38-
for ci, ch in enumerate(text.upper()):
38+
for ch in text.upper():
3939
if ch == " ":
4040
on.append((False, 7))
4141
continue

‎sdr_dsp/examples/fsk_decoder.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def main():
106106
if truth is not None:
107107
n = min(len(syms), len(truth))
108108
# align (recovery may offset by a symbol) and report agreement
109-
errs = sum(int(a) != int(b) for a, b in zip(syms[:n], truth[:n]))
109+
errs = sum(int(a) != int(b) for a, b in zip(syms[:n], truth[:n], strict=True))
110110
print(f"[*] vs known truth: {n-errs}/{n} symbols match")
111111
return 0
112112

0 commit comments

Comments
 (0)