init commit

This commit is contained in:
Ayzen
2026-03-05 14:42:33 +03:00
commit fd4618b20d
964 changed files with 325114 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Python application package for radar GUI, storage, orchestration, and hardware adapters."""
+22
View File
@@ -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`.
+46
View File
@@ -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.
+43
View File
@@ -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.
+35
View File
@@ -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()`.
+50
View File
@@ -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.
+49
View File
@@ -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.
+39
View File
@@ -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.
+41
View File
@@ -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 для одной коллекции.
+1
View File
@@ -0,0 +1 @@
"""GUI package for radar system runtime control and visualization."""
+137
View File
@@ -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)
+1
View File
@@ -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
+31
View File
@@ -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())
+19
View File
@@ -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",
]
+114
View File
@@ -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()))
+37
View File
@@ -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
+252
View File
@@ -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)
+13
View File
@@ -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",
]
+21
View File
@@ -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})"
)
+53
View File
@@ -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,
)
+125
View File
@@ -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)
+1
View File
@@ -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}")
+1
View File
@@ -0,0 +1 @@
"""Domain models for configuration and dataset payloads."""
+61
View File
@@ -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)
+177
View File
@@ -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,
},
},
}
+39
View File
@@ -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",
]
+146
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Runtime orchestration utilities for process control and IPC."""
+42
View File
@@ -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
}
+21
View File
@@ -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
+114
View File
@@ -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)
+150
View File
@@ -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)
+21
View File
@@ -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.
+114
View File
@@ -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
}
}
}
+1
View File
@@ -0,0 +1 @@
"""Utility scripts for smoke checks, inspection, and manual diagnostics."""
+298
View File
@@ -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())
+152
View File
@@ -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())
+72
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Persistent storage interfaces and implementations."""
+13
View File
@@ -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",
]
+52
View File
@@ -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)
+77
View File
@@ -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)
+225
View File
@@ -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
+204
View File
@@ -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"]
+21
View File
@@ -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",
]
+37
View File
@@ -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
+1
View File
@@ -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