UI updates
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user