# Переключение стимула внутри частотной точки Режим, в котором одна развёртка даёт матрицу 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 ``` С этим значением **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` возвращает всё в прежний режим.