182 lines
5.7 KiB
Python
182 lines
5.7 KiB
Python
"""Helpers to assemble high-level sweep results from packet streams."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
from typing import Iterable
|
|
|
|
import numpy as np
|
|
|
|
from ..exceptions import IncompleteSweepError, ParseError
|
|
from ..enums import SweepKind
|
|
from ..models import VNADatapointPacket, VNARawPoint, VNASweepSettings, SweepResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_REFERENCE_FLAG = 0x10
|
|
|
|
|
|
def _extract_stage(flags: int) -> int:
|
|
"""Return stage index encoded inside datapoint flag byte."""
|
|
return flags >> 5
|
|
|
|
|
|
def _find_vna_value(
|
|
datapoint: VNADatapointPacket,
|
|
*,
|
|
stage: int,
|
|
port_index: int,
|
|
reference: bool,
|
|
) -> complex:
|
|
"""Find one complex receiver value for stage/port/ref tuple."""
|
|
source_mask = 1 << port_index
|
|
if reference:
|
|
source_mask |= _REFERENCE_FLAG
|
|
|
|
for idx, flags in enumerate(datapoint.flags):
|
|
if _extract_stage(int(flags)) != stage:
|
|
continue
|
|
if (int(flags) & source_mask) != source_mask:
|
|
continue
|
|
return complex(float(datapoint.real[idx]), float(datapoint.imag[idx]))
|
|
|
|
kind = "reference" if reference else "receiver"
|
|
raise ParseError(
|
|
f"Missing {kind} value for stage={stage}, port_index={port_index}, point={datapoint.point_number}"
|
|
)
|
|
|
|
|
|
def datapoint_to_raw_point(
|
|
datapoint: VNADatapointPacket,
|
|
settings: VNASweepSettings,
|
|
*,
|
|
num_ports: int,
|
|
) -> VNARawPoint:
|
|
"""Convert low-level VNADatapoint packet into callback-friendly object."""
|
|
measurements: dict[str, complex] = {}
|
|
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
|
|
|
|
for excited_port, stage in stage_by_excited_port.items():
|
|
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
|
|
for receiver_port in range(1, num_ports + 1):
|
|
measured = _find_vna_value(
|
|
datapoint,
|
|
stage=stage,
|
|
port_index=receiver_port - 1,
|
|
reference=False,
|
|
)
|
|
measurements[f"S{receiver_port}{excited_port}"] = measured / ref
|
|
|
|
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
|
|
settings.power_start_dbm,
|
|
settings.power_stop_dbm,
|
|
rel_tol=0.0,
|
|
abs_tol=0.0,
|
|
)
|
|
|
|
if zero_span:
|
|
return VNARawPoint(
|
|
point_number=datapoint.point_number,
|
|
frequency_hz=None,
|
|
time_s=float(datapoint.frequency_or_time) * 1e-6,
|
|
power_dbm=None,
|
|
measurements=measurements,
|
|
)
|
|
|
|
return VNARawPoint(
|
|
point_number=datapoint.point_number,
|
|
frequency_hz=float(datapoint.frequency_or_time),
|
|
time_s=None,
|
|
power_dbm=float(datapoint.cdbm) / 100.0,
|
|
measurements=measurements,
|
|
)
|
|
|
|
|
|
def assemble_vna_sweep(
|
|
datapoints: Iterable[VNADatapointPacket],
|
|
settings: VNASweepSettings,
|
|
*,
|
|
num_ports: int,
|
|
expected_points: int,
|
|
) -> SweepResult:
|
|
"""Build a complete VNA sweep from raw datapoints."""
|
|
if expected_points <= 0:
|
|
raise ValueError("expected_points must be > 0")
|
|
|
|
x = np.empty(expected_points, dtype=np.float64)
|
|
seen = np.zeros(expected_points, dtype=bool)
|
|
|
|
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
|
|
receiver_limit = min(num_ports, 2)
|
|
trace_names = [
|
|
f"s{receiver_port}{excited_port}"
|
|
for excited_port in settings.excited_ports
|
|
for receiver_port in range(1, receiver_limit + 1)
|
|
if receiver_port <= 2 and excited_port <= 2
|
|
]
|
|
traces: dict[str, np.ndarray] = {
|
|
name: np.empty(expected_points, dtype=np.complex128) for name in trace_names
|
|
}
|
|
|
|
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
|
|
settings.power_start_dbm,
|
|
settings.power_stop_dbm,
|
|
rel_tol=0.0,
|
|
abs_tol=0.0,
|
|
)
|
|
power_sweep = settings.kind == SweepKind.POWER
|
|
|
|
for datapoint in datapoints:
|
|
idx = datapoint.point_number
|
|
if idx >= expected_points:
|
|
raise ParseError(f"Received out-of-range point index {idx}, expected < {expected_points}")
|
|
|
|
if zero_span:
|
|
x[idx] = float(datapoint.frequency_or_time) * 1e-6
|
|
elif power_sweep:
|
|
x[idx] = float(datapoint.cdbm) / 100.0
|
|
else:
|
|
x[idx] = float(datapoint.frequency_or_time)
|
|
|
|
for excited_port, stage in stage_by_excited_port.items():
|
|
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
|
|
for receiver_port in range(1, num_ports + 1):
|
|
if receiver_port > 2 or excited_port > 2:
|
|
# Public SweepResult intentionally exposes 2-port canonical traces.
|
|
continue
|
|
measured = _find_vna_value(
|
|
datapoint,
|
|
stage=stage,
|
|
port_index=receiver_port - 1,
|
|
reference=False,
|
|
)
|
|
trace_name = f"s{receiver_port}{excited_port}"
|
|
trace = traces.get(trace_name)
|
|
if trace is not None:
|
|
trace[idx] = measured / ref
|
|
|
|
seen[idx] = True
|
|
|
|
missing = np.flatnonzero(~seen)
|
|
if missing.size > 0:
|
|
raise IncompleteSweepError(
|
|
f"Sweep is incomplete: received {int(seen.sum())}/{expected_points} points"
|
|
)
|
|
|
|
x_label = "frequency_hz"
|
|
if zero_span:
|
|
x_label = "time_s"
|
|
elif power_sweep:
|
|
x_label = "power_dbm"
|
|
|
|
result = SweepResult(x=x, traces=traces, x_label=x_label)
|
|
logger.debug(
|
|
"Assembled VNA sweep: points=%d x_label=%s traces=%s",
|
|
len(result.x),
|
|
result.x_label,
|
|
sorted(result.traces.keys()),
|
|
)
|
|
return result
|