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:
Ayzen
2026-09-15 14:41:54 +03:00
parent 3f6cfc9108
commit 62c0266a6b
30 changed files with 1392 additions and 2863 deletions
+24
View File
@@ -166,6 +166,25 @@ ENV/
env.bak/ env.bak/
venv.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 # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
@@ -226,7 +245,12 @@ __marimo__/
python_app/runtime python_app/runtime
SHARE_INTERNET_TO_PI.md SHARE_INTERNET_TO_PI.md
# Локальная обвязка Claude Code: инструкции, хуки, скилы, конфиги инструментов.
# В репозиторий не идёт.
CLAUDE.md CLAUDE.md
.claude/
.graphifyignore
pyrightconfig.json
/docs/ /docs/
test_end_2/ test_end_2/
+4 -2
View File
@@ -69,9 +69,11 @@ controls plus a pixel-identical live plot through an embedded web UI.
## Requirements ## 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. - 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 ## Build and run
@@ -13,12 +13,18 @@ counterpart to the C++ `sweep_orchestrator` used by LibreVNA devices.
| Path | Contents | | Path | Contents |
|------|----------| |------|----------|
| `src/` | Collector implementation (`main.cpp`) and the PTY / capture-file writers. | | `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. | | `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 The sources started as a verbatim import of the original standalone tool, but are
standalone tool and are kept byte-for-byte identical — the wire format and **no longer identical to it**: the collector now drives the RF switches itself
acquisition behaviour are unchanged; only their home moved into this repo. (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 ## Build
+267
View File
@@ -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`, выходные каналы, ограничения стадий |
@@ -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",
]
@@ -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`.
@@ -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
@@ -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 [])]
@@ -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()
@@ -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
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

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)), > внутри частотной точки (см. [`../../per_point_switching.md`](../../per_point_switching.md)).
> а лежащие рядом `.vnafw`, `extracted/` и `build*/` — это ПРЕДЫДУЩАЯ прошивка, > Битстрим и готовый образ с доработкой уже собраны:
> собранная до этой доработки и сейчас залитая в приборы. Поэтому: > `FPGA/VNA/top_master_per_point_switching.bin` и
> `../combined_per_point_switching.vnafw`. **На железе они ещё не проверены.**
> А `combined.vnafw`, `extracted/` и `build/` — это ПРЕДЫДУЩАЯ прошивка,
> собранная до доработки и сейчас залитая в приборы, то есть путь отката.
> Поэтому:
> >
> * `./build.sh` без аргументов по-прежнему даёт побайтовое совпадение — он > * `./build.sh` без аргументов по-прежнему даёт побайтовое совпадение — он
> только переупаковывает готовые `.bin`, не трогая исходники; > только переупаковывает готовые `.bin`, не трогая исходники;
@@ -87,9 +91,18 @@ tail -c +341737 combined.vnafw | head -c 85004 > VNA_embedded.bin # STM32
Аппаратный триггер и external-LO slave — 13 файлов (не 12: в списке не значился Аппаратный триггер и external-LO slave — 13 файлов (не 12: в списке не значился
`PC_Application/LibreVNA-GUI/Traces/trace.cpp`, правка в GUI, к прошивкам `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) ### 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`** — порт излучает не в одной стадии, а в БЛОКЕ подряд **`FPGA/VNA/Sweep.vhd`** — порт излучает не в одной стадии, а в БЛОКЕ подряд
идущих: младшие `SUBSTAGE_BITS` бит счётчика стадий стали положением выходного идущих: младшие `SUBSTAGE_BITS` бит счётчика стадий стали положением выходного
@@ -158,6 +191,13 @@ tail -c +341737 combined.vnafw | head -c 85004 > VNA_embedded.bin # STM32
железа нет порта 3, поле свободно, и его переиспользование не меняет формат железа нет порта 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` логика бит в бит совпадает с апстримом** — это **При `SUBSTAGE_BITS = 0` логика бит в бит совпадает с апстримом** — это
проверено симуляцией. проверено симуляцией.
@@ -3,6 +3,8 @@
# готовые прошивки # готовые прошивки
00431d534c2b2ccb05069399158b7a7f58f70d6955f3885ae322191683fc86d4 *../combined.vnafw 00431d534c2b2ccb05069399158b7a7f58f70d6955f3885ae322191683fc86d4 *../combined.vnafw
adf7b7c7bbc0f85af94876eef4ef6c774e434862977f716099df9c71599f14e6 *../combined_external_lo_slave.vnafw adf7b7c7bbc0f85af94876eef4ef6c774e434862977f716099df9c71599f14e6 *../combined_external_lo_slave.vnafw
# переключение стимула внутри частотной точки (собрано локально, gcc 14.2.1)
e8c2b2c743ad3aed6f89ecb59f5d13c585203c88378b2d5f770c3e0a88463f17 *../combined_per_point_switching.vnafw
# секции, извлечённые из них # секции, извлечённые из них
c0e69bbcadd9fac20de10acbe1875ceba39cc7ce35a7c64ed54f44150560ef75 *extracted/master_fpga.bin c0e69bbcadd9fac20de10acbe1875ceba39cc7ce35a7c64ed54f44150560ef75 *extracted/master_fpga.bin
ba729999be7849187704cf4ccbc4ae4a8877b0860eee1b44225c0585537bb091 *extracted/master_stm32.bin ba729999be7849187704cf4ccbc4ae4a8877b0860eee1b44225c0585537bb091 *extracted/master_stm32.bin
@@ -16,5 +18,5 @@ e184eb92bef1c698849511845fa8d7543287ed1e696c2b461520533112913113 *LibreVNA/FPGA/
ba729999be7849187704cf4ccbc4ae4a8877b0860eee1b44225c0585537bb091 *LibreVNA/Software/VNA_embedded/build/VNA_embedded.bin ba729999be7849187704cf4ccbc4ae4a8877b0860eee1b44225c0585537bb091 *LibreVNA/Software/VNA_embedded/build/VNA_embedded.bin
56654a0344556d24ecca10f119e0226b8297a2bb89d1aa4217280854e9ffa7bd *LibreVNA/Software/VNA_embedded/build_external_lo_slave/VNA_embedded.bin 56654a0344556d24ecca10f119e0226b8297a2bb89d1aa4217280854e9ffa7bd *LibreVNA/Software/VNA_embedded/build_external_lo_slave/VNA_embedded.bin
# комплект # комплект
a3c88be8abbff988650d7fe20f4a93745d46c347c9d0936aa5b43a1ff29dc70b *README.md 9e0b675e8c5b31c5ee46e15380c498540ef06f0267f8595821e0d0c470c4ac92 *README.md
be686c2000094acb7703727a3eb9249f7ae8a3517dc7d8630fe97f394aadb849 *build.sh 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 install.cmd бэкап оригиналов + установка + сборка MCU
build_mcu.cmd только сборка MCU (если правки уже стоят) build_mcu.cmd только сборка MCU (если правки уже стоят)
backup_original\ появится после install.cmd — оригиналы для отката backup_original\ появится после install.cmd — оригиналы для отката
SHA256_new.txt хеши установленных файлов для проверки SHA256_new.txt хеши устанавливаемых файлов для проверки
Изменены: FPGA/VNA/{Sweep,SPIConfig,top}.vhd, Изменены: 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/VNA.cpp,
Software/VNA_embedded/Application/Drivers/FPGA/FPGA.{cpp,hpp} Software/VNA_embedded/Application/Drivers/FPGA/FPGA.{cpp,hpp}
Добавлен: FPGA/VNA/Test_Sweep.vhd (тестбенч, в проект ISE не входит) Добавлен: FPGA/VNA/Test_Sweep.vhd (тестбенч, в проект ISE не входит)
ПЕРЕД ОТПРАВКОЙ НА WINDOWS запустить на машине с репозиторием:
bash install_windows/make_files.sh
Каталог files\ не версионируется намеренно: когда он лежал в git, после
переделки Sweep.vhd и закрепления cost table комплект молча остался со старыми
версиями. Генерация из дерева исключает это.
ВАЖНО: ПЕРЕПРОШИВАТЬ НАДО ТОЛЬКО МАСТЕР ВАЖНО: ПЕРЕПРОШИВАТЬ НАДО ТОЛЬКО МАСТЕР
--------------------------------------- ---------------------------------------
Слейвы остаются как есть. Хост выставляет им обе порт-стадии в 7, а стоковое Слейвы остаются как есть. Хост выставляет им обе порт-стадии в 7, а стоковое
@@ -40,10 +49,14 @@ device_firmware/per_point_switching.md в репозитории.
/home/ise/shared/LibreVNA/FPGA). /home/ise/shared/LibreVNA/FPGA).
2. В ISE открыть VNA.xise — проект МАСТЕРА. Не VNA_external_lo_slave.xise. 2. В ISE открыть VNA.xise — проект МАСТЕРА. Не VNA_external_lo_slave.xise.
3. Process -> Generate Programming File. 3. Process -> Generate Programming File.
4. Забрать top.bin СРАЗУ и переименовать в top_master.bin: оба проекта пишут в 4. Забрать top.bin СРАЗУ и переименовать в top_master_per_point_switching.bin:
один и тот же файл, и следующая сборка его затрёт. Именно так был потерян оба проекта пишут в один и тот же top.bin, и следующая сборка его затрёт.
исходный мастер-битстрим. Именно так был потерян исходный мастер-битстрим.
5. Положить top_master.bin в ...\reproduce\LibreVNA\FPGA\VNA\ 5. Положить его в ...\reproduce\LibreVNA\FPGA\VNA\
ВНИМАНИЕ: имя обязательно новое. Лежащий там top_master.bin — копия
НЕВОСПРОИЗВОДИМОГО битстрима текущей рабочей прошивки, перезаписывать его
нельзя ни при каких обстоятельствах.
Битстрим невоспроизводим побайтово (place & route недетерминирован плюс Битстрим невоспроизводим побайтово (place & route недетерминирован плюс
timestamp внутри), так что новый файл будет отличаться от старого при любом timestamp внутри), так что новый файл будет отличаться от старого при любом
@@ -52,7 +65,7 @@ timestamp внутри), так что новый файл будет отлич
ШАГ 3. Упаковка .vnafw ШАГ 3. Упаковка .vnafw
---------------------- ----------------------
cd %USERPROFILE%\Documents\radar_system\device_firmware\reproduce\LibreVNA 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 ^ --mcu Software\VNA_embedded\build\VNA_embedded.bin ^
--output ..\combined_per_point_switching.vnafw --output ..\combined_per_point_switching.vnafw
@@ -67,7 +80,8 @@ LibreVNA-GUI: Device -> Firmware Update, скормить .vnafw целиком.
--------------- ---------------
1. Сначала СТАРЫЙ режим (stimulus_switch_positions: 1) — должен работать ровно 1. Сначала СТАРЫЙ режим (stimulus_switch_positions: 1) — должен работать ровно
как раньше. Только потом включать переключение. как раньше. Только потом включать переключение.
2. Осциллографом на контроллере ключа: PB2 — положение ключа, PB5 — импульс на 2. Осциллографом на контроллере ключа: PB2 и PB6 — положение ключей (порт 1 и
порт 2, меняются одновременно), PB5 — импульс на
каждое исправление чётности (в норме молчит). На одной частоте должно быть каждое исправление чётности (в норме молчит). На одной частоте должно быть
четыре стадии и два периода A B A B. четыре стадии и два периода A B A B.
3. По USB CDC контроллера, остановив развёртку: parity corrections : 0 и 3. По USB CDC контроллера, остановив развёртку: parity corrections : 0 и
@@ -1,7 +1,8 @@
3cf838aeda126e38397eb97ab10519cc50c725f8c05090890f05837d759c63f2 ./FPGA/VNA/Sweep.vhd
7b59127f46dc16d3a22de4cff02a8499b51465ba29bd5f0be491149022828a8a ./FPGA/VNA/SPIConfig.vhd 7b59127f46dc16d3a22de4cff02a8499b51465ba29bd5f0be491149022828a8a ./FPGA/VNA/SPIConfig.vhd
624a5324ae13fd87285e59edea301ffb5e7eab8f2fed99418759fca52401b3b0 ./FPGA/VNA/Sweep.vhd
02cc0cf7fe2e253a253843578ab6195f1300702afb00c4ca43b3dbc8df118183 ./FPGA/VNA/Test_Sweep.vhd
e70da2b4216862a44b12e4cbde951442ccf1a4b919103fcdd4010613b43cfe46 ./FPGA/VNA/top.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 e12a9be4eee75d8b39b4b21fa7788f310d87ec22ae068ae7e82de9df91555db4 ./Software/VNA_embedded/Application/Drivers/FPGA/FPGA.cpp
db3ad784c1b1e912c7986f98616585b4fe5d2e0c995d9c66268f756ec4d176ad ./Software/VNA_embedded/Application/Drivers/FPGA/FPGA.hpp 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\Sweep.vhd" "%FPGADIR%\" >nul || exit /b 1
copy /y "%ROOT%\FPGA\VNA\SPIConfig.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 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.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\Drivers\FPGA\FPGA.hpp" "%DRVDIR%\" >nul || exit /b 1
copy /y "%ROOT%\Software\VNA_embedded\Application\VNA.cpp" "%APPDIR%\" >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 === Установка новых файлов === 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 xcopy /s /y "%STAGE%\*" "%ROOT%\" >nul || exit /b 1
echo 7 файлов установлено (6 изменённых + Test_Sweep.vhd) echo 8 файлов установлено (7 изменённых + Test_Sweep.vhd)
echo === Сборка прошивки MCU мастера === echo === Сборка прошивки MCU мастера ===
call "%~dp0build_mcu.cmd" call "%~dp0build_mcu.cmd"
@@ -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"
+87 -9
View File
@@ -12,6 +12,10 @@
когерентности: все 16 комбинаций снимаются в пределах ~0.4 мс на одной частоте когерентности: все 16 комбинаций снимаются в пределах ~0.4 мс на одной частоте
вместо разброса в сотни миллисекунд между развёртками. вместо разброса в сотни миллисекунд между развёртками.
Базовое устройство протокола, поверх которого всё это работает (кадр, типы
пакетов, `SweepSettings`, разбор `VNADatapoint`) — в
[librevna_protocol.md](librevna_protocol.md).
## Раскладка стадий ## Раскладка стадий
| Стадия | Порт мастера | Ключ | Выход | | Стадия | Порт мастера | Ключ | Выход |
@@ -33,8 +37,10 @@
## Контракт по проводу ## Контракт по проводу
Хост уже отправляет это (см. `build_sweep_settings_payload`), прошивка должна Хост отправляет это в `build_sweep_settings_payload`, прошивка читает — обе
считать. Слово стадий в `SweepSettings` (16 бит, смещение 25 в 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 символы даже в комментариях. 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 ## Прошивка STM32 LibreVNA
Сделано, собирается. Для самих стадий менять было нечего: `stageCnt` уже считается Сделано, собирается. Для самих стадий менять было нечего: `stageCnt` уже считается
@@ -198,9 +268,10 @@ STM32 прибора в Hi-Z на обеих линиях (`GPIO_MODE_INPUT`, `N
границе точки. Спад означает «оцифровка закончилась», дальше идёт окно границе точки. Спад означает «оцифровка закончилась», дальше идёт окно
успокоения следующей стадии (~62 мкс при `dwell_us = 2`), которого ключу успокоения следующей стадии (~62 мкс при `dwell_us = 2`), которого ключу
хватает с огромным запасом. хватает с огромным запасом.
3. **Одна нога на оба ключа.** Нужное положение зависит только от номера стадии, 3. **По ноге на каждый ключ, но ходят они синхронно.** Нужное положение зависит
а не от того, какой порт излучает, поэтому ключи на порту 1 и порту 2 ходят только от номера стадии, а не от того, какой порт излучает. PB2 управляет
синхронно. ключом выходного порта 1, PB6 — порта 2; оба вывода на порту B, поэтому
переключаются одной записью в `BSRR`, то есть строго одновременно.
Страховка от сбоя чётности: один потерянный фронт инвертирует чередование Страховка от сбоя чётности: один потерянный фронт инвертирует чередование
навсегда и молча меняет антенны местами. Контроллер распознаёт границу точки по навсегда и молча меняет антенны местами. Контроллер распознаёт границу точки по
@@ -273,11 +344,17 @@ make BINPATH="<путь к arm-none-eabi из CubeIDE>/tools/bin" -j4
### 3. Упаковка .vnafw ### 3. Упаковка .vnafw
Готовый образ уже лежит в репозитории:
`multi_libreVNA_firmware/combined_per_point_switching.vnafw`, хеш в
`reproduce/SHA256SUMS`. Пересобрать:
```bash ```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 \ python3 AssembleFirmware.py \
--bitstream FPGA/VNA/top_master_per_point_switching.bin \ --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 --output ../combined_per_point_switching.vnafw
``` ```
@@ -300,8 +377,9 @@ GUI сам разложит битстрим и прошивку MCU.
(`stimulus_switch_positions: 1`) работает как раньше — включать переключение. (`stimulus_switch_positions: 1`) работает как раньше — включать переключение.
Порядок проверки на стенде: Порядок проверки на стенде:
1. Осциллографом на PB2 и PB5 контроллера: на одной частоте четыре стадии и два 1. Осциллографом на PB2, PB6 и PB5 контроллера: на одной частоте четыре стадии и
периода `A B A B`, PB5 молчит. два периода `A B A B`, PB2 и PB6 меняются одновременно и в одну сторону, PB5
молчит.
2. По USB CDC контроллера (развёртку остановить): `parity corrections : 0` и 2. По USB CDC контроллера (развёртку остановить): `parity corrections : 0` и
`stages per point : 4`. `stages per point : 4`.
3. Сверить трейсы: выходы 0 и 1 должны совпасть с тем, что даёт старый режим — 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
View File
@@ -1,7 +1,7 @@
{ {
"radar": { "radar": {
"model": "librevna_multi", "model": "librevna_multi",
"serial": "", "serial": "206830985532",
"remote_host": "127.0.0.1", "remote_host": "127.0.0.1",
"remote_port": 50209, "remote_port": 50209,
"driver_mode": "mock", "driver_mode": "mock",
@@ -9,13 +9,14 @@
"visa_library": "", "visa_library": "",
"multi_device": { "multi_device": {
"slave_serials": [ "slave_serials": [
"20A1307D5532", "206930965532",
"2072306C5532" "206930A15532"
], ],
"force_external_reference": false, "force_external_reference": true,
"recovery_attempts": 3, "recovery_attempts": 3,
"output_switch_positions": 1, "output_switch_positions": 1,
"input_switch_positions": 3 "input_switch_positions": 1,
"stimulus_switch_positions": 2
}, },
"kamil_adc": { "kamil_adc": {
"project_dir": "", "project_dir": "",
@@ -62,7 +63,8 @@
"max_value": 35.0, "max_value": 35.0,
"step": 0.1, "step": 0.1,
"time_step": 20, "time_step": 20,
"delay_time": 3 "delay_time": 3,
"temp_tolerance_c": 0.03
} }
}, },
"sweep": { "sweep": {
@@ -70,6 +72,7 @@
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
"if_bandwidth_hz": 50000.0, "if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0, "stimulus_power_dbm": -10.0,
"dwell_us": 2,
"points": 201 "points": 201
} }
}, },
@@ -79,7 +82,7 @@
"driver_mode": "mock", "driver_mode": "mock",
"driver": "h7992", "driver": "h7992",
"radar_port": 1, "radar_port": 1,
"positions": 2, "positions": 4,
"default_position": 0, "default_position": 0,
"gpio_chip": "/dev/gpiochip0", "gpio_chip": "/dev/gpiochip0",
"pin_a": 17, "pin_a": 17,
@@ -91,7 +94,7 @@
"driver_mode": "mock", "driver_mode": "mock",
"driver": "h7992", "driver": "h7992",
"radar_port": 2, "radar_port": 2,
"positions": 12, "positions": 4,
"default_position": 0, "default_position": 0,
"gpio_chip": "/dev/gpiochip0", "gpio_chip": "/dev/gpiochip0",
"pin_a": 22, "pin_a": 22,
@@ -115,7 +118,7 @@
"settling_ms": 0, "settling_ms": 0,
"idle_sleep_ms": 2, "idle_sleep_ms": 2,
"continuous": true, "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": { "locator_server": {
"device_id": 3, "device_id": 3,
"protocol_version": 1, "protocol_version": 1,
@@ -142,38 +145,6 @@
"input": 3, "input": 3,
"output": 0 "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, "input": 0,
"output": 1 "output": 1
@@ -191,36 +162,36 @@
"output": 1 "output": 1
}, },
{ {
"input": 4, "input": 0,
"output": 1 "output": 2
}, },
{ {
"input": 5, "input": 1,
"output": 1 "output": 2
}, },
{ {
"input": 6, "input": 2,
"output": 1 "output": 2
}, },
{ {
"input": 7, "input": 3,
"output": 1 "output": 2
}, },
{ {
"input": 8, "input": 0,
"output": 1 "output": 3
}, },
{ {
"input": 9, "input": 1,
"output": 1 "output": 3
}, },
{ {
"input": 10, "input": 2,
"output": 1 "output": 3
}, },
{ {
"input": 11, "input": 3,
"output": 1 "output": 3
} }
] ]
}, },
@@ -276,6 +247,18 @@
"x_m": -0.905, "x_m": -0.905,
"y_m": 0.0, "y_m": 0.0,
"z_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": [ "rx_geometry": [
@@ -302,54 +285,6 @@
"x_m": 0.185, "x_m": 0.185,
"y_m": 0.0, "y_m": 0.0,
"z_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, "capacity": 50,
"slot_size_bytes": 2097152 "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
}
}
}