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` возвращает всё в прежний режим.
@@ -17,6 +17,7 @@ from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration, SweepConfiguration,
SweepMeasurementResult, SweepMeasurementResult,
normalize_master_stimulus_ports, normalize_master_stimulus_ports,
normalize_switch_substage_count,
) )
from python_app.hardware_full.librevna_multi_device_driver.protocol import ( from python_app.hardware_full.librevna_multi_device_driver.protocol import (
PacketType, PacketType,
@@ -36,13 +37,21 @@ class MultiDeviceVnaController:
master_serial_number: str, master_serial_number: str,
slave_serial_numbers: Sequence[str] = (), slave_serial_numbers: Sequence[str] = (),
force_external_reference: bool = True, force_external_reference: bool = True,
switch_substage_count: int = 1,
) -> None: ) -> None:
"""Open all configured devices and initialize controller state.""" """Open all configured devices and initialize controller state.
``switch_substage_count`` is a property of the wiring, not of a sweep: it is
how many positions the stimulus switch is stepped through WITHIN one frequency
point. ``1`` is the stock behaviour; anything larger needs the modified master
bitstream and the switch controller sitting in the trigger loop.
"""
self._master_device: LibreVnaUsbBulkConnection | None = None self._master_device: LibreVnaUsbBulkConnection | None = None
self._slave_devices: list[LibreVnaUsbBulkConnection] = [] self._slave_devices: list[LibreVnaUsbBulkConnection] = []
self._all_devices: list[LibreVnaUsbBulkConnection] = [] self._all_devices: list[LibreVnaUsbBulkConnection] = []
self._synchronization_enabled = bool(slave_serial_numbers) self._synchronization_enabled = bool(slave_serial_numbers)
self._force_external_reference = bool(force_external_reference) self._force_external_reference = bool(force_external_reference)
self._switch_substage_count = normalize_switch_substage_count(switch_substage_count)
self._reference_configuration_applied = False self._reference_configuration_applied = False
self._last_applied_sweep_configuration: Optional[SweepConfiguration] = None self._last_applied_sweep_configuration: Optional[SweepConfiguration] = None
self._last_master_stimulus_ports: tuple[int, ...] | None = None self._last_master_stimulus_ports: tuple[int, ...] | None = None
@@ -194,6 +203,7 @@ class MultiDeviceVnaController:
active_sweep_configuration=self._last_applied_sweep_configuration, active_sweep_configuration=self._last_applied_sweep_configuration,
cycle_count=cycle_count, cycle_count=cycle_count,
master_stimulus_ports=self._last_master_stimulus_ports, master_stimulus_ports=self._last_master_stimulus_ports,
switch_substage_count=self._switch_substage_count,
datapoint_timeout_seconds=datapoint_timeout_seconds, datapoint_timeout_seconds=datapoint_timeout_seconds,
) )
except Exception: except Exception:
@@ -332,6 +342,7 @@ class MultiDeviceVnaController:
is_synchronization_master=is_synchronization_master, is_synchronization_master=is_synchronization_master,
synchronization_enabled=self._synchronization_enabled, synchronization_enabled=self._synchronization_enabled,
master_stimulus_ports=master_stimulus_ports, master_stimulus_ports=master_stimulus_ports,
switch_substage_count=self._switch_substage_count,
), ),
timeout_seconds=1.0, timeout_seconds=1.0,
retry_count=1, retry_count=1,
@@ -14,8 +14,10 @@ from python_app.hardware_full.librevna_multi_device_driver.exceptions import (
TransientCollectionError, TransientCollectionError,
) )
from python_app.hardware_full.librevna_multi_device_driver.models import ( from python_app.hardware_full.librevna_multi_device_driver.models import (
OutputChannel,
SweepConfiguration, SweepConfiguration,
SweepMeasurementResult, SweepMeasurementResult,
build_output_channels,
normalize_master_stimulus_ports, normalize_master_stimulus_ports,
) )
from python_app.hardware_full.librevna_multi_device_driver.protocol import ( from python_app.hardware_full.librevna_multi_device_driver.protocol import (
@@ -173,6 +175,7 @@ def collect_complete_running_sweep_cycles(
active_sweep_configuration: SweepConfiguration, active_sweep_configuration: SweepConfiguration,
cycle_count: int, cycle_count: int,
master_stimulus_ports: Sequence[int], master_stimulus_ports: Sequence[int],
switch_substage_count: int = 1,
datapoint_timeout_seconds: float | None = None, datapoint_timeout_seconds: float | None = None,
) -> SweepMeasurementResult: ) -> SweepMeasurementResult:
"""Collect complete cycles from an already-running synchronized sweep.""" """Collect complete cycles from an already-running synchronized sweep."""
@@ -180,7 +183,9 @@ def collect_complete_running_sweep_cycles(
if cycle_count < 1: if cycle_count < 1:
raise ValueError("cycle_count must be >= 1") raise ValueError("cycle_count must be >= 1")
stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports) stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports)
stage_by_master_port = {port: stage for stage, port in enumerate(stimulus_ports)} # One output per (master port, stimulus switch position). With no per-point
# switching this is one output per port, exactly as before.
output_channels = build_output_channels(stimulus_ports, switch_substage_count)
all_device_connections = [master_device_connection, *slave_device_connections] all_device_connections = [master_device_connection, *slave_device_connections]
point_count = active_sweep_configuration.points point_count = active_sweep_configuration.points
@@ -197,13 +202,18 @@ def collect_complete_running_sweep_cycles(
) )
else: else:
frequencies_hz = np.array([float(active_sweep_configuration.start_hz)], dtype=np.float64) frequencies_hz = np.array([float(active_sweep_configuration.start_hz)], dtype=np.float64)
master_reference_measurements_by_port = { # Keyed by 1-based OUTPUT number, not by master port: with per-point switching two
port: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex) # outputs share a stimulus port but are measured in different stages, so each needs
for port in stimulus_ports # its own incident reference.
master_reference_measurements_by_output = {
channel.number: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex)
for channel in output_channels
} }
raw_receiver_measurements_by_s_parameter = { raw_receiver_measurements_by_s_parameter = {
name: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex) name: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex)
for name in build_forward_s_parameter_names(len(slave_device_connections), stimulus_ports) for name in build_forward_s_parameter_names(
len(slave_device_connections), output_channels
)
} }
collection_errors: list[Exception] = [] collection_errors: list[Exception] = []
@@ -250,7 +260,7 @@ def collect_complete_running_sweep_cycles(
# ~150 ms sweep; this estimates 30 ms, tolerance 15 ms, so an off-by-one of # ~150 ms sweep; this estimates 30 ms, tolerance 15 ms, so an off-by-one of
# ~150 ms is flagged with ~10x margin and ~1 ms jitter clears it with ~7x.) # ~150 ms is flagged with ~10x margin and ~1 ms jitter clears it with ~7x.)
# A spread above tolerance is rejected and retried, never silently emitted. # A spread above tolerance is rejected and retried, never silently emitted.
stage_count = max(1, len(stimulus_ports)) stage_count = max(1, len(output_channels))
expected_cycle_seconds = _expected_sweep_cycle_seconds( expected_cycle_seconds = _expected_sweep_cycle_seconds(
point_count, stage_count, active_sweep_configuration.if_bandwidth point_count, stage_count, active_sweep_configuration.if_bandwidth
) )
@@ -541,41 +551,41 @@ def collect_complete_running_sweep_cycles(
def handle_master_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None: def handle_master_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
"""Store the master device's frequency, reference, and reflection values. """Store the master device's frequency, reference, and reflection values.
Records the sweep-point frequency and, per active master stimulus port, the Records the sweep-point frequency and, per output channel, the reference
reference receiver value and the matching reflection (S11/S22) into the receiver value and the matching reflection into the cycle/point measurement
cycle/point measurement buffers. buffers. Two outputs may share a stimulus port (different switch positions);
they are told apart by the stage tag.
""" """
point_index = parsed_datapoint.point_index point_index = parsed_datapoint.point_index
frequencies_hz[point_index] = parsed_datapoint.frequency_hz frequencies_hz[point_index] = parsed_datapoint.frequency_hz
for master_stimulus_port, stage_index in stage_by_master_port.items(): for channel in output_channels:
reference_receiver_value = find_receiver_value( reference_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask, parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index, stage_index=channel.stage_index,
is_reference_receiver=True, is_reference_receiver=True,
required_port_number=master_stimulus_port, required_port_number=channel.stimulus_port,
) )
if reference_receiver_value is None: if reference_receiver_value is None:
reference_receiver_value = find_receiver_value( reference_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask, parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index, stage_index=channel.stage_index,
is_reference_receiver=True, is_reference_receiver=True,
) )
if reference_receiver_value is None: if reference_receiver_value is None:
continue continue
master_reference_measurements_by_port[master_stimulus_port][ master_reference_measurements_by_output[channel.number][
cycle_index, cycle_index,
point_index, point_index,
] = reference_receiver_value ] = reference_receiver_value
receiver_port_number = master_stimulus_port s_parameter_name = master_reflection_s_parameter_name(channel)
s_parameter_name = master_reflection_s_parameter_name(master_stimulus_port)
port_receiver_value = find_receiver_value( port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask, parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index, stage_index=channel.stage_index,
is_reference_receiver=False, is_reference_receiver=False,
required_port_number=receiver_port_number, required_port_number=channel.stimulus_port,
) )
if port_receiver_value is not None: if port_receiver_value is not None:
raw_receiver_measurements_by_s_parameter[s_parameter_name][ raw_receiver_measurements_by_s_parameter[s_parameter_name][
@@ -588,25 +598,25 @@ def collect_complete_running_sweep_cycles(
) -> Callable[[ParsedVnaDatapoint], bool]: ) -> Callable[[ParsedVnaDatapoint], bool]:
"""Build a cycle-tracking datapoint handler for the given slave device. """Build a cycle-tracking datapoint handler for the given slave device.
The slave's two receivers map to ports ``2*slave_index + 3`` and ``+ 4``, The slave's two receivers map to the virtual receiver ports numbered just
producing forward S-parameters (e.g. S3x/S4x) for each active master above the outputs (see :func:`slave_receiver_base_port`), producing one
stimulus port. forward S-parameter per output channel.
""" """
receiver_base_port = 2 * slave_index + 3 receiver_base_port = slave_receiver_base_port(slave_index, output_channels)
def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None: def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
"""Store this slave's forward receiver values into the measurement buffers.""" """Store this slave's forward receiver values into the measurement buffers."""
point_index = parsed_datapoint.point_index point_index = parsed_datapoint.point_index
for master_stimulus_port, stage_index in stage_by_master_port.items(): for channel in output_channels:
first_s_parameter_name = f"S{receiver_base_port}{master_stimulus_port}" first_s_parameter_name = f"S{receiver_base_port}{channel.number}"
second_s_parameter_name = f"S{receiver_base_port + 1}{master_stimulus_port}" second_s_parameter_name = f"S{receiver_base_port + 1}{channel.number}"
for receiver_port_number, s_parameter_name in ( for receiver_port_number, s_parameter_name in (
(1, first_s_parameter_name), (1, first_s_parameter_name),
(2, second_s_parameter_name), (2, second_s_parameter_name),
): ):
port_receiver_value = find_receiver_value( port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask, parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index, stage_index=channel.stage_index,
is_reference_receiver=False, is_reference_receiver=False,
required_port_number=receiver_port_number, required_port_number=receiver_port_number,
) )
@@ -729,29 +739,42 @@ def collect_complete_running_sweep_cycles(
frequencies_hz=frequencies_hz, frequencies_hz=frequencies_hz,
s_parameters=calculate_last_cycle_s_parameters( s_parameters=calculate_last_cycle_s_parameters(
raw_receiver_measurements_by_s_parameter, raw_receiver_measurements_by_s_parameter,
master_reference_measurements_by_port, master_reference_measurements_by_output,
), ),
) )
def master_reflection_s_parameter_name(master_stimulus_port: int) -> str: def slave_receiver_base_port(slave_index: int, output_channels: Sequence[OutputChannel]) -> int:
"""Return master reflection trace name for active output port.""" """Return the first virtual receiver port number of one slave device.
if master_stimulus_port == 1:
return "S11" Receivers are numbered directly above the outputs, so the whole topology reads
return "S22" as one virtual VNA whose first ``N`` ports transmit and whose remaining ports
receive. Numbering them from a fixed 3 instead would make a 4-output setup
collide with itself: the reflection of output 3 and the forward term
``receiver 3 <- output 3`` would both be called ``S33``. With two outputs this
still yields the historical ports 3..6.
"""
return len(output_channels) + 2 * slave_index + 1
def build_forward_s_parameter_names(slave_device_count: int, master_stimulus_ports: Sequence[int]) -> list[str]: def master_reflection_s_parameter_name(channel: OutputChannel) -> str:
"""Return the reflection trace name for one output channel (``S11``, ``S22``, ...)."""
return f"S{channel.number}{channel.number}"
def build_forward_s_parameter_names(
slave_device_count: int, output_channels: Sequence[OutputChannel]
) -> list[str]:
"""Return S-parameter names for the configured forward multi-device topology.""" """Return S-parameter names for the configured forward multi-device topology."""
names: list[str] = [] names: list[str] = []
for master_stimulus_port in master_stimulus_ports: for channel in output_channels:
names.append(master_reflection_s_parameter_name(master_stimulus_port)) names.append(master_reflection_s_parameter_name(channel))
for slave_index in range(slave_device_count): for slave_index in range(slave_device_count):
receiver_base_port = 2 * slave_index + 3 receiver_base_port = slave_receiver_base_port(slave_index, output_channels)
names.extend( names.extend(
[ [
f"S{receiver_base_port}{master_stimulus_port}", f"S{receiver_base_port}{channel.number}",
f"S{receiver_base_port + 1}{master_stimulus_port}", f"S{receiver_base_port + 1}{channel.number}",
] ]
) )
return names return names
@@ -759,7 +782,7 @@ def build_forward_s_parameter_names(slave_device_count: int, master_stimulus_por
def calculate_last_cycle_s_parameters( def calculate_last_cycle_s_parameters(
raw_receiver_measurements_by_s_parameter: dict[str, np.ndarray], raw_receiver_measurements_by_s_parameter: dict[str, np.ndarray],
master_reference_measurements_by_port: dict[int, np.ndarray], master_reference_measurements_by_output: dict[int, np.ndarray],
) -> dict[str, np.ndarray]: ) -> dict[str, np.ndarray]:
"""Convert raw receiver captures from the final sweep cycle into S-parameters. """Convert raw receiver captures from the final sweep cycle into S-parameters.
@@ -775,8 +798,10 @@ def calculate_last_cycle_s_parameters(
zero_filled_points_by_trace: dict[str, int] = {} zero_filled_points_by_trace: dict[str, int] = {}
point_count = 0 point_count = 0
for s_parameter_name, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items(): for s_parameter_name, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items():
master_stimulus_port = int(s_parameter_name[-1]) # The trailing digit of every name is the 1-based OUTPUT number, which is what
master_reference_measurements = master_reference_measurements_by_port[master_stimulus_port] # selects the incident reference (see build_forward_s_parameter_names).
output_number = int(s_parameter_name[-1])
master_reference_measurements = master_reference_measurements_by_output[output_number]
last_cycle_reference = master_reference_measurements[-1] last_cycle_reference = master_reference_measurements[-1]
last_cycle_measurement = raw_receiver_measurements[-1] last_cycle_measurement = raw_receiver_measurements[-1]
@@ -7,6 +7,11 @@ from dataclasses import dataclass
import numpy as np import numpy as np
# Hard ceiling from the wire format: SweepSettings.stages is 3 bits, so a point can
# carry at most 8 stages (and one VNADatapoint holds 3 values per stage, well within
# its 32-value capacity).
MAX_SWEEP_STAGES = 8
def normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]: def normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
"""Validate master stimulus ports and return them as a tuple of ints. """Validate master stimulus ports and return them as a tuple of ints.
@@ -25,6 +30,79 @@ def normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tup
return stimulus_ports return stimulus_ports
def normalize_switch_substage_count(switch_substage_count: int) -> int:
"""Validate the per-point stimulus switch position count.
``1`` means no per-point switching (one stage per master port, the stock
LibreVNA behaviour). Larger values require the modified master bitstream,
which keeps a port excited across a GROUP of stages instead of a single one.
The ceiling is the 3-bit ``stages`` field: ``ports * substages <= 8``.
"""
substage_count = int(switch_substage_count)
if substage_count < 1:
raise ValueError("switch_substage_count must be >= 1")
if substage_count & (substage_count - 1):
# The master bitstream selects the port from the UPPER bits of the stage
# counter, so the substage block must be a power of two.
raise ValueError("switch_substage_count must be a power of two")
return substage_count
@dataclass(frozen=True, slots=True)
class OutputChannel:
"""One stimulus output of the widened matrix: a master port at a switch position.
``index`` is the canonical output position used by the radar combo matrix,
``stage_index`` is the tag the device stamps on every value of this channel
(``descr >> 5`` in the datapoint), and ``stimulus_port`` is the master port
that physically excites it.
"""
index: int
stimulus_port: int
stage_index: int
@property
def number(self) -> int:
"""Return the 1-based output number used in S-parameter names."""
return self.index + 1
def build_output_channels(
master_stimulus_ports: Sequence[int], switch_substage_count: int = 1
) -> tuple[OutputChannel, ...]:
"""Map every (master port, switch position) pair to an output and a device stage.
Outputs are numbered switch-position-major, so positions of the FIRST switch
setting keep the output indices they have without per-point switching (with
``switch_substage_count=1`` this is the identity). Stages are numbered
port-major, because the master bitstream takes the excited port from the upper
bits of the stage counter and the switch position from the lower bits — which
is also what makes the switch line a plain alternation the controller can drive
without tracking an absolute stage number.
This is the single source of truth shared by the protocol builder, the cycle
collector and the acquisition service, so the mapping cannot drift between them.
"""
stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports)
substage_count = normalize_switch_substage_count(switch_substage_count)
stage_count = len(stimulus_ports) * substage_count
if stage_count > MAX_SWEEP_STAGES:
raise ValueError(
f"{len(stimulus_ports)} master port(s) x {substage_count} switch position(s) "
f"needs {stage_count} stages, but the device supports at most {MAX_SWEEP_STAGES}"
)
return tuple(
OutputChannel(
index=substage * len(stimulus_ports) + port_index,
stimulus_port=port,
stage_index=port_index * substage_count + substage,
)
for substage in range(substage_count)
for port_index, port in enumerate(stimulus_ports)
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SweepConfiguration: class SweepConfiguration:
"""User-facing configuration for one VNA sweep.""" """User-facing configuration for one VNA sweep."""
@@ -8,7 +8,9 @@ from dataclasses import dataclass
from python_app.hardware_full.librevna_multi_device_driver.models import ( from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration, SweepConfiguration,
build_output_channels,
normalize_master_stimulus_ports, normalize_master_stimulus_ports,
normalize_switch_substage_count,
) )
@@ -26,6 +28,13 @@ class PacketType:
SYNC_MODE_HARDWARE_TRIGGER = 3 SYNC_MODE_HARDWARE_TRIGGER = 3
FRAME_HEADER_MAGIC = 0x5A FRAME_HEADER_MAGIC = 0x5A
# Stage index handed to a device whose ports must never excite. The modified master
# bitstream matches a port against the UPPER bits of the stage counter, so the stock
# "one past the last stage" sentinel stops working once substages exist (with 4 stages
# the upper bits only ever hold 0 or 1, leaving no free value). The bitstream instead
# treats any port stage >= the stage count as "port disabled", and 7 is always >= it.
DISABLED_PORT_STAGE = 7
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ParsedVnaDatapoint: class ParsedVnaDatapoint:
@@ -52,15 +61,31 @@ def build_sweep_settings_payload(
is_synchronization_master: bool, is_synchronization_master: bool,
synchronization_enabled: bool, synchronization_enabled: bool,
master_stimulus_ports: Sequence[int], master_stimulus_ports: Sequence[int],
switch_substage_count: int = 1,
) -> bytes: ) -> bytes:
"""Build protocol-v14 SweepSettings for staged master outputs or synchronized receivers.""" """Build protocol-v14 SweepSettings for staged master outputs or synchronized receivers.
With ``switch_substage_count > 1`` every master port is excited across a BLOCK of
consecutive stages instead of a single one, so the same frequency point is measured
once per stimulus switch position. That requires the modified master bitstream: the
stock one matches ``stage_cnt`` against the port stage exactly, so a port excites in
exactly one stage and the extra substages would carry no stimulus.
The substage block width is sent as ``log2(switch_substage_count)`` in the otherwise
unused ``port3Stage`` field (bits 9..11 of the stage word) — it tells the bitstream
how many LOW bits of the stage counter are the switch position rather than the port
selector. Zero there is the stock layout, so this payload stays compatible with an
unmodified device as long as ``switch_substage_count`` is 1.
"""
stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports) stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports)
substage_count = normalize_switch_substage_count(switch_substage_count)
output_channels = build_output_channels(stimulus_ports, substage_count)
excitation_power_centidecibels_milliwatt = int(round(sweep_configuration.power_dbm * 100.0)) excitation_power_centidecibels_milliwatt = int(round(sweep_configuration.power_dbm * 100.0))
dwell_time_microseconds = max(0, min(int(sweep_configuration.dwell_us), 0xFFFF)) dwell_time_microseconds = max(0, min(int(sweep_configuration.dwell_us), 0xFFFF))
synchronization_mode = SYNC_MODE_HARDWARE_TRIGGER if synchronization_enabled else 0 synchronization_mode = SYNC_MODE_HARDWARE_TRIGGER if synchronization_enabled else 0
last_stage_index = len(stimulus_ports) - 1 last_stage_index = len(output_channels) - 1
inactive_stage_index = min(last_stage_index + 1, 7) substage_bits = substage_count.bit_length() - 1
configuration_bits = ( configuration_bits = (
0 << 0 0 << 0
@@ -72,17 +97,23 @@ def build_sweep_settings_payload(
) )
if is_synchronization_master: if is_synchronization_master:
stage_by_port = {port: stage for stage, port in enumerate(stimulus_ports)} # The port selector lives in the stage bits ABOVE the substage block, so the
port_1_stimulus_stage = stage_by_port.get(1, inactive_stage_index) # value sent here is the port's index, not its first stage number.
port_2_stimulus_stage = stage_by_port.get(2, inactive_stage_index) port_stage_by_port = {
port: index for index, port in enumerate(stimulus_ports)
}
port_1_stimulus_stage = port_stage_by_port.get(1, DISABLED_PORT_STAGE)
port_2_stimulus_stage = port_stage_by_port.get(2, DISABLED_PORT_STAGE)
else: else:
port_1_stimulus_stage = inactive_stage_index # Receiver-only devices must never excite.
port_2_stimulus_stage = inactive_stage_index port_1_stimulus_stage = DISABLED_PORT_STAGE
port_2_stimulus_stage = DISABLED_PORT_STAGE
stage_configuration_bits = ( stage_configuration_bits = (
(last_stage_index & 0x07) (last_stage_index & 0x07)
| (port_1_stimulus_stage & 0x07) << 3 | (port_1_stimulus_stage & 0x07) << 3
| (port_2_stimulus_stage & 0x07) << 6 | (port_2_stimulus_stage & 0x07) << 6
| (substage_bits & 0x07) << 9
) )
return struct.pack( return struct.pack(
@@ -52,6 +52,7 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
force_external_reference=config.radar.multi_device.force_external_reference, force_external_reference=config.radar.multi_device.force_external_reference,
recovery_attempts=config.radar.multi_device.recovery_attempts, recovery_attempts=config.radar.multi_device.recovery_attempts,
backend_mode=config.radar.driver_mode, backend_mode=config.radar.driver_mode,
switch_substage_count=config.matrix_stimulus_switch_positions,
) )
out_physical = config.matrix_output_switch_positions out_physical = config.matrix_output_switch_positions
in_physical = config.matrix_input_switch_positions in_physical = config.matrix_input_switch_positions
@@ -67,7 +68,7 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
inner=inner, inner=inner,
output_switch=build_physical_switch(config.output_switch, out_physical, config.radar.driver_mode), output_switch=build_physical_switch(config.output_switch, out_physical, config.radar.driver_mode),
input_switch=build_physical_switch(config.input_switch, in_physical, config.radar.driver_mode), input_switch=build_physical_switch(config.input_switch, in_physical, config.radar.driver_mode),
inner_output_positions=RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS, inner_output_positions=config.matrix_virtual_output_positions,
inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS, inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS,
settling_ms=config.runtime.settling_ms, settling_ms=config.runtime.settling_ms,
) )
@@ -13,7 +13,10 @@ import numpy as np
from python_app.hardware_full.librevna_multi_device_driver.cycle_collection import LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS from python_app.hardware_full.librevna_multi_device_driver.cycle_collection import LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS
from python_app.hardware_full.librevna_multi_device_driver.exceptions import TransientCollectionError from python_app.hardware_full.librevna_multi_device_driver.exceptions import TransientCollectionError
from python_app.hardware_full.librevna_multi_device_driver.models import SweepConfiguration from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration,
normalize_switch_substage_count,
)
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
@@ -43,10 +46,33 @@ _MAX_RECOVERY_WALL_SECONDS: float = 10.0
# previously caused the periodic freeze. # previously caused the periodic freeze.
_MAX_TRANSIENT_COLLECTION_RETRIES: int = 2 _MAX_TRANSIENT_COLLECTION_RETRIES: int = 2
_INPUT_S_PARAMETERS_BY_OUTPUT: dict[int, tuple[str, ...]] = { def _build_s_parameter_layout(
0: ("s31", "s41", "s51", "s61"), output_count: int, slave_count: int
1: ("s32", "s42", "s52", "s62"), ) -> tuple[dict[int, tuple[str, ...]], dict[int, str]]:
"""Return the (forward, reflection) trace names for each output position.
Mirrors the naming in ``cycle_collection``: outputs are virtual ports 1..N and
the slaves' receivers are numbered directly above them, so a 2-output setup
keeps the historical ``s31``/``s41``/``s51``/``s61`` layout while a 4-output one
(per-point stimulus switching) shifts the receivers to ports 5..8 to stay
collision-free.
"""
receiver_ports = [
output_count + 2 * slave_index + port_offset
for slave_index in range(slave_count)
for port_offset in (1, 2)
]
forward_by_output = {
output_index: tuple(
f"s{receiver_port}{output_index + 1}" for receiver_port in receiver_ports
)
for output_index in range(output_count)
} }
reflection_by_output = {
output_index: f"s{output_index + 1}{output_index + 1}"
for output_index in range(output_count)
}
return forward_by_output, reflection_by_output
@dataclass(slots=True) @dataclass(slots=True)
class MultiDeviceLibreVnaService: class MultiDeviceLibreVnaService:
@@ -57,6 +83,10 @@ class MultiDeviceLibreVnaService:
force_external_reference: bool = True force_external_reference: bool = True
recovery_attempts: int = 3 recovery_attempts: int = 3
backend_mode: str = "auto" backend_mode: str = "auto"
# Stimulus switch positions stepped through WITHIN one frequency point. 1 keeps the
# stock 2-output matrix; 2 doubles the outputs (4) without a second sweep, at the
# cost of requiring the modified master bitstream and the trigger-loop controller.
switch_substage_count: int = 1
_controller: "MultiDeviceVnaController | None" = field(init=False, default=None, repr=False) _controller: "MultiDeviceVnaController | None" = field(init=False, default=None, repr=False)
_sweep_configuration: SweepConfiguration | None = field(init=False, default=None, repr=False) _sweep_configuration: SweepConfiguration | None = field(init=False, default=None, repr=False)
_using_mock_backend: bool = field(init=False, default=False, repr=False) _using_mock_backend: bool = field(init=False, default=False, repr=False)
@@ -75,6 +105,12 @@ class MultiDeviceLibreVnaService:
raise ValueError(f"Unsupported multi-device backend mode: {self.backend_mode}") raise ValueError(f"Unsupported multi-device backend mode: {self.backend_mode}")
self.backend_mode = mode self.backend_mode = mode
self._using_mock_backend = mode == "mock" self._using_mock_backend = mode == "mock"
self.switch_substage_count = normalize_switch_substage_count(self.switch_substage_count)
@property
def output_position_count(self) -> int:
"""Return how many stimulus outputs one acquisition produces."""
return RunConfigModel.MULTI_DEVICE_MASTER_PORTS * self.switch_substage_count
@property @property
def using_mock_backend(self) -> bool: def using_mock_backend(self) -> bool:
@@ -92,6 +128,7 @@ class MultiDeviceLibreVnaService:
master_serial_number=self.master_serial, master_serial_number=self.master_serial,
slave_serial_numbers=self.slave_serials, slave_serial_numbers=self.slave_serials,
force_external_reference=self.force_external_reference, force_external_reference=self.force_external_reference,
switch_substage_count=self.switch_substage_count,
) )
logger.info( logger.info(
"Multi-device controller opened (master=%s, slaves=%s)", "Multi-device controller opened (master=%s, slaves=%s)",
@@ -198,6 +235,7 @@ class MultiDeviceLibreVnaService:
points=int(sweep.points), points=int(sweep.points),
if_bandwidth=int(round(float(sweep.if_bandwidth_hz))), if_bandwidth=int(round(float(sweep.if_bandwidth_hz))),
power_dbm=float(sweep.power_dbm), power_dbm=float(sweep.power_dbm),
dwell_us=int(sweep.dwell_us),
) )
logger.debug( logger.debug(
"Multi-device configured: %s-%s Hz, %s points, IFBW=%s Hz, %s dBm", "Multi-device configured: %s-%s Hz, %s points, IFBW=%s Hz, %s dBm",
@@ -347,14 +385,17 @@ class MultiDeviceLibreVnaService:
} }
frequencies = np.asarray(result.frequencies_hz, dtype=np.float32) frequencies = np.asarray(result.frequencies_hz, dtype=np.float32)
forward_by_output, reflection_by_output = _build_s_parameter_layout(
self.output_position_count, len(self.slave_serials)
)
traces: list[TraceData] = [] traces: list[TraceData] = []
for output_pos in range(RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS): for output_pos in range(self.output_position_count):
reflection = self._required_s_parameter( reflection = self._required_s_parameter(
normalized_s_parameters, normalized_s_parameters,
"s11" if output_pos == 0 else "s22", reflection_by_output[output_pos],
) )
for input_pos, s_parameter_name in enumerate(_INPUT_S_PARAMETERS_BY_OUTPUT[output_pos]): for input_pos, s_parameter_name in enumerate(forward_by_output[output_pos]):
traces.append( traces.append(
TraceData( TraceData(
combo=ComboKey(input=input_pos, output=output_pos), combo=ComboKey(input=input_pos, output=output_pos),
@@ -388,7 +429,7 @@ class MultiDeviceLibreVnaService:
) )
base_phase = 2.0 * math.pi * np.linspace(0.0, 1.0, points, dtype=np.float32) + self._mock_phase base_phase = 2.0 * math.pi * np.linspace(0.0, 1.0, points, dtype=np.float32) + self._mock_phase
traces: list[TraceData] = [] traces: list[TraceData] = []
for output_pos in range(RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS): for output_pos in range(self.output_position_count):
reflected_phase = base_phase * (0.55 + 0.05 * output_pos) + 0.7 * (output_pos + 1) reflected_phase = base_phase * (0.55 + 0.05 * output_pos) + 0.7 * (output_pos + 1)
s11 = ( s11 = (
(0.22 + 0.04 * output_pos) * np.cos(reflected_phase) (0.22 + 0.04 * output_pos) * np.cos(reflected_phase)
+9 -1
View File
@@ -193,6 +193,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
sweep_payload, "if_bandwidth_hz", model.radar.sweep.if_bandwidth_hz sweep_payload, "if_bandwidth_hz", model.radar.sweep.if_bandwidth_hz
) )
model.radar.sweep.power_dbm = _read_float(sweep_payload, "stimulus_power_dbm", model.radar.sweep.power_dbm) model.radar.sweep.power_dbm = _read_float(sweep_payload, "stimulus_power_dbm", model.radar.sweep.power_dbm)
model.radar.sweep.dwell_us = _read_int(sweep_payload, "dwell_us", model.radar.sweep.dwell_us)
slave_serials_payload = multi_device_payload.get( slave_serials_payload = multi_device_payload.get(
"slave_serials", "slave_serials",
multi_device_payload.get("slave_serial_numbers", model.radar.multi_device.slave_serials), multi_device_payload.get("slave_serial_numbers", model.radar.multi_device.slave_serials),
@@ -229,6 +230,11 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
"input_switch_positions", "input_switch_positions",
model.radar.multi_device.input_switch_positions, model.radar.multi_device.input_switch_positions,
) )
model.radar.multi_device.stimulus_switch_positions = _read_int(
multi_device_payload,
"stimulus_switch_positions",
model.radar.multi_device.stimulus_switch_positions,
)
model.radar.kamil_adc.project_dir = _read_str( model.radar.kamil_adc.project_dir = _read_str(
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
) )
@@ -491,6 +497,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"stop_hz": model.radar.sweep.stop_hz, "stop_hz": model.radar.sweep.stop_hz,
"if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz, "if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz,
"stimulus_power_dbm": model.radar.sweep.power_dbm, "stimulus_power_dbm": model.radar.sweep.power_dbm,
"dwell_us": model.radar.sweep.dwell_us,
} }
if not model.is_kamil_adc: if not model.is_kamil_adc:
sweep_payload["points"] = model.radar.sweep.points sweep_payload["points"] = model.radar.sweep.points
@@ -509,7 +516,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"force_external_reference": model.radar.multi_device.force_external_reference, "force_external_reference": model.radar.multi_device.force_external_reference,
"recovery_attempts": model.radar.multi_device.recovery_attempts, "recovery_attempts": model.radar.multi_device.recovery_attempts,
"output_switch_positions": model.radar.multi_device.output_switch_positions, "output_switch_positions": model.radar.multi_device.output_switch_positions,
"input_switch_positions": model.radar.multi_device.input_switch_positions "input_switch_positions": model.radar.multi_device.input_switch_positions,
"stimulus_switch_positions": model.radar.multi_device.stimulus_switch_positions
}, },
"kamil_adc": { "kamil_adc": {
"project_dir": model.radar.kamil_adc.project_dir, "project_dir": model.radar.kamil_adc.project_dir,
+32 -1
View File
@@ -31,6 +31,10 @@ class RadarSweepModel:
points: int = 1 points: int = 1
if_bandwidth_hz: float = 1.0 if_bandwidth_hz: float = 1.0
power_dbm: float = -30.0 power_dbm: float = -30.0
# Extra per-stage settling requested from the device, on top of its own PLL
# settling delay (60 us by default). With per-point stimulus switching this is the
# window the switch controller has to move the switch between stages.
dwell_us: int = 0
@dataclass(slots=True) @dataclass(slots=True)
@@ -42,6 +46,10 @@ class RadarMultiDeviceModel:
recovery_attempts: int = 3 recovery_attempts: int = 3
output_switch_positions: int = 1 # 1 = свитча нет output_switch_positions: int = 1 # 1 = свитча нет
input_switch_positions: int = 1 # 1 = свитча нет input_switch_positions: int = 1 # 1 = свитча нет
# Positions of the stimulus switch stepped through WITHIN one frequency point,
# driven by the controller in the hardware trigger loop rather than by this host.
# 1 = выключено (стоковое поведение). Требует доработанного битстрима мастера.
stimulus_switch_positions: int = 1
@dataclass(slots=True) @dataclass(slots=True)
@@ -354,6 +362,10 @@ class RunConfigModel:
SN9000_MODEL = "sn9000" SN9000_MODEL = "sn9000"
MULTI_DEVICE_INPUT_POSITIONS = 4 MULTI_DEVICE_INPUT_POSITIONS = 4
MULTI_DEVICE_OUTPUT_POSITIONS = 2 MULTI_DEVICE_OUTPUT_POSITIONS = 2
# Stimulus ports the master itself owns. The virtual output axis is this times the
# per-point stimulus switch positions, so it equals MULTI_DEVICE_OUTPUT_POSITIONS
# unless per-point switching is enabled.
MULTI_DEVICE_MASTER_PORTS = 2
@staticmethod @staticmethod
def build_full_combos(input_positions: int, output_positions: int) -> list[ComboModel]: def build_full_combos(input_positions: int, output_positions: int) -> list[ComboModel]:
@@ -406,6 +418,25 @@ class RunConfigModel:
return 1 return 1
return max(1, int(self.radar.multi_device.input_switch_positions)) return max(1, int(self.radar.multi_device.input_switch_positions))
@property
def matrix_stimulus_switch_positions(self) -> int:
"""Positions the stimulus switch steps through inside one frequency point."""
if not self.is_multi_device:
return 1
return max(1, int(self.radar.multi_device.stimulus_switch_positions))
@property
def matrix_virtual_output_positions(self) -> int:
"""Virtual output axis size produced by one acquisition.
Per-point stimulus switching multiplies the master's own ports, because each
port is measured once per switch position at every frequency — so those extra
outputs cost no extra sweep and are NOT driven by this host.
"""
if not self.is_multi_device:
return self.MULTI_DEVICE_OUTPUT_POSITIONS
return self.MULTI_DEVICE_MASTER_PORTS * self.matrix_stimulus_switch_positions
def build_runtime_combos(self) -> list[ComboModel]: def build_runtime_combos(self) -> list[ComboModel]:
"""Build the combo matrix from the effective switch axis sizes.""" """Build the combo matrix from the effective switch axis sizes."""
return self.build_full_combos(self.input_switch.positions, self.output_switch.positions) return self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
@@ -477,7 +508,7 @@ class RunConfigModel:
self.output_switch.driver_mode = "mock" self.output_switch.driver_mode = "mock"
self.output_switch.driver = self.output_switch.driver or "h7992" self.output_switch.driver = self.output_switch.driver or "h7992"
self.output_switch.radar_port = 1 self.output_switch.radar_port = 1
self.output_switch.positions = out_physical * self.MULTI_DEVICE_OUTPUT_POSITIONS self.output_switch.positions = out_physical * self.matrix_virtual_output_positions
self.output_switch.default_position = 0 self.output_switch.default_position = 0
self.input_switch.name = self.input_switch.name or "virtual_input" self.input_switch.name = self.input_switch.name or "virtual_input"
+143 -2
View File
@@ -21,15 +21,26 @@ from python_app.hardware_full.librevna_multi_device_driver.cycle_collection impo
_CrossDeviceCycleSynchronizer, _CrossDeviceCycleSynchronizer,
_expected_sweep_cycle_seconds, _expected_sweep_cycle_seconds,
_is_transient_collection_error, _is_transient_collection_error,
build_forward_s_parameter_names,
calculate_last_cycle_s_parameters, calculate_last_cycle_s_parameters,
) )
from python_app.hardware_full.librevna_multi_device_driver.exceptions import TransientCollectionError from python_app.hardware_full.librevna_multi_device_driver.exceptions import TransientCollectionError
from python_app.hardware_full.librevna_multi_device_driver.models import normalize_master_stimulus_ports from python_app.hardware_full.librevna_multi_device_driver.models import (
from python_app.hardware_full.librevna_multi_device_driver.protocol import parse_vna_datapoint_payload SweepConfiguration,
build_output_channels,
normalize_master_stimulus_ports,
)
from python_app.hardware_full.librevna_multi_device_driver.protocol import (
DISABLED_PORT_STAGE,
build_sweep_settings_payload,
parse_vna_datapoint_payload,
)
from python_app.hardware_full.multi_device_service import ( from python_app.hardware_full.multi_device_service import (
_MAX_TRANSIENT_COLLECTION_RETRIES, _MAX_TRANSIENT_COLLECTION_RETRIES,
MultiDeviceLibreVnaService, MultiDeviceLibreVnaService,
_build_s_parameter_layout,
) )
from python_app.models.run_config_model import RadarSweepModel
def _control_frame(packet_type: NativePacketType, payload: bytes) -> bytes: def _control_frame(packet_type: NativePacketType, payload: bytes) -> bytes:
@@ -372,5 +383,135 @@ class TransientRetryTierTest(unittest.TestCase):
self.assertEqual(service.collection_calls, 1) # fatal escalates immediately, no in-place retry self.assertEqual(service.collection_calls, 1) # fatal escalates immediately, no in-place retry
class PerPointStimulusSwitchingTest(unittest.TestCase):
"""Per-point stimulus switching: one output per (master port, switch position).
Without hardware these pin the contract the modified master bitstream and the
trigger-loop switch controller have to agree with.
"""
def test_disabled_switching_keeps_the_stock_layout(self) -> None:
channels = build_output_channels((1, 2), 1)
self.assertEqual(
[(c.index, c.stimulus_port, c.stage_index) for c in channels],
[(0, 1, 0), (1, 2, 1)],
)
def test_two_positions_give_four_outputs(self) -> None:
channels = build_output_channels((1, 2), 2)
# Outputs are switch-position-major, so outputs 0/1 stay exactly what they are
# without switching; stages are port-major, matching the bitstream taking the
# port from the stage counter's UPPER bits.
self.assertEqual(
[(c.index, c.stimulus_port, c.stage_index) for c in channels],
[(0, 1, 0), (1, 2, 2), (2, 1, 1), (3, 2, 3)],
)
def test_switch_position_is_the_low_stage_bit(self) -> None:
# This is what lets the controller just invert its pin on every trigger cycle
# instead of tracking an absolute stage number.
channels = build_output_channels((1, 2), 2)
for channel in channels:
expected_position = channel.index // 2 # switch-position-major output order
self.assertEqual(channel.stage_index & 1, expected_position)
def test_stage_budget_is_enforced(self) -> None:
build_output_channels((1, 2), 4) # 8 stages exactly fits
with self.assertRaises(ValueError):
build_output_channels((1, 2), 8) # 16 stages does not
for bad in (0, 3, -1):
with self.assertRaises(ValueError):
build_output_channels((1, 2), bad)
def test_trace_names_do_not_collide_and_stay_stable(self) -> None:
two_output_names = build_forward_s_parameter_names(2, build_output_channels((1, 2), 1))
self.assertEqual(
two_output_names,
["S11", "S31", "S41", "S51", "S61", "S22", "S32", "S42", "S52", "S62"],
)
four_output_names = build_forward_s_parameter_names(2, build_output_channels((1, 2), 2))
# Receivers move to ports 5..8 so no forward term can be named like a
# reflection (a fixed base of 3 would make receiver 3 <- output 3 collide
# with the reflection of output 3).
self.assertEqual(len(four_output_names), len(set(four_output_names)))
self.assertEqual(len(four_output_names), 4 * 5)
for reflection in ("S11", "S22", "S33", "S44"):
self.assertIn(reflection, four_output_names)
def test_service_and_collector_agree_on_trace_names(self) -> None:
# The service looks these up by name in whatever the collector produced, so a
# silent divergence would surface only as a missing-trace failure on hardware.
for substage_count in (1, 2):
channels = build_output_channels((1, 2), substage_count)
produced = {name.lower() for name in build_forward_s_parameter_names(2, channels)}
forward_by_output, reflection_by_output = _build_s_parameter_layout(
len(channels), slave_count=2
)
requested = set(reflection_by_output.values())
for names in forward_by_output.values():
requested.update(names)
self.assertEqual(requested, produced, msg=f"substages={substage_count}")
def test_sweep_settings_carry_stages_and_substage_width(self) -> None:
configuration = SweepConfiguration(
start_hz=1_000_000, stop_hz=2_000_000, points=101, if_bandwidth=1000, power_dbm=-10.0
)
master = build_sweep_settings_payload(
configuration,
is_synchronization_master=True,
synchronization_enabled=True,
master_stimulus_ports=(1, 2),
switch_substage_count=2,
)
stage_word = struct.unpack_from("<H", master, 25)[0]
self.assertEqual(stage_word & 0x07, 3) # 4 stages -> last index 3
self.assertEqual((stage_word >> 3) & 0x07, 0) # port 1 selected by upper stage bits 0
self.assertEqual((stage_word >> 6) & 0x07, 1) # port 2 -> 1
self.assertEqual((stage_word >> 9) & 0x07, 1) # log2(2 positions) = 1 substage bit
slave = build_sweep_settings_payload(
configuration,
is_synchronization_master=False,
synchronization_enabled=True,
master_stimulus_ports=(1, 2),
switch_substage_count=2,
)
slave_stage_word = struct.unpack_from("<H", slave, 25)[0]
self.assertEqual(slave_stage_word & 0x07, 3) # same stage count as the master
# Receivers must never excite. The stock "one past the last stage" sentinel is
# unusable once the port comes from the upper stage bits, so both ports are
# parked at the explicit disabled value instead.
self.assertEqual((slave_stage_word >> 3) & 0x07, DISABLED_PORT_STAGE)
self.assertEqual((slave_stage_word >> 6) & 0x07, DISABLED_PORT_STAGE)
def test_disabled_switching_sends_the_stock_stage_word(self) -> None:
configuration = SweepConfiguration(
start_hz=1_000_000, stop_hz=2_000_000, points=101, if_bandwidth=1000, power_dbm=-10.0
)
payload = build_sweep_settings_payload(
configuration,
is_synchronization_master=True,
synchronization_enabled=True,
master_stimulus_ports=(1, 2),
)
stage_word = struct.unpack_from("<H", payload, 25)[0]
self.assertEqual(stage_word & 0x07, 1) # 2 stages
self.assertEqual((stage_word >> 3) & 0x07, 0)
self.assertEqual((stage_word >> 6) & 0x07, 1)
self.assertEqual((stage_word >> 9) & 0x07, 0) # no substage bits -> stock bitstream
def test_service_reports_four_outputs_when_switching(self) -> None:
service = MultiDeviceLibreVnaService(
master_serial="M", slave_serials=["A", "B"], backend_mode="mock", switch_substage_count=2
)
self.assertEqual(service.output_position_count, 4)
service.configure(RadarSweepModel(start_hz=1e6, stop_hz=2e6, points=4, if_bandwidth_hz=1e3))
collection = service.acquire_collection(collection_id=1)
self.assertEqual(len(collection.traces), 4 * 4)
self.assertEqual(
sorted({trace.combo.output for trace in collection.traces}), [0, 1, 2, 3]
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -13,6 +13,7 @@ from unittest import mock
import numpy as np import numpy as np
from python_app.hardware_full.matrix_radar_service import create_matrix_radar_service
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
@@ -213,5 +214,40 @@ class ManualComboCaptureUsesTargetedAcquisitionTest(unittest.TestCase):
self.assertEqual(inner_acquire.call_count, 2) self.assertEqual(inner_acquire.call_count, 2)
class PerPointStimulusSwitchingConfigTest(unittest.TestCase):
"""Per-point stimulus switching widens the matrix without a host-driven switch."""
@staticmethod
def _config(stimulus_switch_positions: int) -> RunConfigModel:
config = RunConfigModel()
config.radar.model = RunConfigModel.LIBREVNA_MULTI_MODEL
config.radar.driver_mode = "mock"
config.radar.multi_device.slave_serials = ["SLAVE_A", "SLAVE_B"]
config.radar.multi_device.stimulus_switch_positions = stimulus_switch_positions
config.apply_device_model_constraints()
return config
def test_two_positions_double_the_output_axis(self) -> None:
config = self._config(2)
self.assertEqual(config.matrix_virtual_output_positions, 4)
self.assertEqual(config.output_switch.positions, 4)
self.assertEqual(config.input_switch.positions, 4)
self.assertEqual(len(config.combos), 16)
def test_host_does_not_drive_any_switch(self) -> None:
# The switch is stepped by the controller in the hardware trigger loop, so the
# host must acquire the whole 4x4 matrix in ONE sweep and never wrap the
# service in the sweep-per-step widener.
service = create_matrix_radar_service(self._config(2))
self.assertIsInstance(service, MultiDeviceLibreVnaService)
self.assertNotIsInstance(service, SwitchedMatrixRadarService)
self.assertEqual(service.output_position_count, 4)
def test_disabled_switching_keeps_the_two_output_matrix(self) -> None:
config = self._config(1)
self.assertEqual(config.matrix_virtual_output_positions, 2)
self.assertEqual(len(config.combos), 8)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()