Name the band beside every frequency, and map who was heard

Two additions, both about turning a number into something meaningful.

A band column.  Next to every frequency -- on the live display, in the
line-per-hit output, in saunterbrowse's list and details -- is the name of
the band it falls in.  421 MHz is the 70 cm amateur band, and being told
so is quicker than remembering where the edges are.

The names come from the existing preset table, so there is one band plan
to keep right rather than two, but naming is not the job that table was
shaped for: several presets cover any frequency, some of them whole-tuner
sweeps that say nothing.  So the candidates are ranked.  Sweeps and the
"-complete" duplicates are dropped outright.  The narrowest of what is
left wins, because it says the most -- 146.52 MHz comes back as the 2 m
simplex calling channel rather than as the whole 2 m band.  Two exceptions
where the narrowest would be the wrong answer: ISM yields to the
allocation it shares (433.92 is 70 cm first, 915 is 33 cm first), and
shortwave broadcast yields to amateur where the two overlap, because
3.9-4.0 and 7.2-7.3 MHz are Region 1 and 3 broadcast but Region 2 amateur,
and this plan is documented as Region 2.  6 MHz really is 49 m shortwave
and is left alone.

The name is written into each capture's sidecar, so it travels with the
recording and an edit to the plan later cannot rewrite history, and
saunterbrowse searches on it: /70 cm finds the band without anyone having
to remember 420-450 MHz.

A map.  A licence says where its holder is, so a list of callsigns is also
a map.  Callsigns heard during a scan are now looked up as the transcripts
come in, announced on the display, and written to callsigns.kml in the
output directory; saunterbrowse --kml builds the same file from recordings
already on disk, and the two continue one map rather than starting two.

One placemark per station, not one per transmission: the same repeater
heard twenty times in an evening is one operator, and twenty pins on one
rooftop would say less than one.  Each pin carries the callsign, the
licensee, the town, the grid square, and every frequency and time it was
heard on.  The file is read back on open and added to, so later scans
build it up rather than replacing it.

Where a licence has no coordinates the grid square's centre is used and
the placemark says so -- a square is kilometres across where an address is
a street.  A callsign with no licence at all is still recorded, in a
folder that starts switched off, because that a station was heard is worth
keeping even when nothing says where.  A file already there that is not
readable as KML is never overwritten.

Also fixed along the way:

- The hit list's "no signals recorded yet" placeholder was one cell short
  of its row, so it landed in the SNR column and wrapped, making the panel
  taller than the layout had budgeted for and scrolling the display off a
  short terminal.  The identification column can no longer wrap either,
  which is what _hit_capacity has always assumed.

- Licence lookups now record coordinates.  The cache is versioned so that
  entries written before this are asked about again, rather than pinning
  every station to its grid square for good.

- CallsignBook.wait dropped joined threads; an all-night scan calls it
  after every transcript and the list only ever grew.

- Tests redirect XDG_CACHE_HOME, so a run no longer reads or writes the
  real lookup cache.

676 -> 761 tests.
This commit is contained in:
The Dust Council 2026-08-28 11:02:16 -07:00
parent 739a2faaf4
commit fb2bb3344b
23 changed files with 2155 additions and 42 deletions

14
tests/conftest.py Normal file
View file

@ -0,0 +1,14 @@
"""Fixtures every test gets.
The one that matters is the cache: a lookup writes to ``~/.cache`` by
default, and a test run that touches the real one leaves entries behind and
reads back entries an earlier version wrote. Redirecting it per test makes
each run start from nothing.
"""
import pytest
@pytest.fixture(autouse=True)
def isolated_cache(tmp_path_factory, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME",
str(tmp_path_factory.mktemp("cache")))

233
tests/test_bands.py Normal file
View file

