per-point stimulus switching: host side

Матрица 4x4 за одну развёртку: каждый порт мастера измеряется на каждой частоте
дважды, с двумя положениями выходного ключа. Ключ дёргает контроллер в
триггерной петле, поэтому расбери переключателями больше не управляет —
SwitchedMatrixRadarService уходит из тракта, все 16 комбинаций снимаются в
пределах одной точки вместо разброса между тремя развёртками.

Выходы нумеруются позиция-мажорно, стадии порт-мажорно (старшие биты счётчика
стадий выбирают порт, младший — положение ключа). Из этого следует, что
положение ключа = младший бит номера стадии, а период 2 нацело делит 4 стадии —
поэтому контроллеру достаточно инвертировать ногу на каждый цикл триггера, без
абсолютного счётчика. Выходы 0 и 1 при этом сохраняют прежний смысл.

Приёмники слейвов пронумерованы от числа выходов + 1, а не от фиксированного 3:
иначе при 4 выходах прямой член «приёмник 3 <- выход 3» назывался бы так же, как
отражение выхода 3. При двух выходах даёт прежние порты 3..6.

Слейвам порт-стадии выставляются в 7: стоковый приём «одна за последней стадией»
перестаёт работать, когда порт выбирается старшими битами.

Всё за флагом multi_device.stimulus_switch_positions (1 = прежнее поведение,
байт в байт). Спецификация правок FPGA/STM в device_firmware/per_point_switching.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ayzen
2026-09-09 21:26:56 +03:00
co-authored by Claude Opus 5
parent d2338ff10c
commit 6693ece0aa
11 changed files with 645 additions and 64 deletions
+178
View File
@@ -0,0 +1,178 @@
# Переключение стимула внутри частотной точки
Режим, в котором одна развёртка даёт матрицу 4×4 вместо 2×4: каждый порт мастера
измеряется на **каждой** частоте дважды — с двумя положениями выходного ключа.
```
4 выхода = 2 порта мастера × 2 положения ключа
4 входа = 2 слейва × 2 порта (входной ключ не участвует)
```
Выигрыш не в скорости (стадий столько же, сколько было развёрток), а в
когерентности: все 16 комбинаций снимаются в пределах ~0.4 мс на одной частоте
вместо разброса в сотни миллисекунд между развёртками.
## Раскладка стадий
| Стадия | Порт мастера | Ключ | Выход |
|---|---|---|---|
| 0 | 1 | A | 0 |
| 1 | 1 | B | 2 |
| 2 | 2 | A | 1 |
| 3 | 2 | B | 3 |
Стадии нумеруются **порт-мажорно**: старшие биты счётчика стадий выбирают порт,
младший — положение ключа. Выходы нумеруются **позиция-мажорно**, поэтому выходы
0 и 1 сохраняют тот же смысл, что и без переключения.
Из этого следует главное свойство: **положение ключа — это младший бит номера
стадии**, а период 2 нацело делит 4 стадии. Значит через границу точки чередование
продолжается без разрыва, и контроллеру достаточно инвертировать ногу на каждый
цикл триггера — абсолютный счётчик стадий не нужен, нужна только правильная
чётность, и та только один раз на старте развёртки.
## Контракт по проводу
Хост уже отправляет это (см. `build_sweep_settings_payload`), прошивка должна
считать. Слово стадий в `SweepSettings` (16 бит, смещение 25 в payload):
| Биты | Поле | Значение в этом режиме |
|---|---|---|
| 0..2 | `stages` | 3 (то есть 4 стадии) |
| 3..5 | `port1Stage` | 0 — **индекс порта**, а не номер стадии |
| 6..8 | `port2Stage` | 1 |
| 9..11 | `port3Stage` → ширина под-стадии | `log2(положений ключа)` = 1 |
| 12..14 | `port4Stage` | не используется |
| 15 | `unused2` | не используется |
Поле `port3Stage` переиспользовано под ширину блока под-стадий: двухпортовая
прошивка его игнорирует, поэтому формат пакета не меняется. **Ноль там —
стоковая раскладка**, так что хост с `stimulus_switch_positions = 1` остаётся
полностью совместим с немодифицированным прибором.
Слейвам обе порт-стадии выставляются в **7**. Стоковый приём «одна за последней
стадией» здесь не работает: при 4 стадиях старшие биты счётчика принимают только
значения 0 и 1, свободного значения не остаётся, и слейв начал бы излучать.
## FPGA мастера
### `Sweep.vhd`
Сейчас порт включается по точному совпадению, поэтому излучает ровно в одной
стадии, и во второй под-стадии зондирующего сигнала просто не будет:
```vhdl
if std_logic_vector(stage_cnt) = PORT1_STAGE then
```
Меняется на сравнение по старшим битам плюс явное «порт выключен». Сдвиг на
переменное число бит синтезируется в трёхбитный barrel shifter:
```vhdl
-- в entity
SUBSTAGE_BITS : in STD_LOGIC_VECTOR (2 downto 0);
-- в architecture
signal substage_shift : natural range 0 to 7;
substage_shift <= to_integer(unsigned(SUBSTAGE_BITS));
-- в состоянии Settling, вместо каждой из двух проверок:
if unsigned(PORT1_STAGE) <= unsigned(STAGES)
and shift_right(stage_cnt, substage_shift) = unsigned(PORT1_STAGE) then
PORT1_ACTIVE <= '1';
source_active <= '1';
else
PORT1_ACTIVE <= '0';
end if;
```
Проверка совместимости:
* `SUBSTAGE_BITS = 0`, `STAGES = 1`, порты 0 и 1 — сдвиг нулевой, сравнение
вырождается в исходное. Стоковое поведение сохраняется бит в бит.
* Слейв с `port_stage = 7`: `7 <= 1` ложно → порт выключен. Старый хост слал 2 —
`2 <= 1` тоже ложно, так что новый битстрим работает и со старым хостом.
* `SUBSTAGE_BITS = 1`, `STAGES = 3`: порт 0 активен в стадиях 0 и 1, порт 1 — в
2 и 3.
### `SPIConfig.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`
Пробросить `SUBSTAGE_BITS` от `SPIConfig` к `Sweep` — новый сигнал рядом с
`sweep_stages` / `sweep_port1_stage`.
## Прошивка 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,
uint8_t substage_bits, bool synchronize, bool syncMaster) {
...
value |= (uint16_t) (substage_bits & 0x07) << 6;
// VNA.cpp
FPGA::SetupSweep(s.stages, s.port1Stage, s.port2Stage, s.port3Stage,
s.syncMode != 0, s.syncMaster);
```
Собирать так же, как описано в [`reproduce/README.md`](multi_libreVNA_firmware/reproduce/README.md).
Отдельно: битстрим мастера единственный в своём роде, `top.bin` перезаписывается
каждой сборкой ISE — переименовывать сразу.
## Контроллер ключа (отдельный STM32)
Врезается в триггерную петлю между `TRIGGER_OUT` мастера и `TRIGGER_IN` слейва.
Электрически это чисто: в режиме `Mode::Trigger` прошивка LibreVNA уводит STM32
прибора в Hi-Z на обеих линиях (`GPIO_MODE_INPUT`, `NOPULL`, EXTI снят), так что
обе линии свободны.
Три требования:
1. **Прозрачный повторитель обоих фронтов.** Задержка суб-микросекундная
(bare-metal EXTI), иначе она добавляется к каждой из 4 стадий каждой точки.
2. **Ключ переключается по СПАДУ**, а не по фронту. Спад означает «оцифровка
стадии закончилась»; дальше идёт `SamplingDone` → `Settling` следующей стадии,
то есть окно успокоения. При `dwell_us = 2` и штатной задержке PLL это окно
~62 мкс — запас огромный. Переключение по фронту заставило бы удерживать
кольцо на время установления ключа.
3. **Одна нога на два ключа.** Ключи на порту 1 и на порту 2 ходят синхронно;
нужное положение — просто инверсия на каждом цикле триггера.
Чётность привязывается к началу развёртки. Между развёртками FPGA держится в
сбросе (`StartSweep` = снять и поднять AUX3), а хост успевает получить пакет
статуса, поэтому пауза на линии триггера на порядок длиннее межстадийной
(~100 мкс против ≥1 мс — померить осциллографом и взять порог посередине). Если
есть физический доступ к AUX3 мастера (нога FPGA P74) — лучше завести её на
контроллер как точный маркер вместо порога по паузе.
Сбой чётности меняет местами антенны 0↔2 и 1↔3 **молча**, поэтому стоит вывести
текущее положение ключа на отладочную ногу для сверки осциллографом.
## Хост
Уже сделано. Включается одним полем в `run_config.json`:
```json
"multi_device": { "stimulus_switch_positions": 2 },
"sweep": { "dwell_us": 2 }
```
При этом расбери **перестаёт трогать ключ**: `SwitchedMatrixRadarService` уходит
из тракта, матрица 4×4 собирается за одну развёртку. `stimulus_switch_positions:
1` возвращает всё в прежний режим.