|
|
|
@@ -14,7 +14,6 @@ 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,
|
|
|
|
@@ -52,7 +51,7 @@ _MIN_CYCLE_ALIGNMENT_TOLERANCE_SECONDS = 0.5e-3
|
|
|
|
|
|
|
|
|
|
# Below this magnitude a master reference value is treated as a corrupt incident
|
|
|
|
|
# signal rather than a real measurement: dividing by it would explode the
|
|
|
|
|
# S-parameter into noise. Mirrors the single-device C++ driver's reference guard.
|
|
|
|
|
# S-parameter into noise. Both acquisition drivers reject such references.
|
|
|
|
|
_MIN_REFERENCE_MAGNITUDE = 1e-12
|
|
|
|
|
|
|
|
|
|
# A dropped USB frame leaves a single sweep point undelivered. Instead of
|
|
|
|
@@ -76,10 +75,12 @@ class _DeviceCollectionState:
|
|
|
|
|
points are zero-filled when the S-parameters are computed.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__slots__ = ("last_cycle_closed",)
|
|
|
|
|
__slots__ = ("last_cycle_closed", "capture_start_ns", "capture_end_ns")
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.last_cycle_closed = False
|
|
|
|
|
self.capture_start_ns = 0
|
|
|
|
|
self.capture_end_ns = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _CrossDeviceCycleSynchronizer:
|
|
|
|
@@ -168,20 +169,16 @@ def _is_transient_collection_error(error: BaseException) -> bool:
|
|
|
|
|
return isinstance(error, (TransientCollectionError, TimeoutError))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def collect_complete_running_sweep_cycles(
|
|
|
|
|
def collect_complete_running_sweep_cycle(
|
|
|
|
|
*,
|
|
|
|
|
master_device_connection: LibreVnaUsbBulkConnection,
|
|
|
|
|
slave_device_connections: Sequence[LibreVnaUsbBulkConnection],
|
|
|
|
|
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."""
|
|
|
|
|
cycle_count = int(cycle_count)
|
|
|
|
|
if cycle_count < 1:
|
|
|
|
|
raise ValueError("cycle_count must be >= 1")
|
|
|
|
|
"""Collect one complete cycle from an already-running synchronized sweep."""
|
|
|
|
|
stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports)
|
|
|
|
|
# One output per (master port, stimulus switch position). With no per-point
|
|
|
|
|
# switching this is one output per port, exactly as before.
|
|
|
|
@@ -202,18 +199,14 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
frequencies_hz = np.array([float(active_sweep_configuration.start_hz)], dtype=np.float64)
|
|
|
|
|
# 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)
|
|
|
|
|
channel.index: np.full(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), output_channels
|
|
|
|
|
)
|
|
|
|
|
(input_pos, channel.index): np.full(point_count, np.nan + 1j * np.nan, dtype=complex)
|
|
|
|
|
for channel in output_channels
|
|
|
|
|
for input_pos in range(2 * len(slave_device_connections))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
collection_errors: list[Exception] = []
|
|
|
|
@@ -315,12 +308,12 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
``stop_collection_requested`` is set so the other collector threads stop too.
|
|
|
|
|
"""
|
|
|
|
|
datapoints_received = 0
|
|
|
|
|
expected_datapoint_count = cycle_count * point_count
|
|
|
|
|
expected_datapoint_count = point_count
|
|
|
|
|
loop_start_timestamp = time.monotonic()
|
|
|
|
|
# Tracks the last time we actually accepted a datapoint into the cycle.
|
|
|
|
|
# Crucially, *not* updated on rejected datapoints — a device that keeps
|
|
|
|
|
# streaming valid-looking frames the handler ignores (e.g. waiting on
|
|
|
|
|
# point_index=0, or after cycle_count has been reached) must still hit
|
|
|
|
|
# point_index=0, or after the cycle has closed) must still hit
|
|
|
|
|
# the per-device timeout and trigger recovery instead of looping forever.
|
|
|
|
|
last_consumed_timestamp = loop_start_timestamp
|
|
|
|
|
has_consumed_any_datapoint = False
|
|
|
|
@@ -449,15 +442,14 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
datapoints_received += 1
|
|
|
|
|
|
|
|
|
|
def build_cycle_tracking_handler(
|
|
|
|
|
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
|
|
|
|
|
cycle_aware_handler: Callable[[ParsedVnaDatapoint], None],
|
|
|
|
|
device_state: _DeviceCollectionState,
|
|
|
|
|
device_label: str = "device",
|
|
|
|
|
) -> Callable[[ParsedVnaDatapoint], bool]:
|
|
|
|
|
"""Wrap a cycle-aware handler with cross-device cycle tracking.
|
|
|
|
|
|
|
|
|
|
Returns a per-datapoint handler that anchors cycle 0 on the first
|
|
|
|
|
``point_index == 0`` seen, advances the cycle counter on each point-index
|
|
|
|
|
wrap, drops datapoints past ``cycle_count``, and reports whether each
|
|
|
|
|
``point_index == 0`` seen, closes on the next point-index wrap, and reports whether each
|
|
|
|
|
datapoint was consumed.
|
|
|
|
|
"""
|
|
|
|
|
# In steady state the controller leaves the hardware sweep FREE-RUNNING
|
|
|
|
@@ -468,10 +460,8 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
# the drain can let a wrap slip between devices, that first point 0 is only
|
|
|
|
|
# accepted once `_CrossDeviceCycleSynchronizer` confirms every device saw
|
|
|
|
|
# point 0 of the SAME physical sweep; a mismatch rejects the collection for
|
|
|
|
|
# a transient retry. From the agreed anchor, each subsequent wrap advances
|
|
|
|
|
# the cycle counter normally.
|
|
|
|
|
# a transient retry. The next wrap closes the agreed cycle.
|
|
|
|
|
cycle_tracking_state = {
|
|
|
|
|
"current_cycle_index": 0,
|
|
|
|
|
"synchronized": False,
|
|
|
|
|
# How many mid-sweep points were thrown away before the anchor was found.
|
|
|
|
|
# Near zero means the drain landed on a sweep boundary — the case where a
|
|
|
|
@@ -483,11 +473,12 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
|
|
|
|
|
"""Track the cycle index for one datapoint and dispatch it to the handler.
|
|
|
|
|
|
|
|
|
|
Returns ``True`` when the datapoint was consumed (within ``cycle_count``)
|
|
|
|
|
Returns ``True`` when the datapoint was consumed (within the first cycle)
|
|
|
|
|
and ``False`` when it was ignored (pre-sync straggler or past the last
|
|
|
|
|
requested cycle).
|
|
|
|
|
"""
|
|
|
|
|
current_point_index = parsed_datapoint.point_index
|
|
|
|
|
received_ns = time.monotonic_ns()
|
|
|
|
|
|
|
|
|
|
if not cycle_tracking_state["synchronized"]:
|
|
|
|
|
if current_point_index != 0:
|
|
|
|
@@ -497,10 +488,11 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
# observed point 0 of the SAME physical sweep; otherwise reject the
|
|
|
|
|
# whole collection so the caller re-drains and re-collects rather
|
|
|
|
|
# than pairing a reference and measurement from different sweeps.
|
|
|
|
|
if not cycle_synchronizer.confirm_aligned_cycle_zero(time.monotonic()):
|
|
|
|
|
if not cycle_synchronizer.confirm_aligned_cycle_zero(received_ns / 1e9):
|
|
|
|
|
report_cycle_misalignment()
|
|
|
|
|
return False
|
|
|
|
|
cycle_tracking_state["synchronized"] = True
|
|
|
|
|
device_state.capture_start_ns = device_state.capture_end_ns = received_ns
|
|
|
|
|
logger.debug(
|
|
|
|
|
"timing: %s anchored cycle 0 after skipping %d mid-sweep point(s) of %d "
|
|
|
|
|
"(t=%.1f ms)",
|
|
|
|
@@ -509,53 +501,21 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
point_count,
|
|
|
|
|
time.monotonic() * 1e3,
|
|
|
|
|
)
|
|
|
|
|
cycle_aware_handler(parsed_datapoint, 0)
|
|
|
|
|
cycle_aware_handler(parsed_datapoint)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# A new sweep cycle begins exactly when the point index wraps to 0.
|
|
|
|
|
# Keying on ==0 (rather than "the index decreased") means a corrupt
|
|
|
|
|
# mid-cycle index that slipped past the in-band gate cannot fabricate a
|
|
|
|
|
# spurious wrap and desynchronize the cycle counter.
|
|
|
|
|
if current_point_index == 0:
|
|
|
|
|
cycle_tracking_state["current_cycle_index"] += 1
|
|
|
|
|
logger.debug(
|
|
|
|
|
"timing: %s first point of NEXT sweep arrived (cycle -> %d, t=%.1f ms)",
|
|
|
|
|
device_label,
|
|
|
|
|
cycle_tracking_state["current_cycle_index"],
|
|
|
|
|
time.monotonic() * 1e3,
|
|
|
|
|
)
|
|
|
|
|
current_cycle_index = cycle_tracking_state["current_cycle_index"]
|
|
|
|
|
if current_cycle_index >= cycle_count:
|
|
|
|
|
# The sweep just wrapped past the final requested cycle, closing its
|
|
|
|
|
# collection window. Any of its points we never received are now
|
|
|
|
|
# confirmed dropped frames, not points still in flight, so flag the
|
|
|
|
|
# device done and let the collector thread stop immediately. The
|
|
|
|
|
# missing points are emitted as zero when the S-parameters are
|
|
|
|
|
# computed; delivered points keep their real values.
|
|
|
|
|
# A second point 0 closes this cycle; absent points are now losses.
|
|
|
|
|
device_state.last_cycle_closed = True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
cycle_aware_handler(parsed_datapoint, current_cycle_index)
|
|
|
|
|
if current_point_index == point_count - 1:
|
|
|
|
|
logger.debug(
|
|
|
|
|
"timing: %s last point of cycle %d arrived (index=%d, t=%.1f ms)",
|
|
|
|
|
device_label,
|
|
|
|
|
current_cycle_index,
|
|
|
|
|
current_point_index,
|
|
|
|
|
time.monotonic() * 1e3,
|
|
|
|
|
)
|
|
|
|
|
cycle_aware_handler(parsed_datapoint)
|
|
|
|
|
device_state.capture_end_ns = received_ns
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return handle_datapoint
|
|
|
|
|
|
|
|
|
|
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 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.
|
|
|
|
|
"""
|
|
|
|
|
def handle_master_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> None:
|
|
|
|
|
"""Store the frequency and incident reference for each output stage."""
|
|
|
|
|
point_index = parsed_datapoint.point_index
|
|
|
|
|
frequencies_hz[point_index] = parsed_datapoint.frequency_hz
|
|
|
|
|
|
|
|
|
@@ -575,45 +535,20 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
if reference_receiver_value is None:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
master_reference_measurements_by_output[channel.number][
|
|
|
|
|
cycle_index,
|
|
|
|
|
master_reference_measurements_by_output[channel.index][
|
|
|
|
|
point_index,
|
|
|
|
|
] = reference_receiver_value
|
|
|
|
|
|
|
|
|
|
s_parameter_name = master_reflection_s_parameter_name(channel)
|
|
|
|
|
port_receiver_value = find_receiver_value(
|
|
|
|
|
parsed_datapoint.receiver_values_by_description_mask,
|
|
|
|
|
stage_index=channel.stage_index,
|
|
|
|
|
is_reference_receiver=False,
|
|
|
|
|
required_port_number=channel.stimulus_port,
|
|
|
|
|
)
|
|
|
|
|
if port_receiver_value is not None:
|
|
|
|
|
raw_receiver_measurements_by_s_parameter[s_parameter_name][
|
|
|
|
|
cycle_index,
|
|
|
|
|
point_index,
|
|
|
|
|
] = port_receiver_value
|
|
|
|
|
|
|
|
|
|
def build_slave_datapoint_handler(
|
|
|
|
|
slave_index: int, device_state: _DeviceCollectionState
|
|
|
|
|
) -> Callable[[ParsedVnaDatapoint], bool]:
|
|
|
|
|
"""Build a cycle-tracking datapoint handler for the given slave device.
|
|
|
|
|
|
|
|
|
|
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 = slave_receiver_base_port(slave_index, output_channels)
|
|
|
|
|
|
|
|
|
|
def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
|
|
|
|
|
"""Collect both physical receivers of this slave in the configured order."""
|
|
|
|
|
def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> None:
|
|
|
|
|
"""Store this slave's forward receiver values into the measurement buffers."""
|
|
|
|
|
point_index = parsed_datapoint.point_index
|
|
|
|
|
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),
|
|
|
|
|
):
|
|
|
|
|
for receiver_port_number in (1, 2):
|
|
|
|
|
combo = (2 * slave_index + receiver_port_number - 1, channel.index)
|
|
|
|
|
port_receiver_value = find_receiver_value(
|
|
|
|
|
parsed_datapoint.receiver_values_by_description_mask,
|
|
|
|
|
stage_index=channel.stage_index,
|
|
|
|
@@ -621,8 +556,7 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
required_port_number=receiver_port_number,
|
|
|
|
|
)
|
|
|
|
|
if port_receiver_value is not None:
|
|
|
|
|
raw_receiver_measurements_by_s_parameter[s_parameter_name][
|
|
|
|
|
cycle_index,
|
|
|
|
|
raw_receiver_measurements_by_s_parameter[combo][
|
|
|
|
|
point_index,
|
|
|
|
|
] = port_receiver_value
|
|
|
|
|
|
|
|
|
@@ -661,8 +595,7 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
"Collecting %d sweep cycle(s) of %d points from %d device(s) (datapoint_timeout=%.1fs)",
|
|
|
|
|
cycle_count,
|
|
|
|
|
"Collecting one sweep of %d points from %d device(s) (datapoint_timeout=%.1fs)",
|
|
|
|
|
point_count,
|
|
|
|
|
len(all_device_connections),
|
|
|
|
|
datapoint_timeout_seconds,
|
|
|
|
@@ -737,54 +670,20 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
logger.debug("Sweep cycle collection complete (per-device counts: %s)", datapoint_counts_by_device_serial)
|
|
|
|
|
return SweepMeasurementResult(
|
|
|
|
|
frequencies_hz=frequencies_hz,
|
|
|
|
|
s_parameters=calculate_last_cycle_s_parameters(
|
|
|
|
|
s_parameters=calculate_s_parameters(
|
|
|
|
|
raw_receiver_measurements_by_s_parameter,
|
|
|
|
|
master_reference_measurements_by_output,
|
|
|
|
|
),
|
|
|
|
|
capture_start_ns=master_device_state.capture_start_ns,
|
|
|
|
|
capture_end_ns=master_device_state.capture_end_ns,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 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 channel in output_channels:
|
|
|
|
|
names.append(master_reflection_s_parameter_name(channel))
|
|
|
|
|
for slave_index in range(slave_device_count):
|
|
|
|
|
receiver_base_port = slave_receiver_base_port(slave_index, output_channels)
|
|
|
|
|
names.extend(
|
|
|
|
|
[
|
|
|
|
|
f"S{receiver_base_port}{channel.number}",
|
|
|
|
|
f"S{receiver_base_port + 1}{channel.number}",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
return names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def calculate_last_cycle_s_parameters(
|
|
|
|
|
raw_receiver_measurements_by_s_parameter: dict[str, np.ndarray],
|
|
|
|
|
def calculate_s_parameters(
|
|
|
|
|
raw_receiver_measurements_by_s_parameter: dict[tuple[int, 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.
|
|
|
|
|
) -> dict[tuple[int, int], np.ndarray]:
|
|
|
|
|
"""Convert raw receiver captures from the sweep cycle into S-parameters.
|
|
|
|
|
|
|
|
|
|
Points whose value never arrived (a dropped USB frame, still ``NaN``) or whose
|
|
|
|
|
incident reference is unusable (missing or a corrupt ~zero magnitude) cannot be
|
|
|
|
@@ -794,45 +693,40 @@ def calculate_last_cycle_s_parameters(
|
|
|
|
|
genuine fault rather than a stray dropped frame, and the cycle is rejected as
|
|
|
|
|
transient so the caller recovers instead of returning a mostly-zero trace.
|
|
|
|
|
"""
|
|
|
|
|
s_parameters: dict[str, np.ndarray] = {}
|
|
|
|
|
zero_filled_points_by_trace: dict[str, int] = {}
|
|
|
|
|
s_parameters: dict[tuple[int, int], np.ndarray] = {}
|
|
|
|
|
zero_filled_points_by_trace: dict[tuple[int, int], int] = {}
|
|
|
|
|
point_count = 0
|
|
|
|
|
for s_parameter_name, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items():
|
|
|
|
|
# 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]
|
|
|
|
|
for combo, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items():
|
|
|
|
|
master_reference_measurements = master_reference_measurements_by_output[combo[1]]
|
|
|
|
|
|
|
|
|
|
last_cycle_reference = master_reference_measurements[-1]
|
|
|
|
|
last_cycle_measurement = raw_receiver_measurements[-1]
|
|
|
|
|
point_count = last_cycle_measurement.size
|
|
|
|
|
point_count = raw_receiver_measurements.size
|
|
|
|
|
|
|
|
|
|
# Without a valid incident reference no trace at that point can be
|
|
|
|
|
# normalized; without this trace's receiver value there is nothing to
|
|
|
|
|
# normalize. Either way the point is unmeasurable and emitted as zero.
|
|
|
|
|
reference_unusable_mask = np.isnan(last_cycle_reference) | (
|
|
|
|
|
np.abs(last_cycle_reference) <= _MIN_REFERENCE_MAGNITUDE
|
|
|
|
|
reference_unusable_mask = ~np.isfinite(master_reference_measurements) | (
|
|
|
|
|
np.abs(master_reference_measurements) <= _MIN_REFERENCE_MAGNITUDE
|
|
|
|
|
)
|
|
|
|
|
measurement_missing_mask = np.isnan(last_cycle_measurement)
|
|
|
|
|
measurement_missing_mask = ~np.isfinite(raw_receiver_measurements)
|
|
|
|
|
zero_fill_mask = reference_unusable_mask | measurement_missing_mask
|
|
|
|
|
|
|
|
|
|
zero_filled_count = int(zero_fill_mask.sum())
|
|
|
|
|
if point_count and zero_filled_count > _MAX_ZERO_FILLED_POINTS_FRACTION * point_count:
|
|
|
|
|
raise TransientCollectionError(
|
|
|
|
|
f"Trace {s_parameter_name} is unmeasurable at {zero_filled_count}/{point_count} "
|
|
|
|
|
f"Trace {combo} is unmeasurable at {zero_filled_count}/{point_count} "
|
|
|
|
|
f"points (missing reference/measurement); rejecting cycle for recovery"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Substitute neutral operands so the division never produces NaN/inf, then
|
|
|
|
|
# force the unmeasurable points to zero. Delivered points are untouched.
|
|
|
|
|
safe_reference = np.where(reference_unusable_mask, 1.0 + 0j, last_cycle_reference)
|
|
|
|
|
safe_measurement = np.where(measurement_missing_mask, 0.0 + 0j, last_cycle_measurement)
|
|
|
|
|
safe_reference = np.where(reference_unusable_mask, 1.0 + 0j, master_reference_measurements)
|
|
|
|
|
safe_measurement = np.where(measurement_missing_mask, 0.0 + 0j, raw_receiver_measurements)
|
|
|
|
|
s_parameter_values = safe_measurement / safe_reference
|
|
|
|
|
s_parameter_values[zero_fill_mask] = 0.0
|
|
|
|
|
|
|
|
|
|
if zero_filled_count:
|
|
|
|
|
zero_filled_points_by_trace[s_parameter_name] = zero_filled_count
|
|
|
|
|
s_parameters[s_parameter_name.lower()] = s_parameter_values
|
|
|
|
|
zero_filled_points_by_trace[combo] = zero_filled_count
|
|
|
|
|
s_parameters[combo] = s_parameter_values
|
|
|
|
|
|
|
|
|
|
if zero_filled_points_by_trace:
|
|
|
|
|
# One concise line per affected cycle (a rare dropped-frame event), so the
|
|
|
|
|