"""Encoding and decoding logic for :mod:`python_app.models.gui_profile_schema`.""" from __future__ import annotations import logging from typing import Any from python_app.models.gui_profile_schema import ( GuiBscanStateModel, GuiDataActionsStateModel, GuiGprStateModel, GuiLegacyGprStateModel, GuiPassThroughStateModel, GuiPreprocessDialogStateModel, GuiProcessingStateModel, GuiProfileModel, GuiStateModel, GuiSwitchStateModel, ) from python_app.models.run_config_model import RunConfigModel logger = logging.getLogger(__name__) def _as_dict(value: Any, context: str) -> dict[str, Any]: """Validate payload node is object-like, treating missing values as empty object.""" if value is None: return {} if not isinstance(value, dict): raise ValueError(f"{context} must be a JSON object") return value def _optional_string(object_payload: dict[str, Any], key: str, fallback: str, context: str) -> str: """Return optional string field with type validation.""" raw_value = object_payload.get(key, fallback) if not isinstance(raw_value, str): raise ValueError(f"{context}.{key} must be a JSON string") return raw_value def _optional_bool(object_payload: dict[str, Any], key: str, fallback: bool, context: str) -> bool: """Return optional boolean field with type validation.""" raw_value = object_payload.get(key, fallback) if not isinstance(raw_value, bool): raise ValueError(f"{context}.{key} must be a JSON bool") return raw_value def _optional_int(object_payload: dict[str, Any], key: str, fallback: int, context: str) -> int: """Return optional integer field with type validation.""" raw_value = object_payload.get(key, fallback) if isinstance(raw_value, bool) or not isinstance(raw_value, int): raise ValueError(f"{context}.{key} must be a JSON integer") return int(raw_value) def _optional_float(object_payload: dict[str, Any], key: str, fallback: float, context: str) -> float: """Return optional numeric field with type validation.""" raw_value = object_payload.get(key, fallback) if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float)): raise ValueError(f"{context}.{key} must be a JSON number") return float(raw_value) def _legacy_gpr_mode_from_algorithm(value: str) -> str | None: """Translate the short-lived nested GPR algorithm field into legacy mode.""" if value == "legacy_point": return "point" if value == "legacy_extended": return "extended" return None def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: """Decode JSON-like payload into :class:`GuiProfileModel`.""" profile = GuiProfileModel(run_config=RunConfigModel.from_dict(payload), gui=None) gui_payload = payload.get("gui") if gui_payload is None: logger.debug("Decoded GUI profile without a 'gui' section; UI state left unset") return profile gui_object = _as_dict(gui_payload, "gui") gui = GuiStateModel() gui.version = _optional_int(gui_object, "version", gui.version, "gui") if gui.version != 1: raise ValueError(f"Unsupported gui.version: {gui.version}") switches_object = _as_dict(gui_object.get("switches"), "gui.switches") gui.switches = GuiSwitchStateModel( combo_mode=_optional_string(switches_object, "combo_mode", gui.switches.combo_mode, "gui.switches"), combos_text=_optional_string(switches_object, "combos_text", gui.switches.combos_text, "gui.switches"), single_input=_optional_string(switches_object, "single_input", gui.switches.single_input, "gui.switches"), single_output=_optional_string( switches_object, "single_output", gui.switches.single_output, "gui.switches", ), ) if gui.switches.combo_mode not in {"text", "single"}: raise ValueError("gui.switches.combo_mode must be either 'text' or 'single'") processing_object = _as_dict(gui_object.get("processing"), "gui.processing") pass_through_object = _as_dict(processing_object.get("pass_through"), "gui.processing.pass_through") 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") root_gpr_object = payload.get("gpr") selected_mode = _optional_string( processing_object, "selected_mode", gui.processing.selected_mode, "gui.processing", ) legacy_mode_default = gui.processing.legacy_gpr.mode if isinstance(root_gpr_object, dict): if root_gpr_object.get("mode") == "point": legacy_mode_default = "point" elif root_gpr_object.get("mode") == "extended": legacy_mode_default = "extended" legacy_algorithm_mode = _legacy_gpr_mode_from_algorithm(str(gpr_object.get("algorithm", ""))) if legacy_algorithm_mode is not None: legacy_mode_default = legacy_algorithm_mode has_legacy_root_gpr_mode = ( isinstance(root_gpr_object, dict) and root_gpr_object.get("mode") in {"point", "extended"} ) if selected_mode == "gpr" and (legacy_algorithm_mode is not None or has_legacy_root_gpr_mode): logger.debug("Migrating legacy GPR profile: rewriting selected_mode 'gpr' -> 'legacy_gpr'") selected_mode = "legacy_gpr" gpr_context = "gui.processing.gpr" legacy_gpr_context = "gui.processing.legacy_gpr" def legacy_string(key: str, fallback: str) -> str: """Read a legacy field, falling back to the old nested gpr object.""" return _optional_string( legacy_gpr_object, key, _optional_string(gpr_object, key, fallback, gpr_context), legacy_gpr_context, ) def legacy_bool(key: str, fallback: bool) -> bool: """Read a legacy bool, falling back to the old nested gpr object.""" return _optional_bool( legacy_gpr_object, key, _optional_bool(gpr_object, key, fallback, gpr_context), legacy_gpr_context, ) def legacy_int(key: str, fallback: int) -> int: """Read a legacy integer, falling back to the old nested gpr object.""" return _optional_int( legacy_gpr_object, key, _optional_int(gpr_object, key, fallback, gpr_context), legacy_gpr_context, ) def legacy_float(key: str, fallback: float) -> float: """Read a legacy float, falling back to the old nested gpr object.""" return _optional_float( legacy_gpr_object, key, _optional_float(gpr_object, key, fallback, gpr_context), legacy_gpr_context, ) gui.processing = GuiProcessingStateModel( selected_mode=selected_mode, pass_through=GuiPassThroughStateModel( show_magnitude=_optional_bool( pass_through_object, "show_magnitude", gui.processing.pass_through.show_magnitude, "gui.processing.pass_through", ), show_phase=_optional_bool( pass_through_object, "show_phase", gui.processing.pass_through.show_phase, "gui.processing.pass_through", ), unwrap_phase=_optional_bool( pass_through_object, "unwrap_phase", gui.processing.pass_through.unwrap_phase, "gui.processing.pass_through", ), combo_filter=_optional_string( pass_through_object, "combo_filter", gui.processing.pass_through.combo_filter, "gui.processing.pass_through", ), fixed_y_enabled=_optional_bool( pass_through_object, "fixed_y_enabled", gui.processing.pass_through.fixed_y_enabled, "gui.processing.pass_through", ), y_min_db=_optional_float( pass_through_object, "y_min_db", gui.processing.pass_through.y_min_db, "gui.processing.pass_through", ), y_max_db=_optional_float( pass_through_object, "y_max_db", gui.processing.pass_through.y_max_db, "gui.processing.pass_through", ), ), bscan=GuiBscanStateModel( axis=_optional_string(bscan_object, "axis", gui.processing.bscan.axis, "gui.processing.bscan"), cut_m=_optional_float(bscan_object, "cut_m", gui.processing.bscan.cut_m, "gui.processing.bscan"), max_depth_m=_optional_float( bscan_object, "max_depth_m", gui.processing.bscan.max_depth_m, "gui.processing.bscan", ), gain=_optional_float(bscan_object, "gain", gui.processing.bscan.gain, "gui.processing.bscan"), start_freq_mhz=_optional_float( bscan_object, "start_freq_mhz", gui.processing.bscan.start_freq_mhz, "gui.processing.bscan", ), stop_freq_mhz=_optional_float( bscan_object, "stop_freq_mhz", gui.processing.bscan.stop_freq_mhz, "gui.processing.bscan", ), subtract_mean_ascan=_optional_bool( bscan_object, "subtract_mean_ascan", gui.processing.bscan.subtract_mean_ascan, "gui.processing.bscan", ), history_window_scans=_optional_int( bscan_object, "history_window_scans", gui.processing.bscan.history_window_scans, "gui.processing.bscan", ), ), gpr=GuiGprStateModel( input_positions=_optional_string( gpr_object, "input_positions", gui.processing.gpr.input_positions, "gui.processing.gpr", ), output_positions=_optional_string( gpr_object, "output_positions", gui.processing.gpr.output_positions, "gui.processing.gpr", ), min_depth_m=_optional_float( gpr_object, "min_depth_m", gui.processing.gpr.min_depth_m, "gui.processing.gpr", ), max_depth_m=_optional_float( gpr_object, "max_depth_m", gui.processing.gpr.max_depth_m, "gui.processing.gpr", ), range_comp_power=_optional_float( gpr_object, "range_comp_power", gui.processing.gpr.range_comp_power, "gui.processing.gpr", ), object_min_frac=_optional_float( gpr_object, "object_min_frac", gui.processing.gpr.object_min_frac, "gui.processing.gpr", ), score_mode=_optional_string( gpr_object, "score_mode", gui.processing.gpr.score_mode, "gui.processing.gpr", ), motion_mode=_optional_string( gpr_object, "motion_mode", gui.processing.gpr.motion_mode, "gui.processing.gpr", ), look_angle_deg=_optional_float( gpr_object, "look_angle_deg", gui.processing.gpr.look_angle_deg, "gui.processing.gpr", ), direction_sign=_optional_float( gpr_object, "direction_sign", gui.processing.gpr.direction_sign, "gui.processing.gpr", ), speed_m_s=_optional_float( gpr_object, "speed_m_s", gui.processing.gpr.speed_m_s, "gui.processing.gpr", ), ignore_socket_speed_enabled=_optional_bool( gpr_object, "ignore_socket_speed_enabled", gui.processing.gpr.ignore_socket_speed_enabled, "gui.processing.gpr", ), max_detected_objects_to_draw=_optional_int( gpr_object, "max_detected_objects_to_draw", gui.processing.gpr.max_detected_objects_to_draw, "gui.processing.gpr", ), draw_top_m_objects=_optional_int( gpr_object, "draw_top_m_objects", gui.processing.gpr.draw_top_m_objects, "gui.processing.gpr", ), object_approach_min_frames=_optional_int( gpr_object, "object_approach_min_frames", gui.processing.gpr.object_approach_min_frames, "gui.processing.gpr", ), start_freq_mhz=_optional_float( gpr_object, "start_freq_mhz", gui.processing.gpr.start_freq_mhz, "gui.processing.gpr", ), stop_freq_mhz=_optional_float( gpr_object, "stop_freq_mhz", gui.processing.gpr.stop_freq_mhz, "gui.processing.gpr", ), background_subtract_enabled=_optional_bool( gpr_object, "background_subtract_enabled", gui.processing.gpr.background_subtract_enabled, "gui.processing.gpr", ), background_mean_count=_optional_int( gpr_object, "background_mean_count", gui.processing.gpr.background_mean_count, "gui.processing.gpr", ), remove_sidelobe_objects_enabled=_optional_bool( gpr_object, "remove_sidelobe_objects_enabled", gui.processing.gpr.remove_sidelobe_objects_enabled, "gui.processing.gpr", ), imaging_plane_y_m=_optional_float( gpr_object, "imaging_plane_y_m", gui.processing.gpr.imaging_plane_y_m, "gui.processing.gpr", ), render_mode=_optional_string( gpr_object, "render_mode", gui.processing.gpr.render_mode, "gui.processing.gpr", ), visible_x_min_m=_optional_float( gpr_object, "visible_x_min_m", gui.processing.gpr.visible_x_min_m, "gui.processing.gpr", ), visible_x_max_m=_optional_float( gpr_object, "visible_x_max_m", gui.processing.gpr.visible_x_max_m, "gui.processing.gpr", ), visible_z_min_m=_optional_float( gpr_object, "visible_z_min_m", gui.processing.gpr.visible_z_min_m, "gui.processing.gpr", ), visible_z_max_m=_optional_float( gpr_object, "visible_z_max_m", gui.processing.gpr.visible_z_max_m, "gui.processing.gpr", ), ), legacy_gpr=GuiLegacyGprStateModel( mode=legacy_string("mode", legacy_mode_default), input_positions=legacy_string("input_positions", gui.processing.legacy_gpr.input_positions), output_positions=legacy_string("output_positions", gui.processing.legacy_gpr.output_positions), min_depth_m=legacy_float("min_depth_m", gui.processing.legacy_gpr.min_depth_m), max_depth_m=legacy_float("max_depth_m", gui.processing.legacy_gpr.max_depth_m), comp_power=legacy_float("comp_power", gui.processing.legacy_gpr.comp_power), start_freq_mhz=legacy_float("start_freq_mhz", gui.processing.legacy_gpr.start_freq_mhz), stop_freq_mhz=legacy_float("stop_freq_mhz", gui.processing.legacy_gpr.stop_freq_mhz), speed_m_s=legacy_float("speed_m_s", gui.processing.legacy_gpr.speed_m_s), ignore_socket_speed_enabled=legacy_bool( "ignore_socket_speed_enabled", gui.processing.legacy_gpr.ignore_socket_speed_enabled, ), look_angle_deg=legacy_float("look_angle_deg", gui.processing.legacy_gpr.look_angle_deg), apply_freq_phase_correction=legacy_bool( "apply_freq_phase_correction", gui.processing.legacy_gpr.apply_freq_phase_correction, ), reference_mode=legacy_string( "reference_mode", gui.processing.legacy_gpr.reference_mode, ), snr_thresh=legacy_float("snr_thresh", gui.processing.legacy_gpr.snr_thresh), snr_comp_max=legacy_float("snr_comp_max", gui.processing.legacy_gpr.snr_comp_max), background_subtract_enabled=legacy_bool( "background_subtract_enabled", gui.processing.legacy_gpr.background_subtract_enabled, ), background_mean_count=legacy_int( "background_mean_count", gui.processing.legacy_gpr.background_mean_count, ), render_mode=legacy_string("render_mode", gui.processing.legacy_gpr.render_mode), min_visible_pair_count=legacy_int( "min_visible_pair_count", gui.processing.legacy_gpr.min_visible_pair_count, ), visible_x_min_m=legacy_float("visible_x_min_m", gui.processing.legacy_gpr.visible_x_min_m), visible_x_max_m=legacy_float("visible_x_max_m", gui.processing.legacy_gpr.visible_x_max_m), 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), ), ) 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.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"}: raise ValueError("gui.processing.gpr.render_mode must be one of: heatmap, objects_only") if gui.processing.gpr.score_mode not in {"peak", "combined"}: raise ValueError("gui.processing.gpr.score_mode must be one of: peak, combined") if gui.processing.gpr.motion_mode not in {"int_minus", "int_focus"}: raise ValueError("gui.processing.gpr.motion_mode must be one of: int_minus, int_focus") if gui.processing.legacy_gpr.mode not in {"point", "extended"}: raise ValueError("gui.processing.legacy_gpr.mode must be one of: point, extended") if gui.processing.legacy_gpr.render_mode not in {"heatmap", "objects_only"}: raise ValueError("gui.processing.legacy_gpr.render_mode must be one of: heatmap, objects_only") if gui.processing.legacy_gpr.reference_mode not in {"frame_center", "first_tx_event"}: raise ValueError( "gui.processing.legacy_gpr.reference_mode must be one of: frame_center, first_tx_event" ) if gui.processing.gpr.range_comp_power < 0.0: raise ValueError("gui.processing.gpr.range_comp_power must be >= 0") if not 0.0 <= gui.processing.gpr.object_min_frac <= 1.0: raise ValueError("gui.processing.gpr.object_min_frac must be within [0, 1]") if gui.processing.gpr.max_detected_objects_to_draw < 0: raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0") if gui.processing.gpr.draw_top_m_objects < 0: raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0") if gui.processing.gpr.object_approach_min_frames < 1: raise ValueError("gui.processing.gpr.object_approach_min_frames must be >= 1") if gui.processing.legacy_gpr.comp_power < 0.0: raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0") if gui.processing.legacy_gpr.snr_thresh < 0.0: raise ValueError("gui.processing.legacy_gpr.snr_thresh must be >= 0") if gui.processing.legacy_gpr.snr_comp_max < 0.0: 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") data_actions_object = _as_dict(gui_object.get("data_actions"), "gui.data_actions") gui.data_actions = GuiDataActionsStateModel( save_count=_optional_int( data_actions_object, "save_count", gui.data_actions.save_count, "gui.data_actions", ), save_path=_optional_string( data_actions_object, "save_path", gui.data_actions.save_path, "gui.data_actions", ), save_name=_optional_string( data_actions_object, "save_name", gui.data_actions.save_name, "gui.data_actions", ), record_count=_optional_int( data_actions_object, "record_count", gui.data_actions.record_count, "gui.data_actions", ), ) preprocess_dialog_object = _as_dict(gui_object.get("preprocess_dialog"), "gui.preprocess_dialog") gui.preprocess_dialog = GuiPreprocessDialogStateModel( set_name=_optional_string( preprocess_dialog_object, "set_name", gui.preprocess_dialog.set_name, "gui.preprocess_dialog", ), radar_config_dir=_optional_string( preprocess_dialog_object, "radar_config_dir", gui.preprocess_dialog.radar_config_dir, "gui.preprocess_dialog", ), use_all_radar_configs=_optional_bool( preprocess_dialog_object, "use_all_radar_configs", gui.preprocess_dialog.use_all_radar_configs, "gui.preprocess_dialog", ), median_sweep_count=max( 1, _optional_int( preprocess_dialog_object, "median_sweep_count", gui.preprocess_dialog.median_sweep_count, "gui.preprocess_dialog", ), ), ) profile.gui = gui logger.debug("Decoded GUI profile: selected_mode=%s", gui.processing.selected_mode) return profile def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: """Encode :class:`GuiProfileModel` into JSON-serializable dictionary.""" payload = model.run_config.to_dict() gui = model.gui if model.gui is not None else GuiStateModel() payload["gui"] = { "version": gui.version, "switches": { "combo_mode": gui.switches.combo_mode, "combos_text": gui.switches.combos_text, "single_input": gui.switches.single_input, "single_output": gui.switches.single_output, }, "processing": { "selected_mode": gui.processing.selected_mode, "pass_through": { "show_magnitude": gui.processing.pass_through.show_magnitude, "show_phase": gui.processing.pass_through.show_phase, "unwrap_phase": gui.processing.pass_through.unwrap_phase, "combo_filter": gui.processing.pass_through.combo_filter, "fixed_y_enabled": gui.processing.pass_through.fixed_y_enabled, "y_min_db": gui.processing.pass_through.y_min_db, "y_max_db": gui.processing.pass_through.y_max_db, }, "bscan": { "axis": gui.processing.bscan.axis, "cut_m": gui.processing.bscan.cut_m, "max_depth_m": gui.processing.bscan.max_depth_m, "gain": gui.processing.bscan.gain, "start_freq_mhz": gui.processing.bscan.start_freq_mhz, "stop_freq_mhz": gui.processing.bscan.stop_freq_mhz, "subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan, "history_window_scans": gui.processing.bscan.history_window_scans, }, "gpr": { "input_positions": gui.processing.gpr.input_positions, "output_positions": gui.processing.gpr.output_positions, "min_depth_m": gui.processing.gpr.min_depth_m, "max_depth_m": gui.processing.gpr.max_depth_m, "range_comp_power": gui.processing.gpr.range_comp_power, "object_min_frac": gui.processing.gpr.object_min_frac, "score_mode": gui.processing.gpr.score_mode, "motion_mode": gui.processing.gpr.motion_mode, "look_angle_deg": gui.processing.gpr.look_angle_deg, "direction_sign": gui.processing.gpr.direction_sign, "speed_m_s": gui.processing.gpr.speed_m_s, "ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled, "max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw, "draw_top_m_objects": gui.processing.gpr.draw_top_m_objects, "object_approach_min_frames": gui.processing.gpr.object_approach_min_frames, "start_freq_mhz": gui.processing.gpr.start_freq_mhz, "stop_freq_mhz": gui.processing.gpr.stop_freq_mhz, "background_subtract_enabled": gui.processing.gpr.background_subtract_enabled, "background_mean_count": gui.processing.gpr.background_mean_count, "remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled, "imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m, "render_mode": gui.processing.gpr.render_mode, "visible_x_min_m": gui.processing.gpr.visible_x_min_m, "visible_x_max_m": gui.processing.gpr.visible_x_max_m, "visible_z_min_m": gui.processing.gpr.visible_z_min_m, "visible_z_max_m": gui.processing.gpr.visible_z_max_m, }, "legacy_gpr": { "mode": gui.processing.legacy_gpr.mode, "input_positions": gui.processing.legacy_gpr.input_positions, "output_positions": gui.processing.legacy_gpr.output_positions, "min_depth_m": gui.processing.legacy_gpr.min_depth_m, "max_depth_m": gui.processing.legacy_gpr.max_depth_m, "comp_power": gui.processing.legacy_gpr.comp_power, "start_freq_mhz": gui.processing.legacy_gpr.start_freq_mhz, "stop_freq_mhz": gui.processing.legacy_gpr.stop_freq_mhz, "speed_m_s": gui.processing.legacy_gpr.speed_m_s, "ignore_socket_speed_enabled": gui.processing.legacy_gpr.ignore_socket_speed_enabled, "look_angle_deg": gui.processing.legacy_gpr.look_angle_deg, "apply_freq_phase_correction": gui.processing.legacy_gpr.apply_freq_phase_correction, "reference_mode": gui.processing.legacy_gpr.reference_mode, "snr_thresh": gui.processing.legacy_gpr.snr_thresh, "snr_comp_max": gui.processing.legacy_gpr.snr_comp_max, "background_subtract_enabled": gui.processing.legacy_gpr.background_subtract_enabled, "background_mean_count": gui.processing.legacy_gpr.background_mean_count, "render_mode": gui.processing.legacy_gpr.render_mode, "min_visible_pair_count": gui.processing.legacy_gpr.min_visible_pair_count, "visible_x_min_m": gui.processing.legacy_gpr.visible_x_min_m, "visible_x_max_m": gui.processing.legacy_gpr.visible_x_max_m, "visible_z_min_m": gui.processing.legacy_gpr.visible_z_min_m, "visible_z_max_m": gui.processing.legacy_gpr.visible_z_max_m, }, }, "data_actions": { "save_count": gui.data_actions.save_count, "save_path": gui.data_actions.save_path, "save_name": gui.data_actions.save_name, "record_count": gui.data_actions.record_count, }, "preprocess_dialog": { "set_name": gui.preprocess_dialog.set_name, "radar_config_dir": gui.preprocess_dialog.radar_config_dir, "use_all_radar_configs": gui.preprocess_dialog.use_all_radar_configs, "median_sweep_count": gui.preprocess_dialog.median_sweep_count, }, } return payload