@ -0,0 +1,233 @@
"""Naming a frequency: which band it is in, and how that is shown."""
import os
import tempfile
import time
import numpy as np
import pytest
from rich.console import Console
from bandsaunter import bandplan as bp
from bandsaunter.bandplan import (LABEL_SKIP_TAGS, PRESETS, band_label,
band_name, band_names, label_for,
label_presets, shorten_band)
from bandsaunter.config import ScanConfig
from bandsaunter.ranges import parse_range_list
from bandsaunter.recorder import HitRecord
from bandsaunter.scanner import Scanner
from bandsaunter.simulator import SimulatedDevice
from bandsaunter.ui import ScanDisplay, print_hit
# ---------------------------------------------------------------------------
# What a frequency is called
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("hz,expected", [
# The one from the request: a signal at 421 MHz is in the 70 cm band.
(421e6, "70 cm Amateur"),
(146.52e6, "2 m FM Simplex"),
(162.55e6, "NOAA Weather Radio"),
(462.5625e6, "GMRS / FRS"),
(27.185e6, "Citizens Band (CB) 11 m"),
(88.5e6, "FM Broadcast Band"),
(1.0e6, "AM Broadcast Band"),
(1090e6, "ADS-B (1090 MHz)"),
(856.5e6, "800 MHz Trunked Downlinks"),
])
def test_a_frequency_is_named_by_its_band(hz, expected):
assert band_name(hz) == expected
def test_a_frequency_no_preset_covers_has_no_band():
# 2.1 GHz is past the tuner and past every preset in the plan.
assert band_name(2.1e9) == ""
assert band_label(2.1e9) == ""
assert not label_for(2.1e9)
def test_wide_sweeps_never_name_anything():
""""Full UHF Sweep" is somewhere to point the radio, not an answer."""
for hz in (100e6, 421e6, 462e6, 915e6, 1090e6):
for preset in label_presets(hz):
assert not (set(preset.tags) & LABEL_SKIP_TAGS), \
f"{preset.key} named {hz/1e6:g} MHz"
def test_ism_yields_to_the_allocation_it_shares():
"""433 and 915 MHz are ISM, but they are 70 cm and 33 cm first."""
assert band_name(433.92e6) == "70 cm Amateur"
assert band_name(915e6) == "33 cm (902-928) Amateur"
# ISM still wins where nothing else covers the frequency at all.
assert "ISM" in band_name(315e6) or band_name(315e6)
def test_shortwave_broadcast_yields_only_where_a_ham_band_overlaps():
"""3.9-4.0 and 7.2-7.3 MHz are amateur in Region 2, which this plan is."""
assert "Amateur" in band_name(3.965e6)
assert "Amateur" in band_name(7.242e6)
# 6 MHz really is 49 m shortwave; there is no amateur band near it.
assert band_name(6.0e6) == "49 m Shortwave Broadcast"
assert band_name(9.6e6) == "31 m Shortwave Broadcast"
def test_the_broader_name_wins_a_tie():
"""GMRS/FRS and the FRS simplex channels differ by one channel."""
assert band_name(462.5625e6) == "GMRS / FRS"
def test_a_much_narrower_segment_beats_the_band_containing_it():
""""20 m CW" says more than "20 m", and is just as true."""
assert band_name(14.05e6) == "20 m CW / Digital"
assert "20 m Amateur" in band_names(14.05e6)
def test_every_band_names_its_own_middle():
"""A preset that cannot name a frequency inside itself is unreachable."""
orphans = []
for p in PRESETS:
if p.is_group or (set(p.tags) & LABEL_SKIP_TAGS):
continue
middle = 0.5 * (p.start + p.stop)
if p.name not in band_names(middle, limit=99):
orphans.append(p.key)
assert not orphans, f"presets that never name anything: {orphans}"
def test_names_are_ordered_most_specific_first():
names = band_names(146.52e6, limit=99)
assert names[0] == "2 m FM Simplex"
assert "2 m Amateur" in names
# ---------------------------------------------------------------------------
# Fitting it in a column
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name,width,expected", [
("ADS-B (1090 MHz)", 0, "ADS-B"),
("33 cm (902-928) Amateur", 0, "33 cm Amateur"),
("1.25 m Weak Signal (CW/SSB)", 0, "1.25 m Weak Signal"),
("800 MHz Public Safety / SMR", 24, "800 MHz Public Safety"),
# Nothing left to drop: truncate, and say so.
("800 MHz Public Safety / SMR", 12, "800 MHz Pub…"),
("VHF Airband - Tower & Ground", 12, "VHF Airband"),
])
def test_shortening_removes_restatements_before_it_truncates(name, width,
expected):
assert shorten_band(name, width) == expected
def test_shortening_never_exceeds_the_width_it_was_given():
for p in PRESETS:
for width in (8, 12, 16, 20, 24):
assert len(shorten_band(p.name, width)) <= width, p.name
def test_a_name_that_already_fits_is_left_alone():
assert shorten_band("70 cm Amateur", 20) == "70 cm Amateur"
# ---------------------------------------------------------------------------
# Where it is shown
# ---------------------------------------------------------------------------
def _display(width: int) -> ScanDisplay:
console = Console(width=width, height=40, file=open(os.devnull, "w"),
record=True)
cfg = ScanConfig(ranges=parse_range_list("144M-148M"),
output_dir=tempfile.mkdtemp())
scanner = Scanner(cfg, device=SimulatedDevice().open())
scanner.prepare()
display = ScanDisplay(scanner, console=console)
display.attach()
return display
def _hit(hz: float, **kw) -> HitRecord:
hit = HitRecord(frequency=hz, started_at=time.time(), duration=5.0,
snr_db=20.0, classification="FM voice", **kw)
hit.kept = True
return hit
def _render(renderable, console) -> str:
console.print(renderable)
return console.export_text()
def test_the_hit_list_has_a_band_beside_the_frequency():
d = _display(120)
d.hits.appendleft(_hit(421e6))
text = _render(d._hits_table(), d.console)
assert "band" in text
assert "70 cm Amateur" in text
# and beside the frequency, not somewhere else on the row
row = [ln for ln in text.splitlines() if "421 MHz" in ln][0]
assert row.index("421 MHz") < row.index("70 cm Amateur")
def test_the_band_column_is_dropped_before_the_identification_is():
narrow = _display(60)
narrow.hits.appendleft(_hit(421e6))
text = _render(narrow._hits_table(), narrow.console)
assert "70 cm" not in text
assert "FM voice" in text
def test_a_recording_in_progress_says_which_band_it_is_in():
d = _display(120)
d._rec.active = True
d._rec.frequency = 146.52e6
d._rec.mode = "nfm"
text = _render(d._record_panel(), d.console)
assert "2 m FM Simplex" in text
def test_the_sweep_line_says_which_band_is_being_swept():
d = _display(120)
step = d.scanner.plan[0]
d.on_step(0, 6, step, np.full(1024, -70.0),
np.linspace(step.low, step.high, 1024))
text = _render(d._sweep_panel(), d.console)
assert "2 m" in text
def test_the_line_per_hit_output_carries_the_band_too():
console = Console(width=140, file=open(os.devnull, "w"), record=True)
print_hit(console, _hit(421e6))
assert "70 cm Amateur" in console.export_text()
def test_a_hit_keeps_the_band_it_was_filed_under():
"""A band plan edited later must not rewrite what an old scan recorded."""
hit = _hit(421e6)
hit.band = "Somewhere Else"
assert ScanDisplay._band_of(hit, 20) == "Somewhere Else"
def test_a_hit_from_before_bands_were_recorded_still_gets_one():
hit = _hit(421e6)
hit.band = ""
hit.band_labels = []
assert ScanDisplay._band_of(hit, 20) == "70 cm Amateur"
def test_the_scanner_records_the_band_with_every_hit():
"""So the sidecar says what it was, not just where it was."""
cfg = ScanConfig(ranges=parse_range_list("144M-148M"),
output_dir=tempfile.mkdtemp())
scanner = Scanner(cfg, device=SimulatedDevice().open())
scanner.prepare()
label = bp.label_for(146.52e6)
assert label.name and label.names[0] == label.name
def test_an_empty_hit_list_still_takes_one_row():
"""A placeholder that wraps makes the panel taller than the layout
budgeted for, and the whole display then scrolls itself off the screen."""
for width in (60, 100, 140):
d = _display(width)
text = _render(d._hits_table(), d.console)
body = [ln for ln in text.splitlines() if "no signals" in ln]
assert len(body) == 1, f"the placeholder wrapped at width {width}"

