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:
The Dust Council 2026-08-21 23:44:24 -07:00
parent b69d0b7a26
commit 44c98b11e3
9 changed files with 319 additions and 46 deletions

View file

@ -8,8 +8,9 @@ from dataclasses import asdict, dataclass
from .bandplan import BandPreset, by_key, fmt_hz, presets_covering
__all__ = ["ScanRange", "TuneStep", "parse_frequency", "parse_range",
"parse_range_list", "build_plan", "fmt_hz", "RangeError"]
__all__ = ["ScanRange", "TuneStep", "Lockout", "parse_frequency", "parse_range",
"parse_range_list", "parse_lockout_list", "build_plan", "fmt_hz",
"RangeError"]
class RangeError(ValueError):
@ -313,3 +314,105 @@ def plan_summary(ranges: list[ScanRange], steps: list[TuneStep],
cycle = len(steps) * dwell_seconds
return (f"{n_en} range(s), {fmt_hz(total)} total span, {len(steps)} tuner "
f"steps, ~{cycle:.1f} s per full sweep")
# ---------------------------------------------------------------------------
# Lock-outs
# ---------------------------------------------------------------------------
@dataclass
class Lockout:
"""A frequency, or a span of them, the scan must never stop on.
A single frequency is stored with ``stop`` equal to ``start`` and is
widened by the lock-out width setting when it is applied, so changing that
width still moves it. A span is taken exactly as given -- a pager band or
a noisy stretch of spectrum is a definite width, not a point with a guess
around it.
"""
start: float
stop: float = 0.0
def __post_init__(self):
self.start = float(self.start)
self.stop = float(self.stop or self.start)
if self.stop < self.start:
self.start, self.stop = self.stop, self.start
@property
def is_span(self) -> bool:
return self.stop > self.start
def interval(self, width: float) -> tuple[float, float]:
"""The span this covers, given the width a point lock-out gets."""
if self.is_span:
return self.start, self.stop
half = max(1.0, float(width)) / 2.0
return self.start - half, self.start + half
def describe(self) -> str:
if self.is_span:
return f"{fmt_hz(self.start)}-{fmt_hz(self.stop)}"
return fmt_hz(self.start)
def to_dict(self) -> dict:
return {"start": self.start, "stop": self.stop}
@classmethod
def coerce(cls, value) -> "Lockout":
"""Accept anything a saved file or a caller might reasonably hold.
Older settings files stored a bare number per lock-out, and callers
pass plain frequencies, so both still mean what they always did.
"""
if isinstance(value, cls):
return value
if isinstance(value, dict):
return cls(value.get("start", 0.0), value.get("stop", 0.0))
if isinstance(value, (list, tuple)):
if len(value) == 1:
return cls(float(value[0]))
if len(value) == 2:
return cls(float(value[0]), float(value[1]))
raise RangeError(f"cannot read {value!r} as a lock-out")
if isinstance(value, str):
return parse_lockout(value)
return cls(float(value))
def parse_lockout(text: str) -> Lockout:
"""One lock-out: ``162.55M`` or ``162.4M-162.6M``."""
if text is None:
raise RangeError("empty lock-out")
s = str(text).strip()
if not s:
raise RangeError("empty lock-out")
parts = [p for p in _RANGE_SPLIT.split(s) if p.strip()]
if len(parts) == 1:
return Lockout(parse_frequency(parts[0]))
if len(parts) == 2:
# "162.4-162.6M": the unit on the right end applies to the left too.
right = _NUM.match(parts[1])
unit = right.group(2) if right else ""
return Lockout(parse_frequency(parts[0], default_unit=unit),
parse_frequency(parts[1]))
raise RangeError(f"cannot read {text!r} as a lock-out")
def parse_lockout_list(text) -> list[Lockout]:
"""A comma or semicolon separated list of frequencies and spans."""
if text is None:
return []
if isinstance(text, (list, tuple)):
items = text
else:
items = re.split(r"[,;\n]", str(text))
out = []
for item in items:
if isinstance(item, str):
item = item.strip()
if not item or item.lower() in ("(none)", "none", "-"):
continue
out.append(Lockout.coerce(item))
return out