per-point switching: hardware bring-up, profiles, protocol doc
Verified on the bench that the modified master bitstream excites in every stage and the RF switch routes: 16 traces per sweep, 62 dB between the two switch positions of one master port, 9.9k measurements/s. Adds the built master image, two run profiles, a verification script and librevna_protocol.md, and drops the stale prototype driver copy that the firmware tree still carried.
This commit is contained in:
+24
@@ -166,6 +166,25 @@ ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Локальные инструменты, распакованные не из пакетного менеджера (GHDL для
|
||||
# анализа и симуляции VHDL, clangd). Ставятся скриптом, в репозиторий не идут.
|
||||
.tools/
|
||||
|
||||
# База компиляции для clangd: абсолютные пути этой машины.
|
||||
# Генерируется .claude/tools/gen_compile_commands.sh из Makefile.
|
||||
compile_commands.json
|
||||
|
||||
# Граф знаний graphify: 22 МБ, пересобирается из кода за минуту.
|
||||
# Список исключений для него — в .graphifyignore, он тоже не версионируется
|
||||
# (см. ниже, вместе с остальной обвязкой Claude Code).
|
||||
graphify-out/
|
||||
|
||||
# Payload комплекта установки на Windows: копия файлов из дерева LibreVNA.
|
||||
# Генерируется install_windows/make_files.sh; в git не идёт, чтобы не разойтись
|
||||
# с деревом (уже расходился).
|
||||
device_firmware/multi_libreVNA_firmware/reproduce/install_windows/files/
|
||||
device_firmware/multi_libreVNA_firmware/reproduce/install_windows/backup_original/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
@@ -226,7 +245,12 @@ __marimo__/
|
||||
python_app/runtime
|
||||
SHARE_INTERNET_TO_PI.md
|
||||
|
||||
# Локальная обвязка Claude Code: инструкции, хуки, скилы, конфиги инструментов.
|
||||
# В репозиторий не идёт.
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.graphifyignore
|
||||
pyrightconfig.json
|
||||
/docs/
|
||||
test_end_2/
|
||||
|
||||
|
||||
@@ -69,9 +69,11 @@ controls plus a pixel-identical live plot through an embedded web UI.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux (POSIX shared memory; GPIO on Raspberry Pi).
|
||||
- Linux (POSIX shared memory; GPIO on Raspberry Pi). The deployment target is a
|
||||
**Raspberry Pi 5**; hardware PWM gating for the generator sweep is Pi 5
|
||||
specific (GPIO12/13/18/19, `dtoverlay=pwm-2chan`).
|
||||
- A C++20 compiler and `make` — Eigen and nlohmann/json are vendored, so no extra C++ dependencies are needed.
|
||||
- Python 3.12 with a virtual environment.
|
||||
- **Python 3.10 or newer**, in a virtual environment.
|
||||
|
||||
## Build and run
|
||||
|
||||
|
||||
@@ -13,12 +13,18 @@ counterpart to the C++ `sweep_orchestrator` used by LibreVNA devices.
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `src/` | Collector implementation (`main.cpp`) and the PTY / capture-file writers. |
|
||||
| `include/` | Headers for the writers. |
|
||||
| `include/` | Headers for the writers plus `switch_sequencer.h`. |
|
||||
| `vendor/lcard/` | L-Card X502/E502 SDK headers (`x502api.h`, `e502api.h`, `lcard_pstdint.h`), vendored verbatim so the collector builds with no external include path. |
|
||||
|
||||
The sources under `src/` and `include/` are imported verbatim from the original
|
||||
standalone tool and are kept byte-for-byte identical — the wire format and
|
||||
acquisition behaviour are unchanged; only their home moved into this repo.
|
||||
The sources started as a verbatim import of the original standalone tool, but are
|
||||
**no longer identical to it**: the collector now drives the RF switches itself
|
||||
(switch-aware mode, `SwitchSequencer`), stepping to the next combination inside
|
||||
the hardware idle gap between sweeps and flagging a sweep *dirty* when a readout
|
||||
backlog means the toggle could not have landed in that gap. It therefore reuses
|
||||
the orchestrator's switch drivers and the shared `run_config` loader, which is why
|
||||
its build has its own include set and its own `build/kamil/` object tree.
|
||||
|
||||
The wire format towards the Python producer is unchanged.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# Бинарный протокол LibreVNA: что шлёт и парсит наш драйвер
|
||||
|
||||
Разбор USB-протокола LibreVNA и того, какое его подмножество использует
|
||||
`python_app/hardware_full/librevna_multi_device_driver`. Про переключение
|
||||
стимула внутри частотной точки — отдельный документ,
|
||||
[per_point_switching.md](per_point_switching.md).
|
||||
|
||||
Прошивка в репозитории лежит под
|
||||
`device_firmware/multi_libreVNA_firmware/reproduce/LibreVNA/Software/VNA_embedded/Application/`,
|
||||
дальше он сокращён до `<FW>`. Версия протокола, заявленная прошивкой —
|
||||
`Protocol::Version = 14` (`<FW>/Communication/Protocol.hpp:13`).
|
||||
|
||||
## 1. Транспорт
|
||||
|
||||
Поверх USB у LibreVNA свой бинарный протокол, а не tty/serial. Endpoints:
|
||||
|
||||
| Endpoint | Направление |
|
||||
| --- | --- |
|
||||
| `0x01` | хост -> устройство |
|
||||
| `0x81` | устройство -> хост |
|
||||
| `0x82` | отладочные ASCII-логи |
|
||||
|
||||
Наш драйвер шлёт в `0x01`, читает из `0x81`, `0x82` не использует. Приёмный
|
||||
поток и очередь пакетов — в `librevna_multi_device_driver/transport.py`.
|
||||
|
||||
Очередь принятых пакетов ограничена и работает по принципу drop-oldest:
|
||||
блокировать USB-поток чтения нельзя, а устаревшие пакеты всё равно
|
||||
выбрасываются предсборочным дренажом (`transport.py:109`).
|
||||
|
||||
## 2. Структура кадра
|
||||
|
||||
Одна и та же почти для всех типов пакетов
|
||||
(`<FW>/Communication/PacketConstants.h`):
|
||||
|
||||
| Поле | Длина | Значение |
|
||||
| --- | --- | --- |
|
||||
| Header | 1 | всегда `0x5A` |
|
||||
| Length | 2 | длина **всего** кадра, little-endian |
|
||||
| Type | 1 | номер типа пакета |
|
||||
| Payload | переменная | |
|
||||
| CRC32 | 4 | по всему кадру, включая header |
|
||||
|
||||
Итого накладных расходов 8 байт: `длина кадра = 8 + len(payload)`.
|
||||
|
||||
Кодирование и разбор на хосте — в общем модуле одиночного драйвера, а не в
|
||||
multi-device: `python_app/hardware_full/librevna_driver/protocol/frame.py`,
|
||||
функции `encode_frame` (строка 25) и `decode_frame` (строка 49). Поток байт
|
||||
разбирает `FrameScanner`, который умеет ресинхронизироваться после битого кадра,
|
||||
не роняя устройство.
|
||||
|
||||
### CRC: у `VNADatapoint` особый случай
|
||||
|
||||
Прошивка **не считает** CRC для `VNADatapoint` и пишет туда ноль, потому что
|
||||
расчёт занимает около 18 мкс — это основная часть времени кодирования и
|
||||
передачи точки (`<FW>/Communication/Protocol.cpp:149`). На приёме она,
|
||||
наоборот, **требует** ровно нуля: непустой CRC у datapoint считается ошибкой
|
||||
(`Protocol.cpp:69` и `Protocol.cpp:80`).
|
||||
|
||||
Наш хост повторяет это поведение точно: `decode_frame` проверяет CRC32 для всех
|
||||
типов, а для `VNADatapoint` требует ноль (`frame.py:20` — список исключений).
|
||||
|
||||
Дополнительно multi-device парсер отбраковывает пакет, у которого тело не
|
||||
кратно 9 байтам на значение: раз CRC у datapoint нет, это единственная защита
|
||||
от ресинхронизации на `0x5A`, случайно оказавшийся внутри payload
|
||||
(`librevna_multi_device_driver/protocol.py:137`).
|
||||
|
||||
## 3. Типы пакетов
|
||||
|
||||
Полный enum — `<FW>/Communication/Protocol.hpp:573`, там 30+ типов. Наш драйвер
|
||||
использует шесть (`librevna_multi_device_driver/protocol.py:17`):
|
||||
|
||||
| Тип | Номер | Зачем |
|
||||
| --- | --- | --- |
|
||||
| `SweepSettings` | 2 | настройка развёртки, она же её запуск |
|
||||
| `Ack` | 7 | подтверждение команды |
|
||||
| `Nack` | 10 | отказ |
|
||||
| `Reference` | 11 | выбор источника опорной частоты |
|
||||
| `SetIdle` | 20 | остановить развёртку, перевести в idle |
|
||||
| `VNADatapoint` | 27 | поток измерений |
|
||||
|
||||
То есть это узкий VNA-sweep-драйвер, а не полная реализация всех режимов
|
||||
прибора. Не используются `RequestDeviceInfo`, проверка версии протокола,
|
||||
`SetTrigger`/`ClearTrigger`, генератор, анализатор спектра, калибровочные
|
||||
пакеты.
|
||||
|
||||
## 4. Пакеты, которые шлём
|
||||
|
||||
### `Reference` (11)
|
||||
|
||||
Структура — `Protocol.hpp:186`: `uint32 ExtRefOuputFreq` плюс битовое поле
|
||||
`AutomaticSwitch` и `UseExternalRef`.
|
||||
|
||||
Шлём `output_frequency_hz = 0` и флаг внешней опоры: выход `Ref Out` не
|
||||
включаем, а устройству принудительно указываем использовать внешние 10 МГц.
|
||||
Строит `build_reference_settings_payload` (`protocol.py:48`), отправляет
|
||||
`controller.py`.
|
||||
|
||||
### `SweepSettings` (2)
|
||||
|
||||
Структура — `Protocol.hpp:155`:
|
||||
|
||||
```c
|
||||
uint64 f_start, f_stop;
|
||||
uint16 points;
|
||||
uint32 if_bandwidth;
|
||||
int16 cdbm_excitation_start;
|
||||
uint8 standby:1, syncMaster:1, suppressPeaks:1, fixedPowerSetting:1,
|
||||
logSweep:1, syncMode:2, unused1:1;
|
||||
uint16 stages:3, port1Stage:3, port2Stage:3, port3Stage:3, port4Stage:3, unused2:1;
|
||||
int16 cdbm_excitation_stop;
|
||||
uint16 dwell_time; // в микросекундах
|
||||
```
|
||||
|
||||
Что кладём (`build_sweep_settings_payload`, `protocol.py:58`):
|
||||
|
||||
- `f_start`/`f_stop`/`points`/`if_bandwidth` — из `SweepConfiguration`;
|
||||
- мощность старта и стопа одинаковая, `fixedPowerSetting = 1` — аттенюатор и
|
||||
мощность PLL не меняются по развёртке;
|
||||
- `suppressPeaks = 1`, `logSweep = 0`, `standby = 0`;
|
||||
- `syncMode = 3` (триггерная синхронизация), если синхронизация включена;
|
||||
- `syncMaster = 1` только у мастера;
|
||||
- `stages` — номер последней стадии, не их количество;
|
||||
- порт-стадии — см. ниже.
|
||||
|
||||
Про `syncMode = 3`: в стоковой прошивке рядом с полем стоит комментарий
|
||||
«Trigger synchronization (not supported yet by hardware)». В нашем форке этот
|
||||
режим и используется — устройство переводит обе триггерные линии FPGA в Hi-Z.
|
||||
|
||||
**Порт-стадии — не то, что было в первой версии драйвера.** Раньше мастер
|
||||
получал `port1Stage = 0`, а слейв `1`. Сейчас:
|
||||
|
||||
- мастеру отправляется **первая стадия блока** порта (`index * substage_count`);
|
||||
без под-стадий это просто индекс порта, то есть стоковая раскладка;
|
||||
- приёмным устройствам обе порт-стадии выставляются в
|
||||
`DISABLED_PORT_STAGE = 7` (`protocol.py:36`) — «порт выключен»;
|
||||
- ширина блока под-стадий передаётся как `log2` в неиспользуемое поле
|
||||
`port3Stage`.
|
||||
|
||||
Почему именно так и почему семёрка безопасна — в
|
||||
[per_point_switching.md](per_point_switching.md).
|
||||
|
||||
### `SetIdle` (20)
|
||||
|
||||
Без payload. Выключает активный режим и останавливает развёртку. Используется
|
||||
как служебная команда: остановить непрерывную развёртку, убраться при ошибке,
|
||||
`close()`.
|
||||
|
||||
### `Ack` (7) / `Nack` (10)
|
||||
|
||||
Ответы устройства на команды хоста. `wait_for_acknowledgement`
|
||||
(`transport.py:96`) читает входящие пакеты, на `Ack` считает команду
|
||||
применённой, на `Nack` бросает исключение, прочие асинхронные пакеты
|
||||
пропускает.
|
||||
|
||||
## 5. `VNADatapoint` (27) — поток измерений
|
||||
|
||||
Шаблон `VNADatapoint<s>` — `Protocol.hpp:25`; у прошивки `s = 32`, то есть до 32
|
||||
значений в точке (три на стадию при восьми стадиях).
|
||||
|
||||
Payload:
|
||||
|
||||
| Поле | Тип | Длина |
|
||||
| --- | --- | --- |
|
||||
| `frequency` | uint64 | 8 |
|
||||
| `cdBm` | int16 | 2 |
|
||||
| `pointNum` | uint16 | 2 |
|
||||
| `real_values` | n × float32 | 4n |
|
||||
| `imag_values` | n × float32 | 4n |
|
||||
| `descr_values` | n × uint8 | n |
|
||||
|
||||
Фиксированная часть — 12 байт, дальше по 9 байт на значение, причём **не
|
||||
чередуясь**: сначала все реальные части, потом все мнимые, потом все
|
||||
дескрипторы. Отсюда `n = (len(payload) - 12) / 9`.
|
||||
|
||||
Разбирает `parse_vna_datapoint_payload` (`protocol.py:137`).
|
||||
|
||||
### Байт дескриптора
|
||||
|
||||
Смещения битов — `<FW>/Communication/PacketConstants.h:42`:
|
||||
|
||||
| Биты | Значение |
|
||||
| --- | --- |
|
||||
| 0 | порт 1 |
|
||||
| 1 | порт 2 |
|
||||
| 2 | порт 3 |
|
||||
| 3 | порт 4 |
|
||||
| 4 | опорный приёмник (Reference) |
|
||||
| 5..7 | номер стадии |
|
||||
|
||||
То же отражено в `enum class Source` (`Protocol.hpp:17`): `Port1 = 0x01`,
|
||||
`Port2 = 0x02`, `Port3 = 0x04`, `Port4 = 0x08`, `Reference = 0x10`.
|
||||
Прошивка собирает байт как `stage << 5 | sourceMask`.
|
||||
|
||||
На каждую стадию приходит ровно три значения (`<FW>/VNA.cpp:407`): порт 1, порт
|
||||
2 и опорный приёмник. **Ловушка:** у опорного значения маска не `0x10`, а
|
||||
`Port1 | Port2 | Reference = 0x13` — то есть биты портов у него тоже стоят.
|
||||
Поэтому отбирать сигнальное значение только по биту порта нельзя, надо ещё
|
||||
требовать сброшенный бит 4; именно так и сделано в `find_receiver_value`.
|
||||
|
||||
Ищет значение по этому байту `find_receiver_value` (`protocol.py:174`) —
|
||||
фильтрует по стадии, по признаку опорного приёмника и, если задан, по порту.
|
||||
Например: «стадия 0 + ref» — опорный приёмник мастера; «стадия 0 + порт 1, не
|
||||
ref» — сигнал на первом приёмнике.
|
||||
|
||||
## 6. Как из этого получаются S-параметры
|
||||
|
||||
Устройство S-параметры **не считает** — оно отдаёт только сырые комплексные
|
||||
отсчёты приёмников. Считает хост, в
|
||||
`librevna_multi_device_driver/cycle_collection.py`.
|
||||
|
||||
Нормировка (`calculate_last_cycle_s_parameters`, строка 783): каждый канал
|
||||
делится на опорный приёмник **мастера**, а не на локальный опорный приёмник
|
||||
слейва — потому что излучает именно мастер. Опорные измерения хранятся по
|
||||
номеру выходного канала (`master_reference_measurements_by_output`), так как с
|
||||
переключением стимула выходов больше, чем портов мастера.
|
||||
|
||||
Имена S-параметров: отражение мастера — `master_reflection_s_parameter_name`
|
||||
(строка 760), прямые каналы — `build_forward_s_parameter_names` (строка 765).
|
||||
Нумерация приёмных портов слейвов начинается **после** всех выходов, чтобы
|
||||
`S33`/`S44` отражения не столкнулись с прямыми каналами.
|
||||
|
||||
## 7. Почему поле мощности читается, но не используется
|
||||
|
||||
В парсере стоит:
|
||||
|
||||
```python
|
||||
frequency_hz, _power_level_centidecibels_milliwatt, point_index = struct.unpack_from(
|
||||
"<QhH", payload, 0
|
||||
)
|
||||
```
|
||||
|
||||
Поле мощности в payload реально существует и стоит между частотой и номером
|
||||
точки, поэтому прочитать его необходимо, чтобы правильно достать `point_index`.
|
||||
Подчёркивание в имени — обычный питоновский признак «прочитали намеренно, но не
|
||||
используем».
|
||||
|
||||
По смыслу оно не нужно: для сбора циклов достаточно частоты, индекса точки и
|
||||
массива отсчётов, а S-параметры считаются как отношение сигнала к опоре.
|
||||
К тому же развёртка идёт с фиксированной мощностью (`cdbm_excitation_start ==
|
||||
cdbm_excitation_stop`, `fixedPowerSetting = 1`), так что поле лишь дублирует
|
||||
уже известное из настройки.
|
||||
|
||||
Понадобилось бы оно при переменной по частоте мощности, при логировании
|
||||
фактической мощности каждой точки или при проверке, что устройство
|
||||
действительно выдаёт ожидаемый профиль.
|
||||
|
||||
## 8. Чего в драйвере сознательно нет
|
||||
|
||||
- запроса `DeviceInfo` и проверки версии протокола устройства;
|
||||
- отладочного endpoint `0x82`;
|
||||
- пакетов калибровки, генератора, анализатора спектра;
|
||||
- `SetTrigger`/`ClearTrigger` — синхронизация настраивается полем `syncMode` в
|
||||
`SweepSettings`, а сама петля триггера аппаратная.
|
||||
|
||||
CRC входящих пакетов, в отличие от первой версии драйвера, **проверяется** —
|
||||
см. раздел 2.
|
||||
|
||||
## 9. Карта модулей хоста
|
||||
|
||||
| Файл | Роль |
|
||||
| --- | --- |
|
||||
| `librevna_driver/protocol/frame.py` | кадр: кодирование, разбор, CRC, ресинхронизация |
|
||||
| `librevna_multi_device_driver/protocol.py` | номера пакетов, сборка payload, парсер datapoint, поиск по дескриптору |
|
||||
| `librevna_multi_device_driver/transport.py` | USB bulk, приёмный поток, очередь, ожидание `Ack` |
|
||||
| `librevna_multi_device_driver/controller.py` | высокоуровневые команды: настроить развёртку, собрать циклы, остановить |
|
||||
| `librevna_multi_device_driver/cycle_collection.py` | сбор точек в полные циклы, нормировка в S-параметры, имена трасс |
|
||||
| `librevna_multi_device_driver/models.py` | `SweepConfiguration`, выходные каналы, ограничения стадий |
|
||||
Binary file not shown.
-10
@@ -1,10 +0,0 @@
|
||||
from .librevna_usb_transport import list_connected_device_serial_numbers
|
||||
from .multi_device_vna_controller import MultiDeviceVnaController
|
||||
from .sweep_measurement_models import SweepConfiguration, SweepMeasurementResult
|
||||
|
||||
__all__ = [
|
||||
"MultiDeviceVnaController",
|
||||
"SweepConfiguration",
|
||||
"SweepMeasurementResult",
|
||||
"list_connected_device_serial_numbers",
|
||||
]
|
||||
-383
@@ -1,383 +0,0 @@
|
||||
LibreVNA вообще, что именно делает наш текущий Python-код, какие пакеты мы используем, как они кодируются и парсятся, и почему `_power_level_centidecibels_milliwatt` сейчас не нужен.
|
||||
|
||||
**1. Где вообще начинается протокол**
|
||||
У LibreVNA поверх USB идёт свой бинарный протокол, а не `tty/serial`. Это видно и в документации, и в firmware:
|
||||
- USB endpoints: [Device_protocol_v13.tex](/home/europa/Documents/LibreVNA/Documentation/DeveloperInfo/Device_protocol_v13.tex:176)
|
||||
- актуальный код firmware: [Protocol.hpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.hpp:573)
|
||||
|
||||
На USB уровне:
|
||||
- `0x01` — host -> device
|
||||
- `0x81` — device -> host
|
||||
- `0x82` — debug ASCII logs
|
||||
|
||||
В нашем коде data transport работает только через:
|
||||
- [librevna_usb_transport.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_usb_transport.py)
|
||||
|
||||
То есть:
|
||||
- мы шлём команды в `0x01`
|
||||
- читаем протокольные пакеты из `0x81`
|
||||
- `0x82` вообще не используем
|
||||
|
||||
**2. Общая структура любого LibreVNA packet**
|
||||
Общий framing один и тот же для почти всех packet types. Это описано в docs и реализовано в firmware:
|
||||
- docs: [Device_protocol_v13.tex](/home/europa/Documents/LibreVNA/Documentation/DeveloperInfo/Device_protocol_v13.tex:208)
|
||||
- firmware decode/encode: [Protocol.cpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.cpp:5)
|
||||
- field offsets/constants: [PacketConstants.h](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/PacketConstants.h:10)
|
||||
|
||||
Структура такая:
|
||||
1. `Header` — 1 байт, всегда `0x5A`
|
||||
2. `Length` — 2 байта, общая длина всего packet целиком
|
||||
3. `Type` — 1 байт, номер packet type
|
||||
4. `Payload` — переменная длина
|
||||
5. `CRC32` — 4 байта
|
||||
|
||||
В Python это делает:
|
||||
- `encode_protocol_frame(...)` в [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py:31)
|
||||
- `extract_complete_protocol_frames(...)` в [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py:45)
|
||||
|
||||
`encode_protocol_frame(...)`:
|
||||
- считает `total_frame_length = 8 + len(payload)`
|
||||
- пакует header/length/type
|
||||
- считает CRC32
|
||||
- дописывает CRC в конец
|
||||
|
||||
`extract_complete_protocol_frames(...)`:
|
||||
- получает поток байт, а не “готовый packet”
|
||||
- ищет `0x5A`
|
||||
- читает `length`
|
||||
- если полного frame ещё нет, оставляет хвост в буфере
|
||||
- если frame полный, возвращает `(packet_type, payload)`
|
||||
|
||||
Очень важно:
|
||||
- наш Python сейчас **не проверяет CRC**
|
||||
- firmware проверяет CRC для почти всех пакетов, но для `VNADatapoint` у них особый случай: CRC всегда `0`, чтобы не тратить время на вычисление CRC для каждого datapoint, см. [Protocol.cpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.cpp:69) и [Protocol.cpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.cpp:147)
|
||||
|
||||
То есть сейчас Python делает “framing by magic + length”, но не “integrity check”.
|
||||
|
||||
**3. Какие packet types вообще бывают**
|
||||
Полный актуальный enum лежит тут:
|
||||
- [Protocol.hpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.hpp:573)
|
||||
|
||||
Там есть много типов:
|
||||
- `SweepSettings = 2`
|
||||
- `ManualStatus = 3`
|
||||
- `ManualControl = 4`
|
||||
- `DeviceInfo = 5`
|
||||
- `FirmwarePacket = 6`
|
||||
- `Ack = 7`
|
||||
- `ClearFlash = 8`
|
||||
- `PerformFirmwareUpdate = 9`
|
||||
- `Nack = 10`
|
||||
- `Reference = 11`
|
||||
- `Generator = 12`
|
||||
- `SpectrumAnalyzerSettings = 13`
|
||||
- `SpectrumAnalyzerResult = 14`
|
||||
- `RequestDeviceInfo = 15`
|
||||
- ...
|
||||
- `SetIdle = 20`
|
||||
- `DeviceStatus = 25`
|
||||
- `RequestDeviceStatus = 26`
|
||||
- `VNADatapoint = 27`
|
||||
- `SetTrigger = 28`
|
||||
- `ClearTrigger = 29`
|
||||
- `InitiateSweep = 32`
|
||||
- `PerformAction = 33`
|
||||
|
||||
Но наш текущий драйвер использует только маленький поднабор. Это зашито в:
|
||||
- [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py:9)
|
||||
|
||||
Мы используем:
|
||||
- `SweepSettings = 2`
|
||||
- `Ack = 7`
|
||||
- `Nack = 10`
|
||||
- `Reference = 11`
|
||||
- `SetIdle = 20`
|
||||
- `VNADatapoint = 27`
|
||||
|
||||
То есть текущий драйвер — это именно VNA sweep driver, а не “полная реализация всех режимов прибора”.
|
||||
|
||||
**4. Какие именно пакеты использует наш драйвер**
|
||||
Разберу их по смыслу.
|
||||
|
||||
**`ReferenceSettings` / packet type `11`**
|
||||
Структура в firmware:
|
||||
- [Protocol.hpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.hpp:186)
|
||||
|
||||
Поля:
|
||||
- `uint32 ExtRefOutputFreq`
|
||||
- битовое поле:
|
||||
- `AutomaticSwitch`
|
||||
- `UseExternalRef`
|
||||
|
||||
В Python это строит:
|
||||
- `build_reference_settings_payload(...)` в [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py:74)
|
||||
|
||||
Сейчас мы шлём:
|
||||
- `output_frequency_hz = 0`
|
||||
- `force_external_reference = True`
|
||||
|
||||
То есть:
|
||||
- никакой `Ref Out` не включаем
|
||||
- просто принудительно говорим устройству использовать внешний `10 MHz`
|
||||
|
||||
Это применяется в controller:
|
||||
- [multi_device_vna_controller.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/multi_device_vna_controller.py:183)
|
||||
|
||||
**`SweepSettings` / packet type `2`**
|
||||
Структура в firmware:
|
||||
- [Protocol.hpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.hpp:155)
|
||||
|
||||
Поля:
|
||||
- `f_start`
|
||||
- `f_stop`
|
||||
- `points`
|
||||
- `if_bandwidth`
|
||||
- `cdbm_excitation_start`
|
||||
- bitfield config:
|
||||
- `standby`
|
||||
- `syncMaster`
|
||||
- `suppressPeaks`
|
||||
- `fixedPowerSetting`
|
||||
- `logSweep`
|
||||
- `syncMode`
|
||||
- stage mapping bitfield
|
||||
- `cdbm_excitation_stop`
|
||||
- `dwell_time`
|
||||
|
||||
В Python это строит:
|
||||
- `build_sweep_settings_payload(...)` в [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py:92)
|
||||
|
||||
Что мы туда кладём:
|
||||
- start/stop/points/ifbw — из `SweepConfiguration`
|
||||
- power start/stop — одинаковые
|
||||
- `fixedPowerSetting = 1`
|
||||
- `suppressPeaks = 1`
|
||||
- `logSweep = 0`
|
||||
- `syncMode = 3`, если есть slaves
|
||||
- `syncMaster = 1` только у master
|
||||
- `port_1_stimulus_stage = 0` для master, `1` для slave
|
||||
|
||||
Смысл:
|
||||
- master возбуждает `Port 1`
|
||||
- slaves только принимают
|
||||
- sync идёт через hardware trigger path
|
||||
|
||||
Этот packet реально запускает VNA setup в firmware:
|
||||
- [App.cpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/App.cpp:126)
|
||||
|
||||
**`SetIdle` / packet type `20`**
|
||||
Очень простой packet, без payload.
|
||||
В firmware:
|
||||
- [App.cpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/App.cpp:196)
|
||||
|
||||
Он переводит устройство в idle:
|
||||
- выключает активный режим
|
||||
- останавливает sweep path
|
||||
|
||||
У нас он используется только как service-command:
|
||||
- остановить continuous sweep
|
||||
- cleanup при ошибке
|
||||
- `close()`
|
||||
|
||||
Это делает controller:
|
||||
- [multi_device_vna_controller.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/multi_device_vna_controller.py:173)
|
||||
|
||||
**`Ack` / `Nack` / packet types `7` и `10`**
|
||||
Это ответы устройства на команды host.
|
||||
В firmware:
|
||||
- enum: [Protocol.hpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.hpp:581)
|
||||
- отправка `Ack`/`Nack`: [App.cpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/App.cpp:129)
|
||||
|
||||
В transport:
|
||||
- `wait_for_acknowledgement(...)` в [librevna_usb_transport.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_usb_transport.py:62)
|
||||
|
||||
Логика:
|
||||
- читаем входящие пакеты
|
||||
- если пришёл `Ack`, команда считается применённой
|
||||
- если пришёл `Nack`, бросаем exception
|
||||
- прочие asynchronous packets просто пропускаем
|
||||
|
||||
**`VNADatapoint` / packet type `27`**
|
||||
Это главный поток измерения.
|
||||
Структура в firmware:
|
||||
- template `VNADatapoint<s>`: [Protocol.hpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.hpp:25)
|
||||
- декодирование: [Protocol.cpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.cpp:79)
|
||||
|
||||
Payload такой:
|
||||
1. `frequency` — `uint64`, 8 байт
|
||||
2. `cdBm` — `int16`, 2 байта
|
||||
3. `pointNum` — `uint16`, 2 байта
|
||||
4. `real_values` — `n * float32`
|
||||
5. `imag_values` — `n * float32`
|
||||
6. `descr_values` — `n * uint8`
|
||||
|
||||
То есть фиксированный header payload имеет длину 12 байт:
|
||||
- `8 + 2 + 2`
|
||||
|
||||
А дальше идёт `n` значений, каждое занимает:
|
||||
- `4` байта real
|
||||
- `4` байта imag
|
||||
- `1` байт descriptor
|
||||
- итого `9` байт на одно значение
|
||||
|
||||
В Python это парсится тут:
|
||||
- `parse_vna_datapoint_payload(...)` в [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py:134)
|
||||
|
||||
Алгоритм:
|
||||
- читаем `frequency_hz`, `power`, `point_index` через `struct.unpack_from("<QhH", ...)`
|
||||
- считаем `value_count = (len(payload) - 12) // 9`
|
||||
- читаем массив `real_components`
|
||||
- читаем массив `imaginary_components`
|
||||
- читаем массив `description_masks`
|
||||
- собираем `dict[mask] = complex(real, imag)`
|
||||
|
||||
**5. Что такое `description_mask` внутри datapoint**
|
||||
Это очень важный байт. Он говорит:
|
||||
- какой это порт
|
||||
- reference это или нет
|
||||
- к какому stage относится значение
|
||||
|
||||
Точные offsets:
|
||||
- [PacketConstants.h](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/PacketConstants.h:41)
|
||||
|
||||
Биты:
|
||||
- bit 0 = `P1`
|
||||
- bit 1 = `P2`
|
||||
- bit 2 = `P3`
|
||||
- bit 3 = `P4`
|
||||
- bit 4 = `Ref`
|
||||
- bits 5..7 = `stage`
|
||||
|
||||
В firmware это же отражено и через enum `Source`:
|
||||
- [Protocol.hpp](/home/europa/Documents/LibreVNA/Software/VNA_embedded/Application/Communication/Protocol.hpp:17)
|
||||
|
||||
В Python поиск по этому байту делает:
|
||||
- `find_receiver_value(...)` в [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py:174)
|
||||
|
||||
Она:
|
||||
- фильтрует по `stage`
|
||||
- фильтрует по `reference/non-reference`
|
||||
- если задан `required_port_number`, фильтрует по порту
|
||||
|
||||
Например:
|
||||
- `stage 0 + ref=True` — это master reference receiver
|
||||
- `stage 0 + port=1 + ref=False` — это сигнал на receiver port 1
|
||||
- `stage 0 + port=2 + ref=False` — это сигнал на receiver port 2
|
||||
|
||||
**6. Как `VNADatapoint` превращается в S-параметры**
|
||||
Устройство само `S11/S21/S31` не считает.
|
||||
Оно шлёт только сырые complex receiver values.
|
||||
|
||||
В нашей новой разбивке это делает модуль:
|
||||
- [running_sweep_cycle_collection.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/running_sweep_cycle_collection.py)
|
||||
|
||||
Там:
|
||||
- master datapoint даёт:
|
||||
- master ref
|
||||
- `S11` raw
|
||||
- `S21` raw
|
||||
- каждый slave datapoint даёт:
|
||||
- два своих приёмных канала, которые потом становятся `S31/S41`, `S51/S61`, и так далее
|
||||
|
||||
Нормализация:
|
||||
- `calculate_last_cycle_s_parameters(...)` в [running_sweep_cycle_collection.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/running_sweep_cycle_collection.py:226)
|
||||
|
||||
Формула:
|
||||
- все каналы делятся на `master_reference_measurements[-1]`
|
||||
|
||||
Это принципиально важно:
|
||||
- даже `slave` каналы делятся не на локальный slave `Ref`, а на master reference, потому что именно master возбуждает сигнал
|
||||
|
||||
**7. Почему `_power_level_centidecibels_milliwatt` не используется**
|
||||
Это переменная вот здесь:
|
||||
- [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py:143)
|
||||
|
||||
Строка:
|
||||
```python
|
||||
frequency_hz, _power_level_centidecibels_milliwatt, point_index = struct.unpack_from(
|
||||
"<QhH",
|
||||
payload,
|
||||
0,
|
||||
)
|
||||
```
|
||||
|
||||
Почему она есть:
|
||||
- потому что в payload `VNADatapoint` поле мощности реально существует
|
||||
- порядок полей такой: `frequency`, `cdBm`, `pointNum`
|
||||
- чтобы корректно распаковать `point_index`, нам надо прочитать и среднее поле тоже
|
||||
|
||||
Почему она с `_`:
|
||||
- это стандартный Python-сигнал “поле прочитали, но намеренно не используем”
|
||||
|
||||
Почему мы её не используем по смыслу:
|
||||
1. Для сбора sweep-циклов она не нужна.
|
||||
Нам нужны только:
|
||||
- частота
|
||||
- индекс точки
|
||||
- массив complex receiver values
|
||||
2. Для вычисления `S` она тоже не нужна.
|
||||
`S` считаются как отношение signal/ref.
|
||||
3. В нашем текущем sweep builder мы вообще задаём фиксированную мощность:
|
||||
- `cdbm_excitation_start == cdbm_excitation_stop`
|
||||
- `fixedPowerSetting = 1`
|
||||
см. [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py:108)
|
||||
|
||||
То есть в вашем текущем драйвере это поле практически просто дублирует уже известный нам факт о sweep configuration.
|
||||
|
||||
Когда оно могло бы понадобиться:
|
||||
- если бы вы хотели логировать реальную мощность каждой точки
|
||||
- если бы sweep был с изменяющейся мощностью по частоте
|
||||
- если бы нужно было sanity-check’ить, что device реально шлёт ожидаемый power profile
|
||||
- если бы вы строили экспорт “frequency + power + S-parameters”
|
||||
|
||||
Итого:
|
||||
- поле реально есть в протоколе
|
||||
- парсер его читает
|
||||
- но текущему драйверу для работы оно не нужно
|
||||
- поэтому имя с `_` здесь абсолютно осознанное и корректное
|
||||
|
||||
**8. Что именно у нас сейчас реализовано не полностью**
|
||||
Это тоже важно понимать.
|
||||
|
||||
Наш текущий Python:
|
||||
- реализует только subset packet types
|
||||
- не делает `RequestDeviceInfo`
|
||||
- не проверяет `ProtocolVersion`
|
||||
- не проверяет CRC входящих пакетов
|
||||
- не использует debug endpoint `0x82`
|
||||
|
||||
То есть это не “полный client protocol stack”, а достаточно чистый и узкий VNA multi-device driver.
|
||||
|
||||
**9. Краткая карта текущих protocol-функций**
|
||||
В новом пакете роли такие:
|
||||
|
||||
- [librevna_device_protocol.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_device_protocol.py)
|
||||
- описание packet ids
|
||||
- frame encode
|
||||
- frame split
|
||||
- payload builders
|
||||
- datapoint parser
|
||||
- descriptor-mask lookup
|
||||
|
||||
- [librevna_usb_transport.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/librevna_usb_transport.py)
|
||||
- raw USB bulk I/O
|
||||
- receive thread
|
||||
- packet queue
|
||||
- waiting for `Ack`
|
||||
|
||||
- [multi_device_vna_controller.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/multi_device_vna_controller.py)
|
||||
- high-level commands:
|
||||
- configure continuous sweep
|
||||
- collect cycles
|
||||
- stop/close
|
||||
|
||||
- [running_sweep_cycle_collection.py](/home/europa/Documents/LibreVNA/Python/librevna_multi_device_driver/running_sweep_cycle_collection.py)
|
||||
- datapoint collection threads
|
||||
- alignment to full cycle
|
||||
- raw receiver accumulation
|
||||
- normalization to `S`-parameters
|
||||
|
||||
Если хотите, следующим сообщением я могу сделать ещё более низкоуровневый разбор:
|
||||
- прям показать байтовую раскладку одного `SweepSettings` packet
|
||||
или
|
||||
- показать, как из конкретного `VNADatapoint` вручную достать `S21` и `S41`.
|
||||
-191
@@ -1,191 +0,0 @@
|
||||
import binascii
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .sweep_measurement_models import SweepConfiguration
|
||||
|
||||
|
||||
class PacketType:
|
||||
SWEEP_SETTINGS = 2
|
||||
ACKNOWLEDGE = 7
|
||||
NEGATIVE_ACKNOWLEDGE = 10
|
||||
REFERENCE_SETTINGS = 11
|
||||
SET_IDLE = 20
|
||||
VNA_DATAPOINT = 27
|
||||
|
||||
|
||||
SYNC_MODE_HARDWARE_TRIGGER = 3
|
||||
FRAME_HEADER_MAGIC = 0x5A
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedVnaDatapoint:
|
||||
"""Decoded contents of one VNADatapoint payload."""
|
||||
|
||||
frequency_hz: int
|
||||
point_index: int
|
||||
receiver_values_by_description_mask: dict[int, complex]
|
||||
|
||||
|
||||
def encode_protocol_frame(packet_type: int, payload: bytes = b"") -> bytes:
|
||||
"""Wrap a packet payload in the LibreVNA transport frame."""
|
||||
|
||||
total_frame_length = 8 + len(payload)
|
||||
frame_without_crc = struct.pack(
|
||||
"<BHB",
|
||||
FRAME_HEADER_MAGIC,
|
||||
total_frame_length,
|
||||
packet_type,
|
||||
) + payload
|
||||
crc32 = binascii.crc32(frame_without_crc) & 0xFFFF_FFFF
|
||||
return frame_without_crc + struct.pack("<I", crc32)
|
||||
|
||||
|
||||
def extract_complete_protocol_frames(
|
||||
buffered_bytes: bytes,
|
||||
) -> tuple[list[tuple[int, bytes]], bytes]:
|
||||
"""
|
||||
Extract every complete protocol frame from a byte stream buffer.
|
||||
|
||||
Returns a tuple of:
|
||||
1. a list of `(packet_type, payload)` tuples
|
||||
2. the unconsumed trailing bytes that do not yet form a full frame
|
||||
"""
|
||||
|
||||
extracted_packets: list[tuple[int, bytes]] = []
|
||||
while len(buffered_bytes) >= 4:
|
||||
if buffered_bytes[0] != FRAME_HEADER_MAGIC:
|
||||
buffered_bytes = buffered_bytes[1:]
|
||||
continue
|
||||
|
||||
total_frame_length = struct.unpack_from("<H", buffered_bytes, 1)[0]
|
||||
if len(buffered_bytes) < total_frame_length:
|
||||
break
|
||||
|
||||
packet_type = buffered_bytes[3]
|
||||
payload = buffered_bytes[4 : total_frame_length - 4]
|
||||
extracted_packets.append((packet_type, payload))
|
||||
buffered_bytes = buffered_bytes[total_frame_length:]
|
||||
|
||||
return extracted_packets, buffered_bytes
|
||||
|
||||
|
||||
def build_reference_settings_payload(
|
||||
output_frequency_hz: int,
|
||||
force_external_reference: bool,
|
||||
) -> bytes:
|
||||
"""Build the ReferenceSettings payload."""
|
||||
|
||||
automatic_reference_switch = 0
|
||||
external_input_configuration_bits = (
|
||||
automatic_reference_switch << 0
|
||||
| force_external_reference << 1
|
||||
)
|
||||
return struct.pack(
|
||||
"<IB",
|
||||
output_frequency_hz,
|
||||
external_input_configuration_bits,
|
||||
)
|
||||
|
||||
|
||||
def build_sweep_settings_payload(
|
||||
sweep_configuration: SweepConfiguration,
|
||||
is_synchronization_master: bool,
|
||||
synchronization_enabled: bool,
|
||||
) -> bytes:
|
||||
"""Build the current protocol-v14 SweepSettings payload."""
|
||||
|
||||
excitation_power_centidecibels_milliwatt = int(sweep_configuration.power_dbm * 100)
|
||||
dwell_time_microseconds = max(
|
||||
0,
|
||||
min(int(sweep_configuration.dwell_us), 0xFFFF),
|
||||
)
|
||||
synchronization_mode = (
|
||||
SYNC_MODE_HARDWARE_TRIGGER if synchronization_enabled else 0
|
||||
)
|
||||
|
||||
configuration_bits = (
|
||||
0 << 0
|
||||
| is_synchronization_master << 1
|
||||
| 1 << 2
|
||||
| 1 << 3
|
||||
| 0 << 4
|
||||
| synchronization_mode << 5
|
||||
)
|
||||
|
||||
port_1_stimulus_stage = 0 if is_synchronization_master else 1
|
||||
stage_configuration_bits = 0 | port_1_stimulus_stage << 3 | 1 << 6
|
||||
|
||||
return struct.pack(
|
||||
"<QQHIhBHhH",
|
||||
sweep_configuration.start_hz,
|
||||
sweep_configuration.stop_hz,
|
||||
sweep_configuration.points,
|
||||
sweep_configuration.if_bandwidth,
|
||||
excitation_power_centidecibels_milliwatt,
|
||||
configuration_bits,
|
||||
stage_configuration_bits,
|
||||
excitation_power_centidecibels_milliwatt,
|
||||
dwell_time_microseconds,
|
||||
)
|
||||
|
||||
|
||||
def parse_vna_datapoint_payload(payload: bytes) -> Optional[ParsedVnaDatapoint]:
|
||||
"""Decode one VNADatapoint payload into a structured Python object."""
|
||||
|
||||
fixed_header_length_bytes = 12
|
||||
per_value_storage_length_bytes = 9
|
||||
|
||||
if len(payload) < fixed_header_length_bytes:
|
||||
return None
|
||||
|
||||
frequency_hz, _power_level_centidecibels_milliwatt, point_index = struct.unpack_from(
|
||||
"<QhH",
|
||||
payload,
|
||||
0,
|
||||
)
|
||||
value_count = (len(payload) - fixed_header_length_bytes) // per_value_storage_length_bytes
|
||||
if value_count == 0:
|
||||
return None
|
||||
|
||||
real_components = struct.unpack_from(f"<{value_count}f", payload, 12)
|
||||
imaginary_components = struct.unpack_from(
|
||||
f"<{value_count}f",
|
||||
payload,
|
||||
12 + 4 * value_count,
|
||||
)
|
||||
description_masks = payload[12 + 8 * value_count : 12 + 9 * value_count]
|
||||
|
||||
receiver_values_by_description_mask = {
|
||||
description_masks[value_index]: complex(
|
||||
real_components[value_index],
|
||||
imaginary_components[value_index],
|
||||
)
|
||||
for value_index in range(value_count)
|
||||
}
|
||||
return ParsedVnaDatapoint(
|
||||
frequency_hz=frequency_hz,
|
||||
point_index=point_index,
|
||||
receiver_values_by_description_mask=receiver_values_by_description_mask,
|
||||
)
|
||||
|
||||
|
||||
def find_receiver_value(
|
||||
receiver_values_by_description_mask: dict[int, complex],
|
||||
stage_index: int,
|
||||
is_reference_receiver: bool,
|
||||
required_port_number: int = 0,
|
||||
) -> Optional[complex]:
|
||||
"""Look up one complex receiver value by stage, receiver type, and port number."""
|
||||
|
||||
required_port_bit = (1 << (required_port_number - 1)) if required_port_number else 0
|
||||
for description_mask, complex_value in receiver_values_by_description_mask.items():
|
||||
if (description_mask >> 5) != stage_index:
|
||||
continue
|
||||
if bool(description_mask & 0x10) != is_reference_receiver:
|
||||
continue
|
||||
if required_port_bit and not (description_mask & required_port_bit):
|
||||
continue
|
||||
return complex_value
|
||||
return None
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
import usb.core
|
||||
import usb.util
|
||||
|
||||
try:
|
||||
import libusb_package
|
||||
except ImportError:
|
||||
libusb_package = None
|
||||
|
||||
from .librevna_device_protocol import (
|
||||
PacketType,
|
||||
encode_protocol_frame,
|
||||
extract_complete_protocol_frames,
|
||||
)
|
||||
|
||||
|
||||
USB_VENDOR_ID = 0x1209
|
||||
USB_PRODUCT_ID = 0x4121
|
||||
USB_DATA_ENDPOINT_OUT = 0x01
|
||||
USB_DATA_ENDPOINT_IN = 0x81
|
||||
|
||||
transport_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibreVnaUsbBackendError(RuntimeError):
|
||||
"""Raised when PyUSB cannot access a libusb backend."""
|
||||
|
||||
|
||||
def _build_missing_backend_message() -> str:
|
||||
return (
|
||||
"PyUSB could not find a libusb backend. "
|
||||
"On Windows, install the Python USB backend with "
|
||||
"`python -m pip install -r Python/requirements.txt` from the repository root "
|
||||
"or `python -m pip install libusb-package` inside the active virtual environment. "
|
||||
"If the LibreVNA GUI already sees the device, the USB driver is likely fine and "
|
||||
"only the Python libusb DLL is missing."
|
||||
)
|
||||
|
||||
|
||||
def _usb_find(**kwargs):
|
||||
if libusb_package is not None:
|
||||
return libusb_package.find(**kwargs)
|
||||
|
||||
try:
|
||||
return usb.core.find(**kwargs)
|
||||
except usb.core.NoBackendError as exc:
|
||||
raise LibreVnaUsbBackendError(_build_missing_backend_message()) from exc
|
||||
|
||||
|
||||
class LibreVnaUsbBulkConnection:
|
||||
"""USB bulk transport wrapper for one LibreVNA device."""
|
||||
|
||||
_USB_TRANSFER_TIMEOUT_MS = 1_000
|
||||
|
||||
def __init__(self, serial_number: str) -> None:
|
||||
usb_device = _usb_find(
|
||||
idVendor=USB_VENDOR_ID,
|
||||
idProduct=USB_PRODUCT_ID,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
if usb_device is None:
|
||||
raise RuntimeError(f"LibreVNA with serial {serial_number!r} not found on USB")
|
||||
|
||||
try:
|
||||
if usb_device.is_kernel_driver_active(0):
|
||||
usb_device.detach_kernel_driver(0)
|
||||
except (usb.core.USBError, NotImplementedError):
|
||||
pass
|
||||
|
||||
usb_device.set_configuration()
|
||||
|
||||
self.serial_number = serial_number
|
||||
self._usb_device = usb_device
|
||||
self._received_packets: queue.Queue[tuple[int, bytes]] = queue.Queue()
|
||||
self._stop_requested = threading.Event()
|
||||
self._transmit_lock = threading.Lock()
|
||||
self._receive_thread = threading.Thread(
|
||||
target=self._receive_packets_forever,
|
||||
daemon=True,
|
||||
name=f"vna-rx-{serial_number[:8]}",
|
||||
)
|
||||
self._receive_thread.start()
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop_requested.set()
|
||||
self._receive_thread.join(timeout=0.5)
|
||||
usb.util.dispose_resources(self._usb_device)
|
||||
|
||||
def drain_received_packets(self) -> list[tuple[int, bytes]]:
|
||||
"""Remove all already-buffered packets without blocking."""
|
||||
|
||||
drained_packets: list[tuple[int, bytes]] = []
|
||||
while True:
|
||||
try:
|
||||
drained_packets.append(self._received_packets.get_nowait())
|
||||
except queue.Empty:
|
||||
return drained_packets
|
||||
|
||||
def send_packet(self, packet_type: int, payload: bytes = b"") -> None:
|
||||
with self._transmit_lock:
|
||||
self._usb_device.write(
|
||||
USB_DATA_ENDPOINT_OUT,
|
||||
encode_protocol_frame(packet_type, payload),
|
||||
timeout=self._USB_TRANSFER_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
def receive_packet(self, timeout_seconds: float = 2.0) -> tuple[int, bytes]:
|
||||
"""Wait for the next received packet."""
|
||||
|
||||
return self._received_packets.get(timeout=timeout_seconds)
|
||||
|
||||
def wait_for_acknowledgement(self, timeout_seconds: float = 2.0) -> None:
|
||||
"""Wait for an ACK packet while ignoring unrelated asynchronous packets."""
|
||||
|
||||
end_time_monotonic = time.monotonic() + timeout_seconds
|
||||
while True:
|
||||
remaining_seconds = end_time_monotonic - time.monotonic()
|
||||
if remaining_seconds <= 0:
|
||||
raise TimeoutError(f"ACK not received within {timeout_seconds} s")
|
||||
|
||||
packet_type, _payload = self.receive_packet(timeout_seconds=remaining_seconds)
|
||||
if packet_type == PacketType.ACKNOWLEDGE:
|
||||
return
|
||||
if packet_type == PacketType.NEGATIVE_ACKNOWLEDGE:
|
||||
raise RuntimeError("Device returned NACK")
|
||||
|
||||
def _receive_packets_forever(self) -> None:
|
||||
buffered_bytes = b""
|
||||
while not self._stop_requested.is_set():
|
||||
try:
|
||||
usb_chunk = bytes(
|
||||
self._usb_device.read(
|
||||
USB_DATA_ENDPOINT_IN,
|
||||
4096,
|
||||
timeout=100,
|
||||
)
|
||||
)
|
||||
except usb.core.USBTimeoutError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
if not self._stop_requested.is_set():
|
||||
transport_logger.error(
|
||||
"USB read error on %s: %s",
|
||||
self.serial_number,
|
||||
exc,
|
||||
)
|
||||
break
|
||||
|
||||
buffered_bytes += usb_chunk
|
||||
extracted_packets, buffered_bytes = extract_complete_protocol_frames(
|
||||
buffered_bytes
|
||||
)
|
||||
for packet in extracted_packets:
|
||||
self._received_packets.put(packet)
|
||||
|
||||
|
||||
def list_connected_device_serial_numbers() -> list[str]:
|
||||
"""Return the serial numbers of all connected LibreVNA devices."""
|
||||
|
||||
connected_devices = _usb_find(
|
||||
idVendor=USB_VENDOR_ID,
|
||||
idProduct=USB_PRODUCT_ID,
|
||||
find_all=True,
|
||||
)
|
||||
return [device.serial_number for device in (connected_devices or [])]
|
||||
-223
@@ -1,223 +0,0 @@
|
||||
import time
|
||||
from collections.abc import Iterator, Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Optional
|
||||
|
||||
from .librevna_device_protocol import (
|
||||
PacketType,
|
||||
build_reference_settings_payload,
|
||||
build_sweep_settings_payload,
|
||||
)
|
||||
from .librevna_usb_transport import (
|
||||
LibreVnaUsbBulkConnection,
|
||||
)
|
||||
from .running_sweep_cycle_collection import collect_complete_running_sweep_cycles
|
||||
from .sweep_measurement_models import SweepConfiguration, SweepMeasurementResult
|
||||
|
||||
|
||||
class MultiDeviceVnaController:
|
||||
"""
|
||||
Multi-device LibreVNA controller for synchronized forward S-parameter sweeps.
|
||||
|
||||
Port mapping:
|
||||
master Port 1 / Port 2 → S11 / S21
|
||||
slave 0 Port 1 / Port 2 → S31 / S41
|
||||
slave 1 Port 1 / Port 2 → S51 / S61
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master_serial_number: str,
|
||||
slave_serial_numbers: Sequence[str] = (),
|
||||
force_external_reference: bool = True,
|
||||
) -> None:
|
||||
self._master_device = LibreVnaUsbBulkConnection(master_serial_number)
|
||||
self._slave_devices = [
|
||||
LibreVnaUsbBulkConnection(slave_serial_number)
|
||||
for slave_serial_number in slave_serial_numbers
|
||||
]
|
||||
self._all_devices = [self._master_device, *self._slave_devices]
|
||||
self._synchronization_enabled = len(self._slave_devices) > 0
|
||||
self._force_external_reference = force_external_reference
|
||||
self._reference_configuration_applied = False
|
||||
self._last_applied_sweep_configuration: Optional[SweepConfiguration] = None
|
||||
self._sweep_is_running = False
|
||||
self._is_closed = False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._is_closed:
|
||||
return
|
||||
|
||||
self._is_closed = True
|
||||
self._send_idle_to_all_devices()
|
||||
for device_connection in self._all_devices:
|
||||
device_connection.close()
|
||||
|
||||
def stop_continuous_sweep(self) -> None:
|
||||
"""Stop the currently running sweep without closing the controller."""
|
||||
|
||||
if self._is_closed:
|
||||
return
|
||||
self._send_idle_to_all_devices()
|
||||
|
||||
def configure_continuous_sweep(
|
||||
self,
|
||||
sweep_configuration: SweepConfiguration,
|
||||
) -> None:
|
||||
"""
|
||||
Apply reference and sweep settings and leave the devices sweeping.
|
||||
|
||||
Reapplying the same sweep configuration is a no-op.
|
||||
"""
|
||||
|
||||
if self._is_closed:
|
||||
raise RuntimeError("Controller is already closed")
|
||||
|
||||
if not self._reference_configuration_applied:
|
||||
self._configure_reference_clocks()
|
||||
|
||||
if (
|
||||
self._sweep_is_running
|
||||
and self._last_applied_sweep_configuration == sweep_configuration
|
||||
):
|
||||
return
|
||||
|
||||
if self._sweep_is_running:
|
||||
self._send_idle_to_all_devices()
|
||||
time.sleep(0.05)
|
||||
|
||||
self._drain_all_received_packets()
|
||||
self._configure_sweep_on_all_devices(sweep_configuration)
|
||||
|
||||
def collect_running_sweep_cycles(
|
||||
self,
|
||||
cycle_count: int = 1,
|
||||
) -> SweepMeasurementResult:
|
||||
"""
|
||||
Collect one or more complete cycles from the already-running sweep.
|
||||
|
||||
Call configure_continuous_sweep() first. The driver aligns itself to the
|
||||
next full cycle in the incoming datapoint stream and then captures
|
||||
exactly `cycle_count` complete cycles.
|
||||
"""
|
||||
|
||||
if self._is_closed:
|
||||
raise RuntimeError("Controller is already closed")
|
||||
if not self._sweep_is_running or self._last_applied_sweep_configuration is None:
|
||||
raise RuntimeError(
|
||||
"No running sweep is configured. Call configure_continuous_sweep() first."
|
||||
)
|
||||
|
||||
try:
|
||||
return collect_complete_running_sweep_cycles(
|
||||
master_device_connection=self._master_device,
|
||||
slave_device_connections=self._slave_devices,
|
||||
active_sweep_configuration=self._last_applied_sweep_configuration,
|
||||
cycle_count=cycle_count,
|
||||
)
|
||||
except Exception:
|
||||
self._send_idle_to_all_devices()
|
||||
raise
|
||||
|
||||
def iter_running_sweep_cycles(self) -> Iterator[SweepMeasurementResult]:
|
||||
"""Yield complete sweep cycles forever from the already-running sweep."""
|
||||
|
||||
while True:
|
||||
yield self.collect_running_sweep_cycles(1)
|
||||
|
||||
def _send_command_and_wait_for_acknowledgement(
|
||||
self,
|
||||
device_connection: LibreVnaUsbBulkConnection,
|
||||
packet_type: int,
|
||||
payload: bytes = b"",
|
||||
timeout_seconds: float = 3.0,
|
||||
retry_count: int = 1,
|
||||
) -> None:
|
||||
last_error: Optional[Exception] = None
|
||||
for _attempt_index in range(retry_count + 1):
|
||||
device_connection.send_packet(packet_type, payload)
|
||||
try:
|
||||
device_connection.wait_for_acknowledgement(
|
||||
timeout_seconds=timeout_seconds
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
def _try_send_command_without_failing(
|
||||
self,
|
||||
device_connection: LibreVnaUsbBulkConnection,
|
||||
packet_type: int,
|
||||
payload: bytes = b"",
|
||||
timeout_seconds: float = 3.0,
|
||||
retry_count: int = 1,
|
||||
) -> None:
|
||||
try:
|
||||
self._send_command_and_wait_for_acknowledgement(
|
||||
device_connection,
|
||||
packet_type,
|
||||
payload,
|
||||
timeout_seconds=timeout_seconds,
|
||||
retry_count=retry_count,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _send_idle_to_all_devices(self) -> None:
|
||||
for device_connection in self._all_devices:
|
||||
self._try_send_command_without_failing(
|
||||
device_connection,
|
||||
PacketType.SET_IDLE,
|
||||
timeout_seconds=3.0,
|
||||
retry_count=1,
|
||||
)
|
||||
self._sweep_is_running = False
|
||||
|
||||
def _configure_reference_clocks(self) -> None:
|
||||
for device_connection in self._all_devices:
|
||||
self._send_command_and_wait_for_acknowledgement(
|
||||
device_connection,
|
||||
PacketType.REFERENCE_SETTINGS,
|
||||
build_reference_settings_payload(0, self._force_external_reference),
|
||||
timeout_seconds=3.0,
|
||||
retry_count=1,
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
self._reference_configuration_applied = True
|
||||
|
||||
def _configure_sweep_on_all_devices(
|
||||
self,
|
||||
sweep_configuration: SweepConfiguration,
|
||||
) -> None:
|
||||
sweep_configuration_commands = [
|
||||
*[(slave_device, False) for slave_device in self._slave_devices],
|
||||
(self._master_device, True),
|
||||
]
|
||||
for device_connection, is_synchronization_master in sweep_configuration_commands:
|
||||
self._send_command_and_wait_for_acknowledgement(
|
||||
device_connection,
|
||||
PacketType.SWEEP_SETTINGS,
|
||||
build_sweep_settings_payload(
|
||||
sweep_configuration,
|
||||
is_synchronization_master=is_synchronization_master,
|
||||
synchronization_enabled=self._synchronization_enabled,
|
||||
),
|
||||
timeout_seconds=3.0,
|
||||
retry_count=1,
|
||||
)
|
||||
self._last_applied_sweep_configuration = replace(sweep_configuration)
|
||||
self._sweep_is_running = True
|
||||
|
||||
def _drain_all_received_packets(self) -> None:
|
||||
for device_connection in self._all_devices:
|
||||
device_connection.drain_received_packets()
|
||||
-284
@@ -1,284 +0,0 @@
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .librevna_device_protocol import (
|
||||
PacketType,
|
||||
ParsedVnaDatapoint,
|
||||
find_receiver_value,
|
||||
parse_vna_datapoint_payload,
|
||||
)
|
||||
from .librevna_usb_transport import LibreVnaUsbBulkConnection
|
||||
from .sweep_measurement_models import SweepConfiguration, SweepMeasurementResult
|
||||
|
||||
|
||||
def collect_complete_running_sweep_cycles(
|
||||
master_device_connection: LibreVnaUsbBulkConnection,
|
||||
slave_device_connections: Sequence[LibreVnaUsbBulkConnection],
|
||||
active_sweep_configuration: SweepConfiguration,
|
||||
cycle_count: int,
|
||||
) -> SweepMeasurementResult:
|
||||
"""Collect complete cycles from an already-running synchronized sweep."""
|
||||
|
||||
cycle_count = int(cycle_count)
|
||||
if cycle_count < 1:
|
||||
raise ValueError("cycle_count must be >= 1")
|
||||
|
||||
all_device_connections = [master_device_connection, *slave_device_connections]
|
||||
point_count = active_sweep_configuration.points
|
||||
frequencies_hz = np.zeros(point_count, dtype=np.float64)
|
||||
raw_receiver_measurements_by_s_parameter = {
|
||||
s_parameter_name: np.full(
|
||||
(cycle_count, point_count),
|
||||
np.nan + 1j * np.nan,
|
||||
dtype=complex,
|
||||
)
|
||||
for s_parameter_name in build_forward_s_parameter_names(len(slave_device_connections))
|
||||
}
|
||||
master_reference_measurements = np.full(
|
||||
(cycle_count, point_count),
|
||||
np.nan + 1j * np.nan,
|
||||
dtype=complex,
|
||||
)
|
||||
collection_errors: list[Exception] = []
|
||||
datapoint_counts_by_device_serial = {
|
||||
device_connection.serial_number: 0
|
||||
for device_connection in all_device_connections
|
||||
}
|
||||
stop_collection_requested = threading.Event()
|
||||
datapoint_timeout_seconds = max(
|
||||
10.0,
|
||||
active_sweep_configuration.dwell_us / 1_000_000 + 2.0,
|
||||
)
|
||||
|
||||
def collect_datapoints_from_device(
|
||||
device_connection: LibreVnaUsbBulkConnection,
|
||||
handle_datapoint: Callable[[ParsedVnaDatapoint], bool],
|
||||
) -> None:
|
||||
datapoints_received = 0
|
||||
expected_datapoint_count = cycle_count * point_count
|
||||
last_datapoint_timestamp = time.monotonic()
|
||||
|
||||
while datapoints_received < expected_datapoint_count:
|
||||
if stop_collection_requested.is_set():
|
||||
return
|
||||
|
||||
remaining_timeout_seconds = (
|
||||
last_datapoint_timestamp + datapoint_timeout_seconds
|
||||
) - time.monotonic()
|
||||
if remaining_timeout_seconds <= 0:
|
||||
collection_errors.append(
|
||||
TimeoutError(
|
||||
f"No datapoints from {device_connection.serial_number} for "
|
||||
f"{datapoint_timeout_seconds:.1f} s "
|
||||
f"(received {datapoints_received}/{point_count})"
|
||||
)
|
||||
)
|
||||
stop_collection_requested.set()
|
||||
return
|
||||
|
||||
try:
|
||||
packet_type, payload = device_connection.receive_packet(
|
||||
timeout_seconds=min(1.0, remaining_timeout_seconds)
|
||||
)
|
||||
except (queue.Empty, TimeoutError) as exc:
|
||||
if stop_collection_requested.is_set():
|
||||
return
|
||||
if isinstance(exc, queue.Empty):
|
||||
continue
|
||||
collection_errors.append(exc)
|
||||
stop_collection_requested.set()
|
||||
return
|
||||
|
||||
if packet_type != PacketType.VNA_DATAPOINT:
|
||||
continue
|
||||
|
||||
parsed_datapoint = parse_vna_datapoint_payload(payload)
|
||||
if parsed_datapoint and 0 <= parsed_datapoint.point_index < point_count:
|
||||
last_datapoint_timestamp = time.monotonic()
|
||||
datapoint_was_consumed = handle_datapoint(parsed_datapoint)
|
||||
if datapoint_was_consumed:
|
||||
datapoint_counts_by_device_serial[device_connection.serial_number] += 1
|
||||
datapoints_received += 1
|
||||
|
||||
def build_cycle_tracking_handler(
|
||||
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
|
||||
) -> Callable[[ParsedVnaDatapoint], bool]:
|
||||
cycle_tracking_state = {
|
||||
"current_cycle_index": 0,
|
||||
"previous_point_index": -1,
|
||||
"has_seen_cycle_start": False,
|
||||
}
|
||||
|
||||
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
|
||||
current_point_index = parsed_datapoint.point_index
|
||||
if not cycle_tracking_state["has_seen_cycle_start"]:
|
||||
if current_point_index != 0:
|
||||
cycle_tracking_state["previous_point_index"] = current_point_index
|
||||
return False
|
||||
cycle_tracking_state["has_seen_cycle_start"] = True
|
||||
|
||||
if (
|
||||
cycle_tracking_state["previous_point_index"] >= 0
|
||||
and current_point_index < cycle_tracking_state["previous_point_index"]
|
||||
):
|
||||
cycle_tracking_state["current_cycle_index"] += 1
|
||||
|
||||
cycle_tracking_state["previous_point_index"] = current_point_index
|
||||
current_cycle_index = cycle_tracking_state["current_cycle_index"]
|
||||
if current_cycle_index >= cycle_count:
|
||||
return False
|
||||
|
||||
cycle_aware_handler(parsed_datapoint, current_cycle_index)
|
||||
return True
|
||||
|
||||
return handle_datapoint
|
||||
|
||||
def handle_master_datapoint(
|
||||
parsed_datapoint: ParsedVnaDatapoint,
|
||||
cycle_index: int,
|
||||
) -> None:
|
||||
point_index = parsed_datapoint.point_index
|
||||
reference_receiver_value = find_receiver_value(
|
||||
parsed_datapoint.receiver_values_by_description_mask,
|
||||
stage_index=0,
|
||||
is_reference_receiver=True,
|
||||
)
|
||||
if reference_receiver_value is None:
|
||||
return
|
||||
|
||||
frequencies_hz[point_index] = parsed_datapoint.frequency_hz
|
||||
master_reference_measurements[cycle_index, point_index] = reference_receiver_value
|
||||
|
||||
for receiver_port_number, s_parameter_name in ((1, "S11"), (2, "S21")):
|
||||
port_receiver_value = find_receiver_value(
|
||||
parsed_datapoint.receiver_values_by_description_mask,
|
||||
stage_index=0,
|
||||
is_reference_receiver=False,
|
||||
required_port_number=receiver_port_number,
|
||||
)
|
||||
if port_receiver_value is not None:
|
||||
raw_receiver_measurements_by_s_parameter[s_parameter_name][
|
||||
cycle_index,
|
||||
point_index,
|
||||
] = port_receiver_value
|
||||
|
||||
def build_slave_datapoint_handler(
|
||||
slave_index: int,
|
||||
) -> Callable[[ParsedVnaDatapoint], bool]:
|
||||
first_s_parameter_name = f"S{2 * slave_index + 3}1"
|
||||
second_s_parameter_name = f"S{2 * slave_index + 4}1"
|
||||
|
||||
def handle_slave_datapoint(
|
||||
parsed_datapoint: ParsedVnaDatapoint,
|
||||
cycle_index: int,
|
||||
) -> None:
|
||||
point_index = parsed_datapoint.point_index
|
||||
for receiver_port_number, s_parameter_name in (
|
||||
(1, first_s_parameter_name),
|
||||
(2, second_s_parameter_name),
|
||||
):
|
||||
port_receiver_value = find_receiver_value(
|
||||
parsed_datapoint.receiver_values_by_description_mask,
|
||||
stage_index=0,
|
||||
is_reference_receiver=False,
|
||||
required_port_number=receiver_port_number,
|
||||
)
|
||||
if port_receiver_value is not None:
|
||||
raw_receiver_measurements_by_s_parameter[s_parameter_name][
|
||||
cycle_index,
|
||||
point_index,
|
||||
] = port_receiver_value
|
||||
|
||||
return build_cycle_tracking_handler(handle_slave_datapoint)
|
||||
|
||||
collection_threads = [
|
||||
threading.Thread(
|
||||
target=collect_datapoints_from_device,
|
||||
args=(
|
||||
master_device_connection,
|
||||
build_cycle_tracking_handler(handle_master_datapoint),
|
||||
),
|
||||
daemon=True,
|
||||
name="collect-master",
|
||||
),
|
||||
]
|
||||
for slave_index, slave_device_connection in enumerate(slave_device_connections):
|
||||
collection_threads.append(
|
||||
threading.Thread(
|
||||
target=collect_datapoints_from_device,
|
||||
args=(slave_device_connection, build_slave_datapoint_handler(slave_index)),
|
||||
daemon=True,
|
||||
name=f"collect-slave{slave_index}",
|
||||
)
|
||||
)
|
||||
|
||||
for collection_thread in collection_threads:
|
||||
collection_thread.start()
|
||||
for collection_thread in collection_threads:
|
||||
collection_thread.join()
|
||||
|
||||
if collection_errors:
|
||||
raise RuntimeError(
|
||||
f"Sweep collection failed: {collection_errors[0]}"
|
||||
) from collection_errors[0]
|
||||
|
||||
if slave_device_connections and min(datapoint_counts_by_device_serial.values(), default=0) == 0:
|
||||
raise RuntimeError(
|
||||
"No datapoints received from at least one device; "
|
||||
"hardware trigger sync did not start. Check Trigger Out/In loop "
|
||||
"and 10 MHz reference wiring."
|
||||
)
|
||||
|
||||
return SweepMeasurementResult(
|
||||
frequencies_hz=frequencies_hz,
|
||||
s_parameters=calculate_last_cycle_s_parameters(
|
||||
raw_receiver_measurements_by_s_parameter,
|
||||
master_reference_measurements,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def calculate_last_cycle_s_parameters(
|
||||
raw_receiver_measurements_by_s_parameter: dict[str, np.ndarray],
|
||||
master_reference_measurements: np.ndarray,
|
||||
) -> dict[str, np.ndarray]:
|
||||
"""Convert raw receiver captures from the final sweep cycle into S-parameters."""
|
||||
|
||||
if np.isnan(master_reference_measurements).any():
|
||||
missing_reference_count = int(np.isnan(master_reference_measurements).sum())
|
||||
raise RuntimeError(
|
||||
f"Master reference missing for {missing_reference_count} datapoints"
|
||||
)
|
||||
|
||||
s_parameters: dict[str, np.ndarray] = {}
|
||||
for s_parameter_name, raw_receiver_measurements in (
|
||||
raw_receiver_measurements_by_s_parameter.items()
|
||||
):
|
||||
if np.isnan(raw_receiver_measurements).any():
|
||||
missing_measurement_count = int(np.isnan(raw_receiver_measurements).sum())
|
||||
raise RuntimeError(
|
||||
f"Measurement {s_parameter_name} missing for "
|
||||
f"{missing_measurement_count} datapoints"
|
||||
)
|
||||
s_parameters[s_parameter_name] = (
|
||||
raw_receiver_measurements[-1] / master_reference_measurements[-1]
|
||||
)
|
||||
|
||||
return s_parameters
|
||||
|
||||
|
||||
def build_forward_s_parameter_names(slave_device_count: int) -> list[str]:
|
||||
"""Return S-parameter names for the configured forward multi-device topology."""
|
||||
|
||||
s_parameter_names = ["S11", "S21"]
|
||||
for slave_index in range(slave_device_count):
|
||||
s_parameter_names += [
|
||||
f"S{2 * slave_index + 3}1",
|
||||
f"S{2 * slave_index + 4}1",
|
||||
]
|
||||
return s_parameter_names
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class SweepConfiguration:
|
||||
"""User-facing configuration for one VNA sweep."""
|
||||
|
||||
start_hz: int = 100_000
|
||||
stop_hz: int = 3_000_000_000
|
||||
points: int = 201
|
||||
if_bandwidth: int = 1_000
|
||||
power_dbm: float = -10.0
|
||||
dwell_us: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SweepMeasurementResult:
|
||||
"""Measured sweep data from one or more complete sweep cycles."""
|
||||
|
||||
frequencies_hz: np.ndarray
|
||||
s_parameters: dict[str, np.ndarray]
|
||||
@@ -1,4 +0,0 @@
|
||||
pyusb
|
||||
libusb-package; platform_system == "Windows"
|
||||
numpy
|
||||
matplotlib
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 299 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 295 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 151 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB |
@@ -1,336 +0,0 @@
|
||||
"""
|
||||
test_sweep.py - run synchronized sweeps continuously and draw the latest cycle live.
|
||||
|
||||
Usage
|
||||
-----
|
||||
List connected devices:
|
||||
python test_sweep.py
|
||||
|
||||
Single device (S11, S21):
|
||||
python test_sweep.py <master_serial>
|
||||
|
||||
Two devices - master + one slave (S11, S21, S31, S41):
|
||||
python test_sweep.py <master_serial> <slave_serial>
|
||||
|
||||
Multi-device runs always use hardware trigger synchronization.
|
||||
Close the plot window or press Ctrl+C to stop the live sweep.
|
||||
"""
|
||||
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from matplotlib.axes import Axes
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.lines import Line2D
|
||||
from matplotlib.text import Text
|
||||
|
||||
from librevna_multi_device_driver import (
|
||||
MultiDeviceVnaController,
|
||||
SweepConfiguration,
|
||||
SweepMeasurementResult,
|
||||
list_connected_device_serial_numbers,
|
||||
)
|
||||
|
||||
|
||||
# Set to "internal" to keep each device on its own reference clock.
|
||||
DEFAULT_REFERENCE_SOURCE = "external"
|
||||
DEFAULT_SWEEP_CONFIGURATION = SweepConfiguration(
|
||||
start_hz=100_000_000,
|
||||
stop_hz=6_000_000_000,
|
||||
points=501,
|
||||
if_bandwidth=10000,
|
||||
power_dbm=-10.0,
|
||||
)
|
||||
|
||||
_ACTIVE_MEASUREMENT_CONTROLLER: MultiDeviceVnaController | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveSweepPlot:
|
||||
figure: Figure
|
||||
status_text: Text
|
||||
magnitude_axis: Axes
|
||||
phase_axes: list[Axes]
|
||||
magnitude_lines: dict[str, Line2D]
|
||||
phase_lines: dict[str, Line2D]
|
||||
|
||||
|
||||
def _force_external_reference_from_default_source() -> bool:
|
||||
if DEFAULT_REFERENCE_SOURCE == "external":
|
||||
return True
|
||||
if DEFAULT_REFERENCE_SOURCE == "internal":
|
||||
return False
|
||||
raise ValueError(
|
||||
"DEFAULT_REFERENCE_SOURCE must be either 'external' or 'internal'"
|
||||
)
|
||||
|
||||
|
||||
def _parse_device_serial_numbers_from_command_line(
|
||||
command_line_arguments: list[str],
|
||||
) -> list[str]:
|
||||
for command_line_argument in command_line_arguments:
|
||||
if command_line_argument.startswith("--"):
|
||||
raise SystemExit(f"Unknown option: {command_line_argument}")
|
||||
return command_line_arguments
|
||||
|
||||
|
||||
def _print_usage() -> None:
|
||||
try:
|
||||
connected_device_serial_numbers = list_connected_device_serial_numbers()
|
||||
except RuntimeError as exc:
|
||||
print(exc)
|
||||
return
|
||||
|
||||
if not connected_device_serial_numbers:
|
||||
print("No LibreVNA devices found. Check the USB connection and OS USB access.")
|
||||
return
|
||||
|
||||
print("Connected LibreVNA devices:")
|
||||
for connected_device_serial_number in connected_device_serial_numbers:
|
||||
print(f" {connected_device_serial_number}")
|
||||
print(f"\nUsage: {sys.argv[0]} <master_serial> [slave_serial ...]")
|
||||
|
||||
|
||||
def _install_termination_signal_handlers():
|
||||
def handle_stop_signal(received_signal_number, _current_stack_frame) -> None:
|
||||
global _ACTIVE_MEASUREMENT_CONTROLLER
|
||||
if _ACTIVE_MEASUREMENT_CONTROLLER is not None:
|
||||
try:
|
||||
print(
|
||||
f"\nSignal {received_signal_number} received, stopping devices...",
|
||||
flush=True,
|
||||
)
|
||||
_ACTIVE_MEASUREMENT_CONTROLLER.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(128 + received_signal_number)
|
||||
|
||||
previous_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
previous_sigterm_handler = signal.getsignal(signal.SIGTERM)
|
||||
signal.signal(signal.SIGINT, handle_stop_signal)
|
||||
signal.signal(signal.SIGTERM, handle_stop_signal)
|
||||
return previous_sigint_handler, previous_sigterm_handler
|
||||
|
||||
|
||||
def _restore_termination_signal_handlers(
|
||||
previous_sigint_handler,
|
||||
previous_sigterm_handler,
|
||||
) -> None:
|
||||
signal.signal(signal.SIGINT, previous_sigint_handler)
|
||||
signal.signal(signal.SIGTERM, previous_sigterm_handler)
|
||||
|
||||
|
||||
def _print_run_header(
|
||||
master_serial_number: str,
|
||||
slave_serial_numbers: list[str],
|
||||
sweep_configuration: SweepConfiguration,
|
||||
) -> None:
|
||||
print(f"Master : {master_serial_number}")
|
||||
for slave_index, slave_serial_number in enumerate(slave_serial_numbers):
|
||||
print(f"Slave {slave_index} : {slave_serial_number}")
|
||||
print(
|
||||
"Sync : hardware trigger"
|
||||
if slave_serial_numbers
|
||||
else "Sync : disabled (single device)"
|
||||
)
|
||||
print(f"Ref : {DEFAULT_REFERENCE_SOURCE} on all devices")
|
||||
print("Sweeps : continuous until Ctrl+C or the plot window is closed")
|
||||
print(
|
||||
f"Sweep : {sweep_configuration.start_hz / 1e6:.3f} MHz - "
|
||||
f"{sweep_configuration.stop_hz / 1e6:.3f} MHz, "
|
||||
f"{sweep_configuration.points} points, "
|
||||
f"IF BW {sweep_configuration.if_bandwidth} Hz"
|
||||
)
|
||||
print("Running live sweep...")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global _ACTIVE_MEASUREMENT_CONTROLLER
|
||||
|
||||
device_serial_numbers = _parse_device_serial_numbers_from_command_line(
|
||||
sys.argv[1:]
|
||||
)
|
||||
if not device_serial_numbers:
|
||||
_print_usage()
|
||||
return
|
||||
|
||||
master_serial_number = device_serial_numbers[0]
|
||||
slave_serial_numbers = device_serial_numbers[1:]
|
||||
sweep_configuration = DEFAULT_SWEEP_CONFIGURATION
|
||||
force_external_reference = _force_external_reference_from_default_source()
|
||||
sweep_cycle_durations_seconds: list[float] = []
|
||||
|
||||
_print_run_header(
|
||||
master_serial_number,
|
||||
slave_serial_numbers,
|
||||
sweep_configuration,
|
||||
)
|
||||
previous_sigint_handler, previous_sigterm_handler = (
|
||||
_install_termination_signal_handlers()
|
||||
)
|
||||
|
||||
try:
|
||||
with MultiDeviceVnaController(
|
||||
master_serial_number,
|
||||
slave_serial_numbers,
|
||||
force_external_reference=force_external_reference,
|
||||
) as measurement_controller:
|
||||
_ACTIVE_MEASUREMENT_CONTROLLER = measurement_controller
|
||||
measurement_controller.configure_continuous_sweep(sweep_configuration)
|
||||
plt.ion()
|
||||
|
||||
live_plot: LiveSweepPlot | None = None
|
||||
sweep_cycle_count = 0
|
||||
print("Waiting for the first complete sweep cycle...")
|
||||
|
||||
while True:
|
||||
if live_plot is not None and not plt.fignum_exists(live_plot.figure.number):
|
||||
print("\nPlot window closed, stopping sweep.", flush=True)
|
||||
break
|
||||
|
||||
cycle_collection_start_time = time.monotonic()
|
||||
sweep_measurement_result = measurement_controller.collect_running_sweep_cycles(
|
||||
1,
|
||||
)
|
||||
cycle_duration_seconds = time.monotonic() - cycle_collection_start_time
|
||||
sweep_cycle_durations_seconds.append(cycle_duration_seconds)
|
||||
sweep_cycle_count += 1
|
||||
|
||||
if (
|
||||
live_plot is not None
|
||||
and not plt.fignum_exists(live_plot.figure.number)
|
||||
):
|
||||
print("\nPlot window closed, stopping sweep.", flush=True)
|
||||
break
|
||||
|
||||
if live_plot is None:
|
||||
live_plot = _create_live_sweep_plot(sweep_measurement_result)
|
||||
|
||||
_update_live_sweep_plot(
|
||||
live_plot,
|
||||
sweep_measurement_result,
|
||||
sweep_cycle_count=sweep_cycle_count,
|
||||
last_cycle_duration_seconds=cycle_duration_seconds,
|
||||
average_cycle_duration_seconds=float(
|
||||
np.mean(sweep_cycle_durations_seconds)
|
||||
),
|
||||
)
|
||||
finally:
|
||||
_ACTIVE_MEASUREMENT_CONTROLLER = None
|
||||
_restore_termination_signal_handlers(
|
||||
previous_sigint_handler,
|
||||
previous_sigterm_handler,
|
||||
)
|
||||
|
||||
if sweep_cycle_durations_seconds:
|
||||
average_sweep_duration_seconds = float(np.mean(sweep_cycle_durations_seconds))
|
||||
minimum_sweep_duration_seconds = float(np.min(sweep_cycle_durations_seconds))
|
||||
maximum_sweep_duration_seconds = float(np.max(sweep_cycle_durations_seconds))
|
||||
print(
|
||||
"Sweep time (steady-state): "
|
||||
f"{average_sweep_duration_seconds:.6f} s avg, "
|
||||
f"{minimum_sweep_duration_seconds:.6f}-"
|
||||
f"{maximum_sweep_duration_seconds:.6f} s "
|
||||
f"over {len(sweep_cycle_durations_seconds)} cycles"
|
||||
)
|
||||
else:
|
||||
print("Sweep time (steady-state): not enough repeated cycles to estimate")
|
||||
|
||||
print("Sweep stopped.")
|
||||
|
||||
|
||||
def _create_live_sweep_plot(
|
||||
sweep_measurement_result: SweepMeasurementResult,
|
||||
) -> LiveSweepPlot:
|
||||
frequencies_mhz = sweep_measurement_result.frequencies_hz / 1e6
|
||||
s_parameter_names = sorted(sweep_measurement_result.s_parameters)
|
||||
figure_height_inches = 3.5 + 2.5 * len(s_parameter_names)
|
||||
figure, axes = plt.subplots(
|
||||
1 + len(s_parameter_names),
|
||||
1,
|
||||
figsize=(10, figure_height_inches),
|
||||
sharex=True,
|
||||
)
|
||||
axes = np.atleast_1d(axes)
|
||||
magnitude_axis = axes[0]
|
||||
phase_axes = list(axes[1:])
|
||||
magnitude_lines: dict[str, Line2D] = {}
|
||||
phase_lines: dict[str, Line2D] = {}
|
||||
|
||||
for s_parameter_name in s_parameter_names:
|
||||
magnitude_line, = magnitude_axis.plot(
|
||||
frequencies_mhz,
|
||||
np.zeros_like(frequencies_mhz),
|
||||
label=s_parameter_name,
|
||||
linewidth=0.9,
|
||||
)
|
||||
magnitude_lines[s_parameter_name] = magnitude_line
|
||||
|
||||
magnitude_axis.set_title("Multi-port S-parameters (forward, Port 1 stimulus)")
|
||||
magnitude_axis.set_ylabel("Magnitude (dB)")
|
||||
magnitude_axis.set_ylim(-25, 0)
|
||||
magnitude_axis.grid(True, alpha=0.4)
|
||||
magnitude_axis.legend(loc="lower left")
|
||||
|
||||
for phase_axis, s_parameter_name in zip(phase_axes, s_parameter_names):
|
||||
phase_line, = phase_axis.plot(
|
||||
frequencies_mhz,
|
||||
np.zeros_like(frequencies_mhz),
|
||||
label=s_parameter_name,
|
||||
linewidth=0.9,
|
||||
)
|
||||
phase_lines[s_parameter_name] = phase_line
|
||||
phase_axis.set_ylabel("Phase (deg)")
|
||||
phase_axis.set_title(f"{s_parameter_name} phase")
|
||||
phase_axis.set_ylim(-180, 180)
|
||||
phase_axis.grid(True, alpha=0.4)
|
||||
phase_axis.legend(loc="lower left")
|
||||
|
||||
phase_axes[-1].set_xlabel("Frequency (MHz)")
|
||||
|
||||
status_text = figure.suptitle("")
|
||||
figure.tight_layout(rect=(0, 0, 1, 0.97))
|
||||
if figure.canvas.manager is not None:
|
||||
figure.canvas.manager.set_window_title("LibreVNA Live Sweep")
|
||||
plt.show(block=False)
|
||||
|
||||
return LiveSweepPlot(
|
||||
figure=figure,
|
||||
status_text=status_text,
|
||||
magnitude_axis=magnitude_axis,
|
||||
phase_axes=phase_axes,
|
||||
magnitude_lines=magnitude_lines,
|
||||
phase_lines=phase_lines,
|
||||
)
|
||||
|
||||
|
||||
def _update_live_sweep_plot(
|
||||
live_plot: LiveSweepPlot,
|
||||
sweep_measurement_result: SweepMeasurementResult,
|
||||
sweep_cycle_count: int,
|
||||
last_cycle_duration_seconds: float,
|
||||
average_cycle_duration_seconds: float,
|
||||
) -> None:
|
||||
for s_parameter_name, complex_trace in sweep_measurement_result.s_parameters.items():
|
||||
magnitude_db = 20 * np.log10(np.abs(complex_trace) + 1e-15)
|
||||
phase_degrees = np.rad2deg(np.angle(complex_trace))
|
||||
live_plot.magnitude_lines[s_parameter_name].set_ydata(magnitude_db)
|
||||
live_plot.phase_lines[s_parameter_name].set_ydata(phase_degrees)
|
||||
|
||||
live_plot.status_text.set_text(
|
||||
"Cycle "
|
||||
f"{sweep_cycle_count} | "
|
||||
f"Last {last_cycle_duration_seconds:.3f} s | "
|
||||
f"Avg {average_cycle_duration_seconds:.3f} s"
|
||||
)
|
||||
live_plot.figure.canvas.draw_idle()
|
||||
live_plot.figure.canvas.flush_events()
|
||||
plt.pause(0.001)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,9 +4,13 @@
|
||||
Проект лежит целиком — не патч, а полное дерево с уже внесёнными доработками.
|
||||
|
||||
> **Исходники опережают бинарники.** В дерево внесено переключение стимула
|
||||
> внутри частотной точки (см. [`../../per_point_switching.md`](../../per_point_switching.md)),
|
||||
> а лежащие рядом `.vnafw`, `extracted/` и `build*/` — это ПРЕДЫДУЩАЯ прошивка,
|
||||
> собранная до этой доработки и сейчас залитая в приборы. Поэтому:
|
||||
> внутри частотной точки (см. [`../../per_point_switching.md`](../../per_point_switching.md)).
|
||||
> Битстрим и готовый образ с доработкой уже собраны:
|
||||
> `FPGA/VNA/top_master_per_point_switching.bin` и
|
||||
> `../combined_per_point_switching.vnafw`. **На железе они ещё не проверены.**
|
||||
> А `combined.vnafw`, `extracted/` и `build/` — это ПРЕДЫДУЩАЯ прошивка,
|
||||
> собранная до доработки и сейчас залитая в приборы, то есть путь отката.
|
||||
> Поэтому:
|
||||
>
|
||||
> * `./build.sh` без аргументов по-прежнему даёт побайтовое совпадение — он
|
||||
> только переупаковывает готовые `.bin`, не трогая исходники;
|
||||
@@ -87,9 +91,18 @@ tail -c +341737 combined.vnafw | head -c 85004 > VNA_embedded.bin # STM32
|
||||
|
||||
Аппаратный триггер и external-LO slave — 13 файлов (не 12: в списке не значился
|
||||
`PC_Application/LibreVNA-GUI/Traces/trace.cpp`, правка в GUI, к прошивкам
|
||||
отношения не имеющая). Переключение стимула внутри точки добавило ещё 5
|
||||
изменённых файлов и один новый. Всё остальное дерево совпадает с апстримом байт
|
||||
в байт.
|
||||
отношения не имеющая). Переключение стимула внутри точки добавило ещё **7
|
||||
изменённых файлов и 2 новых** (`git diff --stat d2338ff HEAD -- reproduce/LibreVNA/`):
|
||||
`FPGA/VNA/{Sweep,SPIConfig,top}.vhd`, `FPGA/VNA/VNA.xise`,
|
||||
`Application/Drivers/FPGA/FPGA.{hpp,cpp}`, `Application/VNA.cpp`; новые —
|
||||
`FPGA/VNA/Test_Sweep.vhd` и `FPGA/VNA/top_master_per_point_switching.bin`.
|
||||
|
||||
Ещё из дерева удалён каталог `Python/` — прототип драйвера multi-device, который
|
||||
переехал в `python_app/hardware_full/librevna_multi_device_driver/`. К сборке
|
||||
прошивок он отношения не имел; разбор протокола из него сохранён как
|
||||
[`../../librevna_protocol.md`](../../librevna_protocol.md).
|
||||
|
||||
Всё остальное дерево совпадает с апстримом байт в байт.
|
||||
|
||||
### STM32 — поддержка аппаратного триггера (это и есть съём без USB)
|
||||
|
||||
@@ -138,8 +151,28 @@ tail -c +341737 combined.vnafw | head -c 85004 > VNA_embedded.bin # STM32
|
||||
|
||||
### Переключение стимула внутри частотной точки
|
||||
|
||||
Внесено позже остального, бинарников рядом ещё нет (см. блок в начале файла).
|
||||
Полное описание — [`../../per_point_switching.md`](../../per_point_switching.md).
|
||||
Внесено позже остального. В репозитории лежит и битстрим
|
||||
(`FPGA/VNA/top_master_per_point_switching.bin`), и готовый образ
|
||||
`../combined_per_point_switching.vnafw` — оба с хешами в `SHA256SUMS`.
|
||||
|
||||
Образ собран **локально на Linux тулчейном `arm-none-eabi-gcc 14.2.1`**, а не
|
||||
CubeIDE 1.17.0 / gcc 12.3.1, которым собран текущий рабочий `combined.vnafw`.
|
||||
Логика та же (исходники те же), но байты MCU-секции отличаются: 88 660 против
|
||||
85 044. Пересобрать этим же способом:
|
||||
|
||||
```sh
|
||||
cd LibreVNA/Software/VNA_embedded && make BUILD_DIR=build_per_point_switching
|
||||
cd .. && python3 AssembleFirmware.py \
|
||||
--bitstream FPGA/VNA/top_master_per_point_switching.bin \
|
||||
--mcu Software/VNA_embedded/build_per_point_switching/VNA_embedded.bin \
|
||||
--output ../combined_per_point_switching.vnafw
|
||||
```
|
||||
|
||||
`BUILD_DIR` обязательно отдельный: в `build/` лежит эталонный
|
||||
`VNA_embedded.bin`, извлечённый из рабочей прошивки, его перезаписывать нельзя.
|
||||
|
||||
Полное описание — [`../../per_point_switching.md`](../../per_point_switching.md),
|
||||
базовый протокол — [`../../librevna_protocol.md`](../../librevna_protocol.md).
|
||||
|
||||
**`FPGA/VNA/Sweep.vhd`** — порт излучает не в одной стадии, а в БЛОКЕ подряд
|
||||
идущих: младшие `SUBSTAGE_BITS` бит счётчика стадий стали положением выходного
|
||||
@@ -158,6 +191,13 @@ tail -c +341737 combined.vnafw | head -c 85004 > VNA_embedded.bin # STM32
|
||||
железа нет порта 3, поле свободно, и его переиспользование не меняет формат
|
||||
пакета.
|
||||
|
||||
**`FPGA/VNA/VNA.xise`** — одна строка: `Starting Placer Cost Table (1-100) Map
|
||||
spartan6` переведена с `1` на `2`. **Не сбрасывать.** Ограничение
|
||||
`MCU_MOSI OFFSET hold` маргинально уже в стоке (запас +0.458 нс), поэтому любое
|
||||
изменение нетлиста может его опрокинуть; с cost table 2 получается Timing Score 0
|
||||
и запас +0.642 нс, то есть больше стокового. Подробности и цифры ресурсов — в
|
||||
[`../../per_point_switching.md`](../../per_point_switching.md).
|
||||
|
||||
**При `SUBSTAGE_BITS = 0` логика бит в бит совпадает с апстримом** — это
|
||||
проверено симуляцией.
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# готовые прошивки
|
||||
00431d534c2b2ccb05069399158b7a7f58f70d6955f3885ae322191683fc86d4 *../combined.vnafw
|
||||
adf7b7c7bbc0f85af94876eef4ef6c774e434862977f716099df9c71599f14e6 *../combined_external_lo_slave.vnafw
|
||||
# переключение стимула внутри частотной точки (собрано локально, gcc 14.2.1)
|
||||
e8c2b2c743ad3aed6f89ecb59f5d13c585203c88378b2d5f770c3e0a88463f17 *../combined_per_point_switching.vnafw
|
||||
# секции, извлечённые из них
|
||||
c0e69bbcadd9fac20de10acbe1875ceba39cc7ce35a7c64ed54f44150560ef75 *extracted/master_fpga.bin
|
||||
ba729999be7849187704cf4ccbc4ae4a8877b0860eee1b44225c0585537bb091 *extracted/master_stm32.bin
|
||||
@@ -16,5 +18,5 @@ e184eb92bef1c698849511845fa8d7543287ed1e696c2b461520533112913113 *LibreVNA/FPGA/
|
||||
ba729999be7849187704cf4ccbc4ae4a8877b0860eee1b44225c0585537bb091 *LibreVNA/Software/VNA_embedded/build/VNA_embedded.bin
|
||||
56654a0344556d24ecca10f119e0226b8297a2bb89d1aa4217280854e9ffa7bd *LibreVNA/Software/VNA_embedded/build_external_lo_slave/VNA_embedded.bin
|
||||
# комплект
|
||||
a3c88be8abbff988650d7fe20f4a93745d46c347c9d0936aa5b43a1ff29dc70b *README.md
|
||||
9e0b675e8c5b31c5ee46e15380c498540ef06f0267f8595821e0d0c470c4ac92 *README.md
|
||||
be686c2000094acb7703727a3eb9249f7ae8a3517dc7d8630fe97f394aadb849 *build.sh
|
||||
|
||||
@@ -7,17 +7,26 @@ device_firmware/per_point_switching.md в репозитории.
|
||||
|
||||
ЧТО ЗДЕСЬ
|
||||
---------
|
||||
files\ 7 изменённых файлов дерева LibreVNA
|
||||
make_files.sh собирает files\ и SHA256_new.txt из дерева репозитория
|
||||
files\ 8 файлов дерева LibreVNA (НЕ в git, генерируется)
|
||||
install.cmd бэкап оригиналов + установка + сборка MCU
|
||||
build_mcu.cmd только сборка MCU (если правки уже стоят)
|
||||
backup_original\ появится после install.cmd — оригиналы для отката
|
||||
SHA256_new.txt хеши установленных файлов для проверки
|
||||
SHA256_new.txt хеши устанавливаемых файлов для проверки
|
||||
|
||||
Изменены: FPGA/VNA/{Sweep,SPIConfig,top}.vhd,
|
||||
FPGA/VNA/VNA.xise <- закреплён Starting Placer Cost Table = 2,
|
||||
без него сборка даёт нарушение тайминга
|
||||
Software/VNA_embedded/Application/VNA.cpp,
|
||||
Software/VNA_embedded/Application/Drivers/FPGA/FPGA.{cpp,hpp}
|
||||
Добавлен: FPGA/VNA/Test_Sweep.vhd (тестбенч, в проект ISE не входит)
|
||||
|
||||
ПЕРЕД ОТПРАВКОЙ НА WINDOWS запустить на машине с репозиторием:
|
||||
bash install_windows/make_files.sh
|
||||
Каталог files\ не версионируется намеренно: когда он лежал в git, после
|
||||
переделки Sweep.vhd и закрепления cost table комплект молча остался со старыми
|
||||
версиями. Генерация из дерева исключает это.
|
||||
|
||||
ВАЖНО: ПЕРЕПРОШИВАТЬ НАДО ТОЛЬКО МАСТЕР
|
||||
---------------------------------------
|
||||
Слейвы остаются как есть. Хост выставляет им обе порт-стадии в 7, а стоковое
|
||||
@@ -40,10 +49,14 @@ device_firmware/per_point_switching.md в репозитории.
|
||||
/home/ise/shared/LibreVNA/FPGA).
|
||||
2. В ISE открыть VNA.xise — проект МАСТЕРА. Не VNA_external_lo_slave.xise.
|
||||
3. Process -> Generate Programming File.
|
||||
4. Забрать top.bin СРАЗУ и переименовать в top_master.bin: оба проекта пишут в
|
||||
один и тот же файл, и следующая сборка его затрёт. Именно так был потерян
|
||||
исходный мастер-битстрим.
|
||||
5. Положить top_master.bin в ...\reproduce\LibreVNA\FPGA\VNA\
|
||||
4. Забрать top.bin СРАЗУ и переименовать в top_master_per_point_switching.bin:
|
||||
оба проекта пишут в один и тот же top.bin, и следующая сборка его затрёт.
|
||||
Именно так был потерян исходный мастер-битстрим.
|
||||
5. Положить его в ...\reproduce\LibreVNA\FPGA\VNA\
|
||||
|
||||
ВНИМАНИЕ: имя обязательно новое. Лежащий там top_master.bin — копия
|
||||
НЕВОСПРОИЗВОДИМОГО битстрима текущей рабочей прошивки, перезаписывать его
|
||||
нельзя ни при каких обстоятельствах.
|
||||
|
||||
Битстрим невоспроизводим побайтово (place & route недетерминирован плюс
|
||||
timestamp внутри), так что новый файл будет отличаться от старого при любом
|
||||
@@ -52,7 +65,7 @@ timestamp внутри), так что новый файл будет отлич
|
||||
ШАГ 3. Упаковка .vnafw
|
||||
----------------------
|
||||
cd %USERPROFILE%\Documents\radar_system\device_firmware\reproduce\LibreVNA
|
||||
python AssembleFirmware.py --bitstream FPGA\VNA\top_master.bin ^
|
||||
python AssembleFirmware.py --bitstream FPGA\VNA\top_master_per_point_switching.bin ^
|
||||
--mcu Software\VNA_embedded\build\VNA_embedded.bin ^
|
||||
--output ..\combined_per_point_switching.vnafw
|
||||
|
||||
@@ -67,7 +80,8 @@ LibreVNA-GUI: Device -> Firmware Update, скормить .vnafw целиком.
|
||||
---------------
|
||||
1. Сначала СТАРЫЙ режим (stimulus_switch_positions: 1) — должен работать ровно
|
||||
как раньше. Только потом включать переключение.
|
||||
2. Осциллографом на контроллере ключа: PB2 — положение ключа, PB5 — импульс на
|
||||
2. Осциллографом на контроллере ключа: PB2 и PB6 — положение ключей (порт 1 и
|
||||
порт 2, меняются одновременно), PB5 — импульс на
|
||||
каждое исправление чётности (в норме молчит). На одной частоте должно быть
|
||||
четыре стадии и два периода A B A B.
|
||||
3. По USB CDC контроллера, остановив развёртку: parity corrections : 0 и
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
3cf838aeda126e38397eb97ab10519cc50c725f8c05090890f05837d759c63f2 ./FPGA/VNA/Sweep.vhd
|
||||
7b59127f46dc16d3a22de4cff02a8499b51465ba29bd5f0be491149022828a8a ./FPGA/VNA/SPIConfig.vhd
|
||||
624a5324ae13fd87285e59edea301ffb5e7eab8f2fed99418759fca52401b3b0 ./FPGA/VNA/Sweep.vhd
|
||||
02cc0cf7fe2e253a253843578ab6195f1300702afb00c4ca43b3dbc8df118183 ./FPGA/VNA/Test_Sweep.vhd
|
||||
e70da2b4216862a44b12e4cbde951442ccf1a4b919103fcdd4010613b43cfe46 ./FPGA/VNA/top.vhd
|
||||
a7cc99e473ab2ec704c55405e3f1cbcd75315fe14f65ee1dfc7b8dce11eb4ca4 ./FPGA/VNA/VNA.xise
|
||||
5f8b7eeeb495b6c74e9b5137a6e8bd7dc0200fd084a7f0eaf67f2ee0a7c6b5a3 ./FPGA/VNA/Test_Sweep.vhd
|
||||
fb116ff5aaf4161052e77837ac78a6824833e87372a6ce5dd142e0240fc17379 ./Software/VNA_embedded/Application/VNA.cpp
|
||||
e12a9be4eee75d8b39b4b21fa7788f310d87ec22ae068ae7e82de9df91555db4 ./Software/VNA_embedded/Application/Drivers/FPGA/FPGA.cpp
|
||||
db3ad784c1b1e912c7986f98616585b4fe5d2e0c995d9c66268f756ec4d176ad ./Software/VNA_embedded/Application/Drivers/FPGA/FPGA.hpp
|
||||
fb116ff5aaf4161052e77837ac78a6824833e87372a6ce5dd142e0240fc17379 ./Software/VNA_embedded/Application/VNA.cpp
|
||||
|
||||
@@ -23,14 +23,22 @@ if not exist "%APPDIR%" mkdir "%APPDIR%"
|
||||
copy /y "%ROOT%\FPGA\VNA\Sweep.vhd" "%FPGADIR%\" >nul || exit /b 1
|
||||
copy /y "%ROOT%\FPGA\VNA\SPIConfig.vhd" "%FPGADIR%\" >nul || exit /b 1
|
||||
copy /y "%ROOT%\FPGA\VNA\top.vhd" "%FPGADIR%\" >nul || exit /b 1
|
||||
rem VNA.xise тоже правится: в нём закреплён Starting Placer Cost Table = 2,
|
||||
rem без которого сборка даёт нарушение тайминга по MCU_MOSI hold.
|
||||
copy /y "%ROOT%\FPGA\VNA\VNA.xise" "%FPGADIR%\" >nul || exit /b 1
|
||||
copy /y "%ROOT%\Software\VNA_embedded\Application\Drivers\FPGA\FPGA.cpp" "%DRVDIR%\" >nul || exit /b 1
|
||||
copy /y "%ROOT%\Software\VNA_embedded\Application\Drivers\FPGA\FPGA.hpp" "%DRVDIR%\" >nul || exit /b 1
|
||||
copy /y "%ROOT%\Software\VNA_embedded\Application\VNA.cpp" "%APPDIR%\" >nul || exit /b 1
|
||||
echo 6 файлов сохранено в backup_original\
|
||||
echo 7 файлов сохранено в backup_original\
|
||||
|
||||
echo === Установка новых файлов ===
|
||||
if not exist "%STAGE%" (
|
||||
echo ОШИБКА: нет каталога files\ — соберите его из дерева репозитория:
|
||||
echo bash install_windows/make_files.sh
|
||||
exit /b 1
|
||||
)
|
||||
xcopy /s /y "%STAGE%\*" "%ROOT%\" >nul || exit /b 1
|
||||
echo 7 файлов установлено (6 изменённых + Test_Sweep.vhd)
|
||||
echo 8 файлов установлено (7 изменённых + Test_Sweep.vhd)
|
||||
|
||||
echo === Сборка прошивки MCU мастера ===
|
||||
call "%~dp0build_mcu.cmd"
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Собирает payload install_windows/files/ из дерева LibreVNA этого репозитория
|
||||
# и переписывает SHA256_new.txt.
|
||||
#
|
||||
# Зачем скрипт, а не файлы в git: раньше files/ и SHA256_new.txt заполнялись
|
||||
# руками, и после переделки Sweep.vhd (маска вместо сдвига) и закрепления cost
|
||||
# table в VNA.xise комплект молча остался со СТАРЫМИ версиями. Установка таким
|
||||
# комплектом вернула бы битстрим с нарушением тайминга. Генерация из дерева
|
||||
# делает этот класс расхождений невозможным.
|
||||
#
|
||||
# files/ в .gitignore: это копия того, что и так лежит в дереве.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TREE="$(cd "$KIT_DIR/../LibreVNA" && pwd)"
|
||||
STAGE="$KIT_DIR/files"
|
||||
|
||||
# Пути относительно корня дерева LibreVNA. Порядок — как в README.txt.
|
||||
FILES=(
|
||||
FPGA/VNA/Sweep.vhd
|
||||
FPGA/VNA/SPIConfig.vhd
|
||||
FPGA/VNA/top.vhd
|
||||
FPGA/VNA/VNA.xise
|
||||
FPGA/VNA/Test_Sweep.vhd
|
||||
Software/VNA_embedded/Application/VNA.cpp
|
||||
Software/VNA_embedded/Application/Drivers/FPGA/FPGA.cpp
|
||||
Software/VNA_embedded/Application/Drivers/FPGA/FPGA.hpp
|
||||
)
|
||||
|
||||
rm -rf "$STAGE"
|
||||
for relative_path in "${FILES[@]}"; do
|
||||
source_file="$TREE/$relative_path"
|
||||
[[ -f "$source_file" ]] || { echo "нет файла: $source_file" >&2; exit 1; }
|
||||
mkdir -p "$STAGE/$(dirname "$relative_path")"
|
||||
cp "$source_file" "$STAGE/$relative_path"
|
||||
done
|
||||
|
||||
( cd "$STAGE" && sha256sum $(printf './%s\n' "${FILES[@]}") ) > "$KIT_DIR/SHA256_new.txt"
|
||||
|
||||
echo "files/: ${#FILES[@]} файл(ов), SHA256_new.txt обновлён"
|
||||
echo "Проверка на Windows: certutil -hashfile <файл> SHA256"
|
||||
@@ -12,6 +12,10 @@
|
||||
когерентности: все 16 комбинаций снимаются в пределах ~0.4 мс на одной частоте
|
||||
вместо разброса в сотни миллисекунд между развёртками.
|
||||
|
||||
Базовое устройство протокола, поверх которого всё это работает (кадр, типы
|
||||
пакетов, `SweepSettings`, разбор `VNADatapoint`) — в
|
||||
[librevna_protocol.md](librevna_protocol.md).
|
||||
|
||||
## Раскладка стадий
|
||||
|
||||
| Стадия | Порт мастера | Ключ | Выход |
|
||||
@@ -33,8 +37,10 @@
|
||||
|
||||
## Контракт по проводу
|
||||
|
||||
Хост уже отправляет это (см. `build_sweep_settings_payload`), прошивка должна
|
||||
считать. Слово стадий в `SweepSettings` (16 бит, смещение 25 в payload):
|
||||
Хост отправляет это в `build_sweep_settings_payload`, прошивка читает — обе
|
||||
стороны реализованы. Слово стадий в `SweepSettings` (16 бит, смещение 25 в
|
||||
payload из 31 байта; раскладку payload целиком см.
|
||||
[librevna_protocol.md](librevna_protocol.md)):
|
||||
|
||||
| Биты | Поле | Значение в этом режиме |
|
||||
|---|---|---|
|
||||
@@ -155,6 +161,70 @@ ghdl -a --std=93 Sweep.vhd Test_Sweep.vhd && ghdl -r --std=93 Test_Sweep
|
||||
|
||||
VHDL держите в чистом ASCII: GHDL отвергает не-ASCII символы даже в комментариях.
|
||||
|
||||
### Проверено на железе 2026-09-11
|
||||
|
||||
Мастер прошит `combined_per_point_switching.vnafw`, контроллер врезан в петлю,
|
||||
ВЧ-ключ **не** подключён. Скрипт
|
||||
[`per_point_switching_check.py`](../python_app/scripts/per_point_switching_check.py)
|
||||
снял 8 коллекций по 16 трасс:
|
||||
|
||||
```text
|
||||
выход 0: -71.5 дБ выходы 0 и 1: расхождение 0.5 дБ
|
||||
выход 1: -72.0 дБ выходы 2 и 3: расхождение 1.3 дБ
|
||||
выход 2: -70.6 дБ
|
||||
выход 3: -71.9 дБ
|
||||
```
|
||||
|
||||
Что именно этим доказано:
|
||||
|
||||
- **Битстрим в приборе доработанный.** Со стоковым сравнением «стадия равна
|
||||
порт-стадии» излучали бы только стадии 0 и 2, а выходы 1 и 3 провалились бы в
|
||||
шум. Здесь все четыре на одном уровне.
|
||||
- **Петля триггера с контроллером замкнута.** При `syncMode = 3` мастер семплирует
|
||||
по вернувшемуся триггеру, так что без исправного зеркалирования в STM32 данных
|
||||
не было бы вовсе.
|
||||
- **Выходы попарно совпадают**, как и должно быть при отключённом ключе: обе его
|
||||
позиции — физически один путь.
|
||||
|
||||
Считать фронты для этой проверки **бессмысленно**: поле `stages` стоковое, сток
|
||||
тоже отработает четыре стадии на точку, просто без стимула в 1 и 3.
|
||||
|
||||
Межстадийная пауза, измеренная контроллером, — **62 мкс**, ровно `dwell_us = 2`
|
||||
плюс 60 мкс собственного успокоения PLL прибора. Но распознавание границ точек по
|
||||
длине паузы работает плохо, коэффициент 1.5 оказался мал — подробности и цифры в
|
||||
[README контроллера](switch_controller/README.md). На переключение это не влияет.
|
||||
|
||||
### Проверено с подключённым ВЧ-ключом, 30–200 МГц
|
||||
|
||||
Профиль `run_config_examples/run_config_librevna_multi_per_point_lowband.example.json`:
|
||||
30–200 МГц, IFBW 50 кГц, 201 точка. Диапазон выбран выше
|
||||
`HW::BandSwitchFrequency = 25 МГц` — ниже неё прибор уходит на путь Si5351 через
|
||||
I2C, останавливает развёртку и даёт нерегулярные длинные паузы. Верхняя граница
|
||||
200 МГц лежит в полосе осциллографа, поэтому стимул видно напрямую.
|
||||
|
||||
Медиана `|S21|` по трём повторам, дБ:
|
||||
|
||||
| вход | выход 0 (A) | выход 1 (B) | выход 2 (A) | выход 3 (B) |
|
||||
|---|---|---|---|---|
|
||||
| 0 | **−5.4** | −77.1 | −69.8 | −78.0 |
|
||||
| 1 | −69.2 | −68.9 | −69.0 | −69.1 |
|
||||
| 2 | −77.1 | −77.7 | −76.4 | −77.9 |
|
||||
| 3 | −73.6 | −73.8 | −72.8 | −73.5 |
|
||||
|
||||
Подключён был один тракт — вход 0 в выход 0. Разница между двумя положениями
|
||||
одного и того же порта мастера (выходы 0 и 1) составила **72 дБ**: в положении A
|
||||
сигнал идёт в подключённый кабель, в положении B — в открытый порт. Это и есть
|
||||
прямое доказательство, что ключ переключается по стадиям. Повторяемость
|
||||
0.0–1.8 дБ, на сигнальном тракте 0.0 дБ.
|
||||
|
||||
**Ограничение по IFBW.** При `if_bandwidth_hz = 1000` прибор воспроизводимо
|
||||
падает: `LIBUSB_ERROR_IO`, затем устройства отваливаются с шины
|
||||
(`LIBUSB_ERROR_NO_DEVICE`). Ограничения по числу отсчётов тут нет — 800 отсчётов
|
||||
против предела 8191, — так что причина, вероятно, в возросшей скважности
|
||||
источника (при 1 кГц он включён 94% времени против 24% при 50 кГц) и, значит, в
|
||||
среднем токе. IFBW 5, 10, 20 и 50 кГц собираются нормально. **Штатным считать
|
||||
50 кГц**, ниже 5 кГц не опускаться без проверки питания приборов.
|
||||
|
||||
## Прошивка STM32 LibreVNA
|
||||
|
||||
Сделано, собирается. Для самих стадий менять было нечего: `stageCnt` уже считается
|
||||
@@ -198,9 +268,10 @@ STM32 прибора в Hi-Z на обеих линиях (`GPIO_MODE_INPUT`, `N
|
||||
границе точки. Спад означает «оцифровка закончилась», дальше идёт окно
|
||||
успокоения следующей стадии (~62 мкс при `dwell_us = 2`), которого ключу
|
||||
хватает с огромным запасом.
|
||||
3. **Одна нога на оба ключа.** Нужное положение зависит только от номера стадии,
|
||||
а не от того, какой порт излучает, поэтому ключи на порту 1 и порту 2 ходят
|
||||
синхронно.
|
||||
3. **По ноге на каждый ключ, но ходят они синхронно.** Нужное положение зависит
|
||||
только от номера стадии, а не от того, какой порт излучает. PB2 управляет
|
||||
ключом выходного порта 1, PB6 — порта 2; оба вывода на порту B, поэтому
|
||||
переключаются одной записью в `BSRR`, то есть строго одновременно.
|
||||
|
||||
Страховка от сбоя чётности: один потерянный фронт инвертирует чередование
|
||||
навсегда и молча меняет антенны местами. Контроллер распознаёт границу точки по
|
||||
@@ -273,11 +344,17 @@ make BINPATH="<путь к arm-none-eabi из CubeIDE>/tools/bin" -j4
|
||||
|
||||
### 3. Упаковка .vnafw
|
||||
|
||||
Готовый образ уже лежит в репозитории:
|
||||
`multi_libreVNA_firmware/combined_per_point_switching.vnafw`, хеш в
|
||||
`reproduce/SHA256SUMS`. Пересобрать:
|
||||
|
||||
```bash
|
||||
cd multi_libreVNA_firmware/reproduce/LibreVNA
|
||||
cd multi_libreVNA_firmware/reproduce/LibreVNA/Software/VNA_embedded
|
||||
make BUILD_DIR=build_per_point_switching # НЕ в build/: там эталон
|
||||
cd ../..
|
||||
python3 AssembleFirmware.py \
|
||||
--bitstream FPGA/VNA/top_master_per_point_switching.bin \
|
||||
--mcu Software/VNA_embedded/build/VNA_embedded.bin \
|
||||
--mcu Software/VNA_embedded/build_per_point_switching/VNA_embedded.bin \
|
||||
--output ../combined_per_point_switching.vnafw
|
||||
```
|
||||
|
||||
@@ -300,8 +377,9 @@ GUI сам разложит битстрим и прошивку MCU.
|
||||
(`stimulus_switch_positions: 1`) работает как раньше — включать переключение.
|
||||
Порядок проверки на стенде:
|
||||
|
||||
1. Осциллографом на PB2 и PB5 контроллера: на одной частоте четыре стадии и два
|
||||
периода `A B A B`, PB5 молчит.
|
||||
1. Осциллографом на PB2, PB6 и PB5 контроллера: на одной частоте четыре стадии и
|
||||
два периода `A B A B`, PB2 и PB6 меняются одновременно и в одну сторону, PB5
|
||||
молчит.
|
||||
2. По USB CDC контроллера (развёртку остановить): `parity corrections : 0` и
|
||||
`stages per point : 4`.
|
||||
3. Сверить трейсы: выходы 0 и 1 должны совпасть с тем, что даёт старый режим —
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Проверка на железе, что переключение стимула внутри частотной точки работает.
|
||||
|
||||
Зачем отдельная проверка. Число триггерных фронтов на частотную точку НЕ
|
||||
различает стоковый битстрим от доработанного: поле ``stages`` в ``SweepSettings``
|
||||
стоковое, и сток тоже отрабатывает четыре стадии на точку. Отличие в том, КАКИЕ
|
||||
стадии излучают. Сток сравнивает счётчик стадий с порт-стадией точно, поэтому при
|
||||
``port1Stage = 0`` и ``port2Stage = 2`` излучают только стадии 0 и 2, а 1 и 3
|
||||
остаются без стимула. Доработанный битстрим сравнивает по маске, и порт излучает
|
||||
во всём блоке: 0 и 1 — порт 1, 2 и 3 — порт 2.
|
||||
|
||||
Отсюда две проверки по данным:
|
||||
|
||||
1. **Во всех четырёх выходах есть сигнал.** Если выходы 1 и 3 на уровне шума —
|
||||
в приборе стоковый битстрим, заливка FPGA не удалась.
|
||||
2. **Выходы попарно совпадают, когда ВЧ-ключ не подключён.** Тогда обе позиции
|
||||
ключа — это физически один и тот же путь, поэтому 0 ≈ 1 и 2 ≈ 3. Расхождение
|
||||
при отключённом ключе означает, что по стадиям разъезжается что-то ещё.
|
||||
|
||||
Заодно проверяется и контроллер в петле триггера: при ``syncMode = 3`` мастер
|
||||
семплирует по вернувшемуся триггеру, так что без исправного зеркалирования в STM32
|
||||
развёртка просто не пойдёт и данных не будет вовсе.
|
||||
|
||||
Запуск (на машине, к которой подключены приборы):
|
||||
|
||||
.venv/bin/python -m python_app.scripts.per_point_switching_check \\
|
||||
--config run_config.json --collections 3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.librevna_multi_device_driver.models import (
|
||||
build_output_channels,
|
||||
normalize_master_stimulus_ports,
|
||||
)
|
||||
from python_app.hardware_full.matrix_radar_service import create_matrix_radar_service
|
||||
from python_app.models.dataset_model import SweepCollection
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Порты мастера, которые подставляет контроллер по умолчанию в
|
||||
# `configure_continuous_sweep`. Держится здесь, чтобы раскладка выходов в этой
|
||||
# проверке считалась ровно так же, как в драйвере.
|
||||
MASTER_STIMULUS_PORTS = (1, 2)
|
||||
|
||||
# Ниже этого уровня считаем, что стимула в стадии не было. Порог грубый нарочно:
|
||||
# разница между «излучает» и «не излучает» — это десятки дБ, а не единицы.
|
||||
NOISE_FLOOR_DBFS = -80.0
|
||||
# Насколько выходы одной пары могут расходиться при ОТКЛЮЧЁННОМ ключе.
|
||||
PAIR_TOLERANCE_DB = 6.0
|
||||
|
||||
|
||||
def magnitude_db(values: np.ndarray) -> float:
|
||||
"""Медианный модуль трассы в дБ; медиана устойчива к отдельным выбросам."""
|
||||
finite = values[np.isfinite(values)]
|
||||
if finite.size == 0:
|
||||
return float("-inf")
|
||||
magnitude = float(np.median(np.abs(finite)))
|
||||
if magnitude <= 0.0:
|
||||
return float("-inf")
|
||||
return 20.0 * float(np.log10(magnitude))
|
||||
|
||||
|
||||
def build_switch_position_pairs(config: RunConfigModel) -> list[tuple[int, int]]:
|
||||
"""Пары выходов «один порт мастера в двух положениях ключа».
|
||||
|
||||
Берётся из той же модели, что и раскладка стадий, поэтому не разъедется с
|
||||
драйвером. Соседние индексы сравнивать НЕЛЬЗЯ: при двух портах и двух
|
||||
положениях выход 0 — порт 1 в A, выход 1 — порт 2 в A, выход 2 — порт 1 в B,
|
||||
выход 3 — порт 2 в B. Сравнивать надо (0, 2) и (1, 3).
|
||||
"""
|
||||
# Те же порты мастера, что подставляет контроллер по умолчанию
|
||||
# (`configure_continuous_sweep(master_stimulus_ports=(1, 2))`).
|
||||
channels = build_output_channels(
|
||||
normalize_master_stimulus_ports(MASTER_STIMULUS_PORTS),
|
||||
config.matrix_stimulus_switch_positions,
|
||||
)
|
||||
by_port: dict[int, list[int]] = {}
|
||||
for channel in channels:
|
||||
by_port.setdefault(channel.stimulus_port, []).append(channel.index)
|
||||
pairs: list[tuple[int, int]] = []
|
||||
for indices in by_port.values():
|
||||
ordered = sorted(indices)
|
||||
pairs.extend(zip(ordered, ordered[1:]))
|
||||
return pairs
|
||||
|
||||
|
||||
def summarize(collection: SweepCollection) -> dict[int, list[float]]:
|
||||
"""Сгруппировать медианные уровни s21 по номеру выхода."""
|
||||
by_output: dict[int, list[float]] = {}
|
||||
for trace in collection.traces:
|
||||
by_output.setdefault(trace.combo.output, []).append(magnitude_db(trace.s21))
|
||||
return by_output
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--config", required=True, type=Path, help="Путь к run_config.json")
|
||||
parser.add_argument("--collections", type=int, default=3, help="Сколько коллекций снять (по умолчанию 3)")
|
||||
parser.add_argument(
|
||||
"--switch-connected",
|
||||
action="store_true",
|
||||
help="ВЧ-ключ подключён: выходы одной пары теперь ДОЛЖНЫ различаться "
|
||||
"(это разные антенны), поэтому расхождение — признак работы, а не ошибки",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
config = RunConfigModel.load_from_path(args.config)
|
||||
config.apply_device_model_constraints()
|
||||
|
||||
if not config.is_matrix_radar:
|
||||
raise SystemExit("нужен матричный radar.model (librevna_multi)")
|
||||
substages = config.matrix_stimulus_switch_positions
|
||||
outputs = config.output_switch.positions
|
||||
print(f"конфиг: stimulus_switch_positions={substages}, выходов={outputs}, "
|
||||
f"входов={config.input_switch.positions}, combos={len(config.combos)}, "
|
||||
f"driver_mode={config.radar.driver_mode}")
|
||||
if substages <= 1:
|
||||
raise SystemExit("stimulus_switch_positions = 1: проверять нечего, режим выключен")
|
||||
|
||||
radar = create_matrix_radar_service(config)
|
||||
radar.open()
|
||||
try:
|
||||
radar.configure(config.radar.sweep)
|
||||
per_output: dict[int, list[float]] = {}
|
||||
for index in range(max(1, args.collections)):
|
||||
collection = radar.acquire_collection(collection_id=index + 1)
|
||||
print(f" коллекция {index + 1}: трасс {len(collection.traces)}")
|
||||
for output, levels in summarize(collection).items():
|
||||
per_output.setdefault(output, []).extend(levels)
|
||||
finally:
|
||||
radar.close()
|
||||
|
||||
if not per_output:
|
||||
raise SystemExit("данных нет — развёртка не прошла (проверьте петлю триггера и контроллер)")
|
||||
|
||||
print("\nмедианный уровень s21 по выходам:")
|
||||
medians: dict[int, float] = {}
|
||||
for output in sorted(per_output):
|
||||
medians[output] = statistics.median(per_output[output])
|
||||
print(f" выход {output}: {medians[output]:+7.1f} дБ (трасс {len(per_output[output])})")
|
||||
|
||||
verdict_ok = True
|
||||
|
||||
silent = [o for o, db in medians.items() if db < NOISE_FLOOR_DBFS]
|
||||
print("\n1) стимул во всех стадиях:")
|
||||
if silent:
|
||||
verdict_ok = False
|
||||
print(f" ПРОВАЛ: без сигнала выходы {silent}. Похоже, в мастере СТОКОВЫЙ битстрим:")
|
||||
print(" он излучает только в стадиях, точно равных порт-стадии (0 и 2).")
|
||||
else:
|
||||
print(" ок: сигнал есть во всех выходах, значит порт излучает во всём блоке стадий")
|
||||
|
||||
if args.switch_connected:
|
||||
print("2) различие выходов внутри пары (ключ ПОДКЛЮЧЁН, ждём разные антенны):")
|
||||
else:
|
||||
print("2) попарное совпадение выходов (ключ не подключён, ждём один и тот же путь):")
|
||||
# Пары берутся из самой модели, а не из арифметики по индексам: сравнивать
|
||||
# надо ОДИН порт мастера в двух положениях ключа, а соседние индексы — это
|
||||
# РАЗНЫЕ порты в одном положении (выход 0 = порт 1 A, выход 1 = порт 2 A,
|
||||
# выход 2 = порт 1 B, выход 3 = порт 2 B).
|
||||
pairs = build_switch_position_pairs(config)
|
||||
for low, high in pairs:
|
||||
if low not in medians or high not in medians:
|
||||
continue
|
||||
delta = abs(medians[low] - medians[high])
|
||||
differs = delta > PAIR_TOLERANCE_DB
|
||||
# Ключ подключён -> позиции ведут на РАЗНЫЕ антенны, совпадение значило бы,
|
||||
# что ключ не движется. Ключ не подключён -> путь один, ждём совпадение.
|
||||
good = differs if args.switch_connected else not differs
|
||||
if not good:
|
||||
verdict_ok = False
|
||||
if args.switch_connected:
|
||||
status = "ок, ключ переключается" if good else "СОВПАДАЮТ — ключ, похоже, не движется"
|
||||
else:
|
||||
status = "ок" if good else "РАСХОЖДЕНИЕ"
|
||||
print(f" выходы {low} и {high}: расхождение {delta:4.1f} дБ — {status}")
|
||||
|
||||
print("\nИТОГ:", "переключение стимула внутри точки работает" if verdict_ok
|
||||
else "проверка НЕ пройдена, см. выше")
|
||||
return 0 if verdict_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+40
-200
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"radar": {
|
||||
"model": "librevna_multi",
|
||||
"serial": "",
|
||||
"serial": "206830985532",
|
||||
"remote_host": "127.0.0.1",
|
||||
"remote_port": 50209,
|
||||
"driver_mode": "mock",
|
||||
@@ -9,13 +9,14 @@
|
||||
"visa_library": "",
|
||||
"multi_device": {
|
||||
"slave_serials": [
|
||||
"20A1307D5532",
|
||||
"2072306C5532"
|
||||
"206930965532",
|
||||
"206930A15532"
|
||||
],
|
||||
"force_external_reference": false,
|
||||
"force_external_reference": true,
|
||||
"recovery_attempts": 3,
|
||||
"output_switch_positions": 1,
|
||||
"input_switch_positions": 3
|
||||
"input_switch_positions": 1,
|
||||
"stimulus_switch_positions": 2
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
@@ -62,7 +63,8 @@
|
||||
"max_value": 35.0,
|
||||
"step": 0.1,
|
||||
"time_step": 20,
|
||||
"delay_time": 3
|
||||
"delay_time": 3,
|
||||
"temp_tolerance_c": 0.03
|
||||
}
|
||||
},
|
||||
"sweep": {
|
||||
@@ -70,6 +72,7 @@
|
||||
"stop_hz": 6000000000.0,
|
||||
"if_bandwidth_hz": 50000.0,
|
||||
"stimulus_power_dbm": -10.0,
|
||||
"dwell_us": 2,
|
||||
"points": 201
|
||||
}
|
||||
},
|
||||
@@ -79,7 +82,7 @@
|
||||
"driver_mode": "mock",
|
||||
"driver": "h7992",
|
||||
"radar_port": 1,
|
||||
"positions": 2,
|
||||
"positions": 4,
|
||||
"default_position": 0,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin_a": 17,
|
||||
@@ -91,7 +94,7 @@
|
||||
"driver_mode": "mock",
|
||||
"driver": "h7992",
|
||||
"radar_port": 2,
|
||||
"positions": 12,
|
||||
"positions": 4,
|
||||
"default_position": 0,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin_a": 22,
|
||||
@@ -115,7 +118,7 @@
|
||||
"settling_ms": 0,
|
||||
"idle_sleep_ms": 2,
|
||||
"continuous": true,
|
||||
"processing_live_config_path": "/home/guriy/Documents/radar_system/python_app/runtime/processing_live.json",
|
||||
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||
"locator_server": {
|
||||
"device_id": 3,
|
||||
"protocol_version": 1,
|
||||
@@ -142,38 +145,6 @@
|
||||
"input": 3,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 4,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 5,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 6,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 7,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 8,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 9,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 10,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 11,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 1
|
||||
@@ -191,36 +162,36 @@
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 4,
|
||||
"output": 1
|
||||
"input": 0,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 5,
|
||||
"output": 1
|
||||
"input": 1,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 6,
|
||||
"output": 1
|
||||
"input": 2,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 7,
|
||||
"output": 1
|
||||
"input": 3,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 8,
|
||||
"output": 1
|
||||
"input": 0,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 9,
|
||||
"output": 1
|
||||
"input": 1,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 10,
|
||||
"output": 1
|
||||
"input": 2,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 11,
|
||||
"output": 1
|
||||
"input": 3,
|
||||
"output": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -276,6 +247,18 @@
|
||||
"x_m": -0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 2,
|
||||
"x_m": 0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 3,
|
||||
"x_m": -0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
}
|
||||
],
|
||||
"rx_geometry": [
|
||||
@@ -302,54 +285,6 @@
|
||||
"x_m": 0.185,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 4,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 5,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 6,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 7,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 8,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 9,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 10,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 11,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -379,100 +314,5 @@
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
}
|
||||
},
|
||||
"gui": {
|
||||
"version": 1,
|
||||
"switches": {
|
||||
"combo_mode": "text",
|
||||
"combos_text": "0:0,1:0,2:0,3:0,4:0,5:0,6:0,7:0,8:0,9:0,10:0,11:0,0:1,1:1,2:1,3:1,4:1,5:1,6:1,7:1,8:1,9:1,10:1,11:1",
|
||||
"single_input": "0",
|
||||
"single_output": "0"
|
||||
},
|
||||
"processing": {
|
||||
"selected_mode": "gpr",
|
||||
"pass_through": {
|
||||
"show_magnitude": true,
|
||||
"show_phase": false,
|
||||
"unwrap_phase": false,
|
||||
"combo_filter": "",
|
||||
"fixed_y_enabled": false,
|
||||
"y_min_db": -100.0,
|
||||
"y_max_db": 0.0
|
||||
},
|
||||
"bscan": {
|
||||
"axis": "abs",
|
||||
"cut_m": 0.0,
|
||||
"max_depth_m": 3.0,
|
||||
"gain": 1.0,
|
||||
"start_freq_mhz": 100.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"subtract_mean_ascan": false
|
||||
},
|
||||
"gpr": {
|
||||
"input_positions": "0,1,2,3",
|
||||
"output_positions": "0,1",
|
||||
"min_depth_m": 2.0,
|
||||
"max_depth_m": 14.0,
|
||||
"range_comp_power": 0.1,
|
||||
"object_min_frac": 0.7,
|
||||
"score_mode": "combined",
|
||||
"motion_mode": "int_minus",
|
||||
"look_angle_deg": 0.0,
|
||||
"direction_sign": 1.0,
|
||||
"speed_m_s": 0.0,
|
||||
"ignore_socket_speed_enabled": false,
|
||||
"max_detected_objects_to_draw": 5,
|
||||
"draw_top_m_objects": 2,
|
||||
"object_approach_min_frames": 3,
|
||||
"start_freq_mhz": 3000.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"background_subtract_enabled": true,
|
||||
"background_mean_count": 10,
|
||||
"remove_sidelobe_objects_enabled": false,
|
||||
"imaging_plane_y_m": 0.0,
|
||||
"render_mode": "heatmap",
|
||||
"visible_x_min_m": -2.0,
|
||||
"visible_x_max_m": 2.0,
|
||||
"visible_z_min_m": 0.0,
|
||||
"visible_z_max_m": 14.0
|
||||
},
|
||||
"legacy_gpr": {
|
||||
"mode": "point",
|
||||
"input_positions": "0,1,2,3",
|
||||
"output_positions": "0,1",
|
||||
"min_depth_m": 2.0,
|
||||
"max_depth_m": 14.0,
|
||||
"comp_power": 0.2,
|
||||
"start_freq_mhz": 3000.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"speed_m_s": 0.0,
|
||||
"ignore_socket_speed_enabled": false,
|
||||
"look_angle_deg": 0.0,
|
||||
"apply_freq_phase_correction": true,
|
||||
"reference_mode": "frame_center",
|
||||
"snr_thresh": 4.5,
|
||||
"snr_comp_max": 25.0,
|
||||
"background_subtract_enabled": true,
|
||||
"background_mean_count": 10,
|
||||
"render_mode": "heatmap",
|
||||
"min_visible_pair_count": 1,
|
||||
"visible_x_min_m": -2.0,
|
||||
"visible_x_max_m": 2.0,
|
||||
"visible_z_min_m": 0.0,
|
||||
"visible_z_max_m": 14.0
|
||||
}
|
||||
},
|
||||
"data_actions": {
|
||||
"save_count": 10,
|
||||
"save_path": "python_app/data/snapshots",
|
||||
"save_name": "snapshot_simulator",
|
||||
"record_count": 100
|
||||
},
|
||||
"preprocess_dialog": {
|
||||
"set_name": "smoke_cal",
|
||||
"radar_config_dir": "",
|
||||
"use_all_radar_configs": false,
|
||||
"median_sweep_count": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
{
|
||||
"radar": {
|
||||
"model": "librevna_multi",
|
||||
"serial": "206830985532",
|
||||
"remote_host": "127.0.0.1",
|
||||
"remote_port": 50209,
|
||||
"driver_mode": "native",
|
||||
"mock_signal_hz": 5000000.0,
|
||||
"visa_library": "",
|
||||
"multi_device": {
|
||||
"slave_serials": [
|
||||
"206930965532",
|
||||
"206930A15532"
|
||||
],
|
||||
"force_external_reference": true,
|
||||
"recovery_attempts": 3,
|
||||
"output_switch_positions": 1,
|
||||
"input_switch_positions": 1,
|
||||
"stimulus_switch_positions": 2
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": 0.0,
|
||||
"freq0_hz": 2046000000.0,
|
||||
"phase1_rad": 300.0,
|
||||
"freq1_hz": 5612000000.0
|
||||
},
|
||||
"band": {
|
||||
"start_hz": 2100000000.0,
|
||||
"stop_hz": 5500000000.0,
|
||||
"points": 2048
|
||||
}
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
"mode": "manual",
|
||||
"pi_coeff1_p": 2560,
|
||||
"pi_coeff1_i": 128,
|
||||
"pi_coeff2_p": 2560,
|
||||
"pi_coeff2_i": 128,
|
||||
"manual": {
|
||||
"temp1": 25.0,
|
||||
"temp2": 25.0,
|
||||
"current1": 30.0,
|
||||
"current2": 30.0
|
||||
},
|
||||
"variation": {
|
||||
"variation_type": "CHANGE_CURRENT_LD1",
|
||||
"static_temp1": 25.0,
|
||||
"static_temp2": 25.0,
|
||||
"static_current1": 30.0,
|
||||
"static_current2": 30.0,
|
||||
"min_value": 30.0,
|
||||
"max_value": 35.0,
|
||||
"step": 0.1,
|
||||
"time_step": 20,
|
||||
"delay_time": 3,
|
||||
"temp_tolerance_c": 0.03
|
||||
}
|
||||
},
|
||||
"sweep": {
|
||||
"start_hz": 1000000.0,
|
||||
"stop_hz": 6000000000.0,
|
||||
"if_bandwidth_hz": 50000.0,
|
||||
"stimulus_power_dbm": -10.0,
|
||||
"dwell_us": 2,
|
||||
"points": 201
|
||||
}
|
||||
},
|
||||
"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": "h7992",
|
||||
"radar_port": 2,
|
||||
"positions": 4,
|
||||
"default_position": 0,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin_a": 22,
|
||||
"pin_b": 23,
|
||||
"invert_logic": false
|
||||
}
|
||||
},
|
||||
"control_button": {
|
||||
"enabled": false,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin": -1,
|
||||
"active_low": true,
|
||||
"bias": "",
|
||||
"debounce_ms": 50,
|
||||
"action": "capture_tmp_reference"
|
||||
},
|
||||
"logging": {
|
||||
"level": "debug"
|
||||
},
|
||||
"run": {
|
||||
"settling_ms": 0,
|
||||
"idle_sleep_ms": 2,
|
||||
"continuous": true,
|
||||
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||
"locator_server": {
|
||||
"device_id": 3,
|
||||
"protocol_version": 1,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8888,
|
||||
"max_payload_bytes": 65536,
|
||||
"client_queue_size": 32,
|
||||
"logger_name": "locator_runtime"
|
||||
},
|
||||
"combos": [
|
||||
{
|
||||
"input": 0,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"preprocess": {
|
||||
"s21": {
|
||||
"calibration": {
|
||||
"set_name": "smoke_cal3",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"reference": {
|
||||
"set_name": "smoke_cal3",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
"s11": {
|
||||
"calibration": {
|
||||
"open": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"short": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"load": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
"reference": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
"notch": {
|
||||
"enabled": true,
|
||||
"bands_hz": [],
|
||||
"taper_width_hz": 40000000.0,
|
||||
"taper_type": "cosine"
|
||||
}
|
||||
},
|
||||
"gpr": {
|
||||
"relative_permittivity": 1.0,
|
||||
"tx_geometry": [
|
||||
{
|
||||
"output_pos": 0,
|
||||
"x_m": 0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 1,
|
||||
"x_m": -0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 2,
|
||||
"x_m": 0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 3,
|
||||
"x_m": -0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
}
|
||||
],
|
||||
"rx_geometry": [
|
||||
{
|
||||
"input_pos": 0,
|
||||
"x_m": -0.18,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 1,
|
||||
"x_m": 0.485,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 2,
|
||||
"x_m": -0.49,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 3,
|
||||
"x_m": 0.185,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"rings": {
|
||||
"raw": {
|
||||
"name": "/radar_raw_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"raw_tap": {
|
||||
"name": "/radar_raw_tap_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"preprocessed": {
|
||||
"name": "/radar_preprocessed_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"preprocessed_tap": {
|
||||
"name": "/radar_preprocessed_tap_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"results": {
|
||||
"name": "/radar_results_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
{
|
||||
"radar": {
|
||||
"model": "librevna_multi",
|
||||
"serial": "206830985532",
|
||||
"remote_host": "127.0.0.1",
|
||||
"remote_port": 50209,
|
||||
"driver_mode": "native",
|
||||
"mock_signal_hz": 5000000.0,
|
||||
"visa_library": "",
|
||||
"multi_device": {
|
||||
"slave_serials": [
|
||||
"206930965532",
|
||||
"206930A15532"
|
||||
],
|
||||
"force_external_reference": true,
|
||||
"recovery_attempts": 3,
|
||||
"output_switch_positions": 1,
|
||||
"input_switch_positions": 1,
|
||||
"stimulus_switch_positions": 2
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": 0.0,
|
||||
"freq0_hz": 2046000000.0,
|
||||
"phase1_rad": 300.0,
|
||||
"freq1_hz": 5612000000.0
|
||||
},
|
||||
"band": {
|
||||
"start_hz": 2100000000.0,
|
||||
"stop_hz": 5500000000.0,
|
||||
"points": 2048
|
||||
}
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
"mode": "manual",
|
||||
"pi_coeff1_p": 2560,
|
||||
"pi_coeff1_i": 128,
|
||||
"pi_coeff2_p": 2560,
|
||||
"pi_coeff2_i": 128,
|
||||
"manual": {
|
||||
"temp1": 25.0,
|
||||
"temp2": 25.0,
|
||||
"current1": 30.0,
|
||||
"current2": 30.0
|
||||
},
|
||||
"variation": {
|
||||
"variation_type": "CHANGE_CURRENT_LD1",
|
||||
"static_temp1": 25.0,
|
||||
"static_temp2": 25.0,
|
||||
"static_current1": 30.0,
|
||||
"static_current2": 30.0,
|
||||
"min_value": 30.0,
|
||||
"max_value": 35.0,
|
||||
"step": 0.1,
|
||||
"time_step": 20,
|
||||
"delay_time": 3,
|
||||
"temp_tolerance_c": 0.03
|
||||
}
|
||||
},
|
||||
"sweep": {
|
||||
"start_hz": 30000000.0,
|
||||
"stop_hz": 200000000.0,
|
||||
"if_bandwidth_hz": 50000.0,
|
||||
"stimulus_power_dbm": -10.0,
|
||||
"dwell_us": 2,
|
||||
"points": 201
|
||||
}
|
||||
},
|
||||
"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": "h7992",
|
||||
"radar_port": 2,
|
||||
"positions": 4,
|
||||
"default_position": 0,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin_a": 22,
|
||||
"pin_b": 23,
|
||||
"invert_logic": false
|
||||
}
|
||||
},
|
||||
"control_button": {
|
||||
"enabled": false,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin": -1,
|
||||
"active_low": true,
|
||||
"bias": "",
|
||||
"debounce_ms": 50,
|
||||
"action": "capture_tmp_reference"
|
||||
},
|
||||
"logging": {
|
||||
"level": "debug"
|
||||
},
|
||||
"run": {
|
||||
"settling_ms": 0,
|
||||
"idle_sleep_ms": 2,
|
||||
"continuous": true,
|
||||
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||
"locator_server": {
|
||||
"device_id": 3,
|
||||
"protocol_version": 1,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8888,
|
||||
"max_payload_bytes": 65536,
|
||||
"client_queue_size": 32,
|
||||
"logger_name": "locator_runtime"
|
||||
},
|
||||
"combos": [
|
||||
{
|
||||
"input": 0,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 2
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 3
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"preprocess": {
|
||||
"s21": {
|
||||
"calibration": {
|
||||
"set_name": "smoke_cal3",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"reference": {
|
||||
"set_name": "smoke_cal3",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
"s11": {
|
||||
"calibration": {
|
||||
"open": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"short": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"load": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
"reference": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
"notch": {
|
||||
"enabled": true,
|
||||
"bands_hz": [],
|
||||
"taper_width_hz": 40000000.0,
|
||||
"taper_type": "cosine"
|
||||
}
|
||||
},
|
||||
"gpr": {
|
||||
"relative_permittivity": 1.0,
|
||||
"tx_geometry": [
|
||||
{
|
||||
"output_pos": 0,
|
||||
"x_m": 0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 1,
|
||||
"x_m": -0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 2,
|
||||
"x_m": 0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 3,
|
||||
"x_m": -0.905,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
}
|
||||
],
|
||||
"rx_geometry": [
|
||||
{
|
||||
"input_pos": 0,
|
||||
"x_m": -0.18,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 1,
|
||||
"x_m": 0.485,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 2,
|
||||
"x_m": -0.49,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 3,
|
||||
"x_m": 0.185,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"rings": {
|
||||
"raw": {
|
||||
"name": "/radar_raw_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"raw_tap": {
|
||||
"name": "/radar_raw_tap_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"preprocessed": {
|
||||
"name": "/radar_preprocessed_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"preprocessed_tap": {
|
||||
"name": "/radar_preprocessed_tap_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"results": {
|
||||
"name": "/radar_results_simulator",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user