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
@@ -17,6 +17,7 @@ from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration,
SweepMeasurementResult,
normalize_master_stimulus_ports,
normalize_switch_substage_count,
)
from python_app.hardware_full.librevna_multi_device_driver.protocol import (
PacketType,
@@ -36,13 +37,21 @@ class MultiDeviceVnaController:
master_serial_number: str,
slave_serial_numbers: Sequence[str] = (),
force_external_reference: bool = True,
switch_substage_count: int = 1,
) -> 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._slave_devices: list[LibreVnaUsbBulkConnection] = []
self._all_devices: list[LibreVnaUsbBulkConnection] = []
self._synchronization_enabled = bool(slave_serial_numbers)
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._last_applied_sweep_configuration: Optional[SweepConfiguration] = None
self._last_master_stimulus_ports: tuple[int, ...] | None = None
@@ -194,6 +203,7 @@ class MultiDeviceVnaController:
active_sweep_configuration=self._last_applied_sweep_configuration,
cycle_count=cycle_count,
master_stimulus_ports=self._last_master_stimulus_ports,
switch_substage_count=self._switch_substage_count,
datapoint_timeout_seconds=datapoint_timeout_seconds,
)
except Exception:
@@ -332,6 +342,7 @@ class MultiDeviceVnaController:
is_synchronization_master=is_synchronization_master,
synchronization_enabled=self._synchronization_enabled,
master_stimulus_ports=master_stimulus_ports,
switch_substage_count=self._switch_substage_count,
),
timeout_seconds=1.0,
retry_count=1,
@@ -14,8 +14,10 @@ from python_app.hardware_full.librevna_multi_device_driver.exceptions import (
TransientCollectionError,
)
from python_app.hardware_full.librevna_multi_device_driver.models import (
OutputChannel,
SweepConfiguration,
SweepMeasurementResult,
build_output_channels,
normalize_master_stimulus_ports,
)
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,
cycle_count: int,
master_stimulus_ports: Sequence[int],
switch_substage_count: int = 1,
datapoint_timeout_seconds: float | None = None,
) -> SweepMeasurementResult:
"""Collect complete cycles from an already-running synchronized sweep."""
@@ -180,7 +183,9 @@ def collect_complete_running_sweep_cycles(
if cycle_count < 1:
raise ValueError("cycle_count must be >= 1")
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]
point_count = active_sweep_configuration.points
@@ -197,13 +202,18 @@ def collect_complete_running_sweep_cycles(
)
else:
frequencies_hz = np.array([float(active_sweep_configuration.start_hz)], dtype=np.float64)
master_reference_measurements_by_port = {
port: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex)
for port in stimulus_ports
# Keyed by 1-based OUTPUT number, not by master port: with per-point switching two
# outputs share a stimulus port but are measured in different stages, so each needs
# 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 = {
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] = []
@@ -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 is flagged with ~10x margin and ~1 ms jitter clears it with ~7x.)
# 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(
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:
"""Store the master device's frequency, reference, and reflection values.
Records the sweep-point frequency and, per active master stimulus port, the
reference receiver value and the matching reflection (S11/S22) into the
cycle/point measurement buffers.
Records the sweep-point frequency and, per output channel, the reference
receiver value and the matching reflection into the cycle/point measurement
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
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(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index,
stage_index=channel.stage_index,
is_reference_receiver=True,
required_port_number=master_stimulus_port,
required_port_number=channel.stimulus_port,
)
if reference_receiver_value is None:
reference_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index,
stage_index=channel.stage_index,
is_reference_receiver=True,
)
if reference_receiver_value is None:
continue
master_reference_measurements_by_port[master_stimulus_port][
master_reference_measurements_by_output[channel.number][
cycle_index,
point_index,
] = reference_receiver_value
receiver_port_number = master_stimulus_port
s_parameter_name = master_reflection_s_parameter_name(master_stimulus_port)
s_parameter_name = master_reflection_s_parameter_name(channel)
port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index,
stage_index=channel.stage_index,
is_reference_receiver=False,
required_port_number=receiver_port_number,
required_port_number=channel.stimulus_port,
)
if port_receiver_value is not None:
raw_receiver_measurements_by_s_parameter[s_parameter_name][
@@ -588,25 +598,25 @@ def collect_complete_running_sweep_cycles(
) -> Callable[[ParsedVnaDatapoint], bool]:
"""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``,
producing forward S-parameters (e.g. S3x/S4x) for each active master
stimulus port.
The slave's two receivers map to the virtual receiver ports numbered just
above the outputs (see :func:`slave_receiver_base_port`), producing one
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:
"""Store this slave's forward receiver values into the measurement buffers."""
point_index = parsed_datapoint.point_index
for master_stimulus_port, stage_index in stage_by_master_port.items():
first_s_parameter_name = f"S{receiver_base_port}{master_stimulus_port}"
second_s_parameter_name = f"S{receiver_base_port + 1}{master_stimulus_port}"
for channel in output_channels:
first_s_parameter_name = f"S{receiver_base_port}{channel.number}"
second_s_parameter_name = f"S{receiver_base_port + 1}{channel.number}"
for receiver_port_number, s_parameter_name in (
(1, first_s_parameter_name),
(2, second_s_parameter_name),
):
port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index,
stage_index=channel.stage_index,
is_reference_receiver=False,
required_port_number=receiver_port_number,
)
@@ -729,29 +739,42 @@ def collect_complete_running_sweep_cycles(
frequencies_hz=frequencies_hz,
s_parameters=calculate_last_cycle_s_parameters(
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:
"""Return master reflection trace name for active output port."""
if master_stimulus_port == 1:
return "S11"
return "S22"
def slave_receiver_base_port(slave_index: int, output_channels: Sequence[OutputChannel]) -> int:
"""Return the first virtual receiver port number of one slave device.
Receivers are numbered directly above the outputs, so the whole topology reads
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."""
names: list[str] = []
for master_stimulus_port in master_stimulus_ports:
names.append(master_reflection_s_parameter_name(master_stimulus_port))
for channel in output_channels:
names.append(master_reflection_s_parameter_name(channel))
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(
[
f"S{receiver_base_port}{master_stimulus_port}",
f"S{receiver_base_port + 1}{master_stimulus_port}",
f"S{receiver_base_port}{channel.number}",
f"S{receiver_base_port + 1}{channel.number}",
]
)
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(
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]:
"""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] = {}
point_count = 0
for s_parameter_name, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items():
master_stimulus_port = int(s_parameter_name[-1])
master_reference_measurements = master_reference_measurements_by_port[master_stimulus_port]
# The trailing digit of every name is the 1-based OUTPUT number, which is what
# 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_measurement = raw_receiver_measurements[-1]
@@ -7,6 +7,11 @@ from dataclasses import dataclass
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, ...]:
"""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
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)
class SweepConfiguration:
"""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 (
SweepConfiguration,
build_output_channels,
normalize_master_stimulus_ports,
normalize_switch_substage_count,
)
@@ -26,6 +28,13 @@ class PacketType:
SYNC_MODE_HARDWARE_TRIGGER = 3
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)
class ParsedVnaDatapoint:
@@ -52,15 +61,31 @@ def build_sweep_settings_payload(
is_synchronization_master: bool,
synchronization_enabled: bool,
master_stimulus_ports: Sequence[int],
switch_substage_count: int = 1,
) -> 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)
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))
dwell_time_microseconds = max(0, min(int(sweep_configuration.dwell_us), 0xFFFF))
synchronization_mode = SYNC_MODE_HARDWARE_TRIGGER if synchronization_enabled else 0
last_stage_index = len(stimulus_ports) - 1
inactive_stage_index = min(last_stage_index + 1, 7)
last_stage_index = len(output_channels) - 1
substage_bits = substage_count.bit_length() - 1
configuration_bits = (
0 << 0
@@ -72,17 +97,23 @@ def build_sweep_settings_payload(
)
if is_synchronization_master:
stage_by_port = {port: stage for stage, port in enumerate(stimulus_ports)}
port_1_stimulus_stage = stage_by_port.get(1, inactive_stage_index)
port_2_stimulus_stage = stage_by_port.get(2, inactive_stage_index)
# The port selector lives in the stage bits ABOVE the substage block, so the
# value sent here is the port's index, not its first stage number.
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:
port_1_stimulus_stage = inactive_stage_index
port_2_stimulus_stage = inactive_stage_index
# Receiver-only devices must never excite.
port_1_stimulus_stage = DISABLED_PORT_STAGE
port_2_stimulus_stage = DISABLED_PORT_STAGE
stage_configuration_bits = (
(last_stage_index & 0x07)
| (port_1_stimulus_stage & 0x07) << 3
| (port_2_stimulus_stage & 0x07) << 6
| (substage_bits & 0x07) << 9
)
return struct.pack(