projector version

This commit is contained in:
awe
2026-09-15 19:06:05 +03:00
parent 62c0266a6b
commit b3339d101c
33 changed files with 2605 additions and 51 deletions
+3
View File
@@ -371,6 +371,9 @@ class AppWindow(
self._gpr_region_contours = []
self._gpr_geometry_signature = None
self._gpr_selected_geometry = None
self._sar3d_lookup_table = None
self._sar3d_xz_image_item = None
self._sar3d_xy_image_item = None
self._phase_viewbox = None
self._history_run_signature = None
self._processor_run_signature = None
@@ -16,10 +16,9 @@ from python_app.gui.runtime.constraints import validate_processing_mode_constrai
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
_GPR_MODES = ("gpr", "legacy_gpr")
def _is_legacy_gpr(window) -> bool:
return window._processing_mode.currentText() == "legacy_gpr"
# Modes that consume the shared gpr_* live-config fields (band, depth gate, antenna
# selection, motion): both GPR imagers plus the 3D SAR processor.
_GPR_LIKE_MODES = ("gpr", "legacy_gpr", "sar3d")
def _attr(name: str):
@@ -27,9 +26,16 @@ def _attr(name: str):
return lambda window: getattr(window, name)
def _dual(gpr_attr: str, legacy_attr: str):
"""Widget getter for a gpr_* field with separate gpr / legacy_gpr widgets."""
return lambda window: getattr(window, legacy_attr if _is_legacy_gpr(window) else gpr_attr)
def _dual(default_attr: str, mode_attrs: dict[str, str] | None = None):
"""Widget getter for a field with per-mode widgets (gpr / legacy_gpr / sar3d).
`mode_attrs` maps a processor mode to its dedicated widget attribute; any
other mode (including inactive ones) falls back to `default_attr`.
"""
attrs = dict(mode_attrs or {})
return lambda window: getattr(
window, attrs.get(window._processing_mode.currentText(), default_attr)
)
# The ONE authoritative field <-> widget map (inverse of `_live_processing_config`),
@@ -50,17 +56,17 @@ _WEB_LIVE_SCHEMA = [
("bscan_start_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_start_freq_mhz")),
("bscan_stop_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_stop_freq_mhz")),
("legacy_gpr_mode", "Mode", ("legacy_gpr",), _attr("_legacy_gpr_config_mode")),
("gpr_input_positions", "Geometry & depth", _GPR_MODES, _dual("_gpr_input_positions_input", "_legacy_gpr_input_positions_input")),
("gpr_output_positions", "Geometry & depth", _GPR_MODES, _dual("_gpr_output_positions_input", "_legacy_gpr_output_positions_input")),
("gpr_min_depth_m", "Geometry & depth", _GPR_MODES, _dual("_gpr_min_depth_m", "_legacy_gpr_min_depth_m")),
("gpr_max_depth_m", "Geometry & depth", _GPR_MODES, _dual("_gpr_max_depth_m", "_legacy_gpr_max_depth_m")),
("gpr_start_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_start_freq_mhz", "_legacy_gpr_start_freq_mhz")),
("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")),
("gpr_input_positions", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_input_positions_input", {"legacy_gpr": "_legacy_gpr_input_positions_input", "sar3d": "_sar3d_input_positions_input"})),
("gpr_output_positions", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_output_positions_input", {"legacy_gpr": "_legacy_gpr_output_positions_input", "sar3d": "_sar3d_output_positions_input"})),
("gpr_min_depth_m", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_min_depth_m", {"legacy_gpr": "_legacy_gpr_min_depth_m", "sar3d": "_sar3d_min_depth_m"})),
("gpr_max_depth_m", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_max_depth_m", {"legacy_gpr": "_legacy_gpr_max_depth_m", "sar3d": "_sar3d_max_depth_m"})),
("gpr_start_freq_mhz", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_start_freq_mhz", {"legacy_gpr": "_legacy_gpr_start_freq_mhz", "sar3d": "_sar3d_start_freq_mhz"})),
("gpr_stop_freq_mhz", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_stop_freq_mhz", {"legacy_gpr": "_legacy_gpr_stop_freq_mhz", "sar3d": "_sar3d_stop_freq_mhz"})),
("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")),
("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")),
("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")),
("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")),
("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")),
("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", {"legacy_gpr": "_legacy_gpr_background_subtract_enabled"})),
("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", {"legacy_gpr": "_legacy_gpr_background_mean_count"})),
("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")),
("gpr_object_min_frac", "Detection", ("gpr",), _attr("_gpr_object_min_frac")),
("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")),
@@ -70,17 +76,29 @@ _WEB_LIVE_SCHEMA = [
("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")),
("legacy_gpr_min_visible_pair_count", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_min_visible_pair_count")),
("gpr_visible_x_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_min_m", "_legacy_gpr_visible_x_min_m")),
("gpr_visible_x_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_max_m", "_legacy_gpr_visible_x_max_m")),
("gpr_visible_z_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_min_m", "_legacy_gpr_visible_z_min_m")),
("gpr_visible_z_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_max_m", "_legacy_gpr_visible_z_max_m")),
("gpr_visible_x_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_min_m", {"legacy_gpr": "_legacy_gpr_visible_x_min_m"})),
("gpr_visible_x_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_max_m", {"legacy_gpr": "_legacy_gpr_visible_x_max_m"})),
("gpr_visible_z_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_min_m", {"legacy_gpr": "_legacy_gpr_visible_z_min_m"})),
("gpr_visible_z_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_max_m", {"legacy_gpr": "_legacy_gpr_visible_z_max_m"})),
("gpr_motion_mode", "Motion", ("gpr",), _attr("_gpr_motion_mode")),
("gpr_look_angle_deg", "Motion", _GPR_MODES, _dual("_gpr_look_angle_deg", "_legacy_gpr_look_angle_deg")),
("gpr_direction_sign", "Motion", ("gpr",), _attr("_gpr_direction_sign")),
("gpr_apply_freq_phase_correction", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_apply_freq_phase_correction")),
("gpr_look_angle_deg", "Motion", _GPR_LIKE_MODES, _dual("_gpr_look_angle_deg", {"legacy_gpr": "_legacy_gpr_look_angle_deg", "sar3d": "_sar3d_look_angle_deg"})),
("gpr_direction_sign", "Motion", ("gpr", "sar3d"), _dual("_gpr_direction_sign", {"sar3d": "_sar3d_direction_sign"})),
("gpr_apply_freq_phase_correction", "Motion", ("legacy_gpr", "sar3d"), _dual("_legacy_gpr_apply_freq_phase_correction", {"sar3d": "_sar3d_apply_freq_phase_correction"})),
("gpr_reference_mode", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_reference_mode")),
("ignore_socket_speed", "Motion", _GPR_MODES, _dual("_gpr_ignore_socket_speed_enabled", "_legacy_gpr_ignore_socket_speed_enabled")),
("gpr_speed_m_s", "Motion", _GPR_MODES, _dual("_gpr_speed_m_s", "_legacy_gpr_speed_m_s")),
("ignore_socket_speed", "Motion", _GPR_LIKE_MODES, _dual("_gpr_ignore_socket_speed_enabled", {"legacy_gpr": "_legacy_gpr_ignore_socket_speed_enabled", "sar3d": "_sar3d_ignore_socket_speed_enabled"})),
("gpr_speed_m_s", "Motion", _GPR_LIKE_MODES, _dual("_gpr_speed_m_s", {"legacy_gpr": "_legacy_gpr_speed_m_s", "sar3d": "_sar3d_speed_m_s"})),
("sar3d_frame_radius", "3D SAR", ("sar3d",), _attr("_sar3d_frame_radius")),
("sar3d_edge_frame_weight", "3D SAR", ("sar3d",), _attr("_sar3d_edge_frame_weight")),
("sar3d_pitch_deg", "3D SAR", ("sar3d",), _attr("_sar3d_pitch_deg")),
("sar3d_center_depth_m", "3D SAR", ("sar3d",), _attr("_sar3d_center_depth_m")),
("sar3d_half_width_x_m", "3D SAR", ("sar3d",), _attr("_sar3d_half_width_x_m")),
("sar3d_half_width_y_m", "3D SAR", ("sar3d",), _attr("_sar3d_half_width_y_m")),
("sar3d_half_width_z_m", "3D SAR", ("sar3d",), _attr("_sar3d_half_width_z_m")),
("sar3d_grid_nx", "3D SAR", ("sar3d",), _attr("_sar3d_grid_nx")),
("sar3d_grid_ny", "3D SAR", ("sar3d",), _attr("_sar3d_grid_ny")),
("sar3d_grid_nz", "3D SAR", ("sar3d",), _attr("_sar3d_grid_nz")),
("sar3d_use_compensation", "3D SAR", ("sar3d",), _attr("_sar3d_use_compensation")),
("sar3d_normalize_pairs", "3D SAR", ("sar3d",), _attr("_sar3d_normalize_pairs")),
]
_WEB_LIVE_GETTERS = {field: getter for field, _group, _modes, getter in _WEB_LIVE_SCHEMA}
@@ -101,7 +119,7 @@ _NON_WIDGET_LIVE_FIELDS = frozenset({
# config). They apply immediately through the same redraw as a live edit but write
# nothing to processing_live.json.
_WEB_DISPLAY_SCHEMA = [
("render_mode", "Display", _GPR_MODES, _dual("_gpr_render_mode", "_legacy_gpr_render_mode")),
("render_mode", "Display", _GPR_MODES, _dual("_gpr_render_mode", {"legacy_gpr": "_legacy_gpr_render_mode"})),
("subtract_mean_ascan", "Display", ("bscan",), _attr("_bscan_subtract_mean_ascan")),
("combo_filter", "Display", ("pass_through",), _attr("_pass_through_combo_filter_input")),
]
@@ -113,8 +131,8 @@ _WEB_STABLE_SCHEMA = [
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so relative
# permittivity is a legacy-GPR-only knob; BP ignores it.
("relative_permittivity", "Geometry & medium", ("legacy_gpr",), _attr("_gpr_relative_permittivity")),
("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")),
("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")),
("tx_geometry", "Geometry & medium", _GPR_LIKE_MODES, _attr("_gpr_tx_geometry_input")),
("rx_geometry", "Geometry & medium", _GPR_LIKE_MODES, _attr("_gpr_rx_geometry_input")),
]
# Every field the web form renders and may write back, across all three categories.
@@ -222,6 +240,17 @@ class AppWindowLiveProcessingMixin:
"""Return whether a mode uses GPR result payloads and plot surface."""
return mode in {"gpr", "legacy_gpr"}
@staticmethod
def _is_gpr_like_processing_mode(mode: str) -> bool:
"""Return whether a mode consumes the shared gpr_* fields and Tx/Rx geometry.
Covers both GPR imagers and the 3D SAR processor. The latter renders on its
own plot surface, so GPR-object/locator branches must keep using
:meth:`_is_gpr_processing_mode`; this predicate gates only the shared
behaviors (result drain on settings changes, geometry page visibility).
"""
return mode in set(_GPR_LIKE_MODES)
def _validate_processing_mode_selection(self, mode: str) -> None:
"""Validate requested processing mode against current stable/live GUI state."""
validate_processing_mode_constraints(
@@ -352,7 +381,7 @@ class AppWindowLiveProcessingMixin:
return
try:
current_mode = self._processing_mode.currentText()
if self._is_gpr_processing_mode(current_mode):
if self._is_gpr_like_processing_mode(current_mode):
self._drain_results_until_quiet(timeout_s=0.05, poll_s=0.005)
self._write_live_processing_config()
@@ -364,16 +393,16 @@ class AppWindowLiveProcessingMixin:
# recompute of everything on screen. Debounced, so dragging a spin box
# sends one burst instead of one per step.
self._schedule_bscan_history_reprocess()
elif self._is_gpr_processing_mode(current_mode):
elif self._is_gpr_like_processing_mode(current_mode):
latest = self._drain_results_until_quiet(timeout_s=0.8, poll_s=0.02)
collection = latest
if collection is None and self._result_history:
collection = self._result_history[-1]
if collection is not None:
if not self._draw_results(collection):
if collection is None or not self._draw_results(collection):
if current_mode == "sar3d":
self._clear_sar3d_plot()
else:
self._clear_gpr_plot()
else:
self._clear_gpr_plot()
elif self._result_history:
self._draw_results(self._result_history[-1])
else:
@@ -427,9 +456,10 @@ class AppWindowLiveProcessingMixin:
"bscan": 1,
"gpr": 2,
"legacy_gpr": 3,
"sar3d": 4,
}
self._processing_mode_pages.setCurrentIndex(mode_to_page.get(mode, 0))
self._gpr_common_page.setVisible(self._is_gpr_processing_mode(mode))
self._gpr_common_page.setVisible(self._is_gpr_like_processing_mode(mode))
current_page = self._processing_mode_pages.currentWidget()
if current_page is not None:
self._processing_mode_pages.setFixedHeight(current_page.sizeHint().height())
@@ -509,9 +539,21 @@ class AppWindowLiveProcessingMixin:
f"render_mode={self._legacy_gpr_render_mode.currentText()}, "
f"min_pairs={self._legacy_gpr_min_visible_pair_count.value()})"
)
elif mode == "sar3d":
self._log(
"Processing mode selected: sar3d "
f"(inputs={self._sar3d_input_positions_input.text().strip() or '<all>'}, "
f"outputs={self._sar3d_output_positions_input.text().strip() or '<all>'}, "
f"depth={self._sar3d_min_depth_m.value():g}..{self._sar3d_max_depth_m.value():g} m, "
f"freq={self._sar3d_start_freq_mhz.value():g}..{self._sar3d_stop_freq_mhz.value():g} MHz, "
f"frame_radius={self._sar3d_frame_radius.value()}, "
f"grid={self._sar3d_grid_nx.value()}x{self._sar3d_grid_ny.value()}x{self._sar3d_grid_nz.value()}, "
f"center_depth={self._sar3d_center_depth_m.value():g} m, "
f"pitch={self._sar3d_pitch_deg.value():g} deg)"
)
def _clear_history_mode_caches(self) -> None:
"""Drop cached render state for pass-through, B-scan, and GPR views."""
"""Drop cached render state for pass-through, B-scan, GPR, and 3D SAR views."""
self._clear_bscan_plot_history()
if hasattr(self, "_bscan_plot"):
self._bscan_plot.clear()
@@ -519,6 +561,8 @@ class AppWindowLiveProcessingMixin:
self._clear_trace_plots()
if hasattr(self, "_gpr_plot"):
self._clear_gpr_plot()
if hasattr(self, "_sar3d_xz_plot"):
self._clear_sar3d_plot()
def _redraw_after_history_deletion(self) -> None:
"""Refresh plot immediately after destructive history deletion."""
@@ -534,6 +578,11 @@ class AppWindowLiveProcessingMixin:
return
self._clear_gpr_plot()
return
if self._processing_mode.currentText() == "sar3d":
if self._result_history and self._draw_results(self._result_history[-1]):
return
self._clear_sar3d_plot()
return
if self._result_history:
self._draw_results(self._result_history[-1])
return
@@ -336,6 +336,29 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_visible_x_max_m,
self._legacy_gpr_visible_z_min_m,
self._legacy_gpr_visible_z_max_m,
self._sar3d_input_positions_input,
self._sar3d_output_positions_input,
self._sar3d_min_depth_m,
self._sar3d_max_depth_m,
self._sar3d_start_freq_mhz,
self._sar3d_stop_freq_mhz,
self._sar3d_speed_m_s,
self._sar3d_ignore_socket_speed_enabled,
self._sar3d_look_angle_deg,
self._sar3d_direction_sign,
self._sar3d_apply_freq_phase_correction,
self._sar3d_frame_radius,
self._sar3d_edge_frame_weight,
self._sar3d_pitch_deg,
self._sar3d_center_depth_m,
self._sar3d_half_width_x_m,
self._sar3d_half_width_y_m,
self._sar3d_half_width_z_m,
self._sar3d_grid_nx,
self._sar3d_grid_ny,
self._sar3d_grid_nz,
self._sar3d_use_compensation,
self._sar3d_normalize_pairs,
self._save_count,
self._record_count,
self._save_path_input,
@@ -524,6 +547,31 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_visible_z_min_m.setValue(float(gui_state.processing.legacy_gpr.visible_z_min_m))
self._legacy_gpr_visible_z_max_m.setValue(float(gui_state.processing.legacy_gpr.visible_z_max_m))
sar3d_state = gui_state.processing.sar3d
self._sar3d_input_positions_input.setText(str(sar3d_state.input_positions))
self._sar3d_output_positions_input.setText(str(sar3d_state.output_positions))
self._sar3d_min_depth_m.setValue(float(sar3d_state.min_depth_m))
self._sar3d_max_depth_m.setValue(float(sar3d_state.max_depth_m))
self._sar3d_start_freq_mhz.setValue(float(sar3d_state.start_freq_mhz))
self._sar3d_stop_freq_mhz.setValue(float(sar3d_state.stop_freq_mhz))
self._sar3d_speed_m_s.setValue(float(sar3d_state.speed_m_s))
self._sar3d_ignore_socket_speed_enabled.setChecked(bool(sar3d_state.ignore_socket_speed_enabled))
self._sar3d_look_angle_deg.setValue(float(sar3d_state.look_angle_deg))
self._sar3d_direction_sign.setValue(float(sar3d_state.direction_sign))
self._sar3d_apply_freq_phase_correction.setChecked(bool(sar3d_state.apply_freq_phase_correction))
self._sar3d_frame_radius.setValue(int(sar3d_state.frame_radius))
self._sar3d_edge_frame_weight.setValue(float(sar3d_state.edge_frame_weight))
self._sar3d_pitch_deg.setValue(float(sar3d_state.pitch_deg))
self._sar3d_center_depth_m.setValue(float(sar3d_state.center_depth_m))
self._sar3d_half_width_x_m.setValue(float(sar3d_state.half_width_x_m))
self._sar3d_half_width_y_m.setValue(float(sar3d_state.half_width_y_m))
self._sar3d_half_width_z_m.setValue(float(sar3d_state.half_width_z_m))
self._sar3d_grid_nx.setValue(int(sar3d_state.grid_nx))
self._sar3d_grid_ny.setValue(int(sar3d_state.grid_ny))
self._sar3d_grid_nz.setValue(int(sar3d_state.grid_nz))
self._sar3d_use_compensation.setChecked(bool(sar3d_state.use_compensation))
self._sar3d_normalize_pairs.setChecked(bool(sar3d_state.normalize_pairs))
self._save_count.setValue(int(gui_state.data_actions.save_count))
self._record_count.setValue(int(gui_state.data_actions.record_count))
self._save_path_input.setText(str(gui_state.data_actions.save_path))
@@ -182,6 +182,8 @@ class AppWindowRadarLimitsMixin:
"_gpr_stop_freq_mhz": "GPR Stop MHz",
"_legacy_gpr_start_freq_mhz": "Legacy GPR Start MHz",
"_legacy_gpr_stop_freq_mhz": "Legacy GPR Stop MHz",
"_sar3d_start_freq_mhz": "3D SAR Start MHz",
"_sar3d_stop_freq_mhz": "3D SAR Stop MHz",
}
widgets = [getattr(self, widget_name) for widget_name in widget_names]
previous_values = {widget_name: getattr(self, widget_name).value() for widget_name in widget_names}
@@ -232,5 +234,7 @@ class AppWindowRadarLimitsMixin:
"_gpr_stop_freq_mhz",
"_legacy_gpr_start_freq_mhz",
"_legacy_gpr_stop_freq_mhz",
"_sar3d_start_freq_mhz",
"_sar3d_stop_freq_mhz",
)
)
@@ -13,6 +13,7 @@ from python_app.models.gui_profile_model import (
GuiPreprocessDialogStateModel,
GuiProcessingStateModel,
GuiProfileModel,
GuiSar3dStateModel,
GuiStateModel,
GuiSwitchStateModel,
)
@@ -285,6 +286,10 @@ class AppWindowConfigStateBuildersMixin:
visible_z_min_m=0.0,
visible_z_max_m=14.0,
),
sar3d=GuiSar3dStateModel(
input_positions=self._default_gpr_input_positions_from_config(config),
output_positions=self._default_gpr_output_positions_from_config(config),
),
),
data_actions=GuiDataActionsStateModel(
save_count=10,
@@ -418,6 +423,31 @@ class AppWindowConfigStateBuildersMixin:
visible_z_min_m=float(self._legacy_gpr_visible_z_min_m.value()),
visible_z_max_m=float(self._legacy_gpr_visible_z_max_m.value()),
),
sar3d=GuiSar3dStateModel(
input_positions=self._sar3d_input_positions_input.text().strip(),
output_positions=self._sar3d_output_positions_input.text().strip(),
min_depth_m=float(self._sar3d_min_depth_m.value()),
max_depth_m=float(self._sar3d_max_depth_m.value()),
start_freq_mhz=float(self._sar3d_start_freq_mhz.value()),
stop_freq_mhz=float(self._sar3d_stop_freq_mhz.value()),
speed_m_s=float(self._sar3d_speed_m_s.value()),
ignore_socket_speed_enabled=bool(self._sar3d_ignore_socket_speed_enabled.isChecked()),
look_angle_deg=float(self._sar3d_look_angle_deg.value()),
direction_sign=float(self._sar3d_direction_sign.value()),
apply_freq_phase_correction=bool(self._sar3d_apply_freq_phase_correction.isChecked()),
frame_radius=int(self._sar3d_frame_radius.value()),
edge_frame_weight=float(self._sar3d_edge_frame_weight.value()),
pitch_deg=float(self._sar3d_pitch_deg.value()),
center_depth_m=float(self._sar3d_center_depth_m.value()),
half_width_x_m=float(self._sar3d_half_width_x_m.value()),
half_width_y_m=float(self._sar3d_half_width_y_m.value()),
half_width_z_m=float(self._sar3d_half_width_z_m.value()),
grid_nx=int(self._sar3d_grid_nx.value()),
grid_ny=int(self._sar3d_grid_ny.value()),
grid_nz=int(self._sar3d_grid_nz.value()),
use_compensation=bool(self._sar3d_use_compensation.isChecked()),
normalize_pairs=bool(self._sar3d_normalize_pairs.isChecked()),
),
),
data_actions=GuiDataActionsStateModel(
save_count=int(self._save_count.value()),
@@ -472,7 +502,7 @@ class AppWindowConfigStateBuildersMixin:
combo_text = self._combos_text.text()
config.combos = parse_combos_from_text(combo_text)
config.ensure_combos()
if self._processing_mode.currentText() not in {"gpr", "legacy_gpr"} and self._switches_are_effectively_static(config):
if self._processing_mode.currentText() not in {"gpr", "legacy_gpr", "sar3d"} and self._switches_are_effectively_static(config):
config.combos = [ComboModel(input=0, output=0)]
for key in PREPROCESS_ASSET_KEYS:
@@ -3,11 +3,13 @@
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import AppWindowBscanPlotMixin
from python_app.gui.controllers.app_window_plot.bscan_replay_mixin import AppWindowBscanReplayMixin
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
from python_app.gui.controllers.app_window_plot.sar3d_plot_mixin import AppWindowSar3dPlotMixin
from python_app.gui.controllers.app_window_plot.trace_plot_mixin import AppWindowTracePlotMixin
__all__ = [
"AppWindowBscanPlotMixin",
"AppWindowBscanReplayMixin",
"AppWindowGprPlotMixin",
"AppWindowSar3dPlotMixin",
"AppWindowTracePlotMixin",
]
@@ -0,0 +1,131 @@
"""3D SAR projection rendering helpers.
The sar3d processor publishes two normalized max-projections of the imaged
volume as collection-level ImageF32 payloads:
- ``sar3d_proj_xz`` — side view: X (m) horizontal, Z/range (m) vertical;
- ``sar3d_proj_xy`` — top view: X (m) horizontal, Y (m) vertical;
plus ``sar3d_peak`` (ScalarF32), the volume peak before normalization. Both
projections are drawn on their own PlotWidgets stacked in one plot-stack page
(side view on top), mirroring how the pass-through page stacks its two plots.
"""
from __future__ import annotations
from PyQt6.QtCore import QRectF
import numpy as np
import pyqtgraph as pg
from python_app.models.dataset_model import ResultCollection
class AppWindowSar3dPlotMixin:
"""Renders 3D SAR volume projections published by the sar3d processor."""
def _configure_sar3d_plot_axes(self) -> None:
"""Apply persistent axis labels and view settings to both projection plots."""
for plot, y_label in (
(self._sar3d_xz_plot, "Range Z"),
(self._sar3d_xy_plot, "Cross-range Y"),
):
plot_item = plot.getPlotItem()
plot_item.showAxis("left", show=True)
plot_item.showAxis("bottom", show=True)
plot_item.setClipToView(True)
plot.setLabel("bottom", "X", units="m")
plot.setLabel("left", y_label, units="m")
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=False, y=False)
self._disable_gpr_plot_interaction(plot, view_box)
def _ensure_sar3d_plot_items(self) -> None:
"""Create persistent projection image items once and reuse them on redraw."""
if self._sar3d_xz_image_item is not None:
return
self._configure_sar3d_plot_axes()
if self._sar3d_lookup_table is None:
# Same palette as the GPR accumulator so both imagers read alike.
self._sar3d_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
self._sar3d_xz_image_item = pg.ImageItem(axisOrder="row-major")
self._sar3d_xz_image_item.setZValue(0)
self._sar3d_xz_image_item.hide()
self._sar3d_xz_plot.addItem(self._sar3d_xz_image_item)
self._sar3d_xy_image_item = pg.ImageItem(axisOrder="row-major")
self._sar3d_xy_image_item.setZValue(0)
self._sar3d_xy_image_item.hide()
self._sar3d_xy_plot.addItem(self._sar3d_xy_image_item)
def _clear_sar3d_plot(self) -> None:
"""Clear both 3D SAR projection surfaces."""
if not hasattr(self, "_sar3d_xz_plot"):
return
self._configure_sar3d_plot_axes()
if self._sar3d_xz_image_item is not None:
self._sar3d_xz_image_item.hide()
if self._sar3d_xy_image_item is not None:
self._sar3d_xy_image_item.hide()
self._sar3d_xz_plot.setTitle("3D SAR side view (X-Z)")
self._sar3d_xy_plot.setTitle("3D SAR top view (X-Y)")
def _draw_sar3d_projection(self, plot: pg.PlotWidget, image_item: pg.ImageItem, payload) -> bool:
"""Draw one normalized projection payload onto its plot; return success."""
image = np.asarray(payload.image, dtype=np.float32)
x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
if image.ndim != 2 or image.size == 0 or x_axis.size == 0 or y_axis.size == 0:
image_item.hide()
return False
x_min = float(x_axis[0])
x_max = float(x_axis[-1])
y_min = float(y_axis[0])
y_max = float(y_axis[-1])
rect = QRectF(x_min, y_min, max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6))
image_item.setImage(image, autoLevels=False)
image_item.setRect(rect)
image_item.setLookupTable(self._sar3d_lookup_table)
# Projections are normalized to [0, 1] by the processor.
image_item.setLevels((0.0, 1.0))
image_item.show()
plot.setXRange(x_min, x_max, padding=0.0)
plot.setYRange(y_min, y_max, padding=0.0)
return True
def _draw_sar3d_projections(self, collection: ResultCollection) -> bool:
"""Draw the latest collection-level 3D SAR side and top projections."""
xz_payload = self._collection_payload_by_name(collection, "sar3d_proj_xz", kind=3)
xy_payload = self._collection_payload_by_name(collection, "sar3d_proj_xy", kind=3)
if xz_payload is None and xy_payload is None:
self._clear_sar3d_plot()
return False
for plot in (self._sar3d_xz_plot, self._sar3d_xy_plot):
plot.setUpdatesEnabled(False)
try:
self._ensure_sar3d_plot_items()
xz_drawn = xz_payload is not None and self._draw_sar3d_projection(
self._sar3d_xz_plot, self._sar3d_xz_image_item, xz_payload
)
xy_drawn = xy_payload is not None and self._draw_sar3d_projection(
self._sar3d_xy_plot, self._sar3d_xy_image_item, xy_payload
)
if not xz_drawn and not xy_drawn:
self._clear_sar3d_plot()
return False
peak_payload = self._collection_payload_by_name(collection, "sar3d_peak", kind=2)
peak_suffix = ""
if peak_payload is not None:
peak_suffix = f" | peak={float(peak_payload.scalar_value):.3g}"
self._sar3d_xz_plot.setTitle(f"3D SAR side view (X-Z){peak_suffix}")
self._sar3d_xy_plot.setTitle("3D SAR top view (X-Y)")
finally:
for plot in (self._sar3d_xz_plot, self._sar3d_xy_plot):
plot.setUpdatesEnabled(True)
return True
@@ -78,7 +78,7 @@ class AppWindowTracePlotMixin:
def _on_trace_visibility_changed(self, *_args) -> None:
"""Redraw pass-through traces when magnitude/phase toggles changed."""
if self._processing_mode.currentText() in {"bscan", "gpr", "legacy_gpr"}:
if self._processing_mode.currentText() in {"bscan", "gpr", "legacy_gpr", "sar3d"}:
return
if self._result_history:
self._draw_results(self._result_history[-1])
@@ -6,6 +6,7 @@ from python_app.gui.controllers.app_window_plot import (
AppWindowBscanPlotMixin,
AppWindowBscanReplayMixin,
AppWindowGprPlotMixin,
AppWindowSar3dPlotMixin,
AppWindowTracePlotMixin,
)
from python_app.models.dataset_model import ResultCollection
@@ -16,8 +17,9 @@ class AppWindowPlotMixin(
AppWindowBscanPlotMixin,
AppWindowBscanReplayMixin,
AppWindowGprPlotMixin,
AppWindowSar3dPlotMixin,
):
"""Routes plotting to trace, B-scan, or GPR-specific mixins."""
"""Routes plotting to trace, B-scan, GPR, or 3D SAR-specific mixins."""
def _draw_preferred_collection(
self,
@@ -35,6 +37,8 @@ class AppWindowPlotMixin(
return self._draw_bscan_heatmap(collection)
if self._processing_mode.currentText() in {"gpr", "legacy_gpr"}:
return self._draw_gpr_map(collection)
if self._processing_mode.currentText() == "sar3d":
return self._draw_sar3d_projections(collection)
return self._draw_trace_lines(collection)
def _result_collection_has_trace(self, collection: ResultCollection) -> bool:
@@ -74,6 +74,7 @@ class AppWindowUiMixin:
self._plot_stack = QStackedWidget(root)
self._build_bscan_plot_page()
self._build_gpr_plot_page()
self._build_sar3d_plot_page()
self._build_trace_plot_page()
# Default view on startup is pass-through traces.
@@ -139,6 +140,27 @@ class AppWindowUiMixin:
self._configure_gpr_plot_axes()
self._plot_stack.addWidget(self._gpr_plot)
def _build_sar3d_plot_page(self) -> None:
"""Create 3D SAR page with stacked side (X-Z) and top (X-Y) projection plots."""
# 3D SAR surface: container with two projection heatmaps, mirroring the
# pass-through page layout (two stacked PlotWidgets on one stack page).
self._sar3d_plots_container = QWidget()
sar3d_layout = QVBoxLayout(self._sar3d_plots_container)
sar3d_layout.setContentsMargins(0, 0, 0, 0)
sar3d_layout.setSpacing(6)
self._sar3d_xz_plot = self._create_plot_widget(background="#0f141c")
self._sar3d_xz_plot.showGrid(x=True, y=True, alpha=0.2)
sar3d_layout.addWidget(self._sar3d_xz_plot, stretch=1)
self._sar3d_xy_plot = self._create_plot_widget(background="#0f141c")
self._sar3d_xy_plot.showGrid(x=True, y=True, alpha=0.2)
sar3d_layout.addWidget(self._sar3d_xy_plot, stretch=1)
self._configure_sar3d_plot_axes()
self._clear_sar3d_plot()
self._plot_stack.addWidget(self._sar3d_plots_container)
def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None:
"""Create narrow button used to collapse or show settings panel."""
self._settings_toggle_button = QPushButton("<")
@@ -286,6 +308,7 @@ class AppWindowUiMixin:
`bscan` -> show `self._bscan_plot`
`gpr` and `legacy_gpr` -> show `self._gpr_plot`
`sar3d` -> show `self._sar3d_plots_container`
otherwise -> show `self._trace_plots_container`
"""
if mode == "bscan":
@@ -294,6 +317,9 @@ class AppWindowUiMixin:
if mode in {"gpr", "legacy_gpr"}:
self._plot_stack.setCurrentWidget(self._gpr_plot)
return
if mode == "sar3d":
self._plot_stack.setCurrentWidget(self._sar3d_plots_container)
return
self._plot_stack.setCurrentWidget(self._trace_plots_container)
@staticmethod
@@ -59,7 +59,7 @@ def _build_processing_mode_page(parent: QStackedWidget, rows: list[FormRow], *,
def build_processing_group(owner) -> QGroupBox:
"""Create processing mode section with pass-through, B-scan, and GPR pages."""
"""Create processing mode section with pass-through, B-scan, GPR, and 3D SAR pages."""
group = QGroupBox("Processing")
form = QFormLayout(group)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
@@ -68,9 +68,10 @@ def build_processing_group(owner) -> QGroupBox:
bscan_defaults = processing_defaults.bscan
gpr_live_defaults = processing_defaults.gpr
legacy_gpr_defaults = processing_defaults.legacy_gpr
sar3d_defaults = processing_defaults.sar3d
owner._processing_mode = QComboBox()
owner._processing_mode.addItems(["pass_through", "bscan", "gpr", "legacy_gpr"])
owner._processing_mode.addItems(["pass_through", "bscan", "gpr", "legacy_gpr", "sar3d"])
owner._set_combo_current_text(owner._processing_mode, processing_defaults.selected_mode)
owner._processing_mode_pages = QStackedWidget(group)
@@ -570,6 +571,172 @@ def build_processing_group(owner) -> QGroupBox:
)
owner._processing_mode_pages.addWidget(legacy_gpr_page)
# 3D SAR page: shared gpr_* knobs get their own widget set (like legacy_gpr)
# plus the sar3d-specific aperture/volume knobs consumed by the C++ processor.
owner._sar3d_input_positions_input = QLineEdit(str(sar3d_defaults.input_positions))
owner._sar3d_input_positions_input.setPlaceholderText("0,1,2")
owner._sar3d_output_positions_input = QLineEdit(str(sar3d_defaults.output_positions))
owner._sar3d_output_positions_input.setPlaceholderText("0,1")
owner._sar3d_min_depth_m = QDoubleSpinBox()
owner._sar3d_min_depth_m.setDecimals(2)
owner._sar3d_min_depth_m.setRange(0.0, 50.0)
owner._sar3d_min_depth_m.setSingleStep(0.1)
owner._sar3d_min_depth_m.setValue(float(sar3d_defaults.min_depth_m))
owner._sar3d_max_depth_m = QDoubleSpinBox()
owner._sar3d_max_depth_m.setDecimals(2)
owner._sar3d_max_depth_m.setRange(0.1, 50.0)
owner._sar3d_max_depth_m.setSingleStep(0.1)
owner._sar3d_max_depth_m.setValue(float(sar3d_defaults.max_depth_m))
owner._sar3d_start_freq_mhz = QDoubleSpinBox()
owner._sar3d_start_freq_mhz.setDecimals(1)
owner._sar3d_start_freq_mhz.setRange(100.0, 8800.0)
owner._sar3d_start_freq_mhz.setSingleStep(10.0)
owner._sar3d_start_freq_mhz.setValue(float(sar3d_defaults.start_freq_mhz))
owner._sar3d_stop_freq_mhz = QDoubleSpinBox()
owner._sar3d_stop_freq_mhz.setDecimals(1)
owner._sar3d_stop_freq_mhz.setRange(100.0, 8800.0)
owner._sar3d_stop_freq_mhz.setSingleStep(10.0)
owner._sar3d_stop_freq_mhz.setValue(float(sar3d_defaults.stop_freq_mhz))
owner._sar3d_look_angle_deg = QDoubleSpinBox()
owner._sar3d_look_angle_deg.setDecimals(2)
owner._sar3d_look_angle_deg.setRange(-90.0, 90.0)
owner._sar3d_look_angle_deg.setSingleStep(0.5)
owner._sar3d_look_angle_deg.setValue(float(sar3d_defaults.look_angle_deg))
owner._sar3d_look_angle_deg.setToolTip(
"Radar look angle vs the range axis (deg). Used by intra-sweep motion correction."
)
owner._sar3d_direction_sign = QDoubleSpinBox()
owner._sar3d_direction_sign.setDecimals(0)
owner._sar3d_direction_sign.setRange(-1.0, 1.0)
owner._sar3d_direction_sign.setSingleStep(2.0)
owner._sar3d_direction_sign.setValue(float(sar3d_defaults.direction_sign))
owner._sar3d_direction_sign.setToolTip(
"Motion direction along range: +1 (later frequencies deeper) or -1 (shallower)."
)
owner._sar3d_ignore_socket_speed_enabled = QCheckBox("Ignore socket speed")
owner._sar3d_ignore_socket_speed_enabled.setChecked(bool(sar3d_defaults.ignore_socket_speed_enabled))
owner._sar3d_ignore_socket_speed_enabled.setToolTip(
"When checked, use the speed below for motion correction instead of the "
"value streamed over the locator socket."
)
owner._sar3d_speed_m_s = QDoubleSpinBox()
owner._sar3d_speed_m_s.setDecimals(3)
owner._sar3d_speed_m_s.setRange(-100.0, 100.0)
owner._sar3d_speed_m_s.setSingleStep(0.05)
owner._sar3d_speed_m_s.setValue(float(sar3d_defaults.speed_m_s))
owner._sar3d_speed_m_s.setToolTip(
"Radar speed (m/s) used by intra-sweep motion correction when 'Ignore socket speed' is on."
)
owner._sar3d_apply_freq_phase_correction = QCheckBox("Apply intra-sweep phase correction")
owner._sar3d_apply_freq_phase_correction.setChecked(bool(sar3d_defaults.apply_freq_phase_correction))
owner._sar3d_frame_radius = QSpinBox()
owner._sar3d_frame_radius.setRange(0, 25)
owner._sar3d_frame_radius.setValue(int(sar3d_defaults.frame_radius))
owner._sar3d_frame_radius.setToolTip(
"Synthetic-aperture half width in frames: 2*radius+1 consecutive frames form one aperture."
)
owner._sar3d_edge_frame_weight = QDoubleSpinBox()
owner._sar3d_edge_frame_weight.setDecimals(2)
owner._sar3d_edge_frame_weight.setRange(0.0, 1.0)
owner._sar3d_edge_frame_weight.setSingleStep(0.05)
owner._sar3d_edge_frame_weight.setValue(float(sar3d_defaults.edge_frame_weight))
owner._sar3d_edge_frame_weight.setToolTip(
"Weight of the outermost aperture frames (1 = flat window, 0 = strong taper)."
)
owner._sar3d_pitch_deg = QDoubleSpinBox()
owner._sar3d_pitch_deg.setDecimals(2)
owner._sar3d_pitch_deg.setRange(-90.0, 90.0)
owner._sar3d_pitch_deg.setSingleStep(0.5)
owner._sar3d_pitch_deg.setValue(float(sar3d_defaults.pitch_deg))
owner._sar3d_pitch_deg.setToolTip("Antenna pitch above the horizon (deg); tilts the imaged volume.")
owner._sar3d_center_depth_m = QDoubleSpinBox()
owner._sar3d_center_depth_m.setDecimals(2)
owner._sar3d_center_depth_m.setRange(0.0, 50.0)
owner._sar3d_center_depth_m.setSingleStep(0.1)
owner._sar3d_center_depth_m.setValue(float(sar3d_defaults.center_depth_m))
owner._sar3d_center_depth_m.setToolTip("Range distance to the center of the imaged volume (m).")
owner._sar3d_half_width_x_m = QDoubleSpinBox()
owner._sar3d_half_width_x_m.setDecimals(2)
owner._sar3d_half_width_x_m.setRange(0.01, 50.0)
owner._sar3d_half_width_x_m.setSingleStep(0.1)
owner._sar3d_half_width_x_m.setValue(float(sar3d_defaults.half_width_x_m))
owner._sar3d_half_width_y_m = QDoubleSpinBox()
owner._sar3d_half_width_y_m.setDecimals(2)
owner._sar3d_half_width_y_m.setRange(0.01, 50.0)
owner._sar3d_half_width_y_m.setSingleStep(0.1)
owner._sar3d_half_width_y_m.setValue(float(sar3d_defaults.half_width_y_m))
owner._sar3d_half_width_z_m = QDoubleSpinBox()
owner._sar3d_half_width_z_m.setDecimals(2)
owner._sar3d_half_width_z_m.setRange(0.01, 50.0)
owner._sar3d_half_width_z_m.setSingleStep(0.1)
owner._sar3d_half_width_z_m.setValue(float(sar3d_defaults.half_width_z_m))
owner._sar3d_grid_nx = QSpinBox()
owner._sar3d_grid_nx.setRange(2, 1024)
owner._sar3d_grid_nx.setValue(int(sar3d_defaults.grid_nx))
owner._sar3d_grid_ny = QSpinBox()
owner._sar3d_grid_ny.setRange(2, 1024)
owner._sar3d_grid_ny.setValue(int(sar3d_defaults.grid_ny))
owner._sar3d_grid_nz = QSpinBox()
owner._sar3d_grid_nz.setRange(2, 1024)
owner._sar3d_grid_nz.setValue(int(sar3d_defaults.grid_nz))
owner._sar3d_use_compensation = QCheckBox("Use per-voxel compensation")
owner._sar3d_use_compensation.setChecked(bool(sar3d_defaults.use_compensation))
owner._sar3d_normalize_pairs = QCheckBox("Normalize Tx/Rx pairs")
owner._sar3d_normalize_pairs.setChecked(bool(sar3d_defaults.normalize_pairs))
sar3d_page = _build_processing_mode_page(
owner._processing_mode_pages,
[
("Input positions", owner._sar3d_input_positions_input),
("Output positions", owner._sar3d_output_positions_input),
("Min depth m", owner._sar3d_min_depth_m),
("Max depth m", owner._sar3d_max_depth_m),
("Start MHz", owner._sar3d_start_freq_mhz),
("Stop MHz", owner._sar3d_stop_freq_mhz),
("Look angle deg", owner._sar3d_look_angle_deg),
("Direction sign", owner._sar3d_direction_sign),
owner._sar3d_ignore_socket_speed_enabled,
("Speed m/s", owner._sar3d_speed_m_s),
owner._sar3d_apply_freq_phase_correction,
("Frame radius", owner._sar3d_frame_radius),
("Edge frame weight", owner._sar3d_edge_frame_weight),
("Pitch deg", owner._sar3d_pitch_deg),
("Center depth m", owner._sar3d_center_depth_m),
("Half width X m", owner._sar3d_half_width_x_m),
("Half width Y m", owner._sar3d_half_width_y_m),
("Half width Z m", owner._sar3d_half_width_z_m),
("Grid Nx", owner._sar3d_grid_nx),
("Grid Ny", owner._sar3d_grid_ny),
("Grid Nz", owner._sar3d_grid_nz),
owner._sar3d_use_compensation,
owner._sar3d_normalize_pairs,
],
split_index=11,
)
owner._processing_mode_pages.addWidget(sar3d_page)
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)
@@ -642,6 +809,29 @@ def build_processing_group(owner) -> QGroupBox:
owner._legacy_gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
owner._legacy_gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
owner._legacy_gpr_visible_z_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
owner._sar3d_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._sar3d_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._sar3d_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_direction_sign.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_ignore_socket_speed_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._sar3d_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_apply_freq_phase_correction.toggled.connect(owner._on_processing_live_settings_changed)
owner._sar3d_frame_radius.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_edge_frame_weight.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_pitch_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_center_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_half_width_x_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_half_width_y_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_half_width_z_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_grid_nx.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_grid_ny.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_grid_nz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._sar3d_use_compensation.toggled.connect(owner._on_processing_live_settings_changed)
owner._sar3d_normalize_pairs.toggled.connect(owner._on_processing_live_settings_changed)
owner._set_processing_mode_page(owner._processing_mode.currentText())
return group
+3 -1
View File
@@ -24,7 +24,9 @@ def validate_processing_mode_constraints(
)
return
if processing_mode not in {"gpr", "legacy_gpr"}:
# sar3d shares the GPR combo/geometry requirements: it images from the same
# multi-Tx/Rx pair set selected through the gpr_* live-config fields.
if processing_mode not in {"gpr", "legacy_gpr", "sar3d"}:
return
configured_combos = {(int(combo.input), int(combo.output)) for combo in config.combos}
@@ -68,6 +68,24 @@ class PortNotFoundError(CommunicationError):
super().__init__(message)
class PortBusyError(CommunicationError):
"""Raised when the serial port is already held by another program.
A laser controller is a single half-duplex request/response bus: only one
process may own it at a time. When the port is opened exclusively
(``exclusive=True``), a second exclusive open fails with this error instead
of silently sharing the bus and corrupting both sides' traffic.
"""
def __init__(self, port: str = None):
target = f"'{port}'" if port else "the laser controller port"
super().__init__(
f"Serial port {target} is busy — another program is already using "
f"the laser controller. Close it first (only one program may own the "
f"port at a time)."
)
class DeviceNotRespondingError(CommunicationError):
"""Exception raised when device doesn't respond to commands."""
@@ -33,6 +33,7 @@ from .models import Measurements, VariationType
from .exceptions import (
CommunicationError,
PortNotFoundError,
PortBusyError,
CRCError,
ProtocolError,
)
@@ -184,20 +185,36 @@ class Protocol:
# ---- Connection management
def connect(self) -> None:
"""Open the serial port. Auto-detects the device path when port is None."""
"""Open the serial port. Auto-detects the device path when port is None.
The port is opened with ``exclusive=True`` so only one program can own the
laser controller at a time. This is required because the board is a single
half-duplex request/response bus: if two processes opened it, their
commands and responses would interleave and corrupt each other. A second
opener fails fast with :class:`PortBusyError` instead of silently sharing
the bus. Note the lock is advisory (``flock``): every program that opens
this port must also use ``exclusive=True`` for the guarantee to hold.
"""
port = self._port_name or self._detect_port()
try:
self._serial = serial.Serial(
port=port,
baudrate=BAUDRATE,
timeout=SERIAL_TIMEOUT_SEC,
exclusive=True,
)
except serial.SerialException as exc:
# pyserial raises SerialException with errno EAGAIN/EWOULDBLOCK (11)
# when another process already holds the exclusive flock.
if getattr(exc, "errno", None) in (11,) or "exclusively lock" in str(exc).lower():
logger.error("Laser serial port '%s' is busy (held by another program)", port)
raise PortBusyError(port) from exc
logger.error("Cannot open laser serial port '%s': %s", port, exc)
raise CommunicationError(f"Cannot connect to port '{port}': {exc}") from exc
except Exception as exc:
logger.error("Cannot open laser serial port '%s': %s", port, exc)
raise CommunicationError(
f"Cannot connect to port '{port}': {exc}"
) from exc
logger.debug("Laser serial port opened: %s @ %d baud", port, BAUDRATE)
raise CommunicationError(f"Cannot connect to port '{port}': {exc}") from exc
logger.debug("Laser serial port opened (exclusive): %s @ %d baud", port, BAUDRATE)
def disconnect(self) -> None:
"""Close the serial port if open."""
+162 -2
View File
@@ -14,6 +14,7 @@ from python_app.models.gui_profile_schema import (
GuiPreprocessDialogStateModel,
GuiProcessingStateModel,
GuiProfileModel,
GuiSar3dStateModel,
GuiStateModel,
GuiSwitchStateModel,
)
@@ -106,6 +107,7 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
bscan_object = _as_dict(processing_object.get("bscan"), "gui.processing.bscan")
gpr_object = _as_dict(processing_object.get("gpr"), "gui.processing.gpr")
legacy_gpr_object = _as_dict(processing_object.get("legacy_gpr"), "gui.processing.legacy_gpr")
sar3d_object = _as_dict(processing_object.get("sar3d"), "gui.processing.sar3d")
root_gpr_object = payload.get("gpr")
selected_mode = _optional_string(
@@ -452,9 +454,136 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
visible_z_min_m=legacy_float("visible_z_min_m", gui.processing.legacy_gpr.visible_z_min_m),
visible_z_max_m=legacy_float("visible_z_max_m", gui.processing.legacy_gpr.visible_z_max_m),
),
sar3d=GuiSar3dStateModel(
input_positions=_optional_string(
sar3d_object,
"input_positions",
gui.processing.sar3d.input_positions,
"gui.processing.sar3d",
),
output_positions=_optional_string(
sar3d_object,
"output_positions",
gui.processing.sar3d.output_positions,
"gui.processing.sar3d",
),
min_depth_m=_optional_float(
sar3d_object,
"min_depth_m",
gui.processing.sar3d.min_depth_m,
"gui.processing.sar3d",
),
max_depth_m=_optional_float(
sar3d_object,
"max_depth_m",
gui.processing.sar3d.max_depth_m,
"gui.processing.sar3d",
),
start_freq_mhz=_optional_float(
sar3d_object,
"start_freq_mhz",
gui.processing.sar3d.start_freq_mhz,
"gui.processing.sar3d",
),
stop_freq_mhz=_optional_float(
sar3d_object,
"stop_freq_mhz",
gui.processing.sar3d.stop_freq_mhz,
"gui.processing.sar3d",
),
speed_m_s=_optional_float(
sar3d_object,
"speed_m_s",
gui.processing.sar3d.speed_m_s,
"gui.processing.sar3d",
),
ignore_socket_speed_enabled=_optional_bool(
sar3d_object,
"ignore_socket_speed_enabled",
gui.processing.sar3d.ignore_socket_speed_enabled,
"gui.processing.sar3d",
),
look_angle_deg=_optional_float(
sar3d_object,
"look_angle_deg",
gui.processing.sar3d.look_angle_deg,
"gui.processing.sar3d",
),
direction_sign=_optional_float(
sar3d_object,
"direction_sign",
gui.processing.sar3d.direction_sign,
"gui.processing.sar3d",
),
apply_freq_phase_correction=_optional_bool(
sar3d_object,
"apply_freq_phase_correction",
gui.processing.sar3d.apply_freq_phase_correction,
"gui.processing.sar3d",
),
frame_radius=_optional_int(
sar3d_object,
"frame_radius",
gui.processing.sar3d.frame_radius,
"gui.processing.sar3d",
),
edge_frame_weight=_optional_float(
sar3d_object,
"edge_frame_weight",
gui.processing.sar3d.edge_frame_weight,
"gui.processing.sar3d",
),
pitch_deg=_optional_float(
sar3d_object,
"pitch_deg",
gui.processing.sar3d.pitch_deg,
"gui.processing.sar3d",
),
center_depth_m=_optional_float(
sar3d_object,
"center_depth_m",
gui.processing.sar3d.center_depth_m,
"gui.processing.sar3d",
),
half_width_x_m=_optional_float(
sar3d_object,
"half_width_x_m",
gui.processing.sar3d.half_width_x_m,
"gui.processing.sar3d",
),
half_width_y_m=_optional_float(
sar3d_object,
"half_width_y_m",
gui.processing.sar3d.half_width_y_m,
"gui.processing.sar3d",
),
half_width_z_m=_optional_float(
sar3d_object,
"half_width_z_m",
gui.processing.sar3d.half_width_z_m,
"gui.processing.sar3d",
),
grid_nx=_optional_int(sar3d_object, "grid_nx", gui.processing.sar3d.grid_nx, "gui.processing.sar3d"),
grid_ny=_optional_int(sar3d_object, "grid_ny", gui.processing.sar3d.grid_ny, "gui.processing.sar3d"),
grid_nz=_optional_int(sar3d_object, "grid_nz", gui.processing.sar3d.grid_nz, "gui.processing.sar3d"),
use_compensation=_optional_bool(
sar3d_object,
"use_compensation",
gui.processing.sar3d.use_compensation,
"gui.processing.sar3d",
),
normalize_pairs=_optional_bool(
sar3d_object,
"normalize_pairs",
gui.processing.sar3d.normalize_pairs,
"gui.processing.sar3d",
),
),
)
if gui.processing.selected_mode not in {"pass_through", "bscan", "gpr", "legacy_gpr"}:
raise ValueError("gui.processing.selected_mode must be one of: pass_through, bscan, gpr, legacy_gpr")
if gui.processing.selected_mode not in {"pass_through", "bscan", "gpr", "legacy_gpr", "sar3d"}:
raise ValueError(
"gui.processing.selected_mode must be one of: pass_through, bscan, gpr, legacy_gpr, sar3d"
)
if gui.processing.bscan.axis not in {"abs", "real", "phase"}:
raise ValueError("gui.processing.bscan.axis must be one of: abs, real, phase")
if gui.processing.gpr.render_mode not in {"heatmap", "objects_only"}:
@@ -489,6 +618,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
raise ValueError("gui.processing.legacy_gpr.snr_comp_max must be >= 0")
if gui.processing.legacy_gpr.min_visible_pair_count < 1:
raise ValueError("gui.processing.legacy_gpr.min_visible_pair_count must be >= 1")
if gui.processing.sar3d.frame_radius < 0:
raise ValueError("gui.processing.sar3d.frame_radius must be >= 0")
if not 0.0 <= gui.processing.sar3d.edge_frame_weight <= 1.0:
raise ValueError("gui.processing.sar3d.edge_frame_weight must be within [0, 1]")
if min(gui.processing.sar3d.grid_nx, gui.processing.sar3d.grid_ny, gui.processing.sar3d.grid_nz) < 2:
raise ValueError("gui.processing.sar3d.grid_nx/ny/nz must each be >= 2")
data_actions_object = _as_dict(gui_object.get("data_actions"), "gui.data_actions")
gui.data_actions = GuiDataActionsStateModel(
@@ -640,6 +775,31 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"visible_z_min_m": gui.processing.legacy_gpr.visible_z_min_m,
"visible_z_max_m": gui.processing.legacy_gpr.visible_z_max_m,
},
"sar3d": {
"input_positions": gui.processing.sar3d.input_positions,
"output_positions": gui.processing.sar3d.output_positions,
"min_depth_m": gui.processing.sar3d.min_depth_m,
"max_depth_m": gui.processing.sar3d.max_depth_m,
"start_freq_mhz": gui.processing.sar3d.start_freq_mhz,
"stop_freq_mhz": gui.processing.sar3d.stop_freq_mhz,
"speed_m_s": gui.processing.sar3d.speed_m_s,
"ignore_socket_speed_enabled": gui.processing.sar3d.ignore_socket_speed_enabled,
"look_angle_deg": gui.processing.sar3d.look_angle_deg,
"direction_sign": gui.processing.sar3d.direction_sign,
"apply_freq_phase_correction": gui.processing.sar3d.apply_freq_phase_correction,
"frame_radius": gui.processing.sar3d.frame_radius,
"edge_frame_weight": gui.processing.sar3d.edge_frame_weight,
"pitch_deg": gui.processing.sar3d.pitch_deg,
"center_depth_m": gui.processing.sar3d.center_depth_m,
"half_width_x_m": gui.processing.sar3d.half_width_x_m,
"half_width_y_m": gui.processing.sar3d.half_width_y_m,
"half_width_z_m": gui.processing.sar3d.half_width_z_m,
"grid_nx": gui.processing.sar3d.grid_nx,
"grid_ny": gui.processing.sar3d.grid_ny,
"grid_nz": gui.processing.sar3d.grid_nz,
"use_compensation": gui.processing.sar3d.use_compensation,
"normalize_pairs": gui.processing.sar3d.normalize_pairs,
},
},
"data_actions": {
"save_count": gui.data_actions.save_count,
+2
View File
@@ -10,6 +10,7 @@ from python_app.models.gui_profile_schema import (
GuiPreprocessDialogStateModel,
GuiProcessingStateModel,
GuiProfileModel,
GuiSar3dStateModel,
GuiStateModel,
GuiSwitchStateModel,
)
@@ -23,6 +24,7 @@ __all__ = [
"GuiPreprocessDialogStateModel",
"GuiProcessingStateModel",
"GuiProfileModel",
"GuiSar3dStateModel",
"GuiStateModel",
"GuiSwitchStateModel",
"gui_profile_from_dict",
+35
View File
@@ -123,6 +123,40 @@ class GuiLegacyGprStateModel:
visible_z_max_m: float = 14.0
@dataclass(slots=True)
class GuiSar3dStateModel:
"""UI-only defaults for the 3D SAR (sar3d) live settings.
The band, depth gate, antenna selection and motion knobs mirror the shared
gpr_* live-config fields; the sar3d_* knobs mirror the VNA_AlGo gpr_core
defaults used by the C++ sar3d processor.
"""
input_positions: str = ""
output_positions: str = ""
min_depth_m: float = 2.0
max_depth_m: float = 14.0
start_freq_mhz: float = 3000.0
stop_freq_mhz: float = 6000.0
speed_m_s: float = 0.0
ignore_socket_speed_enabled: bool = False
look_angle_deg: float = 0.0
direction_sign: float = 1.0
apply_freq_phase_correction: bool = True
frame_radius: int = 6
edge_frame_weight: float = 0.75
pitch_deg: float = 6.0
center_depth_m: float = 5.6
half_width_x_m: float = 1.8
half_width_y_m: float = 1.0
half_width_z_m: float = 1.8
grid_nx: int = 150
grid_ny: int = 120
grid_nz: int = 160
use_compensation: bool = True
normalize_pairs: bool = True
@dataclass(slots=True)
class GuiProcessingStateModel:
"""UI-only processing-section defaults."""
@@ -132,6 +166,7 @@ class GuiProcessingStateModel:
bscan: GuiBscanStateModel = field(default_factory=GuiBscanStateModel)
gpr: GuiGprStateModel = field(default_factory=GuiGprStateModel)
legacy_gpr: GuiLegacyGprStateModel = field(default_factory=GuiLegacyGprStateModel)
sar3d: GuiSar3dStateModel = field(default_factory=GuiSar3dStateModel)
@dataclass(slots=True)
+13
View File
@@ -175,6 +175,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
"run.locator_server",
)
multi_device_payload = _as_dict(radar_payload.get("multi_device"), "radar.multi_device")
replay_payload = _as_dict(radar_payload.get("replay"), "radar.replay")
kamil_adc_payload = _as_dict(radar_payload.get("kamil_adc"), "radar.kamil_adc")
laser_control_payload = _as_dict(radar_payload.get("laser_control"), "radar.laser_control")
@@ -235,6 +236,13 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
"stimulus_switch_positions",
model.radar.multi_device.stimulus_switch_positions,
)
model.radar.replay.dataset_dir = _read_str(
replay_payload, "dataset_dir", model.radar.replay.dataset_dir
)
model.radar.replay.loop = _read_bool(replay_payload, "loop", model.radar.replay.loop)
model.radar.replay.rate_scale = _read_float(
replay_payload, "rate_scale", model.radar.replay.rate_scale
)
model.radar.kamil_adc.project_dir = _read_str(
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
)
@@ -519,6 +527,11 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"input_switch_positions": model.radar.multi_device.input_switch_positions,
"stimulus_switch_positions": model.radar.multi_device.stimulus_switch_positions
},
"replay": {
"dataset_dir": model.radar.replay.dataset_dir,
"loop": model.radar.replay.loop,
"rate_scale": model.radar.replay.rate_scale,
},
"kamil_adc": {
"project_dir": model.radar.kamil_adc.project_dir,
"executable_path": model.radar.kamil_adc.executable_path,
+20
View File
@@ -52,6 +52,25 @@ class RadarMultiDeviceModel:
stimulus_switch_positions: int = 1
@dataclass(slots=True)
class RadarReplayModel:
"""Recorded-dataset replay settings (no hardware).
When ``dataset_dir`` is non-empty the supervisor launches
``python_app.scripts.replay_raw_producer`` instead of the model's normal
acquisition producer. The dataset is a directory of snapshot folders named
``NNNN_idK_ns<timestamp>``, each holding ``i{input}_o{output}_s21.npy`` /
``_freq.npy`` files (the VNA_AlGo preprocessed-capture layout).
"""
dataset_dir: str = ""
# Restart from the first snapshot when the dataset ends; the replay
# timeline keeps advancing monotonically across the seam.
loop: bool = True
# Playback speed multiplier: 1.0 replays at the recorded frame cadence.
rate_scale: float = 1.0
@dataclass(slots=True)
class KamilAdcPhaseCalibrationModel:
"""Affine law mapping the reference signal's unwrapped phase to frequency.
@@ -159,6 +178,7 @@ class RadarModel:
visa_library: str = ""
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
replay: RadarReplayModel = field(default_factory=RadarReplayModel)
kamil_adc: KamilAdcModel = field(default_factory=KamilAdcModel)
laser_control: LaserControlModel = field(default_factory=LaserControlModel)
@@ -72,6 +72,21 @@ class ProcessingLiveConfig:
gpr_visible_x_max_m: float = 2.0
gpr_visible_z_min_m: float = 0.0
gpr_visible_z_max_m: float = 14.0
# 3D SAR (sar3d processor) parameters; defaults mirror VNA_AlGo gpr_core.
# The frequency band, depth gate, antenna selection and motion parameters
# are shared with the gpr_* fields above.
sar3d_frame_radius: int = 6
sar3d_edge_frame_weight: float = 0.75
sar3d_pitch_deg: float = 6.0
sar3d_center_depth_m: float = 5.6
sar3d_half_width_x_m: float = 1.8
sar3d_half_width_y_m: float = 1.0
sar3d_half_width_z_m: float = 1.8
sar3d_grid_nx: int = 150
sar3d_grid_ny: int = 120
sar3d_grid_nz: int = 160
sar3d_use_compensation: bool = True
sar3d_normalize_pairs: bool = True
# When true, the C++ data_processor ignores socket-supplied vlc updates
# and keeps using `gpr_speed_m_s` from this file.
ignore_socket_speed: bool = False
@@ -136,6 +151,18 @@ class ProcessingLiveConfig:
"gpr_visible_x_max_m": float(self.gpr_visible_x_max_m),
"gpr_visible_z_min_m": float(self.gpr_visible_z_min_m),
"gpr_visible_z_max_m": float(self.gpr_visible_z_max_m),
"sar3d_frame_radius": int(self.sar3d_frame_radius),
"sar3d_edge_frame_weight": float(self.sar3d_edge_frame_weight),
"sar3d_pitch_deg": float(self.sar3d_pitch_deg),
"sar3d_center_depth_m": float(self.sar3d_center_depth_m),
"sar3d_half_width_x_m": float(self.sar3d_half_width_x_m),
"sar3d_half_width_y_m": float(self.sar3d_half_width_y_m),
"sar3d_half_width_z_m": float(self.sar3d_half_width_z_m),
"sar3d_grid_nx": int(self.sar3d_grid_nx),
"sar3d_grid_ny": int(self.sar3d_grid_ny),
"sar3d_grid_nz": int(self.sar3d_grid_nz),
"sar3d_use_compensation": bool(self.sar3d_use_compensation),
"sar3d_normalize_pairs": bool(self.sar3d_normalize_pairs),
"ignore_socket_speed": bool(self.ignore_socket_speed),
"reprocess_current_result": bool(self.reprocess_current_result),
"history_command_seq": int(self.history_command_seq),
@@ -283,6 +283,19 @@ class ProcessSupervisor:
def _acquisition_command(self, config_path: Path) -> list[str]:
"""Return acquisition producer command selected by radar.model."""
# A configured replay dataset overrides the model's normal producer:
# recorded snapshots are replayed into the raw ring instead of
# acquiring from (mock) hardware.
replay_dir = self._read_replay_dataset_dir(config_path)
if replay_dir:
logger.debug("Selecting replay producer for dataset_dir=%s", replay_dir)
return [
sys.executable,
"-m",
"python_app.scripts.replay_raw_producer",
"--config",
str(config_path),
]
radar_model = self._read_radar_model(config_path)
logger.debug("Selecting acquisition producer for radar.model=%s", radar_model)
if radar_model in {"librevna_multi", "sn9000"}:
@@ -318,6 +331,20 @@ class ProcessSupervisor:
return "librevna"
return str(radar_payload.get("model", "librevna"))
@staticmethod
def _read_replay_dataset_dir(config_path: Path) -> str:
"""Read `radar.replay.dataset_dir` cheaply, '' when replay is off."""
payload = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return ""
radar_payload = payload.get("radar")
if not isinstance(radar_payload, dict):
return ""
replay_payload = radar_payload.get("replay")
if not isinstance(replay_payload, dict):
return ""
return str(replay_payload.get("dataset_dir", "")).strip()
def _stop_processes(self, names: Iterable[str]) -> None:
"""Gracefully terminate processes, then force-kill on per-process timeout."""
ordered_names = list(names)
@@ -591,6 +618,7 @@ class ProcessSupervisor:
"build/bin/sweep_orchestrator",
"python_app.scripts.matrix_raw_producer",
"python_app.scripts.kamil_adc_raw_producer",
"python_app.scripts.replay_raw_producer",
)
try:
raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
@@ -0,0 +1,90 @@
"""CLI to create neutral S21 preprocess sets for a profile (headless).
Mirrors the GUI's "Create neutral sets" button: builds calibration = 1+0j and
reference = 0 collections for every combo of the profile and saves them under
the profile's radar key, so the data_preprocessor becomes a bit-exact
pass-through. Needed for replay profiles whose recorded data is already
preprocessed (see run_config_examples/run_config_replay_vna.example.json).
Usage:
python -m python_app.scripts.create_neutral_preprocess_sets \
--config run_config_examples/run_config_replay_vna.example.json \
[--set-name neutral_replay] [--force]
"""
from __future__ import annotations
import argparse
import logging
from pathlib import Path
from python_app.models.run_config_model import RunConfigModel
from python_app.storage.npz_store import NpzStore, radar_key_from_config
from python_app.workflows.kamil_adc_neutral_preprocess import (
build_neutral_s21_sets,
supports_neutral_preprocess_sets,
)
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def main() -> int:
"""Create and save the neutral S21 calibration/reference pair."""
parser = argparse.ArgumentParser(description="Create neutral S21 preprocess sets")
parser.add_argument("--config", required=True, type=Path, help="Path to a run_config profile")
parser.add_argument("--set-name", default="neutral_replay", help="Name for both saved sets")
parser.add_argument(
"--force", action="store_true", help="Overwrite the sets if they already exist"
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
config = RunConfigModel.load_from_path(args.config)
config.ensure_combos()
if not supports_neutral_preprocess_sets(config):
raise RuntimeError(
"Neutral S21 sets are available only for kamil_adc and librevna_multi"
)
radar_key = radar_key_from_config(
model_name=config.radar.model,
serial=config.radar.serial,
sweep_start_hz=config.radar.sweep.start_hz,
sweep_stop_hz=config.radar.sweep.stop_hz,
sweep_points=config.radar.sweep.points,
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
power_dbm=config.radar.sweep.power_dbm,
extra_serials=config.radar_key_extra_parts() or None,
)
store = NpzStore(PROJECT_ROOT / "python_app/data")
existing = [
kind
for kind in ("s21_calibration", "s21_reference")
if args.set_name in store.list_sets(kind, radar_key)
]
if existing and not args.force:
logger.info(
"Sets '%s' already exist for %s (radar_key=%s); nothing to do (use --force to overwrite).",
args.set_name, ", ".join(existing), radar_key,
)
return 0
calibration, reference = build_neutral_s21_sets(config)
store.save_set("s21_calibration", radar_key, args.set_name, calibration)
store.save_set("s21_reference", radar_key, args.set_name, reference)
logger.info(
"Neutral S21 sets saved: set=%s, radar_key=%s, combos=%d, points=%d",
args.set_name,
radar_key,
len(calibration.traces),
int(calibration.traces[0].frequency_hz.size),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+245
View File
@@ -0,0 +1,245 @@
"""Raw producer that replays a recorded snapshot dataset (no hardware).
Selected by the supervisor instead of the model's normal acquisition producer
when ``radar.replay.dataset_dir`` is non-empty. The dataset is a directory of
snapshot folders named ``NNNN_idK_ns<timestamp>`` (the VNA_AlGo
preprocessed-capture layout), each holding ``i{input}_o{output}_s21.npy`` and
``i{input}_o{output}_freq.npy`` files for every run combo.
The recorded data is already background-subtracted, so the profile must pair
this producer with NEUTRAL preprocess sets (calibration = 1, reference = 0,
no notch) — then the C++ preprocessor passes the traces through bit-exactly
and the processors (e.g. sar3d) see the recorded scene.
Timeline: snapshot timestamps are re-anchored to this process's monotonic
clock, preserving the recorded inter-frame gaps. On loop, the seam advances by
the median inter-frame gap, so motion-model consumers (sar3d aperture offsets)
never see time jump backwards. ``collection_id`` increases forever.
"""
from __future__ import annotations
import argparse
import logging
from pathlib import Path
import re
import signal
import statistics
import threading
import time
import numpy as np
from python_app.logging_setup import coerce_level
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
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
logger = logging.getLogger(__name__)
_SNAPSHOT_NAME = re.compile(r"^\d+_id\d+_ns(\d+)$")
class ReplayDataset:
"""Recorded snapshots preloaded into memory, with their recorded timeline."""
def __init__(self, snapshots: list[list[TraceData]], timestamps_ns: list[int]) -> None:
self.snapshots = snapshots
self.timestamps_ns = timestamps_ns
gaps = [b - a for a, b in zip(timestamps_ns, timestamps_ns[1:])]
# Median gap doubles as the loop-seam step and the capture-window span;
# a single-snapshot dataset falls back to an arbitrary sane cadence.
self.median_gap_ns = int(statistics.median(gaps)) if gaps else 160_000_000
def __len__(self) -> int:
return len(self.snapshots)
def _load_dataset(dataset_dir: Path, combos: list[ComboKey], expected_points: int) -> ReplayDataset:
"""Preload every snapshot's traces; fail loudly on a malformed dataset."""
if not dataset_dir.is_dir():
raise RuntimeError(f"radar.replay.dataset_dir is not a directory: {dataset_dir}")
named: list[tuple[str, int]] = []
for entry in sorted(dataset_dir.iterdir()):
match = _SNAPSHOT_NAME.match(entry.name)
if entry.is_dir() and match:
named.append((entry.name, int(match.group(1))))
if not named:
raise RuntimeError(
f"No snapshot folders (NNNN_idK_ns<ts>) found in {dataset_dir}"
)
snapshots: list[list[TraceData]] = []
timestamps_ns: list[int] = []
for name, timestamp_ns in named:
folder = dataset_dir / name
traces: list[TraceData] = []
for combo in combos:
s21_path = folder / f"i{combo.input}_o{combo.output}_s21.npy"
freq_path = folder / f"i{combo.input}_o{combo.output}_freq.npy"
if not s21_path.is_file() or not freq_path.is_file():
raise RuntimeError(
f"Snapshot {name} is missing files for combo "
f"input={combo.input} output={combo.output}: expected "
f"{s21_path.name} and {freq_path.name}. Check run.combos vs the dataset."
)
s21 = np.load(s21_path).astype(np.complex64, copy=False)
freq = np.load(freq_path).astype(np.float32, copy=False)
if s21.size != freq.size:
raise RuntimeError(f"{s21_path}: s21 size {s21.size} != freq size {freq.size}")
if expected_points and s21.size != expected_points:
raise RuntimeError(
f"{s21_path}: {s21.size} points, but radar.sweep.points = "
f"{expected_points}. Align the profile's sweep with the dataset."
)
traces.append(
TraceData(
combo=combo,
frequency_hz=freq,
s11=np.zeros_like(s21),
s21=s21,
)
)
snapshots.append(traces)
timestamps_ns.append(timestamp_ns)
return ReplayDataset(snapshots, timestamps_ns)
def _build_collection(
dataset: ReplayDataset,
index: int,
collection_id: int,
frame_ns: int,
) -> SweepCollection:
"""Wrap one preloaded snapshot in a SweepCollection at the given frame time."""
span_ns = dataset.median_gap_ns
start_ns = frame_ns - span_ns // 2
end_ns = frame_ns + span_ns // 2
traces = []
for source in dataset.snapshots[index]:
traces.append(
TraceData(
combo=source.combo,
frequency_hz=source.frequency_hz,
s11=source.s11,
s21=source.s21,
capture_start_ns=start_ns,
capture_end_ns=end_ns,
)
)
return SweepCollection(
collection_id=collection_id,
monotonic_ns=frame_ns,
traces=traces,
capture_start_ns=start_ns,
capture_end_ns=end_ns,
)
def main() -> int:
"""Replay the configured dataset into the raw SHM rings until stopped."""
parser = argparse.ArgumentParser(description="Replay a recorded dataset to raw SHM rings")
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
stop_requested = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_requested.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
config = RunConfigModel.load_from_path(args.config)
config.apply_device_model_constraints()
logging.getLogger("python_app").setLevel(coerce_level(config.logging.level))
replay = config.radar.replay
if not replay.dataset_dir:
raise RuntimeError("replay_raw_producer requires a non-empty radar.replay.dataset_dir")
rate_scale = float(replay.rate_scale) if replay.rate_scale > 0 else 1.0
combos = [ComboKey(input=combo.input, output=combo.output) for combo in config.combos]
dataset = _load_dataset(Path(replay.dataset_dir), combos, int(config.radar.sweep.points))
logger.info(
"Replay producer starting: dataset=%s, snapshots=%d, combos=%d, "
"median_gap=%.3f s, loop=%s, rate_scale=%.2f",
replay.dataset_dir, len(dataset), len(combos),
dataset.median_gap_ns / 1e9, replay.loop, rate_scale,
)
raw_writer = ShmRingWriter(
config.rings.raw.name,
config.rings.raw.capacity,
config.rings.raw.slot_size_bytes,
)
raw_tap_writer = ShmRingWriter(
config.rings.raw_tap.name,
config.rings.raw_tap.capacity,
config.rings.raw_tap.slot_size_bytes,
)
try:
base_ns = time.monotonic_ns()
wall_start_s = time.monotonic()
first_ts = dataset.timestamps_ns[0]
cycle_span_ns = dataset.timestamps_ns[-1] - first_ts + dataset.median_gap_ns
collection_id = 1
publish_failures = 0
cycle = 0
while not stop_requested.is_set():
for index in range(len(dataset)):
if stop_requested.is_set():
break
# Monotonic replay timeline: recorded offsets re-anchored at
# base_ns, each loop shifted by one full cycle span.
frame_ns = (
base_ns
+ (dataset.timestamps_ns[index] - first_ts)
+ cycle * cycle_span_ns
)
# Publish on the recorded cadence (scaled); waiting via the stop
# event keeps shutdown prompt.
deadline_s = wall_start_s + (frame_ns - base_ns) / 1e9 / rate_scale
delay_s = deadline_s - time.monotonic()
if delay_s > 0 and stop_requested.wait(delay_s):
break
collection = _build_collection(dataset, index, collection_id, frame_ns)
payload = serialize_trace_collection(collection, RAW_MAGIC)
if raw_writer.push(payload):
raw_tap_writer.push(payload) # best-effort GUI tap; never fatal
else:
publish_failures += 1
if publish_failures == 1 or publish_failures % 100 == 0:
logger.error(
"Replay payload %d B exceeds ring slot %d B; dropping collection %d (drops=%d)",
len(payload), raw_writer.slot_size_bytes, collection_id, publish_failures,
)
if collection_id == 1 or collection_id % 50 == 0:
logger.info(
"replay collection %d published (snapshot %d/%d, cycle %d)",
collection_id, index + 1, len(dataset), cycle,
)
collection_id += 1
if not config.runtime.continuous:
stop_requested.set()
break
if not replay.loop:
break
cycle += 1
finally:
raw_tap_writer.close()
raw_writer.close()
logger.info("replay raw producer stopped")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+246
View File
@@ -0,0 +1,246 @@
"""Headless smoke: replay the recorded VNA_AlGo dataset through the full pipeline.
Loads the replay profile, creates neutral preprocess sets, launches the whole
pipeline (replay_raw_producer -> data_preprocessor -> data_processor) via the
ProcessSupervisor with unique ring names, forces the sar3d processor through
processing_live.json, and asserts that:
- sar3d projection payloads arrive with a positive peak;
- collection ids increase monotonically (including across a dataset loop);
- the preprocessor is transparent: a preprocessed_tap trace equals the
recorded snapshot's s21 within float32 rounding.
Usage:
python -m python_app.scripts.replay_smoke_run \
[--config run_config_examples/run_config_replay_vna.example.json] \
[--duration 20.0]
"""
from __future__ import annotations
import argparse
import json
import logging
import os
from pathlib import Path
import time
import numpy as np
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.config_writer import ConfigWriter
from python_app.orchestration.live_processing_config import (
ProcessingLiveConfig,
ProcessingLiveConfigWriter,
)
from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.orchestration.shm import ShmRingReader
from python_app.orchestration.shm.decoder import PREPROC_MAGIC, decode_trace_collection
from python_app.storage.npz_store import NpzStore, radar_key_from_config
from python_app.workflows.kamil_adc_neutral_preprocess import build_neutral_s21_sets
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def _make_unique_ring_name(prefix: str) -> str:
"""Build unique POSIX SHM ring name."""
return f"/{prefix}_{os.getpid()}_{time.monotonic_ns()}"
def _sar3d_live_config(profile_path: Path) -> ProcessingLiveConfig:
"""Build a sar3d live config from the profile's gui.processing.sar3d section.
RunConfigModel does not carry the GUI section (it belongs to the GUI profile
layer), so the sar3d defaults are read straight from the profile JSON.
"""
payload = json.loads(profile_path.read_text(encoding="utf-8"))
sar3d = (
payload.get("gui", {}).get("processing", {}).get("sar3d", {})
if isinstance(payload, dict)
else {}
)
def _positions(text: str) -> list[int]:
return [int(value) for value in str(text).split(",") if value.strip()]
live = ProcessingLiveConfig(processor_mode="sar3d", ignore_socket_speed=True)
if not isinstance(sar3d, dict) or not sar3d:
return live
live.gpr_input_positions = _positions(sar3d.get("input_positions", ""))
live.gpr_output_positions = _positions(sar3d.get("output_positions", ""))
live.gpr_min_depth_m = float(sar3d.get("min_depth_m", live.gpr_min_depth_m))
live.gpr_max_depth_m = float(sar3d.get("max_depth_m", live.gpr_max_depth_m))
live.gpr_start_freq_mhz = float(sar3d.get("start_freq_mhz", live.gpr_start_freq_mhz))
live.gpr_stop_freq_mhz = float(sar3d.get("stop_freq_mhz", live.gpr_stop_freq_mhz))
live.gpr_speed_m_s = float(sar3d.get("speed_m_s", live.gpr_speed_m_s))
live.gpr_look_angle_deg = float(sar3d.get("look_angle_deg", live.gpr_look_angle_deg))
live.gpr_direction_sign = float(sar3d.get("direction_sign", live.gpr_direction_sign))
live.gpr_apply_freq_phase_correction = bool(
sar3d.get("apply_freq_phase_correction", live.gpr_apply_freq_phase_correction)
)
live.sar3d_frame_radius = int(sar3d.get("frame_radius", live.sar3d_frame_radius))
live.sar3d_edge_frame_weight = float(
sar3d.get("edge_frame_weight", live.sar3d_edge_frame_weight)
)
live.sar3d_pitch_deg = float(sar3d.get("pitch_deg", live.sar3d_pitch_deg))
live.sar3d_center_depth_m = float(sar3d.get("center_depth_m", live.sar3d_center_depth_m))
live.sar3d_half_width_x_m = float(sar3d.get("half_width_x_m", live.sar3d_half_width_x_m))
live.sar3d_half_width_y_m = float(sar3d.get("half_width_y_m", live.sar3d_half_width_y_m))
live.sar3d_half_width_z_m = float(sar3d.get("half_width_z_m", live.sar3d_half_width_z_m))
live.sar3d_grid_nx = int(sar3d.get("grid_nx", live.sar3d_grid_nx))
live.sar3d_grid_ny = int(sar3d.get("grid_ny", live.sar3d_grid_ny))
live.sar3d_grid_nz = int(sar3d.get("grid_nz", live.sar3d_grid_nz))
live.sar3d_use_compensation = bool(sar3d.get("use_compensation", live.sar3d_use_compensation))
live.sar3d_normalize_pairs = bool(sar3d.get("normalize_pairs", live.sar3d_normalize_pairs))
return live
def _check_preprocessor_transparency(
tap_reader: ShmRingReader, dataset_dir: Path
) -> bool:
"""Compare one preprocessed trace against the recorded snapshot's s21."""
payload = tap_reader.peek_latest_payload()
if payload is None:
logger.warning("No preprocessed_tap payload available for transparency check")
return False
preprocessed = decode_trace_collection(payload, PREPROC_MAGIC)
# Any snapshot works: neutral preprocessing is time-invariant, so match the
# trace against every recorded snapshot and accept the best one.
trace = preprocessed.traces[0]
combo_file = f"i{trace.combo.input}_o{trace.combo.output}_s21.npy"
best_delta = np.inf
for folder in sorted(p for p in dataset_dir.iterdir() if p.name[:1].isdigit()):
recorded = np.load(folder / combo_file).astype(np.complex64)
if recorded.size != np.asarray(trace.s21).size:
continue
delta = float(np.max(np.abs(np.asarray(trace.s21) - recorded)))
best_delta = min(best_delta, delta)
if best_delta == 0.0:
break
logger.info("Preprocessor transparency: max|delta| vs best snapshot = %.3e", best_delta)
return best_delta < 1e-6
def main() -> int:
"""Run the replay pipeline smoke and report pass/fail."""
parser = argparse.ArgumentParser(description="Headless replay pipeline smoke")
parser.add_argument(
"--config",
type=Path,
default=PROJECT_ROOT / "run_config_examples/run_config_replay_vna.example.json",
)
parser.add_argument("--duration", type=float, default=20.0)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
config = RunConfigModel.load_from_path(args.config)
config.ensure_combos()
dataset_dir = Path(config.radar.replay.dataset_dir)
if not dataset_dir.is_dir():
raise RuntimeError(f"Replay dataset not found: {dataset_dir}")
config.rings.raw.name = _make_unique_ring_name("radar_raw_replay_smoke")
config.rings.raw_tap.name = _make_unique_ring_name("radar_raw_tap_replay_smoke")
config.rings.preprocessed.name = _make_unique_ring_name("radar_preprocessed_replay_smoke")
config.rings.preprocessed_tap.name = _make_unique_ring_name("radar_preprocessed_tap_replay_smoke")
config.rings.results.name = _make_unique_ring_name("radar_results_replay_smoke")
# Private live-config path so the smoke never clobbers the GUI's file.
live_path = PROJECT_ROOT / "python_app/runtime/processing_live_replay_smoke.json"
config.runtime.processing_live_config_path = str(
live_path.relative_to(PROJECT_ROOT)
)
radar_key = radar_key_from_config(
model_name=config.radar.model,
serial=config.radar.serial,
sweep_start_hz=config.radar.sweep.start_hz,
sweep_stop_hz=config.radar.sweep.stop_hz,
sweep_points=config.radar.sweep.points,
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
power_dbm=config.radar.sweep.power_dbm,
extra_serials=config.radar_key_extra_parts() or None,
)
store = NpzStore(PROJECT_ROOT / "python_app/data")
set_name = config.preprocess.s21.calibration.set_name or "neutral_replay"
if set_name not in store.list_sets("s21_calibration", radar_key):
calibration, reference = build_neutral_s21_sets(config)
store.save_set("s21_calibration", radar_key, set_name, calibration)
store.save_set("s21_reference", radar_key, set_name, reference)
logger.info("Created neutral sets '%s' (radar_key=%s)", set_name, radar_key)
config.preprocess.s21.calibration.set_name = set_name
config.preprocess.s21.reference.set_name = set_name
ProcessingLiveConfigWriter(live_path).write(_sar3d_live_config(args.config))
config_writer = ConfigWriter(PROJECT_ROOT / "python_app/runtime")
config_writer.prepare_preprocess_bundles(store, radar_key, config)
config_path = config_writer.write(
config, PROJECT_ROOT / "python_app/runtime/run_config_replay_smoke.json"
)
supervisor = ProcessSupervisor(PROJECT_ROOT)
result_reader: ShmRingReader | None = None
tap_reader: ShmRingReader | None = None
failures: list[str] = []
try:
supervisor.start(config_path)
result_reader = ShmRingReader(config.rings.results.name, open_timeout_s=15.0)
tap_reader = ShmRingReader(config.rings.preprocessed_tap.name, open_timeout_s=15.0)
deadline = time.monotonic() + args.duration
received = 0
sar3d_results = 0
last_peak = 0.0
last_id = 0
ids_monotonic = True
while time.monotonic() < deadline:
result = result_reader.pop_result_collection()
if result is None:
time.sleep(0.02)
continue
received += 1
if last_id and result.collection_id <= last_id:
ids_monotonic = False
last_id = result.collection_id
names = {p.processing_name: p for p in result.collection_payloads}
if "sar3d_proj_xz" in names and "sar3d_proj_xy" in names:
sar3d_results += 1
peak_payload = names.get("sar3d_peak")
if peak_payload is not None:
last_peak = float(peak_payload.scalar_value)
logger.info(
"Results: total=%d, with sar3d projections=%d, last collection_id=%d, last peak=%.4e",
received, sar3d_results, last_id, last_peak,
)
if received == 0:
failures.append("no result collections received")
if sar3d_results == 0:
failures.append("no sar3d projection payloads received")
if not (last_peak > 0.0):
failures.append(f"sar3d peak is not positive: {last_peak}")
if not ids_monotonic:
failures.append("collection ids were not monotonically increasing")
if not _check_preprocessor_transparency(tap_reader, dataset_dir):
failures.append("preprocessor is not transparent vs recorded snapshots")
finally:
supervisor.stop_all()
if result_reader is not None:
result_reader.close()
if tap_reader is not None:
tap_reader.close()
if failures:
for failure in failures:
logger.error("FAIL: %s", failure)
return 1
logger.info("REPLAY SMOKE OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())