Files
radar_system/python_app/scripts/kamil_adc_calibrate.py
T
2026-06-22 16:30:55 +03:00

584 lines
26 KiB
Python

"""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 statistics
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"
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(
"--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)
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())