Skip to content

Commit eaa008f

Browse files
committed
smoke test and tx issue
1 parent 34c5e73 commit eaa008f

10 files changed

Lines changed: 595 additions & 16 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,21 @@ jobs:
116116
steps:
117117
- uses: actions/checkout@v4
118118

119+
- name: Tracked text files are LF in the index
120+
# .gitattributes declares `text=auto eol=lf`; this holds the line.
121+
# A CRLF file sneaking into the index breaks `#!` shebangs on Linux
122+
# and produces whole-file whitespace diffs. `git ls-files --eol`
123+
# reports the INDEX encoding (i/...), independent of the runner's OS.
124+
working-directory: .
125+
run: |
126+
bad=$(git ls-files --eol | grep -E '^i/(crlf|mixed)' || true)
127+
if [ -n "$bad" ]; then
128+
echo "::error::tracked files with CRLF/mixed line endings in the index:"
129+
echo "$bad"
130+
exit 1
131+
fi
132+
echo "index is LF-clean ($(git ls-files --eol | grep -c '^i/lf') text files)"
133+
119134
- name: Install uv
120135
uses: astral-sh/setup-uv@v5
121136
with:

‎docs/TODO.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ here. Strike through or delete when done; the LOG records what happened.
55

66
## Hardware (the gate everything else waits on)
77

8+
- [ ] **Run `uv run pytest -m hardware tests/test_hardware_rx_smoke.py -v`
9+
on the bench.** Written 2026-10-24 without a board; the first run is
10+
its validation. Set `SDR_DSP_TEST_FM_HZ` to a local station.
811
- [ ] **Wired one-way TX bench test** of the FSK packet chain
912
(`examples/two_station_link.py`). Everything digital, TX and protocol
1013
is software-only until this runs (REFERENCE §12).
@@ -37,8 +40,8 @@ here. Strike through or delete when done; the LOG records what happened.
3740
are unannotated (~350 errors under `disallow_untyped_defs`). Enable the
3841
strict flags per module as each is annotated; until then the marker
3942
overpromises, exactly the hackrfpy pre-1.1.0 situation.
40-
- [ ] Line-ending normalization: `.gitattributes` is committed, the
41-
`git add --renormalize .` commit has not been made. Do it alone.
43+
- [x] Line-ending normalization: the index was already LF-clean
44+
(`git ls-files --eol`); CI now guards it. No renormalize commit needed.
4245

4346
## Repo hygiene
4447

‎sdr_dsp/src/sdr_dsp/__init__.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,18 @@
3838
)
3939
from .sources import IQSource, ArraySource, FileSource
4040

41-
__version__ = "0.1.0"
41+
# Resolved from the installed package metadata (pyproject.toml is the single
42+
# source of truth), with a fallback for an uninstalled checkout. A hard-coded
43+
# string here had drifted to 0.1.0 while pyproject said 0.2.0.
44+
try:
45+
from importlib.metadata import version as _pkg_version, PackageNotFoundError
46+
try:
47+
__version__ = _pkg_version("sdr_dsp")
48+
except PackageNotFoundError: # pragma: no cover - uninstalled tree
49+
__version__ = "0.0.0+unknown"
50+
del _pkg_version, PackageNotFoundError
51+
except ImportError: # pragma: no cover
52+
__version__ = "0.0.0+unknown"
4253

4354
# io, sinks, stream, link and Pipeline are imported LAZILY (PEP 562) so that importing
4455
# the package -- or a submodule like `from sdr_dsp.core import demod` -- never

