some fixes

This commit is contained in:
Ayzen
2026-06-22 15:01:26 +03:00
parent e7f2d25585
commit 7c381facaf
4 changed files with 261 additions and 30 deletions
@@ -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