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.
233 lines
8.4 KiB
Python
233 lines
8.4 KiB
Python
"""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}"
|