init commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Python application package for radar GUI, storage, orchestration, and hardware adapters."""
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Архитектурный обзор 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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Архитектура 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.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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()`.
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Потоки данных 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()` и лог.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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 для одной коллекции.
|
||||
@@ -0,0 +1 @@
|
||||
"""GUI package for radar system runtime control and visualization."""
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Main GUI window composed from focused mixins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
||||
|
||||
from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMixin
|
||||
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
|
||||
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
|
||||
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin
|
||||
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin
|
||||
from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.config_writer import ConfigWriter
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
from python_app.storage.npz_store import NpzStore
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||
|
||||
|
||||
class AppWindow(
|
||||
AppWindowUiMixin,
|
||||
AppWindowConfigMixin,
|
||||
AppWindowPreprocessMixin,
|
||||
AppWindowPlotMixin,
|
||||
AppWindowPipelineMixin,
|
||||
AppWindowSnapshotMixin,
|
||||
QMainWindow,
|
||||
):
|
||||
"""Top-level application window coordinating UI and acquisition runtime."""
|
||||
|
||||
def __init__(self, project_root: Path) -> None:
|
||||
"""Initialize application state, services, UI, and polling timer."""
|
||||
super().__init__()
|
||||
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._store = NpzStore(project_root / "python_app/data")
|
||||
self._config_writer = ConfigWriter(project_root / "python_app/runtime")
|
||||
self._supervisor = ProcessSupervisor(project_root)
|
||||
self._live_config_writer = ProcessingLiveConfigWriter(project_root / "python_app/runtime/processing_live.json")
|
||||
|
||||
self._raw_reader: ShmRingReader | None = None
|
||||
self._pre_reader: ShmRingReader | None = None
|
||||
self._result_reader: ShmRingReader | None = None
|
||||
|
||||
self._preprocess_dialog: PreprocessDialog | None = None
|
||||
self._selected_calibration_set = str(self._defaults_config.preprocess.calibration_set)
|
||||
self._selected_reference_set = str(self._defaults_config.preprocess.reference_set)
|
||||
|
||||
self._capture_session: SequentialCaptureSession | None = None
|
||||
self._resume_pipeline_after_capture = False
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns: int | None = None
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id: int | None = None
|
||||
|
||||
self._raw_history: deque[SweepCollection] = deque(maxlen=512)
|
||||
self._pre_history: deque[SweepCollection] = deque(maxlen=512)
|
||||
result_history_limit = max(
|
||||
1,
|
||||
min(
|
||||
int(self._defaults_config.rings.preprocessed.capacity),
|
||||
int(self._defaults_config.rings.results.capacity),
|
||||
50,
|
||||
),
|
||||
)
|
||||
self._result_history: deque[ResultCollection] = deque(maxlen=result_history_limit)
|
||||
self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
|
||||
self._bscan_history_limit = result_history_limit
|
||||
self._bscan_history_by_combo = {}
|
||||
self._bscan_depth_axis_by_combo = {}
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
self._bscan_render_signature = None
|
||||
self._phase_viewbox = None
|
||||
self._history_run_signature = None
|
||||
self._radar_limits: dict[str, float | int] | None = None
|
||||
|
||||
self._max_pop_per_poll = 256
|
||||
self._max_pop_per_snapshot_drain = 4096
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(50)
|
||||
self._timer.timeout.connect(self._poll_rings)
|
||||
|
||||
self._build_ui()
|
||||
self._refresh_preprocess_summary_labels()
|
||||
if self._radar_mode.currentText() == "native":
|
||||
self._refresh_radar_limits_from_device()
|
||||
else:
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
self._write_live_processing_config()
|
||||
self._timer.start()
|
||||
|
||||
def _log(self, text: str) -> None:
|
||||
"""Append a line to the runtime log panel."""
|
||||
self._log_box.appendPlainText(text)
|
||||
|
||||
@staticmethod
|
||||
def _load_history_command_seq(config_path: Path) -> int:
|
||||
"""Load previously used live-command sequence from runtime config file."""
|
||||
try:
|
||||
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
|
||||
raw_value = payload.get("history_command_seq", 0)
|
||||
if isinstance(raw_value, bool):
|
||||
return 0
|
||||
if isinstance(raw_value, (int, float)):
|
||||
return max(0, int(raw_value))
|
||||
return 0
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
"""Log and present an error in a modal dialog."""
|
||||
self._log(f"ERROR: {message}")
|
||||
QMessageBox.critical(self, "Error", message)
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
"""Ensure workers and dialogs are closed before window destruction."""
|
||||
try:
|
||||
self._resume_pipeline_after_capture = False
|
||||
self._abort_capture_sequence(resume_pipeline=False)
|
||||
self._stop_all_processes()
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_dialog.close()
|
||||
finally:
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1 @@
|
||||
"""Controller mixins and orchestration helpers for GUI windows."""
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Configuration and live-processing binding mixin for the main window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.models.run_config_model import ComboModel, RunConfigModel
|
||||
from python_app.orchestration.config_writer import parse_combos_from_text
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
from python_app.storage.npz_store import radar_key_from_config
|
||||
|
||||
|
||||
class AppWindowConfigMixin:
|
||||
"""Builds runtime config models from current UI state."""
|
||||
|
||||
def _save_current_config(self) -> None:
|
||||
"""Persist currently selected GUI settings into root run_config.json."""
|
||||
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}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to save current config: {exc}")
|
||||
|
||||
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._switches_are_effectively_static(config):
|
||||
config.combos = [ComboModel(input=0, output=0)]
|
||||
|
||||
config.preprocess.calibration_set = self._selected_calibration_set
|
||||
config.preprocess.reference_set = self._selected_reference_set
|
||||
return config
|
||||
|
||||
def _radar_key(self, config: RunConfigModel) -> str:
|
||||
"""Build radar key used by calibration/reference storage lookup."""
|
||||
return radar_key_from_config(
|
||||
model_name=config.radar.model,
|
||||
serial=config.radar.serial,
|
||||
sweep_start_hz=config.radar.sweep.start_hz,
|
||||
sweep_stop_hz=config.radar.sweep.stop_hz,
|
||||
sweep_points=config.radar.sweep.points,
|
||||
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=config.radar.sweep.power_dbm,
|
||||
)
|
||||
|
||||
def _live_processing_config(self, *, history_command: str = "none") -> ProcessingLiveConfig:
|
||||
"""Build live processing config from current processing widgets."""
|
||||
self._sync_bscan_frequency_limits_with_radar()
|
||||
return ProcessingLiveConfig(
|
||||
processor_mode=self._processing_mode.currentText(),
|
||||
gain_db=float(self._processing_gain_db.value()),
|
||||
phase_deg=float(self._processing_phase_deg.value()),
|
||||
bscan_axis=self._bscan_axis.currentText(),
|
||||
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()),
|
||||
bscan_start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
||||
bscan_stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
||||
history_command_seq=int(self._history_command_seq),
|
||||
history_command=str(history_command),
|
||||
)
|
||||
|
||||
def _write_live_processing_config(self, *, history_command: str = "none", bump_history_seq: bool = False) -> None:
|
||||
"""Persist current live processing config to runtime JSON file."""
|
||||
if bump_history_seq:
|
||||
self._history_command_seq += 1
|
||||
self._live_config_writer.write(self._live_processing_config(history_command=history_command))
|
||||
|
||||
def _on_processing_live_settings_changed(self, *_args) -> None:
|
||||
"""Handle live-processing setting changes and trigger redraw when needed."""
|
||||
try:
|
||||
self._write_live_processing_config()
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
||||
self._sync_bscan_history_from_results()
|
||||
self._draw_bscan_heatmap_from_history()
|
||||
elif self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to update live processing settings: {exc}")
|
||||
|
||||
def _on_processing_mode_changed(self, mode: str) -> None:
|
||||
"""Switch processing parameter page and refresh corresponding visualization."""
|
||||
mode_to_page = {
|
||||
"pass_through": 0,
|
||||
"bscan": 1,
|
||||
}
|
||||
self._set_plot_mode(mode)
|
||||
self._processing_mode_pages.setCurrentIndex(mode_to_page.get(mode, 0))
|
||||
current_page = self._processing_mode_pages.currentWidget()
|
||||
if current_page is not None:
|
||||
self._processing_mode_pages.setFixedHeight(current_page.sizeHint().height())
|
||||
self._processing_mode_pages.updateGeometry()
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _on_bscan_clear_history_clicked(self) -> None:
|
||||
"""Permanently clear all runtime histories, ring backlogs, and B-scan cache."""
|
||||
self._apply_bscan_history_deletion(remove_last_only=False)
|
||||
|
||||
def _on_bscan_remove_last_sweep_clicked(self) -> None:
|
||||
"""Permanently delete the latest sweep from runtime histories and rings."""
|
||||
self._apply_bscan_history_deletion(remove_last_only=True)
|
||||
|
||||
def _apply_bscan_history_deletion(self, *, remove_last_only: bool) -> None:
|
||||
"""Apply destructive B-scan history deletion via C++ processor history commands."""
|
||||
resume_acquisition = self._supervisor.is_running()
|
||||
history_command = "remove_last" if remove_last_only else "clear_all"
|
||||
action = "last sweep removed" if remove_last_only else "history fully cleared"
|
||||
dropped_results = 0
|
||||
|
||||
try:
|
||||
if resume_acquisition:
|
||||
self._stop_run()
|
||||
|
||||
if self._result_reader is not None:
|
||||
dropped_results = self._result_reader.drop_all()
|
||||
|
||||
if remove_last_only:
|
||||
if self._result_history:
|
||||
self._result_history.pop()
|
||||
else:
|
||||
self._result_history.clear()
|
||||
self._clear_bscan_plot_history()
|
||||
|
||||
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
||||
|
||||
if self._supervisor.is_processor_running():
|
||||
self._drain_results_until_quiet(timeout_s=0.35, poll_s=0.01)
|
||||
|
||||
self._update_history_indicator()
|
||||
self._redraw_after_history_deletion()
|
||||
if resume_acquisition:
|
||||
self._start_run()
|
||||
self._log(f"B-scan {action}; dropped pending results={dropped_results}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to delete B-scan history: {exc}")
|
||||
|
||||
def _redraw_after_history_deletion(self) -> None:
|
||||
"""Refresh plot immediately after destructive history deletion."""
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
if self._result_history:
|
||||
self._sync_bscan_history_from_results()
|
||||
if not self._draw_bscan_heatmap_from_history():
|
||||
self._plot.clear()
|
||||
return
|
||||
if self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
return
|
||||
self._plot.clear()
|
||||
|
||||
def _on_radar_identity_changed(self, *_args) -> None:
|
||||
"""Refresh device limits when radar identity/mode changes."""
|
||||
if self._radar_mode.currentText() != "native":
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
return
|
||||
changed = self._refresh_radar_limits_from_device()
|
||||
if changed:
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _on_radar_sweep_limits_changed(self) -> None:
|
||||
"""Clamp B-scan frequency bounds after sweep start/stop edits."""
|
||||
if self._sync_bscan_frequency_limits_with_radar():
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _refresh_radar_limits_from_device(self) -> bool:
|
||||
"""Query native LibreVNA limits and apply them to GUI fields."""
|
||||
serial = self._serial_input.text().strip()
|
||||
radar_service = LibreVnaService(serial=serial or None)
|
||||
if not radar_service.driver_available:
|
||||
self._fallback_to_mock_mode("LibreVNA Python driver is not available for device limits query")
|
||||
return False
|
||||
|
||||
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}")
|
||||
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)
|
||||
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."""
|
||||
if limits is None:
|
||||
self._radar_limits = None
|
||||
self._radar_start_label.setText("Start Hz")
|
||||
self._radar_stop_label.setText("Stop Hz")
|
||||
self._radar_points_label.setText("Points")
|
||||
self._radar_ifbw_label.setText("IF BW Hz")
|
||||
self._radar_power_label.setText("Stimulus Power dBm")
|
||||
if self._radar_mode.currentText() == "native":
|
||||
self._radar_limits_hint.setText("Device limits unavailable in native mode (device not connected).")
|
||||
else:
|
||||
self._radar_limits_hint.setText("Mock mode: device limits are not applied.")
|
||||
self._power_input.setToolTip("Device power limits are available only in native mode.")
|
||||
return False
|
||||
|
||||
min_freq_hz = float(limits["min_frequency_hz"])
|
||||
max_freq_hz = float(limits["max_frequency_hz"])
|
||||
min_ifbw_hz = float(limits["min_ifbw_hz"])
|
||||
max_ifbw_hz = float(limits["max_ifbw_hz"])
|
||||
max_points = int(limits["max_points"])
|
||||
min_power_dbm = float(limits["min_power_dbm"])
|
||||
max_power_dbm = float(limits["max_power_dbm"])
|
||||
|
||||
self._radar_limits = limits
|
||||
|
||||
self._radar_start_label.setText(f"Start Hz ({min_freq_hz:g}..{max_freq_hz:g})")
|
||||
self._radar_stop_label.setText(f"Stop Hz ({min_freq_hz:g}..{max_freq_hz:g})")
|
||||
self._radar_points_label.setText(f"Points (1..{max_points:d})")
|
||||
self._radar_ifbw_label.setText(f"IF BW Hz ({min_ifbw_hz:g}..{max_ifbw_hz:g})")
|
||||
self._radar_power_label.setText(f"Stimulus Power dBm ({min_power_dbm:g}..{max_power_dbm:g})")
|
||||
self._radar_limits_hint.setText(
|
||||
f"Limits: Freq {min_freq_hz:g}..{max_freq_hz:g} Hz, Points 1..{max_points:d}, "
|
||||
f"IF BW {min_ifbw_hz:g}..{max_ifbw_hz:g} Hz, Power {min_power_dbm:g}..{max_power_dbm:g} dBm."
|
||||
)
|
||||
|
||||
changed = False
|
||||
prev_start = self._start_hz_input.text().strip()
|
||||
prev_stop = self._stop_hz_input.text().strip()
|
||||
prev_points = self._points_input.text().strip()
|
||||
prev_ifbw = self._ifbw_input.text().strip()
|
||||
prev_power = self._power_input.text().strip()
|
||||
|
||||
start_hz = self._clamp_line_edit_float(self._start_hz_input, min_freq_hz, max_freq_hz)
|
||||
stop_hz = self._clamp_line_edit_float(self._stop_hz_input, min_freq_hz, max_freq_hz)
|
||||
if start_hz > stop_hz:
|
||||
stop_hz = start_hz
|
||||
self._stop_hz_input.setText(f"{stop_hz:g}")
|
||||
changed = True
|
||||
|
||||
points = self._clamp_line_edit_int(self._points_input, 1, max_points)
|
||||
ifbw = self._clamp_line_edit_float(self._ifbw_input, min_ifbw_hz, max_ifbw_hz)
|
||||
power = self._clamp_line_edit_float(self._power_input, min_power_dbm, max_power_dbm)
|
||||
self._power_input.setToolTip(f"Device range: {min_power_dbm:g}..{max_power_dbm:g} dBm")
|
||||
|
||||
changed = (
|
||||
changed
|
||||
or prev_start != self._start_hz_input.text().strip()
|
||||
or prev_stop != self._stop_hz_input.text().strip()
|
||||
or prev_points != self._points_input.text().strip()
|
||||
or prev_ifbw != self._ifbw_input.text().strip()
|
||||
or prev_power != self._power_input.text().strip()
|
||||
)
|
||||
|
||||
self._sync_bscan_frequency_limits_with_radar()
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _clamp_line_edit_float(widget, min_value: float, max_value: float) -> float:
|
||||
"""Clamp float line-edit value to inclusive range and rewrite widget text."""
|
||||
try:
|
||||
value = float(widget.text().strip())
|
||||
except ValueError:
|
||||
value = min_value
|
||||
value = min(max(value, min_value), max_value)
|
||||
widget.setText(f"{value:g}")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _clamp_line_edit_int(widget, min_value: int, max_value: int) -> int:
|
||||
"""Clamp integer line-edit value to inclusive range and rewrite widget text."""
|
||||
try:
|
||||
value = int(float(widget.text().strip()))
|
||||
except ValueError:
|
||||
value = min_value
|
||||
value = min(max(value, min_value), max_value)
|
||||
widget.setText(str(value))
|
||||
return value
|
||||
|
||||
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
|
||||
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
|
||||
try:
|
||||
radar_start_hz = float(self._start_hz_input.text().strip())
|
||||
radar_stop_hz = float(self._stop_hz_input.text().strip())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
radar_min_mhz = min(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
||||
radar_max_mhz = max(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
||||
|
||||
changed = False
|
||||
for widget in (self._bscan_start_freq_mhz, self._bscan_stop_freq_mhz):
|
||||
if widget.minimum() != radar_min_mhz or widget.maximum() != radar_max_mhz:
|
||||
changed = True
|
||||
widget.blockSignals(True)
|
||||
widget.setRange(radar_min_mhz, radar_max_mhz)
|
||||
widget.blockSignals(False)
|
||||
|
||||
clamped_start_mhz = min(max(self._bscan_start_freq_mhz.value(), radar_min_mhz), radar_max_mhz)
|
||||
clamped_stop_mhz = min(max(self._bscan_stop_freq_mhz.value(), radar_min_mhz), radar_max_mhz)
|
||||
if clamped_start_mhz != self._bscan_start_freq_mhz.value():
|
||||
changed = True
|
||||
self._bscan_start_freq_mhz.blockSignals(True)
|
||||
self._bscan_start_freq_mhz.setValue(clamped_start_mhz)
|
||||
self._bscan_start_freq_mhz.blockSignals(False)
|
||||
if clamped_stop_mhz != self._bscan_stop_freq_mhz.value():
|
||||
changed = True
|
||||
self._bscan_stop_freq_mhz.blockSignals(True)
|
||||
self._bscan_stop_freq_mhz.setValue(clamped_stop_mhz)
|
||||
self._bscan_stop_freq_mhz.blockSignals(False)
|
||||
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _switches_are_effectively_static(config: RunConfigModel) -> bool:
|
||||
"""Return `True` when switch setup effectively yields one fixed combo."""
|
||||
has_single_position = config.input_switch.positions <= 1 and config.output_switch.positions <= 1
|
||||
both_mock = config.input_switch.driver_mode == "mock" and config.output_switch.driver_mode == "mock"
|
||||
return has_single_position or both_mock
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Pipeline runtime lifecycle mixin for the main GUI window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.gui.runtime.history import build_run_history_signature, record_result_history
|
||||
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.shm_reader import ShmRingReader
|
||||
|
||||
|
||||
class AppWindowPipelineMixin:
|
||||
"""Controls start/stop, readers, and periodic polling of pipeline rings."""
|
||||
|
||||
def _start_single_capture(self) -> None:
|
||||
"""Start acquisition in single-capture mode."""
|
||||
self._start_run(single_capture=True)
|
||||
|
||||
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")
|
||||
return
|
||||
if self._supervisor.is_running():
|
||||
self._show_error("Pipeline is already running")
|
||||
return
|
||||
|
||||
try:
|
||||
processor_was_running = self._supervisor.is_processor_running()
|
||||
if not processor_was_running:
|
||||
self._reset_runtime_history()
|
||||
|
||||
config = self._build_config()
|
||||
self._validate_processing_mode_constraints(config)
|
||||
run_signature = self._build_run_history_signature(config)
|
||||
radar_key = self._radar_key(config)
|
||||
|
||||
if not config.preprocess.calibration_set or not config.preprocess.reference_set:
|
||||
raise RuntimeError("Select calibration and reference sets in Preprocessing Panel before Start")
|
||||
|
||||
combo_keys = [ComboKey(input_pos=combo.input, output_pos=combo.output) for combo in config.combos]
|
||||
|
||||
if not self._store.has_combo_coverage(
|
||||
"calibration", radar_key, config.preprocess.calibration_set, combo_keys
|
||||
):
|
||||
raise RuntimeError("Selected calibration set does not cover requested run combos")
|
||||
if not self._store.has_combo_coverage(
|
||||
"reference", radar_key, config.preprocess.reference_set, combo_keys
|
||||
):
|
||||
raise RuntimeError("Selected reference set does not cover requested run combos")
|
||||
|
||||
calibration_bundle, reference_bundle = self._config_writer.prepare_bundles(
|
||||
self._store,
|
||||
radar_key,
|
||||
config.preprocess.calibration_set,
|
||||
config.preprocess.reference_set,
|
||||
)
|
||||
config.preprocess.calibration_bundle_path = str(calibration_bundle)
|
||||
config.preprocess.reference_bundle_path = str(reference_bundle)
|
||||
config.runtime.continuous = not single_capture
|
||||
|
||||
if not single_capture:
|
||||
self._prepare_radar_for_native_acquisition(config)
|
||||
|
||||
config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json")
|
||||
|
||||
if not single_capture:
|
||||
should_reset_history = (
|
||||
self._history_run_signature is not None and self._history_run_signature != run_signature
|
||||
)
|
||||
if should_reset_history:
|
||||
self._reset_runtime_history()
|
||||
self._log("History reset because run settings changed")
|
||||
self._history_run_signature = run_signature
|
||||
|
||||
self._supervisor.start(config_path)
|
||||
self._close_readers()
|
||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
||||
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name)
|
||||
self._result_reader = ShmRingReader(config.rings.results.name)
|
||||
self._single_capture_active = single_capture
|
||||
self._single_capture_start_ns = None
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id = None
|
||||
|
||||
self._drop_pending_ring_payloads(include_results=not single_capture)
|
||||
if single_capture:
|
||||
self._single_capture_start_ns = time.monotonic_ns()
|
||||
|
||||
if single_capture:
|
||||
self._status_label.setText("Status: single capture running")
|
||||
self._log("Single capture started")
|
||||
else:
|
||||
self._status_label.setText("Status: running")
|
||||
self._log("Pipeline started")
|
||||
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}")
|
||||
|
||||
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")
|
||||
return
|
||||
|
||||
was_running = self._supervisor.is_running()
|
||||
if was_running:
|
||||
self._stop_run()
|
||||
|
||||
try:
|
||||
if self._radar_mode.currentText() == "native":
|
||||
self._refresh_radar_limits_from_device()
|
||||
config = self._build_config()
|
||||
self._prepare_radar_for_native_acquisition(config)
|
||||
self._log("Radar settings applied")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to apply radar settings: {exc}")
|
||||
finally:
|
||||
if was_running:
|
||||
self._start_run()
|
||||
|
||||
def _prepare_radar_for_native_acquisition(self, config: RunConfigModel) -> None:
|
||||
"""Preconfigure native LibreVNA using current sweep settings."""
|
||||
if config.radar.driver_mode != "native":
|
||||
self._log("Radar pre-configuration skipped (mock mode)")
|
||||
return
|
||||
|
||||
radar_service = LibreVnaService(serial=config.radar.serial or None)
|
||||
if not radar_service.driver_available:
|
||||
raise RuntimeError("LibreVNA Python driver is not available for native pre-configuration")
|
||||
|
||||
try:
|
||||
radar_service.open()
|
||||
radar_service.configure(config.radar.sweep)
|
||||
finally:
|
||||
radar_service.close()
|
||||
|
||||
self._log("Radar pre-configured via Python driver")
|
||||
|
||||
def _stop_run(self) -> None:
|
||||
"""Stop acquisition-side processes and close readers as needed."""
|
||||
was_running = self._supervisor.is_running()
|
||||
if was_running:
|
||||
self._supervisor.stop_orchestrator()
|
||||
self._drain_rings_until_quiet(timeout_s=0.35, poll_s=0.02)
|
||||
self._supervisor.stop_preprocessor()
|
||||
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
|
||||
else:
|
||||
self._supervisor.stop()
|
||||
self._drain_rings_once_for_history()
|
||||
keep_results_reader = self._supervisor.is_processor_running()
|
||||
self._close_readers(keep_results=keep_results_reader)
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns = None
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id = None
|
||||
self._update_history_indicator()
|
||||
self._status_label.setText("Status: idle")
|
||||
if was_running:
|
||||
if keep_results_reader:
|
||||
self._log("Acquisition stopped (data_processor kept running)")
|
||||
else:
|
||||
self._log("Pipeline stopped")
|
||||
|
||||
def _stop_all_processes(self) -> None:
|
||||
"""Stop all managed pipeline processes and close all readers."""
|
||||
was_running = self._supervisor.is_running() or self._supervisor.is_processor_running()
|
||||
self._supervisor.stop_all()
|
||||
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
|
||||
self._close_readers(keep_results=False)
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns = None
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id = None
|
||||
self._update_history_indicator()
|
||||
self._status_label.setText("Status: idle")
|
||||
if was_running:
|
||||
self._log("All pipeline processes stopped")
|
||||
|
||||
def _close_readers(self, *, keep_results: bool = False) -> None:
|
||||
"""Close active ring readers."""
|
||||
if self._raw_reader is not None:
|
||||
self._raw_reader.close()
|
||||
self._raw_reader = None
|
||||
if self._pre_reader is not None:
|
||||
self._pre_reader.close()
|
||||
self._pre_reader = None
|
||||
if not keep_results and self._result_reader is not None:
|
||||
self._result_reader.close()
|
||||
self._result_reader = None
|
||||
|
||||
def _poll_rings(self) -> None:
|
||||
"""Poll readers, ingest history, and trigger rendering."""
|
||||
for report in self._supervisor.collect_crash_reports():
|
||||
self._status_label.setText("Status: error")
|
||||
self._log(report)
|
||||
|
||||
try:
|
||||
if self._raw_reader is not None:
|
||||
self._read_all_raw()
|
||||
self._read_all_preprocessed()
|
||||
result_latest = self._read_all_results() if self._result_reader is not None else None
|
||||
self._update_history_indicator()
|
||||
|
||||
if self._single_capture_active:
|
||||
if self._finish_single_capture_if_ready(result_latest):
|
||||
return
|
||||
return
|
||||
|
||||
self._draw_preferred_collection(result_latest=result_latest)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(f"Reader error: {exc}")
|
||||
|
||||
def _finish_single_capture_if_ready(self, result_latest: ResultCollection | None) -> bool:
|
||||
"""Finalize single capture when new result matching start criteria is available."""
|
||||
if not self._single_capture_active:
|
||||
return False
|
||||
if result_latest is None:
|
||||
return False
|
||||
if self._single_capture_start_ns is None:
|
||||
return False
|
||||
if not self._single_capture_seen_raw:
|
||||
return False
|
||||
if (
|
||||
self._single_capture_target_collection_id is not None
|
||||
and result_latest.collection_id < self._single_capture_target_collection_id
|
||||
):
|
||||
return False
|
||||
if result_latest.monotonic_ns < self._single_capture_start_ns:
|
||||
return False
|
||||
if not self._result_collection_has_trace(result_latest):
|
||||
return False
|
||||
|
||||
self._draw_results(result_latest)
|
||||
self._log("Single capture completed")
|
||||
self._stop_run()
|
||||
return True
|
||||
|
||||
def _read_all_raw(self) -> SweepCollection | None:
|
||||
"""Read available raw collections from raw ring."""
|
||||
assert self._raw_reader is not None
|
||||
latest: SweepCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll):
|
||||
collection = self._raw_reader.pop_raw_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._raw_history.append(collection)
|
||||
latest = collection
|
||||
if self._single_capture_active and self._single_capture_start_ns is not None:
|
||||
if collection.monotonic_ns >= self._single_capture_start_ns:
|
||||
self._single_capture_seen_raw = True
|
||||
if self._single_capture_target_collection_id is None:
|
||||
self._single_capture_target_collection_id = collection.collection_id
|
||||
return latest
|
||||
|
||||
def _read_all_preprocessed(self) -> None:
|
||||
"""Read available preprocessed collections from preprocessed ring."""
|
||||
if self._pre_reader is None:
|
||||
return
|
||||
|
||||
for _ in range(self._max_pop_per_poll):
|
||||
collection = self._pre_reader.pop_preprocessed_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._pre_history.append(collection)
|
||||
|
||||
def _read_all_results(self) -> ResultCollection | None:
|
||||
"""Read available result collections from results ring."""
|
||||
assert self._result_reader is not None
|
||||
latest: ResultCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll):
|
||||
collection = self._result_reader.pop_result_collection()
|
||||
if collection is None:
|
||||
break
|
||||
if self._record_result_history(collection):
|
||||
latest = collection
|
||||
return latest
|
||||
|
||||
def _record_result_history(self, collection: ResultCollection) -> bool:
|
||||
"""Merge collection into result history preserving de-dup semantics."""
|
||||
return record_result_history(self._result_history, collection)
|
||||
|
||||
def _drain_rings_once_for_history(self) -> None:
|
||||
"""Perform one non-blocking read pass to extend histories."""
|
||||
if self._raw_reader is not None:
|
||||
self._read_all_raw()
|
||||
self._read_all_preprocessed()
|
||||
if self._result_reader is not None:
|
||||
self._read_all_results()
|
||||
|
||||
def _drain_rings_until_quiet(self, *, timeout_s: float, poll_s: float) -> None:
|
||||
"""Drain rings until history sizes stabilize or timeout expires."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
stable_rounds = 0
|
||||
previous = (
|
||||
len(self._raw_history),
|
||||
len(self._pre_history),
|
||||
len(self._result_history),
|
||||
)
|
||||
|
||||
while time.monotonic() < deadline and stable_rounds < 2:
|
||||
self._drain_rings_once_for_history()
|
||||
current = (
|
||||
len(self._raw_history),
|
||||
len(self._pre_history),
|
||||
len(self._result_history),
|
||||
)
|
||||
if current == previous:
|
||||
stable_rounds += 1
|
||||
else:
|
||||
stable_rounds = 0
|
||||
previous = current
|
||||
time.sleep(poll_s)
|
||||
|
||||
def _drain_results_until_quiet(self, *, timeout_s: float, poll_s: float) -> None:
|
||||
"""Drain only results ring until size stabilizes or timeout expires."""
|
||||
if self._result_reader is None:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
stable_rounds = 0
|
||||
|
||||
while time.monotonic() < deadline and stable_rounds < 2:
|
||||
latest = self._read_all_results()
|
||||
if latest is None:
|
||||
stable_rounds += 1
|
||||
else:
|
||||
stable_rounds = 0
|
||||
time.sleep(poll_s)
|
||||
|
||||
def _update_history_indicator(self) -> None:
|
||||
"""Update UI label with current history buffer sizes."""
|
||||
self._history_label.setText(
|
||||
f"History: raw={len(self._raw_history)}, "
|
||||
f"preprocessed={len(self._pre_history)}, "
|
||||
f"results={len(self._result_history)}"
|
||||
)
|
||||
|
||||
def _reset_runtime_history(self) -> None:
|
||||
"""Reset runtime history and B-scan caches."""
|
||||
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
self._clear_bscan_plot_history()
|
||||
self._update_history_indicator()
|
||||
|
||||
def _replace_runtime_history(
|
||||
self,
|
||||
*,
|
||||
retained_raw: list[SweepCollection],
|
||||
retained_pre: list[SweepCollection],
|
||||
retained_result: list[ResultCollection],
|
||||
) -> None:
|
||||
"""Replace history deques with provided retained tails."""
|
||||
raw_tail = retained_raw[-self._raw_history.maxlen :] if self._raw_history.maxlen is not None else retained_raw
|
||||
pre_tail = retained_pre[-self._pre_history.maxlen :] if self._pre_history.maxlen is not None else retained_pre
|
||||
result_tail = (
|
||||
retained_result[-self._result_history.maxlen :]
|
||||
if self._result_history.maxlen is not None
|
||||
else retained_result
|
||||
)
|
||||
|
||||
self._raw_history.clear()
|
||||
self._pre_history.clear()
|
||||
self._result_history.clear()
|
||||
self._raw_history.extend(raw_tail)
|
||||
self._pre_history.extend(pre_tail)
|
||||
self._result_history.extend(result_tail)
|
||||
|
||||
def _build_run_history_signature(self, config: RunConfigModel) -> tuple[object, ...]:
|
||||
"""Build signature used to decide when history should be reset."""
|
||||
return build_run_history_signature(config)
|
||||
|
||||
def _validate_processing_mode_constraints(self, config: RunConfigModel) -> None:
|
||||
"""Validate processing-mode constraints for run start."""
|
||||
validate_processing_mode_constraints(self._processing_mode.currentText(), config)
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Plot rendering mixin for processed radar result collections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QRectF, Qt
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.gui.plotting.bscan_history import (
|
||||
build_bscan_signature,
|
||||
pick_bscan_display_key,
|
||||
rebuild_bscan_history_from_results,
|
||||
)
|
||||
from python_app.gui.plotting.bscan_math import (
|
||||
bscan_levels,
|
||||
bscan_lookup_table,
|
||||
build_lut,
|
||||
)
|
||||
from python_app.models.dataset_model import ResultCollection, TraceData
|
||||
|
||||
|
||||
class AppWindowPlotMixin:
|
||||
"""Renders result collections on the main pyqtgraph plot."""
|
||||
|
||||
def _draw_preferred_collection(
|
||||
self,
|
||||
*,
|
||||
result_latest: ResultCollection | None,
|
||||
) -> None:
|
||||
"""Draw latest available result collection if present."""
|
||||
if result_latest is None:
|
||||
return
|
||||
self._draw_results(result_latest)
|
||||
|
||||
def _draw_results(self, collection: ResultCollection) -> bool:
|
||||
"""Draw collection based on currently selected processing mode."""
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
return self._draw_bscan_heatmap(collection)
|
||||
return self._draw_trace_lines(collection)
|
||||
|
||||
def _show_magnitude_curves(self) -> bool:
|
||||
"""Return whether magnitude curves should be rendered."""
|
||||
return self._show_magnitude_checkbox.isChecked()
|
||||
|
||||
def _show_phase_curves(self) -> bool:
|
||||
"""Return whether phase curves should be rendered."""
|
||||
return self._show_phase_checkbox.isChecked()
|
||||
|
||||
def _on_trace_visibility_changed(self, *_args) -> None:
|
||||
"""Redraw pass-through traces when magnitude/phase toggles changed."""
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
return
|
||||
if self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
return
|
||||
self._clear_trace_plots()
|
||||
|
||||
def _clear_trace_plots(self) -> None:
|
||||
"""Clear pass-through magnitude and phase plots."""
|
||||
self._trace_magnitude_plot.clear()
|
||||
self._trace_phase_plot.clear()
|
||||
|
||||
def _draw_trace_lines(self, collection: ResultCollection) -> bool:
|
||||
"""Draw result payload traces as stacked magnitude/phase plots."""
|
||||
show_magnitude = self._show_magnitude_curves()
|
||||
show_phase = self._show_phase_curves()
|
||||
magnitude_plot = self._trace_magnitude_plot
|
||||
phase_plot = self._trace_phase_plot
|
||||
|
||||
magnitude_plot.setVisible(show_magnitude)
|
||||
phase_plot.setVisible(show_phase)
|
||||
self._clear_trace_plots()
|
||||
if not show_magnitude and not show_phase:
|
||||
return False
|
||||
|
||||
if show_magnitude:
|
||||
mag_item = magnitude_plot.getPlotItem()
|
||||
magnitude_plot.getViewBox().invertY(False)
|
||||
magnitude_plot.getViewBox().enableAutoRange(x=True, y=True)
|
||||
mag_item.showAxis("left", show=True)
|
||||
mag_item.showAxis("bottom", show=not show_phase)
|
||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
if not show_phase:
|
||||
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
|
||||
if show_phase:
|
||||
phase_item = phase_plot.getPlotItem()
|
||||
phase_plot.getViewBox().invertY(False)
|
||||
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
|
||||
phase_item.showAxis("left", show=True)
|
||||
phase_item.showAxis("bottom", show=True)
|
||||
phase_plot.setLabel("left", "Phase", units="deg")
|
||||
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
|
||||
palette = [
|
||||
"#4cc9f0",
|
||||
"#f72585",
|
||||
"#b8f2e6",
|
||||
"#ffd166",
|
||||
"#90be6d",
|
||||
"#ff595e",
|
||||
"#6a4c93",
|
||||
"#1982c4",
|
||||
]
|
||||
|
||||
color_index = 0
|
||||
has_data = False
|
||||
x_min = np.inf
|
||||
x_max = -np.inf
|
||||
for block in collection.blocks:
|
||||
for payload in block.payloads:
|
||||
if payload.kind != 1 or payload.trace.size == 0:
|
||||
continue
|
||||
if payload.frequency_hz.size == 0 or payload.frequency_hz.size != payload.trace.size:
|
||||
continue
|
||||
|
||||
local_x_min = float(np.min(payload.frequency_hz))
|
||||
local_x_max = float(np.max(payload.frequency_hz))
|
||||
x_min = min(x_min, local_x_min)
|
||||
x_max = max(x_max, local_x_max)
|
||||
color = palette[color_index % len(palette)]
|
||||
|
||||
if show_magnitude:
|
||||
magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12))
|
||||
magnitude_curve = pg.PlotCurveItem(
|
||||
payload.frequency_hz,
|
||||
magnitude_values,
|
||||
pen=pg.mkPen(color, width=1.4),
|
||||
)
|
||||
magnitude_plot.addItem(magnitude_curve)
|
||||
has_data = True
|
||||
|
||||
if show_phase:
|
||||
phase_values = np.degrees(np.angle(payload.trace))
|
||||
phase_curve = pg.PlotCurveItem(
|
||||
payload.frequency_hz,
|
||||
phase_values,
|
||||
pen=pg.mkPen(color, width=1.2, style=Qt.PenStyle.DashLine),
|
||||
)
|
||||
phase_plot.addItem(phase_curve)
|
||||
has_data = True
|
||||
|
||||
color_index += 1
|
||||
|
||||
if has_data:
|
||||
if np.isfinite(x_min) and np.isfinite(x_max):
|
||||
if show_magnitude:
|
||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||
return has_data
|
||||
|
||||
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
|
||||
"""Draw B-scan image rebuilt from processed result history."""
|
||||
self._disable_phase_axis()
|
||||
self._sync_bscan_history_from_results()
|
||||
return self._draw_bscan_heatmap_from_history()
|
||||
|
||||
def _draw_bscan_heatmap_from_history(self) -> bool:
|
||||
"""Render B-scan heatmap from currently cached history arrays."""
|
||||
display_key = self._pick_bscan_display_key()
|
||||
if display_key is None:
|
||||
return False
|
||||
|
||||
history = self._bscan_history_by_combo.get(display_key)
|
||||
depth_axis = self._bscan_depth_axis_by_combo.get(display_key)
|
||||
if not history or depth_axis is None:
|
||||
return False
|
||||
|
||||
sweeps = np.vstack(history).astype(np.float32, copy=False)
|
||||
if sweeps.size == 0:
|
||||
return False
|
||||
|
||||
depth_min = float(np.min(depth_axis))
|
||||
depth_max = float(np.max(depth_axis))
|
||||
depth_span = max(depth_max - depth_min, 1e-6)
|
||||
sweep_count = sweeps.shape[0]
|
||||
sweep_width = float(max(sweep_count, 1))
|
||||
x_min = 0.5
|
||||
x_max = x_min + sweep_width
|
||||
|
||||
image_item = pg.ImageItem(axisOrder="row-major")
|
||||
image_item.setImage(sweeps.T, autoLevels=False)
|
||||
image_item.setRect(QRectF(x_min, depth_min, sweep_width, depth_span))
|
||||
|
||||
axis_mode = self._bscan_axis.currentText()
|
||||
image_item.setLookupTable(self._bscan_lookup_table(axis_mode))
|
||||
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
|
||||
|
||||
self._plot.clear()
|
||||
view_box = self._plot.getViewBox()
|
||||
view_box.invertY(True)
|
||||
view_box.enableAutoRange(x=False, y=False)
|
||||
self._plot.getPlotItem().showAxis("left", show=True)
|
||||
self._plot.getPlotItem().showAxis("bottom", show=True)
|
||||
self._plot.setLabel("bottom", "Sweep #")
|
||||
self._plot.setLabel("left", "Depth", units="m")
|
||||
self._plot.addItem(image_item)
|
||||
self._plot.setXRange(x_min, x_max, padding=0.02)
|
||||
self._plot.setYRange(depth_min, depth_max, padding=0.02)
|
||||
self._plot.setTitle(f"B-scan in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}")
|
||||
return True
|
||||
|
||||
def _sync_bscan_history_from_results(self) -> None:
|
||||
"""Rebuild B-scan history cache when live params or inputs changed."""
|
||||
self._advance_bscan_floor_to_cpp_window()
|
||||
signature = self._bscan_signature()
|
||||
if signature == self._bscan_render_signature:
|
||||
return
|
||||
self._rebuild_bscan_history_from_results()
|
||||
self._bscan_render_signature = signature
|
||||
|
||||
def _bscan_signature(self) -> tuple[object, ...]:
|
||||
"""Build state signature for B-scan history cache invalidation."""
|
||||
live_config = self._live_processing_config()
|
||||
result_history = list(self._result_history)
|
||||
return build_bscan_signature(
|
||||
live_config=live_config,
|
||||
result_history=result_history,
|
||||
history_limit=self._bscan_history_limit,
|
||||
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||
)
|
||||
|
||||
def _rebuild_bscan_history_from_results(self) -> None:
|
||||
"""Recompute B-scan history cache from results history buffer."""
|
||||
result_history = list(self._result_history)
|
||||
history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results(
|
||||
result_history=result_history,
|
||||
history_limit=self._bscan_history_limit,
|
||||
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||
)
|
||||
self._bscan_history_by_combo = history_by_combo
|
||||
self._bscan_depth_axis_by_combo = depth_axis_by_combo
|
||||
|
||||
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)
|
||||
|
||||
def _bscan_lookup_table(self, axis_mode: str) -> np.ndarray:
|
||||
"""Return lookup table for current B-scan axis mode."""
|
||||
return bscan_lookup_table(axis_mode)
|
||||
|
||||
@staticmethod
|
||||
def _build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
|
||||
"""Backward-compatible wrapper around LUT builder."""
|
||||
return build_lut(stops, size=size)
|
||||
|
||||
@staticmethod
|
||||
def _bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
|
||||
"""Return display levels for B-scan image."""
|
||||
return bscan_levels(sweeps, axis_mode)
|
||||
|
||||
def _clear_bscan_plot_history(self) -> None:
|
||||
"""Drop cached B-scan history and invalidate cache signature."""
|
||||
self._bscan_history_by_combo.clear()
|
||||
self._bscan_depth_axis_by_combo.clear()
|
||||
self._bscan_render_signature = None
|
||||
|
||||
def _advance_bscan_floor_to_cpp_window(self) -> None:
|
||||
"""Clamp B-scan source history to C++ available replay window."""
|
||||
if not self._result_history:
|
||||
return
|
||||
|
||||
cpp_window_limit = min(
|
||||
int(self._defaults_config.rings.preprocessed.capacity),
|
||||
int(self._defaults_config.rings.results.capacity),
|
||||
)
|
||||
cpp_window_limit = max(1, cpp_window_limit)
|
||||
latest_collection_id = int(self._result_history[-1].collection_id)
|
||||
current_floor = int(self._bscan_history_floor_collection_id)
|
||||
|
||||
# Collection ids restart from 1 on new C++ run; release floor only while
|
||||
# acquisition is running, so manual "remove last" behavior in stopped mode
|
||||
# remains deterministic.
|
||||
if latest_collection_id < current_floor and self._supervisor.is_running():
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
current_floor = 0
|
||||
|
||||
floor_candidate = max(0, latest_collection_id - cpp_window_limit)
|
||||
if floor_candidate > current_floor:
|
||||
self._bscan_history_floor_collection_id = floor_candidate
|
||||
|
||||
def _ensure_phase_view_box(self) -> pg.ViewBox:
|
||||
"""Create or return secondary right-axis ViewBox for phase curves."""
|
||||
plot_item = self._plot.getPlotItem()
|
||||
phase_view_box = self._phase_viewbox
|
||||
if phase_view_box is None:
|
||||
phase_view_box = pg.ViewBox()
|
||||
self._phase_viewbox = phase_view_box
|
||||
plot_item.scene().addItem(phase_view_box)
|
||||
plot_item.getAxis("right").linkToView(phase_view_box)
|
||||
phase_view_box.setXLink(plot_item.vb)
|
||||
plot_item.vb.sigResized.connect(self._update_phase_view_box_geometry)
|
||||
self._update_phase_view_box_geometry()
|
||||
return phase_view_box
|
||||
|
||||
def _update_phase_view_box_geometry(self) -> None:
|
||||
"""Keep right-axis ViewBox geometry in sync with main plot ViewBox."""
|
||||
phase_view_box = self._phase_viewbox
|
||||
if phase_view_box is None:
|
||||
return
|
||||
plot_item = self._plot.getPlotItem()
|
||||
phase_view_box.setGeometry(plot_item.vb.sceneBoundingRect())
|
||||
phase_view_box.linkedViewChanged(plot_item.vb, phase_view_box.XAxis)
|
||||
|
||||
def _clear_phase_overlay(self) -> None:
|
||||
"""Remove all phase curves from secondary ViewBox."""
|
||||
self._trace_phase_plot.clear()
|
||||
|
||||
def _disable_phase_axis(self) -> None:
|
||||
"""Hide right axis and clear phase overlay when phase is not rendered."""
|
||||
self._clear_phase_overlay()
|
||||
|
||||
def _result_collection_has_trace(self, collection: ResultCollection) -> bool:
|
||||
"""Return `True` when collection contains at least one trace payload."""
|
||||
for block in collection.blocks:
|
||||
for payload in block.payloads:
|
||||
if payload.kind == 1 and payload.trace.size > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _draw_single_trace(self, trace: TraceData, title: str) -> None:
|
||||
"""Draw one trace on stacked magnitude/phase plots."""
|
||||
show_magnitude = self._show_magnitude_curves()
|
||||
show_phase = self._show_phase_curves()
|
||||
magnitude_plot = self._trace_magnitude_plot
|
||||
phase_plot = self._trace_phase_plot
|
||||
|
||||
magnitude_plot.setVisible(show_magnitude)
|
||||
phase_plot.setVisible(show_phase)
|
||||
self._clear_trace_plots()
|
||||
if not show_magnitude and not show_phase:
|
||||
return
|
||||
|
||||
if show_magnitude:
|
||||
magnitude_plot.getViewBox().invertY(False)
|
||||
magnitude_plot.getViewBox().enableAutoRange(x=True, y=True)
|
||||
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
|
||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
magnitude_plot.setTitle(title)
|
||||
if not show_phase:
|
||||
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
if show_phase:
|
||||
phase_plot.getViewBox().invertY(False)
|
||||
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
|
||||
phase_plot.getPlotItem().showAxis("bottom", show=True)
|
||||
phase_plot.setLabel("left", "Phase", units="deg")
|
||||
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
phase_plot.setTitle(title)
|
||||
|
||||
if show_magnitude:
|
||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12))
|
||||
magnitude_curve = pg.PlotCurveItem(
|
||||
trace.frequency_hz,
|
||||
magnitude_db,
|
||||
pen=pg.mkPen("#ffd166", width=1.8),
|
||||
)
|
||||
magnitude_plot.addItem(magnitude_curve)
|
||||
|
||||
if show_phase:
|
||||
phase_deg = np.degrees(np.angle(trace.s21))
|
||||
phase_curve = pg.PlotCurveItem(
|
||||
trace.frequency_hz,
|
||||
phase_deg,
|
||||
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
|
||||
)
|
||||
phase_plot.addItem(phase_curve)
|
||||
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||
|
||||
if np.size(trace.frequency_hz) > 1:
|
||||
x_min = float(np.min(trace.frequency_hz))
|
||||
x_max = float(np.max(trace.frequency_hz))
|
||||
if show_magnitude:
|
||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Preprocessing-set selection and sequential capture workflow mixin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||
|
||||
|
||||
class AppWindowPreprocessMixin:
|
||||
"""Handles calibration/reference set management and capture workflow."""
|
||||
|
||||
def _open_preprocess_panel(self) -> None:
|
||||
"""Open preprocessing dialog and refresh available sets."""
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
try:
|
||||
self._refresh_sets()
|
||||
self._update_capture_dialog_state()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to open preprocessing panel: {exc}")
|
||||
return
|
||||
|
||||
dialog.show()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
|
||||
def _ensure_preprocess_dialog(self) -> PreprocessDialog:
|
||||
"""Create preprocessing dialog lazily and wire its signals once."""
|
||||
if self._preprocess_dialog is not None:
|
||||
return self._preprocess_dialog
|
||||
|
||||
dialog = PreprocessDialog(self)
|
||||
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, calibration_set: str, reference_set: str) -> None:
|
||||
"""Persist selected preprocessing set names from dialog."""
|
||||
self._selected_calibration_set = calibration_set.strip()
|
||||
self._selected_reference_set = reference_set.strip()
|
||||
self._refresh_preprocess_summary_labels()
|
||||
|
||||
def _refresh_preprocess_summary_labels(self) -> None:
|
||||
"""Update compact summary labels in the main window."""
|
||||
self._selected_calibration_label.setText(self._selected_calibration_set or "<not selected>")
|
||||
self._selected_reference_label.setText(self._selected_reference_set or "<not selected>")
|
||||
|
||||
def _refresh_sets(self) -> None:
|
||||
"""Refresh calibration/reference set lists for current radar key."""
|
||||
config = self._build_config()
|
||||
radar_key = self._radar_key(config)
|
||||
calibration_sets = self._store.list_sets("calibration", radar_key)
|
||||
reference_sets = self._store.list_sets("reference", radar_key)
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
dialog.set_calibration_sets(calibration_sets)
|
||||
dialog.set_reference_sets(reference_sets)
|
||||
|
||||
if self._selected_calibration_set not in calibration_sets:
|
||||
self._selected_calibration_set = calibration_sets[0] if calibration_sets else ""
|
||||
if self._selected_reference_set not in reference_sets:
|
||||
self._selected_reference_set = reference_sets[0] if reference_sets else ""
|
||||
|
||||
dialog.set_selected_sets(self._selected_calibration_set, self._selected_reference_set)
|
||||
self._refresh_preprocess_summary_labels()
|
||||
self._log(f"Set lists refreshed for key={radar_key}")
|
||||
|
||||
def _start_capture_sequence(self, kind: str) -> None:
|
||||
"""Start sequential capture session for requested preprocessing kind."""
|
||||
if self._capture_session is not None:
|
||||
self._show_error("Another capture sequence is already active")
|
||||
return
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
set_name = dialog.set_name()
|
||||
if not set_name:
|
||||
self._show_error("Set name is required")
|
||||
return
|
||||
|
||||
was_running = self._supervisor.is_running()
|
||||
if was_running:
|
||||
self._log("Pipeline paused for exclusive hardware capture")
|
||||
self._stop_run()
|
||||
|
||||
self._resume_pipeline_after_capture = was_running
|
||||
|
||||
try:
|
||||
config = self._build_config()
|
||||
radar_key = self._radar_key(config)
|
||||
existing_sets = self._store.list_sets(kind, radar_key)
|
||||
if set_name in existing_sets:
|
||||
raise RuntimeError(f"Set '{set_name}' already exists for {kind} and cannot be overwritten")
|
||||
|
||||
session = SequentialCaptureSession(config=config, kind=kind, set_name=set_name)
|
||||
session.open()
|
||||
self._capture_session = session
|
||||
|
||||
dialog.clear_capture_log()
|
||||
dialog.set_status(f"{kind.title()} sequence started")
|
||||
self._update_capture_dialog_state()
|
||||
self._log(f"{kind.title()} sequence started for set={set_name}; fill all N*M combos")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._cleanup_capture_session()
|
||||
self._show_error(f"Failed to start {kind} sequence: {exc}")
|
||||
self._resume_pipeline_if_needed()
|
||||
|
||||
def _capture_next_combo(self) -> None:
|
||||
"""Capture next combo in active sequential capture session."""
|
||||
session = self._capture_session
|
||||
if session is None:
|
||||
self._show_error("No active capture sequence")
|
||||
return
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
|
||||
try:
|
||||
trace = session.capture_current_combo()
|
||||
state = session.state()
|
||||
tx_label, rx_label = dialog.antenna_labels()
|
||||
|
||||
dialog.append_capture_log_entry(
|
||||
kind=session.kind,
|
||||
captured_count=state.captured_count,
|
||||
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"{session.kind.title()} captured")
|
||||
self._draw_single_trace(trace, title=f"{session.kind.title()} last trace")
|
||||
|
||||
self._log(
|
||||
f"{session.kind.title()} capture: {state.captured_count}/{state.total_count} | "
|
||||
f"input={trace.combo.input_pos} output={trace.combo.output_pos}"
|
||||
)
|
||||
|
||||
if session.is_complete():
|
||||
radar_key, collection = session.finalize(self._store)
|
||||
set_name = session.set_name
|
||||
kind = session.kind
|
||||
self._cleanup_capture_session()
|
||||
|
||||
if kind == "calibration":
|
||||
self._selected_calibration_set = set_name
|
||||
else:
|
||||
self._selected_reference_set = set_name
|
||||
|
||||
self._refresh_sets()
|
||||
dialog.set_status(f"{kind.title()} set saved: {set_name} ({len(collection.traces)} traces)")
|
||||
self._log(f"{kind.title()} sequence completed and saved: set={set_name}, key={radar_key}")
|
||||
self._resume_pipeline_if_needed()
|
||||
else:
|
||||
self._update_capture_dialog_state()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to capture combo: {exc}")
|
||||
self._abort_capture_sequence()
|
||||
|
||||
def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None:
|
||||
"""Abort active capture session and optionally resume pipeline."""
|
||||
if self._capture_session is None:
|
||||
return
|
||||
|
||||
kind = self._capture_session.kind
|
||||
self._cleanup_capture_session()
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
dialog.set_status(f"{kind.title()} sequence aborted")
|
||||
self._log(f"{kind.title()} sequence aborted")
|
||||
if resume_pipeline:
|
||||
self._resume_pipeline_if_needed()
|
||||
|
||||
def _update_capture_dialog_state(self) -> None:
|
||||
"""Sync dialog state widgets with active capture session."""
|
||||
if self._preprocess_dialog is None:
|
||||
return
|
||||
|
||||
if self._capture_session is None:
|
||||
self._preprocess_dialog.set_capture_state(
|
||||
kind=None,
|
||||
captured_count=0,
|
||||
total_count=0,
|
||||
next_input=None,
|
||||
next_output=None,
|
||||
)
|
||||
return
|
||||
|
||||
state = self._capture_session.state()
|
||||
next_input = None
|
||||
next_output = None
|
||||
if state.current_combo is not None:
|
||||
next_input = state.current_combo.input
|
||||
next_output = state.current_combo.output
|
||||
|
||||
self._preprocess_dialog.set_capture_state(
|
||||
kind=state.kind,
|
||||
captured_count=state.captured_count,
|
||||
total_count=state.total_count,
|
||||
next_input=next_input,
|
||||
next_output=next_output,
|
||||
)
|
||||
|
||||
def _cleanup_capture_session(self) -> None:
|
||||
"""Close and clear current capture session object."""
|
||||
if self._capture_session is not None:
|
||||
self._capture_session.close()
|
||||
self._capture_session = None
|
||||
self._update_capture_dialog_state()
|
||||
|
||||
def _resume_pipeline_if_needed(self) -> None:
|
||||
"""Resume acquisition pipeline if it was paused for capture session."""
|
||||
should_resume = self._resume_pipeline_after_capture
|
||||
self._resume_pipeline_after_capture = False
|
||||
if not should_resume:
|
||||
return
|
||||
|
||||
try:
|
||||
self._start_run()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to resume pipeline after capture: {exc}")
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Snapshot and ring-drain helper mixin for the main GUI window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
|
||||
|
||||
class AppWindowSnapshotMixin:
|
||||
"""Saves runtime data snapshots and maintains ring-reader freshness."""
|
||||
|
||||
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")
|
||||
return
|
||||
|
||||
try:
|
||||
last_n = int(self._save_count.value())
|
||||
output_root = Path(self._save_path_input.text().strip()).expanduser()
|
||||
snapshot_name = self._save_name_input.text().strip()
|
||||
snapshot_dir, summary = self._store.save_runtime_snapshot_numpy(
|
||||
output_root,
|
||||
snapshot_name,
|
||||
list(self._raw_history),
|
||||
list(self._pre_history),
|
||||
list(self._result_history),
|
||||
last_n,
|
||||
)
|
||||
self._log(
|
||||
f"Saved numpy snapshot: {snapshot_dir} "
|
||||
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')})"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to save snapshot: {exc}")
|
||||
|
||||
def _browse_save_path(self) -> None:
|
||||
"""Open directory picker for snapshot output path."""
|
||||
selected = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"Select Snapshot Directory",
|
||||
self._save_path_input.text().strip() or str(self._project_root),
|
||||
)
|
||||
if selected:
|
||||
self._save_path_input.setText(selected)
|
||||
|
||||
def _drain_runtime_rings_for_snapshot(self) -> None:
|
||||
"""Drain readers before snapshot to reduce partial-history races."""
|
||||
if self._raw_reader is None and self._pre_reader is None and self._result_reader is None:
|
||||
return
|
||||
try:
|
||||
start_raw_count = len(self._raw_history)
|
||||
start_pre_count = len(self._pre_history)
|
||||
start_result_count = len(self._result_history)
|
||||
deadline = time.monotonic() + 0.8
|
||||
|
||||
while True:
|
||||
progress = False
|
||||
|
||||
if self._raw_reader is not None:
|
||||
for _ in range(self._max_pop_per_snapshot_drain):
|
||||
collection = self._raw_reader.pop_raw_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._raw_history.append(collection)
|
||||
progress = True
|
||||
|
||||
if self._pre_reader is not None:
|
||||
for _ in range(self._max_pop_per_snapshot_drain):
|
||||
collection = self._pre_reader.pop_preprocessed_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._pre_history.append(collection)
|
||||
progress = True
|
||||
|
||||
if self._result_reader is not None:
|
||||
for _ in range(self._max_pop_per_snapshot_drain):
|
||||
collection = self._result_reader.pop_result_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._record_result_history(collection)
|
||||
progress = True
|
||||
|
||||
if progress:
|
||||
continue
|
||||
|
||||
missing_raw = self._raw_reader is not None and len(self._raw_history) == start_raw_count
|
||||
missing_pre = self._pre_reader is not None and len(self._pre_history) == start_pre_count
|
||||
got_results = len(self._result_history) > start_result_count
|
||||
|
||||
if got_results and (missing_raw or missing_pre) and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(f"Snapshot drain warning: {exc}")
|
||||
|
||||
def _drop_pending_ring_payloads(self, *, include_results: bool = True) -> None:
|
||||
"""Drop unread payloads from active readers."""
|
||||
dropped_raw = self._raw_reader.drop_all() if self._raw_reader is not None else 0
|
||||
dropped_pre = self._pre_reader.drop_all() if self._pre_reader is not None else 0
|
||||
dropped_results = 0
|
||||
if include_results and self._result_reader is not None:
|
||||
dropped_results = self._result_reader.drop_all()
|
||||
|
||||
if dropped_raw or dropped_pre or dropped_results:
|
||||
self._log(
|
||||
"Single capture ring reset: "
|
||||
f"dropped raw={dropped_raw}, "
|
||||
f"preprocessed={dropped_pre}, "
|
||||
f"results={dropped_results}"
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""UI construction mixin for the main radar control window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.gui.controllers.sections import (
|
||||
build_data_actions_group,
|
||||
build_hardware_actions_group,
|
||||
build_pipeline_group,
|
||||
build_preprocess_summary_group,
|
||||
build_processing_group,
|
||||
build_radar_group,
|
||||
build_switch_group,
|
||||
)
|
||||
|
||||
|
||||
class AppWindowUiMixin:
|
||||
"""Builds and wires all static UI widgets."""
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build main window widgets, plot area, and settings panel."""
|
||||
self.setWindowTitle("Radar System Control")
|
||||
root = QWidget(self)
|
||||
self.setCentralWidget(root)
|
||||
|
||||
layout = QHBoxLayout(root)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(14)
|
||||
|
||||
self._plot_stack = QStackedWidget(root)
|
||||
|
||||
self._plot = pg.PlotWidget(background="#0f141c")
|
||||
self._plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
self._plot.setLabel("left", "Magnitude", units="dB")
|
||||
self._plot_stack.addWidget(self._plot)
|
||||
|
||||
self._trace_plots_container = QWidget(root)
|
||||
trace_layout = QVBoxLayout(self._trace_plots_container)
|
||||
trace_layout.setContentsMargins(0, 0, 0, 0)
|
||||
trace_layout.setSpacing(6)
|
||||
|
||||
self._trace_magnitude_plot = pg.PlotWidget(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)
|
||||
self._trace_magnitude_plot.getPlotItem().setDownsampling(mode="peak")
|
||||
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.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")
|
||||
self._trace_phase_plot.getPlotItem().setDownsampling(mode="peak")
|
||||
self._trace_phase_plot.getPlotItem().setClipToView(True)
|
||||
trace_layout.addWidget(self._trace_phase_plot, stretch=1)
|
||||
|
||||
self._plot_stack.addWidget(self._trace_plots_container)
|
||||
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
||||
layout.addWidget(self._plot_stack, stretch=11)
|
||||
|
||||
self._settings_toggle_button = QPushButton("<")
|
||||
self._settings_toggle_button.setObjectName("settingsToggleButton")
|
||||
self._settings_toggle_button.setFixedWidth(26)
|
||||
self._settings_toggle_button.clicked.connect(self._toggle_settings_panel)
|
||||
layout.addWidget(self._settings_toggle_button, stretch=0)
|
||||
|
||||
self._settings_panel = QWidget(root)
|
||||
self._settings_panel.setMinimumWidth(659)
|
||||
right_layout = QVBoxLayout(self._settings_panel)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(10)
|
||||
|
||||
# Build log widget early so error handlers can safely write during UI construction.
|
||||
self._log_box = QPlainTextEdit(self._settings_panel)
|
||||
self._log_box.setReadOnly(True)
|
||||
self._log_box.setMinimumHeight(170)
|
||||
|
||||
pipeline_group = self._build_pipeline_group()
|
||||
hardware_actions_group = self._build_hardware_actions_group()
|
||||
data_actions_group = self._build_data_actions_group()
|
||||
preprocess_summary_group = self._build_preprocess_summary_group()
|
||||
radar_group = self._build_radar_group()
|
||||
processing_group = self._build_processing_group()
|
||||
switch_group = self._build_switch_group()
|
||||
|
||||
controls = QWidget(self._settings_panel)
|
||||
controls_layout = QVBoxLayout(controls)
|
||||
controls_layout.setContentsMargins(0, 0, 0, 0)
|
||||
controls_layout.setSpacing(10)
|
||||
controls_layout.addWidget(pipeline_group)
|
||||
controls_layout.addWidget(hardware_actions_group)
|
||||
controls_layout.addWidget(data_actions_group)
|
||||
controls_layout.addWidget(preprocess_summary_group)
|
||||
controls_layout.addWidget(processing_group)
|
||||
controls_layout.addWidget(radar_group)
|
||||
controls_layout.addWidget(switch_group)
|
||||
controls_layout.addStretch(1)
|
||||
|
||||
scroll = QScrollArea(self._settings_panel)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setWidget(controls)
|
||||
right_layout.addWidget(scroll, stretch=1)
|
||||
|
||||
self._status_label = QLabel("Status: idle", self._settings_panel)
|
||||
self._status_label.setObjectName("statusLabel")
|
||||
right_layout.addWidget(self._status_label)
|
||||
|
||||
self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel)
|
||||
self._history_label.setObjectName("hintLabel")
|
||||
right_layout.addWidget(self._history_label)
|
||||
|
||||
right_layout.addWidget(self._log_box, stretch=0)
|
||||
|
||||
layout.addWidget(self._settings_panel, stretch=8)
|
||||
self._set_settings_panel_visible(True)
|
||||
self.resize(1650, 940)
|
||||
|
||||
def _toggle_settings_panel(self) -> None:
|
||||
"""Toggle settings panel visibility."""
|
||||
self._set_settings_panel_visible(not self._settings_panel.isVisible())
|
||||
|
||||
def _set_settings_panel_visible(self, visible: bool) -> None:
|
||||
"""Set settings panel visibility and update toggle button glyph."""
|
||||
self._settings_panel.setVisible(visible)
|
||||
if visible:
|
||||
self._settings_toggle_button.setText(">")
|
||||
self._settings_toggle_button.setToolTip("Hide settings panel")
|
||||
else:
|
||||
self._settings_toggle_button.setText("<")
|
||||
self._settings_toggle_button.setToolTip("Show settings panel")
|
||||
|
||||
def _set_plot_mode(self, mode: str) -> None:
|
||||
"""Switch visible plot surface based on processing mode."""
|
||||
if mode == "bscan":
|
||||
self._plot_stack.setCurrentWidget(self._plot)
|
||||
return
|
||||
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
||||
|
||||
def _build_pipeline_group(self) -> QGroupBox:
|
||||
"""Build pipeline controls section."""
|
||||
return build_pipeline_group(self)
|
||||
|
||||
def _build_hardware_actions_group(self) -> QGroupBox:
|
||||
"""Build hardware actions section."""
|
||||
return build_hardware_actions_group(self)
|
||||
|
||||
def _build_data_actions_group(self) -> QGroupBox:
|
||||
"""Build data actions section."""
|
||||
return build_data_actions_group(self)
|
||||
|
||||
def _build_preprocess_summary_group(self) -> QGroupBox:
|
||||
"""Build selected preprocess sets summary section."""
|
||||
return build_preprocess_summary_group(self)
|
||||
|
||||
def _build_processing_group(self) -> QGroupBox:
|
||||
"""Build processing mode section."""
|
||||
return build_processing_group(self)
|
||||
|
||||
def _build_radar_group(self) -> QGroupBox:
|
||||
"""Build radar settings section."""
|
||||
return build_radar_group(self)
|
||||
|
||||
def _build_switch_group(self) -> QGroupBox:
|
||||
"""Build switch settings section."""
|
||||
return build_switch_group(self)
|
||||
|
||||
@staticmethod
|
||||
def _set_combo_current_text(combo: QComboBox, value: str) -> None:
|
||||
"""Select combo item by text, appending it when missing."""
|
||||
index = combo.findText(value)
|
||||
if index >= 0:
|
||||
combo.setCurrentIndex(index)
|
||||
return
|
||||
combo.addItem(value)
|
||||
combo.setCurrentIndex(combo.count() - 1)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""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.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.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
|
||||
from python_app.gui.controllers.sections.switch_section import build_switch_group
|
||||
|
||||
__all__ = [
|
||||
"build_data_actions_group",
|
||||
"build_hardware_actions_group",
|
||||
"build_pipeline_group",
|
||||
"build_preprocess_summary_group",
|
||||
"build_processing_group",
|
||||
"build_radar_group",
|
||||
"build_switch_group",
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Builder for snapshot and storage actions section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSpinBox, QVBoxLayout
|
||||
|
||||
|
||||
def build_data_actions_group(owner) -> QGroupBox:
|
||||
"""Create snapshot save controls section."""
|
||||
group = QGroupBox("Data Actions")
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(8)
|
||||
|
||||
save_row = QHBoxLayout()
|
||||
save_row.setSpacing(8)
|
||||
|
||||
save_button = QPushButton("Save Numpy Snapshot")
|
||||
save_button.clicked.connect(owner._save_snapshot)
|
||||
owner._save_count = QSpinBox()
|
||||
owner._save_count.setMinimum(1)
|
||||
owner._save_count.setMaximum(10_000)
|
||||
owner._save_count.setValue(10)
|
||||
|
||||
save_row.addWidget(save_button)
|
||||
save_row.addWidget(QLabel("Last N"))
|
||||
save_row.addWidget(owner._save_count)
|
||||
save_row.addStretch(1)
|
||||
layout.addLayout(save_row)
|
||||
|
||||
path_row = QHBoxLayout()
|
||||
path_row.setSpacing(8)
|
||||
owner._save_path_input = QLineEdit(str(owner._project_root / "python_app/data/snapshots"))
|
||||
browse_button = QPushButton("Browse")
|
||||
browse_button.clicked.connect(owner._browse_save_path)
|
||||
path_row.addWidget(QLabel("Path"))
|
||||
path_row.addWidget(owner._save_path_input, stretch=1)
|
||||
path_row.addWidget(browse_button)
|
||||
layout.addLayout(path_row)
|
||||
|
||||
name_row = QHBoxLayout()
|
||||
name_row.setSpacing(8)
|
||||
owner._save_name_input = QLineEdit("snapshot_manual")
|
||||
name_row.addWidget(QLabel("Name"))
|
||||
name_row.addWidget(owner._save_name_input, stretch=1)
|
||||
layout.addLayout(name_row)
|
||||
return group
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Builder for hardware actions section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QGroupBox, QHBoxLayout, QPushButton
|
||||
|
||||
|
||||
def build_hardware_actions_group(owner) -> QGroupBox:
|
||||
"""Create hardware action buttons section."""
|
||||
group = QGroupBox("Hardware Actions")
|
||||
layout = QHBoxLayout(group)
|
||||
layout.setSpacing(8)
|
||||
|
||||
apply_radar_button = QPushButton("Apply Radar Settings")
|
||||
apply_radar_button.clicked.connect(owner._apply_radar_settings)
|
||||
layout.addWidget(apply_radar_button)
|
||||
|
||||
save_config_button = QPushButton("Save Current Config")
|
||||
save_config_button.clicked.connect(owner._save_current_config)
|
||||
layout.addWidget(save_config_button)
|
||||
|
||||
preprocess_button = QPushButton("Preprocessing Panel")
|
||||
preprocess_button.clicked.connect(owner._open_preprocess_panel)
|
||||
layout.addWidget(preprocess_button)
|
||||
return group
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Builder for pipeline control section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
|
||||
def build_pipeline_group(owner) -> QGroupBox:
|
||||
"""Create Start/Single/Stop controls section."""
|
||||
group = QGroupBox("Pipeline")
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(8)
|
||||
|
||||
action_row = QHBoxLayout()
|
||||
action_row.setSpacing(8)
|
||||
|
||||
start_button = QPushButton("Start")
|
||||
start_button.clicked.connect(owner._start_run)
|
||||
action_row.addWidget(start_button)
|
||||
|
||||
single_button = QPushButton("Single Capture")
|
||||
single_button.clicked.connect(owner._start_single_capture)
|
||||
action_row.addWidget(single_button)
|
||||
|
||||
stop_button = QPushButton("Stop")
|
||||
stop_button.clicked.connect(owner._stop_run)
|
||||
action_row.addWidget(stop_button)
|
||||
|
||||
layout.addLayout(action_row)
|
||||
|
||||
hint = QLabel("Start continuous run or single processed collection capture.")
|
||||
hint.setObjectName("hintLabel")
|
||||
layout.addWidget(hint)
|
||||
return group
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Builder for preprocess set summary section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QFormLayout, QGroupBox, QLabel
|
||||
|
||||
|
||||
def build_preprocess_summary_group(owner) -> QGroupBox:
|
||||
"""Create selected calibration/reference summary section."""
|
||||
group = QGroupBox("Selected Preprocess Sets")
|
||||
form = QFormLayout(group)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
owner._selected_calibration_label = QLabel("<not selected>")
|
||||
owner._selected_reference_label = QLabel("<not selected>")
|
||||
|
||||
form.addRow("Calibration", owner._selected_calibration_label)
|
||||
form.addRow("Reference", owner._selected_reference_label)
|
||||
return group
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Builder for processing mode and live-parameter section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
def build_processing_group(owner) -> QGroupBox:
|
||||
"""Create processing mode section with pass-through and B-scan pages."""
|
||||
group = QGroupBox("Processing")
|
||||
form = QFormLayout(group)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
owner._processing_mode = QComboBox()
|
||||
owner._processing_mode.addItems(["pass_through", "bscan"])
|
||||
|
||||
owner._processing_mode_pages = QStackedWidget(group)
|
||||
owner._processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
|
||||
pass_through_page = QWidget(owner._processing_mode_pages)
|
||||
pass_through_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
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._show_magnitude_checkbox = QCheckBox("Show magnitude")
|
||||
owner._show_magnitude_checkbox.setChecked(True)
|
||||
|
||||
owner._show_phase_checkbox = QCheckBox("Show phase")
|
||||
owner._show_phase_checkbox.setChecked(True)
|
||||
|
||||
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(owner._show_magnitude_checkbox)
|
||||
pass_through_form.addRow(owner._show_phase_checkbox)
|
||||
owner._processing_mode_pages.addWidget(pass_through_page)
|
||||
|
||||
bscan_page = QWidget(owner._processing_mode_pages)
|
||||
bscan_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
bscan_form = QFormLayout(bscan_page)
|
||||
bscan_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
owner._bscan_axis = QComboBox()
|
||||
owner._bscan_axis.addItems(["abs", "real", "phase"])
|
||||
|
||||
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_max_depth_m = QDoubleSpinBox()
|
||||
owner._bscan_max_depth_m.setDecimals(1)
|
||||
owner._bscan_max_depth_m.setRange(0.1, 5.0)
|
||||
owner._bscan_max_depth_m.setSingleStep(0.1)
|
||||
owner._bscan_max_depth_m.setValue(1.0)
|
||||
|
||||
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_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_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_clear_history_button = QPushButton("Clear B-scan History")
|
||||
owner._bscan_clear_history_button.clicked.connect(owner._on_bscan_clear_history_clicked)
|
||||
owner._bscan_remove_last_button = QPushButton("Remove Last Sweep")
|
||||
owner._bscan_remove_last_button.clicked.connect(owner._on_bscan_remove_last_sweep_clicked)
|
||||
bscan_actions = QWidget(owner._processing_mode_pages)
|
||||
bscan_actions_layout = QHBoxLayout(bscan_actions)
|
||||
bscan_actions_layout.setContentsMargins(0, 0, 0, 0)
|
||||
bscan_actions_layout.setSpacing(8)
|
||||
bscan_actions_layout.addWidget(owner._bscan_remove_last_button)
|
||||
bscan_actions_layout.addWidget(owner._bscan_clear_history_button)
|
||||
|
||||
bscan_form.addRow("Axis", owner._bscan_axis)
|
||||
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)
|
||||
bscan_form.addRow("Start MHz", owner._bscan_start_freq_mhz)
|
||||
bscan_form.addRow("Stop MHz", owner._bscan_stop_freq_mhz)
|
||||
bscan_form.addRow(bscan_actions)
|
||||
owner._processing_mode_pages.addWidget(bscan_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._show_magnitude_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
||||
owner._show_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
||||
form.addRow("Mode", owner._processing_mode)
|
||||
form.addRow(owner._processing_mode_pages)
|
||||
|
||||
owner._bscan_axis.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)
|
||||
owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
|
||||
owner._on_processing_mode_changed(owner._processing_mode.currentText())
|
||||
return group
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Builder for radar settings section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QComboBox, QFormLayout, QGroupBox, QLabel, QLineEdit
|
||||
|
||||
|
||||
def build_radar_group(owner) -> QGroupBox:
|
||||
"""Create radar settings controls and labels."""
|
||||
group = QGroupBox("Radar")
|
||||
form = QFormLayout(group)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
defaults = owner._defaults_config.radar
|
||||
|
||||
owner._serial_input = QLineEdit(defaults.serial)
|
||||
owner._serial_input.setPlaceholderText("Optional: empty = auto-detect first LibreVNA")
|
||||
|
||||
owner._radar_mode = QComboBox()
|
||||
owner._radar_mode.addItems(["mock", "native"])
|
||||
owner._radar_mode.setToolTip("mock: synthetic signal, native: real LibreVNA hardware")
|
||||
owner._set_combo_current_text(owner._radar_mode, defaults.driver_mode)
|
||||
|
||||
owner._start_hz_input = QLineEdit(f"{defaults.sweep.start_hz:g}")
|
||||
owner._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}")
|
||||
owner._points_input = QLineEdit(str(defaults.sweep.points))
|
||||
owner._ifbw_input = QLineEdit(f"{defaults.sweep.if_bandwidth_hz:g}")
|
||||
owner._power_input = QLineEdit(f"{defaults.sweep.power_dbm:g}")
|
||||
owner._power_input.setToolTip("Device power limits are available only in native mode.")
|
||||
owner._serial_input.editingFinished.connect(owner._on_radar_identity_changed)
|
||||
owner._radar_mode.currentTextChanged.connect(owner._on_radar_identity_changed)
|
||||
owner._start_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
|
||||
owner._stop_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
|
||||
|
||||
owner._radar_start_label = QLabel("Start Hz")
|
||||
owner._radar_stop_label = QLabel("Stop Hz")
|
||||
owner._radar_points_label = QLabel("Points")
|
||||
owner._radar_ifbw_label = QLabel("IF BW Hz")
|
||||
owner._radar_power_label = QLabel("Stimulus Power dBm")
|
||||
|
||||
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)
|
||||
form.addRow(owner._radar_ifbw_label, owner._ifbw_input)
|
||||
form.addRow(owner._radar_power_label, owner._power_input)
|
||||
form.addRow(owner._radar_limits_hint)
|
||||
return group
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Builder for switch and combo settings section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLineEdit, QVBoxLayout
|
||||
|
||||
|
||||
def build_switch_group(owner) -> QGroupBox:
|
||||
"""Create input/output switch controls and run combos settings."""
|
||||
group = QGroupBox("Switches")
|
||||
layout = QVBoxLayout(group)
|
||||
input_defaults = owner._defaults_config.input_switch
|
||||
output_defaults = owner._defaults_config.output_switch
|
||||
|
||||
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()
|
||||
|
||||
input_group = QGroupBox("Input Switch (Radar Port 2)")
|
||||
input_form = QFormLayout(input_group)
|
||||
input_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
output_group = QGroupBox("Output Switch (Radar Port 1)")
|
||||
output_form = QFormLayout(output_group)
|
||||
output_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
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)
|
||||
|
||||
return group
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Application entry point for the PyQt GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
# Ensure imports are resolved when started as a script.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from python_app.gui.app_window import AppWindow
|
||||
from python_app.gui.theme import apply_dark_theme
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run Qt event loop and show main radar control window."""
|
||||
app = QApplication(sys.argv)
|
||||
apply_dark_theme(app)
|
||||
pg.setConfigOptions(antialias=True, foreground="#dbe4f1")
|
||||
window = AppWindow(PROJECT_ROOT)
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Plotting helpers for trace and B-scan visualization."""
|
||||
|
||||
from python_app.gui.plotting.bscan_history import (
|
||||
build_bscan_signature,
|
||||
pick_bscan_display_key,
|
||||
rebuild_bscan_history_from_results,
|
||||
)
|
||||
from python_app.gui.plotting.bscan_math import (
|
||||
bscan_levels,
|
||||
bscan_lookup_table,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"bscan_levels",
|
||||
"bscan_lookup_table",
|
||||
"build_bscan_signature",
|
||||
"pick_bscan_display_key",
|
||||
"rebuild_bscan_history_from_results",
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Helpers for B-scan history signatures and cache rebuilding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
|
||||
def _result_tail(
|
||||
*,
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
floor_collection_id: int,
|
||||
) -> list[ResultCollection]:
|
||||
"""Return filtered and de-duplicated result-history tail for B-scan usage."""
|
||||
filtered = [
|
||||
collection
|
||||
for collection in result_history[-history_limit:]
|
||||
if int(collection.collection_id) > int(floor_collection_id)
|
||||
]
|
||||
unique_tail: list[ResultCollection] = []
|
||||
seen_keys: set[tuple[int, int]] = set()
|
||||
for collection in filtered:
|
||||
key = (int(collection.collection_id), int(collection.monotonic_ns))
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
unique_tail.append(collection)
|
||||
return unique_tail
|
||||
|
||||
|
||||
def build_bscan_signature(
|
||||
live_config: ProcessingLiveConfig,
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
floor_collection_id: int,
|
||||
) -> tuple[object, ...]:
|
||||
"""Build deterministic signature used to detect B-scan cache invalidation."""
|
||||
result_tail = _result_tail(
|
||||
result_history=result_history,
|
||||
history_limit=history_limit,
|
||||
floor_collection_id=floor_collection_id,
|
||||
)
|
||||
return (
|
||||
str(live_config.bscan_axis),
|
||||
float(live_config.bscan_cut_m),
|
||||
float(live_config.bscan_max_depth_m),
|
||||
float(live_config.bscan_gain),
|
||||
float(live_config.bscan_start_freq_mhz),
|
||||
float(live_config.bscan_stop_freq_mhz),
|
||||
int(floor_collection_id),
|
||||
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
|
||||
)
|
||||
|
||||
|
||||
def rebuild_bscan_history_from_results(
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
floor_collection_id: int,
|
||||
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
|
||||
"""Rebuild B-scan history and depth axes from processed result payloads."""
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
||||
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
||||
|
||||
result_tail = _result_tail(
|
||||
result_history=result_history,
|
||||
history_limit=history_limit,
|
||||
floor_collection_id=floor_collection_id,
|
||||
)
|
||||
|
||||
for collection in result_tail:
|
||||
for block in collection.blocks:
|
||||
key = (block.combo.input_pos, block.combo.output_pos)
|
||||
for payload in block.payloads:
|
||||
if payload.kind != 1 or payload.processing_name != "bscan":
|
||||
continue
|
||||
if payload.frequency_hz.size == 0 or payload.trace.size == 0:
|
||||
continue
|
||||
if payload.frequency_hz.size != payload.trace.size:
|
||||
continue
|
||||
|
||||
depth_axis = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||
amplitudes = np.asarray(np.real(payload.trace), dtype=np.float32)
|
||||
if depth_axis.size == 0 or amplitudes.size == 0:
|
||||
continue
|
||||
|
||||
history = history_by_combo.get(key)
|
||||
stored_axis = depth_axis_by_combo.get(key)
|
||||
if (
|
||||
history is None
|
||||
or stored_axis is None
|
||||
or stored_axis.shape != depth_axis.shape
|
||||
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
|
||||
):
|
||||
history = deque(maxlen=history_limit)
|
||||
history_by_combo[key] = history
|
||||
depth_axis_by_combo[key] = depth_axis.copy()
|
||||
|
||||
history.append(amplitudes.copy())
|
||||
|
||||
return history_by_combo, depth_axis_by_combo
|
||||
|
||||
|
||||
def pick_bscan_display_key(
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
|
||||
) -> tuple[int, int] | None:
|
||||
"""Choose combo key to display when multiple histories are present."""
|
||||
if not history_by_combo:
|
||||
return None
|
||||
return next(iter(history_by_combo.keys()))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Color-scaling helpers for B-scan visualization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
def bscan_lookup_table(axis_mode: str) -> np.ndarray:
|
||||
"""Build B-scan colormap table for selected axis mode."""
|
||||
if axis_mode == "abs":
|
||||
return build_lut(["#440154", "#31688e", "#35b779", "#fde725"])
|
||||
return build_lut(["#2166ac", "#67a9cf", "#f7f7f7", "#ef8a62", "#b2182b"])
|
||||
|
||||
|
||||
def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
|
||||
"""Interpolate hex color stops into 8-bit RGB LUT array."""
|
||||
stop_positions = np.linspace(0.0, 1.0, num=len(stops), dtype=np.float32)
|
||||
sample_positions = np.linspace(0.0, 1.0, num=size, dtype=np.float32)
|
||||
stop_colors = np.asarray([pg.mkColor(value).getRgb()[:3] for value in stops], dtype=np.float32)
|
||||
|
||||
lut = np.empty((size, 3), dtype=np.uint8)
|
||||
for channel in range(3):
|
||||
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
|
||||
return lut
|
||||
|
||||
|
||||
def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
|
||||
"""Compute image levels for B-scan data based on axis mode."""
|
||||
min_value = float(np.min(sweeps))
|
||||
max_value = float(np.max(sweeps))
|
||||
if axis_mode == "abs":
|
||||
if max_value <= min_value:
|
||||
return min_value, min_value + 1e-6
|
||||
return min_value, max_value
|
||||
|
||||
max_abs = max(abs(min_value), abs(max_value), 1e-6)
|
||||
return -max_abs, max_abs
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Dialog for calibration/reference set selection and sequential capture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
from PyQt6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
import pyqtgraph as pg
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import TraceData
|
||||
|
||||
|
||||
class PreprocessDialog(QDialog):
|
||||
"""Standalone dialog for preprocessing capture workflows."""
|
||||
|
||||
refresh_requested = pyqtSignal()
|
||||
selection_changed = pyqtSignal(str, str)
|
||||
start_sequence_requested = pyqtSignal(str)
|
||||
capture_next_requested = pyqtSignal()
|
||||
abort_sequence_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
"""Create dialog and build all widgets."""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Preprocessing Setup")
|
||||
self.resize(1040, 760)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build dialog layout, controls, and preview plot."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
sets_group = QGroupBox("Calibration / Reference Sets", self)
|
||||
sets_layout = QGridLayout(sets_group)
|
||||
|
||||
self._set_name_input = QLineEdit("set_001", sets_group)
|
||||
self._calibration_combo = QComboBox(sets_group)
|
||||
self._reference_combo = QComboBox(sets_group)
|
||||
|
||||
refresh_button = QPushButton("Refresh Sets", sets_group)
|
||||
refresh_button.clicked.connect(self.refresh_requested.emit)
|
||||
|
||||
self._calibration_combo.currentTextChanged.connect(self._emit_selection_changed)
|
||||
self._reference_combo.currentTextChanged.connect(self._emit_selection_changed)
|
||||
|
||||
sets_layout.addWidget(QLabel("Set name"), 0, 0)
|
||||
sets_layout.addWidget(self._set_name_input, 0, 1)
|
||||
sets_layout.addWidget(refresh_button, 0, 2)
|
||||
|
||||
sets_layout.addWidget(QLabel("Calibration set"), 1, 0)
|
||||
sets_layout.addWidget(self._calibration_combo, 1, 1, 1, 2)
|
||||
|
||||
sets_layout.addWidget(QLabel("Reference set"), 2, 0)
|
||||
sets_layout.addWidget(self._reference_combo, 2, 1, 1, 2)
|
||||
|
||||
layout.addWidget(sets_group)
|
||||
|
||||
sequence_group = QGroupBox("Sequential Capture (Fill Full N*M)", self)
|
||||
sequence_layout = QGridLayout(sequence_group)
|
||||
|
||||
self._active_kind_label = QLabel("<none>", sequence_group)
|
||||
self._progress_label = QLabel("0 / 0", sequence_group)
|
||||
self._combo_label = QLabel("<none>", sequence_group)
|
||||
|
||||
self._tx_antenna_label_input = QLineEdit(sequence_group)
|
||||
self._rx_antenna_label_input = QLineEdit(sequence_group)
|
||||
self._tx_antenna_label_input.setPlaceholderText("e.g. TX_A")
|
||||
self._rx_antenna_label_input.setPlaceholderText("e.g. RX_B")
|
||||
|
||||
start_calibration_button = QPushButton("Start Calibration Sequence", sequence_group)
|
||||
start_calibration_button.clicked.connect(lambda: self.start_sequence_requested.emit("calibration"))
|
||||
|
||||
start_reference_button = QPushButton("Start Reference Sequence", sequence_group)
|
||||
start_reference_button.clicked.connect(lambda: self.start_sequence_requested.emit("reference"))
|
||||
|
||||
self._capture_next_button = QPushButton("Capture Current Combo", sequence_group)
|
||||
self._capture_next_button.clicked.connect(self.capture_next_requested.emit)
|
||||
self._capture_next_button.setEnabled(False)
|
||||
|
||||
self._abort_button = QPushButton("Abort Sequence", sequence_group)
|
||||
self._abort_button.clicked.connect(self.abort_sequence_requested.emit)
|
||||
self._abort_button.setEnabled(False)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
button_row.addWidget(start_calibration_button)
|
||||
button_row.addWidget(start_reference_button)
|
||||
button_row.addWidget(self._capture_next_button)
|
||||
button_row.addWidget(self._abort_button)
|
||||
|
||||
sequence_layout.addWidget(QLabel("Active type"), 0, 0)
|
||||
sequence_layout.addWidget(self._active_kind_label, 0, 1)
|
||||
sequence_layout.addWidget(QLabel("Progress"), 1, 0)
|
||||
sequence_layout.addWidget(self._progress_label, 1, 1)
|
||||
sequence_layout.addWidget(QLabel("Current combo"), 2, 0)
|
||||
sequence_layout.addWidget(self._combo_label, 2, 1)
|
||||
sequence_layout.addWidget(QLabel("TX antenna label"), 3, 0)
|
||||
sequence_layout.addWidget(self._tx_antenna_label_input, 3, 1)
|
||||
sequence_layout.addWidget(QLabel("RX antenna label"), 4, 0)
|
||||
sequence_layout.addWidget(self._rx_antenna_label_input, 4, 1)
|
||||
sequence_layout.addLayout(button_row, 5, 0, 1, 2)
|
||||
|
||||
self._capture_log = QPlainTextEdit(sequence_group)
|
||||
self._capture_log.setReadOnly(True)
|
||||
self._capture_log.setPlaceholderText("Capture history per combo")
|
||||
sequence_layout.addWidget(self._capture_log, 6, 0, 1, 2)
|
||||
|
||||
layout.addWidget(sequence_group)
|
||||
|
||||
self._status = QLabel("Ready", self)
|
||||
layout.addWidget(self._status)
|
||||
|
||||
self._plot = pg.PlotWidget(background="#101418")
|
||||
self._plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
self._plot.setLabel("left", "Magnitude", units="dB")
|
||||
layout.addWidget(self._plot, stretch=1)
|
||||
|
||||
def set_name(self) -> str:
|
||||
"""Return requested target set name."""
|
||||
return self._set_name_input.text().strip()
|
||||
|
||||
def calibration_set(self) -> str:
|
||||
"""Return currently selected calibration set."""
|
||||
return self._calibration_combo.currentText().strip()
|
||||
|
||||
def reference_set(self) -> str:
|
||||
"""Return currently selected reference set."""
|
||||
return self._reference_combo.currentText().strip()
|
||||
|
||||
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()
|
||||
|
||||
def clear_capture_log(self) -> None:
|
||||
"""Clear capture history text box."""
|
||||
self._capture_log.clear()
|
||||
|
||||
def append_capture_log_entry(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
captured_count: int,
|
||||
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}"
|
||||
)
|
||||
|
||||
def set_capture_state(
|
||||
self,
|
||||
*,
|
||||
kind: str | None,
|
||||
captured_count: int,
|
||||
total_count: int,
|
||||
next_input: int | None,
|
||||
next_output: int | None,
|
||||
) -> None:
|
||||
"""Update sequence progress/status widgets."""
|
||||
if kind is None:
|
||||
self._active_kind_label.setText("<none>")
|
||||
self._progress_label.setText("0 / 0")
|
||||
self._combo_label.setText("<none>")
|
||||
self._capture_next_button.setEnabled(False)
|
||||
self._abort_button.setEnabled(False)
|
||||
return
|
||||
|
||||
self._active_kind_label.setText(kind)
|
||||
self._progress_label.setText(f"{captured_count} / {total_count}")
|
||||
if next_input is None or next_output is None:
|
||||
self._combo_label.setText("<complete>")
|
||||
self._capture_next_button.setEnabled(False)
|
||||
self._abort_button.setEnabled(True)
|
||||
else:
|
||||
self._combo_label.setText(f"input={next_input}, output={next_output}")
|
||||
self._capture_next_button.setEnabled(True)
|
||||
self._abort_button.setEnabled(True)
|
||||
|
||||
def set_calibration_sets(self, names: list[str]) -> None:
|
||||
"""Replace calibration set choices while preserving current selection when possible."""
|
||||
self._set_combo_items(self._calibration_combo, names, self.calibration_set())
|
||||
|
||||
def set_reference_sets(self, names: list[str]) -> None:
|
||||
"""Replace reference set choices while preserving current selection when possible."""
|
||||
self._set_combo_items(self._reference_combo, names, self.reference_set())
|
||||
|
||||
def set_selected_sets(self, calibration_set: str, reference_set: str) -> None:
|
||||
"""Apply selected set names to both comboboxes and emit selection update."""
|
||||
if calibration_set:
|
||||
index = self._calibration_combo.findText(calibration_set)
|
||||
if index >= 0:
|
||||
self._calibration_combo.setCurrentIndex(index)
|
||||
|
||||
if reference_set:
|
||||
index = self._reference_combo.findText(reference_set)
|
||||
if index >= 0:
|
||||
self._reference_combo.setCurrentIndex(index)
|
||||
|
||||
self._emit_selection_changed()
|
||||
|
||||
def set_status(self, message: str) -> None:
|
||||
"""Set short human-readable status line."""
|
||||
self._status.setText(message)
|
||||
|
||||
def draw_last_trace(self, trace: TraceData, title: str) -> None:
|
||||
"""Draw the latest captured sweep trace in dB scale."""
|
||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12))
|
||||
self._plot.clear()
|
||||
self._plot.plot(
|
||||
trace.frequency_hz,
|
||||
magnitude_db,
|
||||
pen=pg.mkPen("#4cc9f0", width=1.8),
|
||||
)
|
||||
combo = trace.combo
|
||||
self._status.setText(
|
||||
f"{title}: input={combo.input_pos}, output={combo.output_pos}, points={trace.frequency_hz.size}"
|
||||
)
|
||||
|
||||
def _emit_selection_changed(self) -> None:
|
||||
"""Emit current calibration/reference selection."""
|
||||
self.selection_changed.emit(self.calibration_set(), self.reference_set())
|
||||
|
||||
@staticmethod
|
||||
def _set_combo_items(combo: QComboBox, names: list[str], current_text: str) -> None:
|
||||
"""Replace combo contents and keep previous value when still available."""
|
||||
combo.clear()
|
||||
combo.addItems(names)
|
||||
if not current_text:
|
||||
return
|
||||
index = combo.findText(current_text)
|
||||
if index >= 0:
|
||||
combo.setCurrentIndex(index)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Runtime helpers for GUI polling, history management, and constraints."""
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.gui.runtime.history import (
|
||||
build_run_history_signature,
|
||||
record_result_history,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_run_history_signature",
|
||||
"record_result_history",
|
||||
"validate_processing_mode_constraints",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Validation helpers for GUI processing mode constraints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
|
||||
|
||||
def validate_processing_mode_constraints(processing_mode: str, config: RunConfigModel) -> None:
|
||||
"""Validate mode-specific constraints for current run configuration."""
|
||||
if processing_mode != "bscan":
|
||||
return
|
||||
|
||||
any_native_switch = config.input_switch.driver_mode == "native" or config.output_switch.driver_mode == "native"
|
||||
if not any_native_switch:
|
||||
return
|
||||
|
||||
combo_count = len({(int(combo.input), int(combo.output)) for combo in config.combos})
|
||||
if combo_count != 1:
|
||||
raise RuntimeError(
|
||||
f"B-scan with native switches requires exactly one run combo (now {combo_count})"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Helpers for GUI-side runtime history management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
|
||||
|
||||
def record_result_history(
|
||||
result_history: deque[ResultCollection],
|
||||
collection: ResultCollection,
|
||||
) -> bool:
|
||||
"""Append new result or replace existing entry by stable collection key."""
|
||||
for index in range(len(result_history) - 1, -1, -1):
|
||||
existing = result_history[index]
|
||||
if (
|
||||
existing.collection_id == collection.collection_id
|
||||
and existing.monotonic_ns == collection.monotonic_ns
|
||||
):
|
||||
result_history[index] = collection
|
||||
return True
|
||||
|
||||
result_history.append(collection)
|
||||
return True
|
||||
|
||||
|
||||
def build_run_history_signature(
|
||||
config: RunConfigModel,
|
||||
) -> tuple[object, ...]:
|
||||
"""Build deterministic signature to detect run-settings changes (excluding live processing params)."""
|
||||
combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos)
|
||||
return (
|
||||
str(config.radar.driver_mode),
|
||||
str(config.radar.serial),
|
||||
float(config.radar.sweep.start_hz),
|
||||
float(config.radar.sweep.stop_hz),
|
||||
int(config.radar.sweep.points),
|
||||
float(config.radar.sweep.if_bandwidth_hz),
|
||||
float(config.radar.sweep.power_dbm),
|
||||
str(config.input_switch.driver_mode),
|
||||
str(config.input_switch.driver),
|
||||
int(config.input_switch.positions),
|
||||
bool(config.input_switch.invert_logic),
|
||||
str(config.output_switch.driver_mode),
|
||||
str(config.output_switch.driver),
|
||||
int(config.output_switch.positions),
|
||||
bool(config.output_switch.invert_logic),
|
||||
str(config.preprocess.calibration_set),
|
||||
str(config.preprocess.reference_set),
|
||||
combos_signature,
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Application-wide Qt palette and stylesheet configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtGui import QColor, QPalette
|
||||
from PyQt6.QtWidgets import QApplication, QStyleFactory
|
||||
|
||||
|
||||
_DARK_STYLESHEET = """
|
||||
QMainWindow, QDialog {
|
||||
background-color: #0f131a;
|
||||
}
|
||||
|
||||
QWidget {
|
||||
color: #e7edf7;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
QGroupBox {
|
||||
background-color: #151b24;
|
||||
border: 1px solid #263142;
|
||||
border-radius: 10px;
|
||||
margin-top: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 6px;
|
||||
color: #9fb4ce;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #1d2735;
|
||||
border: 1px solid #314055;
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #243246;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #1a2432;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #6b7d95;
|
||||
background-color: #151d27;
|
||||
border-color: #232e3d;
|
||||
}
|
||||
|
||||
QPushButton#settingsToggleButton {
|
||||
min-width: 26px;
|
||||
max-width: 26px;
|
||||
padding: 6px 0px;
|
||||
border-radius: 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QComboBox,
|
||||
QSpinBox,
|
||||
QDoubleSpinBox {
|
||||
background-color: #101721;
|
||||
border: 1px solid #2e3b4e;
|
||||
border-radius: 7px;
|
||||
padding: 5px 8px;
|
||||
selection-background-color: #2f7ee6;
|
||||
}
|
||||
|
||||
QLineEdit:focus,
|
||||
QPlainTextEdit:focus,
|
||||
QComboBox:focus,
|
||||
QSpinBox:focus,
|
||||
QDoubleSpinBox:focus {
|
||||
border: 1px solid #5d88bd;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
QScrollArea {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QLabel#statusLabel {
|
||||
color: #94b4d9;
|
||||
font-weight: 600;
|
||||
padding: 2px 1px;
|
||||
}
|
||||
|
||||
QLabel#hintLabel {
|
||||
color: #7f94af;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def apply_dark_theme(app: QApplication) -> None:
|
||||
"""Apply a minimal modern dark theme shared by all windows."""
|
||||
app.setStyle(QStyleFactory.create("Fusion"))
|
||||
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.ColorRole.Window, QColor("#0f131a"))
|
||||
palette.setColor(QPalette.ColorRole.WindowText, QColor("#e7edf7"))
|
||||
palette.setColor(QPalette.ColorRole.Base, QColor("#101721"))
|
||||
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#151b24"))
|
||||
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#151b24"))
|
||||
palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#e7edf7"))
|
||||
palette.setColor(QPalette.ColorRole.Text, QColor("#e7edf7"))
|
||||
palette.setColor(QPalette.ColorRole.Button, QColor("#1d2735"))
|
||||
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#e7edf7"))
|
||||
palette.setColor(QPalette.ColorRole.BrightText, QColor("#ffffff"))
|
||||
palette.setColor(QPalette.ColorRole.Link, QColor("#5d88bd"))
|
||||
palette.setColor(QPalette.ColorRole.Highlight, QColor("#2f7ee6"))
|
||||
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
|
||||
app.setPalette(palette)
|
||||
app.setStyleSheet(_DARK_STYLESHEET)
|
||||
@@ -0,0 +1 @@
|
||||
"""Hardware integration package for radar and switch devices."""
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Backend adapters used by :mod:`python_app.hardware_full.librevna_service`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import math
|
||||
from typing import Any, Protocol
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.run_config_model import RadarSweepModel
|
||||
|
||||
|
||||
class LibreVnaBackend(Protocol):
|
||||
"""Minimal backend contract required by `LibreVnaService`."""
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open backend resources."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close backend resources."""
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Return whether backend currently holds open runtime resources."""
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Apply sweep settings."""
|
||||
|
||||
def read_device_limits(self) -> dict[str, float | int]:
|
||||
"""Query runtime device limits."""
|
||||
|
||||
def acquire_s21(self) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Acquire one S21 trace."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NativeLibreVnaBackend:
|
||||
"""Native backend using direct USB LibreVNA Python driver."""
|
||||
|
||||
serial: str | None
|
||||
strict_protocol_version: int
|
||||
_device: Any | None = field(init=False, default=None, repr=False)
|
||||
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
|
||||
_libre_vna_device: type[Any] | None = field(init=False, default=None, repr=False)
|
||||
_vna_sweep_settings: type[Any] | None = field(init=False, default=None, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Import native driver classes lazily and initialize backend state."""
|
||||
from python_app.hardware_full.librevna_driver import LibreVNADevice, VNASweepSettings
|
||||
|
||||
self._device: LibreVNADevice | None = None
|
||||
self._settings: RadarSweepModel | None = None
|
||||
self._libre_vna_device = LibreVNADevice
|
||||
self._vna_sweep_settings = VNASweepSettings
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open device connection if it is not already open."""
|
||||
if self._device is not None:
|
||||
return
|
||||
|
||||
self._device = self._libre_vna_device()
|
||||
self._device.connect(serial=self.serial, strict_protocol_version=self.strict_protocol_version, timeout_s=2.0)
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Return `True` when native device connection is open."""
|
||||
return self._device is not None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close device connection when open."""
|
||||
if self._device is None:
|
||||
return
|
||||
self._device.disconnect()
|
||||
self._device = None
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Store and apply sweep settings to connected hardware."""
|
||||
self._settings = sweep
|
||||
if self._device is None:
|
||||
return
|
||||
|
||||
settings = self._vna_sweep_settings(
|
||||
f_start_hz=sweep.start_hz,
|
||||
f_stop_hz=sweep.stop_hz,
|
||||
points=sweep.points,
|
||||
if_bandwidth_hz=sweep.if_bandwidth_hz,
|
||||
power_start_dbm=sweep.power_dbm,
|
||||
power_stop_dbm=sweep.power_dbm,
|
||||
excited_ports=(1, 2),
|
||||
)
|
||||
self._device.vna.configure(settings)
|
||||
|
||||
def read_device_limits(self) -> dict[str, float | int]:
|
||||
"""Read frequency/IFBW/power/points limits from connected device."""
|
||||
if self._device is None:
|
||||
raise RuntimeError("Failed to connect to LibreVNA device")
|
||||
|
||||
device_info = self._device.get_device_info()
|
||||
limits = device_info.limits
|
||||
return {
|
||||
"min_frequency_hz": float(limits.min_frequency_hz),
|
||||
"max_frequency_hz": float(limits.max_frequency_hz),
|
||||
"min_ifbw_hz": float(limits.min_ifbw_hz),
|
||||
"max_ifbw_hz": float(limits.max_ifbw_hz),
|
||||
"max_points": int(limits.max_points),
|
||||
"min_power_dbm": float(limits.min_power_dbm),
|
||||
"max_power_dbm": float(limits.max_power_dbm),
|
||||
}
|
||||
|
||||
def acquire_s21(self) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Acquire one S21 sweep from hardware."""
|
||||
if self._settings is None:
|
||||
raise RuntimeError("Radar service is not configured")
|
||||
if self._device is None:
|
||||
raise RuntimeError("Device not found")
|
||||
|
||||
result = self._device.vna.acquire(expected_points=self._settings.points, timeout_s=20.0)
|
||||
return np.asarray(result.x, dtype=np.float32), np.asarray(result.trace("s21"), dtype=np.complex64)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MockLibreVnaBackend:
|
||||
"""Synthetic backend used for local development and tests."""
|
||||
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
|
||||
_mock_phase: float = field(init=False, default=0.0, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Initialize mock backend state."""
|
||||
self._settings = None
|
||||
self._mock_phase = 0.0
|
||||
|
||||
def open(self) -> None:
|
||||
"""No-op for mock backend."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""No-op for mock backend."""
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Mock backend does not hold external resources."""
|
||||
return False
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Store sweep settings used by synthetic acquisition."""
|
||||
self._settings = sweep
|
||||
|
||||
def read_device_limits(self) -> dict[str, float | int]:
|
||||
"""Mock backend does not support native device limits queries."""
|
||||
raise RuntimeError("LibreVNA Python driver is not available")
|
||||
|
||||
def acquire_s21(self) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Generate synthetic S21 values using deterministic phase envelope."""
|
||||
if self._settings is None:
|
||||
raise RuntimeError("Radar service is not configured")
|
||||
|
||||
points = self._settings.points
|
||||
freq = np.linspace(self._settings.start_hz, self._settings.stop_hz, points, dtype=np.float32)
|
||||
phase = (2.0 * math.pi * np.linspace(0.0, 1.0, points, dtype=np.float32)) + self._mock_phase
|
||||
envelope = 0.6 + 0.4 * np.sin(phase * 0.5)
|
||||
s21 = (envelope * np.cos(phase) + 1j * envelope * np.sin(phase)).astype(np.complex64)
|
||||
self._mock_phase += 0.05
|
||||
return freq, s21
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Python driver for direct USB control of LibreVNA (protocol v14)."""
|
||||
|
||||
import logging
|
||||
|
||||
from .device import LibreVNADevice
|
||||
from .enums import (
|
||||
HardwareFamily,
|
||||
PacketType,
|
||||
SParameter,
|
||||
SweepKind,
|
||||
SweepScale,
|
||||
SyncMode,
|
||||
)
|
||||
from .logging_utils import DEFAULT_LOG_LEVEL, configure_logging
|
||||
from .exceptions import (
|
||||
CRCError,
|
||||
DeviceDisconnectedError,
|
||||
IncompleteSweepError,
|
||||
LibreVNAError,
|
||||
NackError,
|
||||
ParseError,
|
||||
ProtocolVersionMismatch,
|
||||
TimeoutError,
|
||||
UnsupportedHardwareError,
|
||||
)
|
||||
from .models import (
|
||||
DeviceConfigVariant,
|
||||
DeviceInfo,
|
||||
DeviceLimits,
|
||||
DeviceStatus,
|
||||
Packet,
|
||||
StreamHandle,
|
||||
SweepResult,
|
||||
USBDeviceDescriptor,
|
||||
VNADatapointPacket,
|
||||
VNARawPoint,
|
||||
VNASweepSettings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CRCError",
|
||||
"DEFAULT_LOG_LEVEL",
|
||||
"DeviceConfigVariant",
|
||||
"DeviceDisconnectedError",
|
||||
"DeviceInfo",
|
||||
"DeviceLimits",
|
||||
"DeviceStatus",
|
||||
"HardwareFamily",
|
||||
"IncompleteSweepError",
|
||||
"LibreVNADevice",
|
||||
"LibreVNAError",
|
||||
"NackError",
|
||||
"Packet",
|
||||
"PacketType",
|
||||
"ParseError",
|
||||
"ProtocolVersionMismatch",
|
||||
"configure_logging",
|
||||
"SParameter",
|
||||
"StreamHandle",
|
||||
"SweepKind",
|
||||
"SweepResult",
|
||||
"SweepScale",
|
||||
"SyncMode",
|
||||
"TimeoutError",
|
||||
"USBDeviceDescriptor",
|
||||
"UnsupportedHardwareError",
|
||||
"VNADatapointPacket",
|
||||
"VNARawPoint",
|
||||
"VNASweepSettings",
|
||||
]
|
||||
|
||||
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Public controller classes."""
|
||||
|
||||
from .config import ConfigController
|
||||
from .vna import VNAController
|
||||
|
||||
__all__ = [
|
||||
"ConfigController",
|
||||
"VNAController",
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Device configuration controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..enums import PacketType
|
||||
from ..exceptions import ParseError
|
||||
from ..models import DeviceConfigVariant, Packet
|
||||
from ..protocol import parse_device_config
|
||||
from ..session import LibreVNASession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigController:
|
||||
"""Device configuration read/write/reset operations."""
|
||||
|
||||
def __init__(self, session: LibreVNASession) -> None:
|
||||
"""Bind controller to an active session."""
|
||||
self._session = session
|
||||
|
||||
def get(self, *, timeout_s: float = 1.0) -> DeviceConfigVariant:
|
||||
"""Read configuration block for active hardware family."""
|
||||
packet = self._session.request(
|
||||
PacketType.REQUEST_DEVICE_CONFIGURATION,
|
||||
PacketType.DEVICE_CONFIGURATION,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if not isinstance(packet.payload, (bytes, bytearray, memoryview)):
|
||||
raise ParseError("DeviceConfiguration payload has unexpected type")
|
||||
cfg = parse_device_config(bytes(packet.payload), self._session.hardware_family)
|
||||
logger.info(
|
||||
"Loaded device configuration for family=%s fields=%d",
|
||||
cfg.family.name,
|
||||
len(cfg.values),
|
||||
)
|
||||
return cfg
|
||||
|
||||
def set(self, cfg: DeviceConfigVariant, *, timeout_s: float = 1.0) -> None:
|
||||
"""Write configuration block for active hardware family."""
|
||||
if cfg.family != self._session.hardware_family:
|
||||
raise ParseError("DeviceConfigVariant family does not match connected hardware family")
|
||||
logger.info(
|
||||
"Writing device configuration for family=%s fields=%d",
|
||||
cfg.family.name,
|
||||
len(cfg.values),
|
||||
)
|
||||
self._session.send(Packet(PacketType.DEVICE_CONFIGURATION, cfg), require_ack=True, timeout_s=timeout_s)
|
||||
|
||||
def reset(self, *, timeout_s: float = 1.0) -> None:
|
||||
"""Reset device configuration to firmware defaults."""
|
||||
logger.warning("Resetting device configuration to firmware defaults")
|
||||
self._session.send(Packet(PacketType.RESET_DEVICE_CONFIGURATION), require_ack=True, timeout_s=timeout_s)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""VNA high-level controller for direct protocol packets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from ..enums import PacketType
|
||||
from ..exceptions import ParseError
|
||||
from ..models import Packet, StreamHandle, SweepResult, VNADatapointPacket, VNARawPoint, VNASweepSettings
|
||||
from ..session import LibreVNASession
|
||||
from ..sweep.assembler import assemble_vna_sweep, datapoint_to_raw_point
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VNAController:
|
||||
"""VNA operations built on direct packet protocol."""
|
||||
|
||||
def __init__(self, session: LibreVNASession) -> None:
|
||||
"""Bind VNA controller to active session."""
|
||||
self._session = session
|
||||
self._settings: VNASweepSettings | None = None
|
||||
|
||||
def configure(self, settings: VNASweepSettings) -> None:
|
||||
"""Send `SweepSettings` packet to configure VNA operation."""
|
||||
effective = settings
|
||||
if effective.sync_mode is None:
|
||||
effective = replace(effective, sync_mode=self._session.default_sync_mode)
|
||||
self._settings = effective
|
||||
|
||||
self._session.clear_queue(PacketType.VNA_DATAPOINT)
|
||||
self._session.send(Packet(PacketType.SWEEP_SETTINGS, effective), require_ack=True)
|
||||
logger.info(
|
||||
"VNA configured: kind=%s start=%.3fHz stop=%.3fHz points=%d ifbw=%.3fHz ports=%s",
|
||||
effective.kind.value,
|
||||
effective.f_start_hz,
|
||||
effective.f_stop_hz,
|
||||
effective.points,
|
||||
effective.if_bandwidth_hz,
|
||||
effective.excited_ports,
|
||||
)
|
||||
|
||||
def acquire(self, *, expected_points: int | None = None, timeout_s: float = 10.0) -> SweepResult:
|
||||
"""Acquire one complete sweep and return assembled complex traces."""
|
||||
if self._settings is None:
|
||||
raise ParseError("VNA is not configured. Call vna.configure() before acquire().")
|
||||
|
||||
settings = self._settings
|
||||
points_target = expected_points if expected_points is not None else settings.points
|
||||
if points_target <= 0:
|
||||
raise ValueError("expected_points must be > 0")
|
||||
|
||||
logger.info(
|
||||
"VNA acquire start: expected_points=%d timeout=%.2fs standby=%s",
|
||||
points_target,
|
||||
timeout_s,
|
||||
settings.standby,
|
||||
)
|
||||
self._session.clear_queue(PacketType.VNA_DATAPOINT)
|
||||
if settings.standby:
|
||||
self._session.send(Packet(PacketType.INITIATE_SWEEP), require_ack=True)
|
||||
|
||||
ordered = self._session.collect_indexed_payloads(
|
||||
packet_type=PacketType.VNA_DATAPOINT,
|
||||
expected_points=points_target,
|
||||
timeout_s=timeout_s,
|
||||
payload_type=VNADatapointPacket,
|
||||
payload_error="Expected decoded VNADatapointPacket payload",
|
||||
)
|
||||
|
||||
device_info = self._session.get_device_info()
|
||||
result = assemble_vna_sweep(
|
||||
ordered,
|
||||
settings,
|
||||
num_ports=device_info.num_ports,
|
||||
expected_points=points_target,
|
||||
)
|
||||
logger.info("VNA acquire complete: received_points=%d", len(result.x))
|
||||
return result
|
||||
|
||||
def stream(self, callback: Callable[[VNARawPoint], None]) -> StreamHandle:
|
||||
"""Subscribe callback for every incoming VNA datapoint packet."""
|
||||
if self._settings is None:
|
||||
raise ParseError("VNA is not configured. Call vna.configure() before stream().")
|
||||
|
||||
settings = self._settings
|
||||
num_ports = self._session.get_device_info().num_ports
|
||||
|
||||
def _on_packet(packet: Packet) -> None:
|
||||
"""Decode datapoint packet and forward mapped raw point to callback."""
|
||||
payload = packet.payload
|
||||
if not isinstance(payload, VNADatapointPacket):
|
||||
raise ParseError("Expected decoded VNADatapointPacket payload")
|
||||
callback(datapoint_to_raw_point(payload, settings, num_ports=num_ports))
|
||||
|
||||
logger.info("VNA stream subscription started")
|
||||
return self._session.subscribe(PacketType.VNA_DATAPOINT, _on_packet)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Main user-facing LibreVNA direct-USB device class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import TracebackType
|
||||
|
||||
from .api.config import ConfigController
|
||||
from .api.vna import VNAController
|
||||
from .enums import PacketType
|
||||
from .models import DeviceInfo, DeviceStatus, Packet, USBDeviceDescriptor
|
||||
from .session import LibreVNASession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibreVNADevice:
|
||||
"""Main entry point for direct USB control of LibreVNA."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create device facade with session-backed VNA/config controllers."""
|
||||
self._session = LibreVNASession()
|
||||
|
||||
self.vna = VNAController(self._session)
|
||||
self.config = ConfigController(self._session)
|
||||
|
||||
def __enter__(self) -> LibreVNADevice:
|
||||
"""Return self to support context-managed lifetime in host applications."""
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
"""Always close USB session when leaving context manager block."""
|
||||
self.disconnect()
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return connection state of underlying USB session."""
|
||||
return self._session.is_connected
|
||||
|
||||
@property
|
||||
def connected_serial(self) -> str | None:
|
||||
"""Return currently connected serial number when available."""
|
||||
return self._session.connected_serial
|
||||
|
||||
@staticmethod
|
||||
def list_devices() -> list[USBDeviceDescriptor]:
|
||||
"""Return all currently discoverable LibreVNA USB devices."""
|
||||
devices = LibreVNASession.list_devices()
|
||||
logger.debug("Discovered %d LibreVNA USB device(s)", len(devices))
|
||||
return devices
|
||||
|
||||
def connect(
|
||||
self,
|
||||
serial: str | None = None,
|
||||
strict_protocol_version: int = 14,
|
||||
timeout_s: float = 1.0,
|
||||
) -> None:
|
||||
"""Connect to a LibreVNA device by optional serial number."""
|
||||
logger.info(
|
||||
"Connecting to LibreVNA (serial=%s, strict_protocol=%d, timeout=%.2fs)",
|
||||
serial,
|
||||
strict_protocol_version,
|
||||
timeout_s,
|
||||
)
|
||||
self._session.connect(
|
||||
serial=serial,
|
||||
strict_protocol_version=strict_protocol_version,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
logger.info("Connected to LibreVNA (serial=%s)", self.connected_serial)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect USB transport and clear runtime state."""
|
||||
logger.info("Disconnecting LibreVNA session")
|
||||
self._session.disconnect()
|
||||
logger.info("LibreVNA session disconnected")
|
||||
|
||||
def send(self, packet: Packet, *, require_ack: bool = True, timeout_s: float = 0.5) -> None:
|
||||
"""Send low-level protocol packet."""
|
||||
logger.debug(
|
||||
"Sending packet %s (require_ack=%s, timeout=%.2fs)",
|
||||
packet.type.name,
|
||||
require_ack,
|
||||
timeout_s,
|
||||
)
|
||||
self._session.send(packet, require_ack=require_ack, timeout_s=timeout_s)
|
||||
|
||||
def request(
|
||||
self,
|
||||
packet_type: PacketType,
|
||||
response_type: PacketType,
|
||||
*,
|
||||
timeout_s: float = 1.0,
|
||||
) -> Packet:
|
||||
"""Send no-payload request packet and wait for a response packet."""
|
||||
logger.debug(
|
||||
"Request packet=%s response=%s timeout=%.2fs",
|
||||
packet_type.name,
|
||||
response_type.name,
|
||||
timeout_s,
|
||||
)
|
||||
return self._session.request(packet_type, response_type, timeout_s=timeout_s)
|
||||
|
||||
def get_device_info(self) -> DeviceInfo:
|
||||
"""Return cached device info from handshake."""
|
||||
return self._session.get_device_info()
|
||||
|
||||
def get_device_status(self, *, timeout_s: float = 1.0) -> DeviceStatus:
|
||||
"""Query and return current device status."""
|
||||
return self._session.get_device_status(timeout_s=timeout_s)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Enumerations used by the LibreVNA direct-USB driver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum, IntEnum
|
||||
|
||||
|
||||
class PacketType(IntEnum):
|
||||
"""Packet identifiers defined by LibreVNA protocol v14."""
|
||||
|
||||
NONE = 0
|
||||
SWEEP_SETTINGS = 2
|
||||
MANUAL_STATUS = 3
|
||||
MANUAL_CONTROL = 4
|
||||
DEVICE_INFO = 5
|
||||
FIRMWARE_PACKET = 6
|
||||
ACK = 7
|
||||
CLEAR_FLASH = 8
|
||||
PERFORM_FIRMWARE_UPDATE = 9
|
||||
NACK = 10
|
||||
REFERENCE = 11
|
||||
GENERATOR = 12
|
||||
SPECTRUM_ANALYZER_SETTINGS = 13
|
||||
SPECTRUM_ANALYZER_RESULT = 14
|
||||
REQUEST_DEVICE_INFO = 15
|
||||
REQUEST_SOURCE_CAL = 16
|
||||
REQUEST_RECEIVER_CAL = 17
|
||||
SOURCE_CAL_POINT = 18
|
||||
RECEIVER_CAL_POINT = 19
|
||||
SET_IDLE = 20
|
||||
REQUEST_FREQUENCY_CORRECTION = 21
|
||||
FREQUENCY_CORRECTION = 22
|
||||
REQUEST_DEVICE_CONFIGURATION = 23
|
||||
DEVICE_CONFIGURATION = 24
|
||||
DEVICE_STATUS = 25
|
||||
REQUEST_DEVICE_STATUS = 26
|
||||
VNA_DATAPOINT = 27
|
||||
SET_TRIGGER = 28
|
||||
CLEAR_TRIGGER = 29
|
||||
STOP_STATUS_UPDATES = 30
|
||||
START_STATUS_UPDATES = 31
|
||||
INITIATE_SWEEP = 32
|
||||
PERFORM_ACTION = 33
|
||||
RESET_DEVICE_CONFIGURATION = 34
|
||||
|
||||
|
||||
class SyncMode(IntEnum):
|
||||
"""Synchronization mode encoded in sweep settings."""
|
||||
|
||||
DISABLED = 0
|
||||
USB = 1
|
||||
EXTERNAL_REFERENCE = 2
|
||||
EXTERNAL_TRIGGER = 3
|
||||
|
||||
|
||||
class HardwareFamily(IntEnum):
|
||||
"""Known LibreVNA hardware families."""
|
||||
|
||||
V1 = 0x01
|
||||
VD0 = 0xD0
|
||||
VE0 = 0xE0
|
||||
VFE = 0xFE
|
||||
VFF = 0xFF
|
||||
UNKNOWN = 0x00
|
||||
|
||||
|
||||
class SweepKind(str, Enum):
|
||||
"""Logical VNA sweep mode."""
|
||||
|
||||
FREQUENCY = "frequency"
|
||||
POWER = "power"
|
||||
|
||||
|
||||
class SweepScale(str, Enum):
|
||||
"""Frequency axis spacing."""
|
||||
|
||||
LIN = "lin"
|
||||
LOG = "log"
|
||||
|
||||
|
||||
class SParameter(str, Enum):
|
||||
"""Canonical 2-port S-parameters."""
|
||||
|
||||
S11 = "S11"
|
||||
S12 = "S12"
|
||||
S21 = "S21"
|
||||
S22 = "S22"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Project exceptions for direct USB and protocol operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class LibreVNAError(Exception):
|
||||
"""Base error for all library exceptions."""
|
||||
|
||||
|
||||
class ProtocolVersionMismatch(LibreVNAError):
|
||||
"""Raised when device protocol version differs from required version."""
|
||||
|
||||
|
||||
class CRCError(LibreVNAError):
|
||||
"""Raised when packet CRC validation fails."""
|
||||
|
||||
|
||||
class NackError(LibreVNAError):
|
||||
"""Raised when the device explicitly responds with NACK."""
|
||||
|
||||
|
||||
class TimeoutError(LibreVNAError):
|
||||
"""Raised when waiting for packet/ack times out."""
|
||||
|
||||
|
||||
class DeviceDisconnectedError(LibreVNAError):
|
||||
"""Raised when USB transport is disconnected or unavailable."""
|
||||
|
||||
|
||||
class IncompleteSweepError(LibreVNAError):
|
||||
"""Raised when a sweep does not contain the required points."""
|
||||
|
||||
|
||||
class ParseError(LibreVNAError):
|
||||
"""Raised when packet decoding or payload parsing fails."""
|
||||
|
||||
|
||||
class UnsupportedHardwareError(LibreVNAError):
|
||||
"""Raised when a requested operation is unsupported for hardware family."""
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Logging helpers for applications embedding ``librevna_driver``.
|
||||
|
||||
The library uses standard ``logging`` module loggers under the
|
||||
``librevna_driver`` namespace and never configures global logging implicitly.
|
||||
Use :func:`configure_logging` in scripts/services when you want a convenient
|
||||
default console setup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
_LOGGER_NAMESPACE = "librevna_driver"
|
||||
_DEFAULT_FORMAT = (
|
||||
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
||||
)
|
||||
DEFAULT_LOG_LEVEL = "INFO"
|
||||
|
||||
|
||||
def configure_logging(
|
||||
level: int | str | None = None,
|
||||
*,
|
||||
fmt: str = _DEFAULT_FORMAT,
|
||||
datefmt: str | None = "%Y-%m-%d %H:%M:%S",
|
||||
) -> None:
|
||||
"""Configure package logger with one stream handler.
|
||||
|
||||
This helper affects only the ``librevna_driver`` logger tree and is safe to
|
||||
call repeatedly (previous handlers attached by this function are replaced).
|
||||
When ``level`` is ``None``, :data:`DEFAULT_LOG_LEVEL` is used.
|
||||
"""
|
||||
|
||||
effective_level = level if level is not None else DEFAULT_LOG_LEVEL
|
||||
|
||||
logger = logging.getLogger(_LOGGER_NAMESPACE)
|
||||
logger.handlers.clear()
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(_parse_level(effective_level))
|
||||
logger.propagate = False
|
||||
|
||||
|
||||
def _parse_level(level: int | str) -> int:
|
||||
"""Parse numeric or textual log level into logging constant."""
|
||||
if isinstance(level, int):
|
||||
return level
|
||||
|
||||
normalized = level.strip().upper()
|
||||
if normalized in logging.getLevelNamesMapping():
|
||||
return logging.getLevelNamesMapping()[normalized]
|
||||
raise ValueError(f"Unknown logging level: {level!r}")
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Public datamodels and low-level packet payload representations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from threading import Event, Thread
|
||||
from typing import Any, Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .enums import HardwareFamily, PacketType, SweepKind, SweepScale, SyncMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Packet:
|
||||
"""Generic protocol packet container."""
|
||||
|
||||
type: PacketType
|
||||
payload: Any = b""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class USBDeviceDescriptor:
|
||||
"""USB device descriptor exposed by transport discovery."""
|
||||
|
||||
serial: str
|
||||
vendor_id: int
|
||||
product_id: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeviceLimits:
|
||||
"""Hardware capability limits reported by the instrument."""
|
||||
|
||||
min_frequency_hz: float
|
||||
max_frequency_hz: float
|
||||
max_frequency_harmonic_hz: float
|
||||
min_ifbw_hz: float
|
||||
max_ifbw_hz: float
|
||||
max_points: int
|
||||
min_power_dbm: float
|
||||
max_power_dbm: float
|
||||
min_rbw_hz: float
|
||||
max_rbw_hz: float
|
||||
max_amplitude_points: int
|
||||
max_dwell_time_s: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeviceInfo:
|
||||
"""Device identity and capabilities."""
|
||||
|
||||
protocol_version: int
|
||||
firmware_major: int
|
||||
firmware_minor: int
|
||||
firmware_patch: int
|
||||
firmware_version: str
|
||||
hardware_version: int
|
||||
hardware_revision: str
|
||||
hardware_family: HardwareFamily
|
||||
limits: DeviceLimits
|
||||
num_ports: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeviceStatus:
|
||||
"""Current runtime status and telemetry."""
|
||||
|
||||
family: HardwareFamily
|
||||
source_locked: bool | None
|
||||
lo_locked: bool | None
|
||||
adc_overload: bool | None
|
||||
unlevel: bool | None
|
||||
temperatures_c: list[float] = field(default_factory=list)
|
||||
raw: dict[str, int | float | bool] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VNASweepSettings:
|
||||
"""Configuration for one VNA sweep setup packet."""
|
||||
|
||||
kind: SweepKind = SweepKind.FREQUENCY
|
||||
f_start_hz: float = 1_000_000.0
|
||||
f_stop_hz: float = 6_000_000_000.0
|
||||
points: int = 501
|
||||
if_bandwidth_hz: float = 1_000.0
|
||||
power_start_dbm: float = -10.0
|
||||
power_stop_dbm: float = -10.0
|
||||
excited_ports: tuple[int, ...] = (1, 2)
|
||||
sweep_scale: SweepScale = SweepScale.LIN
|
||||
dwell_s: float = 0.0
|
||||
suppress_invalid_peaks: bool = True
|
||||
fixed_power_setting: bool = False
|
||||
standby: bool = True
|
||||
sync_mode: SyncMode | None = None
|
||||
sync_master: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate configuration before transmitting to the device."""
|
||||
if self.points <= 0:
|
||||
raise ValueError("points must be > 0")
|
||||
if self.if_bandwidth_hz <= 0:
|
||||
raise ValueError("if_bandwidth_hz must be > 0")
|
||||
if self.dwell_s < 0:
|
||||
raise ValueError("dwell_s must be >= 0")
|
||||
if not self.excited_ports:
|
||||
raise ValueError("excited_ports must not be empty")
|
||||
if len(set(self.excited_ports)) != len(self.excited_ports):
|
||||
raise ValueError("excited_ports must not contain duplicates")
|
||||
if any(port <= 0 for port in self.excited_ports):
|
||||
raise ValueError("excited_ports must use 1-based positive port numbers")
|
||||
if self.f_stop_hz < self.f_start_hz:
|
||||
raise ValueError("f_stop_hz must be >= f_start_hz")
|
||||
if self.kind == SweepKind.POWER and self.power_stop_dbm < self.power_start_dbm:
|
||||
raise ValueError("power_stop_dbm must be >= power_start_dbm for power sweep")
|
||||
if self.kind == SweepKind.POWER and self.f_start_hz != self.f_stop_hz:
|
||||
raise ValueError("power sweep requires f_start_hz == f_stop_hz")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeviceConfigVariant:
|
||||
"""Family-specific device configuration fields."""
|
||||
|
||||
family: HardwareFamily
|
||||
values: dict[str, int | float | bool] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VNADatapointPacket:
|
||||
"""Decoded low-level VNADatapoint payload."""
|
||||
|
||||
frequency_or_time: int
|
||||
cdbm: int
|
||||
point_number: int
|
||||
real: np.ndarray
|
||||
imag: np.ndarray
|
||||
flags: np.ndarray
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VNARawPoint:
|
||||
"""Normalized VNA datapoint used by streaming callback API."""
|
||||
|
||||
point_number: int
|
||||
frequency_hz: float | None
|
||||
time_s: float | None
|
||||
power_dbm: float | None
|
||||
measurements: dict[str, complex] = field(default_factory=dict)
|
||||
z0: float = 50.0
|
||||
|
||||
|
||||
class StreamHandle:
|
||||
"""Handle returned from streaming subscriptions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stop_event: Event,
|
||||
close_callback: Callable[[], None],
|
||||
reader_thread: Thread | None = None,
|
||||
) -> None:
|
||||
"""Create stream handle with stop event and unsubscribe callback."""
|
||||
self._stop_event = stop_event
|
||||
self._close_callback = close_callback
|
||||
self._reader_thread = reader_thread
|
||||
|
||||
def close(self, *, join_timeout_s: float | None = 1.0) -> None:
|
||||
"""Stop streaming and release internal resources."""
|
||||
logger.debug("Closing stream handle (join_timeout_s=%s)", join_timeout_s)
|
||||
if not self._stop_event.is_set():
|
||||
self._stop_event.set()
|
||||
self._close_callback()
|
||||
if self._reader_thread is not None and self._reader_thread.is_alive():
|
||||
self._reader_thread.join(timeout=join_timeout_s)
|
||||
logger.debug("Stream handle closed")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SweepResult:
|
||||
"""Container for complex VNA traces sharing one X-axis."""
|
||||
|
||||
x: np.ndarray
|
||||
traces: dict[str, np.ndarray] = field(default_factory=dict)
|
||||
x_label: str = "frequency_hz"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Normalize dtypes and validate shape compatibility."""
|
||||
self.x = np.asarray(self.x, dtype=np.float64)
|
||||
normalized: dict[str, np.ndarray] = {}
|
||||
for key, values in self.traces.items():
|
||||
arr = np.asarray(values, dtype=np.complex128)
|
||||
if arr.shape != self.x.shape:
|
||||
raise ValueError(
|
||||
f"Trace '{key}' shape {arr.shape} does not match axis shape {self.x.shape}"
|
||||
)
|
||||
normalized[key.strip().lower()] = arr
|
||||
self.traces = normalized
|
||||
|
||||
@property
|
||||
def s11(self) -> np.ndarray | None:
|
||||
"""Return `S11` trace when available."""
|
||||
return self.traces.get("s11")
|
||||
|
||||
@property
|
||||
def s21(self) -> np.ndarray | None:
|
||||
"""Return `S21` trace when available."""
|
||||
return self.traces.get("s21")
|
||||
|
||||
@property
|
||||
def s12(self) -> np.ndarray | None:
|
||||
"""Return `S12` trace when available."""
|
||||
return self.traces.get("s12")
|
||||
|
||||
@property
|
||||
def s22(self) -> np.ndarray | None:
|
||||
"""Return `S22` trace when available."""
|
||||
return self.traces.get("s22")
|
||||
|
||||
def trace(self, parameter: str) -> np.ndarray:
|
||||
"""Return complex trace by parameter name."""
|
||||
key = parameter.strip().lower()
|
||||
if key not in self.traces:
|
||||
raise KeyError(f"Trace '{parameter}' is not available")
|
||||
return self.traces[key]
|
||||
|
||||
def real(self, parameter: str) -> np.ndarray:
|
||||
"""Return real part of selected trace."""
|
||||
return self.trace(parameter).real
|
||||
|
||||
def imag(self, parameter: str) -> np.ndarray:
|
||||
"""Return imaginary part of selected trace."""
|
||||
return self.trace(parameter).imag
|
||||
|
||||
def to_npz(self, path: str) -> None:
|
||||
"""Save result as NumPy `.npz` archive."""
|
||||
data: dict[str, np.ndarray] = {self.x_label: self.x}
|
||||
data.update(self.traces)
|
||||
np.savez(path, **data)
|
||||
|
||||
def to_csv(self, path: str) -> None:
|
||||
"""Save result as CSV with `<trace>_real`/`<trace>_imag` columns."""
|
||||
columns: list[np.ndarray] = [self.x]
|
||||
headers: list[str] = [self.x_label]
|
||||
for name, values in sorted(self.traces.items()):
|
||||
columns.append(values.real)
|
||||
columns.append(values.imag)
|
||||
headers.append(f"{name}_real")
|
||||
headers.append(f"{name}_imag")
|
||||
matrix = np.column_stack(columns)
|
||||
np.savetxt(path, matrix, delimiter=",", header=",".join(headers), comments="")
|
||||
|
||||
|
||||
PacketPayload = Any
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Protocol framing and payload codecs for LibreVNA packet protocol v14."""
|
||||
|
||||
from .codec import (
|
||||
decode_packet_payload,
|
||||
decode_vna_datapoint_payload,
|
||||
encode_device_config_payload,
|
||||
encode_packet_payload,
|
||||
encode_sweep_settings_payload,
|
||||
ensure_no_payload_types,
|
||||
NO_PAYLOAD_PACKET_TYPES,
|
||||
parse_device_config,
|
||||
parse_device_info,
|
||||
parse_device_status,
|
||||
)
|
||||
from .frame import FrameScanner, decode_frame, encode_frame
|
||||
|
||||
__all__ = [
|
||||
"FrameScanner",
|
||||
"NO_PAYLOAD_PACKET_TYPES",
|
||||
"decode_frame",
|
||||
"decode_packet_payload",
|
||||
"decode_vna_datapoint_payload",
|
||||
"encode_device_config_payload",
|
||||
"encode_frame",
|
||||
"encode_packet_payload",
|
||||
"encode_sweep_settings_payload",
|
||||
"ensure_no_payload_types",
|
||||
"parse_device_config",
|
||||
"parse_device_info",
|
||||
"parse_device_status",
|
||||
]
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Payload encoders/decoders for LibreVNA protocol packets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..enums import HardwareFamily, PacketType
|
||||
from ..exceptions import ParseError, UnsupportedHardwareError
|
||||
from ..models import (
|
||||
DeviceConfigVariant,
|
||||
DeviceInfo,
|
||||
DeviceLimits,
|
||||
DeviceStatus,
|
||||
Packet,
|
||||
VNADatapointPacket,
|
||||
VNASweepSettings,
|
||||
)
|
||||
from .structs import DeviceStatusUnion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEVICE_INFO_STRUCT = struct.Struct("<HBBBBcQQIIHhhIIBQBH")
|
||||
_SWEEP_SETTINGS_STRUCT = struct.Struct("<QQHIhBHhH")
|
||||
_DEVICE_CONFIG_V1_STRUCT = struct.Struct("<IBHB")
|
||||
_DEVICE_CONFIG_VFF_STRUCT = struct.Struct("<IIIBH")
|
||||
_DEVICE_CONFIG_VFE_STRUCT = struct.Struct("<H")
|
||||
_DEVICE_CONFIG_VD0_STRUCT = struct.Struct("<HIB")
|
||||
|
||||
_DEVICE_STATUS_VARIANT_BY_FAMILY: dict[HardwareFamily, str] = {
|
||||
HardwareFamily.V1: "V1",
|
||||
HardwareFamily.VFF: "VFF",
|
||||
HardwareFamily.VFE: "VFE",
|
||||
HardwareFamily.VD0: "VD0",
|
||||
HardwareFamily.VE0: "VD0",
|
||||
}
|
||||
|
||||
|
||||
def _family_from_hardware_version(hardware_version: int) -> HardwareFamily:
|
||||
"""Map hardware version byte to typed hardware family enum."""
|
||||
try:
|
||||
return HardwareFamily(hardware_version)
|
||||
except ValueError as exc:
|
||||
raise UnsupportedHardwareError(
|
||||
f"Unsupported hardware_version in DeviceInfo: 0x{hardware_version:02X}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _ensure_payload_length(payload: bytes, expected: int, *, packet_name: str) -> None:
|
||||
"""Validate exact payload length for fixed-size packet structures."""
|
||||
if len(payload) != expected:
|
||||
raise ParseError(
|
||||
f"{packet_name} payload length mismatch: expected {expected}, got {len(payload)}"
|
||||
)
|
||||
|
||||
|
||||
def parse_device_info(payload: bytes) -> DeviceInfo:
|
||||
"""Parse `DeviceInfo` packet payload into typed model."""
|
||||
_ensure_payload_length(payload, _DEVICE_INFO_STRUCT.size, packet_name="DeviceInfo")
|
||||
|
||||
(
|
||||
protocol_version,
|
||||
fw_major,
|
||||
fw_minor,
|
||||
fw_patch,
|
||||
hardware_version,
|
||||
hw_revision_raw,
|
||||
min_freq,
|
||||
max_freq,
|
||||
min_ifbw,
|
||||
max_ifbw,
|
||||
max_points,
|
||||
min_cdbm,
|
||||
max_cdbm,
|
||||
min_rbw,
|
||||
max_rbw,
|
||||
max_amplitude_points,
|
||||
max_harmonic,
|
||||
num_ports,
|
||||
max_dwell_time_us,
|
||||
) = _DEVICE_INFO_STRUCT.unpack(payload)
|
||||
|
||||
family = _family_from_hardware_version(hardware_version)
|
||||
hw_revision = hw_revision_raw.decode("ascii", errors="replace")
|
||||
|
||||
limits = DeviceLimits(
|
||||
min_frequency_hz=float(min_freq),
|
||||
max_frequency_hz=float(max_freq),
|
||||
max_frequency_harmonic_hz=float(max_harmonic),
|
||||
min_ifbw_hz=float(min_ifbw),
|
||||
max_ifbw_hz=float(max_ifbw),
|
||||
max_points=int(max_points),
|
||||
min_power_dbm=float(min_cdbm) / 100.0,
|
||||
max_power_dbm=float(max_cdbm) / 100.0,
|
||||
min_rbw_hz=float(min_rbw),
|
||||
max_rbw_hz=float(max_rbw),
|
||||
max_amplitude_points=int(max_amplitude_points),
|
||||
max_dwell_time_s=float(max_dwell_time_us) * 1e-6,
|
||||
)
|
||||
|
||||
device_info = DeviceInfo(
|
||||
protocol_version=int(protocol_version),
|
||||
firmware_major=int(fw_major),
|
||||
firmware_minor=int(fw_minor),
|
||||
firmware_patch=int(fw_patch),
|
||||
firmware_version=f"{fw_major}.{fw_minor}.{fw_patch}",
|
||||
hardware_version=int(hardware_version),
|
||||
hardware_revision=hw_revision,
|
||||
hardware_family=family,
|
||||
limits=limits,
|
||||
num_ports=int(num_ports),
|
||||
)
|
||||
logger.debug(
|
||||
"Decoded DeviceInfo: protocol=%d fw=%s family=%s ports=%d",
|
||||
device_info.protocol_version,
|
||||
device_info.firmware_version,
|
||||
device_info.hardware_family.name,
|
||||
device_info.num_ports,
|
||||
)
|
||||
return device_info
|
||||
|
||||
|
||||
def _device_status_variant_name_for_family(family: HardwareFamily) -> str:
|
||||
"""Resolve `DeviceStatusUnion` variant name for specific hardware family."""
|
||||
try:
|
||||
return _DEVICE_STATUS_VARIANT_BY_FAMILY[family]
|
||||
except KeyError as exc:
|
||||
raise UnsupportedHardwareError(
|
||||
f"Unsupported hardware family for DeviceStatus: {family!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _structure_fields_dict(struct_obj: ctypes.Structure) -> dict[str, int | float | bool]:
|
||||
"""Convert ctypes structure fields into plain Python dictionary."""
|
||||
values: dict[str, int | float | bool] = {}
|
||||
for entry in struct_obj._fields_:
|
||||
field_name = entry[0]
|
||||
if field_name.startswith("_"):
|
||||
continue
|
||||
raw_value = getattr(struct_obj, field_name)
|
||||
|
||||
if len(entry) == 3:
|
||||
values[field_name] = bool(raw_value)
|
||||
continue
|
||||
|
||||
field_type = entry[1]
|
||||
if field_type in {ctypes.c_float, ctypes.c_double}:
|
||||
values[field_name] = float(raw_value)
|
||||
else:
|
||||
values[field_name] = int(raw_value)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def parse_device_status(payload: bytes, family: HardwareFamily) -> DeviceStatus:
|
||||
"""Parse `DeviceStatus` payload according to active hardware family."""
|
||||
_ensure_payload_length(payload, ctypes.sizeof(DeviceStatusUnion), packet_name="DeviceStatus")
|
||||
|
||||
union = DeviceStatusUnion()
|
||||
ctypes.memmove(ctypes.addressof(union), payload, len(payload))
|
||||
variant_name = _device_status_variant_name_for_family(family)
|
||||
variant = getattr(union, variant_name)
|
||||
raw = _structure_fields_dict(variant)
|
||||
|
||||
source_locked = bool(raw.get("source_locked")) if "source_locked" in raw else None
|
||||
lo_locked = None
|
||||
if "LO_locked" in raw:
|
||||
lo_locked = bool(raw["LO_locked"])
|
||||
elif "LO1_locked" in raw:
|
||||
lo_locked = bool(raw["LO1_locked"])
|
||||
|
||||
adc_overload = bool(raw.get("ADC_overload")) if "ADC_overload" in raw else None
|
||||
unlevel = bool(raw.get("unlevel")) if "unlevel" in raw else None
|
||||
|
||||
temperatures_c: list[float] = []
|
||||
for key in ("temp_source", "temp_LO1", "temp_MCU"):
|
||||
if key in raw:
|
||||
temperatures_c.append(float(raw[key]))
|
||||
if "temp_eCal" in raw:
|
||||
temperatures_c.append(float(raw["temp_eCal"]) / 100.0)
|
||||
|
||||
return DeviceStatus(
|
||||
family=family,
|
||||
source_locked=source_locked,
|
||||
lo_locked=lo_locked,
|
||||
adc_overload=adc_overload,
|
||||
unlevel=unlevel,
|
||||
temperatures_c=temperatures_c,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
|
||||
def decode_vna_datapoint_payload(payload: bytes) -> VNADatapointPacket:
|
||||
"""Decode variable-size VNADatapoint payload."""
|
||||
if len(payload) < 12:
|
||||
raise ParseError("VNADatapoint payload is too short")
|
||||
|
||||
values_block = len(payload) - 12
|
||||
if values_block % 9 != 0:
|
||||
raise ParseError("VNADatapoint payload length is not aligned to value tuple size")
|
||||
|
||||
num_values = values_block // 9
|
||||
|
||||
(frequency_or_time,) = struct.unpack_from("<Q", payload, 0)
|
||||
(cdbm,) = struct.unpack_from("<h", payload, 8)
|
||||
(point_number,) = struct.unpack_from("<H", payload, 10)
|
||||
|
||||
real = np.frombuffer(payload, dtype="<f4", count=num_values, offset=12).astype(np.float64, copy=False)
|
||||
imag = np.frombuffer(payload, dtype="<f4", count=num_values, offset=12 + 4 * num_values).astype(
|
||||
np.float64,
|
||||
copy=False,
|
||||
)
|
||||
flags = np.frombuffer(payload, dtype=np.uint8, count=num_values, offset=12 + 8 * num_values)
|
||||
|
||||
return VNADatapointPacket(
|
||||
frequency_or_time=int(frequency_or_time),
|
||||
cdbm=int(cdbm),
|
||||
point_number=int(point_number),
|
||||
real=real,
|
||||
imag=imag,
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
|
||||
def decode_packet_payload(packet: Packet) -> object:
|
||||
"""Decode packet payload for known response packet types."""
|
||||
if packet.type == PacketType.DEVICE_INFO:
|
||||
return parse_device_info(packet.payload)
|
||||
if packet.type == PacketType.VNA_DATAPOINT:
|
||||
return decode_vna_datapoint_payload(packet.payload)
|
||||
return packet.payload
|
||||
|
||||
|
||||
def encode_sweep_settings_payload(settings: VNASweepSettings) -> bytes:
|
||||
"""Encode `SweepSettings` payload."""
|
||||
if len(settings.excited_ports) > 4:
|
||||
raise ValueError("Protocol supports at most four excited ports")
|
||||
sync_mode = settings.sync_mode if settings.sync_mode is not None else 0
|
||||
|
||||
stage_by_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
|
||||
|
||||
flags1 = 0
|
||||
flags1 |= int(bool(settings.standby)) << 0
|
||||
flags1 |= int(bool(settings.sync_master)) << 1
|
||||
flags1 |= int(bool(settings.suppress_invalid_peaks)) << 2
|
||||
flags1 |= int(bool(settings.fixed_power_setting)) << 3
|
||||
flags1 |= int(settings.sweep_scale.value == "log") << 4
|
||||
flags1 |= (int(sync_mode) & 0x03) << 5
|
||||
|
||||
flags2 = 0
|
||||
stages = len(settings.excited_ports) - 1
|
||||
flags2 |= stages & 0x07
|
||||
flags2 |= (stage_by_port.get(1, 0) & 0x07) << 3
|
||||
flags2 |= (stage_by_port.get(2, 0) & 0x07) << 6
|
||||
flags2 |= (stage_by_port.get(3, 0) & 0x07) << 9
|
||||
flags2 |= (stage_by_port.get(4, 0) & 0x07) << 12
|
||||
|
||||
dwell_us = int(round(settings.dwell_s * 1_000_000.0))
|
||||
dwell_us = max(0, min(0xFFFF, dwell_us))
|
||||
|
||||
return _SWEEP_SETTINGS_STRUCT.pack(
|
||||
int(round(settings.f_start_hz)),
|
||||
int(round(settings.f_stop_hz)),
|
||||
int(settings.points),
|
||||
int(round(settings.if_bandwidth_hz)),
|
||||
int(round(settings.power_start_dbm * 100.0)),
|
||||
flags1,
|
||||
flags2,
|
||||
int(round(settings.power_stop_dbm * 100.0)),
|
||||
dwell_us,
|
||||
)
|
||||
|
||||
|
||||
def parse_device_config(payload: bytes, family: HardwareFamily) -> DeviceConfigVariant:
|
||||
"""Decode family-specific `DeviceConfiguration` payload."""
|
||||
if len(payload) != 15:
|
||||
raise ParseError(f"DeviceConfiguration payload length mismatch: expected 15, got {len(payload)}")
|
||||
|
||||
values: dict[str, int | float | bool]
|
||||
if family == HardwareFamily.V1:
|
||||
if1, adc_prescaler, dft_phase_inc, pll_delay = _DEVICE_CONFIG_V1_STRUCT.unpack(payload[:8])
|
||||
values = {
|
||||
"IF1": int(if1),
|
||||
"ADCprescaler": int(adc_prescaler),
|
||||
"DFTphaseInc": int(dft_phase_inc),
|
||||
"PLLSettlingDelay": int(pll_delay),
|
||||
}
|
||||
elif family == HardwareFamily.VFF:
|
||||
ip, mask, gw, flags1, flags2 = _DEVICE_CONFIG_VFF_STRUCT.unpack(payload)
|
||||
values = {
|
||||
"ip": int(ip),
|
||||
"mask": int(mask),
|
||||
"gw": int(gw),
|
||||
"dhcp": bool(flags1 & 0x01),
|
||||
"autogain": bool(flags2 & 0x01),
|
||||
"portGain": int((flags2 >> 1) & 0x0F),
|
||||
"refGain": int((flags2 >> 5) & 0x0F),
|
||||
}
|
||||
elif family == HardwareFamily.VFE:
|
||||
(flags,) = _DEVICE_CONFIG_VFE_STRUCT.unpack(payload[:2])
|
||||
values = {
|
||||
"autogain": bool(flags & 0x01),
|
||||
"portGain": int((flags >> 1) & 0x0F),
|
||||
"refGain": int((flags >> 5) & 0x0F),
|
||||
}
|
||||
elif family in {HardwareFamily.VD0, HardwareFamily.VE0}:
|
||||
dft_phase_inc, adc_rate, pll_delay = _DEVICE_CONFIG_VD0_STRUCT.unpack(payload[:7])
|
||||
values = {
|
||||
"DFTphaseInc": int(dft_phase_inc),
|
||||
"ADCrate": int(adc_rate),
|
||||
"PLLSettlingDelay": int(pll_delay),
|
||||
}
|
||||
else:
|
||||
raise UnsupportedHardwareError(f"Unsupported hardware family for DeviceConfiguration: {family!r}")
|
||||
|
||||
return DeviceConfigVariant(family=family, values=values)
|
||||
|
||||
|
||||
def encode_device_config_payload(config: DeviceConfigVariant) -> bytes:
|
||||
"""Encode family-specific `DeviceConfiguration` payload."""
|
||||
family = config.family
|
||||
values = config.values
|
||||
|
||||
def _require_value(key: str) -> int | float | bool:
|
||||
"""Fetch required device-config value by key or raise parse error."""
|
||||
if key not in values:
|
||||
raise ParseError(f"Missing required device configuration field: '{key}'")
|
||||
return values[key]
|
||||
|
||||
if family == HardwareFamily.V1:
|
||||
payload = _DEVICE_CONFIG_V1_STRUCT.pack(
|
||||
int(_require_value("IF1")),
|
||||
int(_require_value("ADCprescaler")),
|
||||
int(_require_value("DFTphaseInc")),
|
||||
int(_require_value("PLLSettlingDelay")),
|
||||
)
|
||||
elif family == HardwareFamily.VFF:
|
||||
flags1 = int(bool(_require_value("dhcp")))
|
||||
flags2 = 0
|
||||
flags2 |= int(bool(_require_value("autogain"))) << 0
|
||||
flags2 |= (int(_require_value("portGain")) & 0x0F) << 1
|
||||
flags2 |= (int(_require_value("refGain")) & 0x0F) << 5
|
||||
payload = _DEVICE_CONFIG_VFF_STRUCT.pack(
|
||||
int(_require_value("ip")),
|
||||
int(_require_value("mask")),
|
||||
int(_require_value("gw")),
|
||||
flags1,
|
||||
flags2,
|
||||
)
|
||||
elif family == HardwareFamily.VFE:
|
||||
flags = 0
|
||||
flags |= int(bool(_require_value("autogain"))) << 0
|
||||
flags |= (int(_require_value("portGain")) & 0x0F) << 1
|
||||
flags |= (int(_require_value("refGain")) & 0x0F) << 5
|
||||
payload = _DEVICE_CONFIG_VFE_STRUCT.pack(flags)
|
||||
elif family in {HardwareFamily.VD0, HardwareFamily.VE0}:
|
||||
payload = _DEVICE_CONFIG_VD0_STRUCT.pack(
|
||||
int(_require_value("DFTphaseInc")),
|
||||
int(_require_value("ADCrate")),
|
||||
int(_require_value("PLLSettlingDelay")),
|
||||
)
|
||||
else:
|
||||
raise UnsupportedHardwareError(f"Unsupported hardware family for DeviceConfiguration: {family!r}")
|
||||
|
||||
return payload.ljust(15, b"\x00")
|
||||
|
||||
|
||||
def encode_packet_payload(packet_type: PacketType, payload: object) -> bytes:
|
||||
"""Generic payload encoder for `LibreVNASession.send()` raw API."""
|
||||
if payload is None:
|
||||
return b""
|
||||
if isinstance(payload, (bytes, bytearray, memoryview)):
|
||||
return bytes(payload)
|
||||
|
||||
if packet_type == PacketType.SWEEP_SETTINGS and isinstance(payload, VNASweepSettings):
|
||||
return encode_sweep_settings_payload(payload)
|
||||
if packet_type == PacketType.DEVICE_CONFIGURATION and isinstance(payload, DeviceConfigVariant):
|
||||
return encode_device_config_payload(payload)
|
||||
|
||||
logger.error(
|
||||
"Unsupported payload object for packet %s: %s",
|
||||
packet_type.name,
|
||||
type(payload).__name__,
|
||||
)
|
||||
raise TypeError(f"Unsupported payload object for packet {packet_type.name}: {type(payload).__name__}")
|
||||
|
||||
|
||||
NO_PAYLOAD_PACKET_TYPES = {
|
||||
PacketType.ACK,
|
||||
PacketType.NACK,
|
||||
PacketType.REQUEST_DEVICE_INFO,
|
||||
PacketType.REQUEST_DEVICE_CONFIGURATION,
|
||||
PacketType.REQUEST_DEVICE_STATUS,
|
||||
PacketType.INITIATE_SWEEP,
|
||||
PacketType.RESET_DEVICE_CONFIGURATION,
|
||||
}
|
||||
|
||||
|
||||
def ensure_no_payload_types(packet_type: PacketType, payload: bytes) -> None:
|
||||
"""Validate empty payload requirement for no-payload packet types."""
|
||||
if packet_type in NO_PAYLOAD_PACKET_TYPES and payload:
|
||||
logger.error("No-payload packet %s received payload length %d", packet_type.name, len(payload))
|
||||
raise ParseError(f"Packet {packet_type.name} does not support payload")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""CRC32 helpers for LibreVNA packet framing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zlib
|
||||
|
||||
|
||||
def crc32(data: bytes | bytearray | memoryview) -> int:
|
||||
"""Compute protocol CRC32 over packet bytes excluding trailing CRC field."""
|
||||
return zlib.crc32(data) & 0xFFFFFFFF
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Packet frame encoding/decoding for LibreVNA protocol stream."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from ..enums import PacketType
|
||||
from ..exceptions import CRCError, ParseError
|
||||
from ..models import Packet
|
||||
from .crc32 import crc32
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEADER = 0x5A
|
||||
_FRAME_OVERHEAD = 8 # header + length + type + crc
|
||||
_MAX_FRAME_LENGTH = 4096
|
||||
|
||||
|
||||
_NO_CRC_PACKET_TYPES = {
|
||||
PacketType.VNA_DATAPOINT,
|
||||
}
|
||||
|
||||
|
||||
def encode_frame(packet: Packet) -> bytes:
|
||||
"""Encode one packet into framed wire format."""
|
||||
payload = packet.payload
|
||||
if not isinstance(payload, (bytes, bytearray, memoryview)):
|
||||
raise TypeError("Packet payload must be bytes-like")
|
||||
|
||||
payload_bytes = bytes(payload)
|
||||
length = _FRAME_OVERHEAD + len(payload_bytes)
|
||||
if length > 0xFFFF:
|
||||
raise ValueError("Packet is too large for protocol frame length field")
|
||||
|
||||
frame = bytearray(length)
|
||||
frame[0] = _HEADER
|
||||
struct.pack_into("<H", frame, 1, length)
|
||||
frame[3] = int(packet.type)
|
||||
frame[4 : 4 + len(payload_bytes)] = payload_bytes
|
||||
|
||||
crc_value = 0
|
||||
if packet.type not in _NO_CRC_PACKET_TYPES:
|
||||
crc_value = crc32(frame[:-4])
|
||||
struct.pack_into("<I", frame, length - 4, crc_value)
|
||||
return bytes(frame)
|
||||
|
||||
|
||||
def decode_frame(frame: bytes) -> Packet:
|
||||
"""Decode and validate one complete frame."""
|
||||
if len(frame) < _FRAME_OVERHEAD:
|
||||
raise ParseError("Frame is too short")
|
||||
if frame[0] != _HEADER:
|
||||
raise ParseError("Invalid frame header")
|
||||
|
||||
(length,) = struct.unpack_from("<H", frame, 1)
|
||||
if length != len(frame):
|
||||
raise ParseError(f"Frame length mismatch: declared {length}, got {len(frame)}")
|
||||
|
||||
packet_type_raw = frame[3]
|
||||
try:
|
||||
packet_type = PacketType(packet_type_raw)
|
||||
except ValueError as exc:
|
||||
raise ParseError(f"Unknown packet type id {packet_type_raw}") from exc
|
||||
|
||||
(received_crc,) = struct.unpack_from("<I", frame, length - 4)
|
||||
if packet_type in _NO_CRC_PACKET_TYPES:
|
||||
if received_crc != 0:
|
||||
raise CRCError("VNADatapoint packet must carry zero CRC")
|
||||
else:
|
||||
computed_crc = crc32(frame[:-4])
|
||||
if received_crc != computed_crc:
|
||||
raise CRCError(
|
||||
f"CRC mismatch for packet {packet_type.name}: "
|
||||
f"received 0x{received_crc:08X}, computed 0x{computed_crc:08X}"
|
||||
)
|
||||
|
||||
return Packet(type=packet_type, payload=frame[4:-4])
|
||||
|
||||
|
||||
class FrameScanner:
|
||||
"""Incremental frame scanner for raw USB byte streams."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize internal undecoded byte buffer."""
|
||||
self._buffer = bytearray()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Drop all buffered undecoded bytes."""
|
||||
self._buffer.clear()
|
||||
|
||||
def feed(self, chunk: bytes) -> list[Packet]:
|
||||
"""Feed raw bytes and return every fully decoded packet."""
|
||||
if not chunk:
|
||||
return []
|
||||
self._buffer.extend(chunk)
|
||||
|
||||
decoded: list[Packet] = []
|
||||
while True:
|
||||
header_index = self._buffer.find(_HEADER)
|
||||
if header_index < 0:
|
||||
self._buffer.clear()
|
||||
break
|
||||
if header_index > 0:
|
||||
del self._buffer[:header_index]
|
||||
|
||||
if len(self._buffer) < 4:
|
||||
break
|
||||
|
||||
(length,) = struct.unpack_from("<H", self._buffer, 1)
|
||||
if length < _FRAME_OVERHEAD or length > _MAX_FRAME_LENGTH:
|
||||
logger.debug("Discarding byte due to invalid frame length=%d", length)
|
||||
del self._buffer[0]
|
||||
continue
|
||||
|
||||
if len(self._buffer) < length:
|
||||
break
|
||||
|
||||
frame = bytes(self._buffer[:length])
|
||||
del self._buffer[:length]
|
||||
decoded.append(decode_frame(frame))
|
||||
|
||||
return decoded
|
||||
@@ -0,0 +1,89 @@
|
||||
"""ctypes layouts for protocol status unions used by the driver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
|
||||
|
||||
class DeviceStatusV1(ctypes.LittleEndianStructure):
|
||||
"""Device status bit layout for V1 family."""
|
||||
|
||||
_pack_ = 1
|
||||
_fields_ = [
|
||||
("extRefAvailable", ctypes.c_uint8, 1),
|
||||
("extRefInUse", ctypes.c_uint8, 1),
|
||||
("FPGA_configured", ctypes.c_uint8, 1),
|
||||
("source_locked", ctypes.c_uint8, 1),
|
||||
("LO1_locked", ctypes.c_uint8, 1),
|
||||
("ADC_overload", ctypes.c_uint8, 1),
|
||||
("unlevel", ctypes.c_uint8, 1),
|
||||
("_unused", ctypes.c_uint8, 1),
|
||||
("temp_source", ctypes.c_uint8),
|
||||
("temp_LO1", ctypes.c_uint8),
|
||||
("temp_MCU", ctypes.c_uint8),
|
||||
]
|
||||
|
||||
|
||||
class DeviceStatusVFF(ctypes.LittleEndianStructure):
|
||||
"""Device status bit layout for VFF family."""
|
||||
|
||||
_pack_ = 1
|
||||
_fields_ = [
|
||||
("source_locked", ctypes.c_uint8, 1),
|
||||
("LO_locked", ctypes.c_uint8, 1),
|
||||
("ADC_overload", ctypes.c_uint8, 1),
|
||||
("unlevel", ctypes.c_uint8, 1),
|
||||
("_unused", ctypes.c_uint8, 4),
|
||||
("temp_MCU", ctypes.c_uint8),
|
||||
]
|
||||
|
||||
|
||||
class DeviceStatusVFE(ctypes.LittleEndianStructure):
|
||||
"""Device status bit layout for VFE family."""
|
||||
|
||||
_pack_ = 1
|
||||
_fields_ = [
|
||||
("source_locked", ctypes.c_uint8, 1),
|
||||
("LO_locked", ctypes.c_uint8, 1),
|
||||
("ADC_overload", ctypes.c_uint8, 1),
|
||||
("unlevel", ctypes.c_uint8, 1),
|
||||
("_unused", ctypes.c_uint8, 4),
|
||||
("temp_MCU", ctypes.c_uint8),
|
||||
("temp_eCal", ctypes.c_uint16),
|
||||
("power_heater", ctypes.c_uint16),
|
||||
]
|
||||
|
||||
|
||||
class DeviceStatusVD0(ctypes.LittleEndianStructure):
|
||||
"""Device status bit layout for VD0/VE0 families."""
|
||||
|
||||
_pack_ = 1
|
||||
_fields_ = [
|
||||
("extRefAvailable", ctypes.c_uint8, 1),
|
||||
("extRefInUse", ctypes.c_uint8, 1),
|
||||
("FPGA_configured", ctypes.c_uint8, 1),
|
||||
("source_locked", ctypes.c_uint8, 1),
|
||||
("LO_locked", ctypes.c_uint8, 1),
|
||||
("ADC_overload", ctypes.c_uint8, 1),
|
||||
("unlevel", ctypes.c_uint8, 1),
|
||||
("_unused", ctypes.c_uint8, 1),
|
||||
("temp_MCU", ctypes.c_uint8),
|
||||
("supply_voltage", ctypes.c_uint16),
|
||||
("supply_current", ctypes.c_uint16),
|
||||
]
|
||||
|
||||
|
||||
class DeviceStatusUnion(ctypes.Union):
|
||||
"""6-byte device-status union shared across hardware families."""
|
||||
|
||||
_pack_ = 1
|
||||
_fields_ = [
|
||||
("V1", DeviceStatusV1),
|
||||
("VFF", DeviceStatusVFF),
|
||||
("VFE", DeviceStatusVFE),
|
||||
("VD0", DeviceStatusVD0),
|
||||
("raw", ctypes.c_uint8 * 6),
|
||||
]
|
||||
|
||||
|
||||
assert ctypes.sizeof(DeviceStatusUnion) == 6
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Session orchestration for direct-USB LibreVNA protocol communication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Protocol, TypeVar
|
||||
|
||||
from .enums import HardwareFamily, PacketType, SyncMode
|
||||
from .exceptions import (
|
||||
DeviceDisconnectedError,
|
||||
NackError,
|
||||
ParseError,
|
||||
ProtocolVersionMismatch,
|
||||
TimeoutError,
|
||||
)
|
||||
from .models import DeviceInfo, DeviceStatus, Packet, StreamHandle, USBDeviceDescriptor
|
||||
from .protocol import (
|
||||
FrameScanner,
|
||||
decode_packet_payload,
|
||||
encode_frame,
|
||||
encode_packet_payload,
|
||||
ensure_no_payload_types,
|
||||
parse_device_status,
|
||||
)
|
||||
from .transport import USBTransport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _IndexedPayload(Protocol):
|
||||
"""Protocol for payload types carrying an integer `point_number` field."""
|
||||
|
||||
point_number: int
|
||||
|
||||
|
||||
TPacketPayload = TypeVar("TPacketPayload", bound=_IndexedPayload)
|
||||
|
||||
|
||||
class LibreVNASession:
|
||||
"""Owns USB transport, packet queues, and request/response synchronization."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize transport, queues, synchronization primitives, and defaults."""
|
||||
self._scanner = FrameScanner()
|
||||
self._transport = USBTransport(
|
||||
on_data=self._on_transport_data,
|
||||
on_disconnect=self._on_transport_disconnect,
|
||||
)
|
||||
|
||||
self._incoming: dict[PacketType, deque[Packet]] = defaultdict(deque)
|
||||
self._subscribers: dict[PacketType, set[Callable[[Packet], None]]] = defaultdict(set)
|
||||
|
||||
self._incoming_cv = threading.Condition()
|
||||
self._ack_cv = threading.Condition()
|
||||
self._send_lock = threading.Lock()
|
||||
|
||||
self._awaiting_ack = False
|
||||
self._ack_result: PacketType | None = None
|
||||
|
||||
self._fatal_error: Exception | None = None
|
||||
|
||||
self._device_info: DeviceInfo | None = None
|
||||
self._device_status: DeviceStatus | None = None
|
||||
self._hardware_family: HardwareFamily = HardwareFamily.UNKNOWN
|
||||
|
||||
self._default_sync_mode = SyncMode.DISABLED
|
||||
self._default_sync_master = False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return `True` when USB connection is active."""
|
||||
return self._transport.is_connected
|
||||
|
||||
@property
|
||||
def connected_serial(self) -> str | None:
|
||||
"""Serial number of currently connected device, when available."""
|
||||
return self._transport.connected_serial
|
||||
|
||||
@property
|
||||
def hardware_family(self) -> HardwareFamily:
|
||||
"""Hardware family inferred from `DeviceInfo` packet."""
|
||||
return self._hardware_family
|
||||
|
||||
@property
|
||||
def default_sync_mode(self) -> SyncMode:
|
||||
"""Default sync mode used by controllers when settings do not override."""
|
||||
return self._default_sync_mode
|
||||
|
||||
@property
|
||||
def default_sync_master(self) -> bool:
|
||||
"""Default sync master flag used by controllers."""
|
||||
return self._default_sync_master
|
||||
|
||||
def set_default_sync(self, mode: SyncMode, master: bool) -> None:
|
||||
"""Set session-level default synchronization settings."""
|
||||
self._default_sync_mode = mode
|
||||
self._default_sync_master = master
|
||||
|
||||
@staticmethod
|
||||
def list_devices() -> list[USBDeviceDescriptor]:
|
||||
"""Enumerate available direct-USB LibreVNA devices."""
|
||||
return USBTransport.list_devices()
|
||||
|
||||
def connect(
|
||||
self,
|
||||
serial: str | None = None,
|
||||
*,
|
||||
strict_protocol_version: int = 14,
|
||||
timeout_s: float = 1.0,
|
||||
) -> None:
|
||||
"""Connect transport and perform startup handshake."""
|
||||
logger.info(
|
||||
"Opening USB transport (serial=%s, strict_protocol=%d, timeout=%.2fs)",
|
||||
serial,
|
||||
strict_protocol_version,
|
||||
timeout_s,
|
||||
)
|
||||
self._fatal_error = None
|
||||
self._scanner.clear()
|
||||
self._incoming.clear()
|
||||
|
||||
self._transport.connect(serial=serial, timeout_s=timeout_s)
|
||||
try:
|
||||
info_packet = self.request(
|
||||
PacketType.REQUEST_DEVICE_INFO,
|
||||
PacketType.DEVICE_INFO,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if not isinstance(info_packet.payload, DeviceInfo):
|
||||
raise ParseError("Decoded DeviceInfo payload has unexpected type")
|
||||
|
||||
self._device_info = info_packet.payload
|
||||
self._hardware_family = self._device_info.hardware_family
|
||||
logger.info(
|
||||
"Handshake OK: fw=%s protocol=%d family=%s ports=%d",
|
||||
self._device_info.firmware_version,
|
||||
self._device_info.protocol_version,
|
||||
self._device_info.hardware_family.name,
|
||||
self._device_info.num_ports,
|
||||
)
|
||||
|
||||
if self._device_info.protocol_version != strict_protocol_version:
|
||||
raise ProtocolVersionMismatch(
|
||||
"Protocol version mismatch: "
|
||||
f"device={self._device_info.protocol_version}, "
|
||||
f"required={strict_protocol_version}"
|
||||
)
|
||||
|
||||
self.get_device_status(timeout_s=timeout_s)
|
||||
logger.debug("Initial device status request completed")
|
||||
except Exception:
|
||||
logger.exception("Connect handshake failed, closing session")
|
||||
self.disconnect()
|
||||
raise
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect USB transport and clear session state."""
|
||||
logger.debug("Closing USB transport and clearing session queues")
|
||||
self._transport.disconnect()
|
||||
with self._incoming_cv:
|
||||
self._incoming.clear()
|
||||
self._subscribers.clear()
|
||||
self._incoming_cv.notify_all()
|
||||
with self._ack_cv:
|
||||
self._awaiting_ack = False
|
||||
self._ack_result = None
|
||||
self._ack_cv.notify_all()
|
||||
|
||||
def send(self, packet: Packet, *, require_ack: bool = True, timeout_s: float = 0.5) -> None:
|
||||
"""Send one protocol packet and optionally wait for ACK/NACK."""
|
||||
payload = encode_packet_payload(packet.type, packet.payload)
|
||||
ensure_no_payload_types(packet.type, payload)
|
||||
frame = encode_frame(Packet(type=packet.type, payload=payload))
|
||||
logger.debug(
|
||||
"TX packet=%s payload=%dB require_ack=%s timeout=%.2fs",
|
||||
packet.type.name,
|
||||
len(payload),
|
||||
require_ack,
|
||||
timeout_s,
|
||||
)
|
||||
|
||||
with self._send_lock:
|
||||
if require_ack:
|
||||
with self._ack_cv:
|
||||
self._awaiting_ack = True
|
||||
self._ack_result = None
|
||||
|
||||
self._transport.write(frame, timeout_s=timeout_s)
|
||||
|
||||
if require_ack:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
with self._ack_cv:
|
||||
while self._ack_result is None:
|
||||
self._raise_if_fatal_locked()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
self._awaiting_ack = False
|
||||
raise TimeoutError(f"Timeout waiting for ACK for packet {packet.type.name}")
|
||||
self._ack_cv.wait(timeout=remaining)
|
||||
|
||||
result = self._ack_result
|
||||
self._awaiting_ack = False
|
||||
self._ack_result = None
|
||||
|
||||
if result == PacketType.NACK:
|
||||
raise NackError(f"Received NACK for packet {packet.type.name}")
|
||||
logger.debug("ACK received for packet %s", packet.type.name)
|
||||
|
||||
def request(
|
||||
self,
|
||||
packet_type: PacketType,
|
||||
response_type: PacketType,
|
||||
*,
|
||||
timeout_s: float = 1.0,
|
||||
) -> Packet:
|
||||
"""Send no-payload request packet and wait for one response packet type."""
|
||||
logger.debug(
|
||||
"Request start: packet=%s expect=%s timeout=%.2fs",
|
||||
packet_type.name,
|
||||
response_type.name,
|
||||
timeout_s,
|
||||
)
|
||||
self.clear_queue(response_type)
|
||||
self.send(Packet(type=packet_type), require_ack=True, timeout_s=timeout_s)
|
||||
packet = self.wait_for_packet(response_type, timeout_s=timeout_s)
|
||||
logger.debug("Request complete: received %s", response_type.name)
|
||||
return packet
|
||||
|
||||
def collect_indexed_payloads(
|
||||
self,
|
||||
*,
|
||||
packet_type: PacketType,
|
||||
expected_points: int,
|
||||
timeout_s: float,
|
||||
payload_type: type[TPacketPayload],
|
||||
payload_error: str,
|
||||
) -> list[TPacketPayload]:
|
||||
"""Collect indexed payloads by `point_number` into ascending order.
|
||||
|
||||
Missing points are omitted; callers decide whether this is acceptable.
|
||||
"""
|
||||
if expected_points <= 0:
|
||||
raise ValueError("expected_points must be > 0")
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
collected: list[TPacketPayload | None] = [None] * expected_points
|
||||
received = 0
|
||||
|
||||
while received < expected_points:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
packet = self.wait_for_packet(packet_type, timeout_s=remaining)
|
||||
payload = packet.payload
|
||||
if not isinstance(payload, payload_type):
|
||||
raise ParseError(payload_error)
|
||||
|
||||
point_number = payload.point_number
|
||||
if point_number < 0 or point_number >= expected_points:
|
||||
raise ParseError(
|
||||
f"Received out-of-range point index {point_number}, expected 0..{expected_points - 1}"
|
||||
)
|
||||
|
||||
if collected[point_number] is None:
|
||||
received += 1
|
||||
collected[point_number] = payload
|
||||
|
||||
return [item for item in collected if item is not None]
|
||||
|
||||
def wait_for_packet(self, packet_type: PacketType, *, timeout_s: float = 1.0) -> Packet:
|
||||
"""Wait for the next packet of requested type."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
with self._incoming_cv:
|
||||
while True:
|
||||
self._raise_if_fatal_locked()
|
||||
queue = self._incoming[packet_type]
|
||||
if queue:
|
||||
logger.debug(
|
||||
"Dequeued packet %s (remaining=%d)",
|
||||
packet_type.name,
|
||||
len(queue) - 1,
|
||||
)
|
||||
return queue.popleft()
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(f"Timeout waiting for packet {packet_type.name}")
|
||||
self._incoming_cv.wait(timeout=remaining)
|
||||
|
||||
def clear_queue(self, packet_type: PacketType) -> None:
|
||||
"""Drop queued packets of a given type."""
|
||||
with self._incoming_cv:
|
||||
dropped = len(self._incoming[packet_type])
|
||||
self._incoming[packet_type].clear()
|
||||
if dropped:
|
||||
logger.debug("Cleared %d queued packet(s) of type %s", dropped, packet_type.name)
|
||||
|
||||
def get_device_info(self) -> DeviceInfo:
|
||||
"""Return cached `DeviceInfo` from successful `connect()` handshake."""
|
||||
if self._device_info is None:
|
||||
raise DeviceDisconnectedError("Device info is not available before connect()")
|
||||
return self._device_info
|
||||
|
||||
def get_device_status(self, *, timeout_s: float = 1.0) -> DeviceStatus:
|
||||
"""Request and return current `DeviceStatus`."""
|
||||
packet = self.request(
|
||||
PacketType.REQUEST_DEVICE_STATUS,
|
||||
PacketType.DEVICE_STATUS,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
if not isinstance(packet.payload, (bytes, bytearray, memoryview)):
|
||||
raise ParseError("DeviceStatus packet payload has unexpected type")
|
||||
status = parse_device_status(bytes(packet.payload), self._hardware_family)
|
||||
self._device_status = status
|
||||
logger.debug(
|
||||
"Device status: source_locked=%s lo_locked=%s adc_overload=%s unlevel=%s",
|
||||
status.source_locked,
|
||||
status.lo_locked,
|
||||
status.adc_overload,
|
||||
status.unlevel,
|
||||
)
|
||||
return status
|
||||
|
||||
def subscribe(self, packet_type: PacketType, callback: Callable[[Packet], None]) -> StreamHandle:
|
||||
"""Register packet callback and return handle for unsubscription."""
|
||||
stop_event = threading.Event()
|
||||
with self._incoming_cv:
|
||||
self._subscribers[packet_type].add(callback)
|
||||
logger.debug(
|
||||
"Subscriber added for %s (count=%d)",
|
||||
packet_type.name,
|
||||
len(self._subscribers[packet_type]),
|
||||
)
|
||||
|
||||
def _close() -> None:
|
||||
"""Unsubscribe callback from session packet subscribers."""
|
||||
with self._incoming_cv:
|
||||
callbacks = self._subscribers.get(packet_type)
|
||||
if callbacks is not None:
|
||||
callbacks.discard(callback)
|
||||
logger.debug(
|
||||
"Subscriber removed for %s (count=%d)",
|
||||
packet_type.name,
|
||||
len(callbacks),
|
||||
)
|
||||
|
||||
return StreamHandle(stop_event=stop_event, close_callback=_close)
|
||||
|
||||
def _on_transport_data(self, chunk: bytes) -> None:
|
||||
"""Decode incoming USB chunk and route packets to queues/subscribers."""
|
||||
try:
|
||||
packets = self._scanner.feed(chunk)
|
||||
if logger.isEnabledFor(logging.DEBUG) and packets:
|
||||
logger.debug("RX chunk=%dB decoded_packets=%d", len(chunk), len(packets))
|
||||
except Exception as exc:
|
||||
self._set_fatal_error(exc)
|
||||
return
|
||||
|
||||
for packet in packets:
|
||||
try:
|
||||
decoded_payload = decode_packet_payload(packet)
|
||||
except Exception as exc:
|
||||
self._set_fatal_error(exc)
|
||||
return
|
||||
|
||||
decoded_packet = Packet(type=packet.type, payload=decoded_payload)
|
||||
self._dispatch_packet(decoded_packet)
|
||||
|
||||
def _on_transport_disconnect(self, exc: Exception) -> None:
|
||||
"""Receive asynchronous transport disconnect event."""
|
||||
self._set_fatal_error(exc)
|
||||
|
||||
def _dispatch_packet(self, packet: Packet) -> None:
|
||||
"""Route packet into ack waiter, queue, and subscriber callbacks."""
|
||||
if logger.isEnabledFor(logging.DEBUG) and packet.type not in {
|
||||
PacketType.VNA_DATAPOINT,
|
||||
}:
|
||||
logger.debug("Dispatch packet %s", packet.type.name)
|
||||
|
||||
if packet.type in {PacketType.ACK, PacketType.NACK}:
|
||||
with self._ack_cv:
|
||||
if self._awaiting_ack and self._ack_result is None:
|
||||
self._ack_result = packet.type
|
||||
self._ack_cv.notify_all()
|
||||
return
|
||||
|
||||
callbacks: list[Callable[[Packet], None]] = []
|
||||
with self._incoming_cv:
|
||||
self._incoming[packet.type].append(packet)
|
||||
callbacks = list(self._subscribers.get(packet.type, set()))
|
||||
self._incoming_cv.notify_all()
|
||||
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(packet)
|
||||
except Exception as exc:
|
||||
logger.exception("Subscriber callback failed for packet %s", packet.type.name)
|
||||
self._set_fatal_error(exc)
|
||||
return
|
||||
|
||||
def _set_fatal_error(self, exc: Exception) -> None:
|
||||
"""Mark session as failed and wake all waiting operations."""
|
||||
with self._incoming_cv:
|
||||
with self._ack_cv:
|
||||
if self._fatal_error is None:
|
||||
self._fatal_error = exc
|
||||
logger.error("Session fatal error: %s", exc, exc_info=exc)
|
||||
self._incoming_cv.notify_all()
|
||||
self._ack_cv.notify_all()
|
||||
|
||||
def _raise_if_fatal_locked(self) -> None:
|
||||
"""Raise stored fatal error (called while condition lock is held)."""
|
||||
if self._fatal_error is None:
|
||||
return
|
||||
|
||||
exc = self._fatal_error
|
||||
if isinstance(exc, DeviceDisconnectedError):
|
||||
raise exc
|
||||
raise DeviceDisconnectedError(f"Session stopped due to transport/protocol error: {exc}") from exc
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Sweep assembly and result helpers."""
|
||||
|
||||
from .assembler import assemble_vna_sweep, datapoint_to_raw_point
|
||||
from ..models import SweepResult
|
||||
|
||||
__all__ = [
|
||||
"SweepResult",
|
||||
"assemble_vna_sweep",
|
||||
"datapoint_to_raw_point",
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Helpers to assemble high-level sweep results from packet streams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..exceptions import IncompleteSweepError, ParseError
|
||||
from ..enums import SweepKind
|
||||
from ..models import VNADatapointPacket, VNARawPoint, VNASweepSettings, SweepResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_REFERENCE_FLAG = 0x10
|
||||
|
||||
|
||||
def _extract_stage(flags: int) -> int:
|
||||
"""Return stage index encoded inside datapoint flag byte."""
|
||||
return flags >> 5
|
||||
|
||||
|
||||
def _find_vna_value(
|
||||
datapoint: VNADatapointPacket,
|
||||
*,
|
||||
stage: int,
|
||||
port_index: int,
|
||||
reference: bool,
|
||||
) -> complex:
|
||||
"""Find one complex receiver value for stage/port/ref tuple."""
|
||||
source_mask = 1 << port_index
|
||||
if reference:
|
||||
source_mask |= _REFERENCE_FLAG
|
||||
|
||||
for idx, flags in enumerate(datapoint.flags):
|
||||
if _extract_stage(int(flags)) != stage:
|
||||
continue
|
||||
if (int(flags) & source_mask) != source_mask:
|
||||
continue
|
||||
return complex(float(datapoint.real[idx]), float(datapoint.imag[idx]))
|
||||
|
||||
kind = "reference" if reference else "receiver"
|
||||
raise ParseError(
|
||||
f"Missing {kind} value for stage={stage}, port_index={port_index}, point={datapoint.point_number}"
|
||||
)
|
||||
|
||||
|
||||
def datapoint_to_raw_point(
|
||||
datapoint: VNADatapointPacket,
|
||||
settings: VNASweepSettings,
|
||||
*,
|
||||
num_ports: int,
|
||||
) -> VNARawPoint:
|
||||
"""Convert low-level VNADatapoint packet into callback-friendly object."""
|
||||
measurements: dict[str, complex] = {}
|
||||
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
|
||||
|
||||
for excited_port, stage in stage_by_excited_port.items():
|
||||
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
|
||||
for receiver_port in range(1, num_ports + 1):
|
||||
measured = _find_vna_value(
|
||||
datapoint,
|
||||
stage=stage,
|
||||
port_index=receiver_port - 1,
|
||||
reference=False,
|
||||
)
|
||||
measurements[f"S{receiver_port}{excited_port}"] = measured / ref
|
||||
|
||||
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
|
||||
settings.power_start_dbm,
|
||||
settings.power_stop_dbm,
|
||||
rel_tol=0.0,
|
||||
abs_tol=0.0,
|
||||
)
|
||||
|
||||
if zero_span:
|
||||
return VNARawPoint(
|
||||
point_number=datapoint.point_number,
|
||||
frequency_hz=None,
|
||||
time_s=float(datapoint.frequency_or_time) * 1e-6,
|
||||
power_dbm=None,
|
||||
measurements=measurements,
|
||||
)
|
||||
|
||||
return VNARawPoint(
|
||||
point_number=datapoint.point_number,
|
||||
frequency_hz=float(datapoint.frequency_or_time),
|
||||
time_s=None,
|
||||
power_dbm=float(datapoint.cdbm) / 100.0,
|
||||
measurements=measurements,
|
||||
)
|
||||
|
||||
|
||||
def assemble_vna_sweep(
|
||||
datapoints: Iterable[VNADatapointPacket],
|
||||
settings: VNASweepSettings,
|
||||
*,
|
||||
num_ports: int,
|
||||
expected_points: int,
|
||||
) -> SweepResult:
|
||||
"""Build a complete VNA sweep from raw datapoints."""
|
||||
if expected_points <= 0:
|
||||
raise ValueError("expected_points must be > 0")
|
||||
|
||||
x = np.empty(expected_points, dtype=np.float64)
|
||||
seen = np.zeros(expected_points, dtype=bool)
|
||||
|
||||
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
|
||||
receiver_limit = min(num_ports, 2)
|
||||
trace_names = [
|
||||
f"s{receiver_port}{excited_port}"
|
||||
for excited_port in settings.excited_ports
|
||||
for receiver_port in range(1, receiver_limit + 1)
|
||||
if receiver_port <= 2 and excited_port <= 2
|
||||
]
|
||||
traces: dict[str, np.ndarray] = {
|
||||
name: np.empty(expected_points, dtype=np.complex128) for name in trace_names
|
||||
}
|
||||
|
||||
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
|
||||
settings.power_start_dbm,
|
||||
settings.power_stop_dbm,
|
||||
rel_tol=0.0,
|
||||
abs_tol=0.0,
|
||||
)
|
||||
power_sweep = settings.kind == SweepKind.POWER
|
||||
|
||||
for datapoint in datapoints:
|
||||
idx = datapoint.point_number
|
||||
if idx >= expected_points:
|
||||
raise ParseError(f"Received out-of-range point index {idx}, expected < {expected_points}")
|
||||
|
||||
if zero_span:
|
||||
x[idx] = float(datapoint.frequency_or_time) * 1e-6
|
||||
elif power_sweep:
|
||||
x[idx] = float(datapoint.cdbm) / 100.0
|
||||
else:
|
||||
x[idx] = float(datapoint.frequency_or_time)
|
||||
|
||||
for excited_port, stage in stage_by_excited_port.items():
|
||||
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
|
||||
for receiver_port in range(1, num_ports + 1):
|
||||
if receiver_port > 2 or excited_port > 2:
|
||||
# Public SweepResult intentionally exposes 2-port canonical traces.
|
||||
continue
|
||||
measured = _find_vna_value(
|
||||
datapoint,
|
||||
stage=stage,
|
||||
port_index=receiver_port - 1,
|
||||
reference=False,
|
||||
)
|
||||
trace_name = f"s{receiver_port}{excited_port}"
|
||||
trace = traces.get(trace_name)
|
||||
if trace is not None:
|
||||
trace[idx] = measured / ref
|
||||
|
||||
seen[idx] = True
|
||||
|
||||
missing = np.flatnonzero(~seen)
|
||||
if missing.size > 0:
|
||||
raise IncompleteSweepError(
|
||||
f"Sweep is incomplete: received {int(seen.sum())}/{expected_points} points"
|
||||
)
|
||||
|
||||
x_label = "frequency_hz"
|
||||
if zero_span:
|
||||
x_label = "time_s"
|
||||
elif power_sweep:
|
||||
x_label = "power_dbm"
|
||||
|
||||
result = SweepResult(x=x, traces=traces, x_label=x_label)
|
||||
logger.debug(
|
||||
"Assembled VNA sweep: points=%d x_label=%s traces=%s",
|
||||
len(result.x),
|
||||
result.x_label,
|
||||
sorted(result.traces.keys()),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Transport layer primitives."""
|
||||
|
||||
from .usb import USBTransport
|
||||
|
||||
__all__ = ["USBTransport"]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Direct USB transport using libusb1 for LibreVNA devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
from ..exceptions import DeviceDisconnectedError, TimeoutError
|
||||
from ..models import USBDeviceDescriptor
|
||||
|
||||
import usb1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class USBTransport:
|
||||
"""USB bulk transport for LibreVNA protocol endpoints."""
|
||||
|
||||
DATA_EP_OUT = 0x01
|
||||
DATA_EP_IN = 0x81
|
||||
INTERFACE = 0
|
||||
VALID_USB_IDS = (
|
||||
(0x0483, 0x564E),
|
||||
(0x0483, 0x4121),
|
||||
(0x1209, 0x4121),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
on_data: Callable[[bytes], None],
|
||||
on_disconnect: Callable[[Exception], None] | None = None,
|
||||
read_chunk_size: int = 65536,
|
||||
) -> None:
|
||||
"""Create transport with RX callback and optional disconnect callback."""
|
||||
self._on_data = on_data
|
||||
self._on_disconnect = on_disconnect
|
||||
self._read_chunk_size = read_chunk_size
|
||||
|
||||
self._ctx: usb1.USBContext | None = None # type: ignore[valid-type]
|
||||
self._handle: usb1.USBDeviceHandle | None = None # type: ignore[valid-type]
|
||||
self._rx_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._tx_lock = threading.Lock()
|
||||
|
||||
self.connected_serial: str | None = None
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return `True` when USB handle is open."""
|
||||
return self._handle is not None
|
||||
|
||||
@staticmethod
|
||||
def list_devices() -> list[USBDeviceDescriptor]:
|
||||
"""Enumerate attached LibreVNA USB devices."""
|
||||
devices: list[USBDeviceDescriptor] = []
|
||||
with usb1.USBContext() as ctx:
|
||||
for device, vid, pid in USBTransport._iter_matching_devices(ctx):
|
||||
handle: usb1.USBDeviceHandle | None = None
|
||||
try:
|
||||
handle = device.open()
|
||||
serial = handle.getSerialNumber() or ""
|
||||
except usb1.USBError as exc:
|
||||
logger.debug(
|
||||
"Skipping USB device during discovery vid=0x%04x pid=0x%04x: %s",
|
||||
vid,
|
||||
pid,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
finally:
|
||||
if handle is not None:
|
||||
with suppress(usb1.USBError):
|
||||
handle.close()
|
||||
|
||||
devices.append(USBDeviceDescriptor(serial=serial, vendor_id=vid, product_id=pid))
|
||||
|
||||
devices.sort(key=lambda item: (item.serial, item.vendor_id, item.product_id))
|
||||
logger.debug("USB discovery completed, devices=%d", len(devices))
|
||||
return devices
|
||||
|
||||
def connect(self, *, serial: str | None = None, timeout_s: float = 1.0) -> None:
|
||||
"""Open USB device, claim interface, and start RX thread."""
|
||||
if self._handle is not None:
|
||||
logger.debug("USB connect skipped: already connected")
|
||||
return
|
||||
|
||||
logger.debug("Opening libusb context for connect(serial=%s)", serial)
|
||||
self._ctx = usb1.USBContext()
|
||||
selected_handle: usb1.USBDeviceHandle | None = None
|
||||
selected_serial = ""
|
||||
|
||||
for device, _, _ in self._iter_matching_devices(self._ctx):
|
||||
handle: usb1.USBDeviceHandle | None = None
|
||||
try:
|
||||
handle = device.open()
|
||||
found_serial = handle.getSerialNumber() or ""
|
||||
if serial is not None and found_serial != serial:
|
||||
handle.close()
|
||||
continue
|
||||
|
||||
selected_handle = handle
|
||||
selected_serial = found_serial
|
||||
break
|
||||
except usb1.USBError as exc:
|
||||
if handle is not None:
|
||||
with suppress(usb1.USBError):
|
||||
handle.close()
|
||||
logger.debug("Skipping USB candidate during connect due to error: %s", exc)
|
||||
continue
|
||||
|
||||
if selected_handle is None:
|
||||
if self._ctx is not None:
|
||||
self._ctx.close()
|
||||
self._ctx = None
|
||||
serial_msg = f" with serial '{serial}'" if serial else ""
|
||||
raise DeviceDisconnectedError(f"No compatible LibreVNA USB device found{serial_msg}")
|
||||
|
||||
try:
|
||||
selected_handle.setAutoDetachKernelDriver(True)
|
||||
if selected_handle.kernelDriverActive(self.INTERFACE):
|
||||
selected_handle.detachKernelDriver(self.INTERFACE)
|
||||
except usb1.USBError as exc:
|
||||
selected_handle.close()
|
||||
if self._ctx is not None:
|
||||
self._ctx.close()
|
||||
self._ctx = None
|
||||
raise DeviceDisconnectedError(f"Failed to prepare USB kernel driver state: {exc}") from exc
|
||||
|
||||
try:
|
||||
selected_handle.claimInterface(self.INTERFACE)
|
||||
except usb1.USBError as exc:
|
||||
selected_handle.close()
|
||||
if self._ctx is not None:
|
||||
self._ctx.close()
|
||||
self._ctx = None
|
||||
raise DeviceDisconnectedError(f"Failed to claim USB interface {self.INTERFACE}: {exc}") from exc
|
||||
|
||||
self._handle = selected_handle
|
||||
self.connected_serial = selected_serial
|
||||
self._stop_event.clear()
|
||||
logger.info(
|
||||
"USB connected (serial=%s, timeout=%.2fs, endpoint_out=0x%02x, endpoint_in=0x%02x)",
|
||||
self.connected_serial,
|
||||
timeout_s,
|
||||
self.DATA_EP_OUT,
|
||||
self.DATA_EP_IN,
|
||||
)
|
||||
|
||||
self._rx_thread = threading.Thread(target=self._rx_loop, name="librevna-usb-rx", daemon=True)
|
||||
self._rx_thread.start()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Stop RX thread and close USB resources."""
|
||||
logger.debug("USB disconnect requested")
|
||||
self._stop_event.set()
|
||||
|
||||
if self._rx_thread is not None and self._rx_thread.is_alive():
|
||||
self._rx_thread.join(timeout=1.0)
|
||||
self._rx_thread = None
|
||||
|
||||
if self._handle is not None:
|
||||
self._handle.releaseInterface(self.INTERFACE)
|
||||
self._handle.close()
|
||||
self._handle = None
|
||||
|
||||
if self._ctx is not None:
|
||||
self._ctx.close()
|
||||
self._ctx = None
|
||||
|
||||
logger.info("USB disconnected (serial=%s)", self.connected_serial)
|
||||
self.connected_serial = None
|
||||
|
||||
def write(self, data: bytes, *, timeout_s: float = 0.5) -> None:
|
||||
"""Write one framed packet to device bulk-out endpoint."""
|
||||
handle = self._handle
|
||||
if handle is None:
|
||||
raise DeviceDisconnectedError("USB device is not connected")
|
||||
|
||||
timeout_ms = max(1, int(timeout_s * 1000.0))
|
||||
with self._tx_lock:
|
||||
try:
|
||||
written = handle.bulkWrite(self.DATA_EP_OUT, data, timeout=timeout_ms)
|
||||
except usb1.USBErrorTimeout as exc:
|
||||
raise TimeoutError("Timed out writing USB bulk packet") from exc
|
||||
except usb1.USBErrorNoDevice as exc:
|
||||
raise DeviceDisconnectedError("USB device disconnected during write") from exc
|
||||
except usb1.USBError as exc:
|
||||
raise DeviceDisconnectedError(f"USB write failed: {exc}") from exc
|
||||
|
||||
if written != len(data):
|
||||
raise DeviceDisconnectedError(
|
||||
f"USB bulk write incomplete: wrote {written}/{len(data)} bytes"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("USB TX %d bytes", len(data))
|
||||
|
||||
def _rx_loop(self) -> None:
|
||||
"""Continuously read bulk-in data and forward to frame scanner callback."""
|
||||
handle = self._handle
|
||||
if handle is None:
|
||||
return
|
||||
|
||||
logger.debug("USB RX thread started")
|
||||
timeout_ms = 100
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
data = handle.bulkRead(self.DATA_EP_IN, self._read_chunk_size, timeout=timeout_ms)
|
||||
except usb1.USBErrorTimeout:
|
||||
continue
|
||||
except usb1.USBErrorInterrupted:
|
||||
continue
|
||||
except usb1.USBErrorNoDevice as exc:
|
||||
if self._on_disconnect is not None:
|
||||
self._on_disconnect(DeviceDisconnectedError("USB device disconnected"))
|
||||
logger.warning("USB RX stopped: device disconnected")
|
||||
return
|
||||
except usb1.USBError as exc:
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
if self._on_disconnect is not None:
|
||||
self._on_disconnect(DeviceDisconnectedError(f"USB read failed: {exc}"))
|
||||
logger.error("USB RX failed: %s", exc)
|
||||
return
|
||||
|
||||
if data:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("USB RX %d bytes", len(data))
|
||||
self._on_data(bytes(data))
|
||||
logger.debug("USB RX thread stopped")
|
||||
|
||||
@classmethod
|
||||
def _iter_matching_devices(
|
||||
cls,
|
||||
ctx: "usb1.USBContext", # type: ignore[name-defined]
|
||||
):
|
||||
"""Yield USB devices matching supported LibreVNA VID/PID pairs."""
|
||||
for device in ctx.getDeviceList(skip_on_error=True):
|
||||
vid = int(device.getVendorID())
|
||||
pid = int(device.getProductID())
|
||||
if (vid, pid) in cls.VALID_USB_IDS:
|
||||
yield device, vid, pid
|
||||
@@ -0,0 +1,98 @@
|
||||
"""High-level orchestration service for configuring and querying LibreVNA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.librevna_backends import LibreVnaBackend, MockLibreVnaBackend, NativeLibreVnaBackend
|
||||
from python_app.models.run_config_model import RadarSweepModel
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LibreVnaService:
|
||||
"""Provide stable API for GUI/workflows while hiding backend details."""
|
||||
|
||||
serial: str | None = None
|
||||
strict_protocol_version: int = 14
|
||||
backend_mode: str = "auto"
|
||||
_driver_available: bool = field(init=False, default=False, repr=False)
|
||||
_backend: LibreVnaBackend | None = field(init=False, default=None, repr=False)
|
||||
_using_mock_backend: bool = field(init=False, default=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Initialize selected backend and detect driver availability."""
|
||||
self._driver_available = False
|
||||
self._backend = None
|
||||
self._using_mock_backend = False
|
||||
|
||||
mode = self.backend_mode.strip().lower()
|
||||
if mode not in {"auto", "native", "mock"}:
|
||||
raise ValueError(f"Unsupported LibreVnaService backend mode: {self.backend_mode}")
|
||||
|
||||
if mode == "mock":
|
||||
self._backend = MockLibreVnaBackend()
|
||||
self._using_mock_backend = True
|
||||
return
|
||||
|
||||
try:
|
||||
self._backend = NativeLibreVnaBackend(
|
||||
serial=self.serial,
|
||||
strict_protocol_version=self.strict_protocol_version,
|
||||
)
|
||||
self._driver_available = True
|
||||
except Exception:
|
||||
if mode == "native":
|
||||
raise
|
||||
self._backend = MockLibreVnaBackend()
|
||||
self._using_mock_backend = True
|
||||
|
||||
@property
|
||||
def driver_available(self) -> bool:
|
||||
"""Return `True` when native Python LibreVNA driver is available."""
|
||||
return self._driver_available
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open backend resources."""
|
||||
if not self._driver_available and not self._using_mock_backend:
|
||||
return
|
||||
if self._backend is None:
|
||||
return
|
||||
self._backend.open()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close backend resources."""
|
||||
if self._backend is None:
|
||||
return
|
||||
self._backend.close()
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Apply sweep settings to active backend."""
|
||||
if self._backend is None:
|
||||
raise RuntimeError("LibreVNA backend is not initialized")
|
||||
self._backend.configure(sweep)
|
||||
|
||||
def read_device_limits(self) -> dict[str, float | int]:
|
||||
"""Read native device limits from connected LibreVNA."""
|
||||
if not self._driver_available:
|
||||
raise RuntimeError("LibreVNA Python driver is not available")
|
||||
if self._backend is None:
|
||||
raise RuntimeError("LibreVNA backend is not initialized")
|
||||
|
||||
opened_here = not self._backend.is_open
|
||||
try:
|
||||
if opened_here:
|
||||
self.open()
|
||||
return self._backend.read_device_limits()
|
||||
finally:
|
||||
if opened_here:
|
||||
self.close()
|
||||
|
||||
def acquire_s21(self) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Acquire one S21 trace from currently selected backend."""
|
||||
if self._backend is None:
|
||||
raise RuntimeError("LibreVNA backend is not initialized")
|
||||
if self._using_mock_backend and not self._driver_available and self.backend_mode != "mock":
|
||||
raise RuntimeError("Device not found")
|
||||
return self._backend.acquire_s21()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Switch driver implementations for native GPIO and mock operation."""
|
||||
|
||||
from python_app.hardware_full.switch_drivers.h7992_driver import H7992Driver
|
||||
from python_app.hardware_full.switch_drivers.hmc349a_driver import HMC349ADriver
|
||||
from python_app.hardware_full.switch_drivers.interface import SwitchDriverProtocol
|
||||
from python_app.hardware_full.switch_drivers.mock_driver import MockSwitchDriver
|
||||
|
||||
__all__ = [
|
||||
"H7992Driver",
|
||||
"HMC349ADriver",
|
||||
"MockSwitchDriver",
|
||||
"SwitchDriverProtocol",
|
||||
]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Minimal Linux GPIO v2 UAPI wrapper for output-only line control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
GPIO_MAX_NAME_SIZE = 32
|
||||
GPIO_V2_LINES_MAX = 64
|
||||
GPIO_V2_LINE_NUM_ATTRS_MAX = 10
|
||||
GPIO_V2_LINE_FLAG_OUTPUT = 1 << 3
|
||||
|
||||
_IOC_NRBITS = 8
|
||||
_IOC_TYPEBITS = 8
|
||||
_IOC_SIZEBITS = 14
|
||||
_IOC_DIRBITS = 2
|
||||
|
||||
_IOC_NRSHIFT = 0
|
||||
_IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS
|
||||
_IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS
|
||||
_IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS
|
||||
|
||||
_IOC_WRITE = 1
|
||||
_IOC_READ = 2
|
||||
|
||||
|
||||
def _ioc(direction: int, ioc_type: int, number: int, size: int) -> int:
|
||||
"""Build raw ioctl command number."""
|
||||
return (
|
||||
(direction << _IOC_DIRSHIFT)
|
||||
| (ioc_type << _IOC_TYPESHIFT)
|
||||
| (number << _IOC_NRSHIFT)
|
||||
| (size << _IOC_SIZESHIFT)
|
||||
)
|
||||
|
||||
|
||||
def _iowr(ioc_type: int, number: int, struct_type: type[ctypes.Structure]) -> int:
|
||||
"""Build read-write ioctl number for provided structure."""
|
||||
return _ioc(_IOC_READ | _IOC_WRITE, ioc_type, number, ctypes.sizeof(struct_type))
|
||||
|
||||
|
||||
class GpioV2LineAttribute(ctypes.Structure):
|
||||
"""ctypes mapping of `gpio_v2_line_attribute`."""
|
||||
|
||||
_fields_ = [
|
||||
("id", ctypes.c_uint32),
|
||||
("padding", ctypes.c_uint32),
|
||||
("value", ctypes.c_uint64),
|
||||
]
|
||||
|
||||
|
||||
class GpioV2LineConfigAttribute(ctypes.Structure):
|
||||
"""ctypes mapping of `gpio_v2_line_config_attribute`."""
|
||||
|
||||
_fields_ = [
|
||||
("attr", GpioV2LineAttribute),
|
||||
("mask", ctypes.c_uint64),
|
||||
]
|
||||
|
||||
|
||||
class GpioV2LineConfig(ctypes.Structure):
|
||||
"""ctypes mapping of `gpio_v2_line_config`."""
|
||||
|
||||
_fields_ = [
|
||||
("flags", ctypes.c_uint64),
|
||||
("num_attrs", ctypes.c_uint32),
|
||||
("padding", ctypes.c_uint32 * 5),
|
||||
("attrs", GpioV2LineConfigAttribute * GPIO_V2_LINE_NUM_ATTRS_MAX),
|
||||
]
|
||||
|
||||
|
||||
class GpioV2LineRequest(ctypes.Structure):
|
||||
"""ctypes mapping of `gpio_v2_line_request`."""
|
||||
|
||||
_fields_ = [
|
||||
("offsets", ctypes.c_uint32 * GPIO_V2_LINES_MAX),
|
||||
("consumer", ctypes.c_char * GPIO_MAX_NAME_SIZE),
|
||||
("config", GpioV2LineConfig),
|
||||
("num_lines", ctypes.c_uint32),
|
||||
("event_buffer_size", ctypes.c_uint32),
|
||||
("padding", ctypes.c_uint32 * 5),
|
||||
("fd", ctypes.c_int32),
|
||||
]
|
||||
|
||||
|
||||
class GpioV2LineValues(ctypes.Structure):
|
||||
"""ctypes mapping of `gpio_v2_line_values`."""
|
||||
|
||||
_fields_ = [
|
||||
("bits", ctypes.c_uint64),
|
||||
("mask", ctypes.c_uint64),
|
||||
]
|
||||
|
||||
|
||||
GPIO_V2_GET_LINE_IOCTL = _iowr(0xB4, 0x07, GpioV2LineRequest)
|
||||
GPIO_V2_LINE_SET_VALUES_IOCTL = _iowr(0xB4, 0x0F, GpioV2LineValues)
|
||||
|
||||
|
||||
class GpioOutputLines:
|
||||
"""Open and control one or more GPIO output lines as a single request."""
|
||||
|
||||
def __init__(self, chip: str, offsets: Sequence[int], consumer: str) -> None:
|
||||
"""Build GPIO line request descriptor."""
|
||||
if not chip:
|
||||
raise ValueError("gpio chip path must not be empty")
|
||||
if not offsets:
|
||||
raise ValueError("at least one GPIO line offset is required")
|
||||
if len(offsets) > GPIO_V2_LINES_MAX:
|
||||
raise ValueError(f"too many GPIO offsets requested: {len(offsets)}")
|
||||
|
||||
normalized_offsets = [int(offset) for offset in offsets]
|
||||
if any(offset < 0 for offset in normalized_offsets):
|
||||
raise ValueError("GPIO offsets must be non-negative")
|
||||
if len(set(normalized_offsets)) != len(normalized_offsets):
|
||||
raise ValueError("GPIO offsets must be unique")
|
||||
|
||||
self._chip = chip
|
||||
self._offsets = normalized_offsets
|
||||
self._consumer = (consumer or "radar_switch").encode("ascii", errors="ignore")[: GPIO_MAX_NAME_SIZE - 1]
|
||||
|
||||
self._chip_fd = -1
|
||||
self._line_fd = -1
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open GPIO chip and request configured output lines."""
|
||||
if self._line_fd >= 0:
|
||||
return
|
||||
|
||||
try:
|
||||
self._chip_fd = os.open(self._chip, os.O_RDONLY | os.O_CLOEXEC)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to open GPIO chip '{self._chip}': {exc}") from exc
|
||||
|
||||
request = GpioV2LineRequest()
|
||||
for index, offset in enumerate(self._offsets):
|
||||
request.offsets[index] = ctypes.c_uint32(offset).value
|
||||
|
||||
request.num_lines = ctypes.c_uint32(len(self._offsets)).value
|
||||
request.config.flags = ctypes.c_uint64(GPIO_V2_LINE_FLAG_OUTPUT).value
|
||||
request.consumer = self._consumer
|
||||
|
||||
try:
|
||||
fcntl.ioctl(self._chip_fd, GPIO_V2_GET_LINE_IOCTL, request)
|
||||
except OSError as exc:
|
||||
self._close_chip_fd()
|
||||
raise RuntimeError(f"Failed to request GPIO lines on '{self._chip}': {exc}") from exc
|
||||
|
||||
if request.fd < 0:
|
||||
self._close_chip_fd()
|
||||
raise RuntimeError(f"GPIO line request returned invalid fd for '{self._chip}'")
|
||||
|
||||
self._line_fd = int(request.fd)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close line request and chip file descriptors."""
|
||||
self._close_line_fd()
|
||||
self._close_chip_fd()
|
||||
|
||||
def set_values(self, values: Sequence[int]) -> None:
|
||||
"""Apply output values for all requested lines."""
|
||||
if self._line_fd < 0:
|
||||
raise RuntimeError("GPIO line request is not open")
|
||||
if len(values) != len(self._offsets):
|
||||
raise ValueError(
|
||||
f"GPIO values length mismatch: expected {len(self._offsets)}, got {len(values)}"
|
||||
)
|
||||
|
||||
bits = 0
|
||||
for index, value in enumerate(values):
|
||||
normalized = int(value)
|
||||
if normalized not in (0, 1):
|
||||
raise ValueError(f"GPIO output value must be 0 or 1, got {value}")
|
||||
if normalized == 1:
|
||||
bits |= (1 << index)
|
||||
|
||||
mask = (1 << len(self._offsets)) - 1
|
||||
line_values = GpioV2LineValues(bits=ctypes.c_uint64(bits).value, mask=ctypes.c_uint64(mask).value)
|
||||
|
||||
try:
|
||||
fcntl.ioctl(self._line_fd, GPIO_V2_LINE_SET_VALUES_IOCTL, line_values)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ENODEV:
|
||||
raise RuntimeError("GPIO device disconnected") from exc
|
||||
raise RuntimeError(f"Failed to set GPIO output values: {exc}") from exc
|
||||
|
||||
def _close_line_fd(self) -> None:
|
||||
"""Close line file descriptor if currently open."""
|
||||
if self._line_fd >= 0:
|
||||
os.close(self._line_fd)
|
||||
self._line_fd = -1
|
||||
|
||||
def _close_chip_fd(self) -> None:
|
||||
"""Close chip file descriptor if currently open."""
|
||||
if self._chip_fd >= 0:
|
||||
os.close(self._chip_fd)
|
||||
self._chip_fd = -1
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Native GPIO driver for H7992 switch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
|
||||
|
||||
|
||||
_POSITION_TO_AB = (
|
||||
(0, 0),
|
||||
(0, 1),
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class H7992Driver:
|
||||
"""Drive H7992 using two GPIO lines (A/B)."""
|
||||
|
||||
name: str
|
||||
positions: int = 4
|
||||
default_position: int = 0
|
||||
gpio_chip: str = "/dev/gpiochip0"
|
||||
pin_a: int = 17
|
||||
pin_b: int = 27
|
||||
_lines: GpioOutputLines | None = field(init=False, default=None, repr=False)
|
||||
_is_open: bool = field(init=False, default=False, repr=False)
|
||||
_current_position: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Initialize runtime state."""
|
||||
self._lines: GpioOutputLines | None = None
|
||||
self._is_open = False
|
||||
self._current_position = 0
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open GPIO lines and switch to default position."""
|
||||
if self._is_open:
|
||||
return
|
||||
|
||||
self._validate()
|
||||
self._lines = GpioOutputLines(
|
||||
chip=self.gpio_chip,
|
||||
offsets=(self.pin_a, self.pin_b),
|
||||
consumer=f"radar_{self.name}",
|
||||
)
|
||||
self._lines.open()
|
||||
self._is_open = True
|
||||
self.switch_to(self.default_position)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close GPIO lines."""
|
||||
if self._lines is not None:
|
||||
self._lines.close()
|
||||
self._lines = None
|
||||
self._is_open = False
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""Return number of supported positions."""
|
||||
return self.positions
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
"""Switch hardware to selected position."""
|
||||
if not self._is_open or self._lines is None:
|
||||
raise RuntimeError(f"Switch driver is not open for {self.name}")
|
||||
if position < 0 or position >= self.positions:
|
||||
raise ValueError(f"Position out of range for {self.name}: {position}")
|
||||
|
||||
self._lines.set_values(_POSITION_TO_AB[position])
|
||||
self._current_position = int(position)
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return current position."""
|
||||
return self._current_position
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Validate H7992 driver parameters."""
|
||||
if self.positions <= 0 or self.positions > 4:
|
||||
raise ValueError(f"H7992 positions must be in range [1,4] for {self.name}")
|
||||
if self.default_position < 0 or self.default_position >= self.positions:
|
||||
raise ValueError(f"default_position out of range for {self.name}")
|
||||
if self.pin_a < 0 or self.pin_b < 0 or self.pin_a == self.pin_b:
|
||||
raise ValueError(f"pin_a/pin_b are invalid for {self.name}")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Native GPIO driver for HMC349A switch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HMC349ADriver:
|
||||
"""Drive HMC349A using one control GPIO line."""
|
||||
|
||||
name: str
|
||||
positions: int = 2
|
||||
default_position: int = 0
|
||||
gpio_chip: str = "/dev/gpiochip0"
|
||||
pin_a: int = 17
|
||||
invert_logic: bool = False
|
||||
_lines: GpioOutputLines | None = field(init=False, default=None, repr=False)
|
||||
_is_open: bool = field(init=False, default=False, repr=False)
|
||||
_current_position: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Initialize runtime state."""
|
||||
self._lines: GpioOutputLines | None = None
|
||||
self._is_open = False
|
||||
self._current_position = 0
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open GPIO lines and switch to default position."""
|
||||
if self._is_open:
|
||||
return
|
||||
|
||||
self._validate()
|
||||
|
||||
offsets = [self.pin_a]
|
||||
self._lines = GpioOutputLines(
|
||||
chip=self.gpio_chip,
|
||||
offsets=offsets,
|
||||
consumer=f"radar_{self.name}",
|
||||
)
|
||||
self._lines.open()
|
||||
self._is_open = True
|
||||
self.switch_to(self.default_position)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close GPIO lines."""
|
||||
if self._lines is not None:
|
||||
self._lines.close()
|
||||
self._lines = None
|
||||
self._is_open = False
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""Return number of supported positions."""
|
||||
return self.positions
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
"""Switch hardware to selected position."""
|
||||
if not self._is_open or self._lines is None:
|
||||
raise RuntimeError(f"Switch driver is not open for {self.name}")
|
||||
if position < 0 or position >= self.positions:
|
||||
raise ValueError(f"Position out of range for {self.name}: {position}")
|
||||
|
||||
control = int(position & 0x01)
|
||||
if self.invert_logic:
|
||||
control ^= 0x01
|
||||
|
||||
self._lines.set_values([control])
|
||||
self._current_position = int(position)
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return current position."""
|
||||
return self._current_position
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Validate HMC349A driver parameters."""
|
||||
if self.positions <= 0 or self.positions > 2:
|
||||
raise ValueError(f"HMC349A positions must be in range [1,2] for {self.name}")
|
||||
if self.default_position < 0 or self.default_position >= self.positions:
|
||||
raise ValueError(f"default_position out of range for {self.name}")
|
||||
if self.pin_a < 0:
|
||||
raise ValueError(f"pin_a is invalid for {self.name}")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Protocols for switch backend implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SwitchDriverProtocol(Protocol):
|
||||
"""Required contract for all switch backends."""
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open hardware or mock resources."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close hardware or mock resources."""
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""Return total number of supported positions."""
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
"""Switch to requested zero-based position."""
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return currently active position."""
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Mock switch driver used in development and simulation modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MockSwitchDriver:
|
||||
"""In-memory switch driver that validates and tracks current position."""
|
||||
|
||||
name: str
|
||||
positions: int
|
||||
default_position: int = 0
|
||||
_is_open: bool = field(init=False, default=False, repr=False)
|
||||
_current_position: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Initialize closed driver state."""
|
||||
self._is_open = False
|
||||
self._current_position = 0
|
||||
|
||||
def open(self) -> None:
|
||||
"""Validate config and open driver."""
|
||||
self._validate()
|
||||
self._is_open = True
|
||||
self.switch_to(self.default_position)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close mock driver."""
|
||||
self._is_open = False
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""Return number of supported positions."""
|
||||
return self.positions
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
"""Switch to requested position."""
|
||||
if not self._is_open:
|
||||
raise RuntimeError(f"Switch driver is not open for {self.name}")
|
||||
if position < 0 or position >= self.positions:
|
||||
raise ValueError(f"Position out of range for {self.name}: {position}")
|
||||
self._current_position = int(position)
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return current position."""
|
||||
return self._current_position
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Validate mock driver parameters."""
|
||||
if self.positions <= 0:
|
||||
raise ValueError(f"Switch positions must be > 0 for {self.name}")
|
||||
if self.default_position < 0 or self.default_position >= self.positions:
|
||||
raise ValueError(f"Switch default_position out of range for {self.name}")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""High-level switch service selecting native/mock backend driver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from python_app.hardware_full.switch_drivers import (
|
||||
H7992Driver,
|
||||
HMC349ADriver,
|
||||
MockSwitchDriver,
|
||||
SwitchDriverProtocol,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SwitchService:
|
||||
"""Facade around concrete switch drivers."""
|
||||
|
||||
name: str
|
||||
positions: int
|
||||
mode: str = "mock"
|
||||
driver: str = "h7992"
|
||||
gpio_chip: str = "/dev/gpiochip0"
|
||||
pin_a: int = 17
|
||||
pin_b: int = 27
|
||||
invert_logic: bool = False
|
||||
default_position: int = 0
|
||||
_driver: SwitchDriverProtocol = field(init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Create underlying driver based on configured mode and type."""
|
||||
self._driver = self._build_driver()
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open underlying switch driver resources."""
|
||||
self._driver.open()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close underlying switch driver resources."""
|
||||
self._driver.close()
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
"""Switch to requested position."""
|
||||
self._driver.switch_to(position)
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return current switch position reported by backend driver."""
|
||||
return self._driver.current_position
|
||||
|
||||
def _build_driver(self) -> SwitchDriverProtocol:
|
||||
"""Instantiate concrete driver according to configured mode and driver kind."""
|
||||
mode = self.mode.strip().lower()
|
||||
driver_kind = self.driver.strip().lower()
|
||||
|
||||
if mode == "mock":
|
||||
return MockSwitchDriver(
|
||||
name=self.name,
|
||||
positions=self.positions,
|
||||
default_position=self.default_position,
|
||||
)
|
||||
|
||||
if mode != "native":
|
||||
raise RuntimeError(f"Unsupported switch mode: {self.mode}")
|
||||
|
||||
if driver_kind == "h7992":
|
||||
return H7992Driver(
|
||||
name=self.name,
|
||||
positions=self.positions,
|
||||
default_position=self.default_position,
|
||||
gpio_chip=self.gpio_chip,
|
||||
pin_a=self.pin_a,
|
||||
pin_b=self.pin_b,
|
||||
)
|
||||
if driver_kind == "hmc349a":
|
||||
return HMC349ADriver(
|
||||
name=self.name,
|
||||
positions=self.positions,
|
||||
default_position=self.default_position,
|
||||
gpio_chip=self.gpio_chip,
|
||||
pin_a=self.pin_a,
|
||||
invert_logic=self.invert_logic,
|
||||
)
|
||||
|
||||
raise RuntimeError(f"Unsupported switch driver: {self.driver}")
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain models for configuration and dataset payloads."""
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Data models for sweep and processing payload collections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ComboKey:
|
||||
"""Switch combination key: input position + output position."""
|
||||
|
||||
input_pos: int
|
||||
output_pos: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TraceData:
|
||||
"""One frequency-domain S21 trace for a specific switch combination."""
|
||||
|
||||
combo: ComboKey
|
||||
frequency_hz: np.ndarray
|
||||
s21: np.ndarray
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SweepCollection:
|
||||
"""Raw or preprocessed collection containing multiple combo traces."""
|
||||
|
||||
collection_id: int
|
||||
monotonic_ns: int
|
||||
traces: list[TraceData] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ResultPayload:
|
||||
"""Processed payload item (trace-like or scalar) produced by processor stage."""
|
||||
|
||||
processing_name: str
|
||||
kind: int
|
||||
frequency_hz: np.ndarray
|
||||
trace: np.ndarray
|
||||
scalar_value: float = 0.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ResultBlock:
|
||||
"""Result payload block grouped by one switch combination."""
|
||||
|
||||
combo: ComboKey
|
||||
payloads: list[ResultPayload] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ResultCollection:
|
||||
"""Collection of processed payload blocks for one acquisition cycle."""
|
||||
|
||||
collection_id: int
|
||||
monotonic_ns: int
|
||||
blocks: list[ResultBlock] = field(default_factory=list)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Encoding and decoding logic for :mod:`python_app.models.run_config_schema`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from python_app.models.run_config_schema import ComboModel, RunConfigModel
|
||||
from python_app.models.run_config_validation import as_dict, load_ring_payload, load_switch_payload
|
||||
|
||||
|
||||
def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
"""Decode JSON-like payload into :class:`RunConfigModel`."""
|
||||
# Schema carries only minimal-safe fallbacks; operational defaults live in run_config.json.
|
||||
model = RunConfigModel()
|
||||
|
||||
radar_payload = as_dict(payload.get("radar"), "radar")
|
||||
sweep_payload = as_dict(radar_payload.get("sweep"), "radar.sweep")
|
||||
switches_payload = as_dict(payload.get("switches"), "switches")
|
||||
port1_payload = as_dict(switches_payload.get("port1"), "switches.port1")
|
||||
port2_payload = as_dict(switches_payload.get("port2"), "switches.port2")
|
||||
run_payload = as_dict(payload.get("run"), "run")
|
||||
preprocess_payload = as_dict(payload.get("preprocess"), "preprocess")
|
||||
rings_payload = as_dict(payload.get("rings"), "rings")
|
||||
raw_ring_payload = as_dict(rings_payload.get("raw"), "rings.raw")
|
||||
raw_tap_ring_payload = as_dict(rings_payload.get("raw_tap"), "rings.raw_tap")
|
||||
pre_ring_payload = as_dict(rings_payload.get("preprocessed"), "rings.preprocessed")
|
||||
pre_tap_ring_payload = as_dict(rings_payload.get("preprocessed_tap"), "rings.preprocessed_tap")
|
||||
result_ring_payload = as_dict(rings_payload.get("results"), "rings.results")
|
||||
|
||||
model.radar.model = str(radar_payload.get("model", model.radar.model))
|
||||
model.radar.serial = str(radar_payload.get("serial", model.radar.serial))
|
||||
model.radar.driver_mode = str(radar_payload.get("driver_mode", model.radar.driver_mode))
|
||||
model.radar.mock_signal_hz = float(radar_payload.get("mock_signal_hz", model.radar.mock_signal_hz))
|
||||
|
||||
model.radar.sweep.start_hz = float(sweep_payload.get("start_hz", model.radar.sweep.start_hz))
|
||||
model.radar.sweep.stop_hz = float(sweep_payload.get("stop_hz", model.radar.sweep.stop_hz))
|
||||
model.radar.sweep.points = int(sweep_payload.get("points", model.radar.sweep.points))
|
||||
model.radar.sweep.if_bandwidth_hz = float(
|
||||
sweep_payload.get("if_bandwidth_hz", model.radar.sweep.if_bandwidth_hz)
|
||||
)
|
||||
model.radar.sweep.power_dbm = float(sweep_payload.get("stimulus_power_dbm", model.radar.sweep.power_dbm))
|
||||
|
||||
load_switch_payload(port1_payload, model.output_switch)
|
||||
load_switch_payload(port2_payload, model.input_switch)
|
||||
|
||||
model.runtime.settling_ms = int(run_payload.get("settling_ms", model.runtime.settling_ms))
|
||||
model.runtime.idle_sleep_ms = int(run_payload.get("idle_sleep_ms", model.runtime.idle_sleep_ms))
|
||||
model.runtime.continuous = bool(run_payload.get("continuous", model.runtime.continuous))
|
||||
model.runtime.processing_live_config_path = str(
|
||||
run_payload.get("processing_live_config_path", model.runtime.processing_live_config_path)
|
||||
)
|
||||
|
||||
model.preprocess.calibration_set = str(preprocess_payload.get("calibration_set", model.preprocess.calibration_set))
|
||||
model.preprocess.reference_set = str(preprocess_payload.get("reference_set", model.preprocess.reference_set))
|
||||
model.preprocess.calibration_bundle_path = str(
|
||||
preprocess_payload.get("calibration_bundle_path", model.preprocess.calibration_bundle_path)
|
||||
)
|
||||
model.preprocess.reference_bundle_path = str(
|
||||
preprocess_payload.get("reference_bundle_path", model.preprocess.reference_bundle_path)
|
||||
)
|
||||
|
||||
load_ring_payload(raw_ring_payload, model.rings.raw)
|
||||
load_ring_payload(raw_tap_ring_payload, model.rings.raw_tap)
|
||||
load_ring_payload(pre_ring_payload, model.rings.preprocessed)
|
||||
load_ring_payload(pre_tap_ring_payload, model.rings.preprocessed_tap)
|
||||
load_ring_payload(result_ring_payload, model.rings.results)
|
||||
|
||||
combos_payload = run_payload.get("combos", [])
|
||||
model.combos = []
|
||||
if isinstance(combos_payload, list):
|
||||
for combo in combos_payload:
|
||||
combo_payload = as_dict(combo, "run.combos[]")
|
||||
model.combos.append(
|
||||
ComboModel(
|
||||
input=int(combo_payload.get("input", 0)),
|
||||
output=int(combo_payload.get("output", 0)),
|
||||
)
|
||||
)
|
||||
|
||||
model.ensure_combos()
|
||||
return model
|
||||
|
||||
|
||||
def load_run_config(path: Path) -> RunConfigModel:
|
||||
"""Load JSON config from path and decode into :class:`RunConfigModel`."""
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"Config root must be JSON object: {path}")
|
||||
return run_config_from_dict(payload)
|
||||
|
||||
|
||||
def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"""Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure."""
|
||||
model.ensure_combos()
|
||||
return {
|
||||
"radar": {
|
||||
"model": model.radar.model,
|
||||
"serial": model.radar.serial,
|
||||
"driver_mode": model.radar.driver_mode,
|
||||
"mock_signal_hz": model.radar.mock_signal_hz,
|
||||
"sweep": {
|
||||
"start_hz": model.radar.sweep.start_hz,
|
||||
"stop_hz": model.radar.sweep.stop_hz,
|
||||
"points": model.radar.sweep.points,
|
||||
"if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz,
|
||||
"stimulus_power_dbm": model.radar.sweep.power_dbm,
|
||||
},
|
||||
},
|
||||
"switches": {
|
||||
"port1": {
|
||||
"name": model.output_switch.name,
|
||||
"driver_mode": model.output_switch.driver_mode,
|
||||
"driver": model.output_switch.driver,
|
||||
"radar_port": model.output_switch.radar_port,
|
||||
"positions": model.output_switch.positions,
|
||||
"default_position": model.output_switch.default_position,
|
||||
"gpio_chip": model.output_switch.gpio_chip,
|
||||
"pin_a": model.output_switch.pin_a,
|
||||
"pin_b": model.output_switch.pin_b,
|
||||
"invert_logic": model.output_switch.invert_logic,
|
||||
},
|
||||
"port2": {
|
||||
"name": model.input_switch.name,
|
||||
"driver_mode": model.input_switch.driver_mode,
|
||||
"driver": model.input_switch.driver,
|
||||
"radar_port": model.input_switch.radar_port,
|
||||
"positions": model.input_switch.positions,
|
||||
"default_position": model.input_switch.default_position,
|
||||
"gpio_chip": model.input_switch.gpio_chip,
|
||||
"pin_a": model.input_switch.pin_a,
|
||||
"pin_b": model.input_switch.pin_b,
|
||||
"invert_logic": model.input_switch.invert_logic,
|
||||
},
|
||||
},
|
||||
"run": {
|
||||
"settling_ms": model.runtime.settling_ms,
|
||||
"idle_sleep_ms": model.runtime.idle_sleep_ms,
|
||||
"continuous": model.runtime.continuous,
|
||||
"processing_live_config_path": model.runtime.processing_live_config_path,
|
||||
"combos": [{"input": combo.input, "output": combo.output} for combo in model.combos],
|
||||
},
|
||||
"preprocess": {
|
||||
"calibration_set": model.preprocess.calibration_set,
|
||||
"reference_set": model.preprocess.reference_set,
|
||||
"calibration_bundle_path": model.preprocess.calibration_bundle_path,
|
||||
"reference_bundle_path": model.preprocess.reference_bundle_path,
|
||||
},
|
||||
"rings": {
|
||||
"raw": {
|
||||
"name": model.rings.raw.name,
|
||||
"capacity": model.rings.raw.capacity,
|
||||
"slot_size_bytes": model.rings.raw.slot_size_bytes,
|
||||
},
|
||||
"raw_tap": {
|
||||
"name": model.rings.raw_tap.name,
|
||||
"capacity": model.rings.raw_tap.capacity,
|
||||
"slot_size_bytes": model.rings.raw_tap.slot_size_bytes,
|
||||
},
|
||||
"preprocessed": {
|
||||
"name": model.rings.preprocessed.name,
|
||||
"capacity": model.rings.preprocessed.capacity,
|
||||
"slot_size_bytes": model.rings.preprocessed.slot_size_bytes,
|
||||
},
|
||||
"preprocessed_tap": {
|
||||
"name": model.rings.preprocessed_tap.name,
|
||||
"capacity": model.rings.preprocessed_tap.capacity,
|
||||
"slot_size_bytes": model.rings.preprocessed_tap.slot_size_bytes,
|
||||
},
|
||||
"results": {
|
||||
"name": model.rings.results.name,
|
||||
"capacity": model.rings.results.capacity,
|
||||
"slot_size_bytes": model.rings.results.slot_size_bytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Facade module for run configuration schema, codec, and validation helpers."""
|
||||
|
||||
from python_app.models.run_config_codec import load_run_config, run_config_from_dict, run_config_to_dict
|
||||
from python_app.models.run_config_schema import (
|
||||
ComboModel,
|
||||
PreprocessModel,
|
||||
RadarModel,
|
||||
RadarSweepModel,
|
||||
RingEndpointModel,
|
||||
RingsModel,
|
||||
RunConfigModel,
|
||||
RuntimeModel,
|
||||
SwitchModel,
|
||||
)
|
||||
from python_app.models.run_config_validation import (
|
||||
as_dict,
|
||||
load_ring_payload,
|
||||
load_switch_payload,
|
||||
parse_combos_from_text,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ComboModel",
|
||||
"PreprocessModel",
|
||||
"RadarModel",
|
||||
"RadarSweepModel",
|
||||
"RingEndpointModel",
|
||||
"RingsModel",
|
||||
"RunConfigModel",
|
||||
"RuntimeModel",
|
||||
"SwitchModel",
|
||||
"as_dict",
|
||||
"load_ring_payload",
|
||||
"load_run_config",
|
||||
"load_switch_payload",
|
||||
"parse_combos_from_text",
|
||||
"run_config_from_dict",
|
||||
"run_config_to_dict",
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Dataclass schema for runtime configuration used by Python pipeline tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ComboModel:
|
||||
"""One switch combination used for an acquisition sweep."""
|
||||
|
||||
input: int
|
||||
output: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RadarSweepModel:
|
||||
"""Sweep settings for LibreVNA acquisition."""
|
||||
|
||||
# Keep schema defaults minimal/safe; operational values come from run_config.json.
|
||||
start_hz: float = 0.0
|
||||
stop_hz: float = 0.0
|
||||
points: int = 1
|
||||
if_bandwidth_hz: float = 1.0
|
||||
power_dbm: float = -30.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RadarModel:
|
||||
"""Radar section of run configuration."""
|
||||
|
||||
model: str = ""
|
||||
serial: str = ""
|
||||
driver_mode: str = "mock"
|
||||
mock_signal_hz: float = 1_000_000.0
|
||||
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SwitchModel:
|
||||
"""Generic switch section of run configuration."""
|
||||
|
||||
name: str
|
||||
driver_mode: str = "mock"
|
||||
driver: str = ""
|
||||
radar_port: int = 0
|
||||
positions: int = 1
|
||||
default_position: int = 0
|
||||
gpio_chip: str = ""
|
||||
pin_a: int = -1
|
||||
pin_b: int = -1
|
||||
invert_logic: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RingEndpointModel:
|
||||
"""Shared-memory ring endpoint description."""
|
||||
|
||||
name: str
|
||||
capacity: int = 1
|
||||
slot_size_bytes: int = 4096
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RingsModel:
|
||||
"""Ring endpoints used by orchestration pipeline."""
|
||||
|
||||
raw: RingEndpointModel = field(default_factory=lambda: RingEndpointModel(name=""))
|
||||
raw_tap: RingEndpointModel = field(default_factory=lambda: RingEndpointModel(name=""))
|
||||
preprocessed: RingEndpointModel = field(default_factory=lambda: RingEndpointModel(name=""))
|
||||
preprocessed_tap: RingEndpointModel = field(default_factory=lambda: RingEndpointModel(name=""))
|
||||
results: RingEndpointModel = field(default_factory=lambda: RingEndpointModel(name=""))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeModel:
|
||||
"""Runtime process behavior and paths."""
|
||||
|
||||
settling_ms: int = 0
|
||||
idle_sleep_ms: int = 2
|
||||
continuous: bool = False
|
||||
processing_live_config_path: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PreprocessModel:
|
||||
"""Selected preprocessing artifacts for live acquisition."""
|
||||
|
||||
calibration_set: str = ""
|
||||
reference_set: str = ""
|
||||
calibration_bundle_path: str = ""
|
||||
reference_bundle_path: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunConfigModel:
|
||||
"""Top-level runtime config model consumed by C++ processes and GUI."""
|
||||
|
||||
radar: RadarModel = field(default_factory=RadarModel)
|
||||
input_switch: SwitchModel = field(default_factory=lambda: SwitchModel(name=""))
|
||||
output_switch: SwitchModel = field(default_factory=lambda: SwitchModel(name=""))
|
||||
rings: RingsModel = field(default_factory=RingsModel)
|
||||
runtime: RuntimeModel = field(default_factory=RuntimeModel)
|
||||
preprocess: PreprocessModel = field(default_factory=PreprocessModel)
|
||||
combos: list[ComboModel] = field(default_factory=list)
|
||||
|
||||
@staticmethod
|
||||
def build_full_combos(input_positions: int, output_positions: int) -> list[ComboModel]:
|
||||
"""Build full Cartesian product of input/output switch positions."""
|
||||
return [
|
||||
ComboModel(input=input_pos, output=output_pos)
|
||||
for output_pos in range(output_positions)
|
||||
for input_pos in range(input_positions)
|
||||
]
|
||||
|
||||
def ensure_combos(self) -> None:
|
||||
"""Populate combos with full matrix when no explicit run combos are set."""
|
||||
if self.combos:
|
||||
return
|
||||
self.combos = self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> RunConfigModel:
|
||||
"""Build model from JSON-like payload using codec layer."""
|
||||
from python_app.models.run_config_codec import run_config_from_dict
|
||||
|
||||
return run_config_from_dict(payload)
|
||||
|
||||
@classmethod
|
||||
def load_from_path(cls, path: Path) -> RunConfigModel:
|
||||
"""Load JSON file from disk and decode into model."""
|
||||
from python_app.models.run_config_codec import load_run_config
|
||||
|
||||
return load_run_config(path)
|
||||
|
||||
def clone(self) -> RunConfigModel:
|
||||
"""Create deep copy through codec round-trip."""
|
||||
return RunConfigModel.from_dict(self.to_dict())
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Encode model into JSON-serializable dictionary."""
|
||||
from python_app.models.run_config_codec import run_config_to_dict
|
||||
|
||||
return run_config_to_dict(self)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Validation and normalization helpers for run configuration payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from python_app.models.run_config_schema import ComboModel, RingEndpointModel, SwitchModel
|
||||
|
||||
|
||||
def as_dict(value: Any, context: str) -> dict[str, Any]:
|
||||
"""Validate that a payload node is a JSON object and return it."""
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{context} must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def load_switch_payload(
|
||||
payload: dict[str, Any],
|
||||
target: SwitchModel,
|
||||
) -> None:
|
||||
"""Populate switch model from payload preserving defaults for missing values."""
|
||||
target.name = str(payload.get("name", target.name))
|
||||
target.driver_mode = str(payload.get("driver_mode", target.driver_mode))
|
||||
target.driver = str(payload.get("driver", target.driver))
|
||||
target.radar_port = int(payload.get("radar_port", target.radar_port))
|
||||
target.positions = int(payload.get("positions", target.positions))
|
||||
target.default_position = int(payload.get("default_position", target.default_position))
|
||||
target.gpio_chip = str(payload.get("gpio_chip", target.gpio_chip))
|
||||
target.pin_a = int(payload.get("pin_a", target.pin_a))
|
||||
target.pin_b = int(payload.get("pin_b", target.pin_b))
|
||||
target.invert_logic = bool(payload.get("invert_logic", target.invert_logic))
|
||||
|
||||
|
||||
def load_ring_payload(payload: dict[str, Any], target: RingEndpointModel) -> None:
|
||||
"""Populate ring endpoint model from payload preserving defaults."""
|
||||
target.name = str(payload.get("name", target.name))
|
||||
target.capacity = int(payload.get("capacity", target.capacity))
|
||||
target.slot_size_bytes = int(payload.get("slot_size_bytes", target.slot_size_bytes))
|
||||
|
||||
|
||||
def parse_combos_from_text(text: str) -> list[ComboModel]:
|
||||
"""Parse UI combos string in `input:output,input:output` format."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return []
|
||||
|
||||
combos: list[ComboModel] = []
|
||||
for item in cleaned.split(","):
|
||||
pair = item.strip()
|
||||
if not pair:
|
||||
continue
|
||||
if ":" not in pair:
|
||||
raise ValueError(f"Invalid combo syntax: {pair!r}. Expected input:output")
|
||||
|
||||
input_text, output_text = pair.split(":", 1)
|
||||
combos.append(ComboModel(input=int(input_text.strip()), output=int(output_text.strip())))
|
||||
|
||||
if not combos:
|
||||
raise ValueError("No valid combos were provided")
|
||||
return combos
|
||||
@@ -0,0 +1 @@
|
||||
"""Runtime orchestration utilities for process control and IPC."""
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Helpers for writing runtime configs and preprocessing bundles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel, parse_combos_from_text
|
||||
from python_app.storage.npz_store import NpzStore
|
||||
|
||||
|
||||
class ConfigWriter:
|
||||
"""Write runtime artifacts consumed by C++ processes."""
|
||||
|
||||
def __init__(self, runtime_dir: Path) -> None:
|
||||
"""Create writer rooted at runtime directory."""
|
||||
self._runtime_dir = runtime_dir
|
||||
self._runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def prepare_bundles(
|
||||
self,
|
||||
store: NpzStore,
|
||||
radar_key: str,
|
||||
calibration_set: str,
|
||||
reference_set: str,
|
||||
) -> tuple[Path, Path]:
|
||||
"""Export calibration/reference sets into binary bundles for preprocessor."""
|
||||
calibration_bundle = self._runtime_dir / "calibration_bundle.bin"
|
||||
reference_bundle = self._runtime_dir / "reference_bundle.bin"
|
||||
|
||||
store.export_set_bundle("calibration", radar_key, calibration_set, calibration_bundle)
|
||||
store.export_set_bundle("reference", radar_key, reference_set, reference_bundle)
|
||||
return calibration_bundle, reference_bundle
|
||||
|
||||
def write(self, config: RunConfigModel, output_path: Path) -> Path:
|
||||
"""Write run configuration JSON file."""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(config.to_dict(), indent=2), encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
|
||||
__all__ = ["ConfigWriter", "parse_combos_from_text"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Live processing settings model and atomic JSON writer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
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
|
||||
bscan_axis: str = "abs"
|
||||
bscan_cut_m: float = 0.824
|
||||
bscan_max_depth_m: float = 1.0
|
||||
bscan_gain: float = 1.0
|
||||
bscan_start_freq_mhz: float = 100.0
|
||||
bscan_stop_freq_mhz: float = 8800.0
|
||||
history_command_seq: int = 0
|
||||
history_command: str = "none"
|
||||
|
||||
def to_dict(self) -> dict[str, float | str | int]:
|
||||
"""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),
|
||||
"bscan_axis": str(self.bscan_axis),
|
||||
"bscan_cut_m": float(self.bscan_cut_m),
|
||||
"bscan_max_depth_m": float(self.bscan_max_depth_m),
|
||||
"bscan_gain": float(self.bscan_gain),
|
||||
"bscan_start_freq_mhz": float(self.bscan_start_freq_mhz),
|
||||
"bscan_stop_freq_mhz": float(self.bscan_stop_freq_mhz),
|
||||
"history_command_seq": int(self.history_command_seq),
|
||||
"history_command": str(self.history_command),
|
||||
}
|
||||
|
||||
|
||||
class ProcessingLiveConfigWriter:
|
||||
"""Atomic writer for processing live-config file."""
|
||||
|
||||
def __init__(self, config_path: Path) -> None:
|
||||
"""Create writer targeting `config_path`."""
|
||||
self._config_path = config_path
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Return destination config path."""
|
||||
return self._config_path
|
||||
|
||||
def write(self, config: ProcessingLiveConfig) -> Path:
|
||||
"""Atomically write config by temp-file replace."""
|
||||
temp_path = self._config_path.with_suffix(self._config_path.suffix + ".tmp")
|
||||
temp_path.write_text(json.dumps(config.to_dict(), indent=2), encoding="utf-8")
|
||||
temp_path.replace(self._config_path)
|
||||
return self._config_path
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Process supervisor for lifecycle management of C++ pipeline binaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Iterable
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ManagedProcess:
|
||||
"""Metadata and subprocess handle for one managed child process."""
|
||||
|
||||
name: str
|
||||
command: list[str]
|
||||
handle: subprocess.Popen[str]
|
||||
|
||||
|
||||
class ProcessSupervisor:
|
||||
"""Start, monitor, and stop pipeline subprocesses."""
|
||||
|
||||
def __init__(self, project_root: Path, readiness_timeout_s: float = 3.0) -> None:
|
||||
"""Create supervisor bound to repository root directory."""
|
||||
self._project_root = project_root
|
||||
self._readiness_timeout_s = readiness_timeout_s
|
||||
self._processes: dict[str, ManagedProcess] = {}
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Return whether acquisition-side processes are alive."""
|
||||
return self._is_alive("data_preprocessor") or self._is_alive("sweep_orchestrator")
|
||||
|
||||
def is_processor_running(self) -> bool:
|
||||
"""Return whether data processor process is alive."""
|
||||
return self._is_alive("data_processor")
|
||||
|
||||
def start(self, config_path: Path) -> None:
|
||||
"""Start required pipeline binaries and wait until they are ready."""
|
||||
if self.is_running():
|
||||
raise RuntimeError("Acquisition processes are already running")
|
||||
|
||||
command_specs = {
|
||||
"data_processor": [
|
||||
str(self._project_root / "build/bin/data_processor"),
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
"data_preprocessor": [
|
||||
str(self._project_root / "build/bin/data_preprocessor"),
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
"sweep_orchestrator": [
|
||||
str(self._project_root / "build/bin/sweep_orchestrator"),
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
}
|
||||
processor_was_running = self.is_processor_running()
|
||||
required_processes: list[str] = ["data_preprocessor", "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_preprocessor", command_specs["data_preprocessor"])
|
||||
self._spawn("sweep_orchestrator", command_specs["sweep_orchestrator"])
|
||||
self._wait_until_ready(required_processes)
|
||||
except Exception:
|
||||
if processor_was_running:
|
||||
self.stop()
|
||||
else:
|
||||
self.stop_all()
|
||||
raise
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop acquisition-side processes, keep processor process intact."""
|
||||
self._stop_processes(["sweep_orchestrator", "data_preprocessor"])
|
||||
|
||||
def stop_orchestrator(self) -> None:
|
||||
"""Stop orchestrator process only."""
|
||||
self._stop_processes(["sweep_orchestrator"])
|
||||
|
||||
def stop_preprocessor(self) -> None:
|
||||
"""Stop preprocessor process only."""
|
||||
self._stop_processes(["data_preprocessor"])
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Stop all managed processes."""
|
||||
self._stop_processes(["sweep_orchestrator", "data_preprocessor", "data_processor"])
|
||||
|
||||
def _spawn(self, name: str, command: list[str]) -> 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,
|
||||
)
|
||||
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."""
|
||||
ordered_names = list(names)
|
||||
for name in ordered_names:
|
||||
process = self._processes.get(name)
|
||||
if process is None:
|
||||
continue
|
||||
if process.handle.poll() is None:
|
||||
process.handle.terminate()
|
||||
|
||||
deadline = time.monotonic() + 2.0
|
||||
for name in ordered_names:
|
||||
process = self._processes.get(name)
|
||||
if process is None:
|
||||
continue
|
||||
if process.handle.poll() is not None:
|
||||
continue
|
||||
|
||||
timeout = max(0.0, deadline - time.monotonic())
|
||||
try:
|
||||
process.handle.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.handle.kill()
|
||||
process.handle.wait(timeout=1.0)
|
||||
|
||||
self._drop_exited()
|
||||
|
||||
def _drop_exited(self) -> None:
|
||||
"""Remove exited process entries from internal map."""
|
||||
exited_names = [name for name, process in self._processes.items() if process.handle.poll() is not None]
|
||||
for name in exited_names:
|
||||
self._processes.pop(name, None)
|
||||
|
||||
def _is_alive(self, name: str) -> bool:
|
||||
"""Return `True` when named process handle exists and is running."""
|
||||
process = self._processes.get(name)
|
||||
if process is None:
|
||||
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] = []
|
||||
exited_names: list[str] = []
|
||||
|
||||
for name, process in self._processes.items():
|
||||
return_code = process.handle.poll()
|
||||
if return_code is None:
|
||||
continue
|
||||
|
||||
stderr = ""
|
||||
stdout = ""
|
||||
if process.handle.stdout is not None:
|
||||
stdout = process.handle.stdout.read().strip()
|
||||
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}")
|
||||
exited_names.append(name)
|
||||
|
||||
for name in exited_names:
|
||||
self._processes.pop(name, None)
|
||||
return reports
|
||||
|
||||
def _wait_until_ready(self, required_processes: Sequence[str]) -> None:
|
||||
"""Wait until all required processes are alive or timeout/crash occurs."""
|
||||
deadline = time.monotonic() + self._readiness_timeout_s
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
crashed = self.collect_crash_reports()
|
||||
if crashed:
|
||||
raise RuntimeError("; ".join(crashed))
|
||||
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}")
|
||||
|
||||
def pids(self) -> dict[str, int]:
|
||||
"""Return PID mapping for currently alive managed processes."""
|
||||
return {
|
||||
process.name: process.handle.pid
|
||||
for process in self._processes.values()
|
||||
if process.handle.poll() is None and process.handle.pid is not None
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Shared-memory ring readers and payload decoders."""
|
||||
|
||||
from python_app.orchestration.shm.binary_cursor import ByteCursor
|
||||
from python_app.orchestration.shm.decoder import (
|
||||
PREPROC_MAGIC,
|
||||
RAW_MAGIC,
|
||||
RESULT_MAGIC,
|
||||
decode_result_collection,
|
||||
decode_trace_collection,
|
||||
)
|
||||
from python_app.orchestration.shm.ring_reader import ShmRingReader
|
||||
|
||||
__all__ = [
|
||||
"ByteCursor",
|
||||
"PREPROC_MAGIC",
|
||||
"RAW_MAGIC",
|
||||
"RESULT_MAGIC",
|
||||
"ShmRingReader",
|
||||
"decode_result_collection",
|
||||
"decode_trace_collection",
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Byte-wise cursor utilities for decoding binary ring payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
|
||||
class ByteCursor:
|
||||
"""Read primitive values from bytes while tracking offset."""
|
||||
|
||||
def __init__(self, payload: bytes) -> None:
|
||||
"""Create cursor at start of payload."""
|
||||
self.payload = payload
|
||||
self.offset = 0
|
||||
|
||||
def read_u8(self) -> int:
|
||||
"""Read unsigned 8-bit integer."""
|
||||
value = struct.unpack_from("<B", self.payload, self.offset)[0]
|
||||
self.offset += 1
|
||||
return value
|
||||
|
||||
def read_u16(self) -> int:
|
||||
"""Read unsigned 16-bit integer."""
|
||||
value = struct.unpack_from("<H", self.payload, self.offset)[0]
|
||||
self.offset += 2
|
||||
return value
|
||||
|
||||
def read_u32(self) -> int:
|
||||
"""Read unsigned 32-bit integer."""
|
||||
value = struct.unpack_from("<I", self.payload, self.offset)[0]
|
||||
self.offset += 4
|
||||
return value
|
||||
|
||||
def read_u64(self) -> int:
|
||||
"""Read unsigned 64-bit integer."""
|
||||
value = struct.unpack_from("<Q", self.payload, self.offset)[0]
|
||||
self.offset += 8
|
||||
return value
|
||||
|
||||
def read_f32(self) -> float:
|
||||
"""Read 32-bit float."""
|
||||
value = struct.unpack_from("<f", self.payload, self.offset)[0]
|
||||
self.offset += 4
|
||||
return float(value)
|
||||
|
||||
def read_bytes(self, size: int) -> bytes:
|
||||
"""Read raw byte slice of fixed size."""
|
||||
data = self.payload[self.offset : self.offset + size]
|
||||
self.offset += size
|
||||
return data
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Binary decoders for raw/preprocessed/result payload collections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import (
|
||||
ComboKey,
|
||||
ResultBlock,
|
||||
ResultCollection,
|
||||
ResultPayload,
|
||||
SweepCollection,
|
||||
TraceData,
|
||||
)
|
||||
from python_app.orchestration.shm.binary_cursor import ByteCursor
|
||||
|
||||
RAW_MAGIC = 0x31574152
|
||||
PREPROC_MAGIC = 0x31525050
|
||||
RESULT_MAGIC = 0x314C5352
|
||||
|
||||
|
||||
def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollection:
|
||||
"""Decode one raw/preprocessed collection from binary payload."""
|
||||
cursor = ByteCursor(payload)
|
||||
magic = cursor.read_u32()
|
||||
if magic != expected_magic:
|
||||
raise ValueError("Unexpected trace collection magic")
|
||||
|
||||
collection_id = cursor.read_u64()
|
||||
monotonic_ns = cursor.read_u64()
|
||||
trace_count = cursor.read_u32()
|
||||
|
||||
traces: list[TraceData] = []
|
||||
for _ in range(trace_count):
|
||||
input_pos = cursor.read_u32()
|
||||
output_pos = cursor.read_u32()
|
||||
point_count = cursor.read_u32()
|
||||
|
||||
freq_bytes = point_count * 4
|
||||
freq = np.frombuffer(cursor.read_bytes(freq_bytes), dtype="<f4").astype(np.float32, copy=False)
|
||||
|
||||
interleaved_bytes = point_count * 8
|
||||
interleaved = np.frombuffer(cursor.read_bytes(interleaved_bytes), dtype="<f4")
|
||||
s21 = (interleaved[0::2] + 1j * interleaved[1::2]).astype(np.complex64, copy=False)
|
||||
|
||||
traces.append(
|
||||
TraceData(
|
||||
combo=ComboKey(input_pos=input_pos, output_pos=output_pos),
|
||||
frequency_hz=freq,
|
||||
s21=s21,
|
||||
)
|
||||
)
|
||||
|
||||
return SweepCollection(collection_id=collection_id, monotonic_ns=monotonic_ns, traces=traces)
|
||||
|
||||
|
||||
def decode_result_collection(payload: bytes) -> ResultCollection:
|
||||
"""Decode one processed result collection from binary payload."""
|
||||
cursor = ByteCursor(payload)
|
||||
magic = cursor.read_u32()
|
||||
if magic != RESULT_MAGIC:
|
||||
raise ValueError("Unexpected result collection magic")
|
||||
|
||||
collection_id = cursor.read_u64()
|
||||
monotonic_ns = cursor.read_u64()
|
||||
block_count = cursor.read_u32()
|
||||
|
||||
blocks: list[ResultBlock] = []
|
||||
for _ in range(block_count):
|
||||
input_pos = cursor.read_u32()
|
||||
output_pos = cursor.read_u32()
|
||||
payload_count = cursor.read_u32()
|
||||
|
||||
payloads: list[ResultPayload] = []
|
||||
for _ in range(payload_count):
|
||||
kind = cursor.read_u8()
|
||||
name_size = cursor.read_u16()
|
||||
name = cursor.read_bytes(name_size).decode("utf-8")
|
||||
|
||||
if kind == 1:
|
||||
point_count = cursor.read_u32()
|
||||
freq = np.frombuffer(cursor.read_bytes(point_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
||||
interleaved = np.frombuffer(cursor.read_bytes(point_count * 8), dtype="<f4")
|
||||
trace = (interleaved[0::2] + 1j * interleaved[1::2]).astype(np.complex64, copy=False)
|
||||
payloads.append(
|
||||
ResultPayload(
|
||||
processing_name=name,
|
||||
kind=kind,
|
||||
frequency_hz=freq,
|
||||
trace=trace,
|
||||
)
|
||||
)
|
||||
elif kind == 2:
|
||||
scalar_value = cursor.read_f32()
|
||||
payloads.append(
|
||||
ResultPayload(
|
||||
processing_name=name,
|
||||
kind=kind,
|
||||
frequency_hz=np.array([], dtype=np.float32),
|
||||
trace=np.array([], dtype=np.complex64),
|
||||
scalar_value=scalar_value,
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported result payload kind: {kind}")
|
||||
|
||||
blocks.append(
|
||||
ResultBlock(
|
||||
combo=ComboKey(input_pos=input_pos, output_pos=output_pos),
|
||||
payloads=payloads,
|
||||
)
|
||||
)
|
||||
|
||||
return ResultCollection(collection_id=collection_id, monotonic_ns=monotonic_ns, blocks=blocks)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""POSIX shared-memory ring reader implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mmap
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import time
|
||||
from typing import Final
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
from python_app.orchestration.shm.decoder import (
|
||||
PREPROC_MAGIC,
|
||||
RAW_MAGIC,
|
||||
decode_result_collection,
|
||||
decode_trace_collection,
|
||||
)
|
||||
|
||||
_HEADER_SIZE: Final[int] = 64
|
||||
_SLOT_HEADER_SIZE: Final[int] = 16
|
||||
_MAGIC: Final[bytes] = b"RDRRING2"
|
||||
_VERSION: Final[int] = 1
|
||||
|
||||
|
||||
class ShmRingReader:
|
||||
"""Read binary payloads from a lock-free ring in `/dev/shm`."""
|
||||
|
||||
def __init__(self, ring_name: str, open_timeout_s: float = 2.0, open_poll_s: float = 0.01) -> None:
|
||||
"""Open and validate ring by name, for example `/radar_results`."""
|
||||
if not ring_name.startswith("/"):
|
||||
raise ValueError("ring_name must start with '/'")
|
||||
|
||||
self._ring_name = ring_name
|
||||
self._path = Path("/dev/shm") / ring_name[1:]
|
||||
self._wait_for_ring_file(timeout_s=open_timeout_s, poll_s=open_poll_s)
|
||||
|
||||
self._file = self._path.open("r+b", buffering=0)
|
||||
self._mmap = mmap.mmap(self._file.fileno(), 0)
|
||||
self._validate_header_with_wait(timeout_s=1.0, poll_s=0.002)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close mmap and file handle."""
|
||||
self._mmap.close()
|
||||
self._file.close()
|
||||
|
||||
def pop_payload(self) -> bytes | None:
|
||||
"""Read next payload from ring, or `None` if no unread payload exists."""
|
||||
write_seq = self._read_u64(24)
|
||||
read_seq = self._read_u64(32)
|
||||
if read_seq >= write_seq:
|
||||
return None
|
||||
|
||||
index = read_seq % self.capacity
|
||||
slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self.slot_size_bytes)
|
||||
|
||||
payload_size = self._read_u32(slot_offset)
|
||||
sequence = self._read_u64(slot_offset + 8)
|
||||
if sequence != read_seq + 1:
|
||||
self._write_u64(32, write_seq)
|
||||
return None
|
||||
|
||||
payload_offset = slot_offset + _SLOT_HEADER_SIZE
|
||||
payload = self._mmap[payload_offset : payload_offset + payload_size]
|
||||
self._write_u64(32, read_seq + 1)
|
||||
return bytes(payload)
|
||||
|
||||
def pop_raw_collection(self) -> SweepCollection | None:
|
||||
"""Read next raw collection from ring."""
|
||||
payload = self.pop_payload()
|
||||
if payload is None:
|
||||
return None
|
||||
return decode_trace_collection(payload, RAW_MAGIC)
|
||||
|
||||
def pop_preprocessed_collection(self) -> SweepCollection | None:
|
||||
"""Read next preprocessed collection from ring."""
|
||||
payload = self.pop_payload()
|
||||
if payload is None:
|
||||
return None
|
||||
return decode_trace_collection(payload, PREPROC_MAGIC)
|
||||
|
||||
def pop_result_collection(self) -> ResultCollection | None:
|
||||
"""Read next processed result collection from ring."""
|
||||
payload = self.pop_payload()
|
||||
if payload is None:
|
||||
return None
|
||||
return decode_result_collection(payload)
|
||||
|
||||
def drop_all(self) -> int:
|
||||
"""Mark all unread slots as consumed and return number of dropped payloads."""
|
||||
write_seq = self._read_u64(24)
|
||||
read_seq = self._read_u64(32)
|
||||
if read_seq >= write_seq:
|
||||
return 0
|
||||
dropped = int(write_seq - read_seq)
|
||||
self._write_u64(32, write_seq)
|
||||
return dropped
|
||||
|
||||
@property
|
||||
def capacity(self) -> int:
|
||||
"""Number of slots in ring."""
|
||||
return self._read_u32(12)
|
||||
|
||||
@property
|
||||
def slot_size_bytes(self) -> int:
|
||||
"""Maximum payload size per slot."""
|
||||
return self._read_u32(16)
|
||||
|
||||
def _validate_header_with_wait(self, timeout_s: float, poll_s: float) -> None:
|
||||
"""Wait for ring header to contain expected magic and version."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while True:
|
||||
magic = self._mmap[:8]
|
||||
version = self._read_u32(8)
|
||||
if magic == _MAGIC and version == _VERSION:
|
||||
return
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
if magic != _MAGIC:
|
||||
got_magic = bytes(magic).hex()
|
||||
expected_magic = _MAGIC.hex()
|
||||
raise RuntimeError(
|
||||
f"Shared memory ring magic mismatch for {self._ring_name}: "
|
||||
f"got=0x{got_magic}, expected=0x{expected_magic}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Shared memory ring version mismatch for {self._ring_name}: "
|
||||
f"got={version}, expected={_VERSION}"
|
||||
)
|
||||
|
||||
time.sleep(poll_s)
|
||||
|
||||
def _wait_for_ring_file(self, timeout_s: float, poll_s: float) -> None:
|
||||
"""Wait until ring file appears in `/dev/shm`."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while not self._path.exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise FileNotFoundError(f"Shared memory ring does not exist: {self._ring_name}")
|
||||
time.sleep(poll_s)
|
||||
|
||||
def _read_u32(self, offset: int) -> int:
|
||||
"""Read little-endian u32 at mmap offset."""
|
||||
return struct.unpack_from("<I", self._mmap, offset)[0]
|
||||
|
||||
def _read_u64(self, offset: int) -> int:
|
||||
"""Read little-endian u64 at mmap offset."""
|
||||
return struct.unpack_from("<Q", self._mmap, offset)[0]
|
||||
|
||||
def _write_u64(self, offset: int, value: int) -> None:
|
||||
"""Write little-endian u64 at mmap offset."""
|
||||
struct.pack_into("<Q", self._mmap, offset, value)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Facade exports for shared-memory ring reader and decoders."""
|
||||
|
||||
from python_app.orchestration.shm.binary_cursor import ByteCursor
|
||||
from python_app.orchestration.shm.decoder import (
|
||||
PREPROC_MAGIC,
|
||||
RAW_MAGIC,
|
||||
RESULT_MAGIC,
|
||||
decode_result_collection,
|
||||
decode_trace_collection,
|
||||
)
|
||||
from python_app.orchestration.shm.ring_reader import ShmRingReader
|
||||
|
||||
__all__ = [
|
||||
"ByteCursor",
|
||||
"PREPROC_MAGIC",
|
||||
"RAW_MAGIC",
|
||||
"RESULT_MAGIC",
|
||||
"ShmRingReader",
|
||||
"decode_result_collection",
|
||||
"decode_trace_collection",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"radar": {
|
||||
"model": "librevna",
|
||||
"serial": "",
|
||||
"driver_mode": "mock",
|
||||
"mock_signal_hz": 5000000.0,
|
||||
"sweep": {
|
||||
"start_hz": 1000000.0,
|
||||
"stop_hz": 6000000000.0,
|
||||
"points": 201,
|
||||
"if_bandwidth_hz": 50000.0,
|
||||
"stimulus_power_dbm": -10.0
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
"port1": {
|
||||
"name": "port1",
|
||||
"driver_mode": "mock",
|
||||
"driver": "h7992",
|
||||
"radar_port": 1,
|
||||
"positions": 4,
|
||||
"default_position": 0,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin_a": 17,
|
||||
"pin_b": 27,
|
||||
"invert_logic": false
|
||||
},
|
||||
"port2": {
|
||||
"name": "port2",
|
||||
"driver_mode": "mock",
|
||||
"driver": "hmc349a",
|
||||
"radar_port": 2,
|
||||
"positions": 2,
|
||||
"default_position": 0,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin_a": 22,
|
||||
"pin_b": -1,
|
||||
"invert_logic": false
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"settling_ms": 0,
|
||||
"idle_sleep_ms": 2,
|
||||
"continuous": true,
|
||||
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||
"combos": [
|
||||
{
|
||||
"input": 0,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"preprocess": {
|
||||
"calibration_set": "smoke_cal",
|
||||
"reference_set": "smoke_ref",
|
||||
"calibration_bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/calibration_bundle.bin",
|
||||
"reference_bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/reference_bundle.bin"
|
||||
},
|
||||
"rings": {
|
||||
"raw": {
|
||||
"name": "/radar_raw_smoke_1703912_791574940686872",
|
||||
"capacity": 32,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"raw_tap": {
|
||||
"name": "/radar_raw_tap_smoke_1703912_791574940693657",
|
||||
"capacity": 32,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"preprocessed": {
|
||||
"name": "/radar_preprocessed_smoke_1703912_791574940694423",
|
||||
"capacity": 32,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"preprocessed_tap": {
|
||||
"name": "/radar_preprocessed_tap_smoke_1703912_791574940694970",
|
||||
"capacity": 32,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"results": {
|
||||
"name": "/radar_results_smoke_1703912_791574940696951",
|
||||
"capacity": 32,
|
||||
"slot_size_bytes": 2097152
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility scripts for smoke checks, inspection, and manual diagnostics."""
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Validate and visualize numpy-directory runtime snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
"""Load JSON object from file path."""
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"JSON root must be object: {path}")
|
||||
return payload
|
||||
|
||||
|
||||
def _collection_dirs(stage_dir: Path) -> list[Path]:
|
||||
"""Return sorted list of collection directories for one stage."""
|
||||
if not stage_dir.exists():
|
||||
return []
|
||||
return sorted([path for path in stage_dir.iterdir() if path.is_dir()], key=lambda path: path.name)
|
||||
|
||||
|
||||
def _first_trace_from_raw_or_pre(collection_dir: Path) -> tuple[np.ndarray, np.ndarray, str] | None:
|
||||
"""Return first trace payload from raw/preprocessed collection."""
|
||||
traces = _all_traces_from_raw_or_pre(collection_dir)
|
||||
if not traces:
|
||||
return None
|
||||
return traces[0]
|
||||
|
||||
|
||||
def _all_traces_from_raw_or_pre(collection_dir: Path) -> list[tuple[np.ndarray, np.ndarray, str]]:
|
||||
"""Load all traces from raw/preprocessed collection directory."""
|
||||
meta = _load_json(collection_dir / "meta.json")
|
||||
traces = meta.get("traces", [])
|
||||
if not isinstance(traces, list):
|
||||
raise ValueError(f"Invalid traces in {collection_dir / 'meta.json'}")
|
||||
|
||||
all_traces: list[tuple[np.ndarray, np.ndarray, str]] = []
|
||||
for trace_meta in traces:
|
||||
if not isinstance(trace_meta, dict):
|
||||
raise ValueError(f"Invalid trace record in {collection_dir / 'meta.json'}")
|
||||
|
||||
freq_file = str(trace_meta.get("freq_file", ""))
|
||||
s21_file = str(trace_meta.get("s21_file", ""))
|
||||
freq = np.load(collection_dir / freq_file)
|
||||
s21 = np.load(collection_dir / s21_file)
|
||||
if freq.shape != s21.shape:
|
||||
raise ValueError(f"Shape mismatch freq/s21 in {collection_dir}")
|
||||
if not (np.isfinite(freq).all() and np.isfinite(np.real(s21)).all() and np.isfinite(np.imag(s21)).all()):
|
||||
raise ValueError(f"Non-finite values in {collection_dir}")
|
||||
|
||||
label = f"i{int(trace_meta.get('input', 0))}_o{int(trace_meta.get('output', 0))}"
|
||||
all_traces.append((np.asarray(freq, dtype=np.float64), np.asarray(s21, dtype=np.complex128), label))
|
||||
|
||||
return all_traces
|
||||
|
||||
|
||||
def _first_trace_from_results(collection_dir: Path) -> tuple[np.ndarray, np.ndarray, str] | None:
|
||||
"""Return first trace payload from results collection."""
|
||||
traces = _all_traces_from_results(collection_dir)
|
||||
if not traces:
|
||||
return None
|
||||
return traces[0]
|
||||
|
||||
|
||||
def _all_traces_from_results(collection_dir: Path) -> list[tuple[np.ndarray, np.ndarray, str]]:
|
||||
"""Load all trace-like payloads from results collection directory."""
|
||||
meta = _load_json(collection_dir / "meta.json")
|
||||
blocks = meta.get("blocks", [])
|
||||
if not isinstance(blocks, list):
|
||||
raise ValueError(f"Invalid blocks in {collection_dir / 'meta.json'}")
|
||||
|
||||
all_traces: list[tuple[np.ndarray, np.ndarray, str]] = []
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_dir_name = str(block.get("dir", ""))
|
||||
block_dir = collection_dir / block_dir_name
|
||||
payloads = block.get("payloads", [])
|
||||
if not isinstance(payloads, list):
|
||||
continue
|
||||
for payload in payloads:
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
kind = int(payload.get("kind", 0))
|
||||
if kind != 1:
|
||||
continue
|
||||
freq_file = str(payload.get("freq_file", ""))
|
||||
trace_file = str(payload.get("trace_file", ""))
|
||||
freq = np.load(block_dir / freq_file)
|
||||
trace = np.load(block_dir / trace_file)
|
||||
if freq.shape != trace.shape:
|
||||
raise ValueError(f"Shape mismatch freq/trace in {block_dir}")
|
||||
if not (
|
||||
np.isfinite(freq).all()
|
||||
and np.isfinite(np.real(trace)).all()
|
||||
and np.isfinite(np.imag(trace)).all()
|
||||
):
|
||||
raise ValueError(f"Non-finite values in {block_dir}")
|
||||
|
||||
label = (
|
||||
f"i{int(block.get('input', 0))}_o{int(block.get('output', 0))}_"
|
||||
f"{str(payload.get('name', 'processor'))}"
|
||||
)
|
||||
all_traces.append((np.asarray(freq, dtype=np.float64), np.asarray(trace, dtype=np.complex128), label))
|
||||
|
||||
return all_traces
|
||||
|
||||
|
||||
def _validate_stage(stage_dir: Path, stage: str) -> tuple[list[int], list[tuple[np.ndarray, np.ndarray, str]]]:
|
||||
"""Validate one stage directory and collect representative traces."""
|
||||
collection_ids: list[int] = []
|
||||
traces: list[tuple[np.ndarray, np.ndarray, str]] = []
|
||||
for collection_dir in _collection_dirs(stage_dir):
|
||||
meta = _load_json(collection_dir / "meta.json")
|
||||
collection_ids.append(int(meta.get("collection_id", -1)))
|
||||
if stage in {"raw", "preprocessed"}:
|
||||
trace = _first_trace_from_raw_or_pre(collection_dir)
|
||||
else:
|
||||
trace = _first_trace_from_results(collection_dir)
|
||||
if trace is not None:
|
||||
traces.append(trace)
|
||||
return collection_ids, traces
|
||||
|
||||
|
||||
def _compare_two(
|
||||
name: str,
|
||||
first: tuple[np.ndarray, np.ndarray, str],
|
||||
second: tuple[np.ndarray, np.ndarray, str],
|
||||
) -> None:
|
||||
"""Print numerical difference metrics for two traces."""
|
||||
freq_a, data_a, label_a = first
|
||||
freq_b, data_b, label_b = second
|
||||
same_shape = freq_a.shape == freq_b.shape == data_a.shape == data_b.shape
|
||||
if not same_shape:
|
||||
print(f"[{name}] different shapes: {freq_a.shape}/{freq_b.shape} {data_a.shape}/{data_b.shape}")
|
||||
return
|
||||
|
||||
are_equal = np.array_equal(data_a, data_b)
|
||||
diff = data_a - data_b
|
||||
max_abs_diff = float(np.max(np.abs(diff)))
|
||||
l2_diff = float(np.linalg.norm(diff))
|
||||
print(
|
||||
f"[{name}] compare first two traces: {label_a} vs {label_b}, "
|
||||
f"equal={are_equal}, max_abs_diff={max_abs_diff:.6g}, l2_diff={l2_diff:.6g}"
|
||||
)
|
||||
|
||||
|
||||
def _plot_two(
|
||||
stage: str,
|
||||
first: tuple[np.ndarray, np.ndarray, str],
|
||||
second: tuple[np.ndarray, np.ndarray, str],
|
||||
output_dir: Path,
|
||||
) -> None:
|
||||
"""Plot magnitude comparison for two traces."""
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[{stage}] matplotlib is not available, plot skipped: {exc}")
|
||||
return
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
freq_a, data_a, label_a = first
|
||||
freq_b, data_b, label_b = second
|
||||
if freq_a.shape != freq_b.shape or data_a.shape != data_b.shape:
|
||||
print(f"[{stage}] shapes differ, plot skipped")
|
||||
return
|
||||
|
||||
y_a = 20.0 * np.log10(np.maximum(np.abs(data_a), 1e-12))
|
||||
y_b = 20.0 * np.log10(np.maximum(np.abs(data_b), 1e-12))
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
ax.plot(freq_a, y_a, linewidth=1.4, label=f"collection#1 {label_a}")
|
||||
ax.plot(freq_b, y_b, linewidth=1.4, label=f"collection#2 {label_b}")
|
||||
ax.set_title(f"{stage}: first two collections")
|
||||
ax.set_xlabel("X axis")
|
||||
ax.set_ylabel("Magnitude dB")
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend()
|
||||
fig.tight_layout()
|
||||
|
||||
png_path = output_dir / f"{stage}_first_two.png"
|
||||
fig.savefig(png_path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f"[{stage}] plot saved: {png_path}")
|
||||
|
||||
|
||||
def _plot_all_states_for_one_collection(stage: str, collection_dir: Path, output_dir: Path) -> None:
|
||||
"""Plot all switch-state traces available in one collection."""
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[{stage}] matplotlib is not available, all-states plot skipped: {exc}")
|
||||
return
|
||||
|
||||
if stage in {"raw", "preprocessed"}:
|
||||
traces = _all_traces_from_raw_or_pre(collection_dir)
|
||||
else:
|
||||
traces = _all_traces_from_results(collection_dir)
|
||||
|
||||
if not traces:
|
||||
print(f"[{stage}] no trace payloads found in {collection_dir.name}, all-states plot skipped")
|
||||
return
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
meta = _load_json(collection_dir / "meta.json")
|
||||
collection_id = int(meta.get("collection_id", -1))
|
||||
|
||||
fig, ax = plt.subplots(figsize=(11, 5))
|
||||
for freq, data, label in traces:
|
||||
y = 20.0 * np.log10(np.maximum(np.abs(data), 1e-12))
|
||||
ax.plot(freq, y, linewidth=1.2, label=label)
|
||||
ax.set_title(f"{stage}: all switch states in one collection (id={collection_id})")
|
||||
ax.set_xlabel("X axis")
|
||||
ax.set_ylabel("Magnitude dB")
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(fontsize=8, ncol=2)
|
||||
fig.tight_layout()
|
||||
|
||||
png_path = output_dir / f"{stage}_all_states_one_collection.png"
|
||||
fig.savefig(png_path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f"[{stage}] all-states plot saved: {png_path} (traces={len(traces)})")
|
||||
|
||||
|
||||
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')}"
|
||||
)
|
||||
else:
|
||||
print(f"Snapshot: {snapshot_dir} (manifest.json is missing)")
|
||||
|
||||
stages = ("raw", "preprocessed", "results")
|
||||
for stage in stages:
|
||||
stage_dir = snapshot_dir / stage
|
||||
collection_dirs = _collection_dirs(stage_dir)
|
||||
collection_ids, traces = _validate_stage(stage_dir, stage)
|
||||
duplicate_id_count = len(collection_ids) - len(set(collection_ids))
|
||||
print(
|
||||
f"[{stage}] collections={len(collection_ids)}, "
|
||||
f"duplicate_ids={duplicate_id_count}, "
|
||||
f"trace_samples={len(traces)}"
|
||||
)
|
||||
if len(traces) >= 2:
|
||||
_compare_two(stage, traces[0], traces[1])
|
||||
_plot_two(stage, traces[0], traces[1], output_dir)
|
||||
else:
|
||||
print(f"[{stage}] not enough trace-like collections to compare/plot (need >= 2)")
|
||||
|
||||
if collection_dirs:
|
||||
_plot_all_states_for_one_collection(stage, collection_dirs[-1], output_dir)
|
||||
else:
|
||||
print(f"[{stage}] no collections for all-states plot")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entrypoint."""
|
||||
parser = argparse.ArgumentParser(description="Validate and visualize runtime numpy snapshot collections.")
|
||||
parser.add_argument(
|
||||
"snapshot_dir",
|
||||
type=Path,
|
||||
help="Path to snapshot directory (contains raw/preprocessed/results).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Directory for output plots (default: <snapshot_dir>/inspection_plots).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
snapshot_dir = args.snapshot_dir.expanduser().resolve()
|
||||
if not snapshot_dir.exists():
|
||||
raise FileNotFoundError(f"Snapshot directory not found: {snapshot_dir}")
|
||||
|
||||
output_dir = (
|
||||
args.output_dir.expanduser().resolve()
|
||||
if args.output_dir is not None
|
||||
else snapshot_dir / "inspection_plots"
|
||||
)
|
||||
_run(snapshot_dir, output_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Convert radar_system runtime snapshots into vna_system sweep-history JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceRecord:
|
||||
"""One raw/preprocessed trace extracted from a snapshot collection directory."""
|
||||
|
||||
stage: str
|
||||
collection_id: int
|
||||
monotonic_ns: int
|
||||
stage_index: int
|
||||
frequency_hz: np.ndarray
|
||||
s21: np.ndarray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollectionRef:
|
||||
"""Minimal collection identity descriptor for stage-alignment diagnostics."""
|
||||
|
||||
stage_index: int
|
||||
collection_id: int
|
||||
monotonic_ns: int
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"JSON root must be object: {path}")
|
||||
return payload
|
||||
|
||||
|
||||
def _collection_dirs(stage_dir: Path) -> list[Path]:
|
||||
if not stage_dir.exists():
|
||||
return []
|
||||
return sorted([path for path in stage_dir.iterdir() if path.is_dir()], key=lambda path: path.name)
|
||||
|
||||
|
||||
def _parse_stage_index(name: str, fallback: int) -> int:
|
||||
prefix = name.split("_", 1)[0]
|
||||
return int(prefix) if prefix.isdigit() else fallback
|
||||
|
||||
|
||||
def _pick_trace_meta(meta: dict[str, Any], input_index: int, output_index: int) -> dict[str, Any] | None:
|
||||
traces = meta.get("traces", [])
|
||||
if not isinstance(traces, list):
|
||||
return None
|
||||
for trace in traces:
|
||||
if not isinstance(trace, dict):
|
||||
continue
|
||||
if int(trace.get("input", -1)) == input_index and int(trace.get("output", -1)) == output_index:
|
||||
return trace
|
||||
return None
|
||||
|
||||
|
||||
def _load_stage_records(
|
||||
snapshot_dir: Path,
|
||||
stage: str,
|
||||
*,
|
||||
input_index: int,
|
||||
output_index: int,
|
||||
) -> list[TraceRecord]:
|
||||
stage_dir = snapshot_dir / stage
|
||||
records: list[TraceRecord] = []
|
||||
|
||||
for fallback_idx, collection_dir in enumerate(_collection_dirs(stage_dir)):
|
||||
meta_path = collection_dir / "meta.json"
|
||||
if not meta_path.exists():
|
||||
continue
|
||||
|
||||
meta = _load_json(meta_path)
|
||||
trace_meta = _pick_trace_meta(meta, input_index, output_index)
|
||||
if trace_meta is None:
|
||||
continue
|
||||
|
||||
freq_file = str(trace_meta.get("freq_file", ""))
|
||||
s21_file = str(trace_meta.get("s21_file", ""))
|
||||
if not freq_file or not s21_file:
|
||||
continue
|
||||
|
||||
frequency_hz = np.asarray(np.load(collection_dir / freq_file), dtype=np.float64).reshape(-1)
|
||||
s21 = np.asarray(np.load(collection_dir / s21_file), dtype=np.complex128).reshape(-1)
|
||||
if frequency_hz.shape != s21.shape:
|
||||
raise ValueError(f"Shape mismatch in {collection_dir}: freq{frequency_hz.shape} vs s21{s21.shape}")
|
||||
if frequency_hz.size == 0:
|
||||
continue
|
||||
if not (
|
||||
np.isfinite(frequency_hz).all()
|
||||
and np.isfinite(np.real(s21)).all()
|
||||
and np.isfinite(np.imag(s21)).all()
|
||||
):
|
||||
raise ValueError(f"Non-finite values in {collection_dir}")
|
||||
|
||||
records.append(
|
||||
TraceRecord(
|
||||
stage=stage,
|
||||
collection_id=int(meta.get("collection_id", -1)),
|
||||
monotonic_ns=int(meta.get("monotonic_ns", 0)),
|
||||
stage_index=_parse_stage_index(collection_dir.name, fallback_idx),
|
||||
frequency_hz=frequency_hz,
|
||||
s21=s21,
|
||||
)
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _load_stage_refs(snapshot_dir: Path, stage: str) -> list[CollectionRef]:
|
||||
"""Load `(index, collection_id, monotonic_ns)` for one snapshot stage."""
|
||||
stage_dir = snapshot_dir / stage
|
||||
refs: list[CollectionRef] = []
|
||||
|
||||
for fallback_idx, collection_dir in enumerate(_collection_dirs(stage_dir)):
|
||||
meta_path = collection_dir / "meta.json"
|
||||
if not meta_path.exists():
|
||||
continue
|
||||
meta = _load_json(meta_path)
|
||||
refs.append(
|
||||
CollectionRef(
|
||||
stage_index=_parse_stage_index(collection_dir.name, fallback_idx),
|
||||
collection_id=int(meta.get("collection_id", -1)),
|
||||
monotonic_ns=int(meta.get("monotonic_ns", 0)),
|
||||
)
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
def _index_by_collection_occurrence(records: list[TraceRecord]) -> tuple[dict[tuple[int, int], TraceRecord], list[tuple[int, int]]]:
|
||||
counters: defaultdict[int, int] = defaultdict(int)
|
||||
record_map: dict[tuple[int, int], TraceRecord] = {}
|
||||
order: list[tuple[int, int]] = []
|
||||
for record in records:
|
||||
occurrence = counters[record.collection_id]
|
||||
counters[record.collection_id] += 1
|
||||
key = (record.collection_id, occurrence)
|
||||
record_map[key] = record
|
||||
order.append(key)
|
||||
return record_map, order
|
||||
|
||||
|
||||
def _complex_to_points(values: np.ndarray) -> list[list[float]]:
|
||||
return [[float(v.real), float(v.imag)] for v in values]
|
||||
|
||||
|
||||
def _build_sweep_history(
|
||||
raw_records: list[TraceRecord],
|
||||
preprocessed_records: list[TraceRecord],
|
||||
*,
|
||||
primary_stage: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
raw_map, raw_order = _index_by_collection_occurrence(raw_records)
|
||||
pre_map, pre_order = _index_by_collection_occurrence(preprocessed_records)
|
||||
|
||||
if primary_stage == "preprocessed":
|
||||
primary_order = pre_order or raw_order
|
||||
else:
|
||||
primary_order = raw_order or pre_order
|
||||
|
||||
history: list[dict[str, Any]] = []
|
||||
for fallback_index, key in enumerate(primary_order):
|
||||
raw = raw_map.get(key)
|
||||
pre = pre_map.get(key)
|
||||
base = pre or raw
|
||||
if base is None:
|
||||
continue
|
||||
|
||||
# vna_system uses calibrated_data if present, otherwise sweep_data.
|
||||
sweep_source = raw or pre
|
||||
calibrated_source = pre or raw
|
||||
if sweep_source is None or calibrated_source is None:
|
||||
continue
|
||||
|
||||
start_freq_hz = float(base.frequency_hz[0])
|
||||
stop_freq_hz = float(base.frequency_hz[-1])
|
||||
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0 if base.monotonic_ns > 0 else float(fallback_index)
|
||||
|
||||
history.append(
|
||||
{
|
||||
"timestamp": timestamp_sec,
|
||||
"sweep_points": _complex_to_points(sweep_source.s21),
|
||||
"calibrated_points": _complex_to_points(calibrated_source.s21),
|
||||
"reference_points": [],
|
||||
"vna_config": {
|
||||
"mode": "s11",
|
||||
"start_freq": start_freq_hz,
|
||||
"stop_freq": stop_freq_hz,
|
||||
"points": int(base.frequency_hz.size),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return history
|
||||
|
||||
|
||||
def _stage_alignment_warning(pre_refs: list[CollectionRef], result_refs: list[CollectionRef]) -> str | None:
|
||||
"""Return warning text when preprocessed/results stages are not identity-aligned."""
|
||||
if not pre_refs or not result_refs:
|
||||
return None
|
||||
|
||||
pre_by_index = {ref.stage_index: ref for ref in pre_refs}
|
||||
result_by_index = {ref.stage_index: ref for ref in result_refs}
|
||||
common_indices = sorted(set(pre_by_index) & set(result_by_index))
|
||||
if not common_indices:
|
||||
return None
|
||||
|
||||
mismatches = 0
|
||||
first_mismatch: tuple[int, CollectionRef, CollectionRef] | None = None
|
||||
for index in common_indices:
|
||||
pre = pre_by_index[index]
|
||||
result = result_by_index[index]
|
||||
if pre.collection_id != result.collection_id or pre.monotonic_ns != result.monotonic_ns:
|
||||
mismatches += 1
|
||||
if first_mismatch is None:
|
||||
first_mismatch = (index, pre, result)
|
||||
|
||||
if mismatches == 0:
|
||||
return None
|
||||
|
||||
assert first_mismatch is not None
|
||||
idx, pre, result = first_mismatch
|
||||
return (
|
||||
"WARNING: snapshot stages are not fully aligned (preprocessed vs results). "
|
||||
f"Mismatches={mismatches}/{len(common_indices)}. "
|
||||
f"First mismatch at index={idx}: "
|
||||
f"pre=(id={pre.collection_id},ns={pre.monotonic_ns}) vs "
|
||||
f"results=(id={result.collection_id},ns={result.monotonic_ns}). "
|
||||
"Export uses preprocessed traces; loaded view in vna_system may differ from "
|
||||
"radar_system on-screen replayed results."
|
||||
)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Convert radar_system snapshot (numpy-directory-v1) to a vna_system-compatible "
|
||||
"history JSON file with `sweep_history`."
|
||||
)
|
||||
)
|
||||
parser.add_argument("snapshot_dir", type=Path, help="Path to snapshot directory containing raw/preprocessed/results.")
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Output JSON path (default: <snapshot_dir>/vna_bscan_history.json).",
|
||||
)
|
||||
parser.add_argument("--input", dest="input_index", type=int, default=0, help="Input switch index to export.")
|
||||
parser.add_argument("--output-index", dest="output_index", type=int, default=0, help="Output switch index to export.")
|
||||
parser.add_argument(
|
||||
"--primary-stage",
|
||||
choices=("preprocessed", "raw"),
|
||||
default="preprocessed",
|
||||
help="Stage order to drive collection selection/alignment.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--last-n",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Keep only the last N sweeps in output (0 means all available).",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
snapshot_dir = args.snapshot_dir.expanduser().resolve()
|
||||
if not snapshot_dir.exists():
|
||||
raise FileNotFoundError(f"Snapshot directory not found: {snapshot_dir}")
|
||||
|
||||
output_path = (
|
||||
args.output.expanduser().resolve()
|
||||
if args.output is not None
|
||||
else snapshot_dir / "vna_bscan_history.json"
|
||||
)
|
||||
|
||||
raw_records = _load_stage_records(
|
||||
snapshot_dir,
|
||||
"raw",
|
||||
input_index=args.input_index,
|
||||
output_index=args.output_index,
|
||||
)
|
||||
preprocessed_records = _load_stage_records(
|
||||
snapshot_dir,
|
||||
"preprocessed",
|
||||
input_index=args.input_index,
|
||||
output_index=args.output_index,
|
||||
)
|
||||
if not raw_records and not preprocessed_records:
|
||||
raise ValueError(
|
||||
"No matching raw/preprocessed traces were found in snapshot "
|
||||
f"for input={args.input_index}, output={args.output_index}."
|
||||
)
|
||||
|
||||
sweep_history = _build_sweep_history(raw_records, preprocessed_records, primary_stage=args.primary_stage)
|
||||
if args.last_n > 0:
|
||||
sweep_history = sweep_history[-args.last_n :]
|
||||
if not sweep_history:
|
||||
raise ValueError("Conversion produced empty sweep_history.")
|
||||
|
||||
pre_refs = _load_stage_refs(snapshot_dir, "preprocessed")
|
||||
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",
|
||||
"converted_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"source_snapshot_dir": str(snapshot_dir),
|
||||
"input_index": int(args.input_index),
|
||||
"output_index": int(args.output_index),
|
||||
"primary_stage": args.primary_stage,
|
||||
"raw_record_count": len(raw_records),
|
||||
"preprocessed_record_count": len(preprocessed_records),
|
||||
"sweep_history": sweep_history,
|
||||
}
|
||||
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")
|
||||
|
||||
print(
|
||||
"Converted snapshot to vna_system history JSON:\n"
|
||||
f" input snapshot: {snapshot_dir}\n"
|
||||
f" output file: {output_path}\n"
|
||||
f" sweeps written: {len(sweep_history)}\n"
|
||||
f" raw records: {len(raw_records)}\n"
|
||||
f" pre records: {len(preprocessed_records)}"
|
||||
)
|
||||
if alignment_warning is not None:
|
||||
print(f"[convert-warning] {alignment_warning}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Standalone GUI utility for inspecting raw orchestrator ring output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtWidgets import QApplication, QLabel, QMainWindow, QVBoxLayout, QWidget
|
||||
import pyqtgraph as pg
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.models.run_config_model import RadarSweepModel
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
|
||||
|
||||
def _shm_unlink(name: str) -> None:
|
||||
"""Best-effort unlink for POSIX shared-memory object."""
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if libc_name is None:
|
||||
return
|
||||
|
||||
libc = ctypes.CDLL(libc_name, use_errno=True)
|
||||
libc.shm_unlink.argtypes = [ctypes.c_char_p]
|
||||
libc.shm_unlink.restype = ctypes.c_int
|
||||
|
||||
result = libc.shm_unlink(name.encode("utf-8"))
|
||||
if result == 0:
|
||||
return
|
||||
|
||||
err = ctypes.get_errno()
|
||||
if err != 2: # ENOENT
|
||||
raise OSError(err, f"shm_unlink failed for {name}")
|
||||
|
||||
|
||||
def _read_raw_ring_name(config_path: Path) -> str:
|
||||
"""Extract raw ring name from run config."""
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
ring_name = config["rings"]["raw"]["name"]
|
||||
if not isinstance(ring_name, str) or not ring_name.startswith("/"):
|
||||
raise RuntimeError("Config rings.raw.name must be a POSIX shm name starting with '/'")
|
||||
return ring_name
|
||||
|
||||
|
||||
def _read_native_summary(config_path: Path) -> str:
|
||||
"""Build short summary of radar/switch driver modes."""
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
radar_mode = config["radar"]["driver_mode"]
|
||||
switches = config["switches"]
|
||||
if "port1" in switches and "port2" in switches:
|
||||
port1_mode = switches["port1"]["driver_mode"]
|
||||
port2_mode = switches["port2"]["driver_mode"]
|
||||
else:
|
||||
port1_mode = switches["output"]["driver_mode"]
|
||||
port2_mode = switches["input"]["driver_mode"]
|
||||
return f"radar={radar_mode}, port1={port1_mode}, port2={port2_mode}"
|
||||
|
||||
|
||||
def _prepare_radar_if_needed(config_path: Path, *, strict: bool) -> str | None:
|
||||
"""Preconfigure native radar through Python service when requested."""
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
radar = config["radar"]
|
||||
if radar["driver_mode"] != "native":
|
||||
return "Radar pre-configuration skipped (mock mode)."
|
||||
|
||||
sweep = radar["sweep"]
|
||||
sweep_model = RadarSweepModel(
|
||||
start_hz=float(sweep["start_hz"]),
|
||||
stop_hz=float(sweep["stop_hz"]),
|
||||
points=int(sweep["points"]),
|
||||
if_bandwidth_hz=float(sweep["if_bandwidth_hz"]),
|
||||
power_dbm=float(sweep.get("stimulus_power_dbm", -10.0)),
|
||||
)
|
||||
|
||||
radar_service = LibreVnaService(serial=radar.get("serial") or None)
|
||||
if not radar_service.driver_available:
|
||||
message = "LibreVNA Python driver is unavailable: skipping pre-configuration"
|
||||
if strict:
|
||||
raise RuntimeError(message)
|
||||
return message
|
||||
|
||||
try:
|
||||
radar_service.open()
|
||||
radar_service.configure(sweep_model)
|
||||
return "Radar pre-configuration completed."
|
||||
except Exception as exc:
|
||||
message = f"Radar pre-configuration failed ({exc})"
|
||||
if strict:
|
||||
raise
|
||||
return f"{message}. Continuing with native C++ configuration."
|
||||
finally:
|
||||
radar_service.close()
|
||||
|
||||
|
||||
class RawOrchestratorViewer(QMainWindow):
|
||||
"""Qt window that runs sweep_orchestrator and plots raw collections."""
|
||||
|
||||
def __init__(self, config_path: Path, reset_ring: bool, prepare_radar: bool, strict_prepare: bool) -> None:
|
||||
"""Initialize viewer, optionally prepare radar, and start polling."""
|
||||
super().__init__()
|
||||
|
||||
self._config_path = config_path
|
||||
self._raw_ring_name = _read_raw_ring_name(config_path)
|
||||
self._orchestrator_process: subprocess.Popen[str] | None = None
|
||||
self._raw_reader: ShmRingReader | None = None
|
||||
|
||||
if reset_ring:
|
||||
_shm_unlink(self._raw_ring_name)
|
||||
|
||||
self._build_ui()
|
||||
if prepare_radar:
|
||||
self._status.setText("Preparing radar...")
|
||||
prepare_status = _prepare_radar_if_needed(self._config_path, strict=strict_prepare)
|
||||
if prepare_status is not None:
|
||||
self._status.setText(prepare_status)
|
||||
self._start_orchestrator()
|
||||
self._open_reader_or_fail()
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(60)
|
||||
self._timer.timeout.connect(self._poll)
|
||||
self._timer.start()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build viewer widgets and raw trace plot."""
|
||||
self.setWindowTitle("Raw Sweep Viewer (orchestrator)")
|
||||
root = QWidget(self)
|
||||
self.setCentralWidget(root)
|
||||
|
||||
layout = QVBoxLayout(root)
|
||||
|
||||
mode_summary = _read_native_summary(self._config_path)
|
||||
self._status = QLabel(f"Starting... ({mode_summary})")
|
||||
layout.addWidget(self._status)
|
||||
|
||||
self._plot = pg.PlotWidget(background="#101418")
|
||||
self._plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
self._plot.setLabel("left", "Magnitude", units="dB")
|
||||
layout.addWidget(self._plot)
|
||||
|
||||
self.resize(1400, 900)
|
||||
|
||||
def _start_orchestrator(self) -> None:
|
||||
"""Start sweep orchestrator subprocess."""
|
||||
binary = PROJECT_ROOT / "build/bin/sweep_orchestrator"
|
||||
if not binary.exists():
|
||||
raise RuntimeError(f"Missing binary: {binary}. Build first with 'make -j4'")
|
||||
|
||||
command = [str(binary), "--config", str(self._config_path)]
|
||||
self._orchestrator_process = subprocess.Popen(
|
||||
command,
|
||||
cwd=PROJECT_ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def _open_reader_or_fail(self) -> None:
|
||||
"""Wait for raw ring readiness and open `ShmRingReader`."""
|
||||
if self._orchestrator_process is None:
|
||||
raise RuntimeError("Orchestrator process is not started")
|
||||
|
||||
shm_path = Path("/dev/shm") / self._raw_ring_name[1:]
|
||||
deadline = time.monotonic() + 5.0
|
||||
last_reader_error: str | None = None
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
return_code = self._orchestrator_process.poll()
|
||||
if return_code is not None:
|
||||
details = self._read_process_output(self._orchestrator_process)
|
||||
raise RuntimeError(f"sweep_orchestrator exited with code {return_code}: {details}")
|
||||
|
||||
if shm_path.exists():
|
||||
try:
|
||||
self._raw_reader = ShmRingReader(self._raw_ring_name)
|
||||
self._status.setText(f"Running: ring={self._raw_ring_name}")
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
last_reader_error = str(exc)
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
if last_reader_error is not None:
|
||||
raise RuntimeError(
|
||||
f"Timed out waiting for raw ring header readiness: {self._raw_ring_name}; "
|
||||
f"last error: {last_reader_error}"
|
||||
)
|
||||
raise RuntimeError(f"Timed out waiting for raw ring file: {shm_path}")
|
||||
|
||||
def _poll(self) -> None:
|
||||
"""Poll subprocess state and draw latest available raw collection."""
|
||||
if self._orchestrator_process is None:
|
||||
return
|
||||
|
||||
return_code = self._orchestrator_process.poll()
|
||||
if return_code is not None:
|
||||
details = self._read_process_output(self._orchestrator_process)
|
||||
self._status.setText(f"Error: sweep_orchestrator exited ({return_code})")
|
||||
raise RuntimeError(f"sweep_orchestrator exited with code {return_code}: {details}")
|
||||
|
||||
if self._raw_reader is None:
|
||||
return
|
||||
|
||||
latest = None
|
||||
for _ in range(16):
|
||||
collection = self._raw_reader.pop_raw_collection()
|
||||
if collection is None:
|
||||
break
|
||||
latest = collection
|
||||
|
||||
if latest is not None:
|
||||
self._draw_collection(latest)
|
||||
|
||||
def _draw_collection(self, collection) -> None:
|
||||
"""Render all traces from one raw collection."""
|
||||
self._plot.clear()
|
||||
palette = [
|
||||
"#4cc9f0",
|
||||
"#f72585",
|
||||
"#b8f2e6",
|
||||
"#ffd166",
|
||||
"#90be6d",
|
||||
"#ff595e",
|
||||
"#6a4c93",
|
||||
"#1982c4",
|
||||
"#ff9f1c",
|
||||
"#2ec4b6",
|
||||
"#e71d36",
|
||||
"#a0c4ff",
|
||||
]
|
||||
|
||||
for idx, trace in enumerate(collection.traces):
|
||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12))
|
||||
label = f"input={trace.combo.input_pos}, output={trace.combo.output_pos}"
|
||||
self._plot.plot(
|
||||
trace.frequency_hz,
|
||||
magnitude_db,
|
||||
pen=pg.mkPen(palette[idx % len(palette)], width=1.6),
|
||||
name=label,
|
||||
)
|
||||
|
||||
self._status.setText(
|
||||
f"Running: collection_id={collection.collection_id}, "
|
||||
f"traces={len(collection.traces)}, ring={self._raw_ring_name}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _read_process_output(process: subprocess.Popen[str]) -> str:
|
||||
"""Collect process stdout/stderr text for diagnostics."""
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
if process.stdout is not None:
|
||||
stdout = process.stdout.read().strip()
|
||||
if process.stderr is not None:
|
||||
stderr = process.stderr.read().strip()
|
||||
|
||||
if stderr and stdout:
|
||||
return f"{stderr}\nstdout:\n{stdout}"
|
||||
if stderr:
|
||||
return stderr
|
||||
if stdout:
|
||||
return f"stdout:\n{stdout}"
|
||||
return "no output"
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
"""Stop subprocess and close reader before window destruction."""
|
||||
self._shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _shutdown(self) -> None:
|
||||
"""Close reader and terminate subprocess."""
|
||||
if self._raw_reader is not None:
|
||||
self._raw_reader.close()
|
||||
self._raw_reader = None
|
||||
|
||||
process = self._orchestrator_process
|
||||
self._orchestrator_process = None
|
||||
if process is None:
|
||||
return
|
||||
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=1.0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entrypoint for raw orchestrator viewer."""
|
||||
parser = argparse.ArgumentParser(description="Run sweep_orchestrator and plot raw sweep collections")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=PROJECT_ROOT / "run_config.json",
|
||||
help="Path to run config JSON",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-reset-ring",
|
||||
action="store_true",
|
||||
help="Do not unlink existing raw ring name before starting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-radar-prepare",
|
||||
action="store_true",
|
||||
help="Skip Python pre-configuration of native radar before orchestrator start",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict-radar-prepare",
|
||||
action="store_true",
|
||||
help="Fail immediately if Python pre-configuration cannot run",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config_path = args.config.resolve()
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||
|
||||
if os.geteuid() == 0 and os.environ.get("SUDO_USER"):
|
||||
print(
|
||||
"Warning: running GUI test via sudo can break Qt DBus/session integration. "
|
||||
"Prefer regular user with USB/GPIO permissions."
|
||||
)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
viewer = RawOrchestratorViewer(
|
||||
config_path=config_path,
|
||||
reset_ring=not args.no_reset_ring,
|
||||
prepare_radar=not args.skip_radar_prepare,
|
||||
strict_prepare=args.strict_radar_prepare,
|
||||
)
|
||||
viewer.show()
|
||||
|
||||
def _sig_handler(_signum, _frame):
|
||||
"""Close viewer gracefully on process signals."""
|
||||
viewer.close()
|
||||
|
||||
signal.signal(signal.SIGINT, _sig_handler)
|
||||
signal.signal(signal.SIGTERM, _sig_handler)
|
||||
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Manual smoke scenario for end-to-end pipeline check without GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.config_writer import ConfigWriter
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
from python_app.storage.npz_store import NpzStore, radar_key_from_config
|
||||
|
||||
|
||||
def _make_unique_ring_name(prefix: str) -> str:
|
||||
"""Build unique POSIX SHM ring name."""
|
||||
stamp = time.monotonic_ns()
|
||||
return f"/{prefix}_{os.getpid()}_{stamp}"
|
||||
|
||||
|
||||
def _shm_unlink(name: str) -> None:
|
||||
"""Best-effort unlink for POSIX shared-memory object."""
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if libc_name is None:
|
||||
return
|
||||
|
||||
libc = ctypes.CDLL(libc_name, use_errno=True)
|
||||
libc.shm_unlink.argtypes = [ctypes.c_char_p]
|
||||
libc.shm_unlink.restype = ctypes.c_int
|
||||
|
||||
result = libc.shm_unlink(name.encode("utf-8"))
|
||||
if result == 0:
|
||||
return
|
||||
|
||||
err = ctypes.get_errno()
|
||||
if err != 2: # ENOENT
|
||||
raise OSError(err, f"shm_unlink failed for {name}")
|
||||
|
||||
|
||||
def build_synthetic_collection(config: RunConfigModel, value_scale: float) -> SweepCollection:
|
||||
"""Build synthetic sweep collection for all configured switch combos."""
|
||||
traces: list[TraceData] = []
|
||||
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||
|
||||
for combo in combos:
|
||||
frequency_hz = np.linspace(
|
||||
config.radar.sweep.start_hz,
|
||||
config.radar.sweep.stop_hz,
|
||||
config.radar.sweep.points,
|
||||
dtype=np.float32,
|
||||
)
|
||||
phase = np.linspace(0.0, np.pi * 2.0, config.radar.sweep.points, dtype=np.float32)
|
||||
s21 = value_scale * (np.cos(phase) + 1j * np.sin(phase)).astype(np.complex64)
|
||||
|
||||
traces.append(
|
||||
TraceData(
|
||||
combo=ComboKey(input_pos=combo.input, output_pos=combo.output),
|
||||
frequency_hz=frequency_hz,
|
||||
s21=s21,
|
||||
)
|
||||
)
|
||||
|
||||
return SweepCollection(collection_id=1, monotonic_ns=time.monotonic_ns(), traces=traces)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run manual smoke-test pipeline scenario."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--duration", type=float, default=3.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = PROJECT_ROOT
|
||||
store = NpzStore(project_root / "python_app/data")
|
||||
config_writer = ConfigWriter(project_root / "python_app/runtime")
|
||||
supervisor = ProcessSupervisor(project_root)
|
||||
|
||||
config = RunConfigModel.load_from_path(project_root / "run_config.json")
|
||||
config.radar.driver_mode = "mock"
|
||||
config.input_switch.driver_mode = "mock"
|
||||
config.output_switch.driver_mode = "mock"
|
||||
config.combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||
config.rings.raw.name = _make_unique_ring_name("radar_raw_smoke")
|
||||
config.rings.raw_tap.name = _make_unique_ring_name("radar_raw_tap_smoke")
|
||||
config.rings.preprocessed.name = _make_unique_ring_name("radar_preprocessed_smoke")
|
||||
config.rings.preprocessed_tap.name = _make_unique_ring_name("radar_preprocessed_tap_smoke")
|
||||
config.rings.results.name = _make_unique_ring_name("radar_results_smoke")
|
||||
|
||||
radar_key = radar_key_from_config(
|
||||
model_name=config.radar.model,
|
||||
serial=config.radar.serial,
|
||||
sweep_start_hz=config.radar.sweep.start_hz,
|
||||
sweep_stop_hz=config.radar.sweep.stop_hz,
|
||||
sweep_points=config.radar.sweep.points,
|
||||
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=config.radar.sweep.power_dbm,
|
||||
)
|
||||
|
||||
calibration_set = build_synthetic_collection(config, value_scale=1.0)
|
||||
reference_set = build_synthetic_collection(config, value_scale=0.3)
|
||||
|
||||
store.save_set("calibration", radar_key, "smoke_cal", calibration_set)
|
||||
store.save_set("reference", radar_key, "smoke_ref", reference_set)
|
||||
|
||||
calibration_bundle, reference_bundle = config_writer.prepare_bundles(store, radar_key, "smoke_cal", "smoke_ref")
|
||||
config.preprocess.calibration_set = "smoke_cal"
|
||||
config.preprocess.reference_set = "smoke_ref"
|
||||
config.preprocess.calibration_bundle_path = str(calibration_bundle)
|
||||
config.preprocess.reference_bundle_path = str(reference_bundle)
|
||||
|
||||
config_path = config_writer.write(config, project_root / "python_app/runtime/run_config_smoke.json")
|
||||
|
||||
result_reader: ShmRingReader | None = None
|
||||
try:
|
||||
supervisor.start(config_path)
|
||||
result_reader = ShmRingReader(config.rings.results.name)
|
||||
deadline = time.monotonic() + args.duration
|
||||
received = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
result = result_reader.pop_result_collection() if result_reader is not None else None
|
||||
if result is not None:
|
||||
received += 1
|
||||
time.sleep(0.02)
|
||||
|
||||
print(f"Received result collections: {received}")
|
||||
finally:
|
||||
supervisor.stop_all()
|
||||
if result_reader is not None:
|
||||
result_reader.close()
|
||||
_shm_unlink(config.rings.raw.name)
|
||||
_shm_unlink(config.rings.raw_tap.name)
|
||||
_shm_unlink(config.rings.preprocessed.name)
|
||||
_shm_unlink(config.rings.preprocessed_tap.name)
|
||||
_shm_unlink(config.rings.results.name)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Minimal GUI tool for direct LibreVNA raw acquisition checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from python_app.scripts.hardware_raw_orchestrator_test import RawOrchestratorViewer
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run standalone raw-viewer GUI against a selected run config."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Intermediate test: native VNA acquisition with mock switch drivers"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=PROJECT_ROOT / "run_config.json",
|
||||
help="Path to run config JSON",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-reset-ring",
|
||||
action="store_true",
|
||||
help="Do not unlink existing raw ring name before starting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-radar-prepare",
|
||||
action="store_true",
|
||||
help="Skip Python pre-configuration of native radar before orchestrator start",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict-radar-prepare",
|
||||
action="store_true",
|
||||
help="Fail immediately if Python pre-configuration cannot run",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config_path = args.config.resolve()
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
viewer = RawOrchestratorViewer(
|
||||
config_path=config_path,
|
||||
reset_ring=not args.no_reset_ring,
|
||||
prepare_radar=not args.skip_radar_prepare,
|
||||
strict_prepare=args.strict_radar_prepare,
|
||||
)
|
||||
viewer.setWindowTitle("Raw Sweep Viewer (VNA native + mock switches)")
|
||||
viewer.show()
|
||||
|
||||
def _sig_handler(_signum, _frame):
|
||||
"""Close viewer gracefully on process signals."""
|
||||
viewer.close()
|
||||
|
||||
signal.signal(signal.SIGINT, _sig_handler)
|
||||
signal.signal(signal.SIGTERM, _sig_handler)
|
||||
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Persistent storage interfaces and implementations."""
|
||||
@@ -0,0 +1,13 @@
|
||||
"""NPZ-based dataset storage implementation and serialization helpers."""
|
||||
|
||||
from python_app.storage.npz.paths import radar_key_from_config
|
||||
from python_app.storage.npz.serialize import PREPROC_MAGIC, RAW_MAGIC, RESULT_MAGIC
|
||||
from python_app.storage.npz.store import NpzStore
|
||||
|
||||
__all__ = [
|
||||
"NpzStore",
|
||||
"PREPROC_MAGIC",
|
||||
"RAW_MAGIC",
|
||||
"RESULT_MAGIC",
|
||||
"radar_key_from_config",
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Path and naming helpers for NPZ snapshot storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def radar_key_from_config(
|
||||
model_name: str,
|
||||
serial: str,
|
||||
sweep_start_hz: float,
|
||||
sweep_stop_hz: float,
|
||||
sweep_points: int,
|
||||
ifbw_hz: float,
|
||||
power_dbm: float,
|
||||
) -> str:
|
||||
"""Build deterministic key for calibration/reference set lookup."""
|
||||
serial_part = serial or "no_serial"
|
||||
start_token = _format_float_for_key(sweep_start_hz)
|
||||
stop_token = _format_float_for_key(sweep_stop_hz)
|
||||
ifbw_token = _format_float_for_key(ifbw_hz)
|
||||
power_token = _format_float_for_key(power_dbm)
|
||||
return (
|
||||
f"{model_name}_{serial_part}"
|
||||
f"_st{start_token}_sp{stop_token}"
|
||||
f"_p{sweep_points}_if{ifbw_token}_pw{power_token}"
|
||||
)
|
||||
|
||||
|
||||
def collection_dir_name(index: int, collection_id: int, monotonic_ns: int) -> str:
|
||||
"""Build canonical collection directory name for snapshot stages."""
|
||||
return f"{index:04d}_id{int(collection_id)}_ns{int(monotonic_ns)}"
|
||||
|
||||
|
||||
def sanitize_path_component(name: str) -> str:
|
||||
"""Convert arbitrary string into filesystem-safe component."""
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name.strip())
|
||||
cleaned = cleaned.strip("._")
|
||||
return cleaned or "snapshot"
|
||||
|
||||
|
||||
def format_float_for_key(value: float) -> str:
|
||||
"""Format float compactly for deterministic key serialization."""
|
||||
integer = int(round(value))
|
||||
if abs(value - float(integer)) < 1e-6:
|
||||
return str(integer)
|
||||
return f"{value:.6f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _format_float_for_key(value: float) -> str:
|
||||
"""Private alias preserved for internal compatibility."""
|
||||
return format_float_for_key(value)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Binary payload serialization for sweep/result collections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
|
||||
RAW_MAGIC = 0x31574152
|
||||
PREPROC_MAGIC = 0x31525050
|
||||
RESULT_MAGIC = 0x314C5352
|
||||
|
||||
|
||||
def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes:
|
||||
"""Serialize one raw/preprocessed trace collection into ring-compatible binary format."""
|
||||
buffer = bytearray()
|
||||
buffer.extend(
|
||||
struct.pack("<IQQI", magic, collection.collection_id, collection.monotonic_ns, len(collection.traces))
|
||||
)
|
||||
|
||||
for trace in collection.traces:
|
||||
freq = np.asarray(trace.frequency_hz, dtype=np.float32)
|
||||
s21 = np.asarray(trace.s21, dtype=np.complex64)
|
||||
if freq.size != s21.size:
|
||||
raise ValueError("Trace frequency and S21 sizes must match")
|
||||
|
||||
buffer.extend(struct.pack("<III", trace.combo.input_pos, trace.combo.output_pos, int(freq.size)))
|
||||
buffer.extend(freq.astype("<f4", copy=False).tobytes())
|
||||
|
||||
interleaved = np.empty(freq.size * 2, dtype="<f4")
|
||||
interleaved[0::2] = s21.real.astype("<f4", copy=False)
|
||||
interleaved[1::2] = s21.imag.astype("<f4", copy=False)
|
||||
buffer.extend(interleaved.tobytes())
|
||||
|
||||
return bytes(buffer)
|
||||
|
||||
|
||||
def serialize_result_collection(collection: ResultCollection) -> bytes:
|
||||
"""Serialize one processed collection with result blocks/payloads."""
|
||||
buffer = bytearray()
|
||||
buffer.extend(
|
||||
struct.pack("<IQQI", RESULT_MAGIC, collection.collection_id, collection.monotonic_ns, len(collection.blocks))
|
||||
)
|
||||
|
||||
for block in collection.blocks:
|
||||
buffer.extend(struct.pack("<II", block.combo.input_pos, block.combo.output_pos))
|
||||
buffer.extend(struct.pack("<I", len(block.payloads)))
|
||||
|
||||
for payload in block.payloads:
|
||||
name_bytes = payload.processing_name.encode("utf-8")
|
||||
if len(name_bytes) > 0xFFFF:
|
||||
raise ValueError("processing_name is too long")
|
||||
|
||||
buffer.extend(struct.pack("<BH", payload.kind, len(name_bytes)))
|
||||
buffer.extend(name_bytes)
|
||||
|
||||
if payload.kind == 1:
|
||||
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||
trace = np.asarray(payload.trace, dtype=np.complex64)
|
||||
if freq.size != trace.size:
|
||||
raise ValueError("Result trace frequency and values sizes must match")
|
||||
|
||||
buffer.extend(struct.pack("<I", int(freq.size)))
|
||||
buffer.extend(freq.astype("<f4", copy=False).tobytes())
|
||||
|
||||
interleaved = np.empty(freq.size * 2, dtype="<f4")
|
||||
interleaved[0::2] = trace.real.astype("<f4", copy=False)
|
||||
interleaved[1::2] = trace.imag.astype("<f4", copy=False)
|
||||
buffer.extend(interleaved.tobytes())
|
||||
elif payload.kind == 2:
|
||||
buffer.extend(struct.pack("<f", float(payload.scalar_value)))
|
||||
else:
|
||||
raise ValueError(f"Unsupported payload kind: {payload.kind}")
|
||||
|
||||
return bytes(buffer)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Snapshot selection and filesystem writers for runtime collection histories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
from python_app.storage.npz.paths import collection_dir_name, sanitize_path_component
|
||||
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection
|
||||
|
||||
TCollection = TypeVar("TCollection")
|
||||
|
||||
|
||||
def select_aligned_histories(
|
||||
raw_history: list[SweepCollection],
|
||||
preprocessed_history: list[SweepCollection],
|
||||
result_history: list[ResultCollection],
|
||||
last_n: int,
|
||||
) -> tuple[list[SweepCollection], list[SweepCollection], list[ResultCollection], dict[str, Any]]:
|
||||
"""Select history tails prioritizing currently displayed processed results."""
|
||||
raw_index, _ = _index_by_collection_sequence(raw_history)
|
||||
pre_index, _ = _index_by_collection_sequence(preprocessed_history)
|
||||
result_index, result_pos = _index_by_collection_sequence(result_history)
|
||||
|
||||
if result_index:
|
||||
ordered_result_keys = sorted(result_index, key=lambda key: result_pos[key])
|
||||
selected_keys = ordered_result_keys[-last_n:]
|
||||
return (
|
||||
[raw_index[key] for key in selected_keys if key in raw_index],
|
||||
[pre_index[key] for key in selected_keys if key in pre_index],
|
||||
[result_index[key] for key in selected_keys],
|
||||
{
|
||||
"selection_mode": "result_tail_with_optional_alignment",
|
||||
"selected_collection_ids": [int(key[0]) for key in selected_keys],
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
raw_history[-last_n:],
|
||||
preprocessed_history[-last_n:],
|
||||
result_history[-last_n:],
|
||||
{
|
||||
"selection_mode": "independent_tail",
|
||||
"selected_collection_ids": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def save_trace_history_binary(stage_dir: Path, history: list[SweepCollection], magic: int) -> None:
|
||||
"""Write binary trace history with lightweight metadata sidecars."""
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
for index, collection in enumerate(history):
|
||||
binary_path = stage_dir / f"{index:04d}.bin"
|
||||
metadata_path = stage_dir / f"{index:04d}.json"
|
||||
binary_path.write_bytes(serialize_trace_collection(collection, magic))
|
||||
metadata_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collection_id": collection.collection_id,
|
||||
"monotonic_ns": collection.monotonic_ns,
|
||||
"trace_count": len(collection.traces),
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_result_history_binary(stage_dir: Path, history: list[ResultCollection]) -> None:
|
||||
"""Write binary processed-result history with metadata sidecars."""
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
for index, collection in enumerate(history):
|
||||
binary_path = stage_dir / f"{index:04d}.bin"
|
||||
metadata_path = stage_dir / f"{index:04d}.json"
|
||||
binary_path.write_bytes(serialize_result_collection(collection))
|
||||
metadata_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collection_id": collection.collection_id,
|
||||
"monotonic_ns": collection.monotonic_ns,
|
||||
"block_count": len(collection.blocks),
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_trace_history_numpy(stage_dir: Path, history: list[SweepCollection]) -> None:
|
||||
"""Write raw/preprocessed collections as NumPy directory tree."""
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
for index, collection in enumerate(history):
|
||||
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
|
||||
collection_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
traces_meta: list[dict[str, int | str]] = []
|
||||
for trace in collection.traces:
|
||||
tag = f"i{trace.combo.input_pos}_o{trace.combo.output_pos}"
|
||||
freq = np.asarray(trace.frequency_hz, dtype=np.float32)
|
||||
s21 = np.asarray(trace.s21, dtype=np.complex64)
|
||||
np.save(collection_dir / f"{tag}_freq.npy", freq)
|
||||
np.save(collection_dir / f"{tag}_s21.npy", s21)
|
||||
traces_meta.append(
|
||||
{
|
||||
"input": int(trace.combo.input_pos),
|
||||
"output": int(trace.combo.output_pos),
|
||||
"points": int(freq.size),
|
||||
"freq_file": f"{tag}_freq.npy",
|
||||
"s21_file": f"{tag}_s21.npy",
|
||||
}
|
||||
)
|
||||
|
||||
(collection_dir / "meta.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collection_id": int(collection.collection_id),
|
||||
"monotonic_ns": int(collection.monotonic_ns),
|
||||
"trace_count": len(collection.traces),
|
||||
"traces": traces_meta,
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection]) -> None:
|
||||
"""Write processed result collections as NumPy directory tree."""
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
for index, collection in enumerate(history):
|
||||
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
|
||||
collection_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
blocks_meta: list[dict[str, int | str | list[dict[str, int | str | float]]]] = []
|
||||
for block_index, block in enumerate(collection.blocks):
|
||||
block_dir = collection_dir / f"block_{block_index:03d}_i{block.combo.input_pos}_o{block.combo.output_pos}"
|
||||
block_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
payload_meta: list[dict[str, int | str | float]] = []
|
||||
for payload_index, payload in enumerate(block.payloads):
|
||||
safe_name = sanitize_path_component(payload.processing_name or "processor")
|
||||
base_name = f"{payload_index:03d}_{safe_name}_kind{payload.kind}"
|
||||
if payload.kind == 1:
|
||||
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||
trace = np.asarray(payload.trace, dtype=np.complex64)
|
||||
np.save(block_dir / f"{base_name}_freq.npy", freq)
|
||||
np.save(block_dir / f"{base_name}_trace.npy", trace)
|
||||
payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"points": int(freq.size),
|
||||
"freq_file": f"{base_name}_freq.npy",
|
||||
"trace_file": f"{base_name}_trace.npy",
|
||||
}
|
||||
)
|
||||
elif payload.kind == 2:
|
||||
scalar = np.asarray([float(payload.scalar_value)], dtype=np.float32)
|
||||
np.save(block_dir / f"{base_name}_scalar.npy", scalar)
|
||||
payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"scalar_file": f"{base_name}_scalar.npy",
|
||||
"scalar_value": float(payload.scalar_value),
|
||||
}
|
||||
)
|
||||
|
||||
blocks_meta.append(
|
||||
{
|
||||
"input": int(block.combo.input_pos),
|
||||
"output": int(block.combo.output_pos),
|
||||
"payload_count": len(block.payloads),
|
||||
"dir": block_dir.name,
|
||||
"payloads": payload_meta,
|
||||
}
|
||||
)
|
||||
|
||||
(collection_dir / "meta.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collection_id": int(collection.collection_id),
|
||||
"monotonic_ns": int(collection.monotonic_ns),
|
||||
"block_count": len(collection.blocks),
|
||||
"blocks": blocks_meta,
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def validate_snapshot_name(snapshot_name: str) -> str:
|
||||
"""Normalize snapshot directory stem."""
|
||||
return sanitize_path_component(snapshot_name)
|
||||
|
||||
|
||||
def _index_by_collection_sequence(
|
||||
history: list[TCollection],
|
||||
) -> tuple[dict[tuple[int, int], TCollection], dict[tuple[int, int], int]]:
|
||||
"""Index collections by latest-first `(collection_id, occurrence)` preserving history position.
|
||||
|
||||
Occurrence is counted from the tail (newest item is occurrence `0` for its
|
||||
collection id). This avoids cross-run misalignment when collection ids are
|
||||
reused after restarts and one stage keeps longer history than another.
|
||||
"""
|
||||
indexed: dict[tuple[int, int], TCollection] = {}
|
||||
positions: dict[tuple[int, int], int] = {}
|
||||
seen_count_from_tail_by_id: dict[int, int] = {}
|
||||
|
||||
# Walk from newest to oldest so occurrence=0 always means the most recent
|
||||
# instance for the given collection id.
|
||||
for index in range(len(history) - 1, -1, -1):
|
||||
item = history[index]
|
||||
collection_id = int(getattr(item, "collection_id"))
|
||||
occurrence = seen_count_from_tail_by_id.get(collection_id, 0)
|
||||
key = (collection_id, occurrence)
|
||||
indexed[key] = item
|
||||
positions[key] = index
|
||||
seen_count_from_tail_by_id[collection_id] = occurrence + 1
|
||||
return indexed, positions
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Concrete :class:`StoreApi` implementation backed by NPZ files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection, TraceData
|
||||
from python_app.storage.npz.paths import radar_key_from_config, sanitize_path_component
|
||||
from python_app.storage.npz.serialize import PREPROC_MAGIC, RAW_MAGIC, serialize_trace_collection
|
||||
from python_app.storage.npz.snapshot_numpy import (
|
||||
save_result_history_binary,
|
||||
save_result_history_numpy,
|
||||
save_trace_history_binary,
|
||||
save_trace_history_numpy,
|
||||
select_aligned_histories,
|
||||
)
|
||||
from python_app.storage.store_api import StoreApi
|
||||
|
||||
|
||||
class NpzStore(StoreApi):
|
||||
"""Persist calibration/reference sets and runtime snapshots using NumPy files."""
|
||||
|
||||
def __init__(self, root_dir: Path) -> None:
|
||||
"""Create store rooted at `root_dir`."""
|
||||
self._root_dir = root_dir
|
||||
self._root_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save_set(self, kind: str, radar_key: str, set_name: str, collection: SweepCollection) -> None:
|
||||
"""Persist named calibration/reference set as NPZ and metadata JSON."""
|
||||
set_dir = self._set_dir(kind, radar_key)
|
||||
set_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
npz_path = set_dir / f"{set_name}.npz"
|
||||
meta_path = set_dir / f"{set_name}.json"
|
||||
|
||||
payload: dict[str, np.ndarray] = {}
|
||||
combo_records: list[dict[str, str | int]] = []
|
||||
|
||||
for trace in collection.traces:
|
||||
suffix = f"i{trace.combo.input_pos}_o{trace.combo.output_pos}"
|
||||
freq_key = f"freq_{suffix}"
|
||||
s21_key = f"s21_{suffix}"
|
||||
|
||||
payload[freq_key] = np.asarray(trace.frequency_hz, dtype=np.float32)
|
||||
payload[s21_key] = np.asarray(trace.s21, dtype=np.complex64)
|
||||
combo_records.append(
|
||||
{
|
||||
"input": trace.combo.input_pos,
|
||||
"output": trace.combo.output_pos,
|
||||
"freq_key": freq_key,
|
||||
"s21_key": s21_key,
|
||||
}
|
||||
)
|
||||
|
||||
np.savez(npz_path, **payload)
|
||||
meta = {
|
||||
"collection_id": int(collection.collection_id),
|
||||
"monotonic_ns": int(collection.monotonic_ns),
|
||||
"combos": combo_records,
|
||||
}
|
||||
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
def load_set(self, kind: str, radar_key: str, set_name: str) -> SweepCollection:
|
||||
"""Load named calibration/reference set from NPZ representation."""
|
||||
set_dir = self._set_dir(kind, radar_key)
|
||||
npz_path = set_dir / f"{set_name}.npz"
|
||||
meta_path = set_dir / f"{set_name}.json"
|
||||
|
||||
if not npz_path.exists() or not meta_path.exists():
|
||||
raise FileNotFoundError(f"Missing set files for {kind}/{radar_key}/{set_name}")
|
||||
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
arrays = np.load(npz_path)
|
||||
|
||||
traces: list[TraceData] = []
|
||||
for combo in meta["combos"]:
|
||||
freq = np.asarray(arrays[combo["freq_key"]], dtype=np.float32)
|
||||
s21 = np.asarray(arrays[combo["s21_key"]], dtype=np.complex64)
|
||||
traces.append(
|
||||
TraceData(
|
||||
combo=ComboKey(input_pos=int(combo["input"]), output_pos=int(combo["output"])),
|
||||
frequency_hz=freq,
|
||||
s21=s21,
|
||||
)
|
||||
)
|
||||
|
||||
return SweepCollection(
|
||||
collection_id=int(meta["collection_id"]),
|
||||
monotonic_ns=int(meta["monotonic_ns"]),
|
||||
traces=traces,
|
||||
)
|
||||
|
||||
def list_sets(self, kind: str, radar_key: str) -> list[str]:
|
||||
"""List available set names for `(kind, radar_key)`."""
|
||||
set_dir = self._set_dir(kind, radar_key)
|
||||
if not set_dir.exists():
|
||||
return []
|
||||
return sorted(path.stem for path in set_dir.glob("*.npz"))
|
||||
|
||||
def has_combo_coverage(self, kind: str, radar_key: str, set_name: str, combos: list[ComboKey]) -> bool:
|
||||
"""Validate that named set covers all required switch combinations."""
|
||||
collection = self.load_set(kind, radar_key, set_name)
|
||||
existing = {(trace.combo.input_pos, trace.combo.output_pos) for trace in collection.traces}
|
||||
required = {(combo.input_pos, combo.output_pos) for combo in combos}
|
||||
return required.issubset(existing)
|
||||
|
||||
def export_set_bundle(self, kind: str, radar_key: str, set_name: str, output_path: Path) -> Path:
|
||||
"""Export named set as binary collection bundle for C++ preprocessing stage."""
|
||||
collection = self.load_set(kind, radar_key, set_name)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(serialize_trace_collection(collection, RAW_MAGIC))
|
||||
return output_path
|
||||
|
||||
def save_runtime_snapshot(
|
||||
self,
|
||||
output_dir: Path,
|
||||
raw_history: list[SweepCollection],
|
||||
preprocessed_history: list[SweepCollection],
|
||||
result_history: list[ResultCollection],
|
||||
last_n: int,
|
||||
) -> Path:
|
||||
"""Save historical runtime collections using binary on-disk format."""
|
||||
if last_n <= 0:
|
||||
raise ValueError("last_n must be > 0")
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
snapshot_dir = output_dir / f"snapshot_{timestamp}"
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
save_trace_history_binary(snapshot_dir / "raw", raw_history[-last_n:], RAW_MAGIC)
|
||||
save_trace_history_binary(snapshot_dir / "preprocessed", preprocessed_history[-last_n:], PREPROC_MAGIC)
|
||||
save_result_history_binary(snapshot_dir / "results", result_history[-last_n:])
|
||||
return snapshot_dir
|
||||
|
||||
def save_runtime_snapshot_numpy(
|
||||
self,
|
||||
output_root_dir: Path,
|
||||
snapshot_name: str,
|
||||
raw_history: list[SweepCollection],
|
||||
preprocessed_history: list[SweepCollection],
|
||||
result_history: list[ResultCollection],
|
||||
last_n: int,
|
||||
) -> tuple[Path, dict[str, Any]]:
|
||||
"""Save historical runtime collections in NumPy tree format."""
|
||||
if last_n <= 0:
|
||||
raise ValueError("last_n must be > 0")
|
||||
|
||||
snapshot_stem = snapshot_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S")
|
||||
snapshot_stem = sanitize_path_component(snapshot_stem)
|
||||
|
||||
output_root_dir.mkdir(parents=True, exist_ok=True)
|
||||
snapshot_dir = output_root_dir / snapshot_stem
|
||||
if snapshot_dir.exists():
|
||||
raise FileExistsError(f"Snapshot directory already exists: {snapshot_dir}")
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories(
|
||||
raw_history,
|
||||
preprocessed_history,
|
||||
result_history,
|
||||
last_n,
|
||||
)
|
||||
|
||||
save_trace_history_numpy(snapshot_dir / "raw", selected_raw)
|
||||
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"],
|
||||
"selected_collection_ids": selection_summary["selected_collection_ids"],
|
||||
"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",
|
||||
)
|
||||
|
||||
selection_summary["raw_count"] = len(selected_raw)
|
||||
selection_summary["preprocessed_count"] = len(selected_preprocessed)
|
||||
selection_summary["result_count"] = len(selected_results)
|
||||
selection_summary["snapshot_dir"] = str(snapshot_dir)
|
||||
return snapshot_dir, selection_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
|
||||
|
||||
|
||||
__all__ = ["NpzStore", "radar_key_from_config"]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Facade exports for NPZ-based storage implementation."""
|
||||
|
||||
from python_app.storage.npz.paths import (
|
||||
collection_dir_name as _collection_dir_name,
|
||||
format_float_for_key as _format_float_for_key,
|
||||
radar_key_from_config,
|
||||
sanitize_path_component as _sanitize_path_component,
|
||||
)
|
||||
from python_app.storage.npz.serialize import PREPROC_MAGIC, RAW_MAGIC, RESULT_MAGIC
|
||||
from python_app.storage.npz.store import NpzStore
|
||||
|
||||
__all__ = [
|
||||
"NpzStore",
|
||||
"PREPROC_MAGIC",
|
||||
"RAW_MAGIC",
|
||||
"RESULT_MAGIC",
|
||||
"_collection_dir_name",
|
||||
"_format_float_for_key",
|
||||
"_sanitize_path_component",
|
||||
"radar_key_from_config",
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Abstract storage API for calibration/reference sets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection
|
||||
|
||||
|
||||
class StoreApi(ABC):
|
||||
"""Storage contract used by workflows and GUI code."""
|
||||
|
||||
@abstractmethod
|
||||
def save_set(self, kind: str, radar_key: str, set_name: str, collection: SweepCollection) -> None:
|
||||
"""Persist a named set."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def load_set(self, kind: str, radar_key: str, set_name: str) -> SweepCollection:
|
||||
"""Load a named set."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def list_sets(self, kind: str, radar_key: str) -> list[str]:
|
||||
"""List set names for kind/radar key."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def has_combo_coverage(self, kind: str, radar_key: str, set_name: str, combos: list[ComboKey]) -> bool:
|
||||
"""Check whether stored set covers requested switch combinations."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def export_set_bundle(self, kind: str, radar_key: str, set_name: str, output_path: Path) -> Path:
|
||||
"""Export set into binary bundle file for C++ preprocessing."""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1 @@
|
||||
"""Capture workflows for calibration, reference, and sequential acquisition."""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""One-shot workflow for capturing a full calibration set."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.switch_service import SwitchService
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.storage.npz_store import NpzStore, radar_key_from_config
|
||||
|
||||
|
||||
def capture_calibration_set(
|
||||
config: RunConfigModel,
|
||||
set_name: str,
|
||||
store: NpzStore,
|
||||
) -> tuple[str, SweepCollection]:
|
||||
"""Capture all switch combinations and persist them as calibration set."""
|
||||
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||
|
||||
radar = LibreVnaService(serial=config.radar.serial or None)
|
||||
input_switch = SwitchService(
|
||||
name=config.input_switch.name,
|
||||
positions=config.input_switch.positions,
|
||||
mode=config.input_switch.driver_mode,
|
||||
driver=config.input_switch.driver,
|
||||
gpio_chip=config.input_switch.gpio_chip,
|
||||
pin_a=config.input_switch.pin_a,
|
||||
pin_b=config.input_switch.pin_b,
|
||||
invert_logic=config.input_switch.invert_logic,
|
||||
)
|
||||
output_switch = SwitchService(
|
||||
name=config.output_switch.name,
|
||||
positions=config.output_switch.positions,
|
||||
mode=config.output_switch.driver_mode,
|
||||
driver=config.output_switch.driver,
|
||||
gpio_chip=config.output_switch.gpio_chip,
|
||||
pin_a=config.output_switch.pin_a,
|
||||
pin_b=config.output_switch.pin_b,
|
||||
invert_logic=config.output_switch.invert_logic,
|
||||
)
|
||||
|
||||
traces: list[TraceData] = []
|
||||
try:
|
||||
radar.open()
|
||||
radar.configure(config.radar.sweep)
|
||||
input_switch.open()
|
||||
output_switch.open()
|
||||
|
||||
for combo in combos:
|
||||
output_switch.switch_to(combo.output)
|
||||
input_switch.switch_to(combo.input)
|
||||
if config.runtime.settling_ms > 0:
|
||||
time.sleep(config.runtime.settling_ms / 1000.0)
|
||||
|
||||
frequency_hz, s21 = radar.acquire_s21()
|
||||
traces.append(
|
||||
TraceData(
|
||||
combo=ComboKey(input_pos=combo.input, output_pos=combo.output),
|
||||
frequency_hz=frequency_hz,
|
||||
s21=s21,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
output_switch.close()
|
||||
input_switch.close()
|
||||
radar.close()
|
||||
|
||||
collection = SweepCollection(collection_id=1, monotonic_ns=time.monotonic_ns(), traces=traces)
|
||||
radar_key = radar_key_from_config(
|
||||
model_name=config.radar.model,
|
||||
serial=config.radar.serial,
|
||||
sweep_start_hz=config.radar.sweep.start_hz,
|
||||
sweep_stop_hz=config.radar.sweep.stop_hz,
|
||||
sweep_points=config.radar.sweep.points,
|
||||
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=config.radar.sweep.power_dbm,
|
||||
)
|
||||
store.save_set("calibration", radar_key, set_name, collection)
|
||||
return radar_key, collection
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user