View file

@ -840,3 +840,96 @@ def test_no_lookup_contacts_nothing(net, capsys, monkeypatch):
assert made == [], "a request was made with --no-lookup"
assert "KU0W" in out
assert "United States" in out, "the prefix should still be described"
# ---------------------------------------------------------------------------
# Bands
# ---------------------------------------------------------------------------
def test_the_details_line_names_the_band(library):
b = browser(library)
b.index = [i for i, c in enumerate(b.view)
if abs(c.frequency - 146.52e6) < 1e5][0]
assert "2 m Amateur" in frame(b)
def test_a_capture_keeps_the_band_its_sidecar_recorded(library):
"""The plan can be edited later; the recording was filed under this one."""
b = browser(library)
cap = [c for c in b.captures if abs(c.frequency - 146.52e6) < 1e5][0]
assert cap.band == "2 m Amateur"
def test_a_capture_with_no_band_in_its_sidecar_gets_one_from_the_dial(library):
b = browser(library)
cap = [c for c in b.captures if abs(c.frequency - 144.1e6) < 1e5][0]
assert cap.band and "2 m" in cap.band
def test_a_band_can_be_searched_for(library):
""""70 cm" is how an operator thinks of a range, not 420-450 MHz."""
b = browser(library)
b.query = "2 m"
b.apply()
assert b.view, "searching by band found nothing"
assert all("2 m" in c.band for c in b.view)
def test_the_listing_carries_the_band_beside_the_frequency(library, capsys):
from bandsaunter.browse import main
assert main([str(library), "--list"]) == 0
out = capsys.readouterr().out
row = [ln for ln in out.splitlines() if "146.52 MHz" in ln][0]
assert row.index("146.52 MHz") < row.index("2 m Amateur")
# ---------------------------------------------------------------------------
# The map
# ---------------------------------------------------------------------------
def _stub_lookup(monkeypatch):
def respond(self, call):
return {"status": "VALID", "name": "ARRL HQ OPERATORS CLUB",
"address": {"line2": "NEWINGTON, CT 06111"},
"location": {"latitude": "41.714775",
"longitude": "-72.727260",
"gridsquare": "FN31pr"}}
monkeypatch.setattr(CallsignBook, "_request", respond)
def test_the_browser_writes_a_map_of_who_was_heard(library, monkeypatch,
capsys):
import xml.etree.ElementTree as ET
from bandsaunter.browse import main
_stub_lookup(monkeypatch)
assert main([str(library), "--kml"]) == 0
written = library / "callsigns.kml"
assert written.exists()
root = ET.parse(written).getroot()
ns = "{http://www.opengis.net/kml/2.2}"
names = [m.find(ns + "name").text for m in root.iter(ns + "Placemark")]
assert any("W1AW" in n for n in names)
# and where it is licensed, which is the point of a map
point = next(root.iter(ns + "Placemark")).find(
f"{ns}Point/{ns}coordinates")
assert point is not None and point.text.startswith("-72.7")
assert "with a position" in capsys.readouterr().out
def test_the_map_can_be_written_somewhere_else(library, tmp_path, monkeypatch):
from bandsaunter.browse import main
_stub_lookup(monkeypatch)
where = tmp_path / "elsewhere" / "heard.kml"
assert main([str(library), "--kml", str(where)]) == 0
assert where.exists()
assert not (library / "callsigns.kml").exists()
def test_no_callsigns_means_no_map(tmp_path, monkeypatch):
from bandsaunter.browse import main
_stub_lookup(monkeypatch)
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", 4.0,
transcript="Nobody said a callsign in this one at all.",
meta={"category": "voice"})
assert main([str(tmp_path), "--kml"]) == 1
assert not list(tmp_path.glob("*.kml"))

