115 lines
4.9 KiB
Python
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())
|