some fixes
This commit is contained in:
@@ -53,6 +53,32 @@ _MIN_CYCLE_ALIGNMENT_TOLERANCE_SECONDS = 0.5e-3
|
||||
# S-parameter into noise. Mirrors the single-device C++ driver's reference guard.
|
||||
_MIN_REFERENCE_MAGNITUDE = 1e-12
|
||||
|
||||
# A dropped USB frame leaves a single sweep point undelivered. Instead of
|
||||
# abandoning the whole cycle and re-arming the hardware (a multi-second stall),
|
||||
# we keep every delivered point and emit the few missing ones as zero. This caps
|
||||
# that tolerance: once more than this fraction of a trace's points are
|
||||
# unmeasurable, the loss is no longer a stray dropped frame but a genuine fault,
|
||||
# so the cycle is rejected for proper recovery rather than returned as a
|
||||
# mostly-zero trace.
|
||||
_MAX_ZERO_FILLED_POINTS_FRACTION = 0.05
|
||||
|
||||
|
||||
class _DeviceCollectionState:
|
||||
"""Mutable per-device flag marking that this device's capture has finished.
|
||||
|
||||
A device finishes when its final requested sweep cycle closes — either every
|
||||
point of that cycle was delivered, or the free-running hardware sweep wrapped
|
||||
back to point 0, proving the still-missing points are dropped frames that will
|
||||
never arrive. The collector thread polls this flag so a gap ends the cycle at
|
||||
the wrap instead of stalling until the no-progress timeout fires; the missing
|
||||
points are zero-filled when the S-parameters are computed.
|
||||
"""
|
||||
|
||||
__slots__ = ("last_cycle_closed",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.last_cycle_closed = False
|
||||
|
||||
|
||||
class _CrossDeviceCycleSynchronizer:
|
||||
"""Confirm every device thread anchors cycle 0 on the SAME physical sweep.
|
||||
@@ -158,7 +184,19 @@ def collect_complete_running_sweep_cycles(
|
||||
|
||||
all_device_connections = [master_device_connection, *slave_device_connections]
|
||||
point_count = active_sweep_configuration.points
|
||||
frequencies_hz = np.zeros(point_count, dtype=np.float64)
|
||||
# Seed the frequency axis with the configured linear sweep grid so a point the
|
||||
# master never delivers still carries its correct frequency instead of a 0 Hz
|
||||
# hole. Every delivered point overwrites its slot with the device-reported
|
||||
# frequency, which lands on this same grid.
|
||||
if point_count > 1:
|
||||
frequencies_hz = np.linspace(
|
||||
float(active_sweep_configuration.start_hz),
|
||||
float(active_sweep_configuration.stop_hz),
|
||||
point_count,
|
||||
dtype=np.float64,
|
||||
)
|
||||
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
|
||||
@@ -254,14 +292,17 @@ def collect_complete_running_sweep_cycles(
|
||||
def collect_datapoints_from_device(
|
||||
device_connection: LibreVnaUsbBulkConnection,
|
||||
handle_datapoint: Callable[[ParsedVnaDatapoint], bool],
|
||||
device_state: _DeviceCollectionState,
|
||||
) -> None:
|
||||
"""Read datapoints from one device until enough are consumed or a timeout fires.
|
||||
"""Read datapoints from one device until its cycle closes or a timeout fires.
|
||||
|
||||
Runs on a worker thread. Each datapoint is offered to ``handle_datapoint``,
|
||||
which returns whether it was consumed; only consumed datapoints count toward
|
||||
progress and refresh the no-progress timeout. On any timeout or transport
|
||||
error the error is recorded and ``stop_collection_requested`` is set so the
|
||||
other collector threads also stop.
|
||||
progress and refresh the no-progress timeout. The loop ends when every
|
||||
requested point has arrived, when ``device_state.last_cycle_closed`` is set
|
||||
(the sweep wrapped past the final cycle, so any remaining points are dropped
|
||||
frames), or when a timeout/transport error is recorded — in which case
|
||||
``stop_collection_requested`` is set so the other collector threads stop too.
|
||||
"""
|
||||
datapoints_received = 0
|
||||
expected_datapoint_count = cycle_count * point_count
|
||||
@@ -278,6 +319,13 @@ def collect_complete_running_sweep_cycles(
|
||||
if stop_collection_requested.is_set():
|
||||
return
|
||||
|
||||
if device_state.last_cycle_closed:
|
||||
# The sweep wrapped past the final requested cycle: its window is
|
||||
# closed, so any points still missing are dropped frames, not points
|
||||
# in flight. Finish now and let them be zero-filled downstream
|
||||
# instead of waiting out the no-progress timeout.
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
remaining_timeout_seconds = (last_consumed_timestamp + datapoint_timeout_seconds) - now
|
||||
if remaining_timeout_seconds <= 0:
|
||||
@@ -392,6 +440,7 @@ def collect_complete_running_sweep_cycles(
|
||||
|
||||
def build_cycle_tracking_handler(
|
||||
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
|
||||
device_state: _DeviceCollectionState,
|
||||
) -> Callable[[ParsedVnaDatapoint], bool]:
|
||||
"""Wrap a cycle-aware handler with cross-device cycle tracking.
|
||||
|
||||
@@ -446,6 +495,13 @@ def collect_complete_running_sweep_cycles(
|
||||
cycle_tracking_state["current_cycle_index"] += 1
|
||||
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.
|
||||
device_state.last_cycle_closed = True
|
||||
return False
|
||||
|
||||
cycle_aware_handler(parsed_datapoint, current_cycle_index)
|
||||
@@ -498,7 +554,9 @@ def collect_complete_running_sweep_cycles(
|
||||
point_index,
|
||||
] = port_receiver_value
|
||||
|
||||
def build_slave_datapoint_handler(slave_index: int) -> Callable[[ParsedVnaDatapoint], bool]:
|
||||
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 ports ``2*slave_index + 3`` and ``+ 4``,
|
||||
@@ -529,21 +587,31 @@ def collect_complete_running_sweep_cycles(
|
||||
point_index,
|
||||
] = port_receiver_value
|
||||
|
||||
return build_cycle_tracking_handler(handle_slave_datapoint)
|
||||
return build_cycle_tracking_handler(handle_slave_datapoint, device_state)
|
||||
|
||||
master_device_state = _DeviceCollectionState()
|
||||
collection_threads = [
|
||||
threading.Thread(
|
||||
target=collect_datapoints_from_device,
|
||||
args=(master_device_connection, build_cycle_tracking_handler(handle_master_datapoint)),
|
||||
args=(
|
||||
master_device_connection,
|
||||
build_cycle_tracking_handler(handle_master_datapoint, master_device_state),
|
||||
master_device_state,
|
||||
),
|
||||
daemon=True,
|
||||
name="collect-master",
|
||||
)
|
||||
]
|
||||
for slave_index, slave_device_connection in enumerate(slave_device_connections):
|
||||
slave_device_state = _DeviceCollectionState()
|
||||
collection_threads.append(
|
||||
threading.Thread(
|
||||
target=collect_datapoints_from_device,
|
||||
args=(slave_device_connection, build_slave_datapoint_handler(slave_index)),
|
||||
args=(
|
||||
slave_device_connection,
|
||||
build_slave_datapoint_handler(slave_index, slave_device_state),
|
||||
slave_device_state,
|
||||
),
|
||||
daemon=True,
|
||||
name=f"collect-slave{slave_index}",
|
||||
)
|
||||
@@ -660,30 +728,61 @@ 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],
|
||||
) -> dict[str, np.ndarray]:
|
||||
"""Convert raw receiver captures from the final sweep cycle into S-parameters."""
|
||||
"""Convert raw receiver captures from the final sweep cycle into S-parameters.
|
||||
|
||||
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
|
||||
normalized, so they are emitted as exactly ``0``. Every delivered point keeps
|
||||
its true normalized value, so no extra zeros are introduced. If more than
|
||||
``_MAX_ZERO_FILLED_POINTS_FRACTION`` of a trace is unmeasurable the loss is a
|
||||
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] = {}
|
||||
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]
|
||||
if np.isnan(master_reference_measurements).any():
|
||||
missing_reference_count = int(np.isnan(master_reference_measurements).sum())
|
||||
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 TransientCollectionError(
|
||||
f"Measurement {s_parameter_name} missing for {missing_measurement_count} datapoints"
|
||||
)
|
||||
|
||||
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.
|
||||
last_cycle_measurement = raw_receiver_measurements[-1]
|
||||
point_count = last_cycle_measurement.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
|
||||
)
|
||||
measurement_missing_mask = np.isnan(last_cycle_measurement)
|
||||
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"Master port {master_stimulus_port} reference is ~zero for "
|
||||
f"{int(near_zero_reference_mask.sum())} datapoints (corrupt incident signal)"
|
||||
f"Trace {s_parameter_name} is unmeasurable at {zero_filled_count}/{point_count} "
|
||||
f"points (missing reference/measurement); rejecting cycle for recovery"
|
||||
)
|
||||
s_parameters[s_parameter_name.lower()] = raw_receiver_measurements[-1] / last_cycle_reference
|
||||
|
||||
# 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)
|
||||
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
|
||||
|
||||
if zero_filled_points_by_trace:
|
||||
# One concise line per affected cycle (a rare dropped-frame event), so the
|
||||
# gap-fill is visible at INFO without enabling DEBUG: this is the positive
|
||||
# signal that the cycle was kept instead of triggering a re-arm stall.
|
||||
logger.info(
|
||||
"Zero-filled dropped sweep point(s) to keep the cycle (out of %d points/trace): %s",
|
||||
point_count,
|
||||
zero_filled_points_by_trace,
|
||||
)
|
||||
return s_parameters
|
||||
|
||||
Reference in New Issue
Block a user