"""Calibrate the Kamil ADC reference-phase frequency law from live sweeps. Captures many raw sweeps from the collector and reports — and optionally writes back — the calibration the runtime uses: * ``phase0_rad`` / ``phase1_rad`` — the unwrapped reference phase at the sweep start and stop, taken as the MEDIAN over all captured sweeps. ``freq0_hz`` / ``freq1_hz`` (the known sweep endpoints) come from config and are kept as-is. * ``band.points`` — recommended as the median number of usable points landing inside ``[band.start_hz, band.stop_hz]``, so the fixed output grid matches the native density rather than inflating it. It also reports how reliably the configured band is covered (sweeps that do not span it are rejected at runtime) and how many points the crop discards. The collector is launched with the ``do8_freq_ref`` arguments regardless of what the on-disk config says, so the reference channel is always present. With ``--apply`` the config is migrated to that collector/args and the calibrated anchors + recommended point count are written back. Run on the Pi, e.g.:: .venv/bin/python -m python_app.scripts.kamil_adc_calibrate \ --config run_config_kamil_adc.pi.json --sweeps 200 --apply """ from __future__ import annotations import argparse from contextlib import suppress import json import logging from pathlib import Path import shutil import statistics import subprocess import time import numpy as np from python_app.hardware_full.kamil_adc import KamilAdcService, apply_kamil_adc_laser_control from python_app.hardware_full.kamil_adc.processing import ( KamilAdcProcessingParams, KamilAdcSweepProcessor, ) from python_app.models.run_config_model import RunConfigModel logger = logging.getLogger("kamil_adc_calibrate") # The collector arguments that enable the DI8 reference overlay (mirrors the # kamil example config / run_do8_freq_ref.sh). Forced on so calibration always # sees the reference channel even if the on-disk config predates it. DO8_FREQ_REF_ARGS = [ "profile:phase", "clock:internal", "internal_ref_hz:2000000", "start:di_syn2_rise", "stop:di_syn2_fall", "sample_clock_hz:max", "range:2", "duration_ms:100", "packet_limit:0", "do1_toggle_per_frame", "do1_pair_subtract_avg", "do8_freq_ref", "do8_cycle_period:8", ] COLLECTOR_PATH = "build/bin/kamil_adc_collector" # Hardware the daemon / a prior collector may be holding, and how to free it. # Mirrors what start.sh does before an interactive launch (stop the daemon, kill # an orphaned collector) so this tool can grab the L-Card E-502 + lasers too. RADAR_SERVICE_NAME = "radar.service" COLLECTOR_PROCESS_PATTERN = "kamil_adc_collector" # The E-502 is not reacquirable the instant it is released; wait before opening. _DEVICE_SETTLE_SECONDS = 8.0 def _release_radar_hardware(*, settle_seconds: float = _DEVICE_SETTLE_SECONDS) -> None: """Free the L-Card E-502 + lasers before we open our own collector. Mirrors ``start.sh`` on an interactive launch: stop the headless ``radar.service`` daemon (if active) so it releases the hardware, then kill any orphaned ``kamil_adc_collector`` a prior (e.g. SSH-killed) run left holding the device. Both steps are best-effort and non-fatal — the collector's own open-retry covers the residual settle time — this just removes the usual reason it never frees up. Passwordless ``sudo systemctl stop radar.service`` is provisioned in ``/etc/sudoers.d/radar``. """ freed = False systemctl = shutil.which("systemctl") if systemctl is not None: is_active = subprocess.run( [systemctl, "is-active", "--quiet", RADAR_SERVICE_NAME], check=False, ).returncode == 0 if is_active: logger.info("Stopping %s so it releases the radar hardware...", RADAR_SERVICE_NAME) stopped = subprocess.run( ["sudo", systemctl, "stop", RADAR_SERVICE_NAME], check=False ).returncode == 0 if stopped: freed = True else: logger.warning( "Could not stop %s (need passwordless sudo?); continuing anyway", RADAR_SERVICE_NAME, ) pkill = shutil.which("pkill") if pkill is not None: # pkill returns 0 when it matched & signalled at least one process. if subprocess.run( [pkill, "-9", "-f", COLLECTOR_PROCESS_PATTERN], check=False ).returncode == 0: logger.info("Killed orphaned %s process(es) holding the device", COLLECTOR_PROCESS_PATTERN) freed = True if freed: logger.info("Waiting %.0fs for the L-Card E-502 to settle...", settle_seconds) time.sleep(settle_seconds) def _open_with_retry(service: KamilAdcService, *, attempts: int = 4, delay_s: float = 8.0) -> None: """Open the collector, retrying the transient E-502 device-busy after a close. The L-Card device is not always reacquirable immediately after a previous collector released it; a short backoff lets it settle before the next try. """ for attempt in range(1, attempts + 1): try: service.open() return except Exception as exc: # noqa: BLE001 — device-busy is expected and retried logger.warning("Collector open failed (attempt %d/%d): %s", attempt, attempts, exc) with suppress(Exception): service.close() if attempt < attempts: time.sleep(delay_s) raise RuntimeError(f"Collector did not open after {attempts} attempts") def _capture_reference_phases(service: KamilAdcService, *, warmup: int, sweeps: int) -> list[np.ndarray]: """Capture `sweeps` reference-phase arrays after discarding `warmup` sweeps.""" for index in range(warmup): service.read_raw_sweep() if index == 0: logger.info("Warming up (%d sweeps) while the sweep settles...", warmup) phases: list[np.ndarray] = [] for index in range(sweeps): raw = service.read_raw_sweep() if raw.reference.size >= 2: phases.append(np.unwrap(np.angle(raw.reference.astype(np.complex128)))) if (index + 1) % 50 == 0: logger.info("Captured %d/%d sweeps", index + 1, sweeps) return phases def _summarize(values: np.ndarray) -> str: """Compact min / median / max summary for a 1-D array.""" return f"min={np.min(values):.6g} median={np.median(values):.6g} max={np.max(values):.6g}" def _analyze(phases: list[np.ndarray], config: RunConfigModel) -> dict: """Derive the calibration and band diagnostics from captured phase arrays.""" kamil = config.radar.kamil_adc freq0, freq1 = kamil.phase_calibration.freq0_hz, kamil.phase_calibration.freq1_hz band_start, band_stop = kamil.band.start_hz, kamil.band.stop_hz phase0 = float(statistics.median(float(phase[0]) for phase in phases)) phase1 = float(statistics.median(float(phase[-1]) for phase in phases)) if phase1 == phase0: raise RuntimeError("Degenerate calibration: median start and stop phases are equal") slope = (freq1 - freq0) / (phase1 - phase0) total_points = np.array([phase.size for phase in phases], dtype=np.float64) f_starts, f_stops, in_band_counts, covers = [], [], [], [] for phase in phases: freqs = freq0 + (phase - phase0) * slope lo, hi = float(np.min(freqs)), float(np.max(freqs)) f_starts.append(lo) f_stops.append(hi) in_band_counts.append(int(np.count_nonzero((freqs >= band_start) & (freqs <= band_stop)))) covers.append(lo <= band_start and hi >= band_stop) in_band = np.array(in_band_counts, dtype=np.float64) f_start = np.array(f_starts) f_stop = np.array(f_stops) recommended_points = int(round(float(np.median(in_band)))) # A band that ~95% of sweeps satisfy on each edge: start at the 95th percentile # of per-sweep start frequencies, stop at the 5th percentile of stop frequencies. rec_band_start = float(np.quantile(f_start, 0.95)) rec_band_stop = float(np.quantile(f_stop, 0.05)) rec_coverage = float(np.mean((f_start <= rec_band_start) & (f_stop >= rec_band_stop))) return { "sweeps": len(phases), "phase0_rad": phase0, "phase1_rad": phase1, "phase_span_rad": phase1 - phase0, "phase0_mad_rad": float(np.median(np.abs([float(p[0]) - phase0 for p in phases]))), "phase1_mad_rad": float(np.median(np.abs([float(p[-1]) - phase1 for p in phases]))), "freq0_hz": freq0, "freq1_hz": freq1, "band_start_hz": band_start, "band_stop_hz": band_stop, "total_points_median": float(np.median(total_points)), "in_band_points_median": float(np.median(in_band)), "recommended_points": recommended_points, "cropped_fraction": 1.0 - float(np.median(in_band)) / float(np.median(total_points)), "coverage_fraction": float(np.mean(covers)), "lower_ok_fraction": float(np.mean(f_start <= band_start)), "upper_ok_fraction": float(np.mean(f_stop >= band_stop)), "rec_band_start_hz": rec_band_start, "rec_band_stop_hz": rec_band_stop, "rec_coverage_fraction": rec_coverage, "f_start": f_start, "f_stop": f_stop, } def _report(result: dict) -> None: """Print a human-readable calibration report.""" print("\n" + "=" * 72) print(f"Kamil ADC calibration over {result['sweeps']} sweeps") print("=" * 72) print( f"phase0_rad = {result['phase0_rad']:.6f} (median start phase, MAD " f"{result['phase0_mad_rad']:.4f}) -> {result['freq0_hz'] / 1e9:.4f} GHz" ) print( f"phase1_rad = {result['phase1_rad']:.6f} (median stop phase, MAD " f"{result['phase1_mad_rad']:.4f}) -> {result['freq1_hz'] / 1e9:.4f} GHz" ) print(f"phase span = {result['phase_span_rad']:.4f} rad") print( f"points/sweep: total median={result['total_points_median']:.0f}, " f"in-band median={result['in_band_points_median']:.0f}" ) print(f"recommended band.points = {result['recommended_points']}") print( f"band [{result['band_start_hz'] / 1e9:.3f}, {result['band_stop_hz'] / 1e9:.3f}] GHz: " f"covered by {result['coverage_fraction'] * 100:.1f}% of sweeps " f"(start<=lo: {result['lower_ok_fraction'] * 100:.1f}%, stop>=hi: {result['upper_ok_fraction'] * 100:.1f}%), " f"{result['cropped_fraction'] * 100:.1f}% of points cropped" ) print(f"per-sweep start freq (GHz): {_summarize(result['f_start'] / 1e9)}") print(f"per-sweep stop freq (GHz): {_summarize(result['f_stop'] / 1e9)}") print( f"suggested band for ~95%/edge: [{result['rec_band_start_hz'] / 1e9:.3f}, " f"{result['rec_band_stop_hz'] / 1e9:.3f}] GHz -> covers " f"{result['rec_coverage_fraction'] * 100:.1f}% of sweeps" ) if result["coverage_fraction"] < 0.95: print( "WARNING: many sweeps do not cover the configured band and would be rejected; " "consider the suggested band above (or longer laser settling if sweeps are partial)." ) print("=" * 72 + "\n") _TWO_PI = 2.0 * np.pi def _capture_raw_sweeps( service: KamilAdcService, *, warmup: int, sweeps: int ) -> list[tuple[np.ndarray, np.ndarray, np.ndarray]]: """Capture full ``(steps, main, reference)`` arrays after discarding `warmup`. Unlike :func:`_capture_reference_phases`, this keeps the complete complex main and reference samples (and their step indices) so the diagnosis can study the raw phase, amplitude, and the pass-through behaviour — not just the endpoints. """ for index in range(warmup): service.read_raw_sweep() if index == 0: logger.info("Warming up (%d sweeps) while the sweep settles...", warmup) captured: list[tuple[np.ndarray, np.ndarray, np.ndarray]] = [] for index in range(sweeps): raw = service.read_raw_sweep() if raw.reference.size >= 2 and raw.main.size == raw.reference.size: captured.append( ( np.asarray(raw.steps), raw.main.astype(np.complex128), raw.reference.astype(np.complex128), ) ) if (index + 1) % 50 == 0: logger.info("Captured %d/%d sweeps", index + 1, sweeps) return captured def _diagnose( sweeps: list[tuple[np.ndarray, np.ndarray, np.ndarray]], config: RunConfigModel ) -> dict: """Characterise how the reference phase / frequency axis moves across sweeps. The reference arm has a fixed electrical delay, so each sweep's unwrapped phase is a straight ramp; the sweep start frequency wandering shifts that ramp vertically (the *physical float*). When ``np.angle(ref[0])`` crosses the +/-pi cut, ``np.unwrap`` re-anchors the whole ramp one 2*pi turn away (the *spurious branch wrap*) — a ~``2*pi*slope`` jump of the entire frequency axis that wrecks the band mapping for that sweep. This separates the two: it reconstructs the anchor phase per sweep, unwraps it *across* sweeps (the candidate fix), and reports how that removes discrete 2*pi steps while leaving the slow physical float intact. It also runs the live pass-through (``KamilAdcSweepProcessor``) to flag which sweeps actually come out distorted, and correlates those with the detected branch wraps. """ kamil = config.radar.kamil_adc cal = kamil.phase_calibration phase0, phase1 = float(cal.phase0_rad), float(cal.phase1_rad) freq0, freq1 = float(cal.freq0_hz), float(cal.freq1_hz) slope = (freq1 - freq0) / (phase1 - phase0) # Hz per rad band_start, band_stop = float(kamil.band.start_hz), float(kamil.band.stop_hz) n = len(sweeps) anchor_phase = np.empty(n) # unwrap(angle(ref))[0] == angle(ref[0]) in (-pi, pi] stop_phase = np.empty(n) # unwrap(angle(ref))[-1] span_rad = np.empty(n) # stop_phase - anchor_phase (fixed-delay invariant) n_points = np.empty(n, dtype=np.int64) f_lo = np.empty(n) # min reconstructed frequency (config calibration) f_hi = np.empty(n) # max reconstructed frequency ref_amp_med = np.empty(n) for i, (_steps, _main, reference) in enumerate(sweeps): phase = np.unwrap(np.angle(reference)) anchor_phase[i] = phase[0] stop_phase[i] = phase[-1] span_rad[i] = phase[-1] - phase[0] n_points[i] = reference.size freqs = freq0 + (phase - phase0) * slope f_lo[i] = float(np.min(freqs)) f_hi[i] = float(np.max(freqs)) ref_amp_med[i] = float(np.median(np.abs(reference))) # --- Candidate fix: unwrap the anchor phase ACROSS the sweep-time axis ------ # The physical float moves the anchor smoothly; a branch wrap injects a +/-2*pi # step. Unwrapping along sweep index removes the discrete steps and keeps the # slow float. branch_turns is the integer turns each sweep was wrapped by. anchor_unwrapped = np.unwrap(anchor_phase) branch_turns = np.round((anchor_unwrapped - anchor_phase) / _TWO_PI).astype(np.int64) axis_shift = branch_turns * _TWO_PI * slope # Hz the whole axis was displaced f_lo_corr = f_lo + axis_shift f_hi_corr = f_hi + axis_shift # Branch wraps: consecutive anchor steps above pi are spurious 2*pi jumps. anchor_step = np.diff(anchor_phase) wrap_indices = np.nonzero(np.abs(anchor_step) > np.pi)[0] + 1 # sweep index after wrap covers_raw = (f_lo <= band_start) & (f_hi >= band_stop) covers_corr = (f_lo_corr <= band_start) & (f_hi_corr >= band_stop) passthrough = _passthrough_flatness(sweeps, kamil) return { "sweeps": n, "slope_hz_per_rad": slope, "axis_2pi_shift_hz": _TWO_PI * slope, "band_start_hz": band_start, "band_stop_hz": band_stop, "anchor_phase": anchor_phase, "anchor_unwrapped": anchor_unwrapped, "branch_turns": branch_turns, "n_branch_wraps": int(wrap_indices.size), "wrap_indices": wrap_indices, "span_rad": span_rad, "n_points": n_points, "f_lo": f_lo, "f_hi": f_hi, "f_lo_corr": f_lo_corr, "f_hi_corr": f_hi_corr, "axis_shift_hz": axis_shift, "ref_amp_med": ref_amp_med, "coverage_raw": float(np.mean(covers_raw)), "coverage_corr": float(np.mean(covers_corr)), # Largest sweep-to-sweep step of the *unwrapped* anchor: the fix is only # valid if this stays below pi (otherwise the cross-sweep unwrap is itself # ambiguous). Reported so we can confirm the float really is slow. "max_unwrapped_anchor_step_rad": float(np.max(np.abs(np.diff(anchor_unwrapped)))) if n > 1 else 0.0, **passthrough, } def _passthrough_flatness( sweeps: list[tuple[np.ndarray, np.ndarray, np.ndarray]], kamil ) -> dict: """Run the live processor and measure how flat each pass-through trace is. Builds the same :class:`KamilAdcSweepProcessor` the runtime uses, processes every sweep onto the fixed grid, then measures each trace's deviation from a robust (median) reference trace. A branch-wrapped sweep lands on a frequency axis offset by ~one 2*pi turn, so after resampling it beats against the reference — showing up as a large phase-residual std. This is the observable symptom ("constant offset + oscillations") tied back to the raw-phase analysis. """ processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(kamil)) traces = [processor.process(main, reference) for _steps, main, reference in sweeps] passed = np.array([trace is not None for trace in traces]) n = len(traces) phase_residual_std = np.full(n, np.nan) amp_residual_std = np.full(n, np.nan) stack = np.array([trace for trace in traces if trace is not None], dtype=np.complex128) if stack.shape[0] >= 3: # Robust per-grid-point reference: median of real/imag over passing sweeps. reference_trace = np.median(stack.real, axis=0) + 1j * np.median(stack.imag, axis=0) safe_reference = np.where(np.abs(reference_trace) < 1e-12, 1.0 + 0j, reference_trace) passed_index = 0 for i, trace in enumerate(traces): if trace is None: continue ratio = trace.astype(np.complex128) / safe_reference phase_residual_std[i] = float(np.std(np.angle(ratio))) amp_residual_std[i] = float(np.std(np.abs(ratio))) passed_index += 1 return { "passthrough_passed_fraction": float(np.mean(passed)), "passthrough_phase_residual_std": phase_residual_std, "passthrough_amp_residual_std": amp_residual_std, } def _report_diagnosis(result: dict) -> None: """Print the detailed reference-phase / frequency-axis diagnosis.""" n = result["sweeps"] print("\n" + "=" * 72) print(f"Kamil ADC reference-phase DIAGNOSIS over {n} sweeps") print("=" * 72) print( f"calibration slope = {result['slope_hz_per_rad'] / 1e6:.4f} MHz/rad\n" f"one 2*pi branch wrap = {result['axis_2pi_shift_hz'] / 1e6:.2f} MHz axis shift" ) print("-" * 72) print( f"anchor phase angle(ref[0]): {_summarize(result['anchor_phase'])} rad\n" f"phase span (stop - start) : {_summarize(result['span_rad'])} rad " f"(std {np.std(result['span_rad']):.4f} -> fixed-delay {'OK' if np.std(result['span_rad']) < 1.0 else 'SUSPECT'})\n" f"points per sweep : {_summarize(result['n_points'].astype(float))}" ) print("-" * 72) print( f"branch wraps detected : {result['n_branch_wraps']} " f"(at sweep indices {result['wrap_indices'].tolist()})" ) print(f"branch turns range : {_summarize(result['branch_turns'].astype(float))} turns") print( f"max sweep-to-sweep step of UNWRAPPED anchor = " f"{result['max_unwrapped_anchor_step_rad']:.4f} rad " f"({'< pi: cross-sweep unwrap is unambiguous' if result['max_unwrapped_anchor_step_rad'] < np.pi else '>= pi: AMBIGUOUS, fix may misstep'})" ) print("-" * 72) print("reconstructed band edges (GHz), config calibration:") print(f" raw start {_summarize(result['f_lo'] / 1e9)} stop {_summarize(result['f_hi'] / 1e9)}") print(f" corrected start {_summarize(result['f_lo_corr'] / 1e9)} stop {_summarize(result['f_hi_corr'] / 1e9)}") print( f"band [{result['band_start_hz'] / 1e9:.3f}, {result['band_stop_hz'] / 1e9:.3f}] GHz covered: " f"raw {result['coverage_raw'] * 100:.1f}% -> corrected {result['coverage_corr'] * 100:.1f}%" ) print("-" * 72) phase_res = result["passthrough_phase_residual_std"] finite = phase_res[np.isfinite(phase_res)] print(f"pass-through: {result['passthrough_passed_fraction'] * 100:.1f}% of sweeps covered the band") if finite.size: print(f" phase-residual std vs median trace: {_summarize(finite)} rad") # Sweeps whose pass-through trace is far from flat (the visible glitches). bad = np.nonzero(phase_res > (np.median(finite) + 5.0 * (np.std(finite) + 1e-9)))[0] print(f" distorted sweeps (>5 sigma residual): {bad.tolist()}") print(f" do they coincide with branch wraps? wraps at {result['wrap_indices'].tolist()}") print("-" * 72) print( "HOW TO READ THIS:\n" " * phase span std small -> fixed reference delay confirmed (model holds).\n" " * 'branch wraps' are the suspected glitches: angle(ref[0]) crossing +/-pi.\n" " * If the UNWRAPPED-anchor max step stays < pi, the sweep-to-sweep float is\n" " slow enough that a CROSS-SWEEP unwrap can separate the real drift from the\n" " spurious 2*pi wrap. That is the candidate fix.\n" " * Expect: 'corrected' band edges move CONTINUOUSLY (no ~157 MHz steps) while\n" " 'raw' jumps by one wrap at each detected wrap index, and the distorted\n" " pass-through sweeps line up with those wrap indices." ) print("=" * 72 + "\n") def _dump_diagnosis( path: Path, result: dict, sweeps: list[tuple[np.ndarray, np.ndarray, np.ndarray]], ) -> None: """Save per-sweep metrics and the full raw sweeps to an ``.npz`` for offline study.""" arrays = {key: value for key, value in result.items() if isinstance(value, np.ndarray)} arrays["raw_steps"] = np.array([steps for steps, _m, _r in sweeps], dtype=object) arrays["raw_main"] = np.array([main for _s, main, _r in sweeps], dtype=object) arrays["raw_reference"] = np.array([reference for _s, _m, reference in sweeps], dtype=object) np.savez(path, **arrays) print(f"Saved diagnosis data ({len(sweeps)} sweeps) to {path}") def _plot_diagnosis(path: Path, result: dict) -> None: """Render the key diagnostic plots to a PNG (no-op if matplotlib is missing).""" try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt except Exception as exc: # noqa: BLE001 — plotting is optional logger.warning("Plotting skipped (matplotlib unavailable: %s)", exc) return sweep_index = np.arange(result["sweeps"]) fig, axes = plt.subplots(3, 1, figsize=(11, 12), sharex=True) axes[0].plot(sweep_index, result["anchor_phase"], ".", label="angle(ref[0]) raw (wrapped)") axes[0].plot(sweep_index, result["anchor_unwrapped"], "-", label="unwrapped across sweeps (fix)") for wrap in result["wrap_indices"]: axes[0].axvline(wrap, color="r", alpha=0.3) axes[0].set_ylabel("anchor phase [rad]") axes[0].legend(loc="best") axes[0].set_title("Reference anchor phase: raw wraps vs cross-sweep unwrap") axes[1].plot(sweep_index, result["f_lo"] / 1e9, ".", label="start raw") axes[1].plot(sweep_index, result["f_lo_corr"] / 1e9, "-", label="start corrected") axes[1].plot(sweep_index, result["f_hi"] / 1e9, ".", label="stop raw") axes[1].plot(sweep_index, result["f_hi_corr"] / 1e9, "-", label="stop corrected") axes[1].axhline(result["band_start_hz"] / 1e9, color="k", ls="--", alpha=0.5) axes[1].axhline(result["band_stop_hz"] / 1e9, color="k", ls="--", alpha=0.5) axes[1].set_ylabel("reconstructed band edge [GHz]") axes[1].legend(loc="best") axes[2].plot(sweep_index, result["passthrough_phase_residual_std"], ".", label="phase residual std") for wrap in result["wrap_indices"]: axes[2].axvline(wrap, color="r", alpha=0.3, label="_branch wrap") axes[2].set_ylabel("pass-through residual [rad]") axes[2].set_xlabel("sweep index") axes[2].legend(loc="best") fig.tight_layout() fig.savefig(path, dpi=110) plt.close(fig) print(f"Saved diagnosis plot to {path}") def _apply(config_path: Path, result: dict) -> None: """Write the migrated collector args + calibrated anchors + points to the config.""" payload = json.loads(config_path.read_text(encoding="utf-8")) kamil = payload.setdefault("radar", {}).setdefault("kamil_adc", {}) kamil["executable_path"] = COLLECTOR_PATH kamil["args"] = list(DO8_FREQ_REF_ARGS) kamil["phase_calibration"] = { "phase0_rad": result["phase0_rad"], "freq0_hz": result["freq0_hz"], "phase1_rad": result["phase1_rad"], "freq1_hz": result["freq1_hz"], } kamil["band"] = { "start_hz": result["band_start_hz"], "stop_hz": result["band_stop_hz"], "points": result["recommended_points"], } config_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") print(f"Applied calibration to {config_path}") def main() -> int: parser = argparse.ArgumentParser(description="Calibrate Kamil ADC reference phase -> frequency") parser.add_argument("--config", required=True, type=Path, help="Path to the kamil_adc run config") parser.add_argument("--sweeps", type=int, default=200, help="Sweeps to average (default 200)") parser.add_argument("--warmup", type=int, default=10, help="Sweeps to discard first (default 10)") parser.add_argument("--apply", action="store_true", help="Write the calibration back to --config") parser.add_argument("--no-laser", action="store_true", help="Skip laser setup (already running)") parser.add_argument( "--no-release", action="store_true", help="Do not stop radar.service / kill orphaned collectors before opening", ) parser.add_argument( "--diagnose", action="store_true", help="Investigate reference-phase branch wraps instead of calibrating", ) parser.add_argument( "--dump", type=Path, default=None, help="(--diagnose) save raw sweeps + metrics to this .npz" ) parser.add_argument( "--plot", type=Path, default=None, help="(--diagnose) render diagnostic plots to this .png" ) args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") config = RunConfigModel.load_from_path(args.config) if not config.is_kamil_adc: raise SystemExit("Config is not a kamil_adc profile") # Force the reference-producing collector regardless of the on-disk config. config.radar.kamil_adc.executable_path = COLLECTOR_PATH config.radar.kamil_adc.args = list(DO8_FREQ_REF_ARGS) # Free the device the headless daemon / an orphaned collector may be holding, # so opening our own collector does not fail with E-502 device-busy. if not args.no_release: _release_radar_hardware() if not args.no_laser: logger.info("Applying laser control...") apply_kamil_adc_laser_control(config) service = KamilAdcService(config) logger.info("Opening collector: %s", " ".join(service.command)) _open_with_retry(service) if args.diagnose: try: sweeps = _capture_raw_sweeps(service, warmup=args.warmup, sweeps=args.sweeps) finally: service.close() if len(sweeps) < max(2, args.sweeps // 2): raise SystemExit(f"Only {len(sweeps)} usable sweeps captured; check the reference signal") result = _diagnose(sweeps, config) _report_diagnosis(result) if args.dump is not None: _dump_diagnosis(args.dump, result, sweeps) if args.plot is not None: _plot_diagnosis(args.plot, result) return 0 try: phases = _capture_reference_phases(service, warmup=args.warmup, sweeps=args.sweeps) finally: service.close() if len(phases) < max(2, args.sweeps // 2): raise SystemExit(f"Only {len(phases)} usable sweeps captured; check the reference signal") result = _analyze(phases, config) _report(result) if args.apply: _apply(args.config, result) return 0 if __name__ == "__main__": raise SystemExit(main())