"""GUI profile persistence, startup selection and application to concrete editors.""" from copy import deepcopy from dataclasses import dataclass import os from pathlib import Path from PyQt6.QtWidgets import QFileDialog, QWidget from python_app.gui.controllers.config_controller import ConfigController from python_app.gui.controllers.sections.views import PrimaryView from python_app.file_io import atomic_write_json from python_app.gui.controllers.live_settings_controller import LiveSettingsController from python_app.gui.controllers.log_controller import LogController from python_app.gui.controllers.pipeline_controller import PipelineController from python_app.gui.controllers.preprocess_controller import PreprocessController from python_app.gui.controllers.radar_settings_controller import RadarSettingsController from python_app.gui.settings_defaults import cpp_bscan_replay_window_for_config, default_gui_state_for_config from python_app.models.gui_profile_schema import GuiProfileModel, GuiStateModel from python_app.models.run_config_schema import RunConfigModel from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore @dataclass(slots=True) class ProfileState: config: RunConfigModel gui: GuiStateModel path: Path session: GuiSessionStateStore def _remember(state: ProfileState, path: Path, store: GuiSessionStateStore, log: LogController) -> None: state.path = path.expanduser().resolve(strict=False) try: store.write(GuiSessionState(last_profile_path=str(state.path))) except Exception as exc: log.exception("Failed to update GUI session-state with the active config profile path", exc, level="WARN") def load_startup(project_root: Path, log: LogController) -> ProfileState: store = GuiSessionStateStore(project_root / "python_app/runtime/gui_session_state.json") root_path = (project_root / "run_config.json").resolve(strict=False) requested = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip() if not requested: try: requested = store.load().last_profile_path.strip() except Exception as exc: log.warning("Failed to read GUI session-state; using root run_config.json.", details=log.exception_details(exc)) path = Path(requested).expanduser() if requested else root_path if not path.is_absolute(): path = project_root / path path = path.resolve(strict=False) try: profile = GuiProfileModel.load_from_path(path) except Exception as exc: if path == root_path: raise log.warning("Failed to load the last selected config profile; falling back to root run_config.json.", details=log.exception_details(exc)) profile, path = GuiProfileModel.load_from_path(root_path), root_path config = profile.run_config.clone() log.set_level(config.logging.level) gui = profile.gui or default_gui_state_for_config(config, project_root) if profile.gui is None and path != root_path: log.info(f"Loaded legacy run config without GUI defaults: {path}") state = ProfileState(config, gui, path, store) _remember(state, path, store, log) return state class ProfileController: def __init__( self, parent: QWidget, project_root: Path, state: ProfileState, config: ConfigController, preprocess: PreprocessController, pipeline: PipelineController, radar: RadarSettingsController, live: LiveSettingsController, log: LogController, ) -> None: self.state = state self._parent, self._project_root, self._config = parent, project_root, config self._preprocess, self._pipeline, self._radar, self._live, self._log = preprocess, pipeline, radar, live, log self._session = state.session @staticmethod def write_profile( path: Path, profile: GuiProfileModel, active: RunConfigModel | None = None, *, allow_overwrite: bool = True, dataset: bool = False, ) -> GuiProfileModel: """Atomically save settings or a dataset profile with its acquisition provenance.""" if not allow_overwrite and path.exists(): raise FileExistsError(f"Config profile output already exists: {path}") if dataset and active is not None: profile = profile.clone() profile.run_config.radar = deepcopy(active.radar) profile.run_config.input_switch = deepcopy(active.input_switch) profile.run_config.output_switch = deepcopy(active.output_switch) profile.run_config.combos = deepcopy(active.combos) profile.run_config.preprocess = deepcopy(active.preprocess) profile.run_config.gpr.tx_geometry = deepcopy(active.gpr.tx_geometry) profile.run_config.gpr.rx_geometry = deepcopy(active.gpr.rx_geometry) atomic_write_json(path, profile.to_dict(), durable=True) return profile def bind_actions(self, actions: PrimaryView) -> None: actions.load.clicked.connect(self.load_dialog) actions.save.clicked.connect(self.save_dialog) def build_config(self) -> RunConfigModel: return self._config.build_config(self.state.config, self._preprocess.selected_sets, self._live.writer.path, self._log.level.currentText().lower()) def build_gui_state(self) -> GuiStateModel: return self._config.build_gui_state(self.state.config, self._preprocess.settings()) def build_profile(self) -> GuiProfileModel: return GuiProfileModel(run_config=self.build_config(), gui=self.build_gui_state()) def radar_key(self) -> str: return self._config.radar_key(self.state.config) def write(self, path: Path, *, allow_overwrite: bool = True, dataset: bool = False) -> GuiProfileModel: return self.write_profile(path, self.build_profile(), self._pipeline.active_config, allow_overwrite=allow_overwrite, dataset=dataset) def save_dialog(self) -> None: try: selected, _ = QFileDialog.getSaveFileName(self._parent, "Save Config Profile", str(self.state.path), "JSON Files (*.json);;All Files (*)") if not selected: return path = Path(selected).expanduser().resolve(strict=False) if not path.suffix: path = path.with_suffix(".json") profile = self.write(path) self.state.config = profile.run_config.clone() self.state.gui = profile.gui or default_gui_state_for_config(profile.run_config, self._project_root) _remember(self.state, path, self._session, self._log) config, sweep = profile.run_config, profile.run_config.radar.sweep points = "from RadioPhotonic radar (RPH) stream" if config.is_rph_radar else str(sweep.points) self._log.info( f"Config profile saved: path={path}, combos={len(config.combos)}, " f"sweep={sweep.start_hz:g}..{sweep.stop_hz:g} Hz, points={points}, " f"ifbw={sweep.if_bandwidth_hz:g} Hz, power={sweep.power_dbm:g} dBm, " f"processing_mode={self.state.gui.processing.selected_mode}" ) except Exception as exc: self._log.show_exception("Failed to save config profile", exc) def _ready_to_load(self) -> bool: if self._pipeline.closing: return False reason = self._preprocess.busy_reason() if reason: self._log.show_error(reason, details=self._preprocess.capture_state_details()) return False if self._pipeline.running or self._pipeline.should_run or self._pipeline.paused: self._log.show_error("Stop all pipeline processes before loading a config profile") return False return True def load(self, path: Path) -> None: if not self._ready_to_load(): return try: path = path.expanduser().resolve(strict=False) profile = GuiProfileModel.load_from_path(path) self.apply(profile, path) kind = "legacy run config" if profile.gui is None else "full GUI profile" self._log.info(f"Config profile loaded: path={path}, kind={kind}, combos={len(self.state.config.combos)}, " f"processing_mode={self._live.active_mode}") except Exception as exc: self._log.show_exception("Failed to load config profile", exc) def load_dialog(self) -> None: if not self._ready_to_load(): return selected, _ = QFileDialog.getOpenFileName(self._parent, "Load Config Profile", str(self.state.path), "JSON Files (*.json);;All Files (*)") if selected: self.load(Path(selected)) def apply(self, profile: GuiProfileModel, path: Path) -> None: self._log.debug(f"Applying loaded profile: path={path}, has_gui_state={profile.gui is not None}.") config = profile.run_config.clone() gui = profile.gui or default_gui_state_for_config(config, self._project_root) self.state.config, self.state.gui = config, gui self._config.apply_profile_widgets(config, gui) self._radar.sync_profile(gui.switches.combo_mode) self._live.sync_controls() self._log.set_level(self._log.level.currentText()) self._preprocess.apply_profile(config, gui.preprocess_dialog) self._pipeline.plots.live_window = cpp_bscan_replay_window_for_config(config) self._pipeline.plots.clear_bscan() self._live.set_mode_page(gui.processing.selected_mode) self._radar.initial_limits() if self._preprocess.dialog is not None: self._preprocess.refresh_sets() self._live.mode_changed(gui.processing.selected_mode) self._pipeline.history_changed() _remember(self.state, path, self._session, self._log)