Rework radar system
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""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 Kamil ADC stream" if config.is_kamil_adc 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)
|
||||
Reference in New Issue
Block a user