584 lines
26 KiB
Python
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())
|