Files
radar_system/device_firmware/per_point_switching.md
T
2026-10-06 12:32:26 +03:00

432 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Переключение стимула внутри частотной точки
Режим, в котором одна развёртка даёт матрицу 4×4 вместо 2×4: каждый порт мастера
измеряется на **каждой** частоте дважды — с двумя положениями выходного ключа.
```text
4 выхода = 2 порта мастера × 2 положения ключа
4 входа = 2 слейва × 2 порта (входной ключ не участвует)
```
Выигрыш не в скорости (стадий столько же, сколько было развёрток), а в
когерентности: все 16 комбинаций снимаются в пределах ~0.4 мс на одной частоте
вместо разброса в сотни миллисекунд между развёртками.
Базовое устройство протокола, поверх которого всё это работает (кадр, типы
пакетов, `SweepSettings`, разбор `VNADatapoint`) — в
[librevna_protocol.md](librevna_protocol.md).
## Раскладка стадий
| Стадия | Порт мастера | Ключ | Выход |
|---|---|---|---|
| 0 | 1 | A | 0 |
| 1 | 1 | B | 1 |
| 2 | 2 | A | 2 |
| 3 | 2 | B | 3 |
И стадии, и выходы нумеруются **порт-мажорно**: старшие биты счётчика стадий
выбирают порт, младший — положение ключа. **Номер выхода равен номеру стадии**,
поэтому выходы идут ровно в том порядке, в котором прибор их и меряет — а это тот
же порядок, который видно осциллографом на линии ключа при развешивании антенн.
> **Смена нумерации 2026-09-17.** До этой даты выходы нумеровались
> позиция-мажорно: выходы 0 и 1 сохраняли смысл из стокового режима, но средние
> два оказывались переставлены относительно физического порядка — слоты 0,1,2,3
> попадали на выходы 0,2,1,3. Физический порядок подтверждён замером. Данные,
> снятые до смены, **несопоставимы** с новыми: выходы 1 и 2 поменялись смыслом,
> поэтому калибровки и референсы надо переснять. Выход 1 теперь означает
> «порт 1, положение B», а не «порт 2, положение A».
Из этого следует главное свойство: **положение ключа — это младший бит номера
стадии**, а период 2 нацело делит 4 стадии. Значит через границу точки чередование
продолжается без разрыва, и контроллеру достаточно инвертировать ногу на каждый
цикл триггера — абсолютный счётчик стадий не нужен, нужна только правильная
чётность, а её контроллер восстанавливает на каждой точке по длине паузы.
Приложение сохраняет только прохождение от мастера к приёмникам слейвов.
Отражения мастера не собираются. Канал хранится как физическая пара
`(input, output)`: входы 0 и 1 — порты первого слейва, 2 и 3 — второго;
выход — номер стадии из таблицы.
## Контракт по проводу
Хост отправляет это в `build_sweep_settings_payload`, прошивка читает — обе
стороны реализованы. Слово стадий в `SweepSettings` (16 бит, смещение 25 в
payload из 31 байта; раскладку payload целиком см.
[librevna_protocol.md](librevna_protocol.md)):
| Биты | Поле | Значение в этом режиме |
|---|---|---|
| 0..2 | `stages` | 3 (то есть 4 стадии) |
| 3..5 | `port1Stage` | 0 — **первая стадия блока** порта 1 |
| 6..8 | `port2Stage` | 2 — первая стадия блока порта 2 |
| 9..11 | `port3Stage` → ширина под-стадии | `log2(положений ключа)` = 1 |
| 12..14 | `port4Stage` | не используется |
| 15 | `unused2` | не используется |
Порт-стадия — это **первая стадия блока** порта, а не его индекс. Без под-стадий
блок вырождается в одну стадию, и значение совпадает с индексом, то есть со
стоковой раскладкой.
Поле `port3Stage` переиспользовано под ширину блока под-стадий: двухпортовая
прошивка его игнорирует, поэтому формат пакета не меняется. **Ноль там —
стоковая раскладка**, так что хост с `stimulus_switch_positions = 1` остаётся
полностью совместим с немодифицированным прибором.
Слейвам обе порт-стадии выставляются в **7** — зарезервированное значение
«порт не излучает». Стоковый приём «одна за последней стадией» здесь не работает:
при 4 стадиях старшие биты счётчика принимают только значения 0 и 1, свободного
значения не остаётся, и слейв начал бы излучать.
## FPGA мастера
Сделано и собрано в
[`multi_libreVNA_firmware/reproduce/LibreVNA/FPGA/VNA/`](multi_libreVNA_firmware/reproduce/LibreVNA/FPGA/VNA/).
### `Sweep.vhd`
Порт включался по точному совпадению, поэтому излучал ровно в одной стадии, и во
второй под-стадии зондирующего сигнала просто не было. Сравнение вынесено в
функцию `port_excites`: младшие `SUBSTAGE_BITS` бит счётчика стадий — положение
ключа, и они **маскируются** из сравнения, а старшие биты выбирают порт.
```vhdl
if port_stage = PORT_STAGE_DISABLED then -- "111", порт не излучает
return false;
end if;
return (std_logic_vector(stage) and compare_mask) = (port_stage and compare_mask);
```
Маска декодируется из `SUBSTAGE_BITS` тремя вентилями, причём нераспознанная
ширина даёт все единицы — чтобы неконфигурированный прибор никогда не выродился
в «каждый порт излучает в каждой стадии».
Совместимость со стоком **строгая, а не приблизительная**. При `SUBSTAGE_BITS = 0`
маска все единицы, и выражение становится буквально стоковым
`stage_cnt = port_stage`. Значит для всех существующих вызовов поведение
идентично — включая ручной режим и анализатор спектра, где `STAGES = 0`.
Значение 7 зарезервировано под «порт выключен»: так хост глушит излучение
слейвов. Стоковый хост слал «одну за последней стадией», и это тоже не совпадает
ни с одной реальной стадией, так что новый битстрим работает и со старым хостом.
### `SPIConfig.vhd` и `top.vhd`
В регистре `SweepSetup` (адрес 6) заняты биты 15:13, 12, 5:3 и 2:0 — **биты 11:6
свободны**. Заняты три из них:
```vhdl
when 6 => STAGES <= spi_buf_out(15 downto 13);
SYNC_ENABLED <= spi_buf_out(12);
SUBSTAGE_BITS <= spi_buf_out(8 downto 6); -- новое
PORT1_STAGE <= spi_buf_out(5 downto 3);
PORT2_STAGE <= spi_buf_out(2 downto 0);
```
В `top.vhd` добавлен сигнал `sweep_substage_bits` и проброшен от `SPIConfig` к
`Sweep`.
### Почему маска, а не сдвиг
Первая версия сравнивала `shift_right(stage_cnt, substage_bits)` с номером порта.
Функционально верно, но переменный сдвиг синтезируется в barrel shifter, а
вызывался он четырежды (по два на порт): **+142 LUT** при всего +3 регистрах.
Разводка поехала, и контрольная сборка показала нарушение hold на входе
`MCU_MOSI` — на шине, по которой MCU пишет регистры FPGA. Сбой там означал бы
неверную конфигурацию развёртки, то есть крайне неприятный в поиске отказ.
Маска обходится несколькими вентилями и такого эффекта не даёт.
### Cost table размещателя — обязательно
Ограничение `MCU_MOSI OFFSET = IN ... BEFORE MCU_SCK` маргинально **уже в стоке**:
запас по hold всего +0.458 нс. Поэтому любое изменение нетлиста переставляет
размещение и может его опрокинуть. Проверено прямым A/B: сток даёт Timing Score 0,
первая версия правки — 1880, версия на маске при cost table 1 — 5480.
Решается штатно, подбором cost table. В `VNA.xise` зафиксировано:
```xml
<property xil_pn:name="Starting Placer Cost Table (1-100) Map spartan6" xil_pn:value="2" .../>
```
С этим значением **Timing Score 0**, а запас по hold на `MCU_MOSI` даже больше
стокового: +0.642 нс против +0.458. Ресурсы: 2302 LUT против 2189 у стока (40%
против 38% ёмкости xc6slx9).
**Не сбрасывайте это свойство в 1.** Если после правок в VHDL тайминг снова
поедет — перебирайте cost table дальше (3, 4, 5…) и проверяйте `Timing Score` в
`top.par`; сборка каждого варианта около шести минут, синтез при этом кэширован.
### Проверка
Рядом лежит самопроверяющийся тестбенч `Test_Sweep.vhd` на четыре сценария: сток,
два положения ключа, приёмник без излучения, четыре положения. Он гоняет автомат
без триггерной петли (`SYNC_ENABLED = '0'`) и сверяет `PORT1_ACTIVE`/`PORT2_ACTIVE`
в момент старта каждой оцифровки.
```bash
ghdl -a --std=93 Sweep.vhd Test_Sweep.vhd && ghdl -r --std=93 Test_Sweep
```
Тест проверен мутацией: если выбросить маску из сравнения, сценарии с
под-стадиями падают (8 сработавших проверок), а стоковый продолжает проходить.
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
порт 1, положение A: -71.5 дБ порт 1, A против B: расхождение 0.9 дБ
порт 1, положение B: -70.6 дБ порт 2, A против B: расхождение 0.1 дБ
порт 2, положение A: -72.0 дБ
порт 2, положение B: -71.9 дБ
```
(Числа те же, что были сняты; подписаны по портам и положениям, а не по номерам
выходов, чтобы не зависеть от смены нумерации 2026-09-17. Пары для сравнения —
один порт в двух положениях, а не соседние индексы.)
Что именно этим доказано:
- **Битстрим в приборе доработанный.** Со стоковым сравнением «стадия равна
порт-стадии» излучали бы только стадии 0 и 2, то есть положение A каждого
порта, а положение B провалилось бы в шум. Здесь все четыре на одном уровне.
- **Петля триггера с контроллером замкнута.** При `syncMode = 3` мастер семплирует
по вернувшемуся триггеру, так что без исправного зеркалирования в STM32 данных
не было бы вовсе.
- **Положения одного порта совпадают** (0.9 и 0.1 дБ), как и должно быть при
отключённом ключе: обе его позиции — физически один путь.
Считать фронты для этой проверки **бессмысленно**: поле `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 МГц лежит в полосе осциллографа, поэтому стимул видно напрямую.
Подключён был **один** тракт: вход слейва 1 (вход 0) в порт 1 мастера через ключ.
Остальные 15 комбинаций законно лежали на уровне шума −69…−78 дБ.
Медиана `|S21|` для входа 0, дБ, по портам и положениям ключа:
| порт мастера | положение A | положение B |
|---|---|---|
| порт 1 | −69.8 | **−5.2** |
| порт 2 | −77.1 | −77.6 |
Разница между двумя положениями **одного и того же** порта 1 — **62–64 дБ**: в
одном положении сигнал идёт в подключённый кабель, в другом в открытый выход. Это
и есть прямое доказательство, что ключ переключается по стадиям. Повторяемость
0.0–1.8 дБ, на сигнальном тракте 0.0 дБ, и положение не переехало за **30 свипов
подряд** (`parity corrections: 0` на 200 тысячах измерений).
> Два предупреждения к этим числам. Первое: измерение сделано **до** смены
> нумерации выходов, поэтому в логах того дня сигнал стоит на «выходе 2» — в
> нынешней нумерации это выход **1**. Здесь таблица приведена по портам и
> положениям, чтобы от нумерации не зависеть. Второе: в первой редакции этого
> раздела я сравнивал выходы 0 и 1 и называл разницу между ними доказательством
> переключения. Это было **неверно** — при старой нумерации соседние индексы были
> разными портами, а не двумя положениями одного. Вывод подтвердился, но на
> правильной паре.
**Ограничение по IFBW.** При `if_bandwidth_hz = 1000` прибор воспроизводимо
падает: `LIBUSB_ERROR_IO`, затем устройства отваливаются с шины
(`LIBUSB_ERROR_NO_DEVICE`). Ограничения по числу отсчётов тут нет — 800 отсчётов
против предела 8191, — так что причина, вероятно, в возросшей скважности
источника (при 1 кГц он включён 94% времени против 24% при 50 кГц) и, значит, в
среднем токе. IFBW 5, 10, 20 и 50 кГц собираются нормально. **Штатным считать
50 кГц**, ниже 5 кГц не опускаться без проверки питания приборов.
## Прошивка STM32 LibreVNA
Сделано, собирается. Для самих стадий менять было нечего: `stageCnt` уже считается
до `settings.stages`, значения тегируются номером стадии, `VNADatapoint<32>`
вмещает 4 стадии × 3 значения. Протащено только новое поле:
```c
// FPGA.cpp — параметр со значением по умолчанию, чтобы не трогать вызовы из
// ручного режима и анализатора спектра
void FPGA::SetupSweep(uint8_t stages, uint8_t port1_stage, uint8_t port2_stage,
bool synchronize, bool syncMaster, uint8_t substage_bits) {
...
value |= (uint16_t) (substage_bits & 0x07) << 6;
// VNA.cpp — ширина под-стадии приезжает в port3Stage: порта 3 у этого железа нет,
// поле свободно, формат пакета не меняется
FPGA::SetupSweep(s.stages, s.port1Stage, s.port2Stage, s.syncMode != 0,
s.syncMaster, s.port3Stage);
```
Собирать так же, как описано в [`reproduce/README.md`](multi_libreVNA_firmware/reproduce/README.md).
## Контроллер ключа (отдельный STM32)
Реализован: [`switch_controller/`](switch_controller/) — WeAct STM32G431CB,
PlatformIO, тот же тулчейн, что у пульта тележки. Подробности подключения и
диагностики в [его README](switch_controller/README.md), здесь только суть.
Врезается в триггерную петлю между `TRIGGER_OUT` мастера и `TRIGGER_IN` первого
слейва. Электрически чисто: в режиме `Mode::Trigger` прошивка LibreVNA уводит
STM32 прибора в Hi-Z на обеих линиях (`GPIO_MODE_INPUT`, `NOPULL`, EXTI снят).
Логика сводится к трём правилам:
1. **Зеркалирование обоих фронтов.** Задержка не влияет на измерения: мастер
стартует оцифровку по своему `TRIGGER_IN`, который приходит с конца кольца,
то есть тоже после контроллера. Она общая для всех трёх приборов и в
отношении «приёмник/опорник» сокращается — только чуть удлиняет развёртку.
2. **Инверсия ноги по СПАДУ триггера.** Положение ключа — младший бит номера
стадии, а период 2 нацело делит 4 стадии, поэтому чередование не рвётся и на
границе точки. Спад означает «оцифровка закончилась», дальше идёт окно
успокоения следующей стадии (~62 мкс при `dwell_us = 2`), которого ключу
хватает с огромным запасом.
3. **По ноге на каждый ключ, но ходят они синхронно.** Нужное положение зависит
только от номера стадии, а не от того, какой порт излучает. PB2 управляет
ключом выходного порта 1, PB6 — порта 2; оба вывода на порту B, поэтому
переключаются одной записью в `BSRR`, то есть строго одновременно.
Страховка от сбоя чётности: один потерянный фронт инвертирует чередование
навсегда и молча меняет антенны местами. Контроллер распознаёт границу точки по
длине паузы (между точками к времени успокоения добавляется перезагрузка
регистров PLL, между развёртками — ещё и работа хоста) и принудительно ставит
положение A. Так чётность чинится на **каждой** точке. Порог берётся как минимум
паузы по скользящему окну с запасом, то есть подстраивается сам под любые
настройки развёртки; отдельный провод для маркера начала развёртки не нужен.
## Порядок ввода в строй
### Кого перепрошивать
**Только мастер.** Слейвы остаются на текущей прошивке, и вот почему: хост
выставляет им обе порт-стадии в 7, а стоковое сравнение `stage_cnt = PORTn_STAGE`
при счётчике 0..3 с семёркой не совпадает никогда — то есть слейв не излучает и
без доработки. Число стадий он берёт из того же поля `STAGES`, что и раньше, так
что четыре стадии на точку отработает как есть, а тегирование значений номером
стадии в стоковой прошивке уже общее.
Это заметно экономит силы: slave-битстрим пересобирать не надо, а он, напомню,
побайтово невоспроизводим.
### 1. Битстрим FPGA мастера (Xilinx ISE 14.7)
**Уже собран.** Лежит в `FPGA/VNA/top_master_per_point_switching.bin`,
sha256 `e184eb92bef1c698849511845fa8d754…`, Timing Score 0. Пересобирать нужно
только после правок в VHDL.
Имя отдельное намеренно: `top_master.bin` оставлен нетронутым — это одна из двух
копий невосстановимого битстрима, из которого собран текущий `combined.vnafw`
(вторая — `extracted/master_fpga.bin`). Заменить его новым стоит только после
проверки на железе.
Как собрать заново — в GUI:
1. Скопировать [`multi_libreVNA_firmware/reproduce/LibreVNA/FPGA`](multi_libreVNA_firmware/reproduce/LibreVNA/FPGA)
в общую папку виртуалки (`C:\shared\LibreVNA\FPGA`).
2. Открыть **`VNA.xise`** — проект мастера. Не `VNA_external_lo_slave.xise`.
3. Process → **Generate Programming File**.
4. Забрать `top.bin` **сразу** и переименовать в
`top_master_per_point_switching.bin`: оба проекта
пишут в один и тот же файл, и следующая сборка его затрёт. Именно так был
потерян исходный мастер-битстрим.
5. Проверить `Timing Score` в `top.par` — должен быть 0 (см. про cost table выше).
Или из командной строки в госте, без GUI:
```bash
cd ~/shared/LibreVNA/FPGA/VNA # лучше скопировать на локальный диск: vboxsf медленная
source /opt/Xilinx/14.7/ISE_DS/settings64.sh
printf 'project open VNA.xise\nprocess run "Generate Programming File"\nproject close\n' > /tmp/bit.tcl
xtclsh /tmp/bit.tcl
grep "Timing Score" top.par
```
Битстрим невоспроизводим побайтово (place & route недетерминирован плюс
timestamp внутри), так что новый файл будет отличаться от старого при любом
исходе — это нормально.
### 2. Прошивка MCU мастера
```bash
cd multi_libreVNA_firmware/reproduce/LibreVNA/Software/VNA_embedded
make BINPATH="<путь к arm-none-eabi из CubeIDE>/tools/bin" -j4
```
Без `EXTERNAL_MASTER_LO_SLAVE` — это и есть сборка мастера. Проверено: с этими
правками собирается без ошибок.
### 3. Упаковка .vnafw
Готовый образ уже лежит в репозитории:
`multi_libreVNA_firmware/combined_per_point_switching.vnafw`, хеш в
`reproduce/SHA256SUMS`. Пересобрать:
```bash
cd multi_libreVNA_firmware/reproduce/LibreVNA/Software/VNA_embedded
make BUILD_DIR=build_per_point_switching # НЕ в build/: там эталон
cd ../..
python3 AssembleFirmware.py \
--bitstream FPGA/VNA/top_master_per_point_switching.bin \
--mcu Software/VNA_embedded/build_per_point_switching/VNA_embedded.bin \
--output ../combined_per_point_switching.vnafw
```
Имя намеренно новое: **не перезаписывайте `combined.vnafw`, пока не проверите
всё на железе** — это ваш путь отката. После успешной проверки замените им
`combined.vnafw`, обновите `extracted/`, `build/`, `SHA256SUMS` и уберите
предупреждение в начале [`reproduce/README.md`](multi_libreVNA_firmware/reproduce/README.md).
Учтите: `./build.sh` сверяет результат со старым эталоном и теперь сообщит о
расхождении. Это ожидаемо, а не поломка.
### 4. Заливка
Штатным LibreVNA-GUI: **Device → Firmware Update**, скормить `.vnafw` целиком —
GUI сам разложит битстрим и прошивку MCU.
### 5. Включение режима и проверка
Только после того, как мастер поднялся на новой прошивке и обычный режим
(`stimulus_switch_positions: 1`) работает как раньше — включать переключение.
Порядок проверки на стенде:
1. Осциллографом на PB2, PB6 и PB5 контроллера: на одной частоте четыре стадии и
два периода `A B A B`, PB2 и PB6 меняются одновременно и в одну сторону, PB5
молчит.
2. По USB CDC контроллера (развёртку остановить): `parity corrections : 0` и
`stages per resync` около `число точек x 4`. Строку про «stages per point» из
старых версий прошивки не искать, её больше нет — см.
[README контроллера](switch_controller/README.md).
3. Сверить трейсы: **выходы 0 и 2** должны совпасть с тем, что даёт старый режим —
это порт 1 и порт 2 в положении A. Выходы 1 и 3 — те же порты в положении B,
их в старом режиме не было. (До смены нумерации 2026-09-17 стоковыми были
выходы 0 и 1.)
## Хост
Уже сделано. Включается одним полем в `run_config.json`:
```json
"multi_device": { "stimulus_switch_positions": 2 },
"sweep": { "dwell_us": 2 }
```
При этом расбери **перестаёт трогать ключ**: `SwitchedMatrixRadarService` уходит
из тракта, матрица 4×4 собирается за одну развёртку. `stimulus_switch_positions:
1` возвращает всё в прежний режим.