diff --git a/README.md b/README.md index 1e7d56a..de8a072 100644 --- a/README.md +++ b/README.md @@ -823,9 +823,31 @@ since that is the command to run when something is wrong, and | `q` | stop | | `p` | pause / resume | | `s` | skip this signal, resume sweeping | -| `l` | lock out this frequency for the rest of the run | +| `l` | lock out this frequency — for this run and every later one | | `+` / `-` | adjust the squelch threshold | +### Lock-outs + +A pager transmitter down the road, or a birdie the receiver makes itself, is +worth shutting out permanently. Pressing `l` writes the frequency back to the +settings file the run started from, so it is still locked out tomorrow. Only +that one setting is written back — options passed on the command line for a +single run stay one-off — and `--no-save-lockouts` keeps a lock-out to the +current run. + +Lock-outs can also be given directly, several at a time, as single frequencies +or as spans: + +```bash +bandsaunter scan -r 144M-148M --lockout "162.55M, 450M-455M" +bandsaunter config lockout="88M-108M, 146.52M" +``` + +A single frequency is widened by `--lockout-width` (12.5 kHz by default); a +span is taken exactly as written, since a noisy stretch of spectrum has a +definite width rather than a point with a guess around it. Ranges accept the +same forms as everywhere else — `450M-455M`, `450-455M`, `88M to 108M`. + ## Built-in help Press `h` in the menus for topics covering setup, how the sweep works, why diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index 96981af..92673fa 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -397,8 +397,8 @@ def _handle_key(key: str, scanner: Scanner, display: ScanDisplay) -> None: elif k == "l": freq = display._rec.frequency or scanner.stats.current_freq if freq: + # The scanner says so itself, and says whether it was remembered. scanner.lockout(freq) - display.on_status(f"locked out {fmt_hz(freq)}") scanner.skip() elif k in ("+", "="): scanner.cfg.threshold_db += 1.0 diff --git a/bandsaunter/config.py b/bandsaunter/config.py index 0175937..867f7cd 100755 --- a/bandsaunter/config.py +++ b/bandsaunter/config.py @@ -8,12 +8,12 @@ from pathlib import Path import yaml -from .ranges import ScanRange, parse_range_list +from .ranges import Lockout, ScanRange, parse_range_list __all__ = ["ScanConfig", "DEFAULT_CONFIG_DIR", "DEFAULT_CONFIG_PATH", "load_config", "save_config", "list_profiles", "profile_path", "load_default", "save_default", "delete_profile", - "is_first_run", "DEFAULT_OUTPUT_DIR"] + "is_first_run", "DEFAULT_OUTPUT_DIR", "remember_lockouts"] DEFAULT_CONFIG_DIR = Path( os.environ.get("BANDSAUNTER_CONFIG_DIR", @@ -120,8 +120,9 @@ class ScanConfig: transcribe_min_seconds: float = 1.0 # -- behaviour ------------------------------------------------------ - lockout: list[float] = field(default_factory=list) + lockout: list[Lockout] = field(default_factory=list) lockout_width: float = 12_500.0 + save_lockouts: bool = True # write runtime lock-outs back to disk revisit_seconds: float = 8.0 # ignore a frequency again this soon max_cycles: int = 0 # 0 = run forever max_runtime_seconds: float = 0.0 # 0 = no limit @@ -130,6 +131,12 @@ class ScanConfig: log_file: str = "scan_log.jsonl" # ------------------------------------------------------------------ + def __post_init__(self): + # Lock-outs arrive as bare numbers from older settings files, as + # dicts from newer ones, and as either from callers. One type from + # here on. + self.lockout = [Lockout.coerce(v) for v in self.lockout] + def validate(self) -> list[str]: """Return a list of human-readable problems (empty when config is sane).""" errs = [] @@ -270,6 +277,37 @@ def save_default(cfg: "ScanConfig", directory: Path | None = None) -> Path: return path +def remember_lockouts(cfg: "ScanConfig", + directory: Path | None = None) -> Path | None: + """Write the lock-out list back to the settings file it came from. + + Only that one key: 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 + one-off options permanent. So the file on disk is read, its lock-outs + replaced, and the rest left exactly as it was. + + Returns where it was written, or None if it could not be -- locking out a + birdie must never be the thing that ends a scan. + """ + path = getattr(cfg, "_source_path", None) + path = Path(path) if path else (Path(directory or DEFAULT_CONFIG_DIR) + / "config.yaml") + try: + data = {} + if path.exists(): + with open(path) as fh: + data = yaml.safe_load(fh) or {} + data["lockout"] = [lk.to_dict() for lk in cfg.lockout] + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w") as fh: + yaml.safe_dump(data, fh, sort_keys=False, default_flow_style=False) + tmp.replace(path) + return path + except (OSError, yaml.YAMLError): + return None + + def delete_profile(name: str, directory: Path | None = None) -> bool: path = profile_path(name, directory) if path.exists(): diff --git a/bandsaunter/ranges.py b/bandsaunter/ranges.py index 9909c7c..ae70b27 100755 --- a/bandsaunter/ranges.py +++ b/bandsaunter/ranges.py @@ -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 diff --git a/bandsaunter/scanner.py b/bandsaunter/scanner.py index 032353a..293936c 100755 --- a/bandsaunter/scanner.py +++ b/bandsaunter/scanner.py @@ -22,12 +22,12 @@ import numpy as np from . import dsp from .bandplan import fmt_hz, presets_covering from .classify import classify, ssb_alignment -from .config import ScanConfig +from .config import ScanConfig, remember_lockouts from .demod import make_demodulator from .device import RtlSdrDevice, RtlSdrError from .morse import decode_morse from .quality import Assessment, assess -from .ranges import TuneStep, build_plan +from .ranges import Lockout, TuneStep, build_plan from .recorder import FrequencyLog, HitRecord, Recording, ScanLog, read_wav from .transcribe import TranscriptionWorker, available_engine @@ -99,7 +99,7 @@ class Scanner: self.plan: list[TuneStep] = [] self._floors: dict[int, dsp.NoiseFloorTracker] = {} self._recent: dict[int, float] = {} # frequency bucket -> last visit - self._lockouts: set[int] = set() + self._lockouts: list[tuple[float, float]] = [] # spans, not buckets self._stop = threading.Event() self._pause = threading.Event() @@ -131,15 +131,22 @@ class Scanner: self._skip.set() def lockout(self, freq_hz: float) -> None: + """Never stop here again -- for this run, and for later ones.""" + entry = Lockout(float(freq_hz)) with self._lock: - self._lockouts.add(self._key(freq_hz)) - self.cfg.lockout.append(float(freq_hz)) + self._lockouts.append(entry.interval(self.cfg.lockout_width)) + self.cfg.lockout.append(entry) + if self.cfg.save_lockouts: + path = remember_lockouts(self.cfg) + self._status(f"locked out {entry.describe()}" + + (f", remembered in {path.name}" if path else + " (could not be saved)")) def _key(self, freq_hz: float) -> int: return int(round(freq_hz / self._bucket)) def _is_locked(self, freq_hz: float) -> bool: - return self._key(freq_hz) in self._lockouts + return any(lo <= freq_hz <= hi for lo, hi in self._lockouts) # -- setup ------------------------------------------------------------- def prepare(self) -> None: @@ -192,8 +199,10 @@ class Scanner: else dsp.detector_bias_db( n_seg, self.nfft, "max" if self.cfg.detector == "peak" else "mean")) - for f in self.cfg.lockout: - self._lockouts.add(self._key(f)) + # A single frequency is widened by the lock-out width; a span was + # given a width by whoever wrote it and is taken as it stands. + self._lockouts = [Lockout.coerce(f).interval(self.cfg.lockout_width) + for f in self.cfg.lockout] root = Path(self.cfg.output_dir).expanduser() root.mkdir(parents=True, exist_ok=True) diff --git a/bandsaunter/settings.py b/bandsaunter/settings.py index 6e2397c..fe3cbf2 100644 --- a/bandsaunter/settings.py +++ b/bandsaunter/settings.py @@ -376,13 +376,16 @@ SETTINGS: tuple[Setting, ...] = ( S("max_runtime_seconds", "Stop after time", "Run control", "float", "stop after this long (0 = run until stopped)", "", unit="s", minimum=0.0, flags=("--duration",), metavar="SEC"), - S("lockout", "Locked-out frequencies", "Run control", "freq_list", + S("lockout", "Locked-out frequencies", "Run control", "lockout_list", "never stop on these frequencies", "Useful for a local pager transmitter or a birdie the receiver makes " - "itself. Frequencies may be written 162.55M, 162550000 or 162.55 MHz. " - "The lock-out key during a scan adds the current frequency here for " - "the rest of the run.", - flags=("--lockout",), metavar="FREQ"), + "itself. Separate several with commas, and give a span as a pair: " + "`162.55M, 450M-455M, 88 MHz to 108 MHz`. A single frequency is " + "widened by the lock-out width below; a span is taken exactly as " + "written. The lock-out key during a scan adds the current frequency " + "here, and it is remembered for later runs.", + flags=("--lockout",), metavar="FREQ|RANGE", + example="162.55M, 450M-455M"), S("lockout_width", "Lock-out width", "Run control", "float", "how wide a locked-out frequency is", "A signal within half this distance of a locked-out frequency is " @@ -391,6 +394,14 @@ SETTINGS: tuple[Setting, ...] = ( S("quiet", "Quiet output", "Run control", "bool", "print errors only", "", flags=("--quiet",), off_flags=("--no-quiet",)), + S("save_lockouts", "Remember lock-outs", "Run control", "bool", + "keep frequencies locked out during a scan", + "The lock-out key writes the frequency back to the settings file it " + "came from, so a birdie stays locked out on every later run instead of " + "having to be locked out again. Only that one setting is written back: " + "options passed on the command line for a single run stay one-off. " + "Turn this off to keep lock-outs for the current run only.", + flags=("--save-lockouts",), off_flags=("--no-save-lockouts",)), S("plain", "Plain display", "Run control", "bool", "print one line per hit instead of the live display", "The live display redraws a spectrum and a table in place, which wants " @@ -539,20 +550,14 @@ def parse_value(setting: Setting, text): f"unknown: {', '.join(bad)} (choose from " f"{', '.join(setting.choices)})") return list(dict.fromkeys(items)) - elif kind == "freq_list": - from .ranges import RangeError, parse_frequency + elif kind == "lockout_list": + from .ranges import RangeError, parse_lockout_list if not raw or raw.lower() in ("(none)", "none", "-"): return [] - out = [] - for part in raw.replace(";", ",").split(","): - part = part.strip() - if not part: - continue - try: - out.append(parse_frequency(part)) - except RangeError as exc: - raise SettingError(str(exc)) from None - return out + try: + return parse_lockout_list(raw) + except RangeError as exc: + raise SettingError(str(exc)) from None else: raise SettingError(f"unsupported setting kind {kind!r}") except SettingError: @@ -578,8 +583,8 @@ def format_value(setting: Setting, value) -> str: return "automatic" if value is None else f"{value:g} {setting.unit}".strip() if kind == "accept_list": return ", ".join(value) if value else "(nothing)" - if kind == "freq_list": - return ", ".join(fmt_hz(v) for v in value) if value else "(none)" + if kind == "lockout_list": + return ", ".join(lk.describe() for lk in value) if value else "(none)" if kind == "gain": return "auto" if isinstance(value, str) else f"{float(value):g} dB" if kind == "direct": @@ -631,7 +636,7 @@ def add_arguments(parser) -> None: section.add_argument(*s.off_flags, dest=s.dest, action="store_false", default=None, help=f"do not {s.help}") - elif s.kind == "freq_list": + elif s.kind == "lockout_list": section.add_argument(*s.flags, dest=s.dest, action="append", default=None, metavar=s.metavar or "VALUE", @@ -653,7 +658,7 @@ def apply_args(cfg, args) -> list[str]: continue if s.kind == "bool": value = bool(raw) - elif s.kind == "freq_list": + elif s.kind == "lockout_list": value = [] for item in (raw if isinstance(raw, list) else [raw]): value.extend(parse_value(s, item)) diff --git a/bandsaunter/tui.py b/bandsaunter/tui.py index b1e8728..9f69019 100644 --- a/bandsaunter/tui.py +++ b/bandsaunter/tui.py @@ -310,7 +310,7 @@ def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool while True: raw = _ask(console, f" [bold]{setting.label}[/bold] [grey62]({hint})" f"[/grey62]", st.format_value(setting, current) - if setting.kind not in ("freq_list", "accept_list") + if setting.kind not in ("lockout_list", "accept_list") else "") if raw.strip() == "" or raw == st.format_value(setting, current): return False diff --git a/tests/test_scanner.py b/tests/test_scanner.py index fe97d6b..941aa55 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -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" diff --git a/tests/test_settings.py b/tests/test_settings.py index d7ed702..e074937 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -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)