new kamil adc

This commit is contained in:
Ayzen
2026-06-11 13:02:02 +03:00
parent 21f76d7cd2
commit 9661504e51
46 changed files with 12097 additions and 1063 deletions
+265
View File
@@ -0,0 +1,265 @@
"""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.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")
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)")
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)
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())
+6 -19
View File
@@ -12,10 +12,10 @@ import time
import numpy as np
from python_app.hardware_full.kamil_adc_service import KamilAdcService
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.switch_service import SwitchService
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RunConfigModel, SwitchModel
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.shm import ShmRingWriter
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
@@ -137,8 +137,8 @@ def main() -> int:
config.rings.raw.name, config.rings.raw_tap.name,
)
radar = KamilAdcService(config)
input_switch = _switch_from_model(config.input_switch)
output_switch = _switch_from_model(config.output_switch)
input_switch = SwitchService.from_model(config.input_switch)
output_switch = SwitchService.from_model(config.output_switch)
try:
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
@@ -226,7 +226,8 @@ def main() -> int:
output_switch.close()
with suppress(Exception):
input_switch.close()
radar.close()
with suppress(Exception):
radar.close()
raw_tap_writer.close()
raw_writer.close()
@@ -234,19 +235,5 @@ def main() -> int:
return 0
def _switch_from_model(model: SwitchModel) -> SwitchService:
return SwitchService(
name=model.name,
positions=model.positions,
mode=model.driver_mode,
driver=model.driver,
gpio_chip=model.gpio_chip,
pin_a=model.pin_a,
pin_b=model.pin_b,
invert_logic=model.invert_logic,
default_position=model.default_position,
)
if __name__ == "__main__":
raise SystemExit(main())