|
|
|
@@ -10,9 +10,13 @@ import time
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
|
|
|
|
from python_app.hardware_full.librevna_multi_device_driver.exceptions import (
|
|
|
|
|
TransientCollectionError,
|
|
|
|
|
)
|
|
|
|
|
from python_app.hardware_full.librevna_multi_device_driver.models import (
|
|
|
|
|
SweepConfiguration,
|
|
|
|
|
SweepMeasurementResult,
|
|
|
|
|
normalize_master_stimulus_ports,
|
|
|
|
|
)
|
|
|
|
|
from python_app.hardware_full.librevna_multi_device_driver.protocol import (
|
|
|
|
|
PacketType,
|
|
|
|
@@ -39,6 +43,102 @@ _MAX_FULL_CYCLE_SECONDS = 8.0
|
|
|
|
|
# and let the orphan thread die when the producer process exits.
|
|
|
|
|
_THREAD_JOIN_TIMEOUT_SECONDS = 2.0
|
|
|
|
|
|
|
|
|
|
# Floor for the cross-device cycle-0 alignment tolerance (see
|
|
|
|
|
# _CrossDeviceCycleSynchronizer). Keeps the tolerance sane for very fast sweeps
|
|
|
|
|
# where points/IF-bandwidth would otherwise estimate a sub-millisecond cycle.
|
|
|
|
|
_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.
|
|
|
|
|
_MIN_REFERENCE_MAGNITUDE = 1e-12
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _CrossDeviceCycleSynchronizer:
|
|
|
|
|
"""Confirm every device thread anchors cycle 0 on the SAME physical sweep.
|
|
|
|
|
|
|
|
|
|
The devices are hardware-trigger synchronized, so they emit ``point_index==0``
|
|
|
|
|
at the same physical instant. After a host-side queue drain, however, a sweep
|
|
|
|
|
wrap can slip between the per-device drains, leaving one collector anchored to
|
|
|
|
|
physical sweep N and another to N+1. Their reference and measurement would then
|
|
|
|
|
be divided across different sweeps, producing a whole-trace random-phase result
|
|
|
|
|
(the reported "noise sweep").
|
|
|
|
|
|
|
|
|
|
Each collector calls :meth:`confirm_aligned_cycle_zero` the moment it observes
|
|
|
|
|
its first ``point_index==0``, passing the host monotonic time it saw that wrap,
|
|
|
|
|
and the calls rendezvous at a :class:`threading.Barrier`. Because the wrap is
|
|
|
|
|
physically simultaneous, aligned threads observe it within host-latency jitter
|
|
|
|
|
of one another, while an off-by-one thread is ~one sweep period away. If the
|
|
|
|
|
spread of anchor times exceeds ``alignment_tolerance_seconds`` the alignment is
|
|
|
|
|
rejected, so the caller re-drains and re-collects instead of emitting noise.
|
|
|
|
|
|
|
|
|
|
A single device (no slaves) trivially aligns. If a peer never reaches the
|
|
|
|
|
barrier within ``rendezvous_timeout_seconds`` the barrier breaks and every
|
|
|
|
|
waiting thread reports "not aligned", which the caller also treats as a
|
|
|
|
|
transient failure to retry.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
device_count: int,
|
|
|
|
|
alignment_tolerance_seconds: float,
|
|
|
|
|
rendezvous_timeout_seconds: float,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Create a one-shot synchronizer for ``device_count`` collector threads."""
|
|
|
|
|
self._barrier = threading.Barrier(max(1, int(device_count)))
|
|
|
|
|
self._alignment_tolerance_seconds = float(alignment_tolerance_seconds)
|
|
|
|
|
self._rendezvous_timeout_seconds = float(rendezvous_timeout_seconds)
|
|
|
|
|
self._lock = threading.Lock()
|
|
|
|
|
self._anchor_times: list[float] = []
|
|
|
|
|
self._is_aligned = False
|
|
|
|
|
|
|
|
|
|
def confirm_aligned_cycle_zero(self, anchor_monotonic_seconds: float) -> bool:
|
|
|
|
|
"""Block until every device reported cycle 0; return whether all agree.
|
|
|
|
|
|
|
|
|
|
Returns ``True`` only when all devices anchored within the tolerance (same
|
|
|
|
|
physical sweep). Returns ``False`` on misalignment or if the rendezvous
|
|
|
|
|
times out / breaks.
|
|
|
|
|
"""
|
|
|
|
|
with self._lock:
|
|
|
|
|
self._anchor_times.append(anchor_monotonic_seconds)
|
|
|
|
|
try:
|
|
|
|
|
arrival_index = self._barrier.wait(timeout=self._rendezvous_timeout_seconds)
|
|
|
|
|
# Exactly one thread computes the verdict; the second rendezvous
|
|
|
|
|
# publishes it to every thread before any of them reads it.
|
|
|
|
|
if arrival_index == 0:
|
|
|
|
|
spread_seconds = max(self._anchor_times) - min(self._anchor_times)
|
|
|
|
|
self._is_aligned = spread_seconds <= self._alignment_tolerance_seconds
|
|
|
|
|
self._barrier.wait(timeout=self._rendezvous_timeout_seconds)
|
|
|
|
|
except threading.BrokenBarrierError:
|
|
|
|
|
return False
|
|
|
|
|
return self._is_aligned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _expected_sweep_cycle_seconds(point_count: int, stage_count: int, if_bandwidth_hz: float) -> float:
|
|
|
|
|
"""Return a conservative LOWER bound on one sweep cycle's wall-clock duration.
|
|
|
|
|
|
|
|
|
|
Every frequency point is measured once per excitation stage (one stage per
|
|
|
|
|
active master port), each taking at least one IF integration period
|
|
|
|
|
(1 / IF-bandwidth). Real sweeps are longer because of per-point settling/LO
|
|
|
|
|
overhead, so this underestimates — which is exactly what the cycle-alignment
|
|
|
|
|
tolerance wants (a tolerance safely below a true one-sweep off-by-one).
|
|
|
|
|
Measured anchor: 751 points x 2 stages at 50 kHz -> 30 ms here vs a real
|
|
|
|
|
~150 ms sweep.
|
|
|
|
|
"""
|
|
|
|
|
return (int(point_count) * max(1, int(stage_count))) / max(1.0, float(if_bandwidth_hz))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_transient_collection_error(error: BaseException) -> bool:
|
|
|
|
|
"""Return whether a collection error is recoverable without a USB reopen.
|
|
|
|
|
|
|
|
|
|
Timeouts (no-progress / cycle / cycle-start guards) and explicit transient
|
|
|
|
|
errors mean the devices are still alive and only this cycle was lost. A
|
|
|
|
|
genuine transport failure (surfaced as a plain RuntimeError from the USB
|
|
|
|
|
layer) is fatal and must fall through to the heavy recovery path.
|
|
|
|
|
"""
|
|
|
|
|
return isinstance(error, (TransientCollectionError, TimeoutError))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def collect_complete_running_sweep_cycles(
|
|
|
|
|
*,
|
|
|
|
@@ -53,13 +153,7 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
cycle_count = int(cycle_count)
|
|
|
|
|
if cycle_count < 1:
|
|
|
|
|
raise ValueError("cycle_count must be >= 1")
|
|
|
|
|
stimulus_ports = tuple(int(port) for port in master_stimulus_ports)
|
|
|
|
|
if not stimulus_ports:
|
|
|
|
|
raise ValueError("master_stimulus_ports must not be empty")
|
|
|
|
|
if len(stimulus_ports) > 2 or set(stimulus_ports) - {1, 2}:
|
|
|
|
|
raise ValueError("master_stimulus_ports may contain only ports 1 and 2")
|
|
|
|
|
if len(set(stimulus_ports)) != len(stimulus_ports):
|
|
|
|
|
raise ValueError("master_stimulus_ports must not contain duplicates")
|
|
|
|
|
stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports)
|
|
|
|
|
stage_by_master_port = {port: stage for stage, port in enumerate(stimulus_ports)}
|
|
|
|
|
|
|
|
|
|
all_device_connections = [master_device_connection, *slave_device_connections]
|
|
|
|
@@ -79,6 +173,20 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
device_connection.serial_number: 0
|
|
|
|
|
for device_connection in all_device_connections
|
|
|
|
|
}
|
|
|
|
|
rejected_datapoint_counts_by_device_serial = {
|
|
|
|
|
device_connection.serial_number: 0
|
|
|
|
|
for device_connection in all_device_connections
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# In-band frequency gate. A misframed CRC-less datapoint that still parses
|
|
|
|
|
# carries an essentially random 64-bit frequency, which almost never lands in
|
|
|
|
|
# the swept band, so this rejects corrupt points cheaply. One frequency step of
|
|
|
|
|
# margin keeps every legitimate point (which lies within [start, stop]).
|
|
|
|
|
band_low_hz = float(min(active_sweep_configuration.start_hz, active_sweep_configuration.stop_hz))
|
|
|
|
|
band_high_hz = float(max(active_sweep_configuration.start_hz, active_sweep_configuration.stop_hz))
|
|
|
|
|
frequency_step_hz = (band_high_hz - band_low_hz) / (point_count - 1) if point_count > 1 else band_high_hz
|
|
|
|
|
band_low_hz -= frequency_step_hz
|
|
|
|
|
band_high_hz += frequency_step_hz
|
|
|
|
|
stop_collection_requested = threading.Event()
|
|
|
|
|
if datapoint_timeout_seconds is None:
|
|
|
|
|
datapoint_timeout_seconds = LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS
|
|
|
|
@@ -92,6 +200,57 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
# stall capture indefinitely.
|
|
|
|
|
cycle_start_guard_seconds = max(2.0, datapoint_timeout_seconds * 4.0)
|
|
|
|
|
|
|
|
|
|
# Cross-device cycle-0 alignment. The off-by-one race (master anchored to a
|
|
|
|
|
# different physical sweep than a slave) shows up as anchor-time spreads on the
|
|
|
|
|
# order of one sweep period, whereas aligned anchors differ only by host
|
|
|
|
|
# latency jitter. The tolerance is half of a conservative LOWER bound on the
|
|
|
|
|
# sweep period: every frequency point is measured once per excitation stage
|
|
|
|
|
# (one stage per active master port), and the real period is strictly larger
|
|
|
|
|
# still because point_count*stage_count/IF-bandwidth ignores per-point settling
|
|
|
|
|
# overhead. That keeps the tolerance well below a true off-by-one yet well above
|
|
|
|
|
# host jitter. (Measured anchor: 751 points x 2 stages at 50 kHz IF -> a real
|
|
|
|
|
# ~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))
|
|
|
|
|
expected_cycle_seconds = _expected_sweep_cycle_seconds(
|
|
|
|
|
point_count, stage_count, active_sweep_configuration.if_bandwidth
|
|
|
|
|
)
|
|
|
|
|
cycle_alignment_tolerance_seconds = max(
|
|
|
|
|
_MIN_CYCLE_ALIGNMENT_TOLERANCE_SECONDS, 0.5 * expected_cycle_seconds
|
|
|
|
|
)
|
|
|
|
|
# Keep the rendezvous timeout below the collector join deadline: if a device
|
|
|
|
|
# stops streaming, the threads waiting at the barrier then break and exit with
|
|
|
|
|
# a transient error (cheap re-collect) instead of being orphaned into the
|
|
|
|
|
# "failed to stop" path that forces a USB reopen. It still allows several sweep
|
|
|
|
|
# periods for the slowest device to reach its first point 0 after a drain.
|
|
|
|
|
cycle_rendezvous_timeout_seconds = min(
|
|
|
|
|
0.9 * _THREAD_JOIN_TIMEOUT_SECONDS, max(1.0, 5.0 * expected_cycle_seconds)
|
|
|
|
|
)
|
|
|
|
|
cycle_synchronizer = _CrossDeviceCycleSynchronizer(
|
|
|
|
|
device_count=len(all_device_connections),
|
|
|
|
|
alignment_tolerance_seconds=cycle_alignment_tolerance_seconds,
|
|
|
|
|
rendezvous_timeout_seconds=cycle_rendezvous_timeout_seconds,
|
|
|
|
|
)
|
|
|
|
|
cycle_misalignment_reported = threading.Event()
|
|
|
|
|
|
|
|
|
|
def report_cycle_misalignment() -> None:
|
|
|
|
|
"""Record a transient misalignment once and stop all collector threads."""
|
|
|
|
|
if not cycle_misalignment_reported.is_set():
|
|
|
|
|
cycle_misalignment_reported.set()
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Cross-device cycle-0 misalignment detected (tolerance %.3f ms); "
|
|
|
|
|
"rejecting collection for retry",
|
|
|
|
|
cycle_alignment_tolerance_seconds * 1e3,
|
|
|
|
|
)
|
|
|
|
|
collection_errors.append(
|
|
|
|
|
TransientCollectionError(
|
|
|
|
|
"Cross-device cycle-0 misalignment: master reference and slave "
|
|
|
|
|
"measurement would span different sweeps"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
stop_collection_requested.set()
|
|
|
|
|
|
|
|
|
|
def collect_datapoints_from_device(
|
|
|
|
|
device_connection: LibreVnaUsbBulkConnection,
|
|
|
|
|
handle_datapoint: Callable[[ParsedVnaDatapoint], bool],
|
|
|
|
@@ -211,15 +370,25 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
parsed_datapoint = parse_vna_datapoint_payload(payload)
|
|
|
|
|
if parsed_datapoint and 0 <= parsed_datapoint.point_index < point_count:
|
|
|
|
|
datapoint_was_consumed = handle_datapoint(parsed_datapoint)
|
|
|
|
|
if datapoint_was_consumed:
|
|
|
|
|
# Only refreshed on accepted datapoints so the no-progress
|
|
|
|
|
# timeout above stays honest about real cycle progress.
|
|
|
|
|
last_consumed_timestamp = time.monotonic()
|
|
|
|
|
has_consumed_any_datapoint = True
|
|
|
|
|
datapoint_counts_by_device_serial[device_connection.serial_number] += 1
|
|
|
|
|
datapoints_received += 1
|
|
|
|
|
if (
|
|
|
|
|
parsed_datapoint is None
|
|
|
|
|
or not (0 <= parsed_datapoint.point_index < point_count)
|
|
|
|
|
or not (band_low_hz <= parsed_datapoint.frequency_hz <= band_high_hz)
|
|
|
|
|
):
|
|
|
|
|
# Unparseable, out-of-range index, or out-of-band frequency: almost
|
|
|
|
|
# certainly a corrupt/misframed point. Drop it without refreshing the
|
|
|
|
|
# progress timer so a stream of garbage still trips the timeout.
|
|
|
|
|
rejected_datapoint_counts_by_device_serial[device_connection.serial_number] += 1
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
datapoint_was_consumed = handle_datapoint(parsed_datapoint)
|
|
|
|
|
if datapoint_was_consumed:
|
|
|
|
|
# Only refreshed on accepted datapoints so the no-progress
|
|
|
|
|
# timeout above stays honest about real cycle progress.
|
|
|
|
|
last_consumed_timestamp = time.monotonic()
|
|
|
|
|
has_consumed_any_datapoint = True
|
|
|
|
|
datapoint_counts_by_device_serial[device_connection.serial_number] += 1
|
|
|
|
|
datapoints_received += 1
|
|
|
|
|
|
|
|
|
|
def build_cycle_tracking_handler(
|
|
|
|
|
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
|
|
|
|
@@ -231,18 +400,18 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
wrap, drops datapoints past ``cycle_count``, and reports whether each
|
|
|
|
|
datapoint was consumed.
|
|
|
|
|
"""
|
|
|
|
|
# The controller restarts the sweep before every collection, so the
|
|
|
|
|
# first packet each device emits is point 0 of a brand-new cycle 0.
|
|
|
|
|
# Anchoring cycle 0 on the first observed point_index==0 — instead of
|
|
|
|
|
# synthesising it from a wrap — pins master and slave threads to the
|
|
|
|
|
# same physical cycle even if a stale straggler from the just-stopped
|
|
|
|
|
# sweep escaped the post-idle drain: such a straggler always carries
|
|
|
|
|
# a non-zero point_index and is discarded until the genuine cycle 0
|
|
|
|
|
# arrives. From that anchor, each subsequent wrap advances the cycle
|
|
|
|
|
# counter normally.
|
|
|
|
|
# In steady state the controller leaves the hardware sweep FREE-RUNNING
|
|
|
|
|
# between collections (the unchanged-config fast path only drains the host
|
|
|
|
|
# queues), so the first datapoint each device emits after a drain is some
|
|
|
|
|
# mid-sweep point, not a fresh point 0. Each thread therefore discards
|
|
|
|
|
# until the next observed point_index==0 and anchors cycle 0 there. Because
|
|
|
|
|
# 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.
|
|
|
|
|
cycle_tracking_state = {
|
|
|
|
|
"current_cycle_index": 0,
|
|
|
|
|
"previous_point_index": -1,
|
|
|
|
|
"synchronized": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -258,14 +427,23 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
if not cycle_tracking_state["synchronized"]:
|
|
|
|
|
if current_point_index != 0:
|
|
|
|
|
return False
|
|
|
|
|
# Candidate cycle 0. Commit it only once every device confirms it
|
|
|
|
|
# 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()):
|
|
|
|
|
report_cycle_misalignment()
|
|
|
|
|
return False
|
|
|
|
|
cycle_tracking_state["synchronized"] = True
|
|
|
|
|
cycle_tracking_state["previous_point_index"] = current_point_index
|
|
|
|
|
cycle_aware_handler(parsed_datapoint, 0)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if current_point_index < cycle_tracking_state["previous_point_index"]:
|
|
|
|
|
# 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
|
|
|
|
|
cycle_tracking_state["previous_point_index"] = current_point_index
|
|
|
|
|
current_cycle_index = cycle_tracking_state["current_cycle_index"]
|
|
|
|
|
if current_cycle_index >= cycle_count:
|
|
|
|
|
return False
|
|
|
|
@@ -419,7 +597,15 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if collection_errors:
|
|
|
|
|
raise RuntimeError(f"Sweep collection failed: {collection_errors[0]}") from collection_errors[0]
|
|
|
|
|
first_error = collection_errors[0]
|
|
|
|
|
# Timeouts / no-progress / misalignment leave the devices healthy: surface
|
|
|
|
|
# them as transient so the caller re-arms and re-collects instead of doing a
|
|
|
|
|
# full USB reopen. A genuine transport failure (e.g. USB death) is fatal.
|
|
|
|
|
if _is_transient_collection_error(first_error):
|
|
|
|
|
raise TransientCollectionError(
|
|
|
|
|
f"Sweep collection failed transiently: {first_error}"
|
|
|
|
|
) from first_error
|
|
|
|
|
raise RuntimeError(f"Sweep collection failed: {first_error}") from first_error
|
|
|
|
|
|
|
|
|
|
if slave_device_connections and min(datapoint_counts_by_device_serial.values(), default=0) == 0:
|
|
|
|
|
logger.error(
|
|
|
|
@@ -427,11 +613,16 @@ def collect_complete_running_sweep_cycles(
|
|
|
|
|
"(per-device counts: %s)",
|
|
|
|
|
datapoint_counts_by_device_serial,
|
|
|
|
|
)
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
raise TransientCollectionError(
|
|
|
|
|
"No datapoints received from at least one device; hardware trigger sync did not start. "
|
|
|
|
|
"Check Trigger Out/In loop and 10 MHz reference wiring."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if any(rejected_datapoint_counts_by_device_serial.values()):
|
|
|
|
|
logger.debug(
|
|
|
|
|
"Sweep cycle collection rejected corrupt datapoints (per-device: %s)",
|
|
|
|
|
rejected_datapoint_counts_by_device_serial,
|
|
|
|
|
)
|
|
|
|
|
logger.debug("Sweep cycle collection complete (per-device counts: %s)", datapoint_counts_by_device_serial)
|
|
|
|
|
return SweepMeasurementResult(
|
|
|
|
|
frequencies_hz=frequencies_hz,
|
|
|
|
@@ -476,13 +667,23 @@ def calculate_last_cycle_s_parameters(
|
|
|
|
|
master_reference_measurements = master_reference_measurements_by_port[master_stimulus_port]
|
|
|
|
|
if np.isnan(master_reference_measurements).any():
|
|
|
|
|
missing_reference_count = int(np.isnan(master_reference_measurements).sum())
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
raise TransientCollectionError(
|
|
|
|
|
f"Master port {master_stimulus_port} reference missing for {missing_reference_count} datapoints"
|
|
|
|
|
)
|
|
|
|
|
if np.isnan(raw_receiver_measurements).any():
|
|
|
|
|
missing_measurement_count = int(np.isnan(raw_receiver_measurements).sum())
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
raise TransientCollectionError(
|
|
|
|
|
f"Measurement {s_parameter_name} missing for {missing_measurement_count} datapoints"
|
|
|
|
|
)
|
|
|
|
|
s_parameters[s_parameter_name.lower()] = raw_receiver_measurements[-1] / master_reference_measurements[-1]
|
|
|
|
|
|
|
|
|
|
last_cycle_reference = master_reference_measurements[-1]
|
|
|
|
|
near_zero_reference_mask = np.abs(last_cycle_reference) <= _MIN_REFERENCE_MAGNITUDE
|
|
|
|
|
if near_zero_reference_mask.any():
|
|
|
|
|
# A ~zero incident reference is a corrupt point: dividing by it would
|
|
|
|
|
# explode this S-parameter into noise. Reject the cycle for retry.
|
|
|
|
|
raise TransientCollectionError(
|
|
|
|
|
f"Master port {master_stimulus_port} reference is ~zero for "
|
|
|
|
|
f"{int(near_zero_reference_mask.sum())} datapoints (corrupt incident signal)"
|
|
|
|
|
)
|
|
|
|
|
s_parameters[s_parameter_name.lower()] = raw_receiver_measurements[-1] / last_cycle_reference
|
|
|
|
|
return s_parameters
|
|
|
|
|