‎sdr_dsp/src/sdr_dsp/sources/base.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def __init__(self, iq: np.ndarray, sample_rate: float,
4949
self.block_size = int(block_size)
5050

5151
def blocks(self) -> Iterator[np.ndarray]:
52+
"""Yield the array in consecutive ``block_size`` slices (last may be short)."""
5253
n = len(self.iq)
5354
for start in range(0, n, self.block_size):
5455
yield self.iq[start:start + self.block_size]

‎sdr_dsp/src/sdr_dsp/stream/stages.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def __init__(self, taps):
7676
self._tail: np.ndarray | None = None
7777

7878
def reset(self) -> None:
79+
"""Forget all carried history, e.g. after a retune or a stream restart."""
7980
self._tail = None
8081

8182
def __call__(self, block):
@@ -107,6 +108,7 @@ def __init__(self, factor):
107108
self._skip = 0 # samples to drop at the start of the next block
108109

109110
def reset(self) -> None:
111+
"""Forget all carried history, e.g. after a retune or a stream restart."""
110112
self._skip = 0
111113

112114
def __call__(self, block):
@@ -137,6 +139,7 @@ def __init__(self, shift_hz, sample_rate):
137139
self._n0 = 0
138140

139141
def reset(self) -> None:
142+
"""Forget all carried history, e.g. after a retune or a stream restart."""
140143
self._n0 = 0
141144

142145
def __call__(self, block):
@@ -174,6 +177,7 @@ def __init__(self, deviation_hz=None, sample_rate=None):
174177
self._prev: np.ndarray | None = None
175178

176179
def reset(self) -> None:
180+
"""Forget all carried history, e.g. after a retune or a stream restart."""
177181
self._prev = None
178182

179183
def __call__(self, block):
@@ -234,6 +238,7 @@ def latency_samples(self) -> int:
234238
return self.ahead
235239

236240
def reset(self) -> None:
241+
"""Forget all carried history, e.g. after a retune or a stream restart."""
237242
self._buf = None
238243
self._started = False
239244

@@ -305,6 +310,7 @@ def __init__(self, sample_rate, tau_us=75.0):
305310
self._y_prev = 0.0
306311

307312
def reset(self) -> None:
313+
"""Forget all carried history, e.g. after a retune or a stream restart."""
308314
self._y_prev = 0.0
309315

310316
def __call__(self, block):

‎sdr_dsp/tests/test_examples_run.py‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Run every file-mode and synthetic example's main() end to end.
2+
3+
test_examples_smoke.py proves each example PARSES and IMPORTS. About half of
4+
them had nothing beyond that: argparse surfaces, file loading, the pipeline
5+
glue, and the output writers of am_receiver, ook_decoder, inspect_capture,
6+
iq_basics, waterfall and friends never executed under test. This runs each one
7+
as a subprocess the way a user would, with the smallest arguments that still
8+
exercise the real path:
9+
10+
* file-based examples get a short synthetic SigMF capture written by this test
11+
(an FM station, an OOK burst train, an AM carrier), so no sample_data or
12+
hardware is needed;
13+
* plotting examples run with MPLBACKEND=Agg, where plt.show() is a no-op, so
14+
figures are built but never block;
15+
* anything that needs a radio is excluded by name -- the stub-radio adapter
16+
tests cover that seam.
17+
18+
A non-zero exit, a traceback, or a 120 s timeout fails the example. These are
19+
smoke tests: they assert the run completed and produced its declared output,
20+
not the DSP (the core tests and test_examples_demods do that).
21+
"""
22+
23+
import os
24+
import subprocess
25+
import sys
26+
from pathlib import Path
27+
28+
import numpy as np
29+
import pytest
30+
31+
from sdr_dsp.io import save_iq
32+
from sdr_dsp.core import ook_modulate
33+
34+
EXAMPLES = Path(__file__).resolve().parent.parent / "examples"
35+
TIMEOUT = 120
36+
37+
# Examples that need a radio, a sound device, or an interactive window loop
38+
# with no non-interactive mode. Each is covered elsewhere or by name here so
39+
# the list is explicit, not a silent omission.
40+
NEEDS_HARDWARE = {
41+
"hackrf_capture.py", # adapter: tests/test_hackrf_adapter_stub.py
42+
"hackrf_sink.py", # TX adapter: tests/test_tx_sink.py
43+
"live_433_capture.py", "live_spectrum.py", "live_fm_listen.py",
44+
"collect_sample_data.py", # tests/test_collect_sample_data.py
45+
"fm_band_explorer.py", # interactive UI; tests/test_fm_band_explorer.py
46+
"sdr_adapter_template.py", # a template, exercised by tests/test_modularity.py
47+
}
48+
49+
50+
# ---------------------------------------------------------------------------
51+
# synthetic captures (cf32 SigMF, which load_iq reads like any capture)
52+
# ---------------------------------------------------------------------------
53+
54+
@pytest.fixture(scope="module")
55+
def captures(tmp_path_factory):
56+
d = tmp_path_factory.mktemp("captures")
57+
rng = np.random.default_rng(0)
58+
59+
# FM: 1 kHz tone, 75 kHz deviation, 2 Msps, 0.25 s, a touch of noise
60+
fs = 2_000_000
61+
n = fs // 4
62+
t = np.arange(n) / fs
63+
msg = np.sin(2 * np.pi * 1000 * t)
64+
fm = 0.5 * np.exp(1j * 2 * np.pi * 75e3 * np.cumsum(msg) / fs)
65+
fm += 0.005 * (rng.standard_normal(n) + 1j * rng.standard_normal(n))
66+
fm_path = d / "fm.sigmf-data"
67+
save_iq(fm_path, fm.astype(np.complex64), fs, center_freq=100e6)
68+
69+
# OOK: a few bursts of a 1 kbaud pattern at 250 ksps
70+
fs_ook = 250_000
71+
sps = 250
72+
bits = np.array([1, 0, 1, 1, 0, 0, 1, 0] * 4)
73+
burst = ook_modulate(bits, sps)
74+
gap = np.zeros(20 * sps, dtype=np.complex64)
75+
ook = np.concatenate([gap, burst, gap, burst, gap]).astype(np.complex64)
76+
ook += 0.01 * (rng.standard_normal(len(ook))
77+
+ 1j * rng.standard_normal(len(ook))).astype(np.complex64)
78+
ook_path = d / "ook.sigmf-data"
79+
save_iq(ook_path, ook, fs_ook, center_freq=433.92e6)
80+
81+
# AM: carrier at +50 kHz offset, 60% modulation by a 700 Hz tone
82+
fs_am = 500_000
83+
n = fs_am // 2
84+
t = np.arange(n) / fs_am
85+
am = (1 + 0.6 * np.sin(2 * np.pi * 700 * t)) * np.exp(1j * 2 * np.pi * 50e3 * t)
86+
am = 0.4 * am + 0.005 * (rng.standard_normal(n) + 1j * rng.standard_normal(n))
87+
am_path = d / "am.sigmf-data"
88+
save_iq(am_path, am.astype(np.complex64), fs_am, center_freq=1e6)
89+
90+
return {"fm": str(fm_path), "ook": str(ook_path), "am": str(am_path),
91+
"dir": d}
92+
93+
94+
# ---------------------------------------------------------------------------
95+
# the table: example -> argv (callables receive the captures dict)
96+
# ---------------------------------------------------------------------------
97+
98+
CASES = {
99+
# file-based receivers / inspectors
100+
"am_receiver.py": lambda c: [c["am"], "--tune", "50000", "--out",
101+
str(c["dir"] / "am.wav")],
102+
"fm_receiver.py": lambda c: [c["fm"], "--out", str(c["dir"] / "fm.wav")],
103+
"ook_decoder.py": lambda c: [c["ook"]],
104+
"inspect_capture.py": lambda c: [c["fm"], "--max-samples", "200000"],
105+
"signal_survey.py": lambda c: [c["fm"], c["ook"]],
106+
"spectrum_analyzer.py": lambda c: [c["fm"], "--no-plot"],
107+
"waterfall.py": lambda c: [c["fm"], "--max-samples", "200000"],
108+
"burst_detector.py": lambda c: [c["ook"]],
109+
"annotate_bursts.py": lambda c: [c["ook"], "--out",
110+
str(c["dir"] / "annotated.sigmf-data")],
111+
"channelizer.py": lambda c: [c["fm"]],
112+
"channel_sweep.py": lambda c: ["--scheme", "fsk", "--msg", "HI"],
113+
"power_calibration.py": lambda c: ["--save", str(c["dir"] / "cal.json")],
114+
"live_pipeline_fm.py": lambda c: [c["fm"], "--block", "50000"],
115+
"cfo_demo.py": lambda c: [c["fm"], "--correct"],
116+
"dc_offset_demo.py": lambda c: [c["fm"]],
117+
"filter_explorer.py": lambda c: [c["fm"]],
118+
"iq_basics.py": lambda c: [c["fm"], "--samples", "500"],
119+
# synthetic / teaching demos (no file)
120+
"constellation_recovery.py": lambda c: ["--nsym", "300"],
121+
"decimation_stages.py": lambda c: ["--n", "100000"],
122+
"impairment_extraction.py": lambda c: [],
123+
"matched_filter_demo.py": lambda c: ["--total", "2000"],
124+
"nyquist_aliasing_demo.py": lambda c: [],
125+
"resampler_benchmark.py": lambda c: ["--n", "50000"],
126+
"spectrogram_chirp.py": lambda c: ["--dur", "0.02"],
127+
"windowing_demo.py": lambda c: [],
128+
"agc_demo.py": lambda c: [],
129+
"modulate_demo.py": lambda c: [],
130+
"differential_psk_demo.py": lambda c: [],
131+
"dsss_demo.py": lambda c: [],
132+
"fhss_visualizer.py": lambda c: [],
133+
"cw_decoder.py": lambda c: [],
134+
"fsk_decoder.py": lambda c: [],
135+
"nask_decoder.py": lambda c: [],
136+
"ssb_receiver.py": lambda c: [],
137+
"dsb_sc_demo.py": lambda c: [],
138+
"packet_loopback.py": lambda c: [],
139+
"two_station_link.py": lambda c: [],
140+
"transmit_examples.py": lambda c: [],
141+
}
142+
143+
144+
def test_every_example_is_either_run_or_explicitly_excluded():
145+
"""No example may fall through the cracks: each is in CASES or in
146+
NEEDS_HARDWARE with a reason. Adding an example without deciding which is
147+
a failure, on purpose."""
148+
names = {p.name for p in EXAMPLES.glob("*.py")}
149+
covered = set(CASES) | NEEDS_HARDWARE
150+
missing = sorted(names - covered)
151+
assert not missing, f"examples with no run test and no exclusion: {missing}"
152+
stale = sorted(covered - names)
153+
assert not stale, f"CASES/NEEDS_HARDWARE name files that do not exist: {stale}"
154+
155+
156+
def _run(path, argv, cwd):
157+
env = dict(os.environ, MPLBACKEND="Agg", PYTHONUNBUFFERED="1")
158+
env.pop("DISPLAY", None)
159+
return subprocess.run([sys.executable, str(path), *argv], cwd=cwd,
160+
capture_output=True, text=True, timeout=TIMEOUT,
161+
env=env)
162+
163+
164+
@pytest.mark.parametrize("name", sorted(CASES), ids=lambda n: n)
165+
def test_example_runs_to_completion(name, captures, tmp_path):
166+
path = EXAMPLES / name
167+
argv = CASES[name](captures)
168+
if "matplotlib" in path.read_text() and not pytest.importorskip("matplotlib"):
169+
pytest.skip("matplotlib not installed")
170+
try:
171+
r = _run(path, argv, cwd=tmp_path)
172+
except subprocess.TimeoutExpired:
173+
pytest.fail(f"{name} did not finish in {TIMEOUT}s with args {argv}")
174+
if r.returncode != 0:
175+
tail = "\n".join((r.stderr or r.stdout).strip().splitlines()[-12:])
176+
pytest.fail(f"{name} exited {r.returncode} with args {argv}:\n{tail}")
177+
assert "Traceback" not in r.stderr, r.stderr[-800:]
178+
179+
180+
def test_am_receiver_wrote_audio(captures):
181+
out = captures["dir"] / "am.wav"
182+
if not out.exists(): # the run test for am_receiver.py creates it
183+
_run(EXAMPLES / "am_receiver.py", CASES["am_receiver.py"](captures),
184+
cwd=captures["dir"])
185+
assert out.exists() and out.stat().st_size > 1000

‎sdr_dsp/tests/test_hackrf_adapter_stub.py‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@
77
the hackrfpy stream path underneath all run for real, with no hardware.
88
99
The ramp has I even and Q odd, and the stub writes it in deliberately
10-
odd-sized flushed pieces, so a single dropped byte anywhere shows up as every
10+
odd-sized flushed pieces with a pause after each, so the parent's read1() sees
11+
the odd piece on its own and a single dropped byte anywhere shows up as every
1112
later sample having I and Q exchanged -- a capture whose power statistics look
1213
fine while its phase is garbage. hackrfpy < 1.1.1 does exactly that in
13-
``PersistentReceiver.blocks()``; the xfail flips to a hard failure (strict)
14-
when the fixed hackrfpy is installed, which is the signal to delete the mark.
14+
``PersistentReceiver.blocks()``.
15+
16+
Whether the old code actually misbehaves on a given run depends on how the OS
17+
delivers pipe writes (macOS coalesced them in CI and the bug never fired), so
18+
the alignment assertion is SKIPPED, not xfailed, on the old hackrfpy: a
19+
timing-dependent bug trigger must not be a strict expectation. Once the dev
20+
group pins hackrfpy >= 1.1.1 the skip disappears and the assertion runs.
1521
"""
1622

1723
import os
@@ -39,6 +45,8 @@
3945
n = sizes[k % len(sizes)]; k += 1
4046
sys.stdout.buffer.write(raw[pos:pos + n]); pos += n
4147
sys.stdout.buffer.flush()
48+
if n % 2:
49+
time.sleep(0.02) # let the parent read the odd piece by itself
4250
time.sleep(10) # stay alive until the parent interrupts us
4351
'''
4452

@@ -82,29 +90,29 @@ def _version_tuple(v):
8290
return (0, 0, 0)
8391

8492

85-
needs_fixed_hackrfpy = pytest.mark.xfail(
86-
_version_tuple(hackrfpy.__version__) < (1, 1, 1), strict=True,
93+
needs_fixed_hackrfpy = pytest.mark.skipif(
94+
_version_tuple(hackrfpy.__version__) < (1, 1, 1),
8795
reason="hackrfpy < 1.1.1: PersistentReceiver.blocks() drops an odd byte "
88-
"and swaps I/Q for the rest of the stream")
96+
"and swaps I/Q for the rest of the stream (fixed in 1.1.1)")
8997

9098

9199
def test_adapter_satisfies_iqsource(stub_tools_dir, HackRFCapture):
92100
from sdr_dsp.sources.base import IQSource
93-
src = HackRFCapture(100e6, 2e6, tools_dir=stub_tools_dir)
101+
src = HackRFCapture(100e6, 8e6, tools_dir=stub_tools_dir)
94102
assert isinstance(src, IQSource)
95-
assert src.sample_rate == 2e6 and src.center_freq == 100e6
103+
assert src.sample_rate == 8e6 and src.center_freq == 100e6
96104

97105

98106
def test_adapter_requires_context_manager(stub_tools_dir, HackRFCapture):
99-
src = HackRFCapture(100e6, 2e6, tools_dir=stub_tools_dir)
107+
src = HackRFCapture(100e6, 8e6, tools_dir=stub_tools_dir)
100108
with pytest.raises(RuntimeError):
101109
next(src.blocks())
102110
with pytest.raises(RuntimeError):
103111
src.read(10)
104112

105113

106114
def test_adapter_read_is_exact_and_pair_aligned(stub_tools_dir, HackRFCapture):
107-
with HackRFCapture(100e6, 2e6, tools_dir=stub_tools_dir) as src:
115+
with HackRFCapture(100e6, 8e6, tools_dir=stub_tools_dir) as src:
108116
a = src.read(1000)
109117
b = src.read(70_000)
110118
assert len(a) == 1000 and len(b) == 70_000
@@ -116,7 +124,7 @@ def test_adapter_read_is_exact_and_pair_aligned(stub_tools_dir, HackRFCapture):
116124
def test_adapter_blocks_are_pair_aligned_across_odd_pipe_chunks(stub_tools_dir,
117125
HackRFCapture):
118126
got = []
119-
with HackRFCapture(100e6, 2e6, tools_dir=stub_tools_dir) as src:
127+
with HackRFCapture(100e6, 8e6, tools_dir=stub_tools_dir) as src:
120128
for blk in src.blocks():
121129
assert blk.dtype == np.complex64
122130
got.append(blk)
@@ -126,7 +134,7 @@ def test_adapter_blocks_are_pair_aligned_across_odd_pipe_chunks(stub_tools_dir,
126134

127135

128136
def test_adapter_capture_array_convenience(stub_tools_dir, HackRFCapture):
129-
src = HackRFCapture(100e6, 2e6, tools_dir=stub_tools_dir)
137+
src = HackRFCapture(100e6, 8e6, tools_dir=stub_tools_dir)
130138
x = src.capture_array(5000)
131139
assert len(x) == 5000
132140
_check_ramp(x)
@@ -140,7 +148,7 @@ def test_adapter_drives_a_streaming_pipeline(stub_tools_dir, HackRFCapture):
140148
from sdr_dsp.stream import StreamingFIR
141149
taps = design_lowpass(200e3, 2e6, num_taps=33)
142150
seen = []
143-
with HackRFCapture(100e6, 2e6, tools_dir=stub_tools_dir, block_size=4096) as src:
151+
with HackRFCapture(100e6, 8e6, tools_dir=stub_tools_dir, block_size=4096) as src:
144152
class _Bounded:
145153
sample_rate, center_freq = src.sample_rate, src.center_freq
146154
def blocks(self):

0 commit comments

Comments
 (0)