Remember runtime lock-outs, and let a lock-out be a span
Pressing `l` during a scan locked a frequency out for that run only, so the same birdie had to be locked out again on every later one. It now writes back to the settings file the run started from -- only that one key, since a scan's config also holds whatever was passed on the command line for this run and saving all of it would quietly make those permanent. The file is read, its lock-outs replaced, the rest left as it was. `save_lockouts` turns it off for anyone who would rather their config were never touched. Lock-outs were also single frequencies only. They are now a list of frequencies and spans -- "162.55M, 450M-455M, 88M to 108M" -- which is what a pager band or a noisy stretch of spectrum actually is. A point is still widened by the lock-out width; a span is taken exactly as written, because whoever typed it already said how wide it is. The scanner matched lock-outs by rounding a frequency into a bucket of the lock-out width, which cannot express a span and was never exact at the edges. It now holds intervals and tests them directly. Settings files that predate this hold a bare number per lock-out, and still mean the same thing: Lockout.coerce takes numbers, strings, pairs and dicts, so old profiles load untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b69d0b7a26
commit
44c98b11e3
9 changed files with 319 additions and 46 deletions
|
|
@ -516,3 +516,54 @@ def test_combined_files_are_padded_too(tmp_path):
|
|||
log.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
|
||||
names = sorted(p.name for p in tmp_path.glob("*.wav"))
|
||||
assert names == ["0146.520000MHz.wav", "1090.000000MHz.wav"]
|
||||
|
||||
|
||||
def test_a_span_of_spectrum_can_be_locked_out(tmp_path):
|
||||
"""Not just single frequencies: a whole noisy stretch, as one entry."""
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice"),
|
||||
V(147_000_000, "nfm", 0.4, 12_500, "voice")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-147.1M",
|
||||
lockout=["146.4M-146.8M"], max_cycles=3)
|
||||
assert hits, "nothing recorded at all"
|
||||
assert all(h.frequency > 146_800_000 for h in hits), \
|
||||
[h.frequency for h in hits]
|
||||
|
||||
|
||||
def test_a_lock_out_made_during_a_scan_is_remembered(tmp_path):
|
||||
"""It goes back to the settings file, so the next run starts with it."""
|
||||
import yaml
|
||||
|
||||
from bandsaunter.config import load_default, save_default
|
||||
|
||||
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||
output_dir=str(tmp_path), record_seconds=4.0)
|
||||
save_default(cfg, tmp_path)
|
||||
loaded, path = load_default(tmp_path)
|
||||
|
||||
s = Scanner(loaded, device=SimulatedDevice().open())
|
||||
s.prepare()
|
||||
s.lockout(146_520_000.0)
|
||||
|
||||
saved = yaml.safe_load(path.read_text())
|
||||
assert saved["lockout"] == [{"start": 146_520_000.0, "stop": 146_520_000.0}]
|
||||
# Only that one key: the rest of the file is as it was.
|
||||
assert saved["record_seconds"] == 4.0
|
||||
again, _ = load_default(tmp_path)
|
||||
assert [lk.start for lk in again.lockout] == [146_520_000.0]
|
||||
|
||||
|
||||
def test_lock_outs_can_be_kept_to_the_current_run(tmp_path):
|
||||
from bandsaunter.config import load_default, save_default
|
||||
|
||||
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||
output_dir=str(tmp_path), save_lockouts=False)
|
||||
save_default(cfg, tmp_path)
|
||||
loaded, path = load_default(tmp_path)
|
||||
|
||||
s = Scanner(loaded, device=SimulatedDevice().open())
|
||||
s.prepare()
|
||||
s.lockout(146_520_000.0)
|
||||
|
||||
assert s._is_locked(146_520_000.0), "still locked out for this run"
|
||||
again, _ = load_default(tmp_path)
|
||||
assert not again.lockout, "but not written back"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
from bandsaunter import settings as st
|
||||
from bandsaunter.cli import build_parser
|
||||
from bandsaunter.config import ScanConfig
|
||||
from bandsaunter.ranges import Lockout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -65,7 +66,7 @@ def test_defaults_round_trip_through_parse_and_format():
|
|||
if s.key in ("record_seconds", "max_record_seconds",
|
||||
"max_runtime_seconds", "max_cycles") and not value:
|
||||
continue # shown as "no limit", typed back as 0
|
||||
if s.kind == "freq_list" and not value:
|
||||
if s.kind == "lockout_list" and not value:
|
||||
continue # shown as "(none)"
|
||||
back = st.parse_value(s, shown)
|
||||
assert back == value, f"{s.key}: {value!r} -> {shown!r} -> {back!r}"
|
||||
|
|
@ -83,7 +84,10 @@ def test_defaults_round_trip_through_parse_and_format():
|
|||
("save_iq", "n", False),
|
||||
("accept", "voice, cw", ["voice", "cw"]),
|
||||
("accept", "voice cw digital", ["voice", "cw", "digital"]),
|
||||
("lockout", "162.55M, 146.52M", [162_550_000.0, 146_520_000.0]),
|
||||
("lockout", "162.55M, 146.52M", [Lockout(162_550_000.0),
|
||||
Lockout(146_520_000.0)]),
|
||||
("lockout", "162.55M, 450M-455M", [Lockout(162_550_000.0),
|
||||
Lockout(450_000_000.0, 455_000_000.0)]),
|
||||
("detector_bias_db", "", None),
|
||||
("detector_bias_db", "6", 6.0),
|
||||
("sample_rate", "2048000", 2_048_000),
|
||||
|
|
@ -154,7 +158,7 @@ def test_repeated_lockout_flags_accumulate():
|
|||
"--lockout", "146.52M"])
|
||||
cfg = ScanConfig()
|
||||
st.apply_args(cfg, args)
|
||||
assert cfg.lockout == [162_550_000.0, 146_520_000.0]
|
||||
assert cfg.lockout == [Lockout(162_550_000.0), Lockout(146_520_000.0)]
|
||||
|
||||
|
||||
def test_help_text_renders_for_every_entry():
|
||||
|
|
@ -183,7 +187,7 @@ def test_saved_settings_round_trip(tmp_path):
|
|||
cfg.hang_seconds = 6.0
|
||||
cfg.record_seconds = 0.0
|
||||
cfg.accept = ["voice", "cw"]
|
||||
cfg.lockout = [162_550_000.0]
|
||||
cfg.lockout = [Lockout(162_550_000.0)]
|
||||
cfg.gain = 28.0
|
||||
save_default(cfg, tmp_path)
|
||||
|
||||
|
|
@ -192,7 +196,7 @@ def test_saved_settings_round_trip(tmp_path):
|
|||
assert back.hang_seconds == 6.0
|
||||
assert back.record_seconds == 0.0
|
||||
assert back.accept == ["voice", "cw"]
|
||||
assert back.lockout == [162_550_000.0]
|
||||
assert back.lockout == [Lockout(162_550_000.0)]
|
||||
assert back.gain == 28.0
|
||||
assert [r.label for r in back.ranges] == [r.label for r in cfg.ranges]
|
||||
|
||||
|
|
@ -210,8 +214,8 @@ def test_every_setting_survives_a_save_and_load(tmp_path):
|
|||
value = [c for c in s.choices if c != current][0]
|
||||
elif s.kind == "accept_list":
|
||||
value = ["cw"]
|
||||
elif s.kind == "freq_list":
|
||||
value = [146_520_000.0]
|
||||
elif s.kind == "lockout_list":
|
||||
value = [Lockout(146_520_000.0)]
|
||||
elif s.kind in ("text", "path"):
|
||||
value = "somewhere"
|
||||
elif s.kind == "gain":
|
||||
|
|
@ -317,8 +321,8 @@ def a_different_value(s, current):
|
|||
return 2
|
||||
if s.kind == "path":
|
||||
return "/tmp/somewhere-else"
|
||||
if s.kind == "freq_list":
|
||||
return [162_550_000.0, 146_520_000.0]
|
||||
if s.kind == "lockout_list":
|
||||
return [Lockout(162_550_000.0), Lockout(450_000_000.0, 455_000_000.0)]
|
||||
if s.kind == "text":
|
||||
return f"probe-{s.key}"
|
||||
raise AssertionError(f"no test value for kind {s.kind!r} ({s.key})")
|
||||
|
|
@ -415,3 +419,44 @@ def test_no_setting_is_saved_but_never_read():
|
|||
unread = [s.key for s in st.SETTINGS
|
||||
if not re.search(rf"\bcfg\.{s.key}\b|\bconfig\.{s.key}\b", src)]
|
||||
assert not unread, f"settings nothing ever reads: {unread}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lock-outs: spans, and remembering them
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("162.55M", [(162_550_000.0, 162_550_000.0)]),
|
||||
("162.55M, 146.52M", [(162_550_000.0, 162_550_000.0),
|
||||
(146_520_000.0, 146_520_000.0)]),
|
||||
("450M-455M", [(450_000_000.0, 455_000_000.0)]),
|
||||
("450-455M", [(450_000_000.0, 455_000_000.0)]), # unit carries over
|
||||
("88M to 108M", [(88_000_000.0, 108_000_000.0)]),
|
||||
("450M..455M", [(450_000_000.0, 455_000_000.0)]),
|
||||
("162.55M, 450M-455M; 88M-108M", [(162_550_000.0, 162_550_000.0),
|
||||
(450_000_000.0, 455_000_000.0),
|
||||
(88_000_000.0, 108_000_000.0)]),
|
||||
("455M-450M", [(450_000_000.0, 455_000_000.0)]), # written backwards
|
||||
])
|
||||
def test_lockouts_accept_lists_and_spans(text, expected):
|
||||
got = st.parse_value(st.by_key("lockout"), text)
|
||||
assert [(lk.start, lk.stop) for lk in got] == expected
|
||||
|
||||
|
||||
def test_a_span_is_taken_as_written_and_a_point_is_widened():
|
||||
point, span = st.parse_value(st.by_key("lockout"), "146.52M, 450M-455M")
|
||||
assert point.interval(12_500) == (146_513_750.0, 146_526_250.0)
|
||||
assert span.interval(12_500) == (450_000_000.0, 455_000_000.0)
|
||||
|
||||
|
||||
def test_older_settings_files_keep_their_bare_numbers(tmp_path):
|
||||
"""Lock-outs used to be plain frequencies, and still mean the same thing."""
|
||||
import yaml
|
||||
|
||||
from bandsaunter.config import load_config
|
||||
|
||||
(tmp_path / "old.yaml").write_text(
|
||||
yaml.safe_dump({"lockout": [162_550_000.0, 146_520_000.0]}))
|
||||
cfg = load_config("old", tmp_path)
|
||||
assert [lk.start for lk in cfg.lockout] == [162_550_000.0, 146_520_000.0]
|
||||
assert not any(lk.is_span for lk in cfg.lockout)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue