"""Dataclass schema for full GUI config profiles.""" from __future__ import annotations from copy import deepcopy from dataclasses import dataclass, field import json import logging from pathlib import Path from typing import Protocol from typing import Any, TypeVar from python_app.models.run_config_schema import RunConfigModel logger = logging.getLogger(__name__) Scalar = str | int | float | bool _ScalarT = TypeVar("_ScalarT", str, int, float, bool) @dataclass(frozen=True, slots=True) class Setting: """Qt-free binding and validation metadata for one persistent GUI field.""" widget: str = "" live: str = "" web: str = "" group: str = "" choices: tuple[str, ...] = () minimum: float | None = None maximum: float | None = None exclusive_minimum: bool = False def setting( default: _ScalarT, widget: str = "", *, live: str = "", web: str = "", group: str = "", choices: tuple[str, ...] = (), minimum: float | None = None, maximum: float | None = None, exclusive_minimum: bool = False, ) -> _ScalarT: """Declare a typed dataclass field and its single profile/live/web binding.""" return field(default=default, metadata={"setting": Setting( widget, live, web or live, group, choices, minimum, maximum, exclusive_minimum, )}) @dataclass(slots=True) class GuiSwitchStateModel: """UI-only state for switch selection controls.""" combo_mode: str = setting("text", choices=('text', 'single')) combos_text: str = setting("", '_combos_text') single_input: str = setting("0", '_single_combo_input') single_output: str = setting("0", '_single_combo_output') @dataclass(slots=True) class GuiPassThroughStateModel: """UI-only defaults for pass-through rendering.""" show_magnitude: bool = setting(True, '_show_magnitude_checkbox') show_phase: bool = setting(True, '_show_phase_checkbox') unwrap_phase: bool = setting(False, '_unwrap_phase_checkbox') combo_filter: str = setting("", '_pass_through_combo_filter_input', web='combo_filter', group='Display') fixed_y_enabled: bool = setting(False, '_pass_through_fixed_y_enabled', web='pass_through_fixed_y_enabled', group='Pass-through') y_min_db: float = setting(-100.0, '_pass_through_y_min_db', web='pass_through_y_min_db', group='Pass-through') y_max_db: float = setting(0.0, '_pass_through_y_max_db', web='pass_through_y_max_db', group='Pass-through') @dataclass(slots=True) class GuiBscanStateModel: """UI-only defaults for B-scan live settings.""" axis: str = setting("abs", '_bscan_axis', live='bscan_axis', group='B-scan', choices=('abs', 'real', 'phase')) cut_m: float = setting(0.824, '_bscan_cut_m', live='bscan_cut_m', group='B-scan') max_depth_m: float = setting(1.0, '_bscan_max_depth_m', live='bscan_max_depth_m', group='B-scan') gain: float = setting(1.0, '_bscan_gain', live='bscan_gain', group='B-scan') start_freq_mhz: float = setting(100.0, '_bscan_start_freq_mhz', live='bscan_start_freq_mhz', group='B-scan') stop_freq_mhz: float = setting(8800.0, '_bscan_stop_freq_mhz', live='bscan_stop_freq_mhz', group='B-scan') subtract_mean_ascan: bool = setting(False, '_bscan_subtract_mean_ascan', web='subtract_mean_ascan', group='Display') # How many past sweeps the B-scan heatmap renders once acquisition is stopped. # While running the window stays at the C++ replay window (see # `_cpp_bscan_replay_window_for_config`); this only widens the stopped-mode view. history_window_scans: int = setting(50, '_bscan_history_window') @dataclass(slots=True) class GuiGprStateModel: """UI-only defaults for coherent backprojection GPR live settings.""" input_positions: str = setting("", '_gpr_input_positions_input', live='gpr_input_positions', group='Geometry & depth') output_positions: str = setting("", '_gpr_output_positions_input', live='gpr_output_positions', group='Geometry & depth') min_depth_m: float = setting(2.0, '_gpr_min_depth_m', live='gpr_min_depth_m', group='Geometry & depth') max_depth_m: float = setting(14.0, '_gpr_max_depth_m', live='gpr_max_depth_m', group='Geometry & depth') range_comp_power: float = setting(0.1, '_gpr_range_comp_power', live='gpr_range_comp_power', group='Imaging', minimum=0) # BP object-detection stop level, fraction of the global peak. Angle compensation and # permittivity are fixed for coherent BP, so they are not exposed here. object_min_frac: float = setting(0.7, '_gpr_object_min_frac', live='gpr_object_min_frac', group='Detection', minimum=0, maximum=1) score_mode: str = setting("combined", '_gpr_score_mode', live='gpr_score_mode', group='Imaging', choices=('peak', 'combined')) motion_mode: str = setting("int_minus", '_gpr_motion_mode', live='gpr_motion_mode', group='Motion', choices=('int_minus', 'int_focus')) # Intra-sweep motion-correction inputs. Sweep time is derived from acquisition # metadata; speed comes from the socket unless `ignore_socket_speed_enabled`, # in which case `speed_m_s` from here is used. look_angle_deg: float = setting(0.0, '_gpr_look_angle_deg', live='gpr_look_angle_deg', group='Motion') direction_sign: float = setting(1.0, '_gpr_direction_sign', live='gpr_direction_sign', group='Motion') speed_m_s: float = setting(0.0, '_gpr_speed_m_s', live='gpr_speed_m_s', group='Motion') ignore_socket_speed_enabled: bool = setting(False, '_gpr_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion') max_detected_objects_to_draw: int = setting( 5, '_gpr_max_detected_objects_to_draw', live='gpr_max_detected_objects_to_draw', group='Detection', minimum=0, ) draw_top_m_objects: int = setting(2, '_gpr_draw_top_m_objects', live='gpr_draw_top_m_objects', group='Detection', minimum=0) # Cross-frame approach filter: show an object only after it persists as a # motion-consistent track this many consecutive frames (<= 1 disables it). object_approach_min_frames: int = setting( 3, '_gpr_object_approach_min_frames', live='gpr_object_approach_min_frames', group='Detection', minimum=1, ) start_freq_mhz: float = setting(3000.0, '_gpr_start_freq_mhz', live='gpr_start_freq_mhz', group='Geometry & depth') stop_freq_mhz: float = setting(6000.0, '_gpr_stop_freq_mhz', live='gpr_stop_freq_mhz', group='Geometry & depth') background_subtract_enabled: bool = setting(True, '_gpr_background_subtract_enabled', live='gpr_background_subtract_enabled', group='Imaging') background_mean_count: int = setting(10, '_gpr_background_mean_count', live='gpr_background_mean_count', group='Imaging') remove_sidelobe_objects_enabled: bool = setting( True, '_gpr_remove_sidelobe_objects_enabled', live='gpr_remove_sidelobe_objects_enabled', group='Imaging', ) bp_ground_depth_m: float = setting(1.25, '_gpr_bp_ground_depth_m', live='gpr_bp_ground_depth_m', group='Geometry & depth', minimum=0) pair_depth_equalize: bool = setting(True, '_gpr_pair_depth_equalize', live='pair_depth_equalize', group='Imaging') render_mode: str = setting("heatmap", '_gpr_render_mode', web='render_mode', group='Display', choices=('heatmap', 'objects_only')) visible_x_min_m: float = setting(-2.0, '_gpr_visible_x_min_m', live='gpr_visible_x_min_m', group='Visible window') visible_x_max_m: float = setting(2.0, '_gpr_visible_x_max_m', live='gpr_visible_x_max_m', group='Visible window') visible_z_min_m: float = setting(0.0, '_gpr_visible_z_min_m', live='gpr_visible_z_min_m', group='Visible window') visible_z_max_m: float = setting(14.0, '_gpr_visible_z_max_m', live='gpr_visible_z_max_m', group='Visible window') @dataclass(slots=True) class GuiSar3dStateModel: """UI-only defaults for the 3D SAR (sar3d) live settings. Values are the reference notebook's (Multi_depthnorm_clean.ipynb), whose names the GUI shows: INPUT_IDX, F_START, MIN_DEPTH, LOOK_ANGLE_DEG, ... """ input_positions: str = setting("", '_sar3d_input_positions_input', live='gpr_input_positions', group='Geometry & depth') output_positions: str = setting("", '_sar3d_output_positions_input', live='gpr_output_positions', group='Geometry & depth') min_depth_m: float = setting(2.0, '_sar3d_min_depth_m', live='gpr_min_depth_m', group='Geometry & depth') max_depth_m: float = setting(8.0, '_sar3d_max_depth_m', live='gpr_max_depth_m', group='Geometry & depth') start_freq_mhz: float = setting(1500.0, '_sar3d_start_freq_mhz', live='gpr_start_freq_mhz', group='Geometry & depth') stop_freq_mhz: float = setting(6000.0, '_sar3d_stop_freq_mhz', live='gpr_stop_freq_mhz', group='Geometry & depth') speed_m_s: float = setting(0.0, '_sar3d_speed_m_s', live='gpr_speed_m_s', group='Motion') ignore_socket_speed_enabled: bool = setting(False, '_sar3d_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion') look_angle_deg: float = setting(19.5, '_sar3d_look_angle_deg', live='gpr_look_angle_deg', group='Motion') frame_radius: int = setting(0, '_sar3d_frame_radius', live='sar3d_frame_radius', group='3D SAR', minimum=0) voxel_x_m: float = setting(0.025, '_sar3d_voxel_x_m', live='sar3d_voxel_x_m', group='3D SAR', minimum=0, exclusive_minimum=True) voxel_y_m: float = setting(0.02, '_sar3d_voxel_y_m', live='sar3d_voxel_y_m', group='3D SAR', minimum=0, exclusive_minimum=True) voxel_z_m: float = setting(0.02, '_sar3d_voxel_z_m', live='sar3d_voxel_z_m', group='3D SAR', minimum=0, exclusive_minimum=True) bp_ground_depth_m: float = setting(1.25, '_sar3d_bp_ground_depth_m', live='sar3d_bp_ground_depth_m', group='3D SAR') comp_range_power: float = setting(0.3, '_sar3d_comp_range_power', live='sar3d_comp_range_power', group='3D SAR') use_compensation: bool = setting(True, '_sar3d_use_compensation', live='sar3d_use_compensation', group='3D SAR') normalize_each_frame: bool = setting(True, '_sar3d_normalize_each_frame', live='sar3d_normalize_each_frame', group='3D SAR') pair_depth_equalize: bool = setting(True, '_sar3d_pair_depth_equalize', live='pair_depth_equalize', group='3D SAR') background_subtract_enabled: bool = setting(True, '_sar3d_background_subtract_enabled', live='gpr_background_subtract_enabled', group='3D SAR') background_mean_count: int = setting(10, '_sar3d_background_mean_count', live='gpr_background_mean_count', group='3D SAR', minimum=0) bg_gate_enabled: bool = setting(True, '_sar3d_bg_gate_enabled', live='sar3d_bg_gate_enabled', group='3D SAR') clean_enabled: bool = setting(True, '_sar3d_clean_enabled', live='sar3d_clean_enabled', group='3D SAR') x_size_m: float = setting(0.0, '_sar3d_x_size_m', live='sar3d_x_size_m', group='Scene', minimum=0) y_min_m: float = setting(-0.5, '_sar3d_y_min_m', live='sar3d_y_min_m', group='Scene') y_max_m: float = setting(2.0, '_sar3d_y_max_m', live='sar3d_y_max_m', group='Scene') z_size_m: float = setting(0.0, '_sar3d_z_size_m', live='sar3d_z_size_m', group='Scene', minimum=0) cloud_threshold: float = setting(0.65, '_sar3d_cloud_threshold', web='sar3d_cloud_threshold', group='Display', minimum=0, maximum=1) zoom_percent: int = setting(100, '_sar3d_zoom_percent', web='sar3d_zoom_percent', group='Display', minimum=100, maximum=1000) mark_enabled: bool = setting(True, '_sar3d_mark_enabled', web='sar3d_mark_enabled', group='Display') @dataclass(slots=True) class GuiProcessingStateModel: """UI-only processing-section defaults.""" selected_mode: str = setting( "pass_through", '_processing_mode', live='processor_mode', group='Processor', choices=('pass_through', 'bscan', 'gpr', 'sar3d'), ) pass_through: GuiPassThroughStateModel = field(default_factory=GuiPassThroughStateModel) bscan: GuiBscanStateModel = field(default_factory=GuiBscanStateModel) gpr: GuiGprStateModel = field(default_factory=GuiGprStateModel) sar3d: GuiSar3dStateModel = field(default_factory=GuiSar3dStateModel) @dataclass(slots=True) class GuiDataActionsStateModel: """UI-only defaults for snapshot/export controls.""" save_path: str = setting("", '_save_path_input') save_name: str = setting("snapshot_manual", '_save_name_input') # How many freshly acquired measurements the "Start + Record" action writes to # disk before it stops recording (acquisition keeps running). record_count: int = setting(100, '_record_count') @dataclass(slots=True) class GuiPreprocessDialogStateModel: """UI-only defaults for preprocessing dialog controls.""" set_name: str = setting("set_001") radar_config_dir: str = setting("") use_all_radar_configs: bool = setting(False) median_sweep_count: int = setting(5) @dataclass(slots=True) class GuiStateModel: """GUI-only profile payload stored under top-level `gui`.""" version: int = 1 switches: GuiSwitchStateModel = field(default_factory=GuiSwitchStateModel) processing: GuiProcessingStateModel = field(default_factory=GuiProcessingStateModel) data_actions: GuiDataActionsStateModel = field(default_factory=GuiDataActionsStateModel) preprocess_dialog: GuiPreprocessDialogStateModel = field(default_factory=GuiPreprocessDialogStateModel) @dataclass(slots=True) class GuiProfileModel: """Full GUI profile with pipeline-compatible root config and optional UI state.""" run_config: RunConfigModel = field(default_factory=RunConfigModel) gui: GuiStateModel | None = None @staticmethod def from_dict(payload: dict[str, Any]) -> GuiProfileModel: """Build profile from JSON-like payload using codec layer.""" from python_app.models.gui_profile_codec import gui_profile_from_dict return gui_profile_from_dict(payload) @classmethod def load_from_path(cls, path: Path) -> GuiProfileModel: """Load a JSON file from disk and decode it into a profile model. Raises ValueError when the file's JSON root is not an object. """ logger.debug("Loading GUI profile from %s", path) payload = json.loads(path.read_text(encoding="utf-8")) if not isinstance(payload, dict): raise ValueError(f"Config profile root must be JSON object: {path}") return cls.from_dict(payload) def clone(self) -> GuiProfileModel: """Create deep copy while preserving whether `gui` is absent.""" return deepcopy(self) def to_dict(self) -> dict[str, Any]: """Encode profile into JSON-serializable dictionary.""" from python_app.models.gui_profile_codec import gui_profile_to_dict return gui_profile_to_dict(self) class ProfileWriter(Protocol): """Publish the current profile, optionally using the acquisition provenance.""" def __call__(self, path: Path, /, *, allow_overwrite: bool = True, dataset: bool = False) -> GuiProfileModel: ...