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:
@@ -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(
|
||||
|
||||
@@ -52,6 +52,7 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
|
||||
force_external_reference=config.radar.multi_device.force_external_reference,
|
||||
recovery_attempts=config.radar.multi_device.recovery_attempts,
|
||||
backend_mode=config.radar.driver_mode,
|
||||
switch_substage_count=config.matrix_stimulus_switch_positions,
|
||||
)
|
||||
out_physical = config.matrix_output_switch_positions
|
||||
in_physical = config.matrix_input_switch_positions
|
||||
@@ -67,7 +68,7 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
|
||||
inner=inner,
|
||||
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),
|
||||
inner_output_positions=RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS,
|
||||
inner_output_positions=config.matrix_virtual_output_positions,
|
||||
inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS,
|
||||
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.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.run_config_model import RadarSweepModel, RunConfigModel
|
||||
|
||||
@@ -43,10 +46,33 @@ _MAX_RECOVERY_WALL_SECONDS: float = 10.0
|
||||
# previously caused the periodic freeze.
|
||||
_MAX_TRANSIENT_COLLECTION_RETRIES: int = 2
|
||||
|
||||
_INPUT_S_PARAMETERS_BY_OUTPUT: dict[int, tuple[str, ...]] = {
|
||||
0: ("s31", "s41", "s51", "s61"),
|
||||
1: ("s32", "s42", "s52", "s62"),
|
||||
}
|
||||
def _build_s_parameter_layout(
|
||||
output_count: int, slave_count: int
|
||||
) -> 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)
|
||||
class MultiDeviceLibreVnaService:
|
||||
@@ -57,6 +83,10 @@ class MultiDeviceLibreVnaService:
|
||||
force_external_reference: bool = True
|
||||
recovery_attempts: int = 3
|
||||
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)
|
||||
_sweep_configuration: SweepConfiguration | None = field(init=False, default=None, 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}")
|
||||
self.backend_mode = mode
|
||||
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
|
||||
def using_mock_backend(self) -> bool:
|
||||
@@ -92,6 +128,7 @@ class MultiDeviceLibreVnaService:
|
||||
master_serial_number=self.master_serial,
|
||||
slave_serial_numbers=self.slave_serials,
|
||||
force_external_reference=self.force_external_reference,
|
||||
switch_substage_count=self.switch_substage_count,
|
||||
)
|
||||
logger.info(
|
||||
"Multi-device controller opened (master=%s, slaves=%s)",
|
||||
@@ -198,6 +235,7 @@ class MultiDeviceLibreVnaService:
|
||||
points=int(sweep.points),
|
||||
if_bandwidth=int(round(float(sweep.if_bandwidth_hz))),
|
||||
power_dbm=float(sweep.power_dbm),
|
||||
dwell_us=int(sweep.dwell_us),
|
||||
)
|
||||
logger.debug(
|
||||
"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)
|
||||
|
||||
forward_by_output, reflection_by_output = _build_s_parameter_layout(
|
||||
self.output_position_count, len(self.slave_serials)
|
||||
)
|
||||
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(
|
||||
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(
|
||||
TraceData(
|
||||
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
|
||||
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)
|
||||
s11 = (
|
||||
(0.22 + 0.04 * output_pos) * np.cos(reflected_phase)
|
||||
|
||||
Reference in New Issue
Block a user