bandsaunter/bandsaunter/ranges.py
The Dust Council 44c98b11e3 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>
2026-08-21 23:44:24 -07:00

418 lines
15 KiB
Python
Executable file

"""Frequency ranges, parsing, and turning them into a sweep plan."""
from __future__ import annotations
import math
import re
from dataclasses import asdict, dataclass
from .bandplan import BandPreset, by_key, fmt_hz, presets_covering
__all__ = ["ScanRange", "TuneStep", "Lockout", "parse_frequency", "parse_range",
"parse_range_list", "parse_lockout_list", "build_plan", "fmt_hz",
"RangeError"]
class RangeError(ValueError):
"""Raised for un-parseable user frequency input."""
_SUFFIX = {
"": 1.0, "hz": 1.0,
"k": 1e3, "khz": 1e3,
"m": 1e6, "mhz": 1e6,
"g": 1e9, "ghz": 1e9,
}
_NUM = re.compile(r"^\s*([0-9]*\.?[0-9]+)\s*([a-zA-Z]*)\s*$")
def parse_frequency(text: str, default_unit: str = "") -> float:
"""Parse ``146.52M``, ``146520000``, ``433.92 MHz``, ``14074k`` -> Hz.
A bare number with no suffix is read as MHz when it is small enough to be
unambiguous (under 10000), otherwise as Hz -- which is how people
actually write frequencies.
"""
if text is None:
raise RangeError("empty frequency")
s = str(text).strip().replace(",", "").replace("_", "")
if not s:
raise RangeError("empty frequency")
m = _NUM.match(s)
if not m:
raise RangeError(f"cannot read {text!r} as a frequency")
value, suffix = float(m.group(1)), m.group(2).lower()
if not suffix:
suffix = default_unit.lower()
if not suffix:
# No unit anywhere: guess from magnitude.
return value * 1e6 if value < 10_000 else value
if suffix not in _SUFFIX:
raise RangeError(f"unknown frequency unit {m.group(2)!r} in {text!r}")
return value * _SUFFIX[suffix]
@dataclass
class ScanRange:
"""One start/stop pair the user wants swept."""
start: float
stop: float
step: float = 12_500.0
mode: str = "auto"
bandwidth: float = 0.0
label: str = ""
preset_key: str = ""
enabled: bool = True
threshold_db: float | None = None
def __post_init__(self):
self.start = float(self.start)
self.stop = float(self.stop)
if self.stop < self.start:
self.start, self.stop = self.stop, self.start
if self.stop == self.start:
# A single frequency: give it enough width to actually measure.
pad = max(self.step, 12_500.0) / 2.0
self.start -= pad
self.stop += pad
if self.step <= 0:
self.step = 12_500.0
if not self.label:
self.label = f"{fmt_hz(self.start)}-{fmt_hz(self.stop)}"
@property
def span(self) -> float:
return self.stop - self.start
@classmethod
def from_preset(cls, preset: BandPreset, **over) -> "ScanRange":
kw = dict(start=preset.start, stop=preset.stop, step=preset.step,
mode=preset.mode, bandwidth=preset.bandwidth,
label=preset.name, preset_key=preset.key)
kw.update(over)
return cls(**kw)
def _covering(self, hz: float, need_bandwidth: bool = False):
"""Band-plan segments to resolve against at one frequency.
A range that came from a band-plan preset resolves against segments of
the same service first. Without that, scanning the whole of 70 cm
would hand 433-435 MHz to the ISM preset that overlaps it there, and
an amateur repeater would be treated as an unlicensed device.
"""
covering = [p for p in presets_covering(hz)
if (p.bandwidth > 0 if need_bandwidth else p.mode != "auto")]
own = by_key(self.preset_key) if self.preset_key else None
if own is not None:
same = [p for p in covering if p.category == own.category]
if same:
return same
return covering
def resolved_mode(self, freq: float | None = None) -> str:
"""The demodulator to use, resolving ``auto`` against the band plan."""
if self.mode and self.mode != "auto":
return self.mode
hz = freq if freq is not None else 0.5 * (self.start + self.stop)
# Whole-band entries are skipped: they defer to the segments beneath
# them, so resolving to one would hand "auto" straight back.
covering = self._covering(hz)
if covering:
return min(covering, key=lambda p: p.span).mode
return "nfm"
def resolved_bandwidth(self, freq: float | None = None) -> float:
if self.bandwidth > 0:
return self.bandwidth
hz = freq if freq is not None else 0.5 * (self.start + self.stop)
covering = self._covering(hz, need_bandwidth=True)
if covering:
return min(covering, key=lambda p: p.span).bandwidth
return max(self.step, 12_500.0)
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, d: dict) -> "ScanRange":
known = {f for f in cls.__dataclass_fields__}
return cls(**{k: v for k, v in d.items() if k in known})
def describe(self) -> str:
state = "" if self.enabled else " (disabled)"
return (f"{self.label}: {fmt_hz(self.start)} - {fmt_hz(self.stop)} "
f"step {fmt_hz(self.step)} mode {self.mode}{state}")
_RANGE_SPLIT = re.compile(r"\s*(?:-{1,2}|\.{2}|to|:{2})\s*", re.I)
def parse_range(text: str) -> ScanRange:
"""Parse one range specification.
Accepted forms::
144M-148M explicit start and stop
144-148M unit carried over to both ends
146.52M a single frequency
144M-148M/25k with an explicit step
144M-148M/25k@nfm with a step and a demodulator
gmrs any band-plan preset key
"""
if text is None:
raise RangeError("empty range")
s = str(text).strip()
if not s:
raise RangeError("empty range")
mode = "auto"
if "@" in s:
s, _, mode = s.partition("@")
mode = mode.strip().lower() or "auto"
step = None
if "/" in s:
s, _, step_s = s.partition("/")
step = parse_frequency(step_s, default_unit="khz")
s = s.strip()
preset = by_key(s)
if preset is not None and preset.is_group:
raise RangeError(
f"{preset.key!r} covers {len(preset.expand())} separate ranges; "
"it can only be used where a list of ranges is accepted")
if preset is not None:
r = ScanRange.from_preset(preset)
if step:
r.step = step
if mode != "auto":
r.mode = mode
return r
parts = [p for p in _RANGE_SPLIT.split(s) if p.strip()]
if len(parts) == 1:
hz = parse_frequency(parts[0])
r = ScanRange(hz, hz, step or 12_500.0, mode)
elif len(parts) == 2:
# "144-148M": the unit on the right end applies to the left too.
right_unit = _NUM.match(parts[1])
unit = right_unit.group(2) if right_unit else ""
lo = parse_frequency(parts[0], default_unit=unit)
hi = parse_frequency(parts[1])
r = ScanRange(lo, hi, step or 12_500.0, mode)
else:
raise RangeError(f"cannot read {text!r} as a frequency range")
return r
def parse_range_list(text: str | list[str]) -> list[ScanRange]:
"""Parse a comma/newline separated list into ranges (unlimited count).
A band-plan key that stands for a set of others -- ``all-cw``, say --
expands here into one range per member.
"""
if isinstance(text, (list, tuple)):
items = list(text)
else:
items = re.split(r"[,\n;]+", str(text))
out = []
for item in items:
item = item.strip()
if not item:
continue
preset = by_key(item)
if preset is not None and preset.is_group:
out.extend(ScanRange.from_preset(m) for m in preset.expand())
else:
out.append(parse_range(item))
return out
# ---------------------------------------------------------------------------
# Sweep planning
# ---------------------------------------------------------------------------
@dataclass
class TuneStep:
"""One tuner position, plus the slice of its spectrum we trust."""
center: float
low: float # first frequency this step is responsible for
high: float # last frequency this step is responsible for
range_index: int
range_label: str = ""
mode: str = "auto"
index: int = 0 # position in the plan; keys the noise-floor memory
@property
def width(self) -> float:
return self.high - self.low
def describe(self) -> str:
return f"{fmt_hz(self.center)} covering {fmt_hz(self.low)}-{fmt_hz(self.high)}"
def build_plan(ranges: list[ScanRange], sample_rate: float,
usable_fraction: float = 0.75,
dc_guard: float = 8_000.0,
max_steps: int = 200_000,
tunable=None) -> list[TuneStep]:
"""Split every enabled range into tuner steps.
The tuner is deliberately *not* centred on the span it covers. An RTL2832
always shows a DC spike at whatever it is tuned to, so a centred step would
blank a hole in the middle of every range -- and for a range narrower than
one capture, that hole is the whole point of interest. Instead each step
parks the local oscillator ``dc_guard`` below the span it is responsible
for, so the covered frequencies sit entirely on one side of DC, clear of
both the spike and the anti-alias roll-off.
``tunable`` is an optional predicate; when the offset LO would fall outside
the hardware's range the step falls back to centring.
"""
if sample_rate <= 0 or usable_fraction <= 0:
raise RangeError("sample rate and usable fraction must be positive")
# Half the usable width, because only one side of DC is used per step.
usable = float(sample_rate) * float(usable_fraction) / 2.0
if usable <= dc_guard:
raise RangeError("usable bandwidth is too small for the DC guard band")
steps: list[TuneStep] = []
for i, r in enumerate(ranges):
if not r.enabled:
continue
n = max(1, int(math.ceil(r.span / usable)))
if len(steps) + n > max_steps:
raise RangeError(
f"plan would need over {max_steps} tuner steps; "
"narrow the ranges or raise the sample rate"
)
width = r.span / n
for k in range(n):
low = r.start + k * width
high = low + width
center = low - dc_guard
if tunable is not None and not tunable(center):
center = 0.5 * (low + high) # fall back rather than lose the step
steps.append(TuneStep(
center=center,
low=low, high=high,
range_index=i, range_label=r.label,
mode=r.mode, index=len(steps),
))
return steps
def plan_summary(ranges: list[ScanRange], steps: list[TuneStep],
dwell_seconds: float) -> str:
total = sum(r.span for r in ranges if r.enabled)
n_en = sum(1 for r in ranges if r.enabled)
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