added data saving feature

This commit is contained in:
Ayzen
2026-06-23 12:19:31 +03:00
parent 716fd0b07a
commit 8db14b9482
20 changed files with 1192 additions and 63 deletions
@@ -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)