Rename kamil_adc to rph_radar and fix its data path

This commit is contained in:
Ayzen
2026-10-06 18:15:18 +03:00
parent aef549ea76
commit a185e61495
94 changed files with 2851 additions and 9485 deletions
-78
View File
@@ -1,78 +0,0 @@
"""Standalone laser temperature checker process.
Reads the target setpoints frozen at variation start and tails the JSONL readings
channel produced by the monitor. For every reading it validates both lasers and
prints a console warning whenever a measured temperature drifts from its target by
more than the tolerance (default 0.03 °C). Never touches the serial port, so it is
fully independent of the monitor and can be started/stopped at any time.
Example::
python -m python_app.scripts.laser_temp_checker
python -m python_app.scripts.laser_temp_checker --session s.json --readings r.jsonl
"""
from __future__ import annotations
import argparse
import logging
import signal
import threading
from pathlib import Path
from python_app.hardware_full.laser_control.monitoring import (
DEFAULT_READINGS_PATH,
DEFAULT_SESSION_PATH,
LaserTemperatureChecker,
LaserVariationSession,
ReadingReader,
)
logger = logging.getLogger("laser_temp_checker")
_POLL_INTERVAL_S = 0.2
def main() -> int:
parser = argparse.ArgumentParser(description="Validate laser temperature against setpoints")
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
help="Session snapshot with target setpoints + tolerance")
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
help="JSONL readings channel to tail")
parser.add_argument("--tolerance", type=float, default=None,
help="Override tolerance in °C (default: from session)")
parser.add_argument("--reminder-every", type=int, default=0,
help="Repeat a warning every N readings while off target (0=off)")
parser.add_argument("--from-start", action="store_true",
help="Validate the whole readings file, not just new lines")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
session = LaserVariationSession.load(args.session)
if args.tolerance is not None:
session.tolerance_c = args.tolerance
checker = LaserTemperatureChecker.from_session(session, reminder_every=args.reminder_every)
logger.info(
"Checking against T1=%.3f T2=%.3f °C, tolerance ±%.3f °C (%s)",
session.target_temp1, session.target_temp2, session.tolerance_c, session.variation_type,
)
reader = ReadingReader(args.readings, from_start=args.from_start)
stop_event = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_event.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
while not stop_event.is_set():
for reading in reader.poll():
checker.process(reading)
stop_event.wait(_POLL_INTERVAL_S)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-149
View File
@@ -1,149 +0,0 @@
"""Standalone laser temperature monitor process.
Owns the laser serial port, (optionally) starts a current-variation task, then
polls the board once per sweep and appends each reading to a JSONL channel that
the temperature checker tails. Runs until SIGINT/SIGTERM.
Examples::
# Start LD1 current variation from a run config, then monitor:
python -m python_app.scripts.laser_temp_monitor --config run_config.json --start
# Monitor a variation that is already running:
python -m python_app.scripts.laser_temp_monitor --config run_config.json
"""
from __future__ import annotations
import argparse
import logging
import signal
import threading
from datetime import datetime
from pathlib import Path
from python_app.hardware_full.laser_control.controller import (
DEVICE_MAIN_MESSAGE_ID,
LaserController,
)
from python_app.hardware_full.laser_control.exceptions import PortBusyError
from python_app.hardware_full.laser_control.models import VariationType
from python_app.hardware_full.laser_control.monitoring import (
DEFAULT_READINGS_PATH,
DEFAULT_SESSION_PATH,
LaserTemperatureMonitor,
LaserVariationSession,
ReadingWriter,
resolve_period_s,
)
from python_app.models.run_config_schema import RunConfigModel
logger = logging.getLogger("laser_temp_monitor")
def _start_variation(controller: LaserController, variation) -> None:
"""Send the CHANGE_CURRENT_LD1 task and freeze the session snapshot."""
controller.reset()
controller.set_manual_mode(
temp1=variation.static_temp1,
temp2=variation.static_temp2,
current1=variation.static_current1,
current2=variation.static_current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
controller.start_variation(
variation_type=VariationType[variation.variation_type],
params={
"static_temp1": variation.static_temp1,
"static_temp2": variation.static_temp2,
"static_current1": variation.static_current1,
"static_current2": variation.static_current2,
"min_value": variation.min_value,
"max_value": variation.max_value,
"step": variation.step,
"time_step": variation.time_step,
"delay_time": variation.delay_time,
},
)
def main() -> int:
parser = argparse.ArgumentParser(description="Poll laser temperature once per sweep")
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
help="JSONL readings channel to append to")
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
help="Session snapshot path (written with --start)")
parser.add_argument("--strategy", default="computed",
help="'computed' (per sweep) or 'interval:<ms>'")
parser.add_argument("--start", action="store_true",
help="Send CHANGE_CURRENT_LD1 before monitoring")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
config = RunConfigModel.load_from_path(args.config)
laser = config.radar.laser_control
variation = laser.variation
if variation.variation_type != "CHANGE_CURRENT_LD1":
logger.warning(
"Only CHANGE_CURRENT_LD1 is supported by firmware; got %s",
variation.variation_type,
)
period_s = resolve_period_s(
args.strategy,
min_value=variation.min_value,
max_value=variation.max_value,
step=variation.step,
time_step_us=variation.time_step,
delay_time_ms=variation.delay_time,
)
stop_event = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_event.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
controller = LaserController(
port=laser.port or None,
pi_coeff1_p=laser.pi_coeff1_p,
pi_coeff1_i=laser.pi_coeff1_i,
pi_coeff2_p=laser.pi_coeff2_p,
pi_coeff2_i=laser.pi_coeff2_i,
)
try:
controller.connect()
except PortBusyError as exc:
# Expected, benign conflict: the manual-control UI (or another monitor)
# already owns the port. Exit cleanly with guidance, not a traceback.
logger.error("%s", exc)
return 2
try:
if args.start:
_start_variation(controller, variation)
LaserVariationSession(
variation_type=variation.variation_type,
target_temp1=variation.static_temp1,
target_temp2=variation.static_temp2,
tolerance_c=variation.temp_tolerance_c,
started_at_iso=datetime.now().isoformat(timespec="seconds"),
).save(args.session)
logger.info("Started CHANGE_CURRENT_LD1 and wrote session %s", args.session)
with ReadingWriter(args.readings) as writer:
monitor = LaserTemperatureMonitor(
controller=controller, writer=writer, period_s=period_s
)
logger.info("Monitoring to %s (period=%.3fs)", args.readings, period_s)
monitor.run(stop_event)
finally:
controller.disconnect()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+114
View File
@@ -0,0 +1,114 @@
"""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())
@@ -1,4 +1,4 @@
"""Raw acquisition producer for the external Kamil ADC radar mode."""
"""Raw acquisition producer for RadioPhotonic radar (RPH)."""
from __future__ import annotations
@@ -9,7 +9,7 @@ import time
import numpy as np
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.rph_radar.service import RphRadarService
from python_app.hardware_full.switch_service import SwitchService, select_and_settle
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_schema import RunConfigModel
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
def _open_radar_with_retry(
config: RunConfigModel,
radar: KamilAdcService,
radar: RphRadarService,
input_switch: SwitchService | None,
output_switch: SwitchService | None,
stop_requested: threading.Event,
@@ -28,7 +28,7 @@ def _open_radar_with_retry(
"""Reopen the ADC and Python-owned switches, cleaning partial opens before retry."""
close_devices(input_switch, output_switch, radar)
def attempt() -> KamilAdcService:
def attempt() -> RphRadarService:
try:
radar.open(stop_event=stop_requested)
radar.configure(config.radar.sweep)
@@ -41,16 +41,16 @@ def _open_radar_with_retry(
raise
return radar
return retry_open(attempt, stop_requested, "Kamil ADC") is not None
return retry_open(attempt, stop_requested, "RadioPhotonic radar (RPH)") is not None
def main() -> int:
"""Run producer process until config or signal requests exit."""
config_path, config, stop_requested = load_producer_config("Publish Kamil ADC raw sweeps to SHM rings")
if not config.is_kamil_adc:
raise RuntimeError("kamil_adc_raw_producer requires radar.model='kamil_adc'")
config_path, config, stop_requested = load_producer_config("Publish RadioPhotonic radar (RPH) raw sweeps to SHM rings")
if not config.is_rph_radar:
raise RuntimeError("rph_radar_raw_producer requires radar.model='rph_radar'")
logger.info(
"Kamil ADC raw producer starting: config=%s, combos=%d, continuous=%s",
"RadioPhotonic radar (RPH) raw producer starting: config=%s, combos=%d, continuous=%s",
config_path, len(config.combos), config.runtime.continuous,
)
@@ -59,24 +59,21 @@ def main() -> int:
try:
publisher = RawPublisher(config.rings, cleanup)
# Switch-aware mode: with native switches the collector drives the RF switches
# itself, in the hardware gap between sweeps, and tags each sweep with its combo
# — no sweep lost at a switch boundary. The producer then only reads tagged
# sweeps and must not touch the GPIO lines the collector owns. With mock switches
# (dev/tests) we keep the Python-driven path, which is fine where speed and the
# in-gap timing do not matter.
# itself and tags each sweep with its combo and source cycle. Otherwise Python
# owns the switches and starts a fresh collector session after each changed pair.
collector_driven = (
config.input_switch.driver_mode == "native"
and config.output_switch.driver_mode == "native"
)
radar = KamilAdcService(
radar = RphRadarService(
config,
switch_config_path=str(config_path) if collector_driven else None,
collector_driven=collector_driven,
)
input_switch = None if collector_driven else SwitchService.from_model(config.input_switch)
output_switch = None if collector_driven else SwitchService.from_model(config.output_switch)
logger.info(
"Kamil ADC switch control: %s",
"collector-driven (in-gap, lossless)" if collector_driven else "producer-driven",
"RadioPhotonic radar (RPH) switch control: %s",
"collector-driven" if collector_driven else "producer-driven",
)
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
@@ -85,8 +82,8 @@ def main() -> int:
collection_id = 1
while not stop_requested.is_set():
collection_start = time.monotonic()
capture_start_ns = time.monotonic_ns()
traces: list[TraceData] = []
cycle_id = None
try:
for combo in config.combos:
@@ -95,30 +92,30 @@ def main() -> int:
if collector_driven:
# The collector already switched and tagged the sweep; just
# read the clean capture for this combination.
sweep_start_ns = time.monotonic_ns()
sweep = radar.acquire(combo=(combo.input, combo.output))
if cycle_id is not None and cycle_id != radar.last_cycle_id:
break # A dropped or overwritten pair invalidates this cycle.
cycle_id = radar.last_cycle_id
else:
if input_switch is None or output_switch is None:
raise RuntimeError("Producer-driven switches are not initialized")
if not select_and_settle(output_switch, input_switch, combo.output, combo.input,
config.runtime.settling_ms, wait=stop_requested.wait):
break
# Stamped after switching and settling so the window covers
# the sweep alone, not the dead time before it.
sweep_start_ns = time.monotonic_ns()
radar.prepare_combo(ComboKey(combo.input, combo.output))
sweep = radar.acquire()
traces.append(
TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
capture_start_ns=sweep_start_ns,
capture_end_ns=time.monotonic_ns(),
capture_start_ns=sweep.capture_start_ns,
capture_end_ns=sweep.capture_end_ns,
)
)
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
logger.warning(
"Kamil ADC acquisition failed; reconnecting and waiting for the device: %s",
"RadioPhotonic radar (RPH) acquisition failed; reconnecting and waiting for the device: %s",
exc,
exc_info=True,
)
@@ -132,8 +129,10 @@ def main() -> int:
if len(traces) != len(config.combos):
if stop_requested.is_set():
break
if collector_driven:
continue # Latest-wins transport skipped part of a source cycle.
logger.warning(
"Kamil ADC produced an incomplete collection (%d of %d combos); "
"RadioPhotonic radar (RPH) produced an incomplete collection (%d of %d combos); "
"reconnecting and waiting for the device",
len(traces),
len(config.combos),
@@ -143,11 +142,11 @@ def main() -> int:
continue
collection = SweepCollection(
collection_id=collection_id,
collection_id=cycle_id if cycle_id is not None else collection_id,
monotonic_ns=time.monotonic_ns(),
traces=traces,
capture_start_ns=capture_start_ns,
capture_end_ns=time.monotonic_ns(),
capture_start_ns=min(trace.capture_start_ns for trace in traces),
capture_end_ns=max(trace.capture_end_ns for trace in traces),
)
publisher.publish(collection)
@@ -155,13 +154,13 @@ def main() -> int:
break
collection_duration_s = time.monotonic() - collection_start
if collection_id == 1 or collection_id % 20 == 0 or collection_duration_s > 2.0:
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
logger.info("RadioPhotonic radar (RPH) collection %d acquired in %.3f s", collection_id, collection_duration_s)
collection_id += 1
finally:
close_devices(output_switch, input_switch, radar)
cleanup.close()
logger.info("Kamil ADC raw producer stopped")
logger.info("RadioPhotonic radar (RPH) raw producer stopped")
return 0