View file

@ -356,3 +356,64 @@ def test_the_report_carries_the_extra_detail():
oper_class="EXTRA", grid="DM42lj", status="found")
line = report([entry], width=0)[1]
assert "Extra" in line and "DM42lj" in line
# ---------------------------------------------------------------------------
# Coordinates, and the cache that holds them
# ---------------------------------------------------------------------------
def test_a_lookup_records_where_the_licence_says_the_station_is(monkeypatch):
"""The map is built from these, so they have to survive the parse."""
body = {"status": "VALID", "name": "ARRL HQ OPERATORS CLUB",
"address": {"line2": "NEWINGTON, CT 06111"},
"location": {"latitude": "41.714775", "longitude": "-72.727260",
"gridsquare": "FN31pr"}}
entry = Callsign(call="W1AW")
CallsignBook._apply(entry, body)
assert entry.position == pytest.approx((41.714775, -72.727260))
assert not entry.coarse_position
def test_a_licence_with_only_a_grid_square_falls_back_to_it(monkeypatch):
body = {"status": "VALID", "name": "SOMEONE",
"address": {"line2": "SOMEWHERE, AZ 85742"},
"location": {"gridsquare": "DM42lj"}}
entry = Callsign(call="KU0W")
CallsignBook._apply(entry, body)
assert entry.position is not None
# and it says the position is only as good as the square
assert entry.coarse_position
def test_a_licence_with_no_position_at_all_has_none():
body = {"status": "VALID", "name": "SOMEONE", "address": {}, "location": {}}
entry = Callsign(call="K7XYZ")
CallsignBook._apply(entry, body)
assert entry.position is None
assert not entry.coarse_position
def test_an_entry_cached_before_coordinates_is_looked_up_again(tmp_path):
"""Otherwise a warm cache pins every station to its grid square for good."""
from bandsaunter.callsign import CACHE_VERSION
cache = tmp_path / "callsigns.json"
cache.write_text(json.dumps({"W1AW": {
"call": "W1AW", "name": "Arrl Hq Operators Club", "status": "found",
"grid": "FN31pr", "fetched_at": time.time()}}))
book = CallsignBook(online=False, cache=cache)
assert "W1AW" not in book._entries
cache.write_text(json.dumps({"W1AW": {
"call": "W1AW", "name": "Arrl Hq Operators Club", "status": "found",
"grid": "FN31pr", "fetched_at": time.time(),
"version": CACHE_VERSION}}))
assert "W1AW" in CallsignBook(online=False, cache=cache)._entries
def test_an_unlisted_callsign_is_cached_too(tmp_path, monkeypatch):
"""Asking again every run about a callsign with no licence is waste."""
from bandsaunter.callsign import CACHE_VERSION
entry = Callsign(call="XX9ZZ")
CallsignBook._apply(entry, {"status": "INVALID"})
assert entry.status == "unlisted"
assert entry.version == CACHE_VERSION

