"""Encoding and decoding logic for :mod:`python_app.models.gui_profile_schema`.""" from __future__ import annotations from typing import Any from python_app.models.gui_profile_schema import ( GuiBscanStateModel, GuiDataActionsStateModel, GuiGprStateModel, GuiPassThroughStateModel, GuiPreprocessDialogStateModel, GuiProcessingStateModel, GuiProfileModel, GuiStateModel, GuiSwitchStateModel, ) from python_app.models.run_config_model import RunConfigModel 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 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: 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") gui.processing = GuiProcessingStateModel( selected_mode=_optional_string( processing_object, "selected_mode", gui.processing.selected_mode, "gui.processing", ), 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", ), 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", ), ), 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", ), comp_power=_optional_float( gpr_object, "comp_power", gui.processing.gpr.comp_power, "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", ), speed_m_s=_optional_float( gpr_object, "speed_m_s", gui.processing.gpr.speed_m_s, "gui.processing.gpr", ), look_angle_deg=_optional_float( gpr_object, "look_angle_deg", gui.processing.gpr.look_angle_deg, "gui.processing.gpr", ), snr_thresh=_optional_float( gpr_object, "snr_thresh", gui.processing.gpr.snr_thresh, "gui.processing.gpr", ), snr_comp_max=_optional_float( gpr_object, "snr_comp_max", gui.processing.gpr.snr_comp_max, "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", ), render_mode=_optional_string( gpr_object, "render_mode", gui.processing.gpr.render_mode, "gui.processing.gpr", ), min_visible_pair_count=_optional_int( gpr_object, "min_visible_pair_count", gui.processing.gpr.min_visible_pair_count, "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", ), ), ) if gui.processing.selected_mode not in {"pass_through", "bscan", "gpr"}: raise ValueError("gui.processing.selected_mode must be one of: pass_through, bscan, 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.snr_thresh < 0.0: raise ValueError("gui.processing.gpr.snr_thresh must be >= 0") if gui.processing.gpr.snr_comp_max < 0.0: raise ValueError("gui.processing.gpr.snr_comp_max must be >= 0") if gui.processing.gpr.min_visible_pair_count < 1: raise ValueError("gui.processing.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", ), ) 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", ), ) profile.gui = gui 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, "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, }, "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, "comp_power": gui.processing.gpr.comp_power, "start_freq_mhz": gui.processing.gpr.start_freq_mhz, "stop_freq_mhz": gui.processing.gpr.stop_freq_mhz, "speed_m_s": gui.processing.gpr.speed_m_s, "look_angle_deg": gui.processing.gpr.look_angle_deg, "snr_thresh": gui.processing.gpr.snr_thresh, "snr_comp_max": gui.processing.gpr.snr_comp_max, "background_subtract_enabled": gui.processing.gpr.background_subtract_enabled, "background_mean_count": gui.processing.gpr.background_mean_count, "render_mode": gui.processing.gpr.render_mode, "min_visible_pair_count": gui.processing.gpr.min_visible_pair_count, "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, }, }, "data_actions": { "save_count": gui.data_actions.save_count, "save_path": gui.data_actions.save_path, "save_name": gui.data_actions.save_name, }, "preprocess_dialog": { "set_name": gui.preprocess_dialog.set_name, }, } return payload