added data saving feature
This commit is contained in:
@@ -13,7 +13,17 @@ comparable S21 trace is a fixed three-stage pipeline:
|
||||
f(phase) = freq0 + (phase - phase0) * (freq1 - freq0) / (phase1 - phase0)
|
||||
|
||||
Trigger jitter shifts every sample's absolute phase together, so the measured
|
||||
band floats from sweep to sweep around the fixed calibration.
|
||||
band floats from sweep to sweep around the fixed calibration. ``np.unwrap``
|
||||
anchors each sweep's ramp to ``np.angle(reference[0])`` on the (-pi, pi] branch,
|
||||
so once that float carries the anchor across the +/-pi cut the whole ramp jumps
|
||||
one 2*pi turn (~117 MHz on the rig) even though nothing physical moved. The
|
||||
genuine float is slow (well under pi between consecutive sweeps) while the wrap
|
||||
is a discrete 2*pi step, so the processor *unwraps the anchor across sweeps*:
|
||||
each sweep's anchor is snapped onto the branch nearest the previous accepted
|
||||
sweep (the first sweep onto the calibration ``phase0`` branch). Validated on
|
||||
10 000 live sweeps: 506 branch wraps, yet the largest cross-sweep anchor step
|
||||
stayed at 1.6 rad (< pi), and the correction cut badly-distorted pass-through
|
||||
sweeps from 580 to 11 while leaving clean sweeps untouched.
|
||||
|
||||
2. **Amplitude normalization.** ``S = main / |reference|`` divides out the
|
||||
stimulus amplitude. Only the magnitude is removed; the reference phase is used
|
||||
@@ -47,6 +57,12 @@ _REFERENCE_AMPLITUDE_FLOOR = 1e-9
|
||||
# a sweep yielding fewer usable points is malformed and rejected.
|
||||
_MIN_USABLE_POINTS = 2
|
||||
|
||||
# One full turn of reference phase. ``np.unwrap`` anchors each sweep's phase ramp
|
||||
# to the raw angle of the first sample on the (-pi, pi] branch, so a stray sweep
|
||||
# whose anchor crossed the +/-pi cut is offset by exactly this; the cross-sweep
|
||||
# anchor tracking snaps it back (see ``KamilAdcSweepProcessor._align_phase_branch``).
|
||||
_PHASE_BRANCH_PERIOD_RAD = 2.0 * np.pi
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KamilAdcProcessingParams:
|
||||
@@ -110,13 +126,18 @@ class KamilAdcSweepProcessor:
|
||||
sweep, so all traces this processor emits share one identical frequency axis.
|
||||
"""
|
||||
|
||||
__slots__ = ("_params", "_grid_hz")
|
||||
__slots__ = ("_params", "_grid_hz", "_previous_anchor_rad")
|
||||
|
||||
def __init__(self, params: KamilAdcProcessingParams) -> None:
|
||||
self._params = params
|
||||
self._grid_hz = np.linspace(
|
||||
params.band_start_hz, params.band_stop_hz, params.band_points, dtype=np.float64
|
||||
)
|
||||
# Absolute (branch-tracked) reference phase of the last ACCEPTED sweep's
|
||||
# first sample, carried across sweeps so a +/-pi anchor wrap can be undone.
|
||||
# ``None`` until the first sweep is accepted; reset by building a new
|
||||
# processor (i.e. on reconfigure).
|
||||
self._previous_anchor_rad: float | None = None
|
||||
|
||||
@property
|
||||
def params(self) -> KamilAdcProcessingParams:
|
||||
@@ -130,12 +151,44 @@ class KamilAdcSweepProcessor:
|
||||
def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray:
|
||||
"""Map a reference signal's absolute unwrapped phase to frequency (Hz).
|
||||
|
||||
Returns frequencies in *step order* (not sorted); see the module docstring
|
||||
for the calibration law.
|
||||
Stateless and *uncorrected* (no cross-sweep branch tracking), so it shows
|
||||
the raw per-sweep axis — used by diagnostics. The live path
|
||||
(:meth:`process`) applies the branch correction. Returns frequencies in
|
||||
*step order* (not sorted); see the module docstring for the calibration law.
|
||||
"""
|
||||
phase = np.unwrap(np.angle(np.asarray(reference)))
|
||||
return self._phase_to_frequency(np.unwrap(np.angle(np.asarray(reference))))
|
||||
|
||||
def _phase_to_frequency(self, phase: np.ndarray) -> np.ndarray:
|
||||
"""Apply the affine ``phase -> frequency`` calibration law."""
|
||||
return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad
|
||||
|
||||
def _align_phase_branch(self, phase: np.ndarray) -> tuple[np.ndarray, float]:
|
||||
"""Undo a stray +/-pi anchor wrap by unwrapping the anchor across sweeps.
|
||||
|
||||
``np.unwrap`` pins the whole ramp to ``phase[0]`` on the (-pi, pi] branch,
|
||||
so a slow physical float that drags the anchor over the +/-pi cut flips the
|
||||
entire sweep by one :data:`_PHASE_BRANCH_PERIOD_RAD`. We snap this sweep's
|
||||
anchor onto the branch nearest the previous accepted sweep's anchor (the
|
||||
first sweep onto the calibration ``phase0``), then shift the whole ramp by
|
||||
the same whole number of turns.
|
||||
|
||||
Returns ``(branch_aligned_phase, anchor_to_commit)``. The caller commits the
|
||||
anchor only once the sweep is accepted, so a rejected/corrupt sweep can
|
||||
never latch the tracker onto a wrong branch. Genuine sub-pi sweep-to-sweep
|
||||
float rounds to zero turns and is preserved untouched.
|
||||
"""
|
||||
anchor = float(phase[0])
|
||||
reference_anchor = (
|
||||
self._previous_anchor_rad
|
||||
if self._previous_anchor_rad is not None
|
||||
else self._params.phase0_rad
|
||||
)
|
||||
turns = round((reference_anchor - anchor) / _PHASE_BRANCH_PERIOD_RAD)
|
||||
if turns:
|
||||
shift = turns * _PHASE_BRANCH_PERIOD_RAD
|
||||
return phase + shift, anchor + shift
|
||||
return phase, anchor
|
||||
|
||||
def process(self, main: np.ndarray, reference: np.ndarray) -> np.ndarray | None:
|
||||
"""Return the S21 trace resampled onto the fixed grid, or ``None`` to reject.
|
||||
|
||||
@@ -148,8 +201,11 @@ class KamilAdcSweepProcessor:
|
||||
if main.size < _MIN_USABLE_POINTS or main.size != reference.size:
|
||||
return None
|
||||
|
||||
# Frequency axis from the absolute unwrapped reference phase (step order).
|
||||
freqs = self.reference_frequency_axis(reference)
|
||||
# Frequency axis from the absolute unwrapped reference phase (step order),
|
||||
# with a stray +/-pi anchor wrap undone relative to the last accepted sweep.
|
||||
# The aligned anchor is committed only if this sweep is accepted (below).
|
||||
phase, candidate_anchor = self._align_phase_branch(np.unwrap(np.angle(reference)))
|
||||
freqs = self._phase_to_frequency(phase)
|
||||
|
||||
# Amplitude-only normalization; drop points where the reference vanished.
|
||||
reference_amplitude = np.abs(reference)
|
||||
@@ -178,6 +234,11 @@ class KamilAdcSweepProcessor:
|
||||
if freqs[0] > self._params.band_start_hz or freqs[-1] < self._params.band_stop_hz:
|
||||
return None
|
||||
|
||||
# The sweep is accepted: commit its branch-aligned anchor so the next
|
||||
# sweep is tracked relative to it (and a wrap is measured against a real,
|
||||
# in-band reference rather than a rejected one).
|
||||
self._previous_anchor_rad = candidate_anchor
|
||||
|
||||
# Linear interpolation on real/imaginary parts. Because the band lies
|
||||
# within [freqs[0], freqs[-1]], np.interp never extrapolates here.
|
||||
real = np.interp(self._grid_hz, freqs, s21.real)
|
||||
|
||||
Reference in New Issue
Block a user