UI updates

This commit is contained in:
Ayzen
2026-04-01 20:21:16 +03:00
parent 4abc95c372
commit 669205d8f8
43 changed files with 2055 additions and 973 deletions
@@ -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;
@@ -96,18 +96,6 @@ using Json = nlohmann::json;
}
config.processor_mode = found->get<std::string>();
}
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<float>(found->get<double>());
}
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<float>(found->get<double>());
}
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");
@@ -1,13 +1,6 @@
#include "passthrough_processor.hpp"
#include <cmath>
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));
-22
View File
@@ -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`.
-46
View File
@@ -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.
-43
View File
@@ -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.
-35
View File
@@ -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()`.
-50
View File
@@ -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.
-49
View File
@@ -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 <snapshot_dir>
```
## 6. Конвертация snapshot для vna_system
```bash
python3 python_app/scripts/convert_snapshot_to_vna_history.py \
<snapshot_dir> \
-o <output_json> \
--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.
@@ -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.
-39
View File
@@ -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()` и лог.
@@ -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/<ring_name_without_slash>`.
## 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.
-41
View File
@@ -1,41 +0,0 @@
# Storage formats
## 1. Calibration/reference sets
Хранятся в `python_app/data/{calibration|reference}/{radar_key}`:
- `<set_name>.npz`
- `<set_name>.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/<collection_dir>/...`
- `preprocessed/<collection_dir>/...`
- `results/<collection_dir>/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 для одной коллекции.
+252 -14
View File
@@ -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", "<br>")
@staticmethod
def _exception_summary(exc: Exception) -> str:
"""Build compact one-line exception summary."""
message = str(exc).strip()
if message:
return f"{type(exc).__name__}: {message}"
return type(exc).__name__
@staticmethod
def _exception_details(exc: Exception) -> str:
"""Return full chained traceback for error dialogs and log details."""
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
def _append_log_entry(
self,
level: str,
text: str,
*,
details: str | None = None,
once_key: str | None = None,
) -> None:
"""Append formatted log entry with timestamp and optional details."""
if once_key is not None:
if once_key in self._logged_once_keys:
return
self._logged_once_keys.add(once_key)
level_upper = level.upper()
palette = {
"INFO": ("#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"<span style='color:{accent_color}; font-weight:700;'>{html.escape(level_upper)}</span>"
f" <span style='color:#7f94af;'>{html.escape(timestamp)}</span>"
f" <span style='color:{message_color};'>{self._escape_log_text(text)}</span>"
)
body_parts = [header_html]
if details:
body_parts.append(
"<pre style='margin:3px 0 0 16px; color:"
f"{detail_color};'>{html.escape(details)}</pre>"
)
entry_html = "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
cursor = self._log_box.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self._log_box.setTextCursor(cursor)
self._log_box.insertHtml(entry_html)
self._log_box.insertPlainText("\n")
self._log_box.ensureCursorVisible()
if level_upper == "ERROR" and hasattr(self, "_status_label"):
self._status_label.setText("Status: error")
def _log(self, text: str, *, once_key: str | None = None) -> None:
"""Append informational message to runtime log panel."""
self._append_log_entry("INFO", text, once_key=once_key)
def _log_warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
"""Append warning message to runtime log panel."""
self._append_log_entry("WARN", text, details=details, once_key=once_key)
def _log_error(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
"""Append error message to runtime log panel."""
self._append_log_entry("ERROR", text, details=details, once_key=once_key)
def _log_exception(self, context: str, exc: Exception, *, level: str = "ERROR") -> tuple[str, str]:
"""Log exception with detailed traceback and return `(message, details)`."""
message = f"{context}: {self._exception_summary(exc)}"
details = self._exception_details(exc)
if level.upper() == "WARN":
self._log_warning(message, details=details)
else:
self._log_error(message, details=details)
return message, details
def _process_state_details(self) -> str:
"""Return formatted summary of managed pipeline process state."""
if not hasattr(self, "_supervisor"):
return "Managed processes: unavailable"
pid_map = self._supervisor.pids()
if not pid_map:
return "Managed processes: none"
return "Managed processes:\n" + "\n".join(
f"- {name}: pid={pid}"
for name, pid in sorted(pid_map.items())
)
def _runtime_history_details(self) -> str:
"""Return formatted summary of buffered runtime history counts."""
return (
"Runtime history:\n"
f"- raw={len(getattr(self, '_raw_history', []))}\n"
f"- preprocessed={len(getattr(self, '_pre_history', []))}\n"
f"- results={len(getattr(self, '_result_history', []))}"
)
def _capture_state_details(self) -> str:
"""Return formatted summary of active preprocess capture state."""
session = getattr(self, "_capture_session", None)
if session is None:
return "Capture session: none"
state = session.state()
lines = [
"Capture session:",
f"- kind={state.kind}",
f"- progress={state.captured_count}/{state.total_count}",
]
if state.current_combo is not None:
lines.append(
f"- current_combo=input={state.current_combo.input}, output={state.current_combo.output}"
)
return "\n".join(lines)
@staticmethod
def _load_history_command_seq(config_path: Path) -> int:
@@ -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."""
@@ -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 '<all>'}, "
f"outputs={self._gpr_output_positions_input.text().strip() or '<all>'}, "
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 '<empty>'} -> {current_start}")
if prev_stop != current_stop:
adjustments.append(f"Stop Hz: {prev_stop or '<empty>'} -> {current_stop}")
if prev_points != current_points:
adjustments.append(f"Points: {prev_points or '<empty>'} -> {current_points}")
if prev_ifbw != current_ifbw:
adjustments.append(f"IF BW Hz: {prev_ifbw or '<empty>'} -> {current_ifbw}")
if prev_power != current_power:
adjustments.append(f"Stimulus Power dBm: {prev_power or '<empty>'} -> {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:
@@ -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."""
@@ -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)
@@ -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 '<not selected>'} -> {current_value or '<not selected>'}"
)
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 "<not selected>")
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)
@@ -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."""
@@ -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:
@@ -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",
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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("<not selected>")
owner._selected_preprocess_labels[key] = label
form.addRow(preprocess_asset_display_name(key), label)
@@ -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
@@ -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)
@@ -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)
@@ -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
+91 -53
View File
@@ -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("<none>", 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)
+18
View File
@@ -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;
+305
View File
@@ -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
+28
View File
@@ -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",
]
+129
View File
@@ -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)
+9 -1
View File
@@ -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
@@ -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
@@ -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),
@@ -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)
+95 -27
View File
@@ -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."""
+4 -11
View File
@@ -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:
@@ -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")
+79 -24
View File
@@ -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