522 lines
23 KiB
Python
522 lines
23 KiB
Python
"""Main GUI composition root.
|
|
|
|
This module wires UI/controller mixins together and owns application-level
|
|
state shared across them (runtime services, readers, history buffers, timer).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import deque
|
|
from datetime import datetime
|
|
import html
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import traceback
|
|
|
|
from PyQt6.QtCore import QTimer
|
|
from PyQt6.QtGui import QTextCursor
|
|
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
|
|
|
from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMixin
|
|
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
|
|
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
|
|
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin
|
|
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin
|
|
from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin
|
|
from python_app.gui.preprocess_dialog import PreprocessDialog
|
|
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
|
from python_app.models.gui_profile_model import GuiProfileModel
|
|
from python_app.orchestration.config_writer import ConfigWriter
|
|
from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore
|
|
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter
|
|
from python_app.orchestration.pipeline_metrics import PipelineMetrics
|
|
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
|
|
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
|
from python_app.orchestration.shm_reader import ShmRingReader
|
|
from python_app.storage.npz_store import NpzStore
|
|
from python_app.workflows.multi_radar_capture_workflow import MultiRadarSequentialCaptureSession
|
|
from python_app.workflows.radar_config_variants import RadarConfigScanSummary, RadarConfigVariant
|
|
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
|
|
|
|
|
class AppWindow(
|
|
AppWindowUiMixin,
|
|
AppWindowConfigMixin,
|
|
AppWindowPreprocessMixin,
|
|
AppWindowPlotMixin,
|
|
AppWindowPipelineMixin,
|
|
AppWindowSnapshotMixin,
|
|
QMainWindow,
|
|
):
|
|
"""Top-level window coordinating GUI state and acquisition runtime."""
|
|
|
|
def __init__(self, project_root: Path) -> None:
|
|
"""Initialize all app subsystems in deterministic order."""
|
|
super().__init__()
|
|
|
|
self._init_paths(project_root)
|
|
self._init_runtime_services()
|
|
self._init_config_profile_state()
|
|
self._init_reader_handles()
|
|
self._init_preprocess_state()
|
|
self._init_capture_state()
|
|
self._init_history_state()
|
|
self._init_runtime_limits()
|
|
self._init_polling_timer()
|
|
self._bootstrap_ui_runtime()
|
|
|
|
def _init_paths(self, project_root: Path) -> None:
|
|
"""Initialize static project paths and startup log queue."""
|
|
self._project_root = project_root
|
|
self._root_profile_path = project_root / "run_config.json"
|
|
self._active_profile_path = self._root_profile_path
|
|
self._pending_startup_log_entries: list[tuple[str, str, str | None]] = []
|
|
|
|
def _init_runtime_services(self) -> None:
|
|
"""Initialize long-lived service objects used by mixins."""
|
|
runtime_dir = self._project_root / "python_app/runtime"
|
|
self._runtime_dir = runtime_dir
|
|
self._store = NpzStore(self._project_root / "python_app/data")
|
|
self._config_writer = ConfigWriter(runtime_dir)
|
|
self._supervisor = ProcessSupervisor(self._project_root)
|
|
self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json")
|
|
self._gui_session_state_store = GuiSessionStateStore(runtime_dir / "gui_session_state.json")
|
|
# `log_sink` is attached after the runtime log widget exists.
|
|
self._pipeline_metrics = PipelineMetrics(
|
|
report_every=self._resolve_metrics_report_every()
|
|
)
|
|
|
|
@staticmethod
|
|
def _resolve_metrics_report_every() -> int:
|
|
"""Read the metrics flush threshold from env, falling back to 50."""
|
|
raw = os.environ.get("RADAR_SYSTEM_METRICS_REPORT_EVERY", "").strip()
|
|
if not raw:
|
|
return 50
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
return 50
|
|
return value if value >= 1 else 50
|
|
|
|
def _init_config_profile_state(self) -> None:
|
|
"""Resolve startup profile path, load active profile, and queue fallback notices."""
|
|
active_profile_path = self._resolve_startup_profile_path()
|
|
try:
|
|
profile = GuiProfileModel.load_from_path(active_profile_path)
|
|
except Exception as exc:
|
|
if active_profile_path == self._root_profile_path:
|
|
raise
|
|
self._queue_startup_log_entry(
|
|
"WARN",
|
|
"Failed to load the last selected config profile; falling back to root run_config.json.",
|
|
details=self._exception_details(exc),
|
|
)
|
|
profile = GuiProfileModel.load_from_path(self._root_profile_path)
|
|
active_profile_path = self._root_profile_path
|
|
|
|
self._active_profile_path = active_profile_path
|
|
self._defaults_config = profile.run_config.clone()
|
|
if profile.gui is not None:
|
|
self._gui_defaults = profile.gui
|
|
else:
|
|
self._gui_defaults = self._default_gui_state_for_config(self._defaults_config)
|
|
if active_profile_path != self._root_profile_path:
|
|
self._queue_startup_log_entry(
|
|
"INFO",
|
|
f"Loaded legacy run config without GUI defaults: {active_profile_path}",
|
|
)
|
|
self._remember_active_profile_path(active_profile_path, startup=True)
|
|
|
|
def _init_reader_handles(self) -> None:
|
|
"""Initialize SHM readers as detached (not connected) handles."""
|
|
self._raw_reader: ShmRingReader | None = None
|
|
self._pre_reader: ShmRingReader | None = None
|
|
self._result_reader: ShmRingReader | None = None
|
|
|
|
def _init_preprocess_state(self) -> None:
|
|
"""Initialize preprocessing dialog and selected set names."""
|
|
self._preprocess_dialog: PreprocessDialog | None = None
|
|
self._preprocess_set_name = str(self._gui_defaults.preprocess_dialog.set_name)
|
|
self._preprocess_radar_config_dir = str(self._gui_defaults.preprocess_dialog.radar_config_dir)
|
|
self._preprocess_use_all_radar_configs = bool(self._gui_defaults.preprocess_dialog.use_all_radar_configs)
|
|
self._preprocess_median_sweep_count = max(
|
|
1, int(self._gui_defaults.preprocess_dialog.median_sweep_count)
|
|
)
|
|
self._preprocess_radar_variants: list[RadarConfigVariant] = []
|
|
self._preprocess_radar_scan_summary = RadarConfigScanSummary(
|
|
directory_path=self._preprocess_radar_config_dir,
|
|
json_file_count=0,
|
|
valid_variant_count=0,
|
|
skipped_file_count=0,
|
|
duplicate_variant_count=0,
|
|
issues=(),
|
|
)
|
|
self._selected_preprocess_sets = {
|
|
key: str(preprocess_asset_model(self._defaults_config, key).set_name)
|
|
for key in VISIBLE_PREPROCESS_ASSET_KEYS
|
|
}
|
|
self._selected_preprocess_radar_key = self._radar_key(self._defaults_config)
|
|
|
|
def _init_capture_state(self) -> None:
|
|
"""Initialize one-shot capture and sequence-control flags."""
|
|
self._capture_session: SequentialCaptureSession | MultiRadarSequentialCaptureSession | None = None
|
|
self._resume_pipeline_after_capture = False
|
|
self._single_capture_active = False
|
|
self._single_capture_start_ns: int | None = None
|
|
self._single_capture_seen_raw = False
|
|
self._single_capture_target_collection_id: int | None = None
|
|
|
|
def _init_history_state(self) -> None:
|
|
"""Initialize runtime history buffers and render-cache state."""
|
|
history_limit = self._history_limit_from_config()
|
|
self._raw_history: deque[SweepCollection] = deque(maxlen=history_limit)
|
|
self._pre_history: deque[SweepCollection] = deque(maxlen=history_limit)
|
|
self._result_history: deque[ResultCollection] = deque(maxlen=history_limit)
|
|
|
|
# Sequence id must survive GUI restarts so history commands stay monotonic.
|
|
self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
|
|
self._bscan_history_limit = history_limit
|
|
self._bscan_history_by_combo = {}
|
|
self._bscan_depth_axis_by_combo = {}
|
|
self._bscan_history_floor_collection_id = 0
|
|
self._bscan_render_signature = None
|
|
self._gpr_lookup_table = None
|
|
self._gpr_image_item = None
|
|
self._gpr_tx_item = None
|
|
self._gpr_rx_item = None
|
|
self._gpr_points_item = None
|
|
self._gpr_region_centers_item = None
|
|
self._gpr_point_labels = []
|
|
self._gpr_region_center_labels = []
|
|
self._gpr_region_mask_items = []
|
|
self._gpr_region_contours = []
|
|
self._gpr_geometry_signature = None
|
|
self._gpr_selected_geometry = None
|
|
self._phase_viewbox = None
|
|
self._history_run_signature = None
|
|
self._processor_run_signature = None
|
|
self._active_processing_mode = "pass_through"
|
|
self._radar_limits: dict[str, float | int] | None = None
|
|
|
|
def _history_limit_from_config(self) -> int:
|
|
"""Return unified GUI history limit derived from configured ring capacities."""
|
|
return max(
|
|
1,
|
|
min(
|
|
int(self._defaults_config.rings.raw_tap.capacity),
|
|
int(self._defaults_config.rings.preprocessed_tap.capacity),
|
|
int(self._defaults_config.rings.results.capacity),
|
|
),
|
|
)
|
|
|
|
def _init_runtime_limits(self) -> None:
|
|
"""Initialize read/drain loop limits used by polling and snapshot code."""
|
|
self._max_pop_per_poll = 256
|
|
self._max_pop_per_snapshot_drain = 4096
|
|
self._last_reader_error_signature: tuple[str, str] | None = None
|
|
self._logged_once_keys: set[str] = set()
|
|
|
|
def _init_polling_timer(self) -> None:
|
|
"""Create periodic timer that polls SHM rings for new data."""
|
|
self._timer = QTimer(self)
|
|
self._timer.setInterval(50)
|
|
self._timer.timeout.connect(self._poll_rings)
|
|
|
|
def _bootstrap_ui_runtime(self) -> None:
|
|
"""Build UI and apply initial runtime-bound state after widgets exist."""
|
|
self._build_ui()
|
|
self._flush_pending_startup_log_entries()
|
|
self._log(f"Active config profile: {self._active_profile_path}")
|
|
self._refresh_preprocess_summary_labels()
|
|
self._apply_initial_radar_limits()
|
|
self._on_processing_mode_changed(self._processing_mode.currentText())
|
|
self._write_live_processing_config()
|
|
# Defer the log sink wiring until the runtime log widget exists.
|
|
self._pipeline_metrics.set_log_sink(self._log)
|
|
self._timer.start()
|
|
self._maybe_auto_start_pipeline()
|
|
|
|
def _resolve_startup_profile_path(self) -> Path:
|
|
"""Resolve active profile path from session-state or root fallback path."""
|
|
env_profile_path = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip()
|
|
if env_profile_path:
|
|
profile_path = Path(env_profile_path).expanduser()
|
|
if not profile_path.is_absolute():
|
|
profile_path = (self._project_root / profile_path).resolve(strict=False)
|
|
return profile_path
|
|
|
|
try:
|
|
session_state = self._gui_session_state_store.load()
|
|
except Exception as exc:
|
|
self._queue_startup_log_entry(
|
|
"WARN",
|
|
"Failed to read GUI session-state; using root run_config.json.",
|
|
details=self._exception_details(exc),
|
|
)
|
|
return self._root_profile_path
|
|
|
|
raw_path = session_state.last_profile_path.strip()
|
|
if not raw_path:
|
|
return self._root_profile_path
|
|
|
|
profile_path = Path(raw_path).expanduser()
|
|
if not profile_path.is_absolute():
|
|
profile_path = (self._project_root / profile_path).resolve(strict=False)
|
|
return profile_path
|
|
|
|
def _maybe_auto_start_pipeline(self) -> None:
|
|
"""Schedule pipeline start when requested by launcher environment.
|
|
|
|
With `RADAR_SYSTEM_AUTO_APPLY_RADAR=1` the launcher also reproduces the
|
|
"Apply Radar" click before "Start". This is the headless deployment
|
|
recipe: the GUI configures the device exactly as a human operator
|
|
would, then starts the capture pipeline.
|
|
"""
|
|
auto_start = self._is_truthy_env("RADAR_SYSTEM_AUTO_START")
|
|
if not auto_start:
|
|
return
|
|
self._log("Auto-start requested by launcher.")
|
|
if self._is_truthy_env("RADAR_SYSTEM_AUTO_APPLY_RADAR"):
|
|
QTimer.singleShot(500, self._auto_apply_radar_then_start)
|
|
else:
|
|
QTimer.singleShot(500, self._start_run)
|
|
|
|
def _auto_apply_radar_then_start(self) -> None:
|
|
"""Apply current radar settings then start the pipeline (headless boot)."""
|
|
try:
|
|
self._apply_radar_settings()
|
|
except Exception as exc: # noqa: BLE001
|
|
self._log_exception("Auto apply-radar failed", exc, level="WARN")
|
|
# Hand control back to the event loop so widget updates from
|
|
# _apply_radar_settings can flush before _start_run takes over.
|
|
QTimer.singleShot(100, self._start_run)
|
|
|
|
@staticmethod
|
|
def _is_truthy_env(name: str) -> bool:
|
|
"""Return True when an environment variable is set to a truthy literal."""
|
|
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
def _normalize_profile_path(self, path: Path) -> Path:
|
|
"""Return normalized absolute profile path."""
|
|
return path.expanduser().resolve(strict=False)
|
|
|
|
def _remember_active_profile_path(self, path: Path, *, startup: bool = False) -> None:
|
|
"""Persist last successfully used config profile path."""
|
|
normalized_path = self._normalize_profile_path(path)
|
|
self._active_profile_path = normalized_path
|
|
try:
|
|
self._gui_session_state_store.write(GuiSessionState(last_profile_path=str(normalized_path)))
|
|
except Exception as exc:
|
|
if startup:
|
|
self._queue_startup_log_entry(
|
|
"WARN",
|
|
"Failed to update GUI session-state with the active config profile path.",
|
|
details=self._exception_details(exc),
|
|
)
|
|
else:
|
|
self._log_exception(
|
|
"Failed to update GUI session-state with the active config profile path",
|
|
exc,
|
|
level="WARN",
|
|
)
|
|
|
|
def _queue_startup_log_entry(self, level: str, text: str, *, details: str | None = None) -> None:
|
|
"""Queue startup log entry until log widget exists."""
|
|
self._pending_startup_log_entries.append((level.upper(), text, details))
|
|
|
|
def _flush_pending_startup_log_entries(self) -> None:
|
|
"""Flush startup log entries into the runtime log box after UI creation."""
|
|
if not self._pending_startup_log_entries:
|
|
return
|
|
|
|
for level, text, details in self._pending_startup_log_entries:
|
|
self._append_log_entry(level, text, details=details)
|
|
self._pending_startup_log_entries.clear()
|
|
|
|
def _apply_initial_radar_limits(self) -> None:
|
|
"""Apply startup radar-limits strategy according to selected radar mode."""
|
|
if self._defaults_config.radar.driver_mode == "native":
|
|
self._refresh_radar_limits_from_device()
|
|
return
|
|
self._apply_radar_limits_to_ui(None)
|
|
|
|
@staticmethod
|
|
def _escape_log_text(text: str) -> str:
|
|
"""Escape log text for insertion into rich-text log widget."""
|
|
return html.escape(text).replace("\n", "<br>")
|
|
|
|
@staticmethod
|
|
def _exception_summary(exc: Exception) -> str:
|
|
"""Build compact one-line exception summary."""
|
|
message = str(exc).strip()
|
|
if message:
|
|
return f"{type(exc).__name__}: {message}"
|
|
return type(exc).__name__
|
|
|
|
@staticmethod
|
|
def _exception_details(exc: Exception) -> str:
|
|
"""Return full chained traceback for error dialogs and log details."""
|
|
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
|
|
|
|
def _append_log_entry(
|
|
self,
|
|
level: str,
|
|
text: str,
|
|
*,
|
|
details: str | None = None,
|
|
once_key: str | None = None,
|
|
) -> None:
|
|
"""Append formatted log entry with timestamp and optional details."""
|
|
if once_key is not None:
|
|
if once_key in self._logged_once_keys:
|
|
return
|
|
self._logged_once_keys.add(once_key)
|
|
|
|
level_upper = level.upper()
|
|
palette = {
|
|
"INFO": ("#1d5fbf", "#1f2937", "#526277"),
|
|
"WARN": ("#9a5b00", "#5c4300", "#7a6640"),
|
|
"ERROR": ("#c43d4d", "#6b1f2a", "#8b5d66"),
|
|
}
|
|
accent_color, message_color, detail_color = palette.get(level_upper, palette["INFO"])
|
|
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
header_html = (
|
|
f"<span style='color:{accent_color}; font-weight:700;'>{html.escape(level_upper)}</span>"
|
|
f" <span style='color:#60758d;'>{html.escape(timestamp)}</span>"
|
|
f" <span style='color:{message_color};'>{self._escape_log_text(text)}</span>"
|
|
)
|
|
|
|
body_parts = [header_html]
|
|
if details:
|
|
body_parts.append(
|
|
"<pre style='margin:3px 0 0 16px; color:"
|
|
f"{detail_color};'>{html.escape(details)}</pre>"
|
|
)
|
|
|
|
entry_html = "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
|
|
cursor = self._log_box.textCursor()
|
|
cursor.movePosition(QTextCursor.MoveOperation.End)
|
|
self._log_box.setTextCursor(cursor)
|
|
self._log_box.insertHtml(entry_html)
|
|
self._log_box.insertPlainText("\n")
|
|
self._log_box.ensureCursorVisible()
|
|
|
|
if level_upper == "ERROR" and hasattr(self, "_status_label"):
|
|
self._status_label.setText("Status: error")
|
|
|
|
def _log(self, text: str, *, once_key: str | None = None) -> None:
|
|
"""Append informational message to runtime log panel."""
|
|
self._append_log_entry("INFO", text, once_key=once_key)
|
|
|
|
def _log_warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
|
"""Append warning message to runtime log panel."""
|
|
self._append_log_entry("WARN", text, details=details, once_key=once_key)
|
|
|
|
def _log_error(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
|
"""Append error message to runtime log panel."""
|
|
self._append_log_entry("ERROR", text, details=details, once_key=once_key)
|
|
|
|
def _log_exception(self, context: str, exc: Exception, *, level: str = "ERROR") -> tuple[str, str]:
|
|
"""Log exception with detailed traceback and return `(message, details)`."""
|
|
message = f"{context}: {self._exception_summary(exc)}"
|
|
details = self._exception_details(exc)
|
|
if level.upper() == "WARN":
|
|
self._log_warning(message, details=details)
|
|
else:
|
|
self._log_error(message, details=details)
|
|
return message, details
|
|
|
|
def _process_state_details(self) -> str:
|
|
"""Return formatted summary of managed pipeline process state."""
|
|
if not hasattr(self, "_supervisor"):
|
|
return "Managed processes: unavailable"
|
|
pid_map = self._supervisor.pids()
|
|
if not pid_map:
|
|
return "Managed processes: none"
|
|
return "Managed processes:\n" + "\n".join(
|
|
f"- {name}: pid={pid}"
|
|
for name, pid in sorted(pid_map.items())
|
|
)
|
|
|
|
def _runtime_history_details(self) -> str:
|
|
"""Return formatted summary of buffered runtime history counts."""
|
|
return (
|
|
"Runtime history:\n"
|
|
f"- raw={len(getattr(self, '_raw_history', []))}\n"
|
|
f"- preprocessed={len(getattr(self, '_pre_history', []))}\n"
|
|
f"- results={len(getattr(self, '_result_history', []))}"
|
|
)
|
|
|
|
def _capture_state_details(self) -> str:
|
|
"""Return formatted summary of active preprocess capture state."""
|
|
session = getattr(self, "_capture_session", None)
|
|
if session is None:
|
|
return "Capture session: none"
|
|
state = session.state()
|
|
lines = [
|
|
"Capture session:",
|
|
f"- kind={state.kind}",
|
|
f"- progress={state.captured_count}/{state.total_count}",
|
|
]
|
|
if state.current_combo is not None:
|
|
lines.append(
|
|
f"- current_combo=input={state.current_combo.input}, output={state.current_combo.output}"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
@staticmethod
|
|
def _load_history_command_seq(config_path: Path) -> int:
|
|
"""Load previously used live-command sequence from runtime config file."""
|
|
try:
|
|
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
except Exception: # noqa: BLE001
|
|
# Missing or malformed file should not block startup.
|
|
return 0
|
|
|
|
raw_value = payload.get("history_command_seq", 0)
|
|
if isinstance(raw_value, bool):
|
|
return 0
|
|
if isinstance(raw_value, (int, float)):
|
|
return max(0, int(raw_value))
|
|
return 0
|
|
|
|
def _show_error(self, message: str, *, details: str | None = None) -> None:
|
|
"""Log and present an error in a modal dialog with optional detail text."""
|
|
self._log_error(message, details=details)
|
|
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
|
return
|
|
dialog = QMessageBox(self)
|
|
dialog.setIcon(QMessageBox.Icon.Critical)
|
|
dialog.setWindowTitle("Error")
|
|
dialog.setText(message)
|
|
if details:
|
|
dialog.setDetailedText(details)
|
|
dialog.exec()
|
|
|
|
def _show_exception(self, context: str, exc: Exception) -> None:
|
|
"""Log full exception details and show modal dialog with expandable traceback."""
|
|
message, details = self._log_exception(context, exc, level="ERROR")
|
|
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
|
return
|
|
dialog = QMessageBox(self)
|
|
dialog.setIcon(QMessageBox.Icon.Critical)
|
|
dialog.setWindowTitle("Error")
|
|
dialog.setText(message)
|
|
dialog.setDetailedText(details)
|
|
dialog.exec()
|
|
|
|
def closeEvent(self, event) -> None: # noqa: N802
|
|
"""Ensure workers and dialogs are closed before window destruction."""
|
|
try:
|
|
self._resume_pipeline_after_capture = False
|
|
# 1) Abort active capture first (releases exclusive hardware resources).
|
|
self._abort_capture_sequence(resume_pipeline=False)
|
|
# 2) Stop all managed processes/readers.
|
|
self._stop_all_processes()
|
|
# 3) Close auxiliary dialog windows.
|
|
if self._preprocess_dialog is not None:
|
|
self._preprocess_dialog.close()
|
|
finally:
|
|
super().closeEvent(event)
|