"""Measure only RPH reference phase anchors using the current acquisition settings. Start the laser with Apply Radar and stop acquisition before running this tool. No laser commands are sent. --apply replaces only phase0_rad and phase1_rad; frequency anchors, band, collector settings and JSON formatting are preserved. """ import argparse import json import logging from pathlib import Path import numpy as np from python_app.file_io import atomic_write_text from python_app.hardware_full.rph_radar.service import RphRadarService from python_app.models.run_config_schema import RunConfigModel def _value_span(text: str, path: tuple[str, ...]) -> tuple[int, int]: """Locate a unique JSON member without reserializing its neighbours.""" decoder = json.JSONDecoder() def skip_space(index: int) -> int: while text[index] in " \t\r\n": index += 1 return index start, end = 0, len(text) for member in path: start = skip_space(start) if text[start] != "{": raise ValueError(f"Expected JSON object before {member}") cursor, found = start + 1, None while True: cursor = skip_space(cursor) if text[cursor] == "}": break key, cursor = decoder.raw_decode(text, cursor) cursor = skip_space(cursor) + 1 # colon; JSON already validated cursor = skip_space(cursor) _, next_cursor = decoder.raw_decode(text, cursor) if key == member: if found is not None: raise ValueError(f"Duplicate JSON member: {member}") found = (cursor, next_cursor) cursor = skip_space(next_cursor) if text[cursor] == "}": break cursor += 1 # comma if found is None: raise ValueError(f"Profile must contain explicit {'.'.join(path)}") start, end = found return start, end def calibrate(config: RunConfigModel, sweeps: int, warmup: int) -> dict[str, float]: """Median endpoints after aligning each sweep to the preceding phase branch.""" if sweeps < 1 or warmup < 0: raise ValueError("sweeps must be positive and warmup nonnegative") if not config.is_rph_radar or config.radar.driver_mode != "native": raise ValueError("Calibration requires a native rph_radar profile") previous = config.radar.rph_radar.phase_calibration.phase0_rad endpoints = [] service = RphRadarService(config) try: service.open() for index in range(warmup + sweeps): raw = service.read_raw_sweep() if index < warmup: continue reference = np.asarray(raw.reference, dtype=np.complex128) if (raw.dirty or reference.ndim != 1 or reference.size < 2 or not np.all(np.isfinite(reference)) or np.any(np.abs(reference) <= 1e-9)): raise ValueError("Invalid reference sweep; phase calibration was not changed") phase = np.unwrap(np.angle(reference)) phase += round((previous - float(phase[0])) / (2 * np.pi)) * (2 * np.pi) if abs(float(phase[-1] - phase[0])) <= 1e-9: raise ValueError("Degenerate reference phase span") endpoints.append((phase[0], phase[-1])) previous = float(phase[0]) finally: service.close() phase0, phase1 = np.median(endpoints, axis=0) if abs(float(phase1 - phase0)) <= 1e-9: raise ValueError("Degenerate median phase span") return {"phase0_rad": float(phase0), "phase1_rad": float(phase1)} def main() -> int: parser = argparse.ArgumentParser(description="Calibrate RadioPhotonic radar (RPH) phase anchors") parser.add_argument("--config", required=True, type=Path) parser.add_argument("--sweeps", type=int, default=200) parser.add_argument("--warmup", type=int, default=10) parser.add_argument("--apply", action="store_true", help="Write only the two phase anchors") args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") original = args.config.read_bytes() text = original.decode("utf-8") config = RunConfigModel.from_dict(json.loads(text)) fields = ("phase0_rad", "phase1_rad") spans = {name: _value_span(text, ("radar", "rph_radar", "phase_calibration", name)) for name in fields} result = calibrate(config, args.sweeps, args.warmup) print(json.dumps(result, allow_nan=False)) if args.apply: for name, (start, end) in sorted(spans.items(), key=lambda item: item[1][0], reverse=True): text = text[:start] + json.dumps(result[name], allow_nan=False) + text[end:] if args.config.read_bytes() != original: raise RuntimeError("Profile changed during capture; refusing to overwrite it") atomic_write_text(args.config, text, durable=True) return 0 if __name__ == "__main__": raise SystemExit(main())