Files
radar_system/python_app/scripts/rph_radar_calibrate.py
T

115 lines
4.9 KiB
Python

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