Files
radar_system/device_firmware/per_point_switching.md
T
Ayzen 61ef8720e1 number stimulus outputs in device stage order
The output index now equals the device stage, so outputs follow the order the
device measures them: master port 1 through its switch positions, then port 2.
The previous position-major numbering permuted the middle two against the
physical order, so data captured before this needs its calibration re-captured.
2026-10-02 13:57:33 +03:00

30 KiB
Raw Blame History

Переключение стимула внутри частотной точки

Режим, в котором одна развёртка даёт матрицу 4×4 вместо 2×4: каждый порт мастера измеряется на каждой частоте дважды — с двумя положениями выходного ключа.

4 выхода = 2 порта мастера × 2 положения ключа
4 входа  = 2 слейва × 2 порта (входной ключ не участвует)

Выигрыш не в скорости (стадий столько же, сколько было развёрток), а в когерентности: все 16 комбинаций снимаются в пределах ~0.4 мс на одной частоте вместо разброса в сотни миллисекунд между развёртками.

Базовое устройство протокола, поверх которого всё это работает (кадр, типы пакетов, SweepSettings, разбор VNADatapoint) — в librevna_protocol.md.

Раскладка стадий

Стадия Порт мастера Ключ Выход Отражение
0 1 A 0 S11
1 1 B 1 S22
2 2 A 2 S33
3 2 B 3 S44

И стадии, и выходы нумеруются порт-мажорно: старшие биты счётчика стадий выбирают порт, младший — положение ключа. Номер выхода равен номеру стадии, поэтому выходы идут ровно в том порядке, в котором прибор их и меряет — а это тот же порядок, который видно осциллографом на линии ключа при развешивании антенн.

Смена нумерации 2026-09-17. До этой даты выходы нумеровались позиция-мажорно: выходы 0 и 1 сохраняли смысл из стокового режима, но средние два оказывались переставлены относительно физического порядка — слоты 0,1,2,3 попадали на выходы 0,2,1,3. Физический порядок подтверждён замером. Данные, снятые до смены, несопоставимы с новыми: выходы 1 и 2 поменялись смыслом, поэтому калибровки и референсы надо переснять. Имена S-параметров те же, но S22 теперь означает «порт 1, положение B», а не «порт 2, положение A».

Из этого следует главное свойство: положение ключа — это младший бит номера стадии, а период 2 нацело делит 4 стадии. Значит через границу точки чередование продолжается без разрыва, и контроллеру достаточно инвертировать ногу на каждый цикл триггера — абсолютный счётчик стадий не нужен, нужна только правильная чётность, а её контроллер восстанавливает на каждой точке по длине паузы.

Контракт по проводу

Хост отправляет это в build_sweep_settings_payload, прошивка читает — обе стороны реализованы. Слово стадий в SweepSettings (16 бит, смещение 25 в payload из 31 байта; раскладку payload целиком см. 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/.

Sweep.vhd

Порт включался по точному совпадению, поэтому излучал ровно в одной стадии, и во второй под-стадии зондирующего сигнала просто не было. Сравнение вынесено в функцию port_excites: младшие SUBSTAGE_BITS бит счётчика стадий — положение ключа, и они маскируются из сравнения, а старшие биты выбирают порт.

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 свободны. Заняты три из них:

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 зафиксировано:

<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 в момент старта каждой оцифровки.

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 снял 8 коллекций по 16 трасс:

порт 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 контроллера. На переключение это не влияет.

Проверено с подключённым ВЧ-ключом, 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 значения. Протащено только новое поле:

// 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.

Контроллер ключа (отдельный STM32)

Реализован: switch_controller/ — WeAct STM32G431CB, PlatformIO, тот же тулчейн, что у пульта тележки. Подробности подключения и диагностики в его README, здесь только суть.

Врезается в триггерную петлю между 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 в общую папку виртуалки (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:

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 мастера

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. Пересобрать:

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.

Учтите: ./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 контроллера.
  3. Сверить трейсы: выходы 0 и 2 должны совпасть с тем, что даёт старый режим — это порт 1 и порт 2 в положении A. Выходы 1 и 3 — те же порты в положении B, их в старом режиме не было. (До смены нумерации 2026-09-17 стоковыми были выходы 0 и 1.)

Хост

Уже сделано. Включается одним полем в run_config.json:

"multi_device": { "stimulus_switch_positions": 2 },
"sweep": { "dwell_us": 2 }

При этом расбери перестаёт трогать ключ: SwitchedMatrixRadarService уходит из тракта, матрица 4×4 собирается за одну развёртку. stimulus_switch_positions: 1 возвращает всё в прежний режим.