new kamil adc
This commit is contained in:
@@ -419,6 +419,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._set_combo_current_text(self._processing_mode, gui_state.processing.selected_mode)
|
||||
self._show_magnitude_checkbox.setChecked(bool(gui_state.processing.pass_through.show_magnitude))
|
||||
self._show_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.show_phase))
|
||||
self._unwrap_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.unwrap_phase))
|
||||
self._pass_through_combo_filter_input.setText(str(gui_state.processing.pass_through.combo_filter))
|
||||
self._pass_through_fixed_y_enabled.setChecked(bool(gui_state.processing.pass_through.fixed_y_enabled))
|
||||
self._pass_through_y_min_db.setValue(float(gui_state.processing.pass_through.y_min_db))
|
||||
|
||||
@@ -203,6 +203,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
pass_through=GuiPassThroughStateModel(
|
||||
show_magnitude=True,
|
||||
show_phase=True,
|
||||
unwrap_phase=False,
|
||||
combo_filter="",
|
||||
fixed_y_enabled=False,
|
||||
y_min_db=-100.0,
|
||||
@@ -323,6 +324,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
pass_through=GuiPassThroughStateModel(
|
||||
show_magnitude=bool(self._show_magnitude_checkbox.isChecked()),
|
||||
show_phase=bool(self._show_phase_checkbox.isChecked()),
|
||||
unwrap_phase=bool(self._unwrap_phase_checkbox.isChecked()),
|
||||
combo_filter=self._pass_through_combo_filter_input.text().strip(),
|
||||
fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
|
||||
y_min_db=float(self._pass_through_y_min_db.value()),
|
||||
|
||||
@@ -12,7 +12,7 @@ from python_app.gui.runtime.history import (
|
||||
build_run_history_signature,
|
||||
record_result_history,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
|
||||
@@ -21,6 +21,10 @@ class AppWindowTracePlotMixin:
|
||||
"""Return whether phase curves should be rendered."""
|
||||
return self._show_phase_checkbox.isChecked()
|
||||
|
||||
def _unwrap_phase_enabled(self) -> bool:
|
||||
"""Return whether the phase trace should be unwrapped (cumulative)."""
|
||||
return self._unwrap_phase_checkbox.isChecked()
|
||||
|
||||
def _pass_through_fixed_y_range(self) -> tuple[bool, float, float]:
|
||||
"""Return normalized magnitude Y-range override for pass-through mode."""
|
||||
y_min = float(self._pass_through_y_min_db.value())
|
||||
@@ -48,13 +52,29 @@ class AppWindowTracePlotMixin:
|
||||
return None
|
||||
|
||||
def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
|
||||
"""Apply pass-through magnitude-axis autorange or fixed Y window."""
|
||||
"""Apply the magnitude Y-axis: a fixed window or autorange.
|
||||
|
||||
The X axis is set explicitly from the data band by the caller, so this
|
||||
never re-enables X autorange (which would scan all curves every frame).
|
||||
"""
|
||||
fixed_y_enabled, y_min, y_max = self._pass_through_fixed_y_range()
|
||||
view_box = plot.getViewBox()
|
||||
view_box.invertY(False)
|
||||
view_box.enableAutoRange(x=True, y=not fixed_y_enabled)
|
||||
if fixed_y_enabled:
|
||||
view_box.enableAutoRange(y=False)
|
||||
plot.setYRange(y_min, y_max, padding=0.0)
|
||||
else:
|
||||
view_box.enableAutoRange(y=True)
|
||||
|
||||
def _configure_pass_through_phase_axis(self, plot: pg.PlotWidget) -> None:
|
||||
"""Apply the phase Y-axis: fixed ±180° when wrapped, autorange when unwrapped."""
|
||||
view_box = plot.getViewBox()
|
||||
view_box.invertY(False)
|
||||
if self._unwrap_phase_enabled():
|
||||
view_box.enableAutoRange(y=True)
|
||||
else:
|
||||
view_box.enableAutoRange(y=False)
|
||||
plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||
|
||||
def _on_trace_visibility_changed(self, *_args) -> None:
|
||||
"""Redraw pass-through traces when magnitude/phase toggles changed."""
|
||||
@@ -116,7 +136,6 @@ class AppWindowTracePlotMixin:
|
||||
|
||||
if show_magnitude:
|
||||
mag_item = magnitude_plot.getPlotItem()
|
||||
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||
mag_item.showAxis("left", show=True)
|
||||
mag_item.showAxis("bottom", show=not show_phase)
|
||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
@@ -126,8 +145,6 @@ class AppWindowTracePlotMixin:
|
||||
|
||||
if show_phase:
|
||||
phase_item = phase_plot.getPlotItem()
|
||||
phase_plot.getViewBox().invertY(False)
|
||||
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
|
||||
phase_item.showAxis("left", show=True)
|
||||
phase_item.showAxis("bottom", show=True)
|
||||
phase_plot.setLabel("left", "Phase", units="deg")
|
||||
@@ -179,16 +196,18 @@ class AppWindowTracePlotMixin:
|
||||
x_max = max(x_max, local_x_max)
|
||||
|
||||
if show_magnitude:
|
||||
magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12))
|
||||
mag_x, magnitude_values = self._magnitude_display_arrays(
|
||||
payload.frequency_hz, payload.trace
|
||||
)
|
||||
active_magnitude_keys.add(curve_key)
|
||||
magnitude_curve = self._trace_magnitude_curves.get(curve_key)
|
||||
if magnitude_curve is None:
|
||||
magnitude_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.4))
|
||||
magnitude_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.4), antialias=False)
|
||||
self._trace_magnitude_curves[curve_key] = magnitude_curve
|
||||
magnitude_plot.addItem(magnitude_curve)
|
||||
else:
|
||||
magnitude_curve.setPen(pg.mkPen(color, width=1.4))
|
||||
magnitude_curve.setData(payload.frequency_hz, magnitude_values)
|
||||
magnitude_curve.setData(mag_x, magnitude_values)
|
||||
legend_source_magnitude.setdefault(combo_key, magnitude_curve)
|
||||
has_data = True
|
||||
|
||||
@@ -196,7 +215,7 @@ class AppWindowTracePlotMixin:
|
||||
active_phase_keys.add(curve_key)
|
||||
phase_curve = self._trace_phase_curves.get(curve_key)
|
||||
if phase_curve is None:
|
||||
phase_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.2))
|
||||
phase_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.2), antialias=False)
|
||||
self._trace_phase_curves[curve_key] = phase_curve
|
||||
phase_plot.addItem(phase_curve)
|
||||
else:
|
||||
@@ -231,16 +250,17 @@ class AppWindowTracePlotMixin:
|
||||
phase_sources=legend_source_phase,
|
||||
)
|
||||
|
||||
if has_data:
|
||||
if np.isfinite(x_min) and np.isfinite(x_max):
|
||||
if show_magnitude:
|
||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||
# X is the (constant) frequency band: set it explicitly rather than
|
||||
# autoranging every frame. Y is configured once per draw.
|
||||
if has_data and np.isfinite(x_min) and np.isfinite(x_max):
|
||||
if show_magnitude:
|
||||
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_magnitude:
|
||||
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||
if show_phase:
|
||||
self._configure_pass_through_phase_axis(phase_plot)
|
||||
return has_data
|
||||
|
||||
@staticmethod
|
||||
@@ -268,15 +288,40 @@ class AppWindowTracePlotMixin:
|
||||
plot.removeItem(curve)
|
||||
cache.clear()
|
||||
|
||||
def _phase_display_arrays(self, frequency_hz: np.ndarray, trace: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return phase display arrays with decimation for faster rendering."""
|
||||
max_points = int(getattr(self, "_trace_phase_render_max_points", 1200))
|
||||
if max_points > 0 and trace.size > max_points:
|
||||
step = max(1, int(np.ceil(trace.size / max_points)))
|
||||
frequency_hz = frequency_hz[::step]
|
||||
trace = trace[::step]
|
||||
phase_values = np.arctan2(trace.imag, trace.real) * (180.0 / np.pi)
|
||||
return frequency_hz, phase_values
|
||||
def _render_decimation_step(self, size: int) -> int:
|
||||
"""Stride that caps a rendered trace at the configured maximum point count.
|
||||
|
||||
Beyond ~1 point per screen pixel there is no visual gain, so decimating
|
||||
keeps the line plots responsive even with many combos on screen.
|
||||
"""
|
||||
max_points = int(getattr(self, "_trace_render_max_points", 800))
|
||||
if max_points > 0 and size > max_points:
|
||||
return max(1, int(np.ceil(size / max_points)))
|
||||
return 1
|
||||
|
||||
def _magnitude_display_arrays(
|
||||
self, frequency_hz: np.ndarray, trace: np.ndarray
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return decimated (frequency, magnitude-dB) arrays for fast rendering."""
|
||||
step = self._render_decimation_step(int(trace.size))
|
||||
frequency_hz = frequency_hz[::step]
|
||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace[::step]), 1e-12))
|
||||
return frequency_hz, magnitude_db
|
||||
|
||||
def _phase_display_arrays(
|
||||
self, frequency_hz: np.ndarray, trace: np.ndarray
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return decimated (frequency, phase-deg) arrays, unwrapped when enabled.
|
||||
|
||||
Unwrapping runs at full resolution (before decimation) so the cumulative
|
||||
phase is correct, then both arrays are decimated together for rendering.
|
||||
"""
|
||||
phase = np.angle(trace)
|
||||
if self._unwrap_phase_enabled():
|
||||
phase = np.unwrap(phase)
|
||||
phase_deg = np.degrees(phase)
|
||||
step = self._render_decimation_step(int(trace.size))
|
||||
return frequency_hz[::step], phase_deg[::step]
|
||||
|
||||
def _sync_trace_legends(
|
||||
self,
|
||||
@@ -363,26 +408,24 @@ class AppWindowTracePlotMixin:
|
||||
return
|
||||
|
||||
if show_magnitude:
|
||||
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
|
||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
magnitude_plot.setTitle(title)
|
||||
if not show_phase:
|
||||
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
if show_phase:
|
||||
phase_plot.getViewBox().invertY(False)
|
||||
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
|
||||
phase_plot.getPlotItem().showAxis("bottom", show=True)
|
||||
phase_plot.setLabel("left", "Phase", units="deg")
|
||||
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
phase_plot.setTitle(title)
|
||||
|
||||
if show_magnitude:
|
||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12))
|
||||
mag_x, magnitude_db = self._magnitude_display_arrays(trace.frequency_hz, samples)
|
||||
magnitude_curve = pg.PlotCurveItem(
|
||||
trace.frequency_hz,
|
||||
mag_x,
|
||||
magnitude_db,
|
||||
pen=pg.mkPen("#ffd166", width=1.8),
|
||||
antialias=False,
|
||||
)
|
||||
magnitude_plot.addItem(magnitude_curve)
|
||||
self._trace_magnitude_curves[
|
||||
@@ -390,23 +433,26 @@ class AppWindowTracePlotMixin:
|
||||
] = magnitude_curve
|
||||
|
||||
if show_phase:
|
||||
phase_values = np.degrees(np.angle(samples))
|
||||
phase_x, phase_values = self._phase_display_arrays(trace.frequency_hz, samples)
|
||||
phase_curve = pg.PlotCurveItem(
|
||||
trace.frequency_hz,
|
||||
phase_x,
|
||||
phase_values,
|
||||
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
|
||||
antialias=False,
|
||||
)
|
||||
phase_plot.addItem(phase_curve)
|
||||
self._trace_phase_curves[
|
||||
(int(trace.combo.input), int(trace.combo.output), 0, "__single_trace__")
|
||||
] = phase_curve
|
||||
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||
|
||||
if np.size(trace.frequency_hz) > 1:
|
||||
x_min = float(np.min(trace.frequency_hz))
|
||||
x_max = float(np.max(trace.frequency_hz))
|
||||
if show_magnitude:
|
||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||
if show_phase:
|
||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_magnitude:
|
||||
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||
if show_phase:
|
||||
self._configure_pass_through_phase_axis(phase_plot)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.hardware_full.kamil_adc_service import KamilAdcService
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
PREPROCESS_ASSET_SPECS,
|
||||
VISIBLE_PREPROCESS_ASSET_KEYS,
|
||||
@@ -429,8 +428,8 @@ class AppWindowPreprocessMixin:
|
||||
self._stop_run()
|
||||
pipeline_was_paused = True
|
||||
|
||||
point_count = self._read_kamil_adc_point_count(config)
|
||||
calibration, reference = build_kamil_adc_neutral_s21_sets(config, point_count)
|
||||
calibration, reference = build_kamil_adc_neutral_s21_sets(config)
|
||||
point_count = config.radar.kamil_adc.band.points
|
||||
self._store.save_set("s21_calibration", radar_key, set_name, calibration)
|
||||
self._store.save_set("s21_reference", radar_key, set_name, reference)
|
||||
|
||||
@@ -454,25 +453,6 @@ class AppWindowPreprocessMixin:
|
||||
if pipeline_was_paused:
|
||||
self._start_run()
|
||||
|
||||
def _read_kamil_adc_point_count(self, config) -> int:
|
||||
"""Read one Kamil ADC sweep and return its actual point count."""
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
dialog.set_status("Reading one Kamil ADC sweep to detect point count...")
|
||||
self._log("Reading one Kamil ADC sweep to detect neutral-set point count")
|
||||
|
||||
radar = KamilAdcService(config)
|
||||
try:
|
||||
radar.open()
|
||||
radar.configure(config.radar.sweep)
|
||||
sweep = radar.acquire()
|
||||
finally:
|
||||
radar.close()
|
||||
|
||||
point_count = int(sweep.x.size)
|
||||
if point_count <= 0:
|
||||
raise RuntimeError("Kamil ADC returned an empty sweep while detecting point count")
|
||||
return point_count
|
||||
|
||||
def _build_single_radar_capture_session(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -125,7 +125,10 @@ class AppWindowUiMixin:
|
||||
self._trace_phase_legend_combo_keys = set()
|
||||
self._trace_magnitude_curves = {}
|
||||
self._trace_phase_curves = {}
|
||||
self._trace_phase_render_max_points = 400
|
||||
# Cap rendered points per trace (magnitude and phase alike): beyond ~1
|
||||
# point/pixel there is no visual gain, and decimation keeps pass-through
|
||||
# responsive with many combos on screen.
|
||||
self._trace_render_max_points = 800
|
||||
|
||||
self._plot_stack.addWidget(self._trace_plots_container)
|
||||
|
||||
|
||||
@@ -80,6 +80,13 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._show_phase_checkbox = QCheckBox("Show phase")
|
||||
owner._show_phase_checkbox.setChecked(bool(pass_defaults.show_phase))
|
||||
|
||||
owner._unwrap_phase_checkbox = QCheckBox("Unwrap phase")
|
||||
owner._unwrap_phase_checkbox.setToolTip(
|
||||
"Plot the cumulative (unwrapped) phase instead of wrapping to ±180°; "
|
||||
"the phase axis auto-scales to the unwrapped range."
|
||||
)
|
||||
owner._unwrap_phase_checkbox.setChecked(bool(pass_defaults.unwrap_phase))
|
||||
|
||||
owner._pass_through_combo_filter_input = QLineEdit(str(pass_defaults.combo_filter))
|
||||
owner._pass_through_combo_filter_input.setPlaceholderText("empty = all, e.g. 0:0,1:0")
|
||||
|
||||
@@ -105,12 +112,13 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
[
|
||||
owner._show_magnitude_checkbox,
|
||||
owner._show_phase_checkbox,
|
||||
owner._unwrap_phase_checkbox,
|
||||
("Switch combos", owner._pass_through_combo_filter_input),
|
||||
owner._pass_through_fixed_y_enabled,
|
||||
("Y min dB", owner._pass_through_y_min_db),
|
||||
("Y max dB", owner._pass_through_y_max_db),
|
||||
],
|
||||
split_index=4,
|
||||
split_index=5,
|
||||
)
|
||||
owner._processing_mode_pages.addWidget(pass_through_page)
|
||||
|
||||
@@ -492,6 +500,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed)
|
||||
owner._show_magnitude_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
||||
owner._show_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
||||
owner._unwrap_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
||||
owner._pass_through_combo_filter_input.editingFinished.connect(owner._on_trace_visibility_changed)
|
||||
owner._pass_through_fixed_y_enabled.toggled.connect(owner._sync_pass_through_y_controls)
|
||||
owner._pass_through_fixed_y_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Kamil ADC reference-channel acquisition.
|
||||
|
||||
Layered into small, single-responsibility modules:
|
||||
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.protocol` — TTY wire format → aligned
|
||||
:class:`RawSweep` (pure, incremental parser).
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.processing` — reference-phase
|
||||
frequency axis, amplitude normalization, crop + resample to a fixed grid (pure).
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.tty_reader` — background thread that
|
||||
drains the TTY and publishes the latest sweep.
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.service` — collector process lifecycle
|
||||
and ``acquire() -> SweepResult``.
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.laser` — pre-acquisition laser setup.
|
||||
"""
|
||||
|
||||
from python_app.hardware_full.kamil_adc.laser import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.kamil_adc.processing import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc.protocol import (
|
||||
KamilAdcStreamParser,
|
||||
RawSweep,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc.service import KamilAdcService
|
||||
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
|
||||
|
||||
__all__ = [
|
||||
"KamilAdcProcessingParams",
|
||||
"KamilAdcService",
|
||||
"KamilAdcStreamParser",
|
||||
"KamilAdcSweepProcessor",
|
||||
"KamilAdcTtyReader",
|
||||
"RawSweep",
|
||||
"apply_kamil_adc_laser_control",
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Laser-controller setup applied before Kamil ADC acquisition.
|
||||
|
||||
This mirrors the legacy ``device_main`` command sequence: connect to the laser
|
||||
controller, reset it, and apply either manual or variation mode per
|
||||
``radar.laser_control``. The wire protocol is unchanged from the standalone tool;
|
||||
only its home moved into the project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
|
||||
"""Apply configured laser settings via the device_main command sequence.
|
||||
|
||||
Returns ``True`` when settings were applied, ``False`` when laser control is
|
||||
disabled. The controller is always disconnected before returning.
|
||||
"""
|
||||
laser = config.radar.laser_control
|
||||
if not laser.enabled:
|
||||
logger.debug("Kamil ADC laser control disabled; skipping")
|
||||
return False
|
||||
|
||||
_validate_laser_control_config(config)
|
||||
|
||||
from python_app.hardware_full.laser_control.controller import (
|
||||
DEVICE_MAIN_MESSAGE_ID,
|
||||
LaserController,
|
||||
)
|
||||
from python_app.hardware_full.laser_control.models import VariationType
|
||||
|
||||
controller = LaserController(
|
||||
port=laser.port,
|
||||
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()
|
||||
controller.reset()
|
||||
mode = laser.mode.strip().lower()
|
||||
logger.info("Applying Kamil ADC laser control in %s mode", mode)
|
||||
if mode == "manual":
|
||||
manual = laser.manual
|
||||
controller.set_manual_mode(
|
||||
temp1=manual.temp1,
|
||||
temp2=manual.temp2,
|
||||
current1=manual.current1,
|
||||
current2=manual.current2,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
)
|
||||
return True
|
||||
if mode == "variation":
|
||||
variation = laser.variation
|
||||
try:
|
||||
variation_type = VariationType[variation.variation_type]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
|
||||
) from exc
|
||||
|
||||
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=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,
|
||||
},
|
||||
)
|
||||
return True
|
||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
||||
finally:
|
||||
controller.disconnect()
|
||||
|
||||
|
||||
def _validate_laser_control_config(config: RunConfigModel) -> None:
|
||||
laser = config.radar.laser_control
|
||||
if not laser.port:
|
||||
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode not in {"manual", "variation"}:
|
||||
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
|
||||
if mode == "variation" and not laser.variation.variation_type:
|
||||
raise ValueError("radar.laser_control.variation.variation_type is required")
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Signal processing for the Kamil ADC reference-channel acquisition.
|
||||
|
||||
Each sweep arrives as two aligned complex arrays — the *main* signal and a
|
||||
*reference* signal — sampled at the same step indices (see
|
||||
:mod:`python_app.hardware_full.kamil_adc.protocol`). Turning that into a stable,
|
||||
comparable S21 trace is a fixed three-stage pipeline:
|
||||
|
||||
1. **Frequency axis from the reference phase.** The reference arm has a constant
|
||||
electrical delay, so its unwrapped phase is an affine function of frequency.
|
||||
Two fixed calibration anchors ``(phase0, freq0)`` and ``(phase1, freq1)`` —
|
||||
supplied by config, *never* derived from the live sweep — define that law::
|
||||
|
||||
f(phase) = freq0 + (phase - phase0) * (freq1 - freq0) / (phase1 - phase0)
|
||||
|
||||
Trigger jitter shifts every sample's absolute phase together, so the measured
|
||||
band floats from sweep to sweep around the fixed calibration.
|
||||
|
||||
2. **Amplitude normalization.** ``S = main / |reference|`` divides out the
|
||||
stimulus amplitude. Only the magnitude is removed; the reference phase is used
|
||||
solely for the axis above.
|
||||
|
||||
3. **Crop + resample onto a fixed grid.** The floating per-sweep axis is resampled
|
||||
(linear, on real and imaginary parts) onto a single hardcoded grid
|
||||
``linspace(band_start, band_stop, band_points)``. Every sweep then shares a
|
||||
byte-identical frequency axis, so traces can be averaged and subtracted. A
|
||||
sweep whose floated range does not fully span the band is *rejected* rather
|
||||
than edge-extrapolated.
|
||||
|
||||
Everything here is pure and free of I/O so it can be unit-tested in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.run_config_schema import KamilAdcModel
|
||||
|
||||
# A reference sample whose magnitude falls to (effectively) zero carries no usable
|
||||
# amplitude or phase; such points are dropped before normalization rather than
|
||||
# producing a division blow-up. The threshold only guards genuine zeros — real
|
||||
# reference frames are emitted from settled measurements and sit far above it.
|
||||
_REFERENCE_AMPLITUDE_FLOOR = 1e-9
|
||||
|
||||
# Linear interpolation onto the band needs at least two distinct source samples;
|
||||
# a sweep yielding fewer usable points is malformed and rejected.
|
||||
_MIN_USABLE_POINTS = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KamilAdcProcessingParams:
|
||||
"""Immutable phase→frequency calibration and output-grid definition.
|
||||
|
||||
Built from :class:`~python_app.models.run_config_schema.KamilAdcModel` via
|
||||
:meth:`from_kamil_model`, but kept decoupled from the config types so the
|
||||
processing can be exercised with plain numbers in tests.
|
||||
"""
|
||||
|
||||
phase0_rad: float
|
||||
freq0_hz: float
|
||||
phase1_rad: float
|
||||
freq1_hz: float
|
||||
band_start_hz: float
|
||||
band_stop_hz: float
|
||||
band_points: int
|
||||
reference_amplitude_floor: float = _REFERENCE_AMPLITUDE_FLOOR
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not np.isfinite([self.phase0_rad, self.phase1_rad, self.freq0_hz, self.freq1_hz]).all():
|
||||
raise ValueError("Kamil ADC phase calibration anchors must be finite")
|
||||
if self.phase0_rad == self.phase1_rad:
|
||||
raise ValueError("Kamil ADC phase calibration anchors must use distinct phases")
|
||||
if self.freq0_hz == self.freq1_hz:
|
||||
raise ValueError("Kamil ADC phase calibration anchors must map to distinct frequencies")
|
||||
if not (np.isfinite(self.band_start_hz) and np.isfinite(self.band_stop_hz)):
|
||||
raise ValueError("Kamil ADC band edges must be finite")
|
||||
if self.band_stop_hz <= self.band_start_hz:
|
||||
raise ValueError("Kamil ADC band stop_hz must be greater than start_hz")
|
||||
if self.band_points < 2:
|
||||
raise ValueError("Kamil ADC band points must be >= 2")
|
||||
if self.reference_amplitude_floor <= 0.0:
|
||||
raise ValueError("Kamil ADC reference amplitude floor must be > 0")
|
||||
|
||||
@classmethod
|
||||
def from_kamil_model(cls, kamil_adc: KamilAdcModel) -> KamilAdcProcessingParams:
|
||||
"""Build parameters from the ``radar.kamil_adc`` config section."""
|
||||
calibration = kamil_adc.phase_calibration
|
||||
band = kamil_adc.band
|
||||
return cls(
|
||||
phase0_rad=float(calibration.phase0_rad),
|
||||
freq0_hz=float(calibration.freq0_hz),
|
||||
phase1_rad=float(calibration.phase1_rad),
|
||||
freq1_hz=float(calibration.freq1_hz),
|
||||
band_start_hz=float(band.start_hz),
|
||||
band_stop_hz=float(band.stop_hz),
|
||||
band_points=int(band.points),
|
||||
)
|
||||
|
||||
@property
|
||||
def hz_per_rad(self) -> float:
|
||||
"""Frequency change per radian of reference phase (the calibration slope)."""
|
||||
return (self.freq1_hz - self.freq0_hz) / (self.phase1_rad - self.phase0_rad)
|
||||
|
||||
|
||||
class KamilAdcSweepProcessor:
|
||||
"""Turns aligned (main, reference) sweeps into S21 traces on a fixed grid.
|
||||
|
||||
The output grid is computed once from the parameters and reused for every
|
||||
sweep, so all traces this processor emits share one identical frequency axis.
|
||||
"""
|
||||
|
||||
__slots__ = ("_params", "_grid_hz")
|
||||
|
||||
def __init__(self, params: KamilAdcProcessingParams) -> None:
|
||||
self._params = params
|
||||
self._grid_hz = np.linspace(
|
||||
params.band_start_hz, params.band_stop_hz, params.band_points, dtype=np.float64
|
||||
)
|
||||
|
||||
@property
|
||||
def params(self) -> KamilAdcProcessingParams:
|
||||
return self._params
|
||||
|
||||
@property
|
||||
def grid_hz(self) -> np.ndarray:
|
||||
"""The fixed output frequency axis (float32), identical for every sweep."""
|
||||
return self._grid_hz.astype(np.float32)
|
||||
|
||||
def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray:
|
||||
"""Map a reference signal's absolute unwrapped phase to frequency (Hz).
|
||||
|
||||
Returns frequencies in *step order* (not sorted); see the module docstring
|
||||
for the calibration law.
|
||||
"""
|
||||
phase = np.unwrap(np.angle(np.asarray(reference)))
|
||||
return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad
|
||||
|
||||
def process(self, main: np.ndarray, reference: np.ndarray) -> np.ndarray | None:
|
||||
"""Return the S21 trace resampled onto the fixed grid, or ``None`` to reject.
|
||||
|
||||
``main`` and ``reference`` are equal-length complex arrays ordered by
|
||||
ascending step index. ``None`` is returned when the sweep is malformed
|
||||
(too few usable points) or does not fully cover the configured band.
|
||||
"""
|
||||
main = np.asarray(main, dtype=np.complex128)
|
||||
reference = np.asarray(reference, dtype=np.complex128)
|
||||
if main.size < _MIN_USABLE_POINTS or main.size != reference.size:
|
||||
return None
|
||||
|
||||
# Frequency axis from the absolute unwrapped reference phase (step order).
|
||||
freqs = self.reference_frequency_axis(reference)
|
||||
|
||||
# Amplitude-only normalization; drop points where the reference vanished.
|
||||
reference_amplitude = np.abs(reference)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
s21 = main / reference_amplitude
|
||||
usable = (
|
||||
(reference_amplitude > self._params.reference_amplitude_floor)
|
||||
& np.isfinite(freqs)
|
||||
& np.isfinite(s21.real)
|
||||
& np.isfinite(s21.imag)
|
||||
)
|
||||
if int(np.count_nonzero(usable)) < _MIN_USABLE_POINTS:
|
||||
return None
|
||||
|
||||
freqs = freqs[usable]
|
||||
s21 = s21[usable]
|
||||
|
||||
# Sort onto a monotonically increasing axis (handles either sweep
|
||||
# direction) so interpolation and the coverage check are well defined.
|
||||
order = np.argsort(freqs, kind="stable")
|
||||
freqs = freqs[order]
|
||||
s21 = s21[order]
|
||||
|
||||
# Reject sweeps that do not span the whole band: interpolating past the
|
||||
# measured edge would inject flat, non-physical points.
|
||||
if freqs[0] > self._params.band_start_hz or freqs[-1] < self._params.band_stop_hz:
|
||||
return None
|
||||
|
||||
# Linear interpolation on real/imaginary parts. Because the band lies
|
||||
# within [freqs[0], freqs[-1]], np.interp never extrapolates here.
|
||||
real = np.interp(self._grid_hz, freqs, s21.real)
|
||||
imag = np.interp(self._grid_hz, freqs, s21.imag)
|
||||
return (real + 1j * imag).astype(np.complex64)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Wire protocol for the Kamil ADC collector's TTY stream.
|
||||
|
||||
The collector publishes a stream of 8-byte little-endian frames, four 16-bit
|
||||
words each: ``[marker, step, ch1, ch2]`` (``ch1``/``ch2`` signed). Three frame
|
||||
kinds appear in ``do8_freq_ref`` mode:
|
||||
|
||||
* **Sweep boundary** — ``marker, 0xFFFF, 0xFFFF, 0xFFFF``. Delimits sweeps.
|
||||
* **Main point** — ``0x000A, step, I, Q``. One complex main sample at ``step``.
|
||||
* **Reference point** — ``0x00A8, step, I, Q``. The reference sample paired with
|
||||
the main sample of the same ``step`` (emitted only where the DI8 loopback
|
||||
settled, so reference points are sparser than main points).
|
||||
|
||||
:class:`KamilAdcStreamParser` consumes raw bytes incrementally and yields one
|
||||
:class:`RawSweep` per completed sweep. Main and reference points are aligned by
|
||||
step index; only steps carrying *both* survive (a step needs its reference for
|
||||
the frequency axis and its main for the signal). Anything other than the three
|
||||
known frame kinds is a protocol violation and raises — the reader fails fast and
|
||||
lets the supervisor relaunch a clean collector rather than silently resyncing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Wire-format constants.
|
||||
FRAME_BYTES = 8
|
||||
MAIN_MARKER = 0x000A
|
||||
REFERENCE_MARKER = 0x00A8
|
||||
_BOUNDARY_STEP = 0xFFFF
|
||||
|
||||
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
||||
_FRAME = struct.Struct("<HHhh")
|
||||
# The last three words of a boundary frame are all 0xFFFF; a real step is
|
||||
# 1..0xFFFE, so this tail unambiguously marks a sweep boundary regardless of the
|
||||
# (profile-dependent) start marker.
|
||||
_BOUNDARY_TAIL = b"\xff\xff\xff\xff\xff\xff"
|
||||
# Frame-alignment anchor: the phase-profile sweep-boundary frame.
|
||||
_START_FRAME = struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RawSweep:
|
||||
"""One sweep's main and reference samples, aligned and ordered by step.
|
||||
|
||||
``steps`` is strictly ascending; ``main`` and ``reference`` are the complex
|
||||
samples at those steps. All three arrays share the same length.
|
||||
"""
|
||||
|
||||
steps: np.ndarray
|
||||
main: np.ndarray
|
||||
reference: np.ndarray
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return int(self.steps.size)
|
||||
|
||||
|
||||
class KamilAdcStreamParser:
|
||||
"""Incremental, frame-aligning parser turning the TTY byte stream into sweeps.
|
||||
|
||||
Usage: call :meth:`feed` with each chunk of bytes; it returns the list of
|
||||
sweeps completed by that chunk (usually zero or one). The parser is stateful
|
||||
but holds no I/O and is cheap to unit-test.
|
||||
"""
|
||||
|
||||
__slots__ = ("_buffer", "_aligned", "_main", "_reference")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
self._aligned = False
|
||||
self._main: dict[int, complex] = {}
|
||||
self._reference: dict[int, complex] = {}
|
||||
|
||||
def feed(self, data: bytes) -> list[RawSweep]:
|
||||
"""Append ``data`` and return any sweeps completed by it."""
|
||||
self._buffer.extend(data)
|
||||
if not self._aligned and not self._align():
|
||||
return []
|
||||
|
||||
sweeps: list[RawSweep] = []
|
||||
buffer = self._buffer
|
||||
while len(buffer) >= FRAME_BYTES:
|
||||
frame = bytes(buffer[:FRAME_BYTES])
|
||||
del buffer[:FRAME_BYTES]
|
||||
if frame[2:] == _BOUNDARY_TAIL:
|
||||
sweep = self._take_sweep()
|
||||
if sweep is not None:
|
||||
sweeps.append(sweep)
|
||||
continue
|
||||
marker, step, real, imag = _FRAME.unpack(frame)
|
||||
if marker == MAIN_MARKER:
|
||||
self._main[step] = complex(real, imag)
|
||||
elif marker == REFERENCE_MARKER:
|
||||
self._reference[step] = complex(real, imag)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
||||
)
|
||||
return sweeps
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop all buffered state (e.g. after the collector is relaunched)."""
|
||||
self._buffer.clear()
|
||||
self._aligned = False
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
|
||||
def _align(self) -> bool:
|
||||
"""Discard pre-roll up to and including the first sweep boundary.
|
||||
|
||||
Returns ``True`` once frame-aligned. Keeps a short tail so a boundary
|
||||
split across two feeds can still be found on the next chunk.
|
||||
"""
|
||||
index = self._buffer.find(_START_FRAME)
|
||||
if index < 0:
|
||||
if len(self._buffer) >= FRAME_BYTES:
|
||||
del self._buffer[: -(FRAME_BYTES - 1)]
|
||||
return False
|
||||
del self._buffer[: index + FRAME_BYTES]
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
self._aligned = True
|
||||
return True
|
||||
|
||||
def _take_sweep(self) -> RawSweep | None:
|
||||
"""Assemble the buffered points into a sweep and reset for the next one."""
|
||||
shared = sorted(self._main.keys() & self._reference.keys())
|
||||
main = self._main
|
||||
reference = self._reference
|
||||
self._main = {}
|
||||
self._reference = {}
|
||||
if not shared:
|
||||
return None
|
||||
return RawSweep(
|
||||
steps=np.asarray(shared, dtype=np.int32),
|
||||
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
|
||||
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
||||
)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Service that launches the Kamil ADC collector and serves processed sweeps.
|
||||
|
||||
Owns the lifecycle of the external collector process and its TTY reader, and maps
|
||||
each raw (main, reference) sweep to an :class:`SweepResult` on the fixed
|
||||
processing grid via :class:`KamilAdcSweepProcessor`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc.processing import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc.protocol import RawSweep
|
||||
from python_app.hardware_full.kamil_adc.tty_reader import (
|
||||
KamilAdcTtyReader,
|
||||
raise_if_process_exited,
|
||||
)
|
||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Project root, used to resolve relative collector paths (e.g.
|
||||
# ``build/bin/kamil_adc_collector``) independent of the launching CWD.
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
# Default home of the proprietary L-Card runtime libraries the collector loads
|
||||
# via dlopen. Prepended to LD_LIBRARY_PATH unless the config pins it explicitly.
|
||||
_DEFAULT_LCARD_LIB_DIR = "~/.local/lib"
|
||||
# Rejected-sweep logging is throttled so a persistently mis-set band/calibration
|
||||
# does not flood the log: log the first rejection, then every Nth.
|
||||
_REJECT_LOG_EVERY = 50
|
||||
# The collector's graceful X502 teardown can block, so a stop gives it only this
|
||||
# brief window to release the device cleanly before escalating to SIGKILL. Caps
|
||||
# the configured stop_timeout_s so a stop can never hang.
|
||||
_STOP_KILL_GRACE_S = 0.5
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcService:
|
||||
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
|
||||
|
||||
config: RunConfigModel
|
||||
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
||||
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
||||
_processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False)
|
||||
_rejected_count: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_config()
|
||||
|
||||
@property
|
||||
def command(self) -> list[str]:
|
||||
"""External collector command, including the generated ``tty:`` argument."""
|
||||
adc = self.config.radar.kamil_adc
|
||||
return [str(self._resolve_executable()), *adc.args, f"tty:{adc.tty_path}"]
|
||||
|
||||
def open(self, *, stop_event: threading.Event | None = None) -> None:
|
||||
"""Launch the collector and start the TTY reader thread.
|
||||
|
||||
An optional ``stop_event`` lets a caller abort the TTY-wait loop promptly
|
||||
(e.g. on shutdown) instead of blocking for the full startup timeout.
|
||||
"""
|
||||
if self._reader is not None:
|
||||
return
|
||||
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path)
|
||||
try:
|
||||
self._start_process()
|
||||
self._wait_for_tty(previous_tty_identity, stop_event=stop_event)
|
||||
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
|
||||
reader.open()
|
||||
self._reader = reader
|
||||
logger.info("Kamil ADC service opened")
|
||||
except Exception:
|
||||
logger.exception("Kamil ADC service failed to open; cleaning up")
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the TTY reader and the external collector process."""
|
||||
logger.debug("Closing Kamil ADC service")
|
||||
if self._reader is not None:
|
||||
with suppress(Exception):
|
||||
self._reader.close()
|
||||
self._reader = None
|
||||
self._stop_process()
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Build the sweep processor from the ``radar.kamil_adc`` calibration/band.
|
||||
|
||||
The generic ``sweep`` argument is accepted for interface parity with the
|
||||
other radar services but is not used: the Kamil ADC frequency axis comes
|
||||
from the reference-phase calibration, and the output grid from
|
||||
``radar.kamil_adc.band`` — never from the nominal sweep bounds.
|
||||
"""
|
||||
self._processor = KamilAdcSweepProcessor(
|
||||
KamilAdcProcessingParams.from_kamil_model(self.config.radar.kamil_adc)
|
||||
)
|
||||
self._rejected_count = 0
|
||||
params = self._processor.params
|
||||
logger.debug(
|
||||
"Kamil ADC configured: band %.6g-%.6g Hz, %d points; calibration "
|
||||
"(%.6g rad -> %.6g Hz, %.6g rad -> %.6g Hz)",
|
||||
params.band_start_hz, params.band_stop_hz, params.band_points,
|
||||
params.phase0_rad, params.freq0_hz, params.phase1_rad, params.freq1_hz,
|
||||
)
|
||||
|
||||
def read_device_limits(self) -> dict[str, float | int]:
|
||||
"""Kamil ADC has no runtime-readable sweep-limit API."""
|
||||
raise RuntimeError("Kamil ADC device limits are not available")
|
||||
|
||||
def acquire(self) -> SweepResult:
|
||||
"""Return the next sweep that covers the band, as S21 on the fixed grid.
|
||||
|
||||
Sweeps whose floated frequency range does not span the configured band are
|
||||
rejected and the next sweep is read, until one passes or the sweep timeout
|
||||
elapses (which then surfaces as a :class:`TimeoutError`).
|
||||
"""
|
||||
if self._processor is None:
|
||||
raise RuntimeError("Kamil ADC service is not configured")
|
||||
if self._reader is None:
|
||||
raise RuntimeError("Kamil ADC service is not open")
|
||||
process = self._process
|
||||
if process is None or process.poll() is not None:
|
||||
return_code = None if process is None else process.poll()
|
||||
raise RuntimeError(f"Kamil ADC collector is not running (code={return_code})")
|
||||
|
||||
grid = self._processor.grid_hz
|
||||
points = int(grid.size)
|
||||
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
|
||||
while True:
|
||||
remaining_s = deadline - time.monotonic()
|
||||
if remaining_s <= 0.0:
|
||||
raise TimeoutError(
|
||||
"Timed out waiting for a Kamil ADC sweep covering the configured band"
|
||||
)
|
||||
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
|
||||
s21 = self._processor.process(raw.main, raw.reference)
|
||||
if s21 is not None:
|
||||
return SweepResult(
|
||||
x=grid.copy(),
|
||||
traces={
|
||||
"s11": np.zeros(points, dtype=np.complex64),
|
||||
"s21": s21,
|
||||
},
|
||||
)
|
||||
self._log_rejected_sweep(raw)
|
||||
|
||||
def read_raw_sweep(self) -> RawSweep:
|
||||
"""Return the next raw (main, reference) sweep without any processing.
|
||||
|
||||
Bypasses the frequency mapping, normalization, crop and resample of
|
||||
:meth:`acquire` — intended for calibration tooling that needs the
|
||||
unprocessed reference samples. Raises if the service is not open.
|
||||
"""
|
||||
if self._reader is None:
|
||||
raise RuntimeError("Kamil ADC service is not open")
|
||||
return self._reader.read_sweep(
|
||||
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
|
||||
process=self._process,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Process / TTY lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_process(self) -> None:
|
||||
if self._process is not None and self._process.poll() is None:
|
||||
return
|
||||
logger.info("Starting Kamil ADC collector: %s", " ".join(self.command))
|
||||
self._process = subprocess.Popen(
|
||||
self.command,
|
||||
cwd=str(self._resolve_project_dir()),
|
||||
env=self._build_env(),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
def _stop_process(self) -> None:
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is None or process.poll() is not None:
|
||||
return
|
||||
logger.info("Stopping Kamil ADC collector (pid=%d)", process.pid)
|
||||
# The collector's graceful X502 teardown can block, so give it only a brief
|
||||
# window to release the device cleanly, then SIGKILL the whole group hard.
|
||||
grace_s = min(self.config.radar.kamil_adc.stop_timeout_s, _STOP_KILL_GRACE_S)
|
||||
with suppress(ProcessLookupError):
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=grace_s)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
logger.warning("Kamil ADC collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s)
|
||||
with suppress(ProcessLookupError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
# close() must never raise: a collector wedged in uninterruptible I/O
|
||||
# (USB D-state in the L-Card driver) may not be reaped within the grace
|
||||
# window even after SIGKILL. Best-effort wait; the OS reaps it eventually.
|
||||
with suppress(subprocess.TimeoutExpired):
|
||||
process.wait(timeout=1.0)
|
||||
|
||||
def _wait_for_tty(
|
||||
self,
|
||||
previous_identity: tuple[object, ...] | None,
|
||||
*,
|
||||
stop_event: threading.Event | None = None,
|
||||
) -> None:
|
||||
adc = self.config.radar.kamil_adc
|
||||
deadline = time.monotonic() + adc.startup_timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
|
||||
raise_if_process_exited(self._process)
|
||||
identity = _tty_identity(adc.tty_path)
|
||||
if identity is not None and identity != previous_identity:
|
||||
return
|
||||
if stop_event is not None:
|
||||
if stop_event.wait(0.05):
|
||||
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path / environment resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_executable(self) -> Path:
|
||||
return self._resolve_path(self.config.radar.kamil_adc.executable_path)
|
||||
|
||||
def _resolve_project_dir(self) -> Path:
|
||||
project_dir = self.config.radar.kamil_adc.project_dir
|
||||
return self._resolve_path(project_dir) if project_dir else _REPO_ROOT
|
||||
|
||||
@staticmethod
|
||||
def _resolve_path(path_str: str) -> Path:
|
||||
"""Expand ``~`` and resolve a relative path against the project root."""
|
||||
path = Path(path_str).expanduser()
|
||||
return path if path.is_absolute() else (_REPO_ROOT / path)
|
||||
|
||||
def _build_env(self) -> dict[str, str]:
|
||||
"""Child environment: inherited env + config env, with the L-Card lib path.
|
||||
|
||||
Config ``env`` values are ``~``/``$VAR`` expanded. Unless the config pins
|
||||
``LD_LIBRARY_PATH`` itself, the default L-Card library directory is
|
||||
prepended so the collector's dlopen of libx502api/libe502api succeeds.
|
||||
"""
|
||||
adc = self.config.radar.kamil_adc
|
||||
env = os.environ.copy()
|
||||
for key, value in adc.env.items():
|
||||
env[key] = os.path.expandvars(os.path.expanduser(value))
|
||||
if "LD_LIBRARY_PATH" not in adc.env:
|
||||
lcard_dir = os.path.expanduser(_DEFAULT_LCARD_LIB_DIR)
|
||||
existing = env.get("LD_LIBRARY_PATH", "")
|
||||
env["LD_LIBRARY_PATH"] = f"{lcard_dir}{os.pathsep}{existing}" if existing else lcard_dir
|
||||
return env
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation / diagnostics
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
if not self.config.is_kamil_adc:
|
||||
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
|
||||
if self.config.radar.driver_mode != "native":
|
||||
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
|
||||
|
||||
adc = self.config.radar.kamil_adc
|
||||
if not adc.executable_path:
|
||||
raise ValueError("radar.kamil_adc.executable_path is required")
|
||||
if not adc.tty_path:
|
||||
raise ValueError("radar.kamil_adc.tty_path is required")
|
||||
if any(arg.startswith("tty:") for arg in adc.args):
|
||||
raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
|
||||
for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"):
|
||||
if getattr(adc, name) <= 0.0:
|
||||
raise ValueError(f"radar.kamil_adc.{name} must be > 0")
|
||||
|
||||
project_dir = self._resolve_project_dir()
|
||||
if not project_dir.is_dir():
|
||||
raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}")
|
||||
executable_path = self._resolve_executable()
|
||||
if not executable_path.is_file():
|
||||
raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}")
|
||||
if not os.access(executable_path, os.X_OK):
|
||||
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")
|
||||
|
||||
# Surface a malformed calibration/band at open time, not mid-acquisition.
|
||||
KamilAdcProcessingParams.from_kamil_model(adc)
|
||||
|
||||
def _log_rejected_sweep(self, raw) -> None:
|
||||
"""Log a band-coverage rejection (throttled) with the measured span."""
|
||||
self._rejected_count += 1
|
||||
if self._rejected_count != 1 and self._rejected_count % _REJECT_LOG_EVERY != 0:
|
||||
return
|
||||
params = self._processor.params # type: ignore[union-attr]
|
||||
try:
|
||||
freqs = self._processor.reference_frequency_axis(raw.reference) # type: ignore[union-attr]
|
||||
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
|
||||
except Exception: # noqa: BLE001 — diagnostics must never raise
|
||||
covered = "<unavailable>"
|
||||
logger.warning(
|
||||
"Kamil ADC sweep rejected (count=%d): covered %s Hz does not span band "
|
||||
"[%.6g, %.6g] Hz (usable points=%d). Check phase_calibration/band.",
|
||||
self._rejected_count, covered, params.band_start_hz, params.band_stop_hz, raw.size,
|
||||
)
|
||||
|
||||
|
||||
def _tty_identity(path: str) -> tuple[object, ...] | None:
|
||||
try:
|
||||
if os.path.islink(path):
|
||||
stat_result = os.lstat(path)
|
||||
return (
|
||||
"link",
|
||||
os.readlink(path),
|
||||
int(stat_result.st_dev),
|
||||
int(stat_result.st_ino),
|
||||
int(stat_result.st_mtime_ns),
|
||||
)
|
||||
stat_result = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
return (
|
||||
"node",
|
||||
int(stat_result.st_dev),
|
||||
int(stat_result.st_ino),
|
||||
int(stat_result.st_mtime_ns),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None:
|
||||
"""Remove a stale generated TTY symlink/file before starting the collector."""
|
||||
try:
|
||||
stat_result = os.lstat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode):
|
||||
os.unlink(path)
|
||||
return None
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Background TTY reader publishing the latest completed Kamil ADC sweep.
|
||||
|
||||
The collector emits sweeps continuously, faster than callers invoke
|
||||
:meth:`KamilAdcService.acquire`. A daemon thread drains the device end of the
|
||||
TTY non-stop, feeds the bytes to a :class:`KamilAdcStreamParser`, and stores the
|
||||
most recent :class:`RawSweep` in a single-slot mailbox. :meth:`read_sweep`
|
||||
returns the freshest sweep; if a newer one arrives before the consumer reads, it
|
||||
overwrites the previous unread value — by design, since consumers always want the
|
||||
latest data. Parser/stream errors are captured and re-raised on the consumer
|
||||
thread (fail-fast; the supervisor relaunches a clean collector).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from python_app.hardware_full.kamil_adc.protocol import KamilAdcStreamParser, RawSweep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Large reads keep up with bursty CDC-ACM/PTY writers without raising the syscall
|
||||
# rate; 64 KiB matches the typical Linux PTY buffer size.
|
||||
_READ_CHUNK_BYTES = 65536
|
||||
# select() poll interval — short enough to react to close() promptly, long enough
|
||||
# that idle CPU stays near zero.
|
||||
_READ_POLL_INTERVAL_S = 0.1
|
||||
|
||||
|
||||
def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
|
||||
"""Raise if the external collector process has exited."""
|
||||
if process is None:
|
||||
return
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
raise RuntimeError(f"Kamil ADC collector exited with code {return_code}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcTtyReader:
|
||||
"""Daemon-thread TTY reader publishing the latest completed sweep."""
|
||||
|
||||
tty_path: str
|
||||
_fd: int | None = field(init=False, default=None, repr=False)
|
||||
_thread: threading.Thread | None = field(init=False, default=None, repr=False)
|
||||
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
|
||||
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
|
||||
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
|
||||
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
||||
_published_count: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open the TTY and start the background reader thread."""
|
||||
if self._fd is not None:
|
||||
return
|
||||
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
||||
self._stop_event.clear()
|
||||
self._latest_sweep = None
|
||||
self._reader_error = None
|
||||
self._published_count = 0
|
||||
self._thread = threading.Thread(
|
||||
target=self._reader_loop,
|
||||
name=f"kamil-adc-tty-reader[{self.tty_path}]",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info("Kamil ADC TTY reader started on %s", self.tty_path)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the reader thread and close the TTY descriptor."""
|
||||
logger.debug("Stopping Kamil ADC TTY reader on %s", self.tty_path)
|
||||
self._stop_event.set()
|
||||
with self._mailbox_cv:
|
||||
self._mailbox_cv.notify_all()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=1.0)
|
||||
if self._thread.is_alive():
|
||||
logger.warning("Kamil ADC reader thread did not stop within 1.0s")
|
||||
self._thread = None
|
||||
if self._fd is not None:
|
||||
try:
|
||||
os.close(self._fd)
|
||||
finally:
|
||||
self._fd = None
|
||||
self._latest_sweep = None
|
||||
self._reader_error = None
|
||||
|
||||
@property
|
||||
def published_count(self) -> int:
|
||||
"""Total number of sweeps the reader thread has produced."""
|
||||
with self._mailbox_cv:
|
||||
return self._published_count
|
||||
|
||||
def read_sweep(
|
||||
self,
|
||||
*,
|
||||
timeout_s: float,
|
||||
process: subprocess.Popen[bytes] | None = None,
|
||||
) -> RawSweep:
|
||||
"""Wait for and return the next published sweep.
|
||||
|
||||
Raises :class:`TimeoutError` if none arrives within ``timeout_s``,
|
||||
:class:`RuntimeError` if the collector process exited, and re-raises any
|
||||
error caught by the reader thread.
|
||||
"""
|
||||
if self._thread is None:
|
||||
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||
deadline = time.monotonic() + float(timeout_s)
|
||||
with self._mailbox_cv:
|
||||
while True:
|
||||
# Deliver a pending sweep first: if the reader both published a
|
||||
# sweep and then died, the consumer still sees the good data and
|
||||
# only meets the error on the next call.
|
||||
if self._latest_sweep is not None:
|
||||
sweep = self._latest_sweep
|
||||
self._latest_sweep = None
|
||||
return sweep
|
||||
if self._reader_error is not None:
|
||||
raise self._reader_error
|
||||
raise_if_process_exited(process)
|
||||
remaining_s = deadline - time.monotonic()
|
||||
if remaining_s <= 0.0:
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC sweep after {float(timeout_s):.3f}s"
|
||||
)
|
||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reader-thread internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _reader_loop(self) -> None:
|
||||
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
|
||||
parser = KamilAdcStreamParser()
|
||||
try:
|
||||
while not self._stop_event.is_set():
|
||||
chunk = self._read_available()
|
||||
if not chunk:
|
||||
continue
|
||||
for sweep in parser.feed(chunk):
|
||||
self._publish_sweep(sweep)
|
||||
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
|
||||
logger.exception("Kamil ADC reader thread failed on %s", self.tty_path)
|
||||
self._publish_error(exc)
|
||||
|
||||
def _read_available(self) -> bytes:
|
||||
"""Block on ``select`` up to the poll interval; return new bytes (maybe empty).
|
||||
|
||||
Returns ``b""`` when no data is ready yet or a stop was requested; raises
|
||||
:class:`RuntimeError` on stream close or an unrecoverable read error.
|
||||
"""
|
||||
fd = self._fd
|
||||
if fd is None or self._stop_event.is_set():
|
||||
return b""
|
||||
try:
|
||||
readable, _, _ = select.select([fd], [], [], _READ_POLL_INTERVAL_S)
|
||||
except InterruptedError:
|
||||
return b""
|
||||
if not readable:
|
||||
return b""
|
||||
try:
|
||||
chunk = os.read(fd, _READ_CHUNK_BYTES)
|
||||
except BlockingIOError:
|
||||
return b""
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
||||
return b""
|
||||
raise RuntimeError(f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
|
||||
if not chunk:
|
||||
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
|
||||
return chunk
|
||||
|
||||
def _publish_sweep(self, sweep: RawSweep) -> None:
|
||||
"""Store ``sweep`` as the latest mailbox value, overwriting any unread one."""
|
||||
with self._mailbox_cv:
|
||||
self._latest_sweep = sweep
|
||||
self._published_count += 1
|
||||
self._mailbox_cv.notify()
|
||||
|
||||
def _publish_error(self, exc: Exception) -> None:
|
||||
"""Record ``exc`` as the reader fault and wake any waiter."""
|
||||
with self._mailbox_cv:
|
||||
self._reader_error = exc
|
||||
self._mailbox_cv.notify_all()
|
||||
@@ -1,639 +0,0 @@
|
||||
"""Service for acquiring sweeps from the external Kamil ADC collector.
|
||||
|
||||
The external `kamil_adc` binary publishes its samples on a PTY/TTY device as a
|
||||
stream of 8-byte frames:
|
||||
|
||||
* **Start marker**: `0x000A 0xFFFF 0xFFFF 0xFFFF` — delimits sweep boundaries.
|
||||
* **Point frame**: `0x000A step real_i16 imag_i16` — one complex sample per
|
||||
frame, with `step` running 1, 2, …, N for an N-point sweep.
|
||||
|
||||
The hardware emits sweeps continuously, faster than callers tend to invoke
|
||||
:meth:`KamilAdcService.acquire`. To avoid TTY-buffer overruns and stale data,
|
||||
a daemon thread drains the device end of the TTY non-stop, parses complete
|
||||
sweeps as they arrive, and publishes the **latest** one to a one-slot mailbox.
|
||||
:meth:`acquire` simply waits for the next sweep to appear in that mailbox.
|
||||
|
||||
Sweep length is determined by the first sweep observed at runtime and stays
|
||||
constant for the life of the service; any later mismatch is treated as a
|
||||
protocol violation rather than something to silently discard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Wire-format constants for the Kamil ADC TTY protocol.
|
||||
KAMIL_ADC_MARKER = 0x000A
|
||||
KAMIL_ADC_START_STEP = 0xFFFF
|
||||
KAMIL_ADC_FRAME_BYTES = 8
|
||||
|
||||
_START_FRAME: bytes = struct.pack(
|
||||
"<HHHH", KAMIL_ADC_MARKER, KAMIL_ADC_START_STEP, KAMIL_ADC_START_STEP, KAMIL_ADC_START_STEP
|
||||
)
|
||||
# Point frames carry signed 16-bit real/imag components; start markers reuse
|
||||
# the same 8-byte slot but with all four words unsigned. Comparing the raw
|
||||
# bytes against :data:`_START_FRAME` is therefore the correct boundary check.
|
||||
_POINT_STRUCT = struct.Struct("<HHhh")
|
||||
|
||||
# Larger TTY reads keep up with bursty USB CDC-ACM writers without raising the
|
||||
# syscall rate. 64 KiB matches the typical Linux PTY buffer size.
|
||||
_READ_CHUNK_BYTES = 65536
|
||||
# select() poll interval inside the reader thread — short enough to react to
|
||||
# `close()` requests, long enough that idle CPU stays near zero.
|
||||
_READ_POLL_INTERVAL_S = 0.1
|
||||
|
||||
|
||||
def _parse_point_frame(frame: bytes, expected_step: int) -> complex:
|
||||
"""Parse one 8-byte point frame; validate marker and step ordering."""
|
||||
marker, step, real, imag = _POINT_STRUCT.unpack(frame)
|
||||
if marker != KAMIL_ADC_MARKER:
|
||||
raise ValueError(f"Kamil ADC marker mismatch: got 0x{marker:04x}, expected 0x000a")
|
||||
if step != expected_step:
|
||||
raise ValueError(f"Kamil ADC step mismatch: got {step}, expected {expected_step}")
|
||||
return complex(real, imag)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcTtyReader:
|
||||
"""Background-thread TTY reader publishing the latest completed sweep.
|
||||
|
||||
The reader spawns a daemon thread on :meth:`open` which continuously
|
||||
drains the TTY, parses frames into complete sweeps, and stores the most
|
||||
recent one in a single-slot mailbox. Consumers call :meth:`read_sweep` to
|
||||
take that sweep; if a newer one arrives before the consumer reads, it
|
||||
overwrites the previous unread value — by design, since consumers always
|
||||
want the freshest data.
|
||||
"""
|
||||
|
||||
tty_path: str
|
||||
_fd: int | None = field(init=False, default=None, repr=False)
|
||||
_thread: threading.Thread | None = field(init=False, default=None, repr=False)
|
||||
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
|
||||
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
|
||||
_latest_sweep: np.ndarray | None = field(init=False, default=None, repr=False)
|
||||
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
||||
_locked_points: int | None = field(init=False, default=None, repr=False)
|
||||
_published_count: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open the TTY and start the background reader thread."""
|
||||
if self._fd is not None:
|
||||
return
|
||||
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
||||
self._stop_event.clear()
|
||||
self._latest_sweep = None
|
||||
self._reader_error = None
|
||||
self._locked_points = None
|
||||
self._published_count = 0
|
||||
self._thread = threading.Thread(
|
||||
target=self._reader_loop,
|
||||
name=f"kamil-adc-tty-reader[{self.tty_path}]",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info("Kamil ADC TTY reader started on %s", self.tty_path)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the reader thread and close the TTY descriptor."""
|
||||
logger.debug("Stopping Kamil ADC TTY reader on %s", self.tty_path)
|
||||
self._stop_event.set()
|
||||
with self._mailbox_cv:
|
||||
self._mailbox_cv.notify_all()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=1.0)
|
||||
if self._thread.is_alive():
|
||||
logger.warning("Kamil ADC reader thread did not stop within 1.0s")
|
||||
self._thread = None
|
||||
if self._fd is not None:
|
||||
try:
|
||||
os.close(self._fd)
|
||||
finally:
|
||||
self._fd = None
|
||||
self._latest_sweep = None
|
||||
self._reader_error = None
|
||||
self._locked_points = None
|
||||
|
||||
@property
|
||||
def locked_points(self) -> int | None:
|
||||
"""Return the sweep point count established by the first sweep, or `None`."""
|
||||
return self._locked_points
|
||||
|
||||
@property
|
||||
def published_count(self) -> int:
|
||||
"""Return the total number of sweeps the reader thread has produced."""
|
||||
with self._mailbox_cv:
|
||||
return self._published_count
|
||||
|
||||
def read_sweep(
|
||||
self,
|
||||
*,
|
||||
timeout_s: float,
|
||||
process: subprocess.Popen[bytes] | None = None,
|
||||
) -> np.ndarray:
|
||||
"""Wait for and return the next published sweep.
|
||||
|
||||
Raises :class:`TimeoutError` if no sweep arrives within `timeout_s`,
|
||||
:class:`RuntimeError` if the external collector process exited, and
|
||||
propagates any exception caught by the reader thread.
|
||||
"""
|
||||
if self._thread is None:
|
||||
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||
deadline = time.monotonic() + float(timeout_s)
|
||||
with self._mailbox_cv:
|
||||
while True:
|
||||
# Always deliver a pending sweep first: if the reader thread
|
||||
# both published a sweep and then died, the consumer should
|
||||
# still see the good data and only meet the error on the next
|
||||
# call.
|
||||
if self._latest_sweep is not None:
|
||||
sweep = self._latest_sweep
|
||||
self._latest_sweep = None
|
||||
return sweep
|
||||
if self._reader_error is not None:
|
||||
raise self._reader_error
|
||||
self._raise_if_process_exited(process)
|
||||
remaining_s = deadline - time.monotonic()
|
||||
if remaining_s <= 0.0:
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC sweep after {float(timeout_s):.3f}s"
|
||||
)
|
||||
# Wake periodically so we can re-check process liveness.
|
||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reader-thread internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _reader_loop(self) -> None:
|
||||
"""Drain the TTY, parse frames, and publish completed sweeps until stop.
|
||||
|
||||
Runs on the background reader thread. Any exception is logged and stored
|
||||
so the next :meth:`read_sweep` re-raises it on the consumer thread.
|
||||
"""
|
||||
buffer = bytearray()
|
||||
try:
|
||||
if not self._skip_to_first_start_marker(buffer):
|
||||
return
|
||||
while not self._stop_event.is_set():
|
||||
sweep = self._read_one_sweep(buffer)
|
||||
if sweep is None:
|
||||
return
|
||||
self._publish_sweep(sweep)
|
||||
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
|
||||
logger.exception("Kamil ADC reader thread failed on %s", self.tty_path)
|
||||
self._publish_error(exc)
|
||||
|
||||
def _skip_to_first_start_marker(self, buffer: bytearray) -> bool:
|
||||
"""Discard pre-roll bytes until a start marker is consumed from `buffer`."""
|
||||
while not self._stop_event.is_set():
|
||||
start_index = buffer.find(_START_FRAME)
|
||||
if start_index >= 0:
|
||||
del buffer[: start_index + KAMIL_ADC_FRAME_BYTES]
|
||||
return True
|
||||
# Keep just enough trailing bytes that a marker split across read
|
||||
# boundaries can still be reassembled on the next chunk.
|
||||
if len(buffer) >= KAMIL_ADC_FRAME_BYTES:
|
||||
del buffer[: -(KAMIL_ADC_FRAME_BYTES - 1)]
|
||||
if not self._read_more(buffer):
|
||||
return False
|
||||
return False
|
||||
|
||||
def _read_one_sweep(self, buffer: bytearray) -> np.ndarray | None:
|
||||
"""Parse frames from `buffer` until the next start marker; return the sweep."""
|
||||
values: list[complex] = []
|
||||
expected_step = 1
|
||||
while not self._stop_event.is_set():
|
||||
while len(buffer) < KAMIL_ADC_FRAME_BYTES:
|
||||
if not self._read_more(buffer):
|
||||
return None
|
||||
frame = bytes(buffer[:KAMIL_ADC_FRAME_BYTES])
|
||||
del buffer[:KAMIL_ADC_FRAME_BYTES]
|
||||
|
||||
if frame == _START_FRAME:
|
||||
if not values:
|
||||
# Two consecutive markers — ignore the empty sweep and keep parsing.
|
||||
continue
|
||||
self._validate_and_lock_point_count(len(values))
|
||||
return np.asarray(values, dtype=np.complex64)
|
||||
|
||||
if self._locked_points is not None and expected_step > self._locked_points:
|
||||
raise RuntimeError(
|
||||
f"Kamil ADC sweep exceeded locked point count {self._locked_points} "
|
||||
"without a start marker"
|
||||
)
|
||||
values.append(_parse_point_frame(frame, expected_step))
|
||||
expected_step += 1
|
||||
return None
|
||||
|
||||
def _validate_and_lock_point_count(self, points: int) -> None:
|
||||
"""Lock the point count on the first sweep; reject mismatches thereafter."""
|
||||
if self._locked_points is None:
|
||||
self._locked_points = points
|
||||
logger.info("Kamil ADC sweep point count locked to %d", points)
|
||||
return
|
||||
if points != self._locked_points:
|
||||
raise RuntimeError(
|
||||
f"Kamil ADC sweep length changed: locked={self._locked_points}, got={points}"
|
||||
)
|
||||
|
||||
def _read_more(self, buffer: bytearray) -> bool:
|
||||
"""Block on `select` until bytes arrive, then append them to `buffer`.
|
||||
|
||||
Returns `False` if the reader was asked to stop, `True` if at least one
|
||||
byte was appended. Raises on stream-level errors.
|
||||
"""
|
||||
fd = self._fd
|
||||
if fd is None:
|
||||
return False
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
readable, _, _ = select.select([fd], [], [], _READ_POLL_INTERVAL_S)
|
||||
except InterruptedError:
|
||||
continue
|
||||
if not readable:
|
||||
continue
|
||||
try:
|
||||
chunk = os.read(fd, _READ_CHUNK_BYTES)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}"
|
||||
) from exc
|
||||
if not chunk:
|
||||
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
|
||||
buffer.extend(chunk)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _publish_sweep(self, sweep: np.ndarray) -> None:
|
||||
"""Store `sweep` as the latest mailbox value, overwriting any prior unread one."""
|
||||
with self._mailbox_cv:
|
||||
self._latest_sweep = sweep
|
||||
self._published_count += 1
|
||||
self._mailbox_cv.notify()
|
||||
|
||||
def _publish_error(self, exc: Exception) -> None:
|
||||
"""Record `exc` as the reader fault and wake any waiter."""
|
||||
with self._mailbox_cv:
|
||||
self._reader_error = exc
|
||||
self._mailbox_cv.notify_all()
|
||||
|
||||
@staticmethod
|
||||
def _raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
|
||||
if process is None:
|
||||
return
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
raise RuntimeError(f"Kamil ADC process exited with code {return_code}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcService:
|
||||
"""Launch the external `kamil_adc` collector and serve its sweeps."""
|
||||
|
||||
config: RunConfigModel
|
||||
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
||||
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
||||
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
|
||||
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_config()
|
||||
|
||||
@property
|
||||
def command(self) -> list[str]:
|
||||
"""Return external collector command including the generated TTY argument."""
|
||||
adc = self.config.radar.kamil_adc
|
||||
executable_path = str(Path(adc.executable_path).expanduser())
|
||||
return [executable_path, *adc.args, f"tty:{adc.tty_path}"]
|
||||
|
||||
def open(self, *, stop_event: threading.Event | None = None) -> None:
|
||||
"""Launch the collector and start the TTY reader thread.
|
||||
|
||||
An optional `stop_event` lets a caller abort the TTY-wait loop promptly
|
||||
(e.g. on shutdown) instead of blocking for the full startup timeout.
|
||||
"""
|
||||
if self._reader is not None:
|
||||
return
|
||||
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path)
|
||||
try:
|
||||
self._start_process()
|
||||
self._wait_for_tty(previous_tty_identity, stop_event=stop_event)
|
||||
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
|
||||
reader.open()
|
||||
self._reader = reader
|
||||
logger.info("Kamil ADC service opened")
|
||||
except Exception:
|
||||
logger.exception("Kamil ADC service failed to open; cleaning up")
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the TTY reader and the external collector process."""
|
||||
logger.debug("Closing Kamil ADC service")
|
||||
if self._reader is not None:
|
||||
with suppress(Exception):
|
||||
self._reader.close()
|
||||
self._reader = None
|
||||
self._stop_process()
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Store sweep settings used to build the synthetic frequency axis."""
|
||||
self._validate_sweep(sweep)
|
||||
self._settings = sweep
|
||||
self._frequency_hz = None
|
||||
logger.debug(
|
||||
"Kamil ADC configured: frequency axis %s-%s Hz", sweep.start_hz, sweep.stop_hz
|
||||
)
|
||||
|
||||
def read_device_limits(self) -> dict[str, float | int]:
|
||||
"""Kamil ADC has no runtime-readable sweep limit API."""
|
||||
raise RuntimeError("Kamil ADC device limits are not available")
|
||||
|
||||
def acquire(self) -> SweepResult:
|
||||
"""Return the most recent completed sweep as S21 (S11 filled with zeros)."""
|
||||
if self._settings is None:
|
||||
raise RuntimeError("Kamil ADC service is not configured")
|
||||
if self._reader is None:
|
||||
raise RuntimeError("Kamil ADC service is not open")
|
||||
process = self._process
|
||||
if process is None or process.poll() is not None:
|
||||
return_code = None if process is None else process.poll()
|
||||
raise RuntimeError(f"Kamil ADC process is not running (code={return_code})")
|
||||
|
||||
s21 = self._reader.read_sweep(
|
||||
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
|
||||
process=process,
|
||||
)
|
||||
points = int(s21.size)
|
||||
if self._frequency_hz is None or self._frequency_hz.size != points:
|
||||
logger.debug("Building Kamil ADC frequency axis for %d points", points)
|
||||
self._frequency_hz = self._build_frequency_axis(points)
|
||||
return SweepResult(
|
||||
x=self._frequency_hz.copy(),
|
||||
traces={
|
||||
"s11": np.zeros(points, dtype=np.complex64),
|
||||
"s21": s21,
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Process / TTY lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_process(self) -> None:
|
||||
if self._process is not None and self._process.poll() is None:
|
||||
return
|
||||
adc = self.config.radar.kamil_adc
|
||||
env = os.environ.copy()
|
||||
env.update(adc.env)
|
||||
logger.info("Starting Kamil ADC collector: %s", " ".join(self.command))
|
||||
self._process = subprocess.Popen(
|
||||
self.command,
|
||||
cwd=str(Path(adc.project_dir).expanduser()),
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
def _stop_process(self) -> None:
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is None or process.poll() is not None:
|
||||
return
|
||||
logger.info("Stopping Kamil ADC collector (pid=%d)", process.pid)
|
||||
with suppress(ProcessLookupError):
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=self.config.radar.kamil_adc.stop_timeout_s)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
logger.warning(
|
||||
"Kamil ADC collector (pid=%d) ignored SIGTERM; sending SIGKILL", process.pid
|
||||
)
|
||||
with suppress(ProcessLookupError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait(timeout=1.0)
|
||||
|
||||
def _wait_for_tty(
|
||||
self,
|
||||
previous_identity: tuple[object, ...] | None,
|
||||
*,
|
||||
stop_event: threading.Event | None = None,
|
||||
) -> None:
|
||||
adc = self.config.radar.kamil_adc
|
||||
deadline = time.monotonic() + adc.startup_timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
# Abort promptly if a stop was requested mid-wait.
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
|
||||
KamilAdcTtyReader._raise_if_process_exited(self._process)
|
||||
identity = _tty_identity(adc.tty_path)
|
||||
if identity is not None and identity != previous_identity:
|
||||
return
|
||||
# Use the stop event's wait() so a set() breaks the poll immediately.
|
||||
if stop_event is not None:
|
||||
if stop_event.wait(0.05):
|
||||
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
if not self.config.is_kamil_adc:
|
||||
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
|
||||
if self.config.radar.driver_mode != "native":
|
||||
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
|
||||
|
||||
adc = self.config.radar.kamil_adc
|
||||
if not adc.project_dir:
|
||||
raise ValueError("radar.kamil_adc.project_dir is required")
|
||||
if not adc.executable_path:
|
||||
raise ValueError("radar.kamil_adc.executable_path is required")
|
||||
if not adc.tty_path:
|
||||
raise ValueError("radar.kamil_adc.tty_path is required")
|
||||
if any(arg.startswith("tty:") for arg in adc.args):
|
||||
raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
|
||||
if adc.startup_timeout_s <= 0.0:
|
||||
raise ValueError("radar.kamil_adc.startup_timeout_s must be > 0")
|
||||
if adc.sweep_timeout_s <= 0.0:
|
||||
raise ValueError("radar.kamil_adc.sweep_timeout_s must be > 0")
|
||||
if adc.stop_timeout_s <= 0.0:
|
||||
raise ValueError("radar.kamil_adc.stop_timeout_s must be > 0")
|
||||
|
||||
project_dir = Path(adc.project_dir).expanduser()
|
||||
if not project_dir.is_dir():
|
||||
raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}")
|
||||
executable_path = Path(adc.executable_path).expanduser()
|
||||
if not executable_path.is_file():
|
||||
raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}")
|
||||
if not os.access(executable_path, os.X_OK):
|
||||
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_sweep(sweep: RadarSweepModel) -> None:
|
||||
if float(sweep.stop_hz) < float(sweep.start_hz):
|
||||
raise ValueError("Kamil ADC sweep stop_hz must be >= start_hz")
|
||||
|
||||
def _build_frequency_axis(self, points: int) -> np.ndarray:
|
||||
if self._settings is None:
|
||||
raise RuntimeError("Kamil ADC service is not configured")
|
||||
return np.linspace(
|
||||
float(self._settings.start_hz),
|
||||
float(self._settings.stop_hz),
|
||||
int(points),
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
|
||||
def _tty_identity(path: str) -> tuple[object, ...] | None:
|
||||
try:
|
||||
if os.path.islink(path):
|
||||
stat_result = os.lstat(path)
|
||||
return (
|
||||
"link",
|
||||
os.readlink(path),
|
||||
int(stat_result.st_dev),
|
||||
int(stat_result.st_ino),
|
||||
int(stat_result.st_mtime_ns),
|
||||
)
|
||||
stat_result = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
return (
|
||||
"node",
|
||||
int(stat_result.st_dev),
|
||||
int(stat_result.st_ino),
|
||||
int(stat_result.st_mtime_ns),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None:
|
||||
"""Remove stale generated TTY links before starting the external collector."""
|
||||
try:
|
||||
stat_result = os.lstat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode):
|
||||
os.unlink(path)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
|
||||
"""Apply the configured laser settings through the legacy device_main command sequence.
|
||||
|
||||
Connects to the laser controller, resets it, and applies either manual or
|
||||
variation mode per ``radar.laser_control``. Returns `True` when settings were
|
||||
applied, `False` when laser control is disabled. The controller is always
|
||||
disconnected before returning.
|
||||
"""
|
||||
laser = config.radar.laser_control
|
||||
if not laser.enabled:
|
||||
logger.debug("Kamil ADC laser control disabled; skipping")
|
||||
return False
|
||||
|
||||
_validate_laser_control_config(config)
|
||||
|
||||
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
|
||||
from python_app.hardware_full.laser_control.models import VariationType
|
||||
|
||||
controller = LaserController(
|
||||
port=laser.port,
|
||||
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()
|
||||
controller.reset()
|
||||
mode = laser.mode.strip().lower()
|
||||
logger.info("Applying Kamil ADC laser control in %s mode", mode)
|
||||
if mode == "manual":
|
||||
manual = laser.manual
|
||||
controller.set_manual_mode(
|
||||
temp1=manual.temp1,
|
||||
temp2=manual.temp2,
|
||||
current1=manual.current1,
|
||||
current2=manual.current2,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
)
|
||||
return True
|
||||
if mode == "variation":
|
||||
variation = laser.variation
|
||||
try:
|
||||
variation_type = VariationType[variation.variation_type]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
|
||||
) from exc
|
||||
|
||||
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=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,
|
||||
},
|
||||
)
|
||||
return True
|
||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
||||
finally:
|
||||
controller.disconnect()
|
||||
|
||||
|
||||
def _validate_laser_control_config(config: RunConfigModel) -> None:
|
||||
laser = config.radar.laser_control
|
||||
if not laser.port:
|
||||
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode not in {"manual", "variation"}:
|
||||
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
|
||||
if mode == "variation" and not laser.variation.variation_type:
|
||||
raise ValueError("radar.laser_control.variation.variation_type is required")
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
from python_app.hardware_full.kamil_adc_service import KamilAdcService
|
||||
from python_app.hardware_full.kamil_adc import KamilAdcService
|
||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.remote_compact_m_k209_service import RemoteCompactMK209Service
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_app.hardware_full.switch_drivers import (
|
||||
H7992Driver,
|
||||
@@ -11,6 +12,9 @@ from python_app.hardware_full.switch_drivers import (
|
||||
SwitchDriverProtocol,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_app.models.run_config_schema import SwitchModel
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SwitchService:
|
||||
@@ -31,6 +35,25 @@ class SwitchService:
|
||||
"""Create underlying driver based on configured mode and type."""
|
||||
self._driver = self._build_driver()
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, model: SwitchModel) -> SwitchService:
|
||||
"""Build a switch service from a ``SwitchModel`` config section.
|
||||
|
||||
The single construction path shared by every acquisition producer and
|
||||
capture workflow, so all devices drive switches identically.
|
||||
"""
|
||||
return cls(
|
||||
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,
|
||||
)
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open underlying switch driver resources."""
|
||||
self._driver.open()
|
||||
|
||||
@@ -185,6 +185,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
gui.processing.pass_through.show_phase,
|
||||
"gui.processing.pass_through",
|
||||
),
|
||||
unwrap_phase=_optional_bool(
|
||||
pass_through_object,
|
||||
"unwrap_phase",
|
||||
gui.processing.pass_through.unwrap_phase,
|
||||
"gui.processing.pass_through",
|
||||
),
|
||||
combo_filter=_optional_string(
|
||||
pass_through_object,
|
||||
"combo_filter",
|
||||
@@ -521,6 +527,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
||||
"pass_through": {
|
||||
"show_magnitude": gui.processing.pass_through.show_magnitude,
|
||||
"show_phase": gui.processing.pass_through.show_phase,
|
||||
"unwrap_phase": gui.processing.pass_through.unwrap_phase,
|
||||
"combo_filter": gui.processing.pass_through.combo_filter,
|
||||
"fixed_y_enabled": gui.processing.pass_through.fixed_y_enabled,
|
||||
"y_min_db": gui.processing.pass_through.y_min_db,
|
||||
|
||||
@@ -30,6 +30,7 @@ class GuiPassThroughStateModel:
|
||||
|
||||
show_magnitude: bool = True
|
||||
show_phase: bool = True
|
||||
unwrap_phase: bool = False
|
||||
combo_filter: str = ""
|
||||
fixed_y_enabled: bool = False
|
||||
y_min_db: float = -100.0
|
||||
|
||||
@@ -239,6 +239,19 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
model.radar.kamil_adc.stop_timeout_s = _read_float(
|
||||
kamil_adc_payload, "stop_timeout_s", model.radar.kamil_adc.stop_timeout_s
|
||||
)
|
||||
phase_calibration_payload = _as_dict(
|
||||
kamil_adc_payload.get("phase_calibration"), "radar.kamil_adc.phase_calibration"
|
||||
)
|
||||
calibration = model.radar.kamil_adc.phase_calibration
|
||||
calibration.phase0_rad = _read_float(phase_calibration_payload, "phase0_rad", calibration.phase0_rad)
|
||||
calibration.freq0_hz = _read_float(phase_calibration_payload, "freq0_hz", calibration.freq0_hz)
|
||||
calibration.phase1_rad = _read_float(phase_calibration_payload, "phase1_rad", calibration.phase1_rad)
|
||||
calibration.freq1_hz = _read_float(phase_calibration_payload, "freq1_hz", calibration.freq1_hz)
|
||||
band_payload = _as_dict(kamil_adc_payload.get("band"), "radar.kamil_adc.band")
|
||||
band = model.radar.kamil_adc.band
|
||||
band.start_hz = _read_float(band_payload, "start_hz", band.start_hz)
|
||||
band.stop_hz = _read_float(band_payload, "stop_hz", band.stop_hz)
|
||||
band.points = _read_int(band_payload, "points", band.points)
|
||||
|
||||
model.radar.laser_control.enabled = _read_bool(
|
||||
laser_control_payload, "enabled", model.radar.laser_control.enabled
|
||||
@@ -490,6 +503,17 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"startup_timeout_s": model.radar.kamil_adc.startup_timeout_s,
|
||||
"sweep_timeout_s": model.radar.kamil_adc.sweep_timeout_s,
|
||||
"stop_timeout_s": model.radar.kamil_adc.stop_timeout_s,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": model.radar.kamil_adc.phase_calibration.phase0_rad,
|
||||
"freq0_hz": model.radar.kamil_adc.phase_calibration.freq0_hz,
|
||||
"phase1_rad": model.radar.kamil_adc.phase_calibration.phase1_rad,
|
||||
"freq1_hz": model.radar.kamil_adc.phase_calibration.freq1_hz,
|
||||
},
|
||||
"band": {
|
||||
"start_hz": model.radar.kamil_adc.band.start_hz,
|
||||
"stop_hz": model.radar.kamil_adc.band.stop_hz,
|
||||
"points": model.radar.kamil_adc.band.points,
|
||||
},
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": model.radar.laser_control.enabled,
|
||||
|
||||
@@ -6,7 +6,9 @@ from python_app.models.run_config_schema import (
|
||||
GprModel,
|
||||
GprRxGeometryModel,
|
||||
GprTxGeometryModel,
|
||||
KamilAdcBandModel,
|
||||
KamilAdcModel,
|
||||
KamilAdcPhaseCalibrationModel,
|
||||
LaserControlModel,
|
||||
LaserManualModeModel,
|
||||
LaserVariationModeModel,
|
||||
@@ -37,7 +39,9 @@ __all__ = [
|
||||
"GprModel",
|
||||
"GprRxGeometryModel",
|
||||
"GprTxGeometryModel",
|
||||
"KamilAdcBandModel",
|
||||
"KamilAdcModel",
|
||||
"KamilAdcPhaseCalibrationModel",
|
||||
"LaserControlModel",
|
||||
"LaserManualModeModel",
|
||||
"LaserVariationModeModel",
|
||||
|
||||
@@ -42,6 +42,38 @@ class RadarMultiDeviceModel:
|
||||
recovery_attempts: int = 3
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcPhaseCalibrationModel:
|
||||
"""Affine law mapping the reference signal's unwrapped phase to frequency.
|
||||
|
||||
Two fixed anchor points ``(phase0_rad, freq0_hz)`` and ``(phase1_rad, freq1_hz)``
|
||||
define ``f(phase) = freq0_hz + (phase - phase0_rad) * (freq1_hz - freq0_hz)
|
||||
/ (phase1_rad - phase0_rad)``, applied to the *absolute* unwrapped phase of
|
||||
every sweep. These are physical constants of the reference arm and must be
|
||||
supplied by config — never derived from a live sweep.
|
||||
"""
|
||||
|
||||
phase0_rad: float = 0.0
|
||||
freq0_hz: float = 2_046_000_000.0
|
||||
phase1_rad: float = 300.0
|
||||
freq1_hz: float = 5_612_000_000.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcBandModel:
|
||||
"""Fixed frequency window every sweep is cropped to and resampled onto.
|
||||
|
||||
Each sweep is resampled onto ``linspace(start_hz, stop_hz, points)`` so all
|
||||
sweeps share one identical axis and can be averaged/subtracted. The window
|
||||
must lie inside the (floating) range each sweep actually covers; sweeps that
|
||||
fail to cover it are rejected rather than edge-extrapolated.
|
||||
"""
|
||||
|
||||
start_hz: float = 2_100_000_000.0
|
||||
stop_hz: float = 5_500_000_000.0
|
||||
points: int = 2048
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcModel:
|
||||
"""External Kamil ADC acquisition process settings."""
|
||||
@@ -54,6 +86,10 @@ class KamilAdcModel:
|
||||
startup_timeout_s: float = 5.0
|
||||
sweep_timeout_s: float = 5.0
|
||||
stop_timeout_s: float = 2.0
|
||||
phase_calibration: KamilAdcPhaseCalibrationModel = field(
|
||||
default_factory=KamilAdcPhaseCalibrationModel
|
||||
)
|
||||
band: KamilAdcBandModel = field(default_factory=KamilAdcBandModel)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -23,6 +23,10 @@ logger = logging.getLogger(__name__)
|
||||
_LOG_MAX_BYTES = 8 * 1024 * 1024
|
||||
# Per-process force-kill deadline used on stop; each child gets its own window.
|
||||
_STOP_GRACE_SECONDS = 2.0
|
||||
# Cmdline marker for the Kamil ADC collector binary. The collector runs in its own
|
||||
# session, so it can outlive a force-killed acquisition producer while still holding
|
||||
# the L-Card E-502; we hunt it by name as a guaranteed device-release backstop.
|
||||
_KAMIL_COLLECTOR_MARKER = b"kamil_adc_collector"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -135,6 +139,9 @@ class ProcessSupervisor:
|
||||
"""Start required pipeline binaries and wait until they are ready."""
|
||||
if self.is_running():
|
||||
raise RuntimeError("Acquisition processes are already running")
|
||||
# Clean slate: an ADC collector orphaned by a prior crash/force-kill can
|
||||
# still hold the E-502 and make a fresh acquisition fail to open.
|
||||
self._kill_kamil_collectors()
|
||||
|
||||
logger.info(
|
||||
"Starting pipeline from config %s (allow_clean_orchestrator_exit=%s)",
|
||||
@@ -186,6 +193,7 @@ class ProcessSupervisor:
|
||||
def stop(self) -> None:
|
||||
"""Stop acquisition-side processes, keep processor process intact."""
|
||||
self._stop_processes(["sweep_orchestrator", "data_preprocessor"])
|
||||
self._kill_kamil_collectors()
|
||||
|
||||
def stop_orchestrator(self) -> None:
|
||||
"""Stop orchestrator process only."""
|
||||
@@ -198,6 +206,7 @@ class ProcessSupervisor:
|
||||
def stop_all(self) -> None:
|
||||
"""Stop all managed processes."""
|
||||
self._stop_processes(["sweep_orchestrator", "data_preprocessor", "data_processor"])
|
||||
self._kill_kamil_collectors()
|
||||
|
||||
def _spawn(self, name: str, command: list[str], *, allow_clean_exit: bool) -> None:
|
||||
"""Spawn one process unless same process is already alive.
|
||||
@@ -353,6 +362,32 @@ class ProcessSupervisor:
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
|
||||
def _kill_kamil_collectors(self) -> None:
|
||||
"""SIGKILL any Kamil ADC collector found by name — a guaranteed device backstop.
|
||||
|
||||
The collector survives a force-killed producer (own session) and can hang
|
||||
the E-502, so it is hunted by cmdline and its group killed hard, regardless
|
||||
of who its parent is. Best-effort and never raises.
|
||||
"""
|
||||
own_pid = os.getpid()
|
||||
try:
|
||||
pids = [int(entry.name) for entry in Path("/proc").iterdir() if entry.name.isdigit()]
|
||||
except OSError:
|
||||
return
|
||||
killed = 0
|
||||
for pid in pids:
|
||||
if pid <= 1 or pid == own_pid:
|
||||
continue
|
||||
try:
|
||||
cmdline = (Path("/proc") / str(pid) / "cmdline").read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
if _KAMIL_COLLECTOR_MARKER in cmdline:
|
||||
self._signal_group(pid, signal.SIGKILL)
|
||||
killed += 1
|
||||
if killed:
|
||||
logger.warning("Force-killed %d orphaned Kamil ADC collector process(es)", killed)
|
||||
|
||||
@staticmethod
|
||||
def _log_abnormal_stop_exit(process: ManagedProcess) -> None:
|
||||
"""Log a process that self-exited abnormally around stop time.
|
||||
@@ -523,6 +558,9 @@ class ProcessSupervisor:
|
||||
self._pidfile_path.write_text("", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
# The ADC collector is a grandchild (spawned by the producer), not in the
|
||||
# pidfile, so reap it separately by name.
|
||||
self._kill_kamil_collectors()
|
||||
|
||||
def _is_stale_pipeline_pid(self, pid: int) -> bool:
|
||||
"""Return whether `pid` still runs one of our pipeline binaries/scripts.
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -30,6 +30,22 @@ class GuiProfileCodecTest(unittest.TestCase):
|
||||
self.assertEqual(decoded.gui.processing.pass_through.combo_filter, "0:0,1:0")
|
||||
self.assertEqual(encoded["gui"]["processing"]["pass_through"]["combo_filter"], "0:0,1:0")
|
||||
|
||||
def test_pass_through_unwrap_phase_round_trips(self) -> None:
|
||||
profile = GuiProfileModel(
|
||||
gui=GuiStateModel(
|
||||
processing=GuiProcessingStateModel(
|
||||
pass_through=GuiPassThroughStateModel(unwrap_phase=True)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
encoded = profile.to_dict()
|
||||
decoded = GuiProfileModel.from_dict(encoded)
|
||||
|
||||
assert decoded.gui is not None
|
||||
self.assertTrue(decoded.gui.processing.pass_through.unwrap_phase)
|
||||
self.assertTrue(encoded["gui"]["processing"]["pass_through"]["unwrap_phase"])
|
||||
|
||||
def test_default_profile_round_trips_idempotently(self) -> None:
|
||||
# Full-subtree idempotence catches field-drop/mis-map regressions across every
|
||||
# sub-model, which the single combo_filter round-trip above cannot.
|
||||
|
||||
@@ -10,35 +10,26 @@ from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets
|
||||
|
||||
|
||||
def _kamil_config(*, points: int = 5) -> RunConfigModel:
|
||||
return RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"kamil_adc": {
|
||||
"band": {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": points},
|
||||
},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 2}},
|
||||
"run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class KamilAdcNeutralPreprocessTest(unittest.TestCase):
|
||||
def test_builds_passthrough_s21_sets_for_current_sweep(self) -> None:
|
||||
config = RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"sweep": {
|
||||
"start_hz": 1_000_000.0,
|
||||
"stop_hz": 4_000_000.0,
|
||||
"if_bandwidth_hz": 1.0,
|
||||
"stimulus_power_dbm": -10.0,
|
||||
},
|
||||
},
|
||||
"switches": {
|
||||
"port1": {"positions": 1},
|
||||
"port2": {"positions": 2},
|
||||
},
|
||||
"run": {
|
||||
"combos": [
|
||||
{"input": 0, "output": 0},
|
||||
{"input": 1, "output": 0},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_builds_passthrough_s21_sets_on_band_grid(self) -> None:
|
||||
calibration, reference = build_kamil_adc_neutral_s21_sets(_kamil_config(points=5))
|
||||
|
||||
calibration, reference = build_kamil_adc_neutral_s21_sets(config, point_count=4)
|
||||
|
||||
expected_frequency = np.linspace(1_000_000.0, 4_000_000.0, 4, dtype=np.float32)
|
||||
expected_frequency = np.linspace(2_100_000_000.0, 5_500_000_000.0, 5).astype(np.float32)
|
||||
self.assertEqual(len(calibration.traces), 2)
|
||||
self.assertEqual(len(reference.traces), 2)
|
||||
self.assertEqual(
|
||||
@@ -47,47 +38,35 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
|
||||
)
|
||||
for trace in calibration.traces:
|
||||
np.testing.assert_array_equal(trace.frequency_hz, expected_frequency)
|
||||
np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64))
|
||||
np.testing.assert_array_equal(trace.s21, np.ones(4, dtype=np.complex64))
|
||||
np.testing.assert_array_equal(trace.s11, np.zeros(5, dtype=np.complex64))
|
||||
np.testing.assert_array_equal(trace.s21, np.ones(5, dtype=np.complex64))
|
||||
for trace in reference.traces:
|
||||
np.testing.assert_array_equal(trace.frequency_hz, expected_frequency)
|
||||
np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64))
|
||||
np.testing.assert_array_equal(trace.s21, np.zeros(4, dtype=np.complex64))
|
||||
np.testing.assert_array_equal(trace.s21, np.zeros(5, dtype=np.complex64))
|
||||
|
||||
def test_grid_matches_processor_grid(self) -> None:
|
||||
"""The neutral axis must be byte-identical to the acquisition grid."""
|
||||
from python_app.hardware_full.kamil_adc import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
|
||||
config = _kamil_config(points=17)
|
||||
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(config)
|
||||
np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz)
|
||||
|
||||
def test_rejects_non_kamil_config(self) -> None:
|
||||
config = RunConfigModel.from_dict({"radar": {"model": "librevna"}})
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "kamil_adc"):
|
||||
build_kamil_adc_neutral_s21_sets(config, point_count=4)
|
||||
build_kamil_adc_neutral_s21_sets(config)
|
||||
|
||||
@staticmethod
|
||||
def _kamil_config() -> RunConfigModel:
|
||||
return RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"sweep": {"start_hz": 1_000_000.0, "stop_hz": 4_000_000.0,
|
||||
"if_bandwidth_hz": 1.0, "stimulus_power_dbm": -10.0},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 2}},
|
||||
"run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]},
|
||||
}
|
||||
)
|
||||
|
||||
def test_point_count_zero_or_negative_raises(self) -> None:
|
||||
config = self._kamil_config()
|
||||
for bad in (0, -1):
|
||||
with self.subTest(point_count=bad), self.assertRaisesRegex(ValueError, "point count"):
|
||||
build_kamil_adc_neutral_s21_sets(config, point_count=bad)
|
||||
|
||||
def test_single_point_sweep(self) -> None:
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=1)
|
||||
for trace in calibration.traces:
|
||||
self.assertEqual(trace.frequency_hz.tolist(), [1_000_000.0])
|
||||
self.assertEqual(trace.s21.shape, (1,))
|
||||
def test_rejects_degenerate_band(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
|
||||
build_kamil_adc_neutral_s21_sets(_kamil_config(points=1))
|
||||
|
||||
def test_dtypes_are_float32_and_complex64(self) -> None:
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(_kamil_config())
|
||||
trace = calibration.traces[0]
|
||||
self.assertEqual(trace.frequency_hz.dtype, np.float32)
|
||||
self.assertEqual(trace.s21.dtype, np.complex64)
|
||||
@@ -96,7 +75,7 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
|
||||
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
|
||||
# The C++ through-calibrator divides measured/calibration, so calibration S21
|
||||
# must never be zero — that is the whole point of the '1+0j neutral' contract.
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(_kamil_config())
|
||||
for trace in calibration.traces:
|
||||
self.assertTrue(bool(np.all(trace.s21 != 0)))
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests for the Kamil ADC reference-channel signal processing.
|
||||
|
||||
References are built from an explicit *unwrapped* phase ramp whose first sample
|
||||
lies in ``(-pi, pi]`` and whose steps are below ``pi``, so that
|
||||
``np.unwrap(np.angle(ref))`` recovers exactly the phase we specify. This mirrors
|
||||
how a real swept reference behaves (absolute phase anchored at sample 0,
|
||||
accumulating forward) and lets us reason precisely about the frequency mapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc.processing import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
|
||||
|
||||
def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
|
||||
"""A reference signal whose unwrapped phase equals ``phase`` (radians)."""
|
||||
return amplitude * np.exp(1j * np.asarray(phase, dtype=np.float64))
|
||||
|
||||
|
||||
# Calibration shared by the processor tests: phase 0 rad -> 2 GHz, phase 100 rad
|
||||
# -> 4 GHz (slope 2e7 Hz/rad). Band [2.5, 3.5] GHz corresponds to phase [25, 75].
|
||||
_CALIBRATION = dict(
|
||||
phase0_rad=0.0,
|
||||
freq0_hz=2_000_000_000.0,
|
||||
phase1_rad=100.0,
|
||||
freq1_hz=4_000_000_000.0,
|
||||
band_start_hz=2_500_000_000.0,
|
||||
band_stop_hz=3_500_000_000.0,
|
||||
band_points=11,
|
||||
)
|
||||
|
||||
|
||||
class ProcessingParamsTest(unittest.TestCase):
|
||||
def _params(self, **overrides: float) -> KamilAdcProcessingParams:
|
||||
return KamilAdcProcessingParams(**{**_CALIBRATION, **overrides}) # type: ignore[arg-type]
|
||||
|
||||
def test_slope_is_constant_from_anchors(self) -> None:
|
||||
self.assertAlmostEqual(self._params().hz_per_rad, 2.0e7)
|
||||
|
||||
def test_rejects_equal_phase_anchors(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "distinct phases"):
|
||||
self._params(phase0_rad=5.0, phase1_rad=5.0)
|
||||
|
||||
def test_rejects_equal_frequency_anchors(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "distinct frequencies"):
|
||||
self._params(freq0_hz=3.0e9, freq1_hz=3.0e9)
|
||||
|
||||
def test_rejects_inverted_band(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "stop_hz must be greater"):
|
||||
self._params(band_start_hz=4.0e9, band_stop_hz=3.0e9)
|
||||
|
||||
def test_rejects_degenerate_point_count(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
|
||||
self._params(band_points=1)
|
||||
|
||||
|
||||
class SweepProcessorTest(unittest.TestCase):
|
||||
def _processor(self, **overrides: float) -> KamilAdcSweepProcessor:
|
||||
return KamilAdcSweepProcessor(KamilAdcProcessingParams(**{**_CALIBRATION, **overrides})) # type: ignore[arg-type]
|
||||
|
||||
def test_grid_is_fixed_and_identical_across_calls(self) -> None:
|
||||
processor = self._processor()
|
||||
grid = processor.grid_hz
|
||||
self.assertEqual(grid.shape, (11,))
|
||||
self.assertAlmostEqual(float(grid[0]), 2.5e9)
|
||||
self.assertAlmostEqual(float(grid[-1]), 3.5e9)
|
||||
np.testing.assert_array_equal(grid, processor.grid_hz)
|
||||
|
||||
def test_frequency_axis_follows_calibration_law(self) -> None:
|
||||
processor = self._processor()
|
||||
# A dense ramp 0 -> 50 rad: endpoints map to 2 GHz and 3 GHz.
|
||||
ref = _reference(np.linspace(0.0, 50.0, 201))
|
||||
freqs = processor.reference_frequency_axis(ref)
|
||||
self.assertAlmostEqual(float(freqs[0]), 2.0e9, delta=1.0)
|
||||
self.assertAlmostEqual(float(freqs[-1]), 3.0e9, delta=1.0)
|
||||
|
||||
def test_uses_config_constants_not_sweep_endpoints(self) -> None:
|
||||
"""Two sweeps with different phase spans map a given absolute phase to the
|
||||
SAME frequency — proving fixed config anchors, not endpoint normalization."""
|
||||
processor = self._processor()
|
||||
ref_short = _reference(np.linspace(0.0, 100.0, 401)) # spans phase [0, 100]
|
||||
ref_long = _reference(np.linspace(0.0, 130.0, 521)) # spans phase [0, 130]
|
||||
# Encode S(f) = (f - 3 GHz) / 1 GHz, a line in TRUE frequency.
|
||||
line = lambda ref: ((processor.reference_frequency_axis(ref) - 3.0e9) / 1.0e9) * np.abs(ref)
|
||||
s_short = processor.process(line(ref_short), ref_short)
|
||||
s_long = processor.process(line(ref_long), ref_long)
|
||||
self.assertIsNotNone(s_short)
|
||||
self.assertIsNotNone(s_long)
|
||||
expected = (processor.grid_hz.astype(np.float64) - 3.0e9) / 1.0e9
|
||||
# Endpoint normalization would compress the longer sweep's axis and break this.
|
||||
np.testing.assert_allclose(s_short.real, expected, atol=1e-3)
|
||||
np.testing.assert_allclose(s_long.real, expected, atol=1e-3)
|
||||
np.testing.assert_allclose(s_short.imag, 0.0, atol=1e-3)
|
||||
|
||||
def test_amplitude_normalization_divides_by_reference_magnitude(self) -> None:
|
||||
processor = self._processor()
|
||||
ref = _reference(np.linspace(0.0, 100.0, 401), amplitude=4.0)
|
||||
# |main| = 8 everywhere -> |S| = 8 / 4 = 2.
|
||||
main = 8.0 * np.exp(1j * np.angle(ref))
|
||||
result = processor.process(main, ref)
|
||||
self.assertIsNotNone(result)
|
||||
np.testing.assert_allclose(np.abs(result), 2.0, atol=1e-3)
|
||||
|
||||
def test_rejects_sweep_that_does_not_cover_band(self) -> None:
|
||||
processor = self._processor()
|
||||
# Phase [0, 40] -> freq [2.0, 2.8] GHz, short of the 3.5 GHz band stop.
|
||||
ref = _reference(np.linspace(0.0, 40.0, 201))
|
||||
self.assertIsNone(processor.process(np.ones(201, dtype=np.complex128), ref))
|
||||
|
||||
def test_accepts_sweep_that_covers_band(self) -> None:
|
||||
processor = self._processor()
|
||||
ref = _reference(np.linspace(0.0, 100.0, 401)) # freq [2.0, 4.0] GHz
|
||||
result = processor.process(np.abs(ref).astype(np.complex128), ref)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.shape, (11,))
|
||||
self.assertEqual(result.dtype, np.complex64)
|
||||
np.testing.assert_allclose(np.abs(result), 1.0, atol=1e-3) # main=|ref| -> |S|=1
|
||||
|
||||
def test_interpolates_linear_trace_onto_grid(self) -> None:
|
||||
processor = self._processor()
|
||||
ref = _reference(np.linspace(0.0, 100.0, 401))
|
||||
freqs = processor.reference_frequency_axis(ref)
|
||||
# S(f) = (f - 2.5 GHz) / 1 GHz -> must resample to that same line on the grid.
|
||||
main = ((freqs - 2.5e9) / 1.0e9) * np.abs(ref)
|
||||
result = processor.process(main, ref)
|
||||
self.assertIsNotNone(result)
|
||||
expected = (processor.grid_hz.astype(np.float64) - 2.5e9) / 1.0e9
|
||||
np.testing.assert_allclose(result.real, expected, atol=1e-3)
|
||||
np.testing.assert_allclose(result.imag, 0.0, atol=1e-3)
|
||||
|
||||
def test_handles_descending_phase_direction(self) -> None:
|
||||
# phase 0 -> 2 GHz, phase -100 -> 4 GHz (negative slope). Phase ramp
|
||||
# 0 -> -100 therefore sweeps frequency UP across the band.
|
||||
processor = self._processor(phase1_rad=-100.0)
|
||||
ref = _reference(np.linspace(0.0, -100.0, 401))
|
||||
result = processor.process(np.abs(ref).astype(np.complex128), ref)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.shape, (11,))
|
||||
np.testing.assert_allclose(np.abs(result), 1.0, atol=1e-3)
|
||||
|
||||
def test_rejects_too_few_points(self) -> None:
|
||||
processor = self._processor()
|
||||
one = np.ones(1, dtype=np.complex128)
|
||||
self.assertIsNone(processor.process(one, one))
|
||||
|
||||
def test_rejects_length_mismatch(self) -> None:
|
||||
processor = self._processor()
|
||||
self.assertIsNone(
|
||||
processor.process(np.ones(10, dtype=np.complex128), np.ones(9, dtype=np.complex128))
|
||||
)
|
||||
|
||||
def test_drops_zero_amplitude_reference_points(self) -> None:
|
||||
processor = self._processor()
|
||||
ref = _reference(np.linspace(0.0, 100.0, 401))
|
||||
main = np.abs(ref).astype(np.complex128)
|
||||
ref[10] = 0.0 # vanished reference samples must be ignored, not crash
|
||||
ref[200] = 0.0
|
||||
result = processor.process(main, ref)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertTrue(np.all(np.isfinite(result.real)))
|
||||
self.assertTrue(np.all(np.isfinite(result.imag)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for the Kamil ADC TTY wire-protocol parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc.protocol import (
|
||||
MAIN_MARKER,
|
||||
REFERENCE_MARKER,
|
||||
KamilAdcStreamParser,
|
||||
)
|
||||
|
||||
|
||||
def _boundary() -> bytes:
|
||||
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
|
||||
|
||||
def _main(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
||||
|
||||
|
||||
def _reference(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
||||
|
||||
|
||||
class KamilAdcStreamParserTest(unittest.TestCase):
|
||||
def test_pairs_main_and_reference_by_step(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(1, 10, -1) + _reference(1, 100, 5)
|
||||
+ _main(2, 20, -2) + _reference(2, 200, 6)
|
||||
+ _boundary()
|
||||
)
|
||||
sweeps = parser.feed(stream)
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
sweep = sweeps[0]
|
||||
self.assertEqual(sweep.steps.tolist(), [1, 2])
|
||||
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(20, -2)])
|
||||
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
|
||||
|
||||
def test_keeps_only_steps_present_in_both_channels(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(1, 10, 0) # main only -> dropped
|
||||
+ _main(2, 20, 0) + _reference(2, 200, 0) # both -> kept
|
||||
+ _reference(3, 300, 0) # reference only -> dropped
|
||||
+ _boundary()
|
||||
)
|
||||
(sweep,) = parser.feed(stream)
|
||||
self.assertEqual(sweep.steps.tolist(), [2])
|
||||
self.assertEqual(sweep.main.tolist(), [complex(20, 0)])
|
||||
self.assertEqual(sweep.reference.tolist(), [complex(200, 0)])
|
||||
|
||||
def test_orders_by_ascending_step_regardless_of_arrival(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(3, 3, 0) + _reference(3, 30, 0)
|
||||
+ _main(1, 1, 0) + _reference(1, 10, 0)
|
||||
+ _main(2, 2, 0) + _reference(2, 20, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
(sweep,) = parser.feed(stream)
|
||||
self.assertEqual(sweep.steps.tolist(), [1, 2, 3])
|
||||
self.assertEqual(sweep.main.real.tolist(), [1, 2, 3])
|
||||
|
||||
def test_discards_preroll_before_first_boundary(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
# Garbage + a partial point before the first real boundary must be skipped.
|
||||
stream = (
|
||||
_main(7, 7, 7) # pre-roll point (no preceding boundary) -> ignored
|
||||
+ _boundary()
|
||||
+ _main(1, 11, 0) + _reference(1, 1, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
(sweep,) = parser.feed(stream)
|
||||
self.assertEqual(sweep.steps.tolist(), [1])
|
||||
|
||||
def test_handles_chunk_splits_across_frames(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(1, 10, 0) + _reference(1, 100, 0)
|
||||
+ _main(2, 20, 0) + _reference(2, 200, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
sweeps: list = []
|
||||
# Feed one byte at a time to exercise reassembly across feed() calls.
|
||||
for byte in stream:
|
||||
sweeps.extend(parser.feed(bytes([byte])))
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
self.assertEqual(sweeps[0].steps.tolist(), [1, 2])
|
||||
|
||||
def test_multiple_sweeps_in_one_feed(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(1, 1, 0) + _reference(1, 10, 0)
|
||||
+ _boundary()
|
||||
+ _main(1, 2, 0) + _reference(1, 20, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
sweeps = parser.feed(stream)
|
||||
self.assertEqual(len(sweeps), 2)
|
||||
self.assertEqual(sweeps[0].main.real.tolist(), [1])
|
||||
self.assertEqual(sweeps[1].main.real.tolist(), [2])
|
||||
|
||||
def test_empty_sweep_between_boundaries_is_skipped(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = _boundary() + _boundary() + _main(1, 5, 0) + _reference(1, 1, 0) + _boundary()
|
||||
sweeps = parser.feed(stream)
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
self.assertEqual(sweeps[0].steps.tolist(), [1])
|
||||
|
||||
def test_corrupt_marker_raises(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
|
||||
with self.assertRaisesRegex(ValueError, "protocol violation"):
|
||||
parser.feed(_boundary() + _main(1, 1, 0) + corrupt + _boundary())
|
||||
|
||||
def test_partial_trailing_frame_is_buffered(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
self.assertEqual(parser.feed(_boundary() + _main(1, 1, 0)[:5]), [])
|
||||
# Supply the rest of the frame plus its reference and the closing boundary.
|
||||
rest = _main(1, 1, 0)[5:]
|
||||
(sweep,) = parser.feed(rest + _reference(1, 9, 0) + _boundary())
|
||||
self.assertEqual(sweep.steps.tolist(), [1])
|
||||
|
||||
def test_reset_clears_state(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
parser.feed(_boundary() + _main(1, 1, 0))
|
||||
parser.reset()
|
||||
# After reset we must re-align on a fresh boundary before collecting.
|
||||
sweeps = parser.feed(_main(9, 9, 0) + _boundary() + _main(1, 2, 0) + _reference(1, 2, 0) + _boundary())
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
self.assertEqual(sweeps[0].main.real.tolist(), [2])
|
||||
|
||||
def test_dtypes(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
|
||||
self.assertEqual(sweep.main.dtype, np.complex64)
|
||||
self.assertEqual(sweep.reference.dtype, np.complex64)
|
||||
self.assertEqual(sweep.steps.dtype, np.int32)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for Kamil ADC config, frame parsing, TTY reader, and producer wiring."""
|
||||
"""Tests for the Kamil ADC TTY reader, config round-trip, and producer wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,40 +7,30 @@ import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import tty
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from python_app.hardware_full.kamil_adc_service import (
|
||||
KamilAdcTtyReader,
|
||||
_parse_point_frame,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
|
||||
from python_app.hardware_full.kamil_adc.protocol import MAIN_MARKER, REFERENCE_MARKER
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
|
||||
|
||||
def _start_frame() -> bytes:
|
||||
return struct.pack("<HHHH", 0x000A, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
def _boundary() -> bytes:
|
||||
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
|
||||
|
||||
def _point_frame(step: int, real: int, imag: int, *, marker: int = 0x000A) -> bytes:
|
||||
return struct.pack("<HHhh", marker, step, real, imag)
|
||||
def _main(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
||||
|
||||
|
||||
class ParsePointFrameTest(unittest.TestCase):
|
||||
def test_parses_valid_point(self) -> None:
|
||||
value = _parse_point_frame(_point_frame(1, 123, -45), expected_step=1)
|
||||
self.assertEqual(value, complex(123, -45))
|
||||
|
||||
def test_rejects_bad_marker(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "marker mismatch"):
|
||||
_parse_point_frame(_point_frame(1, 10, 20, marker=0x001A), expected_step=1)
|
||||
|
||||
def test_rejects_wrong_step(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "step mismatch"):
|
||||
_parse_point_frame(_point_frame(2, 10, 20), expected_step=1)
|
||||
def _reference(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
||||
|
||||
|
||||
class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
@@ -61,97 +51,68 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
os.close(master_fd)
|
||||
os.close(slave_fd)
|
||||
|
||||
def test_publishes_first_complete_sweep(self) -> None:
|
||||
def test_publishes_sweep_with_aligned_main_and_reference(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, -20, 2)
|
||||
+ _start_frame(),
|
||||
_boundary()
|
||||
+ _main(1, 10, -1) + _reference(1, 100, 5)
|
||||
+ _main(2, -20, 2) + _reference(2, 200, 6)
|
||||
+ _boundary(),
|
||||
)
|
||||
values = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)])
|
||||
self.assertEqual(reader.locked_points, 2)
|
||||
sweep = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(sweep.steps.tolist(), [1, 2])
|
||||
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(-20, 2)])
|
||||
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_consecutive_constant_length_sweeps(self) -> None:
|
||||
"""Each newly-completed sweep is delivered once new data arrives after a read."""
|
||||
def test_variable_length_sweeps_are_allowed(self) -> None:
|
||||
"""Unlike the old format, sweep length may vary — no locking, just resample later."""
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, -20, 2)
|
||||
+ _start_frame(),
|
||||
)
|
||||
# One complete sweep per write, read between, so delivery is deterministic
|
||||
# (the reader publishes only the latest, overwriting unread sweeps).
|
||||
os.write(master_fd, _boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
|
||||
first = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(first.tolist(), [complex(10, -1), complex(-20, 2)])
|
||||
self.assertEqual(first.steps.tolist(), [1])
|
||||
|
||||
os.write(
|
||||
master_fd,
|
||||
_point_frame(1, 30, -3) + _point_frame(2, -40, 4) + _start_frame(),
|
||||
_main(1, 2, 0) + _reference(1, 8, 0)
|
||||
+ _main(2, 3, 0) + _reference(2, 7, 0)
|
||||
+ _boundary(),
|
||||
)
|
||||
second = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(second.tolist(), [complex(30, -3), complex(-40, 4)])
|
||||
self.assertEqual(second.steps.tolist(), [1, 2])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_shorter_sweep_after_lock_raises(self) -> None:
|
||||
"""A later sweep with fewer points than the locked-in count fails fast."""
|
||||
def test_only_latest_sweep_is_published(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, -20, 2)
|
||||
+ _start_frame()
|
||||
+ _point_frame(1, 30, -3)
|
||||
+ _start_frame(),
|
||||
payload = (
|
||||
_boundary() + _main(1, 1, 0) + _reference(1, 1, 0)
|
||||
+ _boundary() + _main(1, 2, 0) + _reference(1, 2, 0)
|
||||
+ _boundary() + _main(1, 3, 0) + _reference(1, 3, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
first = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(first.tolist(), [complex(10, -1), complex(-20, 2)])
|
||||
with self.assertRaisesRegex(RuntimeError, "sweep length changed"):
|
||||
reader.read_sweep(timeout_s=1.0)
|
||||
os.write(master_fd, payload)
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline and reader.published_count < 3:
|
||||
time.sleep(0.005)
|
||||
self.assertGreaterEqual(reader.published_count, 3)
|
||||
sweep = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(sweep.main.tolist(), [complex(3, 0)])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_longer_sweep_after_lock_raises(self) -> None:
|
||||
"""A later sweep with more points than the locked-in count fails fast."""
|
||||
def test_corrupt_frame_fails_fast(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _start_frame()
|
||||
+ _point_frame(1, 30, -3)
|
||||
+ _point_frame(2, -40, 4)
|
||||
+ _start_frame(),
|
||||
)
|
||||
first = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(first.tolist(), [complex(10, -1)])
|
||||
with self.assertRaisesRegex(RuntimeError, "exceeded locked point count"):
|
||||
reader.read_sweep(timeout_s=1.0)
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_corrupt_frame_fails_fast_without_resync(self) -> None:
|
||||
"""A garbage frame (bad marker) mid-stream surfaces on read; the reader does
|
||||
NOT silently resync — fail-fast lets the producer die and the supervisor relaunch."""
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, 5, 5, marker=0x001A) # corrupt marker (not 0x000A)
|
||||
+ _start_frame(),
|
||||
)
|
||||
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
|
||||
os.write(master_fd, _boundary() + _main(1, 10, -1) + corrupt + _boundary())
|
||||
with self.assertRaises((ValueError, RuntimeError)):
|
||||
reader.read_sweep(timeout_s=1.0)
|
||||
finally:
|
||||
@@ -160,44 +121,12 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
def test_no_completed_sweep_times_out(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
# Start marker plus a partial sweep with no follow-up boundary.
|
||||
os.write(master_fd, _start_frame() + _point_frame(1, 10, -1))
|
||||
os.write(master_fd, _boundary() + _main(1, 10, -1) + _reference(1, 1, 0))
|
||||
with self.assertRaisesRegex(TimeoutError, "Timed out waiting for Kamil ADC sweep"):
|
||||
reader.read_sweep(timeout_s=0.1)
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_only_latest_sweep_is_published(self) -> None:
|
||||
"""If multiple sweeps arrive before the consumer reads, only the newest survives."""
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
payload = (
|
||||
_start_frame()
|
||||
+ _point_frame(1, 1, 0)
|
||||
+ _point_frame(2, 2, 0)
|
||||
+ _start_frame()
|
||||
+ _point_frame(1, 3, 0)
|
||||
+ _point_frame(2, 4, 0)
|
||||
+ _start_frame()
|
||||
+ _point_frame(1, 5, 0)
|
||||
+ _point_frame(2, 6, 0)
|
||||
+ _start_frame()
|
||||
)
|
||||
os.write(master_fd, payload)
|
||||
# Wait until the reader thread has parsed all three sweeps before
|
||||
# reading from the mailbox — otherwise we'd race the producer and
|
||||
# might consume an intermediate value.
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline and reader.published_count < 3:
|
||||
time.sleep(0.005)
|
||||
self.assertGreaterEqual(reader.published_count, 3)
|
||||
values = reader.read_sweep(timeout_s=1.0)
|
||||
# The reader thread overwrites unread sweeps; the consumer sees the
|
||||
# most recently completed one.
|
||||
self.assertEqual(values.tolist(), [complex(5, 0), complex(6, 0)])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
|
||||
class KamilAdcConfigTest(unittest.TestCase):
|
||||
def test_config_round_trip_preserves_kamil_sections(self) -> None:
|
||||
@@ -207,85 +136,84 @@ class KamilAdcConfigTest(unittest.TestCase):
|
||||
"serial": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": "/home/europa/Documents/kamil_adc",
|
||||
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
|
||||
"project_dir": "",
|
||||
"executable_path": "build/bin/kamil_adc_collector",
|
||||
"tty_path": "/tmp/ttyADC_data",
|
||||
"args": ["profile:phase", "do1_pair_subtract_avg"],
|
||||
"args": ["profile:phase", "do8_freq_ref"],
|
||||
"env": {"ADC_ENV": "1"},
|
||||
"startup_timeout_s": 7.0,
|
||||
"sweep_timeout_s": 8.0,
|
||||
"stop_timeout_s": 3.0,
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": True,
|
||||
"port": "/dev/ttyUSB0",
|
||||
"mode": "variation",
|
||||
"pi_coeff1_p": 2560,
|
||||
"pi_coeff1_i": 128,
|
||||
"pi_coeff2_p": 2600,
|
||||
"pi_coeff2_i": 140,
|
||||
"manual": {
|
||||
"temp1": 26.0,
|
||||
"temp2": 27.0,
|
||||
"current1": 31.0,
|
||||
"current2": 32.0,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": 1.5,
|
||||
"freq0_hz": 2_046_000_000.0,
|
||||
"phase1_rad": 301.0,
|
||||
"freq1_hz": 5_612_000_000.0,
|
||||
},
|
||||
"variation": {
|
||||
"variation_type": "CHANGE_CURRENT_LD2",
|
||||
"static_temp1": 28.0,
|
||||
"static_temp2": 29.0,
|
||||
"static_current1": 33.0,
|
||||
"static_current2": 34.0,
|
||||
"min_value": 30.0,
|
||||
"max_value": 40.0,
|
||||
"step": 0.5,
|
||||
"time_step": 50,
|
||||
"delay_time": 10,
|
||||
"band": {
|
||||
"start_hz": 2_100_000_000.0,
|
||||
"stop_hz": 5_500_000_000.0,
|
||||
"points": 1024,
|
||||
},
|
||||
},
|
||||
"sweep": {
|
||||
"start_hz": 1.0,
|
||||
"stop_hz": 2.0,
|
||||
"points": 2,
|
||||
"if_bandwidth_hz": 1.0,
|
||||
"stimulus_power_dbm": -10.0,
|
||||
},
|
||||
},
|
||||
"switches": {
|
||||
"port1": {"positions": 1},
|
||||
"port2": {"positions": 1},
|
||||
"laser_control": {"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual"},
|
||||
"sweep": {"start_hz": 1.0, "stop_hz": 2.0, "points": 2},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||
}
|
||||
|
||||
encoded = RunConfigModel.from_dict(payload).to_dict()
|
||||
kamil = encoded["radar"]["kamil_adc"]
|
||||
|
||||
self.assertEqual(encoded["radar"]["model"], "kamil_adc")
|
||||
self.assertNotIn("points", encoded["radar"]["sweep"])
|
||||
self.assertEqual(encoded["radar"]["kamil_adc"]["tty_path"], "/tmp/ttyADC_data")
|
||||
self.assertEqual(encoded["radar"]["kamil_adc"]["args"], ["profile:phase", "do1_pair_subtract_avg"])
|
||||
self.assertEqual(encoded["radar"]["kamil_adc"]["env"], {"ADC_ENV": "1"})
|
||||
self.assertEqual(encoded["radar"]["laser_control"]["mode"], "variation")
|
||||
self.assertEqual(
|
||||
encoded["radar"]["laser_control"]["variation"]["variation_type"],
|
||||
"CHANGE_CURRENT_LD2",
|
||||
)
|
||||
self.assertEqual(kamil["executable_path"], "build/bin/kamil_adc_collector")
|
||||
self.assertEqual(kamil["args"], ["profile:phase", "do8_freq_ref"])
|
||||
self.assertEqual(kamil["phase_calibration"]["phase0_rad"], 1.5)
|
||||
self.assertEqual(kamil["phase_calibration"]["freq1_hz"], 5_612_000_000.0)
|
||||
self.assertEqual(kamil["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024})
|
||||
|
||||
def test_close_never_raises_when_collector_refuses_to_die(self) -> None:
|
||||
"""close() must stay exception-safe even if a SIGKILL'd collector is not
|
||||
reaped within the grace window (e.g. wedged in USB D-state)."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config = RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": tmp_dir,
|
||||
"executable_path": "/bin/sh", # any real executable
|
||||
"tty_path": "/tmp/ttyADC_test",
|
||||
},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||
}
|
||||
)
|
||||
service = KamilAdcService(config)
|
||||
|
||||
class _UnreapableProcess:
|
||||
pid = 2_000_000_000 # implausible; killpg is patched out below anyway
|
||||
|
||||
def poll(self) -> None:
|
||||
return None # always "alive"
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
raise subprocess.TimeoutExpired(cmd="kamil_adc_collector", timeout=timeout)
|
||||
|
||||
service._process = _UnreapableProcess() # type: ignore[assignment]
|
||||
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
|
||||
service.close() # must not raise
|
||||
|
||||
def test_supervisor_selects_kamil_adc_producer(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "run_config.json"
|
||||
config_path.write_text(json.dumps({"radar": {"model": "kamil_adc"}}), encoding="utf-8")
|
||||
|
||||
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
|
||||
|
||||
self.assertEqual(
|
||||
command,
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"python_app.scripts.kamil_adc_raw_producer",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
[sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer", "--config", str(config_path)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
||||
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
|
||||
from python_app.hardware_full.laser_control.models import VariationType
|
||||
from python_app.hardware_full.laser_control.protocol import Protocol, TaskType
|
||||
from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import ComboModel, RunConfigModel
|
||||
|
||||
@@ -15,33 +19,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def build_kamil_adc_neutral_s21_sets(
|
||||
config: RunConfigModel,
|
||||
point_count: int,
|
||||
) -> tuple[SweepCollection, SweepCollection]:
|
||||
"""Build neutral S21 calibration/reference collections for the Kamil ADC radar.
|
||||
|
||||
The calibration uses unit S21 (1+0j) and the reference uses zero S21 across
|
||||
every configured combo, so applying them in the preprocessing pipeline leaves
|
||||
the input S21 unchanged. Returns the ``(calibration, reference)`` collections.
|
||||
the input S21 unchanged. The frequency axis is the exact acquisition grid
|
||||
(``radar.kamil_adc.band``), so neutral sets line up sample-for-sample with
|
||||
live sweeps. Returns the ``(calibration, reference)`` collections.
|
||||
"""
|
||||
if not config.is_kamil_adc:
|
||||
raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'")
|
||||
|
||||
points = int(point_count)
|
||||
if points <= 0:
|
||||
raise ValueError("Kamil ADC neutral set point count must be > 0")
|
||||
|
||||
combos = list(config.combos)
|
||||
if not combos:
|
||||
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||
combos = RunConfigModel.build_full_combos(
|
||||
config.input_switch.positions, config.output_switch.positions
|
||||
)
|
||||
if not combos:
|
||||
raise ValueError("Kamil ADC neutral sets require at least one switch combo")
|
||||
|
||||
frequency_hz = np.linspace(
|
||||
float(config.radar.sweep.start_hz),
|
||||
float(config.radar.sweep.stop_hz),
|
||||
points,
|
||||
dtype=np.float32,
|
||||
)
|
||||
# Single source of truth for the axis: the same grid the processor emits.
|
||||
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
|
||||
frequency_hz = processor.grid_hz
|
||||
|
||||
now_ns = time.monotonic_ns()
|
||||
calibration = _neutral_collection(
|
||||
combos=combos,
|
||||
@@ -55,7 +56,9 @@ def build_kamil_adc_neutral_s21_sets(
|
||||
s21_value=np.complex64(0.0 + 0.0j),
|
||||
monotonic_ns=now_ns,
|
||||
)
|
||||
logger.info("Built neutral Kamil ADC S21 sets: combos=%d points=%d", len(combos), points)
|
||||
logger.info(
|
||||
"Built neutral Kamil ADC S21 sets: combos=%d points=%d", len(combos), int(frequency_hz.size)
|
||||
)
|
||||
return calibration, reference
|
||||
|
||||
|
||||
@@ -66,17 +69,16 @@ def _neutral_collection(
|
||||
s21_value: np.complex64,
|
||||
monotonic_ns: int,
|
||||
) -> SweepCollection:
|
||||
traces = []
|
||||
for combo in combos:
|
||||
point_count = int(frequency_hz.size)
|
||||
traces.append(
|
||||
TraceData(
|
||||
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
|
||||
frequency_hz=frequency_hz.copy(),
|
||||
s11=np.zeros(point_count, dtype=np.complex64),
|
||||
s21=np.full(point_count, s21_value, dtype=np.complex64),
|
||||
)
|
||||
point_count = int(frequency_hz.size)
|
||||
traces = [
|
||||
TraceData(
|
||||
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
|
||||
frequency_hz=frequency_hz.copy(),
|
||||
s11=np.zeros(point_count, dtype=np.complex64),
|
||||
s21=np.full(point_count, s21_value, dtype=np.complex64),
|
||||
)
|
||||
for combo in combos
|
||||
]
|
||||
return SweepCollection(
|
||||
collection_id=1,
|
||||
monotonic_ns=monotonic_ns,
|
||||
|
||||
@@ -115,28 +115,8 @@ class MultiRadarSequentialCaptureSession:
|
||||
self._output_switch = None
|
||||
else:
|
||||
self._radar = create_single_radar_service(base_config)
|
||||
self._input_switch = SwitchService(
|
||||
name=base_config.input_switch.name,
|
||||
positions=base_config.input_switch.positions,
|
||||
default_position=base_config.input_switch.default_position,
|
||||
mode=base_config.input_switch.driver_mode,
|
||||
driver=base_config.input_switch.driver,
|
||||
gpio_chip=base_config.input_switch.gpio_chip,
|
||||
pin_a=base_config.input_switch.pin_a,
|
||||
pin_b=base_config.input_switch.pin_b,
|
||||
invert_logic=base_config.input_switch.invert_logic,
|
||||
)
|
||||
self._output_switch = SwitchService(
|
||||
name=base_config.output_switch.name,
|
||||
positions=base_config.output_switch.positions,
|
||||
default_position=base_config.output_switch.default_position,
|
||||
mode=base_config.output_switch.driver_mode,
|
||||
driver=base_config.output_switch.driver,
|
||||
gpio_chip=base_config.output_switch.gpio_chip,
|
||||
pin_a=base_config.output_switch.pin_a,
|
||||
pin_b=base_config.output_switch.pin_b,
|
||||
invert_logic=base_config.output_switch.invert_logic,
|
||||
)
|
||||
self._input_switch = SwitchService.from_model(base_config.input_switch)
|
||||
self._output_switch = SwitchService.from_model(base_config.output_switch)
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
|
||||
@@ -91,28 +91,8 @@ class SequentialCaptureSession:
|
||||
self._output_switch = None
|
||||
else:
|
||||
self._radar = create_single_radar_service(config)
|
||||
self._input_switch = SwitchService(
|
||||
name=config.input_switch.name,
|
||||
positions=config.input_switch.positions,
|
||||
default_position=config.input_switch.default_position,
|
||||
mode=config.input_switch.driver_mode,
|
||||
driver=config.input_switch.driver,
|
||||
gpio_chip=config.input_switch.gpio_chip,
|
||||
pin_a=config.input_switch.pin_a,
|
||||
pin_b=config.input_switch.pin_b,
|
||||
invert_logic=config.input_switch.invert_logic,
|
||||
)
|
||||
self._output_switch = SwitchService(
|
||||
name=config.output_switch.name,
|
||||
positions=config.output_switch.positions,
|
||||
default_position=config.output_switch.default_position,
|
||||
mode=config.output_switch.driver_mode,
|
||||
driver=config.output_switch.driver,
|
||||
gpio_chip=config.output_switch.gpio_chip,
|
||||
pin_a=config.output_switch.pin_a,
|
||||
pin_b=config.output_switch.pin_b,
|
||||
invert_logic=config.output_switch.invert_logic,
|
||||
)
|
||||
self._input_switch = SwitchService.from_model(config.input_switch)
|
||||
self._output_switch = SwitchService.from_model(config.output_switch)
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
|
||||
Reference in New Issue
Block a user