"""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}"