diff --git a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp index 5024564..2c47435 100644 --- a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp +++ b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp @@ -16,8 +16,6 @@ enum class HistoryCommand { struct ProcessingLiveConfig { std::string processor_mode = "pass_through"; - float gain_db = 0.0F; - float phase_deg = 0.0F; std::string pass_through_channel = "s21"; bool pass_through_fixed_y_enabled = false; float pass_through_y_min_db = -100.0F; diff --git a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp index 589fd43..a8966e6 100644 --- a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp +++ b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp @@ -96,18 +96,6 @@ using Json = nlohmann::json; } config.processor_mode = found->get(); } - if (const auto found = root.find("gain_db"); found != root.end()) { - if (!found->is_number()) { - throw std::runtime_error("processing.gain_db must be number"); - } - config.gain_db = static_cast(found->get()); - } - if (const auto found = root.find("phase_deg"); found != root.end()) { - if (!found->is_number()) { - throw std::runtime_error("processing.phase_deg must be number"); - } - config.phase_deg = static_cast(found->get()); - } if (const auto found = root.find("pass_through_channel"); found != root.end()) { if (!found->is_string()) { throw std::runtime_error("processing.pass_through_channel must be string"); diff --git a/data_acq_and_processing/processing/processors/src/passthrough_processor.cpp b/data_acq_and_processing/processing/processors/src/passthrough_processor.cpp index 247737a..068978c 100644 --- a/data_acq_and_processing/processing/processors/src/passthrough_processor.cpp +++ b/data_acq_and_processing/processing/processors/src/passthrough_processor.cpp @@ -1,13 +1,6 @@ #include "passthrough_processor.hpp" -#include - namespace radar::processing { -namespace { - -constexpr float kPi = 3.14159265358979323846F; - -} // namespace auto PassThroughProcessor::name() const -> std::string { return "pass_through"; @@ -35,18 +28,6 @@ auto PassThroughProcessor::process_collection( payload.trace = trace.s21; } - const float linear_gain = std::pow(10.0F, live_config.gain_db / 20.0F); - const float phase_rad = live_config.phase_deg * (kPi / 180.0F); - const float cos_phase = std::cos(phase_rad); - const float sin_phase = std::sin(phase_rad); - - for (auto& sample : payload.trace) { - const float re = sample.re; - const float im = sample.im; - sample.re = linear_gain * ((re * cos_phase) - (im * sin_phase)); - sample.im = linear_gain * ((re * sin_phase) + (im * cos_phase)); - } - ipc::ResultBlock block{}; block.combo = trace.combo; block.payloads.push_back(std::move(payload)); diff --git a/python_app/docs/README.md b/python_app/docs/README.md deleted file mode 100644 index 190a398..0000000 --- a/python_app/docs/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Python App Documentation - -## Назначение -Каталог `python_app/docs` содержит техническую документацию по Python-части радарной системы: GUI, orchestration, storage, hardware adapters и эксплуатационные сценарии. - -## Карта документов -- `architecture_overview.md`: общая модульная архитектура и границы ответственности. -- `runtime_data_flow.md`: потоки данных от acquisition до отображения и snapshot. -- `gui_architecture.md`: структура GUI, mixin-контроллеры, plotting/runtime helpers. -- `hardware_layer.md`: слой работы с LibreVNA и переключателями. -- `storage_formats.md`: форматы NPZ/binary/numpy snapshot и naming. -- `shm_protocol_and_readers.md`: shared-memory ring контракт и декодирование payload. -- `preprocessing_and_capture_workflow.md`: калибровка/референс и последовательный захват. -- `operations_runbook.md`: запуск, диагностика, типовые инциденты. -- `module_reference.md`: справочник ключевых Python-модулей. - -## Границы -- Документация описывает Python-часть проекта в `python_app`. -- C++ конвейер (`data_acq_and_processing`) описывается только в части интеграционных точек с Python. - -## Версия документации -Актуально для состояния репозитория после рефакторинга: разделения `models`, `storage/npz`, `orchestration/shm`, `gui/plotting`, `gui/runtime`, `gui/controllers/sections`. diff --git a/python_app/docs/architecture_overview.md b/python_app/docs/architecture_overview.md deleted file mode 100644 index abc8afc..0000000 --- a/python_app/docs/architecture_overview.md +++ /dev/null @@ -1,46 +0,0 @@ -# Архитектурный обзор Python-части - -## 1. Верхнеуровневая схема -Python-часть разделена на следующие пакеты: -- `gui`: desktop UI на PyQt6 + pyqtgraph. -- `models`: dataclass-модели runtime-конфига и датасетов. -- `orchestration`: управление runtime-конфигом, процессами и SHM-ридерами. -- `storage`: persistent storage для calibration/reference и runtime snapshots. -- `hardware_full`: Python-обертки над LibreVNA и switch drivers. -- `workflows`: сценарии калибровки/референса/последовательного захвата. -- `scripts`: эксплуатационные и отладочные скрипты. - -## 2. Ключевые архитектурные решения -- GUI оставлен единой точкой входа (`AppWindow`), но тяжелая логика вынесена в `sections`, `plotting` и `runtime` helpers. -- Конфиг разделен на schema/codec/validation: - - `run_config_schema.py` - - `run_config_codec.py` - - `run_config_validation.py` - - `run_config_model.py` как фасадный модуль. -- Storage разделен на подпакет `storage/npz/*`: - - `paths.py` - - `serialize.py` - - `snapshot_numpy.py` - - `store.py` -- SHM-декодирование разделено на `orchestration/shm/*`: - - `binary_cursor.py` - - `decoder.py` - - `ring_reader.py` - - `shm_reader.py` как фасад. -- LibreVNA service переведен на backend-подход (`librevna_backends.py`) с orchestration-оберткой `librevna_service.py`. - -## 3. Инварианты -- Формат `run_config.json` сохраняется совместимым с C++ pipeline. -- Форматы snapshot (`binary`, `numpy-directory-v1`) сохраняются. -- Сигнатуры ключевых пользовательских входов (`gui/main.py`, scripts) сохраняются. -- Данные в `python_app/data` и runtime-файлы в `python_app/runtime` не реорганизуются автоматически. - -## 4. Основные зависимости -- GUI: `PyQt6`, `pyqtgraph`, `numpy`. -- Hardware: `libusb1` через драйвер в `hardware_full/librevna_driver`. -- Storage/processing tools: `numpy`, stdlib JSON/Path/mmap/subprocess. - -## 5. Точки расширения -- Новые processing modes: через live-config + GUI section + C++ processor. -- Новые storage backends: реализовать `StoreApi`. -- Новые hardware adapters: добавить backend/driver и подключить в service/factory. diff --git a/python_app/docs/gui_architecture.md b/python_app/docs/gui_architecture.md deleted file mode 100644 index b158c76..0000000 --- a/python_app/docs/gui_architecture.md +++ /dev/null @@ -1,43 +0,0 @@ -# Архитектура GUI - -## 1. Состав -- `gui/main.py`: entrypoint приложения. -- `gui/app_window.py`: компоновка миксинов и базовой инфраструктуры. -- `gui/controllers/*`: orchestration-логика. -- `gui/controllers/sections/*`: построение UI-групп (Pipeline, Processing, Radar, Switches и т.д.). -- `gui/plotting/*`: чистая логика B-scan расчетов и cache helpers. -- `gui/runtime/*`: runtime history/constraint helpers. -- `gui/preprocess_dialog.py`: окно последовательного захвата calibration/reference. - -## 2. Mixin роли -- `AppWindowUiMixin`: сборка виджетов и layout. -- `AppWindowConfigMixin`: сборка `RunConfigModel`, live settings и лимиты устройства. -- `AppWindowPipelineMixin`: старт/стоп процессов, polling ring readers, history lifecycle. -- `AppWindowPlotMixin`: выбор режима отрисовки и рендер. -- `AppWindowPreprocessMixin`: workflow для calibration/reference capture. -- `AppWindowSnapshotMixin`: сохранение runtime snapshot. - -## 3. Состояния AppWindow -Ключевые поля состояния: -- readers: `_raw_reader`, `_pre_reader`, `_result_reader` -- history: `_raw_history`, `_pre_history`, `_result_history` -- bscan cache: `_bscan_history_by_combo`, `_bscan_depth_axis_by_combo`, `_bscan_render_signature` -- single capture flags: `_single_capture_*` -- radar limits: `_radar_limits` - -## 4. UI composition -Секции строятся через builders в `gui/controllers/sections`: -- `build_pipeline_group` -- `build_hardware_actions_group` -- `build_data_actions_group` -- `build_preprocess_summary_group` -- `build_processing_group` -- `build_radar_group` -- `build_switch_group` - -## 5. Рекомендации по расширению GUI -- Не добавлять тяжелую математику в mixin-файлы. -- Для новых параметров processing mode: - - добавить поле в live config, - - добавить controls в `processing_section`, - - добавить обработку в plotting/runtime helpers. diff --git a/python_app/docs/hardware_layer.md b/python_app/docs/hardware_layer.md deleted file mode 100644 index 21b0244..0000000 --- a/python_app/docs/hardware_layer.md +++ /dev/null @@ -1,35 +0,0 @@ -# Hardware layer - -## 1. LibreVNA -- `hardware_full/librevna_service.py`: orchestration facade. -- `hardware_full/librevna_backends.py`: - - `NativeLibreVnaBackend` - - `MockLibreVnaBackend` -- `hardware_full/librevna_driver/*`: низкоуровневый USB/protocol stack. - -### Native flow -1. `LibreVnaService.open()` -2. `configure(RadarSweepModel)` -3. `acquire_s21()` -4. `close()` - -### Device limits -- `read_device_limits()` возвращает min/max frequency, IFBW, power и max points. -- GUI применяет лимиты для clamp/labels и live B-scan frequency bounds. - -## 2. Switches -- `switch_service.py`: выбор mock/native драйвера по конфигурации. -- `switch_drivers/*`: - - `MockSwitchDriver` - - `H7992Driver` - - `HMC349ADriver` - - `gpio_uapi.py` (Linux GPIO v2 wrapper) - -### Инварианты -- Позиции валидируются на уровне драйвера. -- Драйверы требуют `open()` перед `switch_to()`. -- Для native GPIO ошибки ОС конвертируются в Python `RuntimeError` с контекстом. - -## 3. Расширение hardware слоя -- Новый радар: добавить backend с тем же контрактом, подключить в service. -- Новый switch: реализовать `SwitchDriverProtocol` и добавить ветку в `SwitchService._build_driver()`. diff --git a/python_app/docs/module_reference.md b/python_app/docs/module_reference.md deleted file mode 100644 index dd54483..0000000 --- a/python_app/docs/module_reference.md +++ /dev/null @@ -1,50 +0,0 @@ -# Module reference - -## `python_app.gui` -- `main.py`: GUI entrypoint. -- `app_window.py`: composition root. -- `controllers/app_window_*_mixin.py`: функциональные части окна. -- `controllers/sections/*`: построители UI-секций. -- `plotting/bscan_math.py`: математика B-scan. -- `plotting/bscan_history.py`: cache/signature для B-scan. -- `runtime/history.py`: сигнатуры run/history merge. -- `runtime/constraints.py`: режимные ограничения. - -## `python_app.models` -- `dataset_model.py`: dataclass-модели sweep/result данных. -- `run_config_schema.py`: dataclass-schema run config. -- `run_config_codec.py`: encode/decode/load. -- `run_config_validation.py`: payload checks/parsing helpers. -- `run_config_model.py`: фасадный экспорт. - -## `python_app.orchestration` -- `config_writer.py`: runtime config + bundle files. -- `live_processing_config.py`: live processing JSON writer. -- `process_supervisor.py`: управление процессами C++ pipeline. -- `shm/*`: shared-memory чтение и декодирование. -- `shm_reader.py`: фасадный экспорт. - -## `python_app.storage` -- `store_api.py`: абстракция хранилища. -- `npz/store.py`: `NpzStore`. -- `npz/serialize.py`: binary serializers. -- `npz/snapshot_numpy.py`: snapshot selection/writers. -- `npz/paths.py`: naming/path helpers. -- `npz_store.py`: фасадный экспорт. - -## `python_app.hardware_full` -- `librevna_service.py`: high-level service. -- `librevna_backends.py`: native/mock adapters. -- `librevna_driver/*`: USB/protocol/session/controller stack. -- `switch_service.py`: switch facade. -- `switch_drivers/*`: native/mock switch implementations. - -## `python_app.workflows` -- `sequential_capture_workflow.py`: последовательный capture session. -- `calibration_workflow.py`, `reference_workflow.py`: helper workflows. - -## `python_app.scripts` -- `check_snapshot_numpy.py`: snapshot validator + plots. -- `manual_smoke_run.py`: локальный smoke сценарий. -- `hardware_raw_orchestrator_test.py`: raw ring visualization utility. -- `vna_only_raw_test.py`: direct VNA check utility. diff --git a/python_app/docs/operations_runbook.md b/python_app/docs/operations_runbook.md deleted file mode 100644 index 8d1e21a..0000000 --- a/python_app/docs/operations_runbook.md +++ /dev/null @@ -1,49 +0,0 @@ -# Operations runbook - -## 1. Подготовка -- Собрать C++ binaries (`make`). -- Проверить `run_config.json` и доступность hardware. -- Для native режима убедиться в правах на USB/GPIO. - -## 2. Запуск GUI -```bash -python3 -m python_app.gui.main -``` - -## 3. Базовый сценарий live run -1. Проверить Radar/Switches config. -2. Выбрать calibration/reference sets. -3. Нажать `Start`. -4. Проверить обновление history и графика. -5. Нажать `Stop`. - -## 4. Сохранение snapshot -1. Указать `Path`, `Name`, `Last N`. -2. Нажать `Save Numpy Snapshot`. -3. Проверить `manifest.json` и каталоги `raw/preprocessed/results`. - -## 5. Проверка snapshot -```bash -python3 python_app/scripts/check_snapshot_numpy.py -``` - -## 6. Конвертация snapshot для vna_system -```bash -python3 python_app/scripts/convert_snapshot_to_vna_history.py \ - \ - -o \ - --input 0 \ - --output-index 0 -``` - -Полученный JSON содержит `sweep_history` и загружается в `vna_system` через кнопку загрузки истории у B-scan графика. - -## 7. Типовые проблемы -- Пустой график: проверить processing mode и наличие trace payloads. -- Нет запуска в B-scan: проверить ограничение combo для native switches. -- Ошибки лимитов радара: проверить соединение с устройством и serial. -- Процессы не стартуют: смотреть crash reports в GUI log. - -## 8. Безопасная остановка -- Использовать `Stop` в UI. -- При закрытии окна вызывается стоп всех процессов и закрытие dialogs/readers. diff --git a/python_app/docs/preprocessing_and_capture_workflow.md b/python_app/docs/preprocessing_and_capture_workflow.md deleted file mode 100644 index dbf1805..0000000 --- a/python_app/docs/preprocessing_and_capture_workflow.md +++ /dev/null @@ -1,32 +0,0 @@ -# Preprocessing and capture workflow - -## 1. Назначение -До запуска live-pipeline необходимо выбрать calibration/reference set, покрывающие целевые run combos. - -## 2. Процесс в GUI -1. Открыть `Preprocessing Panel`. -2. Выбрать или создать имя набора (`set_name`). -3. Запустить `Start Calibration Sequence` или `Start Reference Sequence`. -4. Для каждого combo нажимать `Capture Current Combo`. -5. По завершении данные сохраняются в `NpzStore`. - -## 3. Internal flow -- `AppWindowPreprocessMixin` создает `SequentialCaptureSession`. -- Session управляет: - - `LibreVnaService` - - input/output `SwitchService` - - последовательным обходом full combo matrix. -- После полной матрицы вызывается `finalize(store)`. - -## 4. Связь с live run -`_start_run()` проверяет: -- выбраны ли calibration/reference sets, -- покрывают ли выбранные sets все run combos (`has_combo_coverage`), -- доступны ли bundle-файлы для preprocessor. - -## 5. Диагностика -Типовые ошибки: -- set already exists, -- incomplete capture sequence, -- hardware not available, -- mismatch requested combos vs stored combos. diff --git a/python_app/docs/runtime_data_flow.md b/python_app/docs/runtime_data_flow.md deleted file mode 100644 index 2a0328b..0000000 --- a/python_app/docs/runtime_data_flow.md +++ /dev/null @@ -1,39 +0,0 @@ -# Потоки данных runtime - -## 1. Непрерывный acquisition pipeline -1. `AppWindowPipelineMixin._start_run()` строит `RunConfigModel` из UI. -2. `ConfigWriter` пишет runtime config и bundles calibration/reference. -3. `ProcessSupervisor` запускает: - - `data_processor` - - `data_preprocessor` - - `sweep_orchestrator` -4. GUI открывает `ShmRingReader` для: - - raw tap - - preprocessed tap - - results -5. Таймер GUI (`QTimer`) вызывает `_poll_rings()`: - - чтение raw/preprocessed/results - - запись в history deques - - рендер либо trace-lines, либо B-scan heatmap. - -## 2. B-scan path -- Источник: `preprocessed` history ring. -- Кэш-сигнатура: `build_bscan_signature()` учитывает live-параметры и tail history. -- Ребилд: `rebuild_bscan_history_from_preprocessed()` + `compute_bscan_profile()`. -- Рендер: `ImageItem` с LUT и уровнями (`bscan_lookup_table`, `bscan_levels`). - -## 3. Single capture path -- `_start_single_capture()` запускает pipeline в non-continuous режиме. -- GUI ждет collection после стартового timestamp и валидного trace payload. -- По завершении выполняется `_stop_run()`. - -## 4. Snapshot path -- `AppWindowSnapshotMixin._save_snapshot()` предварительно дренирует ring buffers. -- `NpzStore.save_runtime_snapshot_numpy()`: - - выбирает aligned histories (`select_aligned_histories`), - - пишет `raw/preprocessed/results` + `manifest.json`. - -## 5. Failure handling -- Process crashes читаются через `ProcessSupervisor.collect_crash_reports()`. -- Reader/parsing errors логируются в GUI runtime log. -- Hardware/validation errors показываются через `_show_error()` и лог. diff --git a/python_app/docs/shm_protocol_and_readers.md b/python_app/docs/shm_protocol_and_readers.md deleted file mode 100644 index 9d8566b..0000000 --- a/python_app/docs/shm_protocol_and_readers.md +++ /dev/null @@ -1,28 +0,0 @@ -# SHM protocol and readers - -## 1. Ring format -Python reader ожидает ring header: -- magic: `RDRRING2` -- version: `1` -- fields capacity/slot_size/write_seq/read_seq. - -Ring files находятся в `/dev/shm/`. - -## 2. Reader components -- `orchestration/shm/binary_cursor.py`: primitive cursor API. -- `orchestration/shm/decoder.py`: decode trace/result payloads. -- `orchestration/shm/ring_reader.py`: mmap-based ring consumption. -- `orchestration/shm_reader.py`: фасад для импортов. - -## 3. Consumption semantics -- `pop_payload()` возвращает `None`, если новых payload нет. -- При sequence mismatch reader выполняет fast-forward read pointer. -- `drop_all()` принудительно дропает unread payloads. - -## 4. Error cases -- missing ring file -> `FileNotFoundError` -- magic/version mismatch -> `RuntimeError` -- bad payload magic/kind -> `ValueError` - -## 5. Integration -`AppWindowPipelineMixin` использует по одному reader на raw/preprocessed/results ring. diff --git a/python_app/docs/storage_formats.md b/python_app/docs/storage_formats.md deleted file mode 100644 index d73c6f7..0000000 --- a/python_app/docs/storage_formats.md +++ /dev/null @@ -1,41 +0,0 @@ -# Storage formats - -## 1. Calibration/reference sets -Хранятся в `python_app/data/{calibration|reference}/{radar_key}`: -- `.npz` -- `.json` - -`radar_key` формируется из sweep и power параметров (`radar_key_from_config`). - -## 2. Binary collection serialization -`storage/npz/serialize.py`: -- `RAW_MAGIC = 0x31574152` -- `PREPROC_MAGIC = 0x31525050` -- `RESULT_MAGIC = 0x314C5352` - -Поддерживается сериализация: -- trace collections (`serialize_trace_collection`) -- result collections (`serialize_result_collection`) - -## 3. Runtime numpy snapshot (`numpy-directory-v1`) -Структура: -- `manifest.json` -- `raw//...` -- `preprocessed//...` -- `results//block_*/...` - -Коллекция: `0003_id22_ns11999302655222`. - -## 4. History selection -Перед записью snapshot вызывается `select_aligned_histories()`: -- primary mode: `aligned_by_collection_and_occurrence` -- fallback: `independent_tail` - -Это предотвращает рассинхронизацию raw/preprocessed/results при сохранении. - -## 5. Обратное чтение -Для валидации snapshot используется `scripts/check_snapshot_numpy.py`: -- проверка shape/finite, -- сравнение коллекций, -- построение графиков сравнения, -- plot всех switch states для одной коллекции. diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index 23640d7..b48eb3b 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -7,10 +7,14 @@ 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 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 @@ -21,10 +25,12 @@ from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapsh 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.models.run_config_model import RunConfigModel 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.preprocess_assets import PREPROCESS_ASSET_KEYS, preprocess_asset_model +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 @@ -46,8 +52,9 @@ class AppWindow( """Initialize all app subsystems in deterministic order.""" super().__init__() - self._init_paths_and_defaults(project_root) + 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() @@ -56,19 +63,51 @@ class AppWindow( self._init_polling_timer() self._bootstrap_ui_runtime() - def _init_paths_and_defaults(self, project_root: Path) -> None: - """Initialize project paths and baseline run configuration.""" + def _init_paths(self, project_root: Path) -> None: + """Initialize static project paths and startup log queue.""" self._project_root = project_root - self._defaults_config_path = project_root / "run_config.json" - self._defaults_config = RunConfigModel.load_from_path(self._defaults_config_path) + 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") + + 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.""" @@ -79,9 +118,10 @@ class AppWindow( 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._selected_preprocess_sets = { key: str(preprocess_asset_model(self._defaults_config, key).set_name) - for key in PREPROCESS_ASSET_KEYS + for key in VISIBLE_PREPROCESS_ASSET_KEYS } def _init_capture_state(self) -> None: @@ -138,6 +178,8 @@ class AppWindow( """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.""" @@ -148,11 +190,71 @@ class AppWindow( 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._write_live_processing_config() self._timer.start() + def _resolve_startup_profile_path(self) -> Path: + """Resolve active profile path from session-state or root fallback 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 _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._radar_mode.currentText() == "native": @@ -160,9 +262,129 @@ class AppWindow( return self._apply_radar_limits_to_ui(None) - def _log(self, text: str) -> None: - """Append a line to the runtime log panel.""" - self._log_box.appendPlainText(text) + @staticmethod + def _escape_log_text(text: str) -> str: + """Escape log text for insertion into rich-text log widget.""" + return html.escape(text).replace("\n", "
") + + @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": ("#7fb1ff", "#dce8f8", "#8ba2be"), + "WARN": ("#f4bf4f", "#f4dca0", "#9f8b55"), + "ERROR": ("#ff5f6d", "#ffd1d5", "#b5878c"), + } + accent_color, message_color, detail_color = palette.get(level_upper, palette["INFO"]) + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + header_html = ( + f"{html.escape(level_upper)}" + f" {html.escape(timestamp)}" + f" {self._escape_log_text(text)}" + ) + + body_parts = [header_html] + if details: + body_parts.append( + "
{html.escape(details)}
" + ) + + entry_html = "
" + "".join(body_parts) + "
" + 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: @@ -180,10 +402,26 @@ class AppWindow( return max(0, int(raw_value)) return 0 - def _show_error(self, message: str) -> None: - """Log and present an error in a modal dialog.""" - self._log(f"ERROR: {message}") - QMessageBox.critical(self, "Error", message) + 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) + 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") + 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.""" diff --git a/python_app/gui/controllers/app_window_config_mixin.py b/python_app/gui/controllers/app_window_config_mixin.py index 989ff48..012cd5c 100644 --- a/python_app/gui/controllers/app_window_config_mixin.py +++ b/python_app/gui/controllers/app_window_config_mixin.py @@ -2,12 +2,35 @@ from __future__ import annotations +from collections import deque +from contextlib import ExitStack +import json +from pathlib import Path + +from PyQt6.QtCore import QSignalBlocker +from PyQt6.QtWidgets import QFileDialog + from python_app.hardware_full.librevna_service import LibreVnaService +from python_app.models.gui_profile_model import ( + GuiBscanStateModel, + GuiDataActionsStateModel, + GuiGprStateModel, + GuiPassThroughStateModel, + GuiPreprocessDialogStateModel, + GuiProcessingStateModel, + GuiProfileModel, + GuiStateModel, + GuiSwitchStateModel, +) from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel from python_app.models.run_config_validation import validate_gpr_model from python_app.orchestration.config_writer import parse_combos_from_text from python_app.orchestration.live_processing_config import ProcessingLiveConfig -from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, preprocess_asset_model +from python_app.orchestration.preprocess_assets import ( + PREPROCESS_ASSET_KEYS, + VISIBLE_PREPROCESS_ASSET_KEYS, + preprocess_asset_model, +) from python_app.storage.npz_store import radar_key_from_config @@ -66,63 +89,435 @@ class AppWindowConfigMixin: ) return entries + @staticmethod + def _format_combos_text_from_config(config: RunConfigModel) -> str: + """Render configured combos for UI text editor, keeping full matrix as empty.""" + combos = list(config.combos) + full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions) + if len(combos) == len(full_combos) and all( + int(left.input) == int(right.input) and int(left.output) == int(right.output) + for left, right in zip(combos, full_combos, strict=True) + ): + return "" + return ",".join(f"{int(combo.input)}:{int(combo.output)}" for combo in combos) + + @staticmethod + def _default_gpr_input_positions_from_config(config: RunConfigModel) -> str: + """Build default live GPR input-position selection from stable config.""" + geometry_values = {int(entry.input_pos) for entry in config.gpr.rx_geometry} + combo_values = {int(combo.input) for combo in config.combos} + values = sorted(geometry_values & combo_values) or sorted(geometry_values) + return ",".join(str(value) for value in values) + + @staticmethod + def _default_gpr_output_positions_from_config(config: RunConfigModel) -> str: + """Build default live GPR output-position selection from stable config.""" + geometry_values = {int(entry.output_pos) for entry in config.gpr.tx_geometry} + combo_values = {int(combo.output) for combo in config.combos} + values = sorted(geometry_values & combo_values) or sorted(geometry_values) + return ",".join(str(value) for value in values) + + @staticmethod + def _history_limit_for_config(config: RunConfigModel) -> int: + """Return unified GUI history limit derived from config ring capacities.""" + return max( + 1, + min( + int(config.rings.raw_tap.capacity), + int(config.rings.preprocessed_tap.capacity), + int(config.rings.results.capacity), + ), + ) + + def _default_gui_state_for_config(self, config: RunConfigModel) -> GuiStateModel: + """Build fallback GUI-only defaults for a stable run config.""" + default_combo = config.combos[0] if config.combos else ComboModel(input=0, output=0) + default_mode = "single" if len(config.combos) == 1 else "text" + return GuiStateModel( + switches=GuiSwitchStateModel( + combo_mode=default_mode, + combos_text=self._format_combos_text_from_config(config), + single_input=str(int(default_combo.input)), + single_output=str(int(default_combo.output)), + ), + processing=GuiProcessingStateModel( + selected_mode="pass_through", + pass_through=GuiPassThroughStateModel( + show_magnitude=True, + show_phase=True, + fixed_y_enabled=False, + y_min_db=-100.0, + y_max_db=0.0, + ), + bscan=GuiBscanStateModel( + axis="abs", + cut_m=0.824, + max_depth_m=1.0, + gain=1.0, + start_freq_mhz=100.0, + stop_freq_mhz=8800.0, + ), + gpr=GuiGprStateModel( + input_positions=self._default_gpr_input_positions_from_config(config), + output_positions=self._default_gpr_output_positions_from_config(config), + min_depth_m=2.0, + max_depth_m=14.0, + comp_power=0.2, + start_freq_mhz=3000.0, + stop_freq_mhz=6000.0, + background_subtract_enabled=True, + background_mean_count=10, + ), + ), + data_actions=GuiDataActionsStateModel( + save_count=10, + save_path=str(self._project_root / "python_app/data/snapshots"), + save_name="snapshot_manual", + ), + preprocess_dialog=GuiPreprocessDialogStateModel(set_name="set_001"), + ) + + def _current_preprocess_set_name(self) -> str: + """Return current preprocess dialog set name, even when dialog is still closed.""" + if self._preprocess_dialog is not None: + self._preprocess_set_name = self._preprocess_dialog.set_name() + return self._preprocess_set_name + + def _build_gui_state(self) -> GuiStateModel: + """Build GUI-only persistent state from current widget values.""" + return GuiStateModel( + switches=GuiSwitchStateModel( + combo_mode="single" if self._single_combo_select_button.isChecked() else "text", + combos_text=self._combos_text.text().strip(), + single_input=self._single_combo_input.text().strip(), + single_output=self._single_combo_output.text().strip(), + ), + processing=GuiProcessingStateModel( + selected_mode=self._processing_mode.currentText(), + pass_through=GuiPassThroughStateModel( + show_magnitude=bool(self._show_magnitude_checkbox.isChecked()), + show_phase=bool(self._show_phase_checkbox.isChecked()), + fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()), + y_min_db=float(self._pass_through_y_min_db.value()), + y_max_db=float(self._pass_through_y_max_db.value()), + ), + bscan=GuiBscanStateModel( + axis=self._bscan_axis.currentText(), + cut_m=float(self._bscan_cut_m.value()), + max_depth_m=float(self._bscan_max_depth_m.value()), + gain=float(self._bscan_gain.value()), + start_freq_mhz=float(self._bscan_start_freq_mhz.value()), + stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()), + ), + gpr=GuiGprStateModel( + input_positions=self._gpr_input_positions_input.text().strip(), + output_positions=self._gpr_output_positions_input.text().strip(), + min_depth_m=float(self._gpr_min_depth_m.value()), + max_depth_m=float(self._gpr_max_depth_m.value()), + comp_power=float(self._gpr_comp_power.value()), + start_freq_mhz=float(self._gpr_start_freq_mhz.value()), + stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()), + background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()), + background_mean_count=int(self._gpr_background_mean_count.value()), + ), + ), + data_actions=GuiDataActionsStateModel( + save_count=int(self._save_count.value()), + save_path=self._save_path_input.text().strip(), + save_name=self._save_name_input.text().strip(), + ), + preprocess_dialog=GuiPreprocessDialogStateModel( + set_name=self._current_preprocess_set_name(), + ), + ) + + def _build_gui_profile(self) -> GuiProfileModel: + """Build full GUI config profile from current window state.""" + return GuiProfileModel( + run_config=self._build_config(), + gui=self._build_gui_state(), + ) + + def _write_gui_profile_to_path(self, output_path: Path, *, allow_overwrite: bool = True) -> GuiProfileModel: + """Serialize current full GUI profile to `output_path` and return the persisted model.""" + if not allow_overwrite and output_path.exists(): + raise FileExistsError(f"Config profile output already exists: {output_path}") + + profile = self._build_gui_profile() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(profile.to_dict(), indent=2), encoding="utf-8") + return profile + + def _set_combo_selection_mode(self, mode: str) -> None: + """Highlight current combo mode and enable only the relevant editors.""" + text_selected = mode != "single" + self._run_combos_select_button.setChecked(text_selected) + self._single_combo_select_button.setChecked(not text_selected) + self._combos_text.setEnabled(text_selected) + self._single_combo_output.setEnabled(not text_selected) + self._single_combo_input.setEnabled(not text_selected) + + def _sync_pass_through_y_controls(self) -> None: + """Enable Y-range editors only when fixed Y mode is active.""" + enabled = bool(self._pass_through_fixed_y_enabled.isChecked()) + self._pass_through_y_min_db.setEnabled(enabled) + self._pass_through_y_max_db.setEnabled(enabled) + + def _apply_history_limit_from_config(self, config: RunConfigModel) -> None: + """Resize in-memory history buffers to match the loaded config.""" + history_limit = self._history_limit_for_config(config) + self._raw_history = deque(self._raw_history, maxlen=history_limit) + self._pre_history = deque(self._pre_history, maxlen=history_limit) + self._result_history = deque(self._result_history, maxlen=history_limit) + self._bscan_history_limit = history_limit + self._clear_bscan_plot_history() + def _save_current_config(self) -> None: - """Persist currently selected GUI settings into root run_config.json.""" + """Persist current full GUI profile to a user-selected JSON file.""" try: - config = self._build_config() - self._config_writer.write(config, self._defaults_config_path) - self._defaults_config = config.clone() - self._log(f"Current config saved: {self._defaults_config_path}") + suggested_path = str(self._active_profile_path) + selected_path, _selected_filter = QFileDialog.getSaveFileName( + self, + "Save Config Profile", + suggested_path, + "JSON Files (*.json);;All Files (*)", + ) + if not selected_path: + return + + output_path = self._normalize_profile_path(Path(selected_path)) + if not output_path.suffix: + output_path = output_path.with_suffix(".json") + + profile = self._write_gui_profile_to_path(output_path) + + self._defaults_config = profile.run_config.clone() + self._gui_defaults = profile.gui + self._remember_active_profile_path(output_path) + self._log( + f"Config profile saved: path={output_path}, " + f"combos={len(profile.run_config.combos)}, " + f"sweep={profile.run_config.radar.sweep.start_hz:g}.." + f"{profile.run_config.radar.sweep.stop_hz:g} Hz, " + f"points={profile.run_config.radar.sweep.points}, " + f"ifbw={profile.run_config.radar.sweep.if_bandwidth_hz:g} Hz, " + f"power={profile.run_config.radar.sweep.power_dbm:g} dBm, " + f"processing_mode={profile.gui.processing.selected_mode}" + ) except Exception as exc: # noqa: BLE001 - self._show_error(f"Failed to save current config: {exc}") + self._show_exception("Failed to save config profile", exc) + + def _load_config_from_dialog(self) -> None: + """Load full GUI profile from a user-selected JSON file.""" + if self._capture_session is not None: + self._show_error( + "Cannot load config during active capture sequence", + details=self._capture_state_details(), + ) + return + if self._supervisor.is_running() or self._supervisor.is_processor_running(): + self._show_error( + "Stop all pipeline processes before loading a config profile", + details=self._process_state_details(), + ) + return + + selected_path, _selected_filter = QFileDialog.getOpenFileName( + self, + "Load Config Profile", + str(self._active_profile_path), + "JSON Files (*.json);;All Files (*)", + ) + if not selected_path: + return + + try: + self._load_config_profile(Path(selected_path)) + except Exception as exc: # noqa: BLE001 + self._show_exception("Failed to load config profile", exc) + + def _load_config_profile(self, profile_path: Path) -> None: + """Load config profile from `profile_path` and atomically apply it to the UI.""" + normalized_path = self._normalize_profile_path(profile_path) + profile = GuiProfileModel.load_from_path(normalized_path) + self._apply_loaded_profile(profile, normalized_path) + profile_kind = "legacy run config" if profile.gui is None else "full GUI profile" + self._log( + f"Config profile loaded: path={normalized_path}, " + f"kind={profile_kind}, " + f"combos={len(self._defaults_config.combos)}, " + f"processing_mode={self._processing_mode.currentText()}" + ) + + def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None: + """Apply already parsed profile to GUI state without restarting the pipeline.""" + config = profile.run_config.clone() + gui_state = profile.gui if profile.gui is not None else self._default_gui_state_for_config(config) + selected_preprocess_sets = { + key: str(preprocess_asset_model(config, key).set_name) + for key in VISIBLE_PREPROCESS_ASSET_KEYS + } + + radio_widgets = ( + self._serial_input, + self._radar_mode, + self._start_hz_input, + self._stop_hz_input, + self._points_input, + self._ifbw_input, + self._power_input, + self._settling_ms, + self._combos_text, + self._single_combo_output, + self._single_combo_input, + self._run_combos_select_button, + self._single_combo_select_button, + self._processing_mode, + self._show_magnitude_checkbox, + self._show_phase_checkbox, + self._pass_through_fixed_y_enabled, + self._pass_through_y_min_db, + self._pass_through_y_max_db, + self._bscan_axis, + self._bscan_cut_m, + self._bscan_max_depth_m, + self._bscan_gain, + self._bscan_start_freq_mhz, + self._bscan_stop_freq_mhz, + self._gpr_config_mode, + self._gpr_relative_permittivity, + self._gpr_tx_geometry_input, + self._gpr_rx_geometry_input, + self._gpr_input_positions_input, + self._gpr_output_positions_input, + self._gpr_min_depth_m, + self._gpr_max_depth_m, + self._gpr_comp_power, + self._gpr_start_freq_mhz, + self._gpr_stop_freq_mhz, + self._gpr_background_subtract_enabled, + self._gpr_background_mean_count, + self._save_count, + self._save_path_input, + self._save_name_input, + ) + + with ExitStack() as blockers: + for widget in radio_widgets: + blockers.enter_context(QSignalBlocker(widget)) + + self._serial_input.setText(str(config.radar.serial)) + self._set_combo_current_text(self._radar_mode, str(config.radar.driver_mode)) + self._start_hz_input.setText(f"{config.radar.sweep.start_hz:g}") + self._stop_hz_input.setText(f"{config.radar.sweep.stop_hz:g}") + self._points_input.setText(str(int(config.radar.sweep.points))) + self._ifbw_input.setText(f"{config.radar.sweep.if_bandwidth_hz:g}") + self._power_input.setText(f"{config.radar.sweep.power_dbm:g}") + self._settling_ms.setText(str(int(config.runtime.settling_ms))) + + self._combos_text.setText(str(gui_state.switches.combos_text)) + self._single_combo_output.setText(str(gui_state.switches.single_output)) + self._single_combo_input.setText(str(gui_state.switches.single_input)) + self._set_combo_selection_mode(gui_state.switches.combo_mode) + + self._set_combo_current_text(self._processing_mode, gui_state.processing.selected_mode) + self._show_magnitude_checkbox.setChecked(bool(gui_state.processing.pass_through.show_magnitude)) + self._show_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.show_phase)) + self._pass_through_fixed_y_enabled.setChecked(bool(gui_state.processing.pass_through.fixed_y_enabled)) + self._pass_through_y_min_db.setValue(float(gui_state.processing.pass_through.y_min_db)) + self._pass_through_y_max_db.setValue(float(gui_state.processing.pass_through.y_max_db)) + + self._set_combo_current_text(self._bscan_axis, gui_state.processing.bscan.axis) + self._bscan_cut_m.setValue(float(gui_state.processing.bscan.cut_m)) + self._bscan_max_depth_m.setValue(float(gui_state.processing.bscan.max_depth_m)) + self._bscan_gain.setValue(float(gui_state.processing.bscan.gain)) + self._bscan_start_freq_mhz.setValue(float(gui_state.processing.bscan.start_freq_mhz)) + self._bscan_stop_freq_mhz.setValue(float(gui_state.processing.bscan.stop_freq_mhz)) + + self._set_combo_current_text(self._gpr_config_mode, str(config.gpr.mode)) + self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity)) + self._gpr_tx_geometry_input.setPlainText( + "\n".join( + f"{int(entry.output_pos)} {float(entry.x_m):g}" + for entry in config.gpr.tx_geometry + ) + ) + self._gpr_rx_geometry_input.setPlainText( + "\n".join( + f"{int(entry.input_pos)} {float(entry.x_m):g}" + for entry in config.gpr.rx_geometry + ) + ) + self._gpr_input_positions_input.setText(str(gui_state.processing.gpr.input_positions)) + self._gpr_output_positions_input.setText(str(gui_state.processing.gpr.output_positions)) + self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m)) + self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m)) + self._gpr_comp_power.setValue(float(gui_state.processing.gpr.comp_power)) + self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz)) + self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz)) + self._gpr_background_subtract_enabled.setChecked( + bool(gui_state.processing.gpr.background_subtract_enabled) + ) + self._gpr_background_mean_count.setValue(int(gui_state.processing.gpr.background_mean_count)) + + self._save_count.setValue(int(gui_state.data_actions.save_count)) + self._save_path_input.setText(str(gui_state.data_actions.save_path)) + self._save_name_input.setText(str(gui_state.data_actions.save_name)) + + self._defaults_config = config + self._gui_defaults = gui_state + self._selected_preprocess_sets = selected_preprocess_sets + self._preprocess_set_name = str(gui_state.preprocess_dialog.set_name) + self._apply_history_limit_from_config(config) + self._gpr_geometry_signature = None + self._gpr_selected_geometry = None + self._sync_pass_through_y_controls() + self._refresh_preprocess_summary_labels() + + if self._preprocess_dialog is not None: + with ExitStack() as dialog_blockers: + dialog_blockers.enter_context(QSignalBlocker(self._preprocess_dialog._set_name_input)) + for combo in self._preprocess_dialog._set_combos.values(): + dialog_blockers.enter_context(QSignalBlocker(combo)) + self._preprocess_dialog.set_set_name(self._preprocess_set_name) + self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False) + + self._apply_initial_radar_limits() + self._on_processing_mode_changed(gui_state.processing.selected_mode) + self._update_history_indicator() + self._remember_active_profile_path(profile_path) def _build_config(self) -> RunConfigModel: """Build `RunConfigModel` from current GUI widget values.""" config = self._defaults_config.clone() - config.radar.serial = self._serial_input.text().strip() - config.radar.driver_mode = self._radar_mode.currentText() - config.radar.sweep.start_hz = float(self._start_hz_input.text().strip()) config.radar.sweep.stop_hz = float(self._stop_hz_input.text().strip()) config.radar.sweep.points = int(self._points_input.text().strip()) config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip()) config.radar.sweep.power_dbm = float(self._power_input.text().strip()) - config.input_switch.driver_mode = self._input_mode.currentText() - config.output_switch.driver_mode = self._output_mode.currentText() - - config.input_switch.driver = self._input_driver.currentText() - config.output_switch.driver = self._output_driver.currentText() - config.input_switch.radar_port = 2 - config.output_switch.radar_port = 1 - - config.input_switch.positions = int(self._input_positions.text().strip()) - config.output_switch.positions = int(self._output_positions.text().strip()) - - config.input_switch.gpio_chip = self._input_gpio_chip.text().strip() - config.input_switch.pin_a = int(self._input_pin_a.text().strip()) - config.input_switch.pin_b = int(self._input_pin_b.text().strip()) - config.input_switch.invert_logic = self._input_invert_logic.currentText() == "true" - - config.output_switch.gpio_chip = self._output_gpio_chip.text().strip() - config.output_switch.pin_a = int(self._output_pin_a.text().strip()) - config.output_switch.pin_b = int(self._output_pin_b.text().strip()) - config.output_switch.invert_logic = self._output_invert_logic.currentText() == "true" - config.runtime.settling_ms = int(self._settling_ms.text().strip()) config.runtime.processing_live_config_path = str(self._live_config_writer.path) - combo_text = self._combos_text.text() - config.combos = parse_combos_from_text(combo_text) - config.ensure_combos() + if self._single_combo_select_button.isChecked(): + config.combos = [ + ComboModel( + input=int(self._single_combo_input.text().strip()), + output=int(self._single_combo_output.text().strip()), + ) + ] + else: + combo_text = self._combos_text.text() + config.combos = parse_combos_from_text(combo_text) + config.ensure_combos() if self._switches_are_effectively_static(config): config.combos = [ComboModel(input=0, output=0)] for key in PREPROCESS_ASSET_KEYS: - asset = preprocess_asset_model(config, key) - asset.set_name = self._selected_preprocess_sets[key] - asset.bundle_path = "" + preprocess_asset_model(config, key).bundle_path = "" + for key in VISIBLE_PREPROCESS_ASSET_KEYS: + preprocess_asset_model(config, key).set_name = self._selected_preprocess_sets.get(key, "") config.gpr.mode = self._gpr_config_mode.currentText() config.gpr.relative_permittivity = float(self._gpr_relative_permittivity.value()) config.gpr.tx_geometry = self._parse_gpr_tx_geometry_text(self._gpr_tx_geometry_input.toPlainText()) @@ -154,14 +549,12 @@ class AppWindowConfigMixin: y_max_db = float(self._pass_through_y_max_db.value()) return ProcessingLiveConfig( processor_mode=self._processing_mode.currentText(), - gain_db=float(self._processing_gain_db.value()), - phase_deg=float(self._processing_phase_deg.value()), - pass_through_channel=self._pass_through_channel.currentText(), + pass_through_channel="s21", pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()), pass_through_y_min_db=min(y_min_db, y_max_db), pass_through_y_max_db=max(y_min_db, y_max_db), bscan_axis=self._bscan_axis.currentText(), - bscan_channel=self._bscan_channel.currentText(), + bscan_channel="s21", bscan_cut_m=float(self._bscan_cut_m.value()), bscan_max_depth_m=float(self._bscan_max_depth_m.value()), bscan_gain=float(self._bscan_gain.value()), @@ -207,7 +600,7 @@ class AppWindowConfigMixin: else: self._clear_trace_plots() except Exception as exc: # noqa: BLE001 - self._show_error(f"Failed to update live processing settings: {exc}") + self._show_exception("Failed to update live processing settings", exc) def _on_processing_mode_changed(self, mode: str) -> None: """Switch processing parameter page and refresh corresponding visualization.""" @@ -223,6 +616,33 @@ class AppWindowConfigMixin: self._processing_mode_pages.setFixedHeight(current_page.sizeHint().height()) self._processing_mode_pages.updateGeometry() self._on_processing_live_settings_changed() + if mode == "pass_through": + self._log( + "Processing mode selected: pass_through " + f"(show_magnitude={self._show_magnitude_checkbox.isChecked()}, " + f"show_phase={self._show_phase_checkbox.isChecked()}, " + f"fixed_y={self._pass_through_fixed_y_enabled.isChecked()}, " + f"y_range={self._pass_through_y_min_db.value():g}..{self._pass_through_y_max_db.value():g} dB)" + ) + elif mode == "bscan": + self._log( + "Processing mode selected: bscan " + f"(axis={self._bscan_axis.currentText()}, " + f"cut={self._bscan_cut_m.value():g} m, " + f"max_depth={self._bscan_max_depth_m.value():g} m, " + f"gain={self._bscan_gain.value():g}, " + f"freq={self._bscan_start_freq_mhz.value():g}..{self._bscan_stop_freq_mhz.value():g} MHz)" + ) + elif mode == "gpr": + self._log( + "Processing mode selected: gpr " + f"(inputs={self._gpr_input_positions_input.text().strip() or ''}, " + f"outputs={self._gpr_output_positions_input.text().strip() or ''}, " + f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, " + f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, " + f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, " + f"mean_count={self._gpr_background_mean_count.value()})" + ) def _clear_history_mode_caches(self) -> None: """Drop cached render state for pass-through, B-scan, and GPR views.""" @@ -278,22 +698,20 @@ class AppWindowConfigMixin: try: limits = radar_service.read_device_limits() except Exception as exc: # noqa: BLE001 - self._fallback_to_mock_mode(f"Failed to query LibreVNA limits: {exc}") + self._log_exception("Failed to query LibreVNA limits; using UI fallback", exc, level="WARN") + self._apply_radar_limits_to_ui(None) return False return self._apply_radar_limits_to_ui(limits) def _fallback_to_mock_mode(self, reason: str) -> None: - """Fallback to mock mode when native limits cannot be queried.""" - self._log(f"{reason}; switched radar mode to mock") - if self._radar_mode.currentText() != "mock": - was_blocked = self._radar_mode.blockSignals(True) - self._radar_mode.setCurrentText("mock") - self._radar_mode.blockSignals(was_blocked) + """Handle unavailable native limits without mutating JSON-backed mode.""" + self._log_warning(reason) self._apply_radar_limits_to_ui(None) def _apply_radar_limits_to_ui(self, limits: dict[str, float | int] | None) -> bool: """Apply optional radar limits and clamp dependent GUI fields.""" + previous_limits = dict(self._radar_limits) if self._radar_limits is not None else None if limits is None: self._radar_limits = None self._radar_start_label.setText("Start Hz") @@ -356,6 +774,38 @@ class AppWindowConfigMixin: or prev_power != self._power_input.text().strip() ) + applied_limits_changed = previous_limits != limits + if applied_limits_changed: + self._log( + "Applied radar device limits: " + f"freq={min_freq_hz:g}..{max_freq_hz:g} Hz, " + f"points=1..{max_points}, " + f"ifbw={min_ifbw_hz:g}..{max_ifbw_hz:g} Hz, " + f"power={min_power_dbm:g}..{max_power_dbm:g} dBm" + ) + + adjustments: list[str] = [] + current_start = self._start_hz_input.text().strip() + current_stop = self._stop_hz_input.text().strip() + current_points = self._points_input.text().strip() + current_ifbw = self._ifbw_input.text().strip() + current_power = self._power_input.text().strip() + if prev_start != current_start: + adjustments.append(f"Start Hz: {prev_start or ''} -> {current_start}") + if prev_stop != current_stop: + adjustments.append(f"Stop Hz: {prev_stop or ''} -> {current_stop}") + if prev_points != current_points: + adjustments.append(f"Points: {prev_points or ''} -> {current_points}") + if prev_ifbw != current_ifbw: + adjustments.append(f"IF BW Hz: {prev_ifbw or ''} -> {current_ifbw}") + if prev_power != current_power: + adjustments.append(f"Stimulus Power dBm: {prev_power or ''} -> {current_power}") + if adjustments: + self._log_warning( + "Radar fields were adjusted to satisfy device limits.", + details="\n".join(adjustments), + ) + self._sync_processing_frequency_limits_with_radar() return changed @@ -403,7 +853,14 @@ class AppWindowConfigMixin: radar_max_mhz = max(radar_start_hz, radar_stop_hz) / 1_000_000.0 changed = False + widget_labels = { + "_bscan_start_freq_mhz": "B-scan Start MHz", + "_bscan_stop_freq_mhz": "B-scan Stop MHz", + "_gpr_start_freq_mhz": "GPR Start MHz", + "_gpr_stop_freq_mhz": "GPR Stop MHz", + } widgets = [getattr(self, widget_name) for widget_name in widget_names] + previous_values = {widget_name: getattr(self, widget_name).value() for widget_name in widget_names} for widget in widgets: if widget.minimum() != radar_min_mhz or widget.maximum() != radar_max_mhz: changed = True @@ -418,6 +875,20 @@ class AppWindowConfigMixin: widget.blockSignals(True) widget.setValue(clamped_value) widget.blockSignals(False) + clamped_fields = [] + for widget_name in widget_names: + current_value = getattr(self, widget_name).value() + previous_value = previous_values[widget_name] + if current_value == previous_value: + continue + clamped_fields.append( + f"{widget_labels.get(widget_name, widget_name)}: {previous_value:g} -> {current_value:g}" + ) + if clamped_fields: + self._log_warning( + "Processing frequency limits were clamped to the active radar sweep.", + details="\n".join(clamped_fields), + ) return changed def _sync_bscan_frequency_limits_with_radar(self) -> bool: diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index 5ba26a7..e0d7a97 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -9,7 +9,12 @@ from python_app.gui.runtime.history import build_run_history_signature, record_r from python_app.hardware_full.librevna_service import LibreVnaService from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection from python_app.models.run_config_model import RunConfigModel -from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, PREPROCESS_ASSET_SPECS, preprocess_asset_model +from python_app.orchestration.preprocess_assets import ( + PREPROCESS_ASSET_SPECS, + REQUIRED_PREPROCESS_ASSET_KEYS, + preprocess_asset_model, + runtime_preprocess_asset_keys, +) from python_app.orchestration.shm_reader import ShmRingReader @@ -23,10 +28,13 @@ class AppWindowPipelineMixin: def _start_run(self, *, single_capture: bool = False) -> None: """Start pipeline processes and ring readers.""" if self._capture_session is not None: - self._show_error("Cannot start pipeline during active capture sequence") + self._show_error( + "Cannot start pipeline during active capture sequence", + details=self._capture_state_details(), + ) return if self._supervisor.is_running(): - self._show_error("Pipeline is already running") + self._show_error("Pipeline is already running", details=self._process_state_details()) return try: @@ -41,7 +49,7 @@ class AppWindowPipelineMixin: missing_assets = [ PREPROCESS_ASSET_SPECS[key].display_name - for key in PREPROCESS_ASSET_KEYS + for key in REQUIRED_PREPROCESS_ASSET_KEYS if not preprocess_asset_model(config, key).set_name ] if missing_assets: @@ -51,8 +59,14 @@ class AppWindowPipelineMixin: ) combo_keys = [ComboKey(input_pos=combo.input, output_pos=combo.output) for combo in config.combos] + active_preprocess_keys = runtime_preprocess_asset_keys(config) + preprocess_summary = "; ".join( + f"{PREPROCESS_ASSET_SPECS[key].display_name}={preprocess_asset_model(config, key).set_name}" + for key in active_preprocess_keys + ) + self._log(f"Active preprocess assets for run: {preprocess_summary}") - for key in PREPROCESS_ASSET_KEYS: + for key in active_preprocess_keys: spec = PREPROCESS_ASSET_SPECS[key] asset = preprocess_asset_model(config, key) if not self._store.has_combo_coverage(spec.set_kind, radar_key, asset.set_name, combo_keys): @@ -65,6 +79,14 @@ class AppWindowPipelineMixin: self._prepare_radar_for_native_acquisition(config) config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json") + combo_preview = ", ".join(f"in{combo.input}/out{combo.output}" for combo in config.combos[:6]) + if len(config.combos) > 6: + combo_preview += ", ..." + self._log( + f"Starting pipeline: mode={'single_capture' if single_capture else 'continuous'}, " + f"config={config_path}, combos={len(config.combos)}" + f"{', ' + combo_preview if combo_preview else ''}, radar_key={radar_key}" + ) if not single_capture: should_reset_history = ( @@ -75,7 +97,7 @@ class AppWindowPipelineMixin: self._log("History reset because run settings changed") self._history_run_signature = run_signature - self._supervisor.start(config_path) + self._supervisor.start(config_path, allow_clean_orchestrator_exit=single_capture) self._close_readers() self._raw_reader = ShmRingReader(config.rings.raw_tap.name) self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name) @@ -88,25 +110,31 @@ class AppWindowPipelineMixin: # Always drop unread payloads for all stages so single-capture starts # from a clean boundary and does not retain stale results-only tail. self._drop_pending_ring_payloads(include_results=True) + self._last_reader_error_signature = None if single_capture: self._single_capture_start_ns = time.monotonic_ns() + pid_map = self._supervisor.pids() + pid_text = ", ".join(f"{name}={pid}" for name, pid in sorted(pid_map.items())) or "none" if single_capture: self._status_label.setText("Status: single capture running") - self._log("Single capture started") + self._log(f"Single capture started; managed processes: {pid_text}") else: self._status_label.setText("Status: running") - self._log("Pipeline started") + self._log(f"Pipeline started; managed processes: {pid_text}") except Exception as exc: # noqa: BLE001 self._single_capture_active = False self._single_capture_start_ns = None self._stop_all_processes() - self._show_error(f"Failed to start pipeline: {exc}") + self._show_exception("Failed to start pipeline", exc) def _apply_radar_settings(self) -> None: """Apply current radar settings by preconfiguring native device.""" if self._capture_session is not None: - self._show_error("Finish or abort capture sequence before applying radar settings") + self._show_error( + "Finish or abort capture sequence before applying radar settings", + details=self._capture_state_details(), + ) return was_running = self._supervisor.is_running() @@ -118,9 +146,16 @@ class AppWindowPipelineMixin: self._refresh_radar_limits_from_device() config = self._build_config() self._prepare_radar_for_native_acquisition(config) - self._log("Radar settings applied") + self._log( + "Radar settings applied: " + f"start={config.radar.sweep.start_hz:g} Hz, " + f"stop={config.radar.sweep.stop_hz:g} Hz, " + f"points={config.radar.sweep.points}, " + f"ifbw={config.radar.sweep.if_bandwidth_hz:g} Hz, " + f"power={config.radar.sweep.power_dbm:g} dBm" + ) except Exception as exc: # noqa: BLE001 - self._show_error(f"Failed to apply radar settings: {exc}") + self._show_exception("Failed to apply radar settings", exc) finally: if was_running: self._start_run() @@ -197,9 +232,12 @@ class AppWindowPipelineMixin: def _poll_rings(self) -> None: """Poll readers, ingest history, and trigger rendering.""" - for report in self._supervisor.collect_crash_reports(): + for report in self._supervisor.collect_exit_reports(): + if report.level == "INFO": + self._log(report.format()) + continue self._status_label.setText("Status: error") - self._log(report) + self._log_error(report.format()) try: if self._raw_reader is not None: @@ -207,6 +245,7 @@ class AppWindowPipelineMixin: self._read_all_preprocessed() result_latest = self._read_all_results() if self._result_reader is not None else None self._update_history_indicator() + self._last_reader_error_signature = None if self._single_capture_active: if self._finish_single_capture_if_ready(): @@ -215,7 +254,11 @@ class AppWindowPipelineMixin: self._draw_preferred_collection(result_latest=result_latest) except Exception as exc: # noqa: BLE001 - self._log(f"Reader error: {exc}") + signature = (type(exc).__name__, str(exc)) + if self._last_reader_error_signature == signature: + return + self._last_reader_error_signature = signature + self._log_exception("Reader poll failed", exc, level="ERROR") def _finish_single_capture_if_ready(self) -> bool: """Finalize single capture when the exact target result becomes available.""" diff --git a/python_app/gui/controllers/app_window_plot_mixin.py b/python_app/gui/controllers/app_window_plot_mixin.py index 7d7a421..26d35c2 100644 --- a/python_app/gui/controllers/app_window_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot_mixin.py @@ -86,8 +86,11 @@ class AppWindowPlotMixin: if mag_legend is not None: try: self._trace_magnitude_plot.getPlotItem().removeItem(mag_legend) - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 + self._log_warning( + f"Failed to remove pass-through magnitude legend: {type(exc).__name__}: {exc}", + once_key="plot_remove_magnitude_legend_failed", + ) self._trace_magnitude_legend = None self._trace_magnitude_legend_combo_keys.clear() @@ -95,8 +98,11 @@ class AppWindowPlotMixin: if phase_legend is not None: try: self._trace_phase_plot.getPlotItem().removeItem(phase_legend) - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 + self._log_warning( + f"Failed to remove pass-through phase legend: {type(exc).__name__}: {exc}", + once_key="plot_remove_phase_legend_failed", + ) self._trace_phase_legend = None self._trace_phase_legend_combo_keys.clear() @@ -106,7 +112,7 @@ class AppWindowPlotMixin: show_phase = self._show_phase_curves() magnitude_plot = self._trace_magnitude_plot phase_plot = self._trace_phase_plot - pass_through_channel = self._pass_through_channel.currentText().upper() + pass_through_channel = "S21" magnitude_plot.setVisible(show_magnitude) phase_plot.setVisible(show_phase) @@ -318,8 +324,11 @@ class AppWindowPlotMixin: if legend is not None: try: plot.getPlotItem().removeItem(legend) - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 + self._log_warning( + f"Failed to clear plot legend: {type(exc).__name__}: {exc}", + once_key=f"{legend_attr}_clear_failed", + ) setattr(self, legend_attr, None) existing_keys.clear() return @@ -330,8 +339,11 @@ class AppWindowPlotMixin: if legend is not None: try: plot.getPlotItem().removeItem(legend) - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 + self._log_warning( + f"Failed to replace plot legend: {type(exc).__name__}: {exc}", + once_key=f"{legend_attr}_replace_failed", + ) legend = plot.addLegend(offset=(8, 8)) for combo_key in sorted(active_keys): @@ -389,7 +401,7 @@ class AppWindowPlotMixin: self._bscan_plot.addItem(image_item) self._bscan_plot.setXRange(x_min, x_max, padding=0.02) self._bscan_plot.setYRange(depth_min, depth_max, padding=0.02) - bscan_channel = self._bscan_channel.currentText().upper() + bscan_channel = "S21" self._bscan_plot.setTitle( f"B-scan {bscan_channel} in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}" ) @@ -428,7 +440,24 @@ class AppWindowPlotMixin: def _pick_bscan_display_key(self) -> tuple[int, int] | None: """Choose combo history key to render.""" - return pick_bscan_display_key(self._bscan_history_by_combo) + display_key = pick_bscan_display_key(self._bscan_history_by_combo) + available_keys = sorted(self._bscan_history_by_combo.keys()) + if display_key is not None and len(available_keys) > 1: + combo_signature = ",".join(f"{input_pos}:{output_pos}" for input_pos, output_pos in available_keys) + details = "\n".join( + f"- in{input_pos}/out{output_pos}" + for input_pos, output_pos in available_keys + ) + self._log( + f"B-scan auto-selected combo in{display_key[0]}/out{display_key[1]} because multiple combos are available.", + once_key=f"bscan_auto_display_{combo_signature}", + ) + self._log_warning( + "B-scan has multiple combo histories but the UI currently renders only one at a time.", + details=details, + once_key=f"bscan_multi_combo_warning_{combo_signature}", + ) + return display_key def _bscan_lookup_table(self, axis_mode: str) -> np.ndarray: """Return lookup table for current B-scan axis mode.""" @@ -578,8 +607,11 @@ class AppWindowPlotMixin: for item in self._gpr_point_labels: try: self._gpr_plot.removeItem(item) - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 + self._log_warning( + f"Failed to remove GPR point label: {type(exc).__name__}: {exc}", + once_key="gpr_remove_point_label_failed", + ) self._gpr_point_labels.clear() def _clear_gpr_region_labels(self) -> None: @@ -587,8 +619,11 @@ class AppWindowPlotMixin: for item in self._gpr_region_center_labels: try: self._gpr_plot.removeItem(item) - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 + self._log_warning( + f"Failed to remove GPR region label: {type(exc).__name__}: {exc}", + once_key="gpr_remove_region_label_failed", + ) self._gpr_region_center_labels.clear() def _clear_gpr_region_masks(self) -> None: @@ -596,8 +631,11 @@ class AppWindowPlotMixin: for item in self._gpr_region_mask_items: try: self._gpr_plot.removeItem(item) - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 + self._log_warning( + f"Failed to remove GPR region mask: {type(exc).__name__}: {exc}", + once_key="gpr_remove_region_mask_failed", + ) self._gpr_region_mask_items.clear() self._gpr_region_contours.clear() @@ -838,10 +876,10 @@ class AppWindowPlotMixin: ] = magnitude_curve if show_phase: - phase_deg = np.degrees(np.angle(samples)) + phase_values = np.degrees(np.angle(samples)) phase_curve = pg.PlotCurveItem( trace.frequency_hz, - phase_deg, + phase_values, pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine), ) phase_plot.addItem(phase_curve) diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index 47a9f2e..c4c78cf 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -4,8 +4,8 @@ from __future__ import annotations from python_app.gui.preprocess_dialog import PreprocessDialog from python_app.orchestration.preprocess_assets import ( - PREPROCESS_ASSET_KEYS, PREPROCESS_ASSET_SPECS, + VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_channel, preprocess_asset_display_name, ) @@ -22,7 +22,7 @@ class AppWindowPreprocessMixin: self._refresh_sets() self._update_capture_dialog_state() except Exception as exc: # noqa: BLE001 - self._show_error(f"Failed to open preprocessing panel: {exc}") + self._show_exception("Failed to open preprocessing panel", exc) return dialog.showMaximized() @@ -35,24 +35,39 @@ class AppWindowPreprocessMixin: return self._preprocess_dialog dialog = PreprocessDialog(self) + self._preprocess_dialog = dialog + dialog.set_set_name(self._preprocess_set_name) + dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False) dialog.refresh_requested.connect(self._refresh_sets) dialog.selection_changed.connect(self._on_preprocess_selection_changed) dialog.start_sequence_requested.connect(self._start_capture_sequence) dialog.capture_next_requested.connect(self._capture_next_combo) dialog.abort_sequence_requested.connect(self._abort_capture_sequence) - self._preprocess_dialog = dialog self._update_capture_dialog_state() return dialog def _on_preprocess_selection_changed(self) -> None: """Persist selected preprocessing set names from dialog.""" dialog = self._ensure_preprocess_dialog() + previous_selection = dict(self._selected_preprocess_sets) self._selected_preprocess_sets = dialog.selection_snapshot() self._refresh_preprocess_summary_labels() + changes = [] + for key in VISIBLE_PREPROCESS_ASSET_KEYS: + previous_value = previous_selection.get(key, "") + current_value = self._selected_preprocess_sets.get(key, "") + if previous_value == current_value: + continue + changes.append( + f"{preprocess_asset_display_name(key)}: " + f"{previous_value or ''} -> {current_value or ''}" + ) + if changes: + self._log("Preprocess selection changed: " + "; ".join(changes)) def _refresh_preprocess_summary_labels(self) -> None: """Update compact summary labels in the main window.""" - for key in PREPROCESS_ASSET_KEYS: + for key in VISIBLE_PREPROCESS_ASSET_KEYS: self._selected_preprocess_labels[key].setText(self._selected_preprocess_sets.get(key, "") or "") def _refresh_sets(self) -> None: @@ -63,22 +78,37 @@ class AppWindowPreprocessMixin: available_sets = { key: self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key) - for key in PREPROCESS_ASSET_KEYS + for key in VISIBLE_PREPROCESS_ASSET_KEYS } dialog.set_available_sets(available_sets) + unavailable_selections: list[str] = [] for key, names in available_sets.items(): - if self._selected_preprocess_sets.get(key, "") not in names: - self._selected_preprocess_sets[key] = names[0] if names else "" + current_value = self._selected_preprocess_sets.get(key, "") + if not current_value or current_value in names: + continue + unavailable_selections.append( + f"{preprocess_asset_display_name(key)}: " + f"{current_value} (not available for current radar key)" + ) dialog.set_selected_sets(self._selected_preprocess_sets) self._refresh_preprocess_summary_labels() - self._log(f"Preprocess set lists refreshed for key={radar_key}") + available_counts = ", ".join( + f"{preprocess_asset_display_name(key)}={len(names)}" + for key, names in available_sets.items() + ) + self._log(f"Preprocess set lists refreshed: radar_key={radar_key}, {available_counts}") + if unavailable_selections: + self._log_warning( + "Some selected preprocess sets are not currently available for this radar key.", + details="\n".join(unavailable_selections), + ) def _start_capture_sequence(self, kind: str) -> None: """Start sequential capture session for requested preprocess asset.""" if self._capture_session is not None: - self._show_error("Another capture sequence is already active") + self._show_error("Another capture sequence is already active", details=self._capture_state_details()) return dialog = self._ensure_preprocess_dialog() @@ -109,10 +139,13 @@ class AppWindowPreprocessMixin: dialog.clear_capture_log() dialog.set_status(f"{display_name} sequence started") self._update_capture_dialog_state() - self._log(f"{display_name} sequence started for set={set_name}; fill all N*M combos") + self._log( + f"{display_name} sequence started: set={set_name}, radar_key={radar_key}, " + f"combos={session.state().total_count}" + ) except Exception as exc: # noqa: BLE001 self._cleanup_capture_session() - self._show_error(f"Failed to start {kind} sequence: {exc}") + self._show_exception(f"Failed to start {kind} sequence", exc) self._resume_pipeline_if_needed() def _capture_next_combo(self) -> None: @@ -127,7 +160,6 @@ class AppWindowPreprocessMixin: try: trace = session.capture_current_combo() state = session.state() - tx_label, rx_label = dialog.antenna_labels() display_name = preprocess_asset_display_name(session.kind) channel = preprocess_asset_channel(session.kind) @@ -137,8 +169,6 @@ class AppWindowPreprocessMixin: total_count=state.total_count, input_pos=trace.combo.input_pos, output_pos=trace.combo.output_pos, - tx_label=tx_label, - rx_label=rx_label, ) dialog.draw_last_trace(trace, title=f"{display_name} captured", channel=channel) @@ -164,7 +194,7 @@ class AppWindowPreprocessMixin: else: self._update_capture_dialog_state() except Exception as exc: # noqa: BLE001 - self._show_error(f"Failed to capture combo: {exc}") + self._show_exception("Failed to capture preprocess combo", exc) self._abort_capture_sequence() def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None: @@ -227,4 +257,4 @@ class AppWindowPreprocessMixin: try: self._start_run() except Exception as exc: # noqa: BLE001 - self._show_error(f"Failed to resume pipeline after capture: {exc}") + self._show_exception("Failed to resume pipeline after capture", exc) diff --git a/python_app/gui/controllers/app_window_snapshot_mixin.py b/python_app/gui/controllers/app_window_snapshot_mixin.py index 45836da..1c2299f 100644 --- a/python_app/gui/controllers/app_window_snapshot_mixin.py +++ b/python_app/gui/controllers/app_window_snapshot_mixin.py @@ -12,12 +12,22 @@ from python_app.gui.runtime.history import record_result_history, remove_last_al class AppWindowSnapshotMixin: """Saves runtime data snapshots and maintains ring-reader freshness.""" + @staticmethod + def _snapshot_config_profile_path(snapshot_dir: Path) -> Path: + """Return companion config-profile path inside a saved snapshot directory.""" + return snapshot_dir / "config_profile.json" + + @staticmethod + def _vna_json_config_profile_path(output_root: Path, output_stem: str) -> Path: + """Return companion config-profile path for one VNA-history JSON export batch.""" + return output_root / f"{output_stem}_config_profile.json" + def _save_snapshot(self) -> None: """Save runtime snapshot in numpy-directory format.""" self._drain_runtime_rings_for_snapshot() if not self._raw_history and not self._pre_history and not self._result_history: - self._show_error("No runtime data is available for save") + self._show_error("No runtime data is available for save", details=self._runtime_history_details()) return try: @@ -32,6 +42,19 @@ class AppWindowSnapshotMixin: list(self._result_history), last_n, ) + config_profile_path = self._snapshot_config_profile_path(snapshot_dir) + try: + self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False) + except Exception as exc: # noqa: BLE001 + self._show_error( + "Snapshot data was saved, but the adjacent config profile could not be written", + details=( + f"snapshot_dir={snapshot_dir}\n" + f"config_profile_path={config_profile_path}\n\n" + f"{self._exception_details(exc)}" + ), + ) + return self._log( f"Saved numpy snapshot: {snapshot_dir} " f"(raw={summary.get('raw_count', 0)}, " @@ -43,55 +66,77 @@ class AppWindowSnapshotMixin: f"raw_missing={summary.get('raw_missing_count', 0)}, " f"pre_missing={summary.get('preprocessed_missing_count', 0)}, " f"result_missing={summary.get('result_missing_count', 0)}, " - f"requested_last_n={last_n})" + f"requested_last_n={last_n}, " + f"config_profile={config_profile_path})" ) except Exception as exc: # noqa: BLE001 - self._show_error(f"Failed to save snapshot: {exc}") + self._show_exception("Failed to save snapshot", exc) def _save_vna_history_json(self) -> None: - """Save runtime history as vna_system-compatible JSON file.""" + """Save one VNA-history JSON per available combo in runtime history.""" self._drain_runtime_rings_for_snapshot() if not self._raw_history and not self._pre_history and not self._result_history: - self._show_error("No runtime data is available for save") + self._show_error("No runtime data is available for save", details=self._runtime_history_details()) return try: last_n = int(self._save_count.value()) - input_index = int(self._vna_json_input_index.value()) - output_index = int(self._vna_json_output_index.value()) - channel = self._vna_json_channel.currentText() + channel = "s21" output_root = Path(self._save_path_input.text().strip()).expanduser() output_name = self._save_name_input.text().strip() - output_path, summary = self._store.save_runtime_vna_history_json( + output_paths, summary = self._store.save_runtime_vna_history_json_batch( output_root, output_name, list(self._raw_history), list(self._pre_history), list(self._result_history), last_n, - input_index=input_index, - output_index=output_index, channel=channel, primary_stage="preprocessed", ) + output_stem = str(summary.get("output_stem", "")).strip() + if not output_stem: + raise RuntimeError("VNA history JSON export did not report output_stem for config companion save") + + config_profile_path = self._vna_json_config_profile_path(output_root, output_stem) + try: + self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False) + except Exception as exc: # noqa: BLE001 + exported_preview = "\n".join(str(path) for path in output_paths[:8]) + if len(output_paths) > 8: + exported_preview += "\n..." + self._show_error( + "VNA history JSON files were saved, but the adjacent config profile could not be written", + details=( + f"output_root={output_root}\n" + f"config_profile_path={config_profile_path}\n" + f"saved_json_files={len(output_paths)}\n" + f"{exported_preview}\n\n" + f"{self._exception_details(exc)}" + ), + ) + return + combos = summary.get("combos", []) + combo_preview = ", ".join(f"in{input_pos}/out{output_pos}" for input_pos, output_pos in combos[:6]) + if len(combos) > 6: + combo_preview += ", ..." self._log( - f"Saved VNA history JSON: {output_path} " - f"(sweeps={summary.get('sweep_count', 0)}, " - f"raw_records={summary.get('raw_record_count', 0)}, " + f"Saved VNA history JSON batch: files={len(output_paths)} " + f"(combos={summary.get('combo_count', 0)}" + f"{', ' + combo_preview if combo_preview else ''}, " f"preprocessed_records={summary.get('preprocessed_record_count', 0)}, " f"raw={summary.get('raw_count', 0)}, " f"preprocessed={summary.get('preprocessed_count', 0)}, " f"results={summary.get('result_count', 0)}, " f"mode={summary.get('selection_mode', 'unknown')}, " f"anchor={summary.get('anchor_stage', 'unknown')}, " - f"input={input_index}, " - f"output={output_index}, " f"channel={channel}, " - f"requested_last_n={last_n})" + f"requested_last_n={last_n}, " + f"config_profile={config_profile_path})" ) except Exception as exc: # noqa: BLE001 - self._show_error(f"Failed to save VNA history JSON: {exc}") + self._show_exception("Failed to save VNA history JSON", exc) def _remove_last_runtime_history(self) -> None: """Remove the newest runtime measurement from all stages and processor replay state.""" @@ -104,7 +149,10 @@ class AppWindowSnapshotMixin: def _apply_runtime_history_deletion(self, *, remove_last_only: bool) -> None: """Apply destructive runtime-history deletion across readers, caches, and processor replay state.""" if self._capture_session is not None: - self._show_error("Cannot modify runtime history during active capture sequence") + self._show_error( + "Cannot modify runtime history during active capture sequence", + details=f"{self._capture_state_details()}\n\n{self._runtime_history_details()}", + ) return resume_acquisition = self._supervisor.is_running() @@ -161,7 +209,7 @@ class AppWindowSnapshotMixin: if resume_acquisition: self._start_run() except Exception as exc: # noqa: BLE001 - self._show_error(f"Failed to {error_action}: {exc}") + self._show_exception(f"Failed to {error_action}", exc) def _browse_save_path(self) -> None: """Open directory picker for snapshot output path.""" @@ -238,7 +286,7 @@ class AppWindowSnapshotMixin: continue break except Exception as exc: # noqa: BLE001 - self._log(f"Snapshot drain warning: {exc}") + self._log_exception("Snapshot drain warning", exc, level="WARN") def _drop_pending_ring_payloads(self, *, include_results: bool = True) -> None: """Drop unread payloads from active readers.""" diff --git a/python_app/gui/controllers/app_window_ui_mixin.py b/python_app/gui/controllers/app_window_ui_mixin.py index f184e08..2752873 100644 --- a/python_app/gui/controllers/app_window_ui_mixin.py +++ b/python_app/gui/controllers/app_window_ui_mixin.py @@ -16,10 +16,10 @@ from PyQt6.QtWidgets import ( QGroupBox, QHBoxLayout, QLabel, - QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, + QTextEdit, QVBoxLayout, QWidget, ) @@ -27,9 +27,7 @@ import pyqtgraph as pg from python_app.gui.controllers.sections import ( build_data_actions_group, - build_gpr_config_group, - build_hardware_actions_group, - build_pipeline_group, + build_primary_actions_group, build_preprocess_summary_group, build_processing_group, build_radar_group, @@ -81,10 +79,15 @@ class AppWindowUiMixin: self._plot_stack.setCurrentWidget(self._trace_plots_container) root_layout.addWidget(self._plot_stack, stretch=12) + @staticmethod + def _create_plot_widget(*, background: str) -> pg.PlotWidget: + """Create PlotWidget with pyqtgraph context menu disabled for PyQt6 compatibility.""" + return pg.PlotWidget(background=background, enableMenu=False) + def _build_bscan_plot_page(self) -> None: """Create B-scan page in plot stack.""" # B-scan surface: one PlotWidget used as canvas for ImageItem heatmap. - self._bscan_plot = pg.PlotWidget(background="#0f141c") + self._bscan_plot = self._create_plot_widget(background="#0f141c") self._bscan_plot.showGrid(x=True, y=True, alpha=0.2) self._plot_stack.addWidget(self._bscan_plot) @@ -97,7 +100,7 @@ class AppWindowUiMixin: trace_layout.setContentsMargins(0, 0, 0, 0) trace_layout.setSpacing(6) - self._trace_magnitude_plot = pg.PlotWidget(background="#0f141c") + self._trace_magnitude_plot = self._create_plot_widget(background="#0f141c") self._trace_magnitude_plot.showGrid(x=True, y=True, alpha=0.2) self._trace_magnitude_plot.setLabel("left", "Magnitude", units="dB") self._trace_magnitude_plot.getPlotItem().showAxis("bottom", show=False) @@ -105,7 +108,7 @@ class AppWindowUiMixin: self._trace_magnitude_plot.getPlotItem().setClipToView(True) trace_layout.addWidget(self._trace_magnitude_plot, stretch=1) - self._trace_phase_plot = pg.PlotWidget(background="#0f141c") + self._trace_phase_plot = self._create_plot_widget(background="#0f141c") self._trace_phase_plot.showGrid(x=True, y=True, alpha=0.2) self._trace_phase_plot.setLabel("left", "Phase", units="deg") self._trace_phase_plot.setLabel("bottom", "Frequency", units="Hz") @@ -126,7 +129,7 @@ class AppWindowUiMixin: def _build_gpr_plot_page(self) -> None: """Create GPR page in plot stack.""" - self._gpr_plot = pg.PlotWidget(background="#0f141c") + self._gpr_plot = self._create_plot_widget(background="#0f141c") self._gpr_plot.showGrid(x=True, y=True, alpha=0.2) self._plot_stack.addWidget(self._gpr_plot) @@ -141,17 +144,21 @@ class AppWindowUiMixin: def _build_settings_panel(self, root_layout: QHBoxLayout, root: QWidget) -> None: """Build right settings panel with controls, status labels, and log.""" self._settings_panel = QWidget(root) - self._settings_panel.setMinimumWidth(530) + self._settings_panel.setMinimumWidth(610) right_layout = QVBoxLayout(self._settings_panel) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(10) # Build log early so `_show_error()` can append text even during # subsequent group construction if something fails. - self._log_box = QPlainTextEdit(self._settings_panel) + self._log_box = QTextEdit(self._settings_panel) + self._log_box.setObjectName("runtimeLogBox") self._log_box.setReadOnly(True) + self._log_box.setUndoRedoEnabled(False) self._log_box.setMinimumHeight(170) + self._log_box.document().setMaximumBlockCount(1200) + right_layout.addWidget(build_primary_actions_group(self), stretch=0) right_layout.addWidget(self._build_settings_scroll(), stretch=1) self._status_label = QLabel("Status: idle", self._settings_panel) @@ -163,7 +170,7 @@ class AppWindowUiMixin: right_layout.addWidget(self._history_label) right_layout.addWidget(self._log_box, stretch=0) - root_layout.addWidget(self._settings_panel, stretch=6) + root_layout.addWidget(self._settings_panel, stretch=7) def _build_settings_scroll(self) -> QScrollArea: """Build scroll area with all control groups in display order.""" @@ -186,14 +193,11 @@ class AppWindowUiMixin: def _build_control_groups(self) -> list[QGroupBox]: """Create all settings groups in top-to-bottom order.""" return [ - build_pipeline_group(self), - build_hardware_actions_group(self), + build_switch_group(self), build_data_actions_group(self), build_preprocess_summary_group(self), build_processing_group(self), - build_gpr_config_group(self), build_radar_group(self), - build_switch_group(self), ] def _toggle_settings_panel(self, *, visible: bool | None = None) -> None: diff --git a/python_app/gui/controllers/sections/__init__.py b/python_app/gui/controllers/sections/__init__.py index 72bbf3f..1f9c8b3 100644 --- a/python_app/gui/controllers/sections/__init__.py +++ b/python_app/gui/controllers/sections/__init__.py @@ -1,9 +1,7 @@ """Composable UI section builders used by AppWindow UI mixin.""" from python_app.gui.controllers.sections.data_actions_section import build_data_actions_group -from python_app.gui.controllers.sections.gpr_config_section import build_gpr_config_group -from python_app.gui.controllers.sections.hardware_actions_section import build_hardware_actions_group -from python_app.gui.controllers.sections.pipeline_section import build_pipeline_group +from python_app.gui.controllers.sections.primary_actions_section import build_primary_actions_group from python_app.gui.controllers.sections.preprocess_summary_section import build_preprocess_summary_group from python_app.gui.controllers.sections.processing_section import build_processing_group from python_app.gui.controllers.sections.radar_section import build_radar_group @@ -11,9 +9,7 @@ from python_app.gui.controllers.sections.switch_section import build_switch_grou __all__ = [ "build_data_actions_group", - "build_gpr_config_group", - "build_hardware_actions_group", - "build_pipeline_group", + "build_primary_actions_group", "build_preprocess_summary_group", "build_processing_group", "build_radar_group", diff --git a/python_app/gui/controllers/sections/data_actions_section.py b/python_app/gui/controllers/sections/data_actions_section.py index 26aef32..ffb1a55 100644 --- a/python_app/gui/controllers/sections/data_actions_section.py +++ b/python_app/gui/controllers/sections/data_actions_section.py @@ -2,8 +2,17 @@ from __future__ import annotations -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QComboBox, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSpinBox, QVBoxLayout +from PyQt6.QtWidgets import ( + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, +) def build_data_actions_group(owner) -> QGroupBox: @@ -11,59 +20,46 @@ def build_data_actions_group(owner) -> QGroupBox: group = QGroupBox("Data Actions") layout = QVBoxLayout(group) layout.setSpacing(8) + data_defaults = owner._gui_defaults.data_actions - save_button = QPushButton("Save Snapshot") + save_button = QPushButton("Save Dataset") save_button.clicked.connect(owner._save_snapshot) - save_vna_json_button = QPushButton("Save VNA JSON") + save_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + save_vna_json_button = QPushButton("Save JSON") save_vna_json_button.clicked.connect(owner._save_vna_history_json) + save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) remove_last_button = QPushButton("Remove Last Measurement") remove_last_button.clicked.connect(owner._remove_last_runtime_history) + remove_last_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) clear_history_button = QPushButton("Clear Runtime History") clear_history_button.clicked.connect(owner._clear_all_runtime_history) + clear_history_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) owner._save_count = QSpinBox() owner._save_count.setMinimum(1) owner._save_count.setMaximum(10_000) - owner._save_count.setValue(10) - owner._vna_json_input_index = QSpinBox() - owner._vna_json_input_index.setMinimum(0) - owner._vna_json_input_index.setMaximum(65_535) - owner._vna_json_input_index.setValue(0) - owner._vna_json_output_index = QSpinBox() - owner._vna_json_output_index.setMinimum(0) - owner._vna_json_output_index.setMaximum(65_535) - owner._vna_json_output_index.setValue(0) - owner._vna_json_channel = QComboBox() - owner._vna_json_channel.addItems(["s21", "s11"]) + owner._save_count.setValue(int(data_defaults.save_count)) - button_column = QVBoxLayout() - button_column.setSpacing(8) - button_column.addWidget(save_button, alignment=Qt.AlignmentFlag.AlignLeft) - button_column.addWidget(save_vna_json_button, alignment=Qt.AlignmentFlag.AlignLeft) - button_column.addWidget(remove_last_button, alignment=Qt.AlignmentFlag.AlignLeft) - button_column.addWidget(clear_history_button, alignment=Qt.AlignmentFlag.AlignLeft) - layout.addLayout(button_column) + button_grid = QGridLayout() + button_grid.setHorizontalSpacing(8) + button_grid.setVerticalSpacing(8) + button_grid.addWidget(save_button, 0, 0) + button_grid.addWidget(save_vna_json_button, 0, 1) + button_grid.addWidget(remove_last_button, 1, 0) + button_grid.addWidget(clear_history_button, 1, 1) + button_grid.setColumnStretch(0, 1) + button_grid.setColumnStretch(1, 1) + layout.addLayout(button_grid) count_row = QHBoxLayout() count_row.setSpacing(8) - count_row.addWidget(QLabel("Last N")) + count_row.addWidget(QLabel("Number of Measurements to Save")) count_row.addWidget(owner._save_count) count_row.addStretch(1) layout.addLayout(count_row) - json_row = QHBoxLayout() - json_row.setSpacing(8) - json_row.addWidget(QLabel("JSON input")) - json_row.addWidget(owner._vna_json_input_index) - json_row.addWidget(QLabel("output")) - json_row.addWidget(owner._vna_json_output_index) - json_row.addWidget(QLabel("channel")) - json_row.addWidget(owner._vna_json_channel) - json_row.addStretch(1) - layout.addLayout(json_row) - path_row = QHBoxLayout() path_row.setSpacing(8) - owner._save_path_input = QLineEdit(str(owner._project_root / "python_app/data/snapshots")) + owner._save_path_input = QLineEdit(str(data_defaults.save_path)) browse_button = QPushButton("Browse") browse_button.clicked.connect(owner._browse_save_path) path_row.addWidget(QLabel("Path")) @@ -73,7 +69,7 @@ def build_data_actions_group(owner) -> QGroupBox: name_row = QHBoxLayout() name_row.setSpacing(8) - owner._save_name_input = QLineEdit("snapshot_manual") + owner._save_name_input = QLineEdit(str(data_defaults.save_name)) name_row.addWidget(QLabel("Name")) name_row.addWidget(owner._save_name_input, stretch=1) layout.addLayout(name_row) diff --git a/python_app/gui/controllers/sections/gpr_config_section.py b/python_app/gui/controllers/sections/gpr_config_section.py deleted file mode 100644 index 56d6b7a..0000000 --- a/python_app/gui/controllers/sections/gpr_config_section.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Builder for stable GPR configuration section.""" - -from __future__ import annotations - -from PyQt6.QtWidgets import QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, QPlainTextEdit - - -def _format_tx_geometry(owner) -> str: - """Render Tx geometry defaults into editable line-based text.""" - return "\n".join( - f"{int(entry.output_pos)} {float(entry.x_m):g}" - for entry in owner._defaults_config.gpr.tx_geometry - ) - - -def _format_rx_geometry(owner) -> str: - """Render Rx geometry defaults into editable line-based text.""" - return "\n".join( - f"{int(entry.input_pos)} {float(entry.x_m):g}" - for entry in owner._defaults_config.gpr.rx_geometry - ) - - -def build_gpr_config_group(owner) -> QGroupBox: - """Create stable GPR config controls backed by run_config.json.""" - group = QGroupBox("GPR Config") - form = QFormLayout(group) - form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - defaults = owner._defaults_config.gpr - - owner._gpr_config_mode = QComboBox() - owner._gpr_config_mode.addItems(["point", "extended"]) - owner._set_combo_current_text(owner._gpr_config_mode, defaults.mode) - - owner._gpr_relative_permittivity = QDoubleSpinBox() - owner._gpr_relative_permittivity.setDecimals(4) - owner._gpr_relative_permittivity.setRange(0.0001, 1000.0) - owner._gpr_relative_permittivity.setSingleStep(0.05) - owner._gpr_relative_permittivity.setValue(float(defaults.relative_permittivity)) - - owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner)) - owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m") - owner._gpr_tx_geometry_input.setMinimumHeight(88) - - owner._gpr_rx_geometry_input = QPlainTextEdit(_format_rx_geometry(owner)) - owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m") - owner._gpr_rx_geometry_input.setMinimumHeight(120) - - form.addRow("Mode", owner._gpr_config_mode) - form.addRow("Relative Permittivity", owner._gpr_relative_permittivity) - form.addRow("Tx Geometry", owner._gpr_tx_geometry_input) - form.addRow("Rx Geometry", owner._gpr_rx_geometry_input) - return group diff --git a/python_app/gui/controllers/sections/hardware_actions_section.py b/python_app/gui/controllers/sections/hardware_actions_section.py deleted file mode 100644 index db9b9ac..0000000 --- a/python_app/gui/controllers/sections/hardware_actions_section.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Builder for hardware actions section.""" - -from __future__ import annotations - -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QGroupBox, QPushButton, QVBoxLayout - - -def build_hardware_actions_group(owner) -> QGroupBox: - """Create hardware action buttons section.""" - group = QGroupBox("Hardware Actions") - layout = QVBoxLayout(group) - layout.setSpacing(8) - - apply_radar_button = QPushButton("Apply Radar") - apply_radar_button.clicked.connect(owner._apply_radar_settings) - layout.addWidget(apply_radar_button, alignment=Qt.AlignmentFlag.AlignLeft) - - save_config_button = QPushButton("Save Config") - save_config_button.clicked.connect(owner._save_current_config) - layout.addWidget(save_config_button, alignment=Qt.AlignmentFlag.AlignLeft) - - preprocess_button = QPushButton("Preprocessing") - preprocess_button.clicked.connect(owner._open_preprocess_panel) - layout.addWidget(preprocess_button, alignment=Qt.AlignmentFlag.AlignLeft) - return group diff --git a/python_app/gui/controllers/sections/pipeline_section.py b/python_app/gui/controllers/sections/pipeline_section.py deleted file mode 100644 index ab6be87..0000000 --- a/python_app/gui/controllers/sections/pipeline_section.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Builder for pipeline control section.""" - -from __future__ import annotations - -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QGroupBox, QLabel, QPushButton, QVBoxLayout - - -def build_pipeline_group(owner) -> QGroupBox: - """Create Start/Single/Stop controls section.""" - group = QGroupBox("Pipeline") - layout = QVBoxLayout(group) - layout.setSpacing(8) - - start_button = QPushButton("Start") - start_button.clicked.connect(owner._start_run) - layout.addWidget(start_button, alignment=Qt.AlignmentFlag.AlignLeft) - - single_button = QPushButton("Single Capture") - single_button.clicked.connect(owner._start_single_capture) - layout.addWidget(single_button, alignment=Qt.AlignmentFlag.AlignLeft) - - stop_button = QPushButton("Stop") - stop_button.clicked.connect(owner._stop_run) - layout.addWidget(stop_button, alignment=Qt.AlignmentFlag.AlignLeft) - - hint = QLabel("Start continuous run or single processed collection capture.") - hint.setObjectName("hintLabel") - layout.addWidget(hint) - return group diff --git a/python_app/gui/controllers/sections/preprocess_summary_section.py b/python_app/gui/controllers/sections/preprocess_summary_section.py index 13458d9..a855ac7 100644 --- a/python_app/gui/controllers/sections/preprocess_summary_section.py +++ b/python_app/gui/controllers/sections/preprocess_summary_section.py @@ -4,7 +4,7 @@ from __future__ import annotations from PyQt6.QtWidgets import QFormLayout, QGroupBox, QLabel -from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, preprocess_asset_display_name +from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_display_name def build_preprocess_summary_group(owner) -> QGroupBox: @@ -14,7 +14,7 @@ def build_preprocess_summary_group(owner) -> QGroupBox: form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) owner._selected_preprocess_labels = {} - for key in PREPROCESS_ASSET_KEYS: + for key in VISIBLE_PREPROCESS_ASSET_KEYS: label = QLabel("") owner._selected_preprocess_labels[key] = label form.addRow(preprocess_asset_display_name(key), label) diff --git a/python_app/gui/controllers/sections/primary_actions_section.py b/python_app/gui/controllers/sections/primary_actions_section.py new file mode 100644 index 0000000..6cdb902 --- /dev/null +++ b/python_app/gui/controllers/sections/primary_actions_section.py @@ -0,0 +1,53 @@ +"""Builder for pinned primary action controls.""" + +from __future__ import annotations + +from PyQt6.QtWidgets import QGridLayout, QGroupBox, QPushButton, QSizePolicy + + +def _expanding_button(label: str) -> QPushButton: + """Create horizontally expanding action button.""" + button = QPushButton(label) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + return button + + +def build_primary_actions_group(owner) -> QGroupBox: + """Create pinned action block combining pipeline and hardware actions.""" + group = QGroupBox("Actions") + layout = QGridLayout(group) + layout.setHorizontalSpacing(8) + layout.setVerticalSpacing(8) + + start_button = _expanding_button("Start") + start_button.clicked.connect(owner._start_run) + layout.addWidget(start_button, 0, 0) + + single_button = _expanding_button("Single Capture") + single_button.clicked.connect(owner._start_single_capture) + layout.addWidget(single_button, 0, 1) + + stop_button = _expanding_button("Stop") + stop_button.clicked.connect(owner._stop_run) + layout.addWidget(stop_button, 0, 2) + + apply_radar_button = _expanding_button("Apply Radar") + apply_radar_button.clicked.connect(owner._apply_radar_settings) + layout.addWidget(apply_radar_button, 1, 0) + + load_config_button = _expanding_button("Load Config") + load_config_button.clicked.connect(owner._load_config_from_dialog) + layout.addWidget(load_config_button, 1, 1) + + save_config_button = _expanding_button("Save Config") + save_config_button.clicked.connect(owner._save_current_config) + layout.addWidget(save_config_button, 1, 2) + + preprocess_button = _expanding_button("Preprocessing") + preprocess_button.clicked.connect(owner._open_preprocess_panel) + layout.addWidget(preprocess_button, 2, 0, 1, 3) + + layout.setColumnStretch(0, 1) + layout.setColumnStretch(1, 1) + layout.setColumnStretch(2, 1) + return group diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index d8e0650..616bd9c 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -9,27 +9,27 @@ from PyQt6.QtWidgets import ( QFormLayout, QGroupBox, QLineEdit, + QPlainTextEdit, QSizePolicy, QSpinBox, QStackedWidget, QWidget, ) - -def _default_gpr_input_positions(owner) -> str: - """Build default live input-position selection from stable GPR config.""" - geometry_values = {int(entry.input_pos) for entry in owner._defaults_config.gpr.rx_geometry} - combo_values = {int(combo.input) for combo in owner._defaults_config.combos} - values = sorted(geometry_values & combo_values) or sorted(geometry_values) - return ",".join(str(value) for value in values) +def _format_tx_geometry(owner) -> str: + """Render Tx geometry defaults into editable line-based text.""" + return "\n".join( + f"{int(entry.output_pos)} {float(entry.x_m):g}" + for entry in owner._defaults_config.gpr.tx_geometry + ) -def _default_gpr_output_positions(owner) -> str: - """Build default live output-position selection from stable GPR config.""" - geometry_values = {int(entry.output_pos) for entry in owner._defaults_config.gpr.tx_geometry} - combo_values = {int(combo.output) for combo in owner._defaults_config.combos} - values = sorted(geometry_values & combo_values) or sorted(geometry_values) - return ",".join(str(value) for value in values) +def _format_rx_geometry(owner) -> str: + """Render Rx geometry defaults into editable line-based text.""" + return "\n".join( + f"{int(entry.input_pos)} {float(entry.x_m):g}" + for entry in owner._defaults_config.gpr.rx_geometry + ) def build_processing_group(owner) -> QGroupBox: @@ -37,9 +37,14 @@ def build_processing_group(owner) -> QGroupBox: group = QGroupBox("Processing") form = QFormLayout(group) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + processing_defaults = owner._gui_defaults.processing + pass_defaults = processing_defaults.pass_through + bscan_defaults = processing_defaults.bscan + gpr_live_defaults = processing_defaults.gpr owner._processing_mode = QComboBox() owner._processing_mode.addItems(["pass_through", "bscan", "gpr"]) + owner._set_combo_current_text(owner._processing_mode, processing_defaults.selected_mode) owner._processing_mode_pages = QStackedWidget(group) owner._processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) @@ -49,52 +54,29 @@ def build_processing_group(owner) -> QGroupBox: pass_through_form = QFormLayout(pass_through_page) pass_through_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - owner._processing_gain_db = QDoubleSpinBox() - owner._processing_gain_db.setDecimals(2) - owner._processing_gain_db.setRange(-40.0, 40.0) - owner._processing_gain_db.setSingleStep(0.25) - owner._processing_gain_db.setValue(0.0) - - owner._processing_phase_deg = QDoubleSpinBox() - owner._processing_phase_deg.setDecimals(1) - owner._processing_phase_deg.setRange(-180.0, 180.0) - owner._processing_phase_deg.setSingleStep(1.0) - owner._processing_phase_deg.setValue(0.0) - - owner._pass_through_channel = QComboBox() - owner._pass_through_channel.addItems(["s21", "s11"]) - owner._show_magnitude_checkbox = QCheckBox("Show magnitude") - owner._show_magnitude_checkbox.setChecked(True) + owner._show_magnitude_checkbox.setChecked(bool(pass_defaults.show_magnitude)) owner._show_phase_checkbox = QCheckBox("Show phase") - owner._show_phase_checkbox.setChecked(True) + owner._show_phase_checkbox.setChecked(bool(pass_defaults.show_phase)) owner._pass_through_fixed_y_enabled = QCheckBox("Fix magnitude Y range") - owner._pass_through_fixed_y_enabled.setChecked(False) + owner._pass_through_fixed_y_enabled.setChecked(bool(pass_defaults.fixed_y_enabled)) owner._pass_through_y_min_db = QDoubleSpinBox() owner._pass_through_y_min_db.setDecimals(1) owner._pass_through_y_min_db.setRange(-240.0, 240.0) owner._pass_through_y_min_db.setSingleStep(1.0) - owner._pass_through_y_min_db.setValue(-100.0) + owner._pass_through_y_min_db.setValue(float(pass_defaults.y_min_db)) owner._pass_through_y_max_db = QDoubleSpinBox() owner._pass_through_y_max_db.setDecimals(1) owner._pass_through_y_max_db.setRange(-240.0, 240.0) owner._pass_through_y_max_db.setSingleStep(1.0) - owner._pass_through_y_max_db.setValue(0.0) + owner._pass_through_y_max_db.setValue(float(pass_defaults.y_max_db)) - def sync_pass_through_y_controls() -> None: - enabled = owner._pass_through_fixed_y_enabled.isChecked() - owner._pass_through_y_min_db.setEnabled(enabled) - owner._pass_through_y_max_db.setEnabled(enabled) + owner._sync_pass_through_y_controls() - sync_pass_through_y_controls() - - pass_through_form.addRow("Gain dB (live)", owner._processing_gain_db) - pass_through_form.addRow("Phase deg (live)", owner._processing_phase_deg) - pass_through_form.addRow("Channel", owner._pass_through_channel) pass_through_form.addRow(owner._show_magnitude_checkbox) pass_through_form.addRow(owner._show_phase_checkbox) pass_through_form.addRow(owner._pass_through_fixed_y_enabled) @@ -109,42 +91,39 @@ def build_processing_group(owner) -> QGroupBox: owner._bscan_axis = QComboBox() owner._bscan_axis.addItems(["abs", "real", "phase"]) - - owner._bscan_channel = QComboBox() - owner._bscan_channel.addItems(["s21", "s11"]) + owner._set_combo_current_text(owner._bscan_axis, bscan_defaults.axis) owner._bscan_cut_m = QDoubleSpinBox() owner._bscan_cut_m.setDecimals(3) owner._bscan_cut_m.setRange(0.0, 2.0) owner._bscan_cut_m.setSingleStep(0.001) - owner._bscan_cut_m.setValue(0.824) + owner._bscan_cut_m.setValue(float(bscan_defaults.cut_m)) owner._bscan_max_depth_m = QDoubleSpinBox() owner._bscan_max_depth_m.setDecimals(1) owner._bscan_max_depth_m.setRange(0.1, 20.0) owner._bscan_max_depth_m.setSingleStep(0.1) - owner._bscan_max_depth_m.setValue(1.0) + owner._bscan_max_depth_m.setValue(float(bscan_defaults.max_depth_m)) owner._bscan_gain = QDoubleSpinBox() owner._bscan_gain.setDecimals(1) owner._bscan_gain.setRange(0.0, 3.0) owner._bscan_gain.setSingleStep(0.1) - owner._bscan_gain.setValue(1.0) + owner._bscan_gain.setValue(float(bscan_defaults.gain)) owner._bscan_start_freq_mhz = QDoubleSpinBox() owner._bscan_start_freq_mhz.setDecimals(1) owner._bscan_start_freq_mhz.setRange(100.0, 8800.0) owner._bscan_start_freq_mhz.setSingleStep(10.0) - owner._bscan_start_freq_mhz.setValue(100.0) + owner._bscan_start_freq_mhz.setValue(float(bscan_defaults.start_freq_mhz)) owner._bscan_stop_freq_mhz = QDoubleSpinBox() owner._bscan_stop_freq_mhz.setDecimals(1) owner._bscan_stop_freq_mhz.setRange(100.0, 8800.0) owner._bscan_stop_freq_mhz.setSingleStep(10.0) - owner._bscan_stop_freq_mhz.setValue(8800.0) + owner._bscan_stop_freq_mhz.setValue(float(bscan_defaults.stop_freq_mhz)) bscan_form.addRow("Axis", owner._bscan_axis) - bscan_form.addRow("Channel", owner._bscan_channel) bscan_form.addRow("Cut m", owner._bscan_cut_m) bscan_form.addRow("Max depth m", owner._bscan_max_depth_m) bscan_form.addRow("Gain", owner._bscan_gain) @@ -156,50 +135,73 @@ def build_processing_group(owner) -> QGroupBox: gpr_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) gpr_form = QFormLayout(gpr_page) gpr_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + gpr_defaults = owner._defaults_config.gpr - owner._gpr_input_positions_input = QLineEdit(_default_gpr_input_positions(owner)) + owner._gpr_config_mode = QComboBox() + owner._gpr_config_mode.addItems(["point", "extended"]) + owner._set_combo_current_text(owner._gpr_config_mode, gpr_defaults.mode) + + owner._gpr_relative_permittivity = QDoubleSpinBox() + owner._gpr_relative_permittivity.setDecimals(4) + owner._gpr_relative_permittivity.setRange(0.0001, 1000.0) + owner._gpr_relative_permittivity.setSingleStep(0.05) + owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity)) + + owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner)) + owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m") + owner._gpr_tx_geometry_input.setMinimumHeight(88) + + owner._gpr_rx_geometry_input = QPlainTextEdit(_format_rx_geometry(owner)) + owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m") + owner._gpr_rx_geometry_input.setMinimumHeight(120) + + owner._gpr_input_positions_input = QLineEdit(str(gpr_live_defaults.input_positions)) owner._gpr_input_positions_input.setPlaceholderText("0,1,2") - owner._gpr_output_positions_input = QLineEdit(_default_gpr_output_positions(owner)) + owner._gpr_output_positions_input = QLineEdit(str(gpr_live_defaults.output_positions)) owner._gpr_output_positions_input.setPlaceholderText("0,1") owner._gpr_min_depth_m = QDoubleSpinBox() owner._gpr_min_depth_m.setDecimals(2) owner._gpr_min_depth_m.setRange(0.0, 50.0) owner._gpr_min_depth_m.setSingleStep(0.1) - owner._gpr_min_depth_m.setValue(2.0) + owner._gpr_min_depth_m.setValue(float(gpr_live_defaults.min_depth_m)) owner._gpr_max_depth_m = QDoubleSpinBox() owner._gpr_max_depth_m.setDecimals(2) owner._gpr_max_depth_m.setRange(0.1, 50.0) owner._gpr_max_depth_m.setSingleStep(0.1) - owner._gpr_max_depth_m.setValue(14.0) + owner._gpr_max_depth_m.setValue(float(gpr_live_defaults.max_depth_m)) owner._gpr_comp_power = QDoubleSpinBox() owner._gpr_comp_power.setDecimals(3) owner._gpr_comp_power.setRange(0.0, 5.0) owner._gpr_comp_power.setSingleStep(0.05) - owner._gpr_comp_power.setValue(0.2) + owner._gpr_comp_power.setValue(float(gpr_live_defaults.comp_power)) owner._gpr_start_freq_mhz = QDoubleSpinBox() owner._gpr_start_freq_mhz.setDecimals(1) owner._gpr_start_freq_mhz.setRange(100.0, 8800.0) owner._gpr_start_freq_mhz.setSingleStep(10.0) - owner._gpr_start_freq_mhz.setValue(3000.0) + owner._gpr_start_freq_mhz.setValue(float(gpr_live_defaults.start_freq_mhz)) owner._gpr_stop_freq_mhz = QDoubleSpinBox() owner._gpr_stop_freq_mhz.setDecimals(1) owner._gpr_stop_freq_mhz.setRange(100.0, 8800.0) owner._gpr_stop_freq_mhz.setSingleStep(10.0) - owner._gpr_stop_freq_mhz.setValue(6000.0) + owner._gpr_stop_freq_mhz.setValue(float(gpr_live_defaults.stop_freq_mhz)) owner._gpr_background_subtract_enabled = QCheckBox("Subtract mean of previous collections") - owner._gpr_background_subtract_enabled.setChecked(True) + owner._gpr_background_subtract_enabled.setChecked(bool(gpr_live_defaults.background_subtract_enabled)) owner._gpr_background_mean_count = QSpinBox() owner._gpr_background_mean_count.setRange(0, 10_000) - owner._gpr_background_mean_count.setValue(10) + owner._gpr_background_mean_count.setValue(int(gpr_live_defaults.background_mean_count)) + gpr_form.addRow("Config mode", owner._gpr_config_mode) + gpr_form.addRow("Relative permittivity", owner._gpr_relative_permittivity) + gpr_form.addRow("Tx geometry", owner._gpr_tx_geometry_input) + gpr_form.addRow("Rx geometry", owner._gpr_rx_geometry_input) gpr_form.addRow("Input positions", owner._gpr_input_positions_input) gpr_form.addRow("Output positions", owner._gpr_output_positions_input) gpr_form.addRow("Min depth m", owner._gpr_min_depth_m) @@ -212,12 +214,9 @@ def build_processing_group(owner) -> QGroupBox: owner._processing_mode_pages.addWidget(gpr_page) owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed) - owner._processing_gain_db.valueChanged.connect(owner._on_processing_live_settings_changed) - owner._processing_phase_deg.valueChanged.connect(owner._on_processing_live_settings_changed) - owner._pass_through_channel.currentTextChanged.connect(owner._on_processing_live_settings_changed) owner._show_magnitude_checkbox.toggled.connect(owner._on_trace_visibility_changed) owner._show_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed) - owner._pass_through_fixed_y_enabled.toggled.connect(sync_pass_through_y_controls) + owner._pass_through_fixed_y_enabled.toggled.connect(owner._sync_pass_through_y_controls) owner._pass_through_fixed_y_enabled.toggled.connect(owner._on_processing_live_settings_changed) owner._pass_through_y_min_db.valueChanged.connect(owner._on_processing_live_settings_changed) owner._pass_through_y_max_db.valueChanged.connect(owner._on_processing_live_settings_changed) @@ -225,7 +224,6 @@ def build_processing_group(owner) -> QGroupBox: form.addRow(owner._processing_mode_pages) owner._bscan_axis.currentTextChanged.connect(owner._on_processing_live_settings_changed) - owner._bscan_channel.currentTextChanged.connect(owner._on_processing_live_settings_changed) owner._bscan_cut_m.valueChanged.connect(owner._on_processing_live_settings_changed) owner._bscan_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed) owner._bscan_gain.valueChanged.connect(owner._on_processing_live_settings_changed) diff --git a/python_app/gui/controllers/sections/radar_section.py b/python_app/gui/controllers/sections/radar_section.py index c651fca..48071a9 100644 --- a/python_app/gui/controllers/sections/radar_section.py +++ b/python_app/gui/controllers/sections/radar_section.py @@ -40,8 +40,6 @@ def build_radar_group(owner) -> QGroupBox: owner._radar_limits_hint = QLabel("Mock mode: device limits are not applied.") owner._radar_limits_hint.setObjectName("hintLabel") - form.addRow("Serial", owner._serial_input) - form.addRow("Mode", owner._radar_mode) form.addRow(owner._radar_start_label, owner._start_hz_input) form.addRow(owner._radar_stop_label, owner._stop_hz_input) form.addRow(owner._radar_points_label, owner._points_input) diff --git a/python_app/gui/controllers/sections/switch_section.py b/python_app/gui/controllers/sections/switch_section.py index fcc99f5..d0ce5ce 100644 --- a/python_app/gui/controllers/sections/switch_section.py +++ b/python_app/gui/controllers/sections/switch_section.py @@ -1,82 +1,68 @@ -"""Builder for switch and combo settings section.""" +"""Builder for switch timing and combo-selection settings section.""" from __future__ import annotations -from PyQt6.QtWidgets import QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLineEdit, QVBoxLayout - +from PyQt6.QtWidgets import ( + QButtonGroup, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, +) def build_switch_group(owner) -> QGroupBox: - """Create input/output switch controls and run combos settings.""" + """Create switch timing field and two explicit combo-selection modes.""" group = QGroupBox("Switches") layout = QVBoxLayout(group) - input_defaults = owner._defaults_config.input_switch - output_defaults = owner._defaults_config.output_switch + layout.setSpacing(8) + + switch_defaults = owner._gui_defaults.switches global_form = QFormLayout() global_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) owner._settling_ms = QLineEdit(str(owner._defaults_config.runtime.settling_ms)) - owner._combos_text = QLineEdit("") - owner._combos_text.setPlaceholderText("input:output,input:output or empty for full") global_form.addRow("Settling ms", owner._settling_ms) - global_form.addRow("Run combos", owner._combos_text) layout.addLayout(global_form) - switch_columns = QHBoxLayout() + owner._combos_text = QLineEdit(str(switch_defaults.combos_text)) + owner._combos_text.setPlaceholderText("empty = full matrix, or input:output,input:output") + owner._run_combos_select_button = QPushButton("Select") + owner._run_combos_select_button.setCheckable(True) - input_group = QGroupBox("Input Switch (Radar Port 2)") - input_form = QFormLayout(input_group) - input_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + combos_row = QHBoxLayout() + combos_row.setSpacing(8) + combos_row.addWidget(QLabel("Run combos")) + combos_row.addWidget(owner._combos_text, stretch=1) + combos_row.addWidget(owner._run_combos_select_button) + layout.addLayout(combos_row) - owner._input_mode = QComboBox() - owner._input_mode.addItems(["mock", "native"]) - owner._set_combo_current_text(owner._input_mode, input_defaults.driver_mode) - owner._input_driver = QComboBox() - owner._input_driver.addItems(["hmc349a", "h7992"]) - owner._set_combo_current_text(owner._input_driver, input_defaults.driver) - owner._input_positions = QLineEdit(str(input_defaults.positions)) - owner._input_gpio_chip = QLineEdit(input_defaults.gpio_chip) - owner._input_pin_a = QLineEdit(str(input_defaults.pin_a)) - owner._input_pin_b = QLineEdit(str(input_defaults.pin_b)) - owner._input_invert_logic = QComboBox() - owner._input_invert_logic.addItems(["false", "true"]) - owner._set_combo_current_text(owner._input_invert_logic, "true" if input_defaults.invert_logic else "false") + owner._single_combo_output = QLineEdit(str(switch_defaults.single_output)) + owner._single_combo_output.setPlaceholderText("0") + owner._single_combo_input = QLineEdit(str(switch_defaults.single_input)) + owner._single_combo_input.setPlaceholderText("0") + owner._single_combo_select_button = QPushButton("Select") + owner._single_combo_select_button.setCheckable(True) - input_form.addRow("Mode", owner._input_mode) - input_form.addRow("Driver", owner._input_driver) - input_form.addRow("Positions", owner._input_positions) - input_form.addRow("GPIO chip", owner._input_gpio_chip) - input_form.addRow("Pin A", owner._input_pin_a) - input_form.addRow("Pin B", owner._input_pin_b) - input_form.addRow("Invert logic", owner._input_invert_logic) + single_row = QHBoxLayout() + single_row.setSpacing(8) + single_row.addWidget(QLabel("Single combo")) + single_row.addWidget(QLabel("Output")) + single_row.addWidget(owner._single_combo_output) + single_row.addWidget(QLabel("Input")) + single_row.addWidget(owner._single_combo_input) + single_row.addWidget(owner._single_combo_select_button) + layout.addLayout(single_row) - output_group = QGroupBox("Output Switch (Radar Port 1)") - output_form = QFormLayout(output_group) - output_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + owner._combo_select_group = QButtonGroup(group) + owner._combo_select_group.setExclusive(True) + owner._combo_select_group.addButton(owner._run_combos_select_button) + owner._combo_select_group.addButton(owner._single_combo_select_button) - owner._output_mode = QComboBox() - owner._output_mode.addItems(["mock", "native"]) - owner._set_combo_current_text(owner._output_mode, output_defaults.driver_mode) - owner._output_driver = QComboBox() - owner._output_driver.addItems(["h7992", "hmc349a"]) - owner._set_combo_current_text(owner._output_driver, output_defaults.driver) - owner._output_positions = QLineEdit(str(output_defaults.positions)) - owner._output_gpio_chip = QLineEdit(output_defaults.gpio_chip) - owner._output_pin_a = QLineEdit(str(output_defaults.pin_a)) - owner._output_pin_b = QLineEdit(str(output_defaults.pin_b)) - owner._output_invert_logic = QComboBox() - owner._output_invert_logic.addItems(["false", "true"]) - owner._set_combo_current_text(owner._output_invert_logic, "true" if output_defaults.invert_logic else "false") - - output_form.addRow("Mode", owner._output_mode) - output_form.addRow("Driver", owner._output_driver) - output_form.addRow("Positions", owner._output_positions) - output_form.addRow("GPIO chip", owner._output_gpio_chip) - output_form.addRow("Pin A", owner._output_pin_a) - output_form.addRow("Pin B", owner._output_pin_b) - output_form.addRow("Invert logic", owner._output_invert_logic) - - switch_columns.addWidget(input_group) - switch_columns.addWidget(output_group) - layout.addLayout(switch_columns) + owner._run_combos_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("text")) + owner._single_combo_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("single")) + owner._set_combo_selection_mode(str(switch_defaults.combo_mode)) return group diff --git a/python_app/gui/preprocess_dialog.py b/python_app/gui/preprocess_dialog.py index a554202..eaf91cd 100644 --- a/python_app/gui/preprocess_dialog.py +++ b/python_app/gui/preprocess_dialog.py @@ -2,7 +2,7 @@ from __future__ import annotations -from PyQt6.QtCore import pyqtSignal +from PyQt6.QtCore import QSignalBlocker, pyqtSignal from PyQt6.QtWidgets import ( QComboBox, QDialog, @@ -23,10 +23,8 @@ import pyqtgraph as pg from python_app.models.dataset_model import TraceData from python_app.orchestration.preprocess_assets import ( - PREPROCESS_ASSET_KEYS, PREPROCESS_ASSET_SPECS, - S11_PREPROCESS_ASSET_KEYS, - S21_PREPROCESS_ASSET_KEYS, + VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_display_name, ) @@ -44,6 +42,10 @@ class PreprocessDialog(QDialog): """Initialize window metadata and compose dialog UI.""" super().__init__(parent) self._set_combos: dict[str, QComboBox] = {} + self._preview_plot: pg.PlotWidget | None = None + self._preview_placeholder: QLabel | None = None + self._preview_host_layout: QVBoxLayout | None = None + self._preview_plot_unavailable = False self._init_window() self._build_ui() @@ -84,8 +86,7 @@ class PreprocessDialog(QDialog): header_row.addWidget(refresh_button) layout.addLayout(header_row) - layout.addWidget(self._build_selector_group("S21", S21_PREPROCESS_ASSET_KEYS, group)) - layout.addWidget(self._build_selector_group("S11", S11_PREPROCESS_ASSET_KEYS, group)) + layout.addWidget(self._build_selector_group("S21", VISIBLE_PREPROCESS_ASSET_KEYS, group)) return group def _build_selector_group(self, title: str, keys: tuple[str, ...], parent: QGroupBox) -> QGroupBox: @@ -110,35 +111,26 @@ class PreprocessDialog(QDialog): self._progress_label = QLabel("0 / 0", group) self._combo_label = QLabel("", group) - self._tx_antenna_label_input = QLineEdit(group) - self._rx_antenna_label_input = QLineEdit(group) - self._tx_antenna_label_input.setPlaceholderText("e.g. TX_A") - self._rx_antenna_label_input.setPlaceholderText("e.g. RX_B") - layout.addWidget(QLabel("Active type"), 0, 0) layout.addWidget(self._active_kind_label, 0, 1) layout.addWidget(QLabel("Progress"), 1, 0) layout.addWidget(self._progress_label, 1, 1) layout.addWidget(QLabel("Current combo"), 2, 0) layout.addWidget(self._combo_label, 2, 1) - layout.addWidget(QLabel("TX antenna label"), 3, 0) - layout.addWidget(self._tx_antenna_label_input, 3, 1) - layout.addWidget(QLabel("RX antenna label"), 4, 0) - layout.addWidget(self._rx_antenna_label_input, 4, 1) - layout.addLayout(self._build_sequence_button_grid(group), 5, 0, 1, 2) - layout.addLayout(self._build_sequence_action_row(group), 6, 0, 1, 2) + layout.addLayout(self._build_sequence_button_grid(group), 3, 0, 1, 2) + layout.addLayout(self._build_sequence_action_row(group), 4, 0, 1, 2) self._capture_log = QPlainTextEdit(group) self._capture_log.setReadOnly(True) self._capture_log.setPlaceholderText("Capture history per combo") self._capture_log.setMinimumHeight(180) - layout.addWidget(self._capture_log, 7, 0, 1, 2) + layout.addWidget(self._capture_log, 5, 0, 1, 2) return group def _build_sequence_button_grid(self, parent: QGroupBox) -> QGridLayout: """Build per-asset capture start buttons.""" layout = QGridLayout() - for index, key in enumerate(PREPROCESS_ASSET_KEYS): + for index, key in enumerate(VISIBLE_PREPROCESS_ASSET_KEYS): button = QPushButton(f"Start {preprocess_asset_display_name(key)}", parent) button.clicked.connect(lambda _checked=False, asset_key=key: self.start_sequence_requested.emit(asset_key)) layout.addWidget(button, index // 2, index % 2) @@ -166,25 +158,32 @@ class PreprocessDialog(QDialog): root_layout.addWidget(self._status_label) def _build_preview_plot(self, root_layout: QVBoxLayout) -> None: - """Build trace preview plot used after each successful capture.""" - self._preview_plot = pg.PlotWidget(background="#101418") - self._preview_plot.showGrid(x=True, y=True, alpha=0.2) - self._preview_plot.setLabel("bottom", "Frequency", units="Hz") - self._preview_plot.setLabel("left", "Magnitude", units="dB") - self._preview_plot.setMinimumHeight(320) - root_layout.addWidget(self._preview_plot) + """Build lazy preview host used after each successful capture.""" + host = QWidget(self) + layout = QVBoxLayout(host) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + placeholder = QLabel("Preview will appear after the first successful capture.", host) + placeholder.setWordWrap(True) + placeholder.setMinimumHeight(320) + layout.addWidget(placeholder) + + self._preview_host_layout = layout + self._preview_placeholder = placeholder + root_layout.addWidget(host) def set_name(self) -> str: """Return requested target set name.""" return self._set_name_input.text().strip() + def set_set_name(self, value: str) -> None: + """Replace requested target set name.""" + self._set_name_input.setText(value) + def selection_snapshot(self) -> dict[str, str]: """Return currently selected set names keyed by preprocess asset key.""" - return {key: self._set_combos[key].currentText().strip() for key in PREPROCESS_ASSET_KEYS} - - def antenna_labels(self) -> tuple[str, str]: - """Return optional TX/RX user labels used in capture logs.""" - return self._tx_antenna_label_input.text().strip(), self._rx_antenna_label_input.text().strip() + return {key: self._set_combos[key].currentText().strip() for key in VISIBLE_PREPROCESS_ASSET_KEYS} def clear_capture_log(self) -> None: """Clear capture history text box.""" @@ -198,16 +197,11 @@ class PreprocessDialog(QDialog): total_count: int, input_pos: int, output_pos: int, - tx_label: str, - rx_label: str, ) -> None: """Append one capture progress row to dialog log.""" - tx_info = tx_label or "-" - rx_info = rx_label or "-" self._capture_log.appendPlainText( f"{kind}: {captured_count}/{total_count} | " - f"input={input_pos} output={output_pos} | " - f"TX={tx_info} RX={rx_info}" + f"input={input_pos} output={output_pos}" ) def set_capture_state( @@ -242,21 +236,26 @@ class PreprocessDialog(QDialog): def set_available_sets(self, available_sets: dict[str, list[str]]) -> None: """Replace combo-box choices for all preprocess assets.""" - for key in PREPROCESS_ASSET_KEYS: + for key in VISIBLE_PREPROCESS_ASSET_KEYS: combo = self._set_combos[key] - self._set_combo_items(combo, available_sets.get(key, []), combo.currentText().strip()) + with QSignalBlocker(combo): + self._set_combo_items(combo, available_sets.get(key, []), combo.currentText().strip()) - def set_selected_sets(self, selected_sets: dict[str, str]) -> None: - """Apply selected set names to all comboboxes and emit selection update.""" - for key in PREPROCESS_ASSET_KEYS: + def set_selected_sets(self, selected_sets: dict[str, str], *, emit_signal: bool = True) -> None: + """Apply selected set names to all comboboxes and optionally emit update.""" + for key in VISIBLE_PREPROCESS_ASSET_KEYS: selected_value = selected_sets.get(key, "") if not selected_value: continue combo = self._set_combos[key] - index = combo.findText(selected_value) - if index >= 0: + with QSignalBlocker(combo): + index = combo.findText(selected_value) + if index < 0: + combo.addItem(selected_value) + index = combo.findText(selected_value) combo.setCurrentIndex(index) - self._emit_selection_changed() + if emit_signal: + self._emit_selection_changed() def set_status(self, message: str) -> None: """Set short human-readable status line.""" @@ -266,17 +265,54 @@ class PreprocessDialog(QDialog): """Draw the latest captured sweep trace for the requested channel in dB scale.""" samples = trace.s11 if channel == "s11" else trace.s21 magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12)) - self._preview_plot.clear() - self._preview_plot.plot( - trace.frequency_hz, - magnitude_db, - pen=pg.mkPen("#4cc9f0", width=1.8), - ) + if self._ensure_preview_plot(): + assert self._preview_plot is not None + self._preview_plot.clear() + self._preview_plot.plot( + trace.frequency_hz, + magnitude_db, + pen=pg.mkPen("#4cc9f0", width=1.8), + ) + elif self._preview_placeholder is not None: + self._preview_placeholder.setText( + f"{title}\n" + f"input={trace.combo.input_pos}, output={trace.combo.output_pos}, " + f"points={trace.frequency_hz.size}\n" + f"Preview plot is unavailable on this PyQtGraph/PyQt6 build." + ) combo = trace.combo self._status_label.setText( f"{title}: input={combo.input_pos}, output={combo.output_pos}, points={trace.frequency_hz.size}" ) + def _ensure_preview_plot(self) -> bool: + """Create preview plot lazily and keep a text fallback when unavailable.""" + if self._preview_plot is not None: + return True + if self._preview_plot_unavailable: + return False + if self._preview_host_layout is None: + return False + + try: + plot = pg.PlotWidget(background="#101418", enableMenu=False) + plot.showGrid(x=True, y=True, alpha=0.2) + plot.setLabel("bottom", "Frequency", units="Hz") + plot.setLabel("left", "Magnitude", units="dB") + plot.setMinimumHeight(320) + except Exception: + self._preview_plot_unavailable = True + return False + + if self._preview_placeholder is not None: + self._preview_host_layout.removeWidget(self._preview_placeholder) + self._preview_placeholder.deleteLater() + self._preview_placeholder = None + + self._preview_plot = plot + self._preview_host_layout.addWidget(plot) + return True + def _emit_selection_changed(self) -> None: """Emit current selection snapshot change.""" self.selection_changed.emit() @@ -294,5 +330,7 @@ class PreprocessDialog(QDialog): if not current_text: return index = combo.findText(current_text) - if index >= 0: - combo.setCurrentIndex(index) + if index < 0: + combo.addItem(current_text) + index = combo.findText(current_text) + combo.setCurrentIndex(index) diff --git a/python_app/gui/theme.py b/python_app/gui/theme.py index e27c5ff..34b630f 100644 --- a/python_app/gui/theme.py +++ b/python_app/gui/theme.py @@ -47,6 +47,16 @@ QPushButton:pressed { background-color: #1a2432; } +QPushButton:checked { + background-color: #2f7ee6; + border-color: #5d88bd; + color: #ffffff; +} + +QPushButton:checked:hover { + background-color: #3c89ee; +} + QPushButton:disabled { color: #6b7d95; background-color: #151d27; @@ -63,6 +73,7 @@ QPushButton#settingsToggleButton { QLineEdit, QPlainTextEdit, +QTextEdit, QComboBox, QSpinBox, QDoubleSpinBox { @@ -75,12 +86,19 @@ QDoubleSpinBox { QLineEdit:focus, QPlainTextEdit:focus, +QTextEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid #5d88bd; } +QTextEdit#runtimeLogBox { + font-family: "DejaVu Sans Mono"; + font-size: 12px; + padding: 6px 8px; +} + QComboBox::drop-down { border: none; width: 18px; diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py new file mode 100644 index 0000000..3a72af8 --- /dev/null +++ b/python_app/models/gui_profile_codec.py @@ -0,0 +1,305 @@ +"""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", + ), + 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", + ), + ), + ) + 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") + + 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, + "background_subtract_enabled": gui.processing.gpr.background_subtract_enabled, + "background_mean_count": gui.processing.gpr.background_mean_count, + }, + }, + "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 + diff --git a/python_app/models/gui_profile_model.py b/python_app/models/gui_profile_model.py new file mode 100644 index 0000000..5dbcb44 --- /dev/null +++ b/python_app/models/gui_profile_model.py @@ -0,0 +1,28 @@ +"""Facade module for GUI profile schema and codec helpers.""" + +from python_app.models.gui_profile_codec import gui_profile_from_dict, gui_profile_to_dict +from python_app.models.gui_profile_schema import ( + GuiBscanStateModel, + GuiDataActionsStateModel, + GuiGprStateModel, + GuiPassThroughStateModel, + GuiPreprocessDialogStateModel, + GuiProcessingStateModel, + GuiProfileModel, + GuiStateModel, + GuiSwitchStateModel, +) + +__all__ = [ + "GuiBscanStateModel", + "GuiDataActionsStateModel", + "GuiGprStateModel", + "GuiPassThroughStateModel", + "GuiPreprocessDialogStateModel", + "GuiProcessingStateModel", + "GuiProfileModel", + "GuiStateModel", + "GuiSwitchStateModel", + "gui_profile_from_dict", + "gui_profile_to_dict", +] diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py new file mode 100644 index 0000000..630fcf5 --- /dev/null +++ b/python_app/models/gui_profile_schema.py @@ -0,0 +1,129 @@ +"""Dataclass schema for full GUI config profiles.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +import json +from pathlib import Path +from typing import Any + +from python_app.models.run_config_model import RunConfigModel + + +@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 + 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 + + +@dataclass(slots=True) +class GuiGprStateModel: + """UI-only defaults for GPR live settings.""" + + 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 + background_subtract_enabled: bool = True + background_mean_count: int = 10 + + +@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) + + +@dataclass(slots=True) +class GuiDataActionsStateModel: + """UI-only defaults for snapshot/export controls.""" + + save_count: int = 10 + save_path: str = "" + save_name: str = "snapshot_manual" + + +@dataclass(slots=True) +class GuiPreprocessDialogStateModel: + """UI-only defaults for preprocessing dialog controls.""" + + set_name: str = "set_001" + + +@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 JSON file from disk and decode into profile model.""" + 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) diff --git a/python_app/orchestration/config_writer.py b/python_app/orchestration/config_writer.py index 242b929..edd35aa 100644 --- a/python_app/orchestration/config_writer.py +++ b/python_app/orchestration/config_writer.py @@ -6,7 +6,12 @@ import json from pathlib import Path from python_app.models.run_config_model import RunConfigModel, parse_combos_from_text -from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, PREPROCESS_ASSET_SPECS, preprocess_asset_model +from python_app.orchestration.preprocess_assets import ( + PREPROCESS_ASSET_KEYS, + PREPROCESS_ASSET_SPECS, + preprocess_asset_model, + runtime_preprocess_asset_keys, +) from python_app.storage.npz_store import NpzStore @@ -26,6 +31,9 @@ class ConfigWriter: ) -> None: """Export selected preprocess sets into runtime bundles and update config paths.""" for key in PREPROCESS_ASSET_KEYS: + preprocess_asset_model(config, key).bundle_path = "" + + for key in runtime_preprocess_asset_keys(config): spec = PREPROCESS_ASSET_SPECS[key] asset = preprocess_asset_model(config, key) bundle_path = self._runtime_dir / spec.runtime_filename diff --git a/python_app/orchestration/gui_session_state.py b/python_app/orchestration/gui_session_state.py new file mode 100644 index 0000000..12377a2 --- /dev/null +++ b/python_app/orchestration/gui_session_state.py @@ -0,0 +1,52 @@ +"""Persistent session-state helpers for GUI-only runtime preferences.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path + + +@dataclass(slots=True) +class GuiSessionState: + """Small persisted GUI session state.""" + + last_profile_path: str = "" + + +class GuiSessionStateStore: + """Atomic JSON store for GUI session-state file.""" + + def __init__(self, path: Path) -> None: + """Create store targeting `path`.""" + self._path = path + self._path.parent.mkdir(parents=True, exist_ok=True) + + @property + def path(self) -> Path: + """Return backing session-state file path.""" + return self._path + + def load(self) -> GuiSessionState: + """Load session-state from disk or return empty defaults when missing.""" + if not self._path.exists(): + return GuiSessionState() + + payload = json.loads(self._path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"GUI session-state root must be JSON object: {self._path}") + + raw_path = payload.get("last_profile_path", "") + if not isinstance(raw_path, str): + raise ValueError("GUI session-state `last_profile_path` must be a string") + return GuiSessionState(last_profile_path=raw_path) + + def write(self, state: GuiSessionState) -> Path: + """Atomically write session-state JSON file.""" + temp_path = self._path.with_suffix(self._path.suffix + ".tmp") + temp_path.write_text( + json.dumps({"last_profile_path": state.last_profile_path}, indent=2), + encoding="utf-8", + ) + temp_path.replace(self._path) + return self._path diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 0902368..805034f 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -12,8 +12,6 @@ class ProcessingLiveConfig: """Runtime-adjustable processing parameters shared with data processor.""" processor_mode: str = "pass_through" - gain_db: float = 0.0 - phase_deg: float = 0.0 pass_through_channel: str = "s21" pass_through_fixed_y_enabled: bool = False pass_through_y_min_db: float = -100.0 @@ -52,8 +50,6 @@ class ProcessingLiveConfig: """Convert live config to JSON-serializable dictionary.""" return { "processor_mode": str(self.processor_mode), - "gain_db": float(self.gain_db), - "phase_deg": float(self.phase_deg), "pass_through_channel": str(self.pass_through_channel), "pass_through_fixed_y_enabled": bool(self.pass_through_fixed_y_enabled), "pass_through_y_min_db": float(self.pass_through_y_min_db), diff --git a/python_app/orchestration/preprocess_assets.py b/python_app/orchestration/preprocess_assets.py index b0d3727..09ed818 100644 --- a/python_app/orchestration/preprocess_assets.py +++ b/python_app/orchestration/preprocess_assets.py @@ -66,6 +66,9 @@ PREPROCESS_ASSET_SPECS = { PREPROCESS_ASSET_KEYS = tuple(PREPROCESS_ASSET_SPECS.keys()) S21_PREPROCESS_ASSET_KEYS = ("s21_calibration", "s21_reference") S11_PREPROCESS_ASSET_KEYS = ("s11_open", "s11_short", "s11_load", "s11_reference") +S11_PREPROCESS_CALIBRATION_KEYS = ("s11_open", "s11_short", "s11_load") +VISIBLE_PREPROCESS_ASSET_KEYS = S21_PREPROCESS_ASSET_KEYS +REQUIRED_PREPROCESS_ASSET_KEYS = S21_PREPROCESS_ASSET_KEYS def preprocess_asset_model(config: RunConfigModel, key: str) -> PreprocessAssetModel: @@ -93,3 +96,25 @@ def preprocess_asset_display_name(key: str) -> str: def preprocess_asset_channel(key: str) -> str: """Return associated trace channel for preprocess asset.""" return PREPROCESS_ASSET_SPECS[key].channel + + +def preprocess_asset_set_name(config: RunConfigModel, key: str) -> str: + """Return normalized selected set name for one preprocess asset.""" + return str(preprocess_asset_model(config, key).set_name).strip() + + +def runtime_preprocess_asset_keys(config: RunConfigModel) -> tuple[str, ...]: + """Return preprocess assets that should be exported and validated for runtime.""" + enabled_keys = list(REQUIRED_PREPROCESS_ASSET_KEYS) + + has_full_s11_calibration = all( + preprocess_asset_set_name(config, key) + for key in S11_PREPROCESS_CALIBRATION_KEYS + ) + if not has_full_s11_calibration: + return tuple(enabled_keys) + + enabled_keys.extend(S11_PREPROCESS_CALIBRATION_KEYS) + if preprocess_asset_set_name(config, "s11_reference"): + enabled_keys.append("s11_reference") + return tuple(enabled_keys) diff --git a/python_app/orchestration/process_supervisor.py b/python_app/orchestration/process_supervisor.py index 96dd07e..fe262b0 100644 --- a/python_app/orchestration/process_supervisor.py +++ b/python_app/orchestration/process_supervisor.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path +import shlex import subprocess import time from typing import Iterable @@ -16,9 +17,50 @@ class ManagedProcess: name: str command: list[str] + allow_clean_exit: bool handle: subprocess.Popen[str] +@dataclass(slots=True) +class ProcessExitReport: + """Structured report for one exited managed process.""" + + name: str + command: list[str] + working_directory: Path + return_code: int + stdout: str + stderr: str + expected_clean_exit: bool + + @property + def level(self) -> str: + """Return log level appropriate for this exit report.""" + return "INFO" if self.expected_clean_exit else "ERROR" + + def format(self) -> str: + """Render human-readable multiline exit report.""" + if self.expected_clean_exit: + headline = f"Process `{self.name}` completed normally with code {self.return_code}." + elif self.return_code == 0: + headline = f"Process `{self.name}` exited unexpectedly with code 0." + else: + headline = f"Process `{self.name}` exited with code {self.return_code}." + + lines = [ + headline, + f"Command: {shlex.join(self.command)}", + f"Working directory: {self.working_directory}", + ] + if self.stderr: + lines.append(f"stderr:\n{self.stderr}") + if self.stdout: + lines.append(f"stdout:\n{self.stdout}") + if not self.stderr and not self.stdout: + lines.append("stdout/stderr: none") + return "\n".join(lines) + + class ProcessSupervisor: """Start, monitor, and stop pipeline subprocesses.""" @@ -36,7 +78,7 @@ class ProcessSupervisor: """Return whether data processor process is alive.""" return self._is_alive("data_processor") - def start(self, config_path: Path) -> None: + def start(self, config_path: Path, *, allow_clean_orchestrator_exit: bool = False) -> None: """Start required pipeline binaries and wait until they are ready.""" if self.is_running(): raise RuntimeError("Acquisition processes are already running") @@ -59,16 +101,22 @@ class ProcessSupervisor: ], } processor_was_running = self.is_processor_running() - required_processes: list[str] = ["data_preprocessor", "sweep_orchestrator"] + required_processes: list[str] = ["data_preprocessor"] + if not allow_clean_orchestrator_exit: + required_processes.append("sweep_orchestrator") if not processor_was_running: required_processes.insert(0, "data_processor") try: if not processor_was_running: - self._spawn("data_processor", command_specs["data_processor"]) + self._spawn("data_processor", command_specs["data_processor"], allow_clean_exit=False) - self._spawn("data_preprocessor", command_specs["data_preprocessor"]) - self._spawn("sweep_orchestrator", command_specs["sweep_orchestrator"]) + self._spawn("data_preprocessor", command_specs["data_preprocessor"], allow_clean_exit=False) + self._spawn( + "sweep_orchestrator", + command_specs["sweep_orchestrator"], + allow_clean_exit=allow_clean_orchestrator_exit, + ) self._wait_until_ready(required_processes) except Exception: if processor_was_running: @@ -93,20 +141,32 @@ class ProcessSupervisor: """Stop all managed processes.""" self._stop_processes(["sweep_orchestrator", "data_preprocessor", "data_processor"]) - def _spawn(self, name: str, command: list[str]) -> None: + def _spawn(self, name: str, command: list[str], *, allow_clean_exit: bool) -> None: """Spawn one process unless same process is already alive.""" existing = self._processes.get(name) if existing is not None and existing.handle.poll() is None: return - handle = subprocess.Popen( - command, - cwd=self._project_root, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, + try: + handle = subprocess.Popen( + command, + cwd=self._project_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except OSError as exc: + command_text = shlex.join(command) + raise RuntimeError( + f"Failed to spawn {name} with command `{command_text}` from `{self._project_root}`: " + f"{type(exc).__name__}: {exc}" + ) from exc + self._processes[name] = ManagedProcess( + name=name, + command=command, + allow_clean_exit=allow_clean_exit, + handle=handle, ) - self._processes[name] = ManagedProcess(name=name, command=command, handle=handle) def _stop_processes(self, names: Iterable[str]) -> None: """Gracefully terminate processes, then force-kill on timeout.""" @@ -148,9 +208,9 @@ class ProcessSupervisor: return False return process.handle.poll() is None - def collect_crash_reports(self) -> list[str]: - """Collect stderr/stdout reports from processes that have exited.""" - reports: list[str] = [] + def collect_exit_reports(self) -> list[ProcessExitReport]: + """Collect reports for managed processes that have exited.""" + reports: list[ProcessExitReport] = [] exited_names: list[str] = [] for name, process in self._processes.items(): @@ -165,13 +225,17 @@ class ProcessSupervisor: if process.handle.stderr is not None: stderr = process.handle.stderr.read().strip() - details = stderr - if stdout and stderr: - details = f"{stderr}\nstdout:\n{stdout}" - elif stdout: - details = f"stdout:\n{stdout}" - - reports.append(f"{process.name} exited with code {return_code}: {details}") + reports.append( + ProcessExitReport( + name=process.name, + command=list(process.command), + working_directory=self._project_root, + return_code=int(return_code), + stdout=stdout, + stderr=stderr, + expected_clean_exit=bool(process.allow_clean_exit and int(return_code) == 0), + ) + ) exited_names.append(name) for name in exited_names: @@ -183,15 +247,19 @@ class ProcessSupervisor: deadline = time.monotonic() + self._readiness_timeout_s while time.monotonic() < deadline: - crashed = self.collect_crash_reports() - if crashed: - raise RuntimeError("; ".join(crashed)) + exit_reports = self.collect_exit_reports() + unexpected_reports = [report for report in exit_reports if not report.expected_clean_exit] + if unexpected_reports: + raise RuntimeError("; ".join(report.format() for report in unexpected_reports)) if all(self._is_alive(process_name) for process_name in required_processes): return time.sleep(0.05) names = ", ".join(required_processes) - raise RuntimeError(f"Timed out waiting for processes to start: {names}") + raise RuntimeError( + f"Timed out waiting for processes to start: {names}. " + f"Alive processes: {self.pids() or 'none'}" + ) def pids(self) -> dict[str, int]: """Return PID mapping for currently alive managed processes.""" diff --git a/python_app/scripts/check_snapshot_numpy.py b/python_app/scripts/check_snapshot_numpy.py index 2da91c7..c5fec86 100644 --- a/python_app/scripts/check_snapshot_numpy.py +++ b/python_app/scripts/check_snapshot_numpy.py @@ -240,18 +240,11 @@ def _plot_all_states_for_one_collection(stage: str, collection_dir: Path, output def _run(snapshot_dir: Path, output_dir: Path) -> None: """Execute snapshot validation and plotting workflow.""" - manifest_path = snapshot_dir / "manifest.json" - if manifest_path.exists(): - manifest = _load_json(manifest_path) - print( - f"Snapshot: {snapshot_dir}\n" - f"selection_mode={manifest.get('selection_mode')} " - f"raw={manifest.get('raw_collections')} " - f"pre={manifest.get('preprocessed_collections')} " - f"res={manifest.get('result_collections')}" - ) + config_profile_path = snapshot_dir / "config_profile.json" + if config_profile_path.exists(): + print(f"Snapshot: {snapshot_dir}\nconfig_profile={config_profile_path.name}") else: - print(f"Snapshot: {snapshot_dir} (manifest.json is missing)") + print(f"Snapshot: {snapshot_dir}") stages = ("raw", "preprocessed", "results") for stage in stages: diff --git a/python_app/scripts/convert_snapshot_to_vna_history.py b/python_app/scripts/convert_snapshot_to_vna_history.py index 984ec74..d07ea23 100644 --- a/python_app/scripts/convert_snapshot_to_vna_history.py +++ b/python_app/scripts/convert_snapshot_to_vna_history.py @@ -337,9 +337,6 @@ def main() -> None: result_refs = _load_stage_refs(snapshot_dir, "results") alignment_warning = _stage_alignment_warning(pre_refs, result_refs) - manifest_path = snapshot_dir / "manifest.json" - manifest = _load_json(manifest_path) if manifest_path.exists() else {} - payload: dict[str, Any] = { "format": "vna-system-history-v1", "converter": "python_app/scripts/convert_snapshot_to_vna_history.py", @@ -355,8 +352,6 @@ def main() -> None: } if alignment_warning is not None: payload["alignment_warning"] = alignment_warning - if manifest: - payload["snapshot_manifest"] = manifest output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/python_app/storage/npz/store.py b/python_app/storage/npz/store.py index f5acd5e..c323b6d 100644 --- a/python_app/storage/npz/store.py +++ b/python_app/storage/npz/store.py @@ -176,30 +176,6 @@ class NpzStore(StoreApi): save_trace_history_numpy(snapshot_dir / "preprocessed", selected_preprocessed) save_result_history_numpy(snapshot_dir / "results", selected_results) - (snapshot_dir / "manifest.json").write_text( - json.dumps( - { - "format": "numpy-directory-v1", - "selection_mode": selection_summary["selection_mode"], - "anchor_stage": selection_summary.get("anchor_stage", "unknown"), - "selected_collection_ids": selection_summary["selected_collection_ids"], - "aligned_key_count": int(selection_summary.get("aligned_key_count", 0)), - "raw_missing_count": int(selection_summary.get("raw_missing_count", 0)), - "preprocessed_missing_count": int(selection_summary.get("preprocessed_missing_count", 0)), - "result_missing_count": int(selection_summary.get("result_missing_count", 0)), - "raw_collections": len(selected_raw), - "preprocessed_collections": len(selected_preprocessed), - "result_collections": len(selected_results), - "last_n_requested": int(last_n), - "raw_history_size": len(raw_history), - "preprocessed_history_size": len(preprocessed_history), - "result_history_size": len(result_history), - }, - indent=2, - ), - encoding="utf-8", - ) - if selection_summary.get("anchor_stage") == "results": expected = min(int(last_n), len(result_history)) if len(selected_results) != expected: @@ -211,6 +187,7 @@ class NpzStore(StoreApi): selection_summary["raw_count"] = len(selected_raw) selection_summary["preprocessed_count"] = len(selected_preprocessed) selection_summary["result_count"] = len(selected_results) + selection_summary["snapshot_stem"] = snapshot_stem selection_summary["snapshot_dir"] = str(snapshot_dir) return snapshot_dir, selection_summary @@ -271,9 +248,87 @@ class NpzStore(StoreApi): summary["output_index"] = int(output_index) summary["channel"] = str(channel) summary["primary_stage"] = str(primary_stage) + summary["output_stem"] = output_stem summary["output_path"] = str(output_path) return output_path, summary + def save_runtime_vna_history_json_batch( + self, + output_root_dir: Path, + output_name: str, + raw_history: list[SweepCollection], + preprocessed_history: list[SweepCollection], + result_history: list[ResultCollection], + last_n: int, + *, + channel: str = "s21", + primary_stage: str = "preprocessed", + ) -> tuple[list[Path], dict[str, Any]]: + """Save one runtime VNA-history JSON per available combo.""" + if last_n <= 0: + raise ValueError("last_n must be > 0") + + output_stem = output_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S") + output_stem = sanitize_path_component(output_stem) + output_root_dir.mkdir(parents=True, exist_ok=True) + + selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories( + raw_history, + preprocessed_history, + result_history, + last_n, + ) + + combos = sorted( + { + (int(trace.combo.input_pos), int(trace.combo.output_pos)) + for collection in [*selected_raw, *selected_preprocessed] + for trace in collection.traces + } + ) + if not combos: + raise ValueError("No matching raw/preprocessed traces were found in runtime history for any combo.") + + output_paths: list[Path] = [] + payloads: list[dict[str, Any]] = [] + for input_index, output_index in combos: + output_path = output_root_dir / ( + f"{output_stem}_i{input_index}_o{output_index}_{channel}_vna_bscan_history.json" + ) + if output_path.exists(): + raise FileExistsError(f"Output JSON file already exists: {output_path}") + payloads.append( + build_vna_history_payload( + selected_raw, + selected_preprocessed, + selected_results, + input_index=input_index, + output_index=output_index, + channel=channel, + primary_stage=primary_stage, + ) + ) + output_paths.append(output_path) + + for output_path, payload in zip(output_paths, payloads, strict=True): + output_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + summary: dict[str, Any] = dict(selection_summary) + summary["raw_count"] = len(selected_raw) + summary["preprocessed_count"] = len(selected_preprocessed) + summary["result_count"] = len(selected_results) + summary["preprocessed_record_count"] = int(sum(int(payload.get("preprocessed_record_count", 0)) for payload in payloads)) + summary["combo_count"] = len(combos) + summary["combos"] = [list(combo) for combo in combos] + summary["channel"] = str(channel) + summary["primary_stage"] = str(primary_stage) + summary["output_stem"] = output_stem + summary["output_paths"] = [str(path) for path in output_paths] + return output_paths, summary + def _set_dir(self, kind: str, radar_key: str) -> Path: """Return directory for set kind and radar key.""" return self._root_dir / kind / radar_key