Files
radar_system/python_app/models/gui_profile_schema.py
T
2026-06-23 12:19:31 +03:00

196 lines
6.2 KiB
Python

"""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 Any
from python_app.models.run_config_model import RunConfigModel
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class GuiSwitchStateModel:
"""UI-only state for switch selection controls."""
combo_mode: str = "text"
combos_text: str = ""
single_input: str = "0"
single_output: str = "0"
@dataclass(slots=True)
class GuiPassThroughStateModel:
"""UI-only defaults for pass-through rendering."""
show_magnitude: bool = True
show_phase: bool = True
unwrap_phase: bool = False
combo_filter: str = ""
fixed_y_enabled: bool = False
y_min_db: float = -100.0
y_max_db: float = 0.0
@dataclass(slots=True)
class GuiBscanStateModel:
"""UI-only defaults for B-scan live settings."""
axis: str = "abs"
cut_m: float = 0.824
max_depth_m: float = 1.0
gain: float = 1.0
start_freq_mhz: float = 100.0
stop_freq_mhz: float = 8800.0
subtract_mean_ascan: bool = False
@dataclass(slots=True)
class GuiGprStateModel:
"""UI-only defaults for coherent backprojection GPR live settings."""
input_positions: str = ""
output_positions: str = ""
min_depth_m: float = 2.0
max_depth_m: float = 14.0
range_comp_power: float = 0.1
angle_comp_power: float = 0.0
score_mode: str = "combined"
motion_mode: str = "int_minus"
# 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 = 0.0
direction_sign: float = 1.0
speed_m_s: float = 0.0
ignore_socket_speed_enabled: bool = False
max_detected_objects_to_draw: int = 5
draw_top_m_objects: int = 2
start_freq_mhz: float = 3000.0
stop_freq_mhz: float = 6000.0
background_subtract_enabled: bool = True
background_mean_count: int = 10
remove_sidelobe_objects_enabled: bool = True
imaging_plane_y_m: float = 0.0
render_mode: str = "heatmap"
min_visible_score: float = 0.0
visible_x_min_m: float = -2.0
visible_x_max_m: float = 2.0
visible_z_min_m: float = 0.0
visible_z_max_m: float = 14.0
@dataclass(slots=True)
class GuiLegacyGprStateModel:
"""UI-only defaults for legacy point/extended GPR live settings."""
mode: str = "point"
input_positions: str = ""
output_positions: str = ""
min_depth_m: float = 2.0
max_depth_m: float = 14.0
comp_power: float = 0.2
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
apply_freq_phase_correction: bool = True
reference_mode: str = "frame_center"
snr_thresh: float = 4.5
snr_comp_max: float = 25.0
background_subtract_enabled: bool = True
background_mean_count: int = 10
render_mode: str = "heatmap"
min_visible_pair_count: int = 1
visible_x_min_m: float = -2.0
visible_x_max_m: float = 2.0
visible_z_min_m: float = 0.0
visible_z_max_m: float = 14.0
@dataclass(slots=True)
class GuiProcessingStateModel:
"""UI-only processing-section defaults."""
selected_mode: str = "pass_through"
pass_through: GuiPassThroughStateModel = field(default_factory=GuiPassThroughStateModel)
bscan: GuiBscanStateModel = field(default_factory=GuiBscanStateModel)
gpr: GuiGprStateModel = field(default_factory=GuiGprStateModel)
legacy_gpr: GuiLegacyGprStateModel = field(default_factory=GuiLegacyGprStateModel)
@dataclass(slots=True)
class GuiDataActionsStateModel:
"""UI-only defaults for snapshot/export controls."""
save_count: int = 10
save_path: str = ""
save_name: str = "snapshot_manual"
# How many freshly acquired measurements the "Start + Record" action writes to
# disk before it stops recording (acquisition keeps running).
record_count: int = 100
@dataclass(slots=True)
class GuiPreprocessDialogStateModel:
"""UI-only defaults for preprocessing dialog controls."""
set_name: str = "set_001"
radar_config_dir: str = ""
use_all_radar_configs: bool = False
median_sweep_count: int = 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)