Two things, both found by measuring rather than by reading the code. The voice-activity filter inside the recogniser is off. It was costing words: across a night of land-mobile captures it dropped 5-15% of what the same model finds without it -- 491 against 507, 339 against 384, 263 against 310 -- because a single-word over between two transmissions looks to a VAD exactly like the noise it exists to remove, and on a scanner those short replies are the ones worth having. Turning it off has a cost, and the cost is that Whisper hands back "You" for five seconds of hiss as confidently as it hands back a sentence. So the whole capture is now asked once whether anything in it rises above its own noise. Digital silence measures 0.0 dB of contrast and hiss at any level 0.7, while the quietest real capture of that night measures 8.9 and most measure 10-27; the bar sits at 3, an order of magnitude clear of both. It can veto a capture but never trim one, which is the whole difference between it and the filter it replaces. The second thing: callsigns like WQVF960 were being missed entirely. The shape being matched was the amateur one -- prefix, district digit, suffix -- and everything else the FCC licenses is written the other way round, the letters first and then the digits. On the GMRS and business channels that is most of what is said: nine callsigns across five transcripts of one evening went by unrecognised, and now do not. The shape is written as the three allocations that exist rather than as "letters then digits", which claims KN95, WD40 and KC135. Its letters are checked against the word list even when they arrive as a single token, which the amateur shape does not need -- no English word has a digit in the middle of it, but "west 120" and "word 100" fit this one exactly. Lookups now fall back to hamdb.org when callook has nothing. Not a spare copy: callook holds United States amateur licences only, so DL1ABC and VE3ABC are INVALID there and resolve perfectly well from the other. And a GMRS callsign is not looked up at all -- every database reachable without an account is an amateur register, so reporting WQVF960 as "unlisted" would blame the callsign for the absence of a source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
453 lines
17 KiB
Python
453 lines
17 KiB
Python
"""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, url, 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, url, 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, url, 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
|