"""Builder for processing mode and live-parameter section.""" from __future__ import annotations from PyQt6.QtCore import QRegularExpression from PyQt6.QtGui import QRegularExpressionValidator from PyQt6.QtWidgets import ( QCheckBox, QDoubleSpinBox, QFormLayout, QGroupBox, QLineEdit, QSizePolicy, QStackedWidget, QVBoxLayout, QWidget, ) from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor from python_app.gui.controllers.sections.views import ( ProcessingView, SectionView, choice, double_spin, integer_spin, write_setting, ) from python_app.models.gui_profile_schema import GuiStateModel from python_app.models.run_config_schema import RunConfigModel from python_app.models.setting_fields import GUI_FIELDS from python_app.models.run_config_validation import POSITION_CSV_PATTERN # Same semantics on every GPR-like page; the data_processor applies them. _IGNORE_SOCKET_SPEED_TOOLTIP = ( "Checked: the speed below drives the motion correction. Unchecked: the speed a locator " "socket client streams ('vlc') does, and the field below only stands in while no client " "has sent one in the last 5 s." ) def _build_processing_mode_page(parent: QWidget, rows: list[FormRow], *, split_index: int | None = None) -> QWidget: """Create one processing-mode page with a two-column form body.""" page = QWidget(parent) page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) page_layout = QVBoxLayout(page) page_layout.setContentsMargins(0, 0, 0, 0) page_layout.setSpacing(0) page_layout.addWidget(build_two_column_form_widget(page, rows, split_index=split_index)) return page def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> ProcessingView: """Create processing mode section with pass-through, B-scan, GPR, and 3D SAR pages.""" view = SectionView(QGroupBox("Processing")) group = view.group form = QFormLayout(group) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) processing_mode = view.add("_processing_mode", choice(['pass_through', 'bscan', 'gpr', 'legacy_gpr', 'sar3d'])) processing_mode_pages = QStackedWidget(group) processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) show_magnitude_checkbox = view.add("_show_magnitude_checkbox", QCheckBox("Show magnitude")) show_phase_checkbox = view.add("_show_phase_checkbox", QCheckBox("Show phase")) unwrap_phase_checkbox = view.add("_unwrap_phase_checkbox", QCheckBox("Unwrap phase")) unwrap_phase_checkbox.setToolTip( "Plot the cumulative (unwrapped) phase instead of wrapping to ±180°; " "the phase axis auto-scales to the unwrapped range." ) pass_through_combo_filter_input = view.add("_pass_through_combo_filter_input", QLineEdit()) pass_through_combo_filter_input.setPlaceholderText("empty = all, e.g. 0:0,1:0") pass_through_fixed_y_enabled = view.add("_pass_through_fixed_y_enabled", QCheckBox("Fix magnitude Y range")) pass_through_y_min_db = view.add("_pass_through_y_min_db", double_spin(-240.0, 240.0, 1, 1.0)) pass_through_y_max_db = view.add("_pass_through_y_max_db", double_spin(-240.0, 240.0, 1, 1.0)) pass_through_page = _build_processing_mode_page( processing_mode_pages, [ show_magnitude_checkbox, show_phase_checkbox, unwrap_phase_checkbox, ("Switch combos", pass_through_combo_filter_input), pass_through_fixed_y_enabled, ("Y min (dB)", pass_through_y_min_db), ("Y max (dB)", pass_through_y_max_db), ], split_index=5, ) processing_mode_pages.addWidget(pass_through_page) bscan_axis = view.add("_bscan_axis", choice(['abs', 'real', 'phase'])) bscan_cut_m = view.add("_bscan_cut_m", double_spin(0.0, 2.0, 3, 0.001)) bscan_max_depth_m = view.add("_bscan_max_depth_m", double_spin(0.1, 20.0, 1, 0.1)) bscan_gain = view.add("_bscan_gain", double_spin(0.0, 3.0, 1, 0.1)) bscan_start_freq_mhz = view.add("_bscan_start_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0)) bscan_stop_freq_mhz = view.add("_bscan_stop_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0)) bscan_subtract_mean_ascan = view.add("_bscan_subtract_mean_ascan", QCheckBox("Subtract mean A-scan")) bscan_history_window = view.add("_bscan_history_window", integer_spin(1, 1000)) bscan_history_window.setToolTip( "How many past sweeps the B-scan shows once acquisition is stopped. While " "running, the window stays clamped to the C++ ring capacity." ) bscan_page = _build_processing_mode_page( processing_mode_pages, [ ("Axis", bscan_axis), ("Cut (m)", bscan_cut_m), ("Max depth (m)", bscan_max_depth_m), ("Gain", bscan_gain), ("Start (MHz)", bscan_start_freq_mhz), ("Stop (MHz)", bscan_stop_freq_mhz), ("Scans to show (stopped)", bscan_history_window), bscan_subtract_mean_ascan, ], split_index=4, ) processing_mode_pages.addWidget(bscan_page) gpr_defaults = config.gpr # Medium permittivity is a legacy-GPR-only knob (shown on the legacy page below). # Coherent BP fixes the medium to eps_r = 1, so it is not offered there. gpr_relative_permittivity = view.add("_gpr_relative_permittivity", double_spin(0.0001, 1000.0, 4, 0.05)) gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity)) gpr_tx_geometry_input = view.add("_gpr_tx_geometry_input", AntennaGeometryEditor( "output_pos", gpr_defaults.tx_geometry, position_count=config.output_switch.positions, )) gpr_rx_geometry_input = view.add("_gpr_rx_geometry_input", AntennaGeometryEditor( "input_pos", gpr_defaults.rx_geometry, position_count=config.input_switch.positions, )) gpr_common_page = _build_processing_mode_page( group, [ ("Tx geometry", gpr_tx_geometry_input), ("Rx geometry", gpr_rx_geometry_input), ], split_index=1, ) gpr_input_positions_input = view.add("_gpr_input_positions_input", QLineEdit()) gpr_input_positions_input.setPlaceholderText("0,1,2") gpr_output_positions_input = view.add("_gpr_output_positions_input", QLineEdit()) gpr_output_positions_input.setPlaceholderText("0,1") gpr_min_depth_m = view.add("_gpr_min_depth_m", double_spin(0.0, 50.0, 2, 0.1)) gpr_max_depth_m = view.add("_gpr_max_depth_m", double_spin(0.1, 50.0, 2, 0.1)) gpr_range_comp_power = view.add("_gpr_range_comp_power", double_spin(0.0, 5.0, 3, 0.01)) gpr_object_min_frac = view.add("_gpr_object_min_frac", double_spin(0.0, 1.0, 2, 0.05)) gpr_object_min_frac.setToolTip( "Object detection stops once a peak falls below this fraction of the global maximum." ) gpr_score_mode = view.add("_gpr_score_mode", choice(['peak', 'combined'])) gpr_motion_mode = view.add("_gpr_motion_mode", choice(['int_minus', 'int_focus'])) gpr_motion_mode.setToolTip( "Intra-sweep speed correction before the IFFT. int_minus: full correction; " "int_focus: focusing residual only (no net Z shift)." ) gpr_look_angle_deg = view.add("_gpr_look_angle_deg", double_spin(-90.0, 90.0, 2, 0.5)) gpr_look_angle_deg.setToolTip( "Radar look angle vs the range axis (deg). Used by intra-sweep motion correction." ) gpr_direction_sign = view.add("_gpr_direction_sign", double_spin(-1.0, 1.0, 0, 2.0)) gpr_direction_sign.setToolTip( "Motion direction along range: +1 (later frequencies deeper) or -1 (shallower)." ) gpr_ignore_socket_speed_enabled = view.add("_gpr_ignore_socket_speed_enabled", QCheckBox("Ignore socket speed")) gpr_ignore_socket_speed_enabled.setToolTip(_IGNORE_SOCKET_SPEED_TOOLTIP) gpr_speed_m_s = view.add("_gpr_speed_m_s", double_spin(-100.0, 100.0, 3, 0.05)) gpr_speed_m_s.setToolTip( "Radar speed (m/s) used by intra-sweep motion correction when 'Ignore socket speed' is on." ) gpr_max_detected_objects_to_draw = view.add("_gpr_max_detected_objects_to_draw", integer_spin(0, 10000)) gpr_draw_top_m_objects = view.add("_gpr_draw_top_m_objects", integer_spin(0, 10000)) gpr_object_approach_min_frames = view.add("_gpr_object_approach_min_frames", integer_spin(1, 100)) gpr_object_approach_min_frames.setToolTip( "Show an object only after it persists as a motion-consistent track this many " "consecutive frames (1 disables the approach filter)." ) gpr_start_freq_mhz = view.add("_gpr_start_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0)) gpr_stop_freq_mhz = view.add("_gpr_stop_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0)) gpr_background_subtract_enabled = view.add("_gpr_background_subtract_enabled", QCheckBox("Subtract mean of previous collections")) gpr_background_mean_count = view.add("_gpr_background_mean_count", integer_spin(0, 10000)) gpr_remove_sidelobe_objects_enabled = view.add("_gpr_remove_sidelobe_objects_enabled", QCheckBox("Remove sidelobe objects")) gpr_render_mode = view.add("_gpr_render_mode", choice(['heatmap', 'objects_only'])) gpr_visible_x_min_m = view.add("_gpr_visible_x_min_m", double_spin(-100.0, 100.0, 2, 0.1)) gpr_visible_x_max_m = view.add("_gpr_visible_x_max_m", double_spin(-100.0, 100.0, 2, 0.1)) gpr_visible_z_min_m = view.add("_gpr_visible_z_min_m", double_spin(0.0, 100.0, 2, 0.1)) gpr_visible_z_max_m = view.add("_gpr_visible_z_max_m", double_spin(0.0, 100.0, 2, 0.1)) gpr_imaging_plane_y_m = view.add("_gpr_imaging_plane_y_m", double_spin(-50.0, 50.0, 3, 0.05)) gpr_imaging_plane_y_m.setToolTip( "Y coordinate of the BP imaging slice (m). Use 0 for legacy 1D antenna layouts." ) gpr_page = _build_processing_mode_page( processing_mode_pages, [ ("Input positions", gpr_input_positions_input), ("Output positions", gpr_output_positions_input), ("Min depth (m)", gpr_min_depth_m), ("Max depth (m)", gpr_max_depth_m), ("Range compensation power", gpr_range_comp_power), ("Object threshold fraction", gpr_object_min_frac), ("Score mode", gpr_score_mode), ("Motion mode", gpr_motion_mode), ("Look angle (°)", gpr_look_angle_deg), ("Direction sign", gpr_direction_sign), gpr_ignore_socket_speed_enabled, ("Speed (m/s)", gpr_speed_m_s), ("Render mode", gpr_render_mode), ("Max detected objects", gpr_max_detected_objects_to_draw), ("Draw top M objects", gpr_draw_top_m_objects), ("Approach min frames", gpr_object_approach_min_frames), ("Start (MHz)", gpr_start_freq_mhz), ("Stop (MHz)", gpr_stop_freq_mhz), ("Imaging plane Y (m)", gpr_imaging_plane_y_m), ("Visible X min (m)", gpr_visible_x_min_m), ("Visible X max (m)", gpr_visible_x_max_m), ("Visible Z min (m)", gpr_visible_z_min_m), ("Visible Z max (m)", gpr_visible_z_max_m), gpr_background_subtract_enabled, ("Mean count", gpr_background_mean_count), gpr_remove_sidelobe_objects_enabled, ], split_index=8, ) processing_mode_pages.addWidget(gpr_page) legacy_gpr_config_mode = view.add("_legacy_gpr_config_mode", choice(['point', 'extended'])) legacy_gpr_input_positions_input = view.add("_legacy_gpr_input_positions_input", QLineEdit()) legacy_gpr_input_positions_input.setPlaceholderText("0,1,2") legacy_gpr_output_positions_input = view.add("_legacy_gpr_output_positions_input", QLineEdit()) legacy_gpr_output_positions_input.setPlaceholderText("0,1") legacy_gpr_min_depth_m = view.add("_legacy_gpr_min_depth_m", double_spin(0.0, 50.0, 2, 0.1)) legacy_gpr_max_depth_m = view.add("_legacy_gpr_max_depth_m", double_spin(0.1, 50.0, 2, 0.1)) legacy_gpr_comp_power = view.add("_legacy_gpr_comp_power", double_spin(0.0, 5.0, 3, 0.05)) legacy_gpr_snr_thresh = view.add("_legacy_gpr_snr_thresh", double_spin(0.0, 1000.0, 2, 0.1)) legacy_gpr_snr_comp_max = view.add("_legacy_gpr_snr_comp_max", double_spin(0.0, 1000.0, 2, 0.5)) legacy_gpr_start_freq_mhz = view.add("_legacy_gpr_start_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0)) legacy_gpr_stop_freq_mhz = view.add("_legacy_gpr_stop_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0)) legacy_gpr_speed_m_s = view.add("_legacy_gpr_speed_m_s", double_spin(-100.0, 100.0, 3, 0.01)) legacy_gpr_ignore_socket_speed_enabled = view.add("_legacy_gpr_ignore_socket_speed_enabled", QCheckBox("Ignore socket speed")) legacy_gpr_ignore_socket_speed_enabled.setToolTip(_IGNORE_SOCKET_SPEED_TOOLTIP) legacy_gpr_look_angle_deg = view.add("_legacy_gpr_look_angle_deg", double_spin(-90.0, 90.0, 2, 0.1)) # Intra-sweep phase compensation toggle. When checked, S21 phase is # corrected per frequency point before the IFFT so the radar's motion # during one sweep is removed at the spectrum stage. Only matters when # speed != 0; otherwise has no effect by construction. legacy_gpr_apply_freq_phase_correction = view.add("_legacy_gpr_apply_freq_phase_correction", QCheckBox("Apply intra-sweep phase correction")) # Anchor point for the motion model's per-event `dt_ref`. `frame_center` # spreads motion symmetrically around the frame midpoint; `first_tx_event` # accumulates it forward from the first event center. legacy_gpr_reference_mode = view.add("_legacy_gpr_reference_mode", choice(['frame_center', 'first_tx_event'])) legacy_gpr_background_subtract_enabled = view.add("_legacy_gpr_background_subtract_enabled", QCheckBox("Subtract mean of previous collections")) legacy_gpr_background_mean_count = view.add("_legacy_gpr_background_mean_count", integer_spin(0, 10000)) legacy_gpr_render_mode = view.add("_legacy_gpr_render_mode", choice(['heatmap', 'objects_only'])) legacy_gpr_min_visible_pair_count = view.add("_legacy_gpr_min_visible_pair_count", integer_spin(1, 10000)) legacy_gpr_visible_x_min_m = view.add("_legacy_gpr_visible_x_min_m", double_spin(-100.0, 100.0, 2, 0.1)) legacy_gpr_visible_x_max_m = view.add("_legacy_gpr_visible_x_max_m", double_spin(-100.0, 100.0, 2, 0.1)) legacy_gpr_visible_z_min_m = view.add("_legacy_gpr_visible_z_min_m", double_spin(0.0, 100.0, 2, 0.1)) legacy_gpr_visible_z_max_m = view.add("_legacy_gpr_visible_z_max_m", double_spin(0.0, 100.0, 2, 0.1)) legacy_gpr_page = _build_processing_mode_page( processing_mode_pages, [ ("Config mode", legacy_gpr_config_mode), ("Relative permittivity", gpr_relative_permittivity), ("Input positions", legacy_gpr_input_positions_input), ("Output positions", legacy_gpr_output_positions_input), ("Min depth (m)", legacy_gpr_min_depth_m), ("Max depth (m)", legacy_gpr_max_depth_m), ("Compensation power", legacy_gpr_comp_power), ("SNR threshold", legacy_gpr_snr_thresh), ("Max SNR compensation", legacy_gpr_snr_comp_max), ("Speed (m/s)", legacy_gpr_speed_m_s), legacy_gpr_ignore_socket_speed_enabled, ("Render mode", legacy_gpr_render_mode), ("Min visible pairs", legacy_gpr_min_visible_pair_count), ("Start (MHz)", legacy_gpr_start_freq_mhz), ("Stop (MHz)", legacy_gpr_stop_freq_mhz), ("Look angle (°)", legacy_gpr_look_angle_deg), ("Reference mode", legacy_gpr_reference_mode), legacy_gpr_apply_freq_phase_correction, ("Visible X min (m)", legacy_gpr_visible_x_min_m), ("Visible X max (m)", legacy_gpr_visible_x_max_m), ("Visible Z min (m)", legacy_gpr_visible_z_min_m), ("Visible Z max (m)", legacy_gpr_visible_z_max_m), legacy_gpr_background_subtract_enabled, ("Mean count", legacy_gpr_background_mean_count), ], split_index=10, ) processing_mode_pages.addWidget(legacy_gpr_page) # 3D SAR page. The labels are the names of the reference notebook # Multi_antenna_3D_clean_v1.ipynb; the shared gpr_* fields get their own # widgets here, like on the legacy page. sar3d_input_positions_input = view.add("_sar3d_input_positions_input", QLineEdit()) sar3d_input_positions_input.setPlaceholderText("0,1,2") sar3d_output_positions_input = view.add("_sar3d_output_positions_input", QLineEdit()) sar3d_output_positions_input.setPlaceholderText("0,1") sar3d_start_freq_mhz = view.add("_sar3d_start_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0)) sar3d_stop_freq_mhz = view.add("_sar3d_stop_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0)) sar3d_min_depth_m = view.add("_sar3d_min_depth_m", double_spin(0.0, 50.0, 2, 0.1)) sar3d_min_depth_m.setToolTip("Radar depth gate start (m); the scene grid along Z follows the gate.") sar3d_max_depth_m = view.add("_sar3d_max_depth_m", double_spin(0.1, 50.0, 2, 0.1)) sar3d_max_depth_m.setToolTip("Radar depth gate end (m); the scene grid along Z follows the gate.") sar3d_voxel_m = view.add("_sar3d_voxel_m", double_spin(0.01, 0.1, 3, 0.005)) sar3d_voxel_m.setToolTip( "Edge of the cubic voxel (m), the same along X, Y and Z. Smaller is finer and slower: " "halving it makes 8 times more voxels." ) sar3d_look_angle_deg = view.add("_sar3d_look_angle_deg", double_spin(-90.0, 90.0, 2, 0.5)) sar3d_look_angle_deg.setToolTip( "Downward tilt of the radar (deg): motion correction along range and the ground scene axes." ) sar3d_ignore_socket_speed_enabled = view.add("_sar3d_ignore_socket_speed_enabled", QCheckBox("Ignore socket speed")) sar3d_ignore_socket_speed_enabled.setToolTip(_IGNORE_SOCKET_SPEED_TOOLTIP) sar3d_speed_m_s = view.add("_sar3d_speed_m_s", double_spin(-100.0, 100.0, 3, 0.05)) sar3d_frame_radius = view.add("_sar3d_frame_radius", integer_spin(0, 24)) sar3d_frame_radius.setToolTip( "Aperture of 2*r+1 consecutive frames. 0: the current frame only (needs a vertical antenna " "baseline); >0 adds aperture from motion and needs an accurate speed. The image lags by r frames." ) sar3d_bp_ground_depth_m = view.add("_sar3d_bp_ground_depth_m", double_spin(0.0, 10.0, 2, 0.05)) sar3d_bp_ground_depth_m.setToolTip("Height of the lower antenna row above the ground (m); scene Y = 0 is the ground.") sar3d_comp_range_power = view.add("_sar3d_comp_range_power", double_spin(0.0, 2.0, 2, 0.05)) sar3d_comp_range_power.setToolTip("Geometric spreading compensation (Rtx*Rrx)^power, capped at 5.") sar3d_use_compensation = view.add("_sar3d_use_compensation", QCheckBox("SAR_USE_COMPENSATION")) sar3d_normalize_each_frame = view.add("_sar3d_normalize_each_frame", QCheckBox("SAR_NORMALIZE_EACH_FRAME")) sar3d_normalize_each_frame.setToolTip("Scale every Tx/Rx A-scan by its median inside the depth gate.") sar3d_page = _build_processing_mode_page( processing_mode_pages, [ ("INPUT_IDX", sar3d_input_positions_input), ("OUTPUT_IDX", sar3d_output_positions_input), ("F_START MHz", sar3d_start_freq_mhz), ("F_STOP MHz", sar3d_stop_freq_mhz), ("MIN_DEPTH", sar3d_min_depth_m), ("MAX_DEPTH", sar3d_max_depth_m), ("SAR_VOXEL_M", sar3d_voxel_m), ("LOOK_ANGLE_DEG", sar3d_look_angle_deg), sar3d_ignore_socket_speed_enabled, ("SPEED_M_S", sar3d_speed_m_s), ("SAR_FRAME_RADIUS", sar3d_frame_radius), ("BP_GROUND_DEPTH_M", sar3d_bp_ground_depth_m), ("COMP_RANGE_POWER", sar3d_comp_range_power), sar3d_use_compensation, sar3d_normalize_each_frame, ], split_index=8, ) processing_mode_pages.addWidget(sar3d_page) form.addRow(processing_mode_pages) form.addRow(gpr_common_page) band = config.radar.rph_radar.band if config.is_rph_radar else config.radar.sweep for binding in GUI_FIELDS: if binding.path[0] == "processing": widget = view.fields[binding.spec.widget] if isinstance(widget, AntennaGeometryEditor): raise TypeError("Geometry is not a scalar GUI setting") if binding.path[-1] in {"start_freq_mhz", "stop_freq_mhz"}: assert isinstance(widget, QDoubleSpinBox) widget.setRange(band.start_hz / 1e6, band.stop_hz / 1e6) if binding.spec.live in {"gpr_input_positions", "gpr_output_positions"}: assert isinstance(widget, QLineEdit) widget.setValidator(QRegularExpressionValidator(QRegularExpression(POSITION_CSV_PATTERN), widget)) write_setting(widget, binding.read(gui)) return ProcessingView( group=group, fields=view.fields, mode=processing_mode, pages=processing_mode_pages, geometry_page=gpr_common_page, geometries=(gpr_tx_geometry_input, gpr_rx_geometry_input), fixed_y=pass_through_fixed_y_enabled, y_bounds=(pass_through_y_min_db, pass_through_y_max_db), speed_controls=((gpr_ignore_socket_speed_enabled, gpr_speed_m_s), (legacy_gpr_ignore_socket_speed_enabled, legacy_gpr_speed_m_s), (sar3d_ignore_socket_speed_enabled, sar3d_speed_m_s)), trace_controls=(show_magnitude_checkbox, show_phase_checkbox, unwrap_phase_checkbox, pass_through_combo_filter_input, pass_through_fixed_y_enabled, pass_through_y_min_db, pass_through_y_max_db), visual_controls=(gpr_render_mode, legacy_gpr_render_mode), locator_controls=(gpr_max_detected_objects_to_draw, gpr_draw_top_m_objects, legacy_gpr_min_visible_pair_count, gpr_visible_x_min_m, gpr_visible_x_max_m, gpr_visible_z_min_m, gpr_visible_z_max_m, legacy_gpr_visible_x_min_m, legacy_gpr_visible_x_max_m, legacy_gpr_visible_z_min_m, legacy_gpr_visible_z_max_m), frequency_controls=(bscan_start_freq_mhz, bscan_stop_freq_mhz, gpr_start_freq_mhz, gpr_stop_freq_mhz, legacy_gpr_start_freq_mhz, legacy_gpr_stop_freq_mhz, sar3d_start_freq_mhz, sar3d_stop_freq_mhz), )