453
tests/test_kml.py Normal file
View file

@ -0,0 +1,453 @@
"""The map of who was heard.
KML is a text format, and every one of these reads back what was written
with an XML parser rather than by matching strings, so a file that would not
open in Google Earth fails here first.
Nothing touches the network: the licence data is built by hand.
"""
import time
import xml.etree.ElementTree as ET
import pytest
from bandsaunter.callsign import Callsign, CallsignBook, grid_to_latlon
from bandsaunter.kml import Contact, Hearing, KmlLog
KML_NS = "{http://www.opengis.net/kml/2.2}"
@pytest.fixture(autouse=True)
def no_network(monkeypatch):
def refuse(self, call):
raise AssertionError(f"a test tried to look up {call} for real")
monkeypatch.setattr(CallsignBook, "_request", refuse)
def _when(text: str) -> float:
return time.mktime(time.strptime(text, "%Y-%m-%d %H:%M:%S"))
def _entry(call="KU0W", **kw) -> Callsign:
body = dict(name="Rod R Gowdy", location="Tucson, AZ",
country="United States", grid="DM42lj", oper_class="EXTRA",
latitude=32.3345, longitude=-111.0421, status="found")
body.update(kw)
return Callsign(call=call, **body)
def _placemarks(path):
root = ET.parse(path).getroot()
return root.iter(KML_NS + "Placemark")
def _fields(placemark) -> dict:
out = {}
for data in placemark.iter(KML_NS + "Data"):
value = data.find(KML_NS + "value")
out[data.get("name")] = value.text if value is not None else ""
return out
# ---------------------------------------------------------------------------
# One station, one pin
# ---------------------------------------------------------------------------
def test_a_callsign_becomes_a_placemark_where_it_is_licensed(tmp_path):
log = KmlLog(tmp_path / "map.kml")
log.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44"),
recording="0146.880000MHz-nfm.wav")
written = log.save()
assert written is not None and written.exists()
marks = list(_placemarks(written))
assert len(marks) == 1
fields = _fields(marks[0])
assert fields["callsign"] == "KU0W"
assert fields["licensee"] == "Rod R Gowdy"
assert fields["location"] == "Tucson, AZ"
assert fields["grid"] == "DM42lj"
point = marks[0].find(f"{KML_NS}Point/{KML_NS}coordinates")
lon, lat, _ = point.text.split(",")
# KML puts longitude first, which is the easy thing to get backwards.
assert float(lon) == pytest.approx(-111.0421, abs=1e-4)
assert float(lat) == pytest.approx(32.3345, abs=1e-4)
def test_the_name_on_the_pin_is_the_callsign_and_the_licensee(tmp_path):
log = KmlLog(tmp_path / "map.kml")
log.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44"))
mark = next(_placemarks(log.save()))
assert mark.find(KML_NS + "name").text == "KU0W — Rod R Gowdy"
def test_the_balloon_names_the_frequency_and_the_time(tmp_path):
log = KmlLog(tmp_path / "map.kml")
log.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44"))
body = next(_placemarks(log.save())).find(KML_NS + "description").text
assert "146.88 MHz" in body
assert "2026-08-22 13:01:44" in body
assert "2 m Amateur" in body or "2 m" in body
assert "Rod R Gowdy" in body
def test_hearing_a_station_again_adds_a_line_not_a_second_pin(tmp_path):
log = KmlLog(tmp_path / "map.kml")
for offset in (0, 300, 900):
log.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44") + offset)
written = log.save()
assert len(list(_placemarks(written))) == 1
heard = _fields(next(_placemarks(written)))["heard"].splitlines()
assert len(heard) == 3
def test_the_very_same_transmission_is_not_recorded_twice(tmp_path):
"""A re-run over the same recordings must not inflate the count."""
log = KmlLog(tmp_path / "map.kml")
at = _when("2026-08-22 13:01:44")
assert log.add(_entry(), 146.88e6, at) is True
assert log.add(_entry(), 146.88e6, at) is False
heard = _fields(next(_placemarks(log.save())))["heard"].splitlines()
assert len(heard) == 1
def test_a_station_heard_on_two_bands_keeps_both(tmp_path):
log = KmlLog(tmp_path / "map.kml")
at = _when("2026-08-22 13:01:44")
log.add(_entry(), 146.88e6, at)
log.add(_entry(), 7.242e6, at + 60)
heard = _fields(next(_placemarks(log.save())))["heard"]
assert "146.88 MHz" in heard and "7.242 MHz" in heard
def test_the_time_span_covers_first_to_last(tmp_path):
log = KmlLog(tmp_path / "map.kml")
at = _when("2026-08-22 13:01:44")
log.add(_entry(), 146.88e6, at)
log.add(_entry(), 146.88e6, at + 3600)
mark = next(_placemarks(log.save()))
span = mark.find(KML_NS + "TimeSpan")
assert span is not None
begin = span.find(KML_NS + "begin").text
end = span.find(KML_NS + "end").text
assert begin < end and begin.endswith("Z")
# ---------------------------------------------------------------------------
# Continuing an existing map
# ---------------------------------------------------------------------------
def test_a_later_run_adds_to_the_map_rather_than_replacing_it(tmp_path):
path = tmp_path / "map.kml"
first = KmlLog(path)
first.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44"))
first.save()
second = KmlLog(path)
assert "KU0W" in second.contacts, "the existing map was not read back"
second.add(_entry("W1AW", name="Arrl Hq Operators Club",
location="Newington, CT", grid="FN31pr",
latitude=41.7147, longitude=-72.7270),
3.965e6, _when("2026-08-23 20:00:00"))
written = second.save()
calls = {_fields(m)["callsign"] for m in _placemarks(written)}
assert calls == {"KU0W", "W1AW"}
def test_what_was_heard_before_survives_the_round_trip(tmp_path):
path = tmp_path / "map.kml"
first = KmlLog(path)
at = _when("2026-08-22 13:01:44")
first.add(_entry(), 146.88e6, at, recording="one.wav")
first.save()
second = KmlLog(path)
heard = second.contacts["KU0W"].hearings
assert len(heard) == 1
assert heard[0].frequency == pytest.approx(146.88e6)
assert heard[0].at == pytest.approx(at)
assert heard[0].recording == "one.wav"
# and the same transmission read back is still not added twice
assert second.add(_entry(), 146.88e6, at) is False
def test_a_position_learned_later_updates_the_pin(tmp_path):
path = tmp_path / "map.kml"
first = KmlLog(path)
first.add(Callsign(call="KU0W", status="offline"), 146.88e6, 1.0)
first.save()
second = KmlLog(path)
second.add(_entry(), 146.88e6, 2.0)
mark = next(_placemarks(second.save()))
assert mark.find(f"{KML_NS}Point/{KML_NS}coordinates") is not None
def test_a_failed_lookup_does_not_blank_out_what_is_known(tmp_path):
log = KmlLog(tmp_path / "map.kml")
log.add(_entry(), 146.88e6, 1.0)
log.add(Callsign(call="KU0W", status="offline"), 146.88e6, 2.0)
fields = _fields(next(_placemarks(log.save())))
assert fields["licensee"] == "Rod R Gowdy"
# ---------------------------------------------------------------------------
# Stations with nowhere to put them
# ---------------------------------------------------------------------------
def test_a_callsign_with_no_licence_is_kept_but_not_placed(tmp_path):
log = KmlLog(tmp_path / "map.kml")
log.add(Callsign(call="2E0XYZ", country="United Kingdom",
status="unlisted"), 145.5e6, 1.0)
written = log.save()
mark = next(_placemarks(written))
assert _fields(mark)["callsign"] == "2E0XYZ"
assert mark.find(f"{KML_NS}Point/{KML_NS}coordinates") is None
root = ET.parse(written).getroot()
folder = root.find(f".//{KML_NS}Folder/{KML_NS}name")
assert folder is not None and "no location" in folder.text
def test_a_grid_square_places_a_station_the_licence_did_not(tmp_path):
log = KmlLog(tmp_path / "map.kml")
log.add(Callsign(call="W1AW", grid="FN31pr", status="found"),
3.965e6, 1.0)
mark = next(_placemarks(log.save()))
point = mark.find(f"{KML_NS}Point/{KML_NS}coordinates")
lon, lat, _ = point.text.split(",")
assert float(lat) == pytest.approx(41.73, abs=0.05)
assert float(lon) == pytest.approx(-72.71, abs=0.05)
# and it says so, because a grid square is kilometres wide
assert "grid square" in mark.find(KML_NS + "description").text
def test_a_record_with_no_coordinates_is_not_placed_off_africa(tmp_path):
"""Zeroed coordinates are missing data, not a position in the Atlantic."""
entry = Callsign(call="K7XYZ", status="found",
latitude=0.0, longitude=0.0)
log = KmlLog(tmp_path / "map.kml")
log.add(entry, 146.0e6, 1.0)
mark = next(_placemarks(log.save()))
assert mark.find(f"{KML_NS}Point/{KML_NS}coordinates") is None
@pytest.mark.parametrize("grid,lat,lon", [
("DM42lj", 32.40, -111.04),
("FN31pr", 41.73, -72.71),
("EM79", 39.5, -85.0),
])
def test_grid_squares_convert_to_their_centre(grid, lat, lon):
got = grid_to_latlon(grid)
assert got == pytest.approx((lat, lon), abs=0.06)
@pytest.mark.parametrize("bad", ["", "nonsense", "ZZ99", "DM4", "DM42lj9"])
def test_what_is_not_a_grid_square_converts_to_nothing(bad):
assert grid_to_latlon(bad) is None
# ---------------------------------------------------------------------------
# Not breaking the file
# ---------------------------------------------------------------------------
def test_a_name_with_xml_in_it_does_not_break_the_document(tmp_path):
log = KmlLog(tmp_path / "map.kml")
log.add(_entry(name="Smith & Sons <Radio> Club"), 146.88e6, 1.0)
fields = _fields(next(_placemarks(log.save())))
assert fields["licensee"] == "Smith & Sons <Radio> Club"
def test_an_unreadable_file_is_never_overwritten(tmp_path):
path = tmp_path / "map.kml"
path.write_text("this is not KML, and something else wrote it")
log = KmlLog(path)
log.add(_entry(), 146.88e6, 1.0)
assert log.save() is None
assert path.read_text().startswith("this is not KML")
def test_nothing_heard_writes_no_file(tmp_path):
path = tmp_path / "map.kml"
assert KmlLog(path).save() is None
assert not path.exists()
def test_the_write_leaves_no_temporary_behind(tmp_path):
log = KmlLog(tmp_path / "map.kml")
log.add(_entry(), 146.88e6, 1.0)
log.save()
assert [p.name for p in tmp_path.iterdir()] == ["map.kml"]
def test_a_callsign_with_no_call_is_refused(tmp_path):
log = KmlLog(tmp_path / "map.kml")
assert log.add(Callsign(call=""), 146.0e6, 1.0) is False
assert len(log) == 0
# ---------------------------------------------------------------------------
# The pieces
# ---------------------------------------------------------------------------
def test_a_hearing_reads_back_what_it_wrote():
original = Hearing(146.88e6, _when("2026-08-22 13:01:44"),
"2 m Amateur", "capture.wav")
again = Hearing.parse(original.describe())
assert again.frequency == pytest.approx(original.frequency)
assert again.at == pytest.approx(original.at)
assert again.band == original.band
assert again.recording == original.recording
def test_a_mangled_line_loses_only_itself():
"""Someone editing the map by hand must not destroy the rest of it."""
assert Hearing.parse("") is None
assert Hearing.parse("who knows what this is") is not None
def test_a_contact_orders_its_hearings_by_time():
c = Contact(call="KU0W")
c.add(Hearing(146.88e6, 300.0))
c.add(Hearing(146.88e6, 100.0))
assert [h.at for h in c.hearings] == [100.0, 300.0]
assert c.first_heard == 100.0 and c.last_heard == 300.0
# ---------------------------------------------------------------------------
# A scan that hears somebody
# ---------------------------------------------------------------------------
CALLOOK = {
"status": "VALID",
"type": "PERSON",
"current": {"callsign": "W1AW", "operClass": "CLUB"},
"name": "ARRL HQ OPERATORS CLUB",
"address": {"line1": "225 MAIN ST", "line2": "NEWINGTON, CT 06111"},
"location": {"latitude": "41.714775", "longitude": "-72.727260",
"gridsquare": "FN31pr"},
"otherInfo": {"expiryDate": "02/29/2032"},
}
@pytest.fixture
def fake_lookup(monkeypatch):
"""The licence database, answering from a fixture instead of the network."""
asked = []
def respond(self, call):
asked.append(call)
return dict(CALLOOK, current={"callsign": call, "operClass": "CLUB"})
monkeypatch.setattr(CallsignBook, "_request", respond)
return asked
@pytest.fixture
def heard_speech(monkeypatch):
"""A recogniser that hears one station identify itself."""
from bandsaunter import transcribe as tr
monkeypatch.setitem(
tr._DISPATCH, "fake",
lambda audio, rate, model, lang: tr.Transcript(
text="Net control, this is W1AW, standing by.", engine="fake"))
monkeypatch.setattr(tr, "ENGINES", ("fake",) + tr.ENGINES)
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
def _scan(tmp_path, **over):
from bandsaunter.config import ScanConfig
from bandsaunter.ranges import parse_range_list
from bandsaunter.scanner import Scanner, ScannerCallbacks
from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
output_dir=str(tmp_path), record_seconds=4.0,
hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05,
max_cycles=1, revisit_seconds=0.2, transcribe=True,
transcribe_engine="fake")
on_status = over.pop("_on_status", None)
for key, value in over.items():
setattr(cfg, key, value)
scanner = Scanner(cfg, device=SimulatedDevice(
transmitters=[V(146_520_000, "nfm", 0.4, 12_500, "v")]).open(),
callbacks=ScannerCallbacks(on_status=on_status) if on_status else None)
scanner.prepare()
scanner.run()
return scanner
def test_a_scan_maps_the_stations_it_hears(tmp_path, heard_speech,
fake_lookup):
scanner = _scan(tmp_path)
written = tmp_path / "callsigns.kml"
assert written.exists(), "the scan heard a callsign but wrote no map"
assert fake_lookup == ["W1AW"]
mark = next(_placemarks(written))
fields = _fields(mark)
assert fields["callsign"] == "W1AW"
assert fields["licensee"] == "ARRL HQ Operators Club"
assert fields["location"] == "Newington, CT"
# The frequency is the one the detector settled on, which is
# near the transmitter rather than exactly on it.
assert "146.5" in fields["heard"]
assert "2 m FM Simplex" in fields["heard"]
assert ".wav" in fields["heard"]
assert mark.find(f"{KML_NS}Point/{KML_NS}coordinates") is not None
assert scanner.heard.get("W1AW")
def test_a_scan_says_on_the_display_who_it_heard(tmp_path, heard_speech,
fake_lookup):
said = []
_scan(tmp_path, _on_status=said.append)
spoken = [m for m in said if "W1AW" in m]
assert spoken, f"the scan never announced the callsign: {said}"
assert "ARRL HQ Operators Club" in spoken[0]
def test_the_same_station_is_only_announced_once(tmp_path, heard_speech,
fake_lookup):
"""A repeater net would otherwise fill the status line with one name."""
said = []
_scan(tmp_path, _on_status=said.append, max_cycles=3,
revisit_seconds=0.0)
assert len([m for m in said if m.startswith("heard W1AW")]) == 1
def test_the_map_is_off_when_no_file_is_named(tmp_path, heard_speech,
fake_lookup):
_scan(tmp_path, kml_file="")
assert not list(tmp_path.glob("*.kml"))
def test_lookups_can_be_turned_off_entirely(tmp_path, heard_speech):
"""Nothing may reach the network with the lookup switched off."""
def refuse(self, call):
raise AssertionError("looked a callsign up with lookups off")
import bandsaunter.callsign as cs
original, cs.CallsignBook._request = cs.CallsignBook._request, refuse
try:
_scan(tmp_path, callsign_lookup=False)
finally:
cs.CallsignBook._request = original
# The station is still on the map, named by its prefix rather than a
# licence: that it was heard is worth recording either way.
written = tmp_path / "callsigns.kml"
assert written.exists()
assert _fields(next(_placemarks(written)))["callsign"] == "W1AW"
def test_a_second_scan_adds_to_the_same_map(tmp_path, heard_speech,
fake_lookup):
_scan(tmp_path)
first = len(_fields(next(_placemarks(tmp_path / "callsigns.kml")
))["heard"].splitlines())
_scan(tmp_path)
marks = list(_placemarks(tmp_path / "callsigns.kml"))
assert len(marks) == 1, "a second scan started a second pin"
assert len(_fields(marks[0])["heard"].splitlines()) > first