bandsaunter/tests/test_classify.py
The Dust Council db3e0c79b9 Initial commit: bandsaunter, an RTL-SDR signal scanner
Sweeps any set of frequency ranges, records what it finds, and works out
what kind of signal it was.

- Frequency ranges entered by hand or picked from a 135-entry US band plan,
  including whole-band and all-CW sweeps that resolve the demodulator per
  segment.
- Detection calibrated against the peak-hold detector's own noise statistics,
  so the threshold means real margin over static rather than over the floor.
- A content gate: captures are kept only if they carry voice, decodable CW,
  or an identified digital keying scheme. Speech is recognised by a pitch
  track that drifts, which static cannot imitate.
- Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR,
  NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK.
- Gapless streaming capture, with the signal path fast enough to keep up in
  real time, so recordings play back at the right speed.
- Optional one-file-per-frequency recording with spoken timestamps, and
  speech-to-text transcription.
- Menus and command line generated from one settings table, so neither can
  offer something the other cannot; settings persist in ~/.config.

367 tests, run against synthetic signals, a built-in receiver simulator, and
real hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:50:20 -07:00

50 lines
1.9 KiB
Python

import pytest
from bandsaunter.classify import classify, extract_features
from signals import FS, make
CASES = [
("cw", "cw", 14.05e6), ("nfm", "nfm", 146.52e6), ("am", "am", 121.5e6),
("usb", "ssb", 14.2e6), ("lsb", "ssb", 7.2e6),
("fsk2", "fsk", 929.5e6), ("fsk4", "fsk", 460.0e6),
("psk4", "psk", 450.0e6), ("psk2", "psk", 437.0e6),
("carrier", "carrier", 446.0e6), ("noise", "unknown", 300e6),
]
@pytest.mark.parametrize("kind,family,freq", CASES)
@pytest.mark.parametrize("seed", [3, 11, 42])
def test_modulation_family(kind, family, freq, seed):
c = classify(make(kind, seed=seed), FS, freq_hz=freq, snr_db=28)
assert c.family == family, f"{kind} -> {c.label!r} (family {c.family})"
def test_known_systems_are_named_from_frequency():
assert "airband" in classify(make("am"), FS, freq_hz=121.5e6,
snr_db=28).label.lower()
assert "broadcast" in classify(make("wfm"), FS, freq_hz=97.5e6,
snr_db=28).label.lower()
assert "weather" in classify(make("nfm"), FS, freq_hz=162.475e6,
snr_db=28).label.lower()
def test_ctcss_tone_recovered():
c = classify(make("nfm"), FS, freq_hz=146.52e6, snr_db=28)
assert c.features.ctcss_hz == pytest.approx(100.0, abs=0.5)
def test_low_snr_reduces_confidence():
strong = classify(make("nfm", snr_db=30), FS, freq_hz=146.52e6, snr_db=30)
weak = classify(make("nfm", snr_db=5), FS, freq_hz=146.52e6, snr_db=5)
assert weak.confidence < strong.confidence
def test_fsk_level_count():
assert extract_features(make("fsk2"), FS, snr_db=28).freq_modes == 2
assert extract_features(make("fsk4"), FS, snr_db=28).freq_modes == 4
def test_symbol_rate_estimate():
f = extract_features(make("fsk4"), FS, snr_db=28)
# The cyclostationary line lands on the baud rate or a low harmonic of it.
assert f.baud > 1000