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
@@ -13,7 +13,10 @@ 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. When that float
carries the unwrap anchor (sample 0) across the +/-pi branch cut, a stray sweep
is offset by a whole 2*pi turn; it is snapped back onto the branch nearest the
calibration before mapping (see ``_anchor_phase_to_calibration_branch``).
2. **Amplitude normalization.** ``S = main / |reference|`` divides out the
stimulus amplitude. Only the magnitude is removed; the reference phase is used
@@ -47,6 +50,13 @@ _REFERENCE_AMPLITUDE_FLOOR = 1e-9
# a sweep yielding fewer usable points is malformed and rejected.
_MIN_USABLE_POINTS = 2
# One full turn of phase. ``np.unwrap`` reconstructs each sweep's phase ramp but
# anchors it to the raw ``np.angle`` of the first sample, which lives on the
# (-pi, pi] branch. Trigger jitter occasionally lands that anchor on the far side
# of the +/-pi branch cut for a stray sweep or two, rigidly offsetting the whole
# ramp by exactly this much before it settles back onto the physical branch.
_PHASE_BRANCH_PERIOD_RAD = 2.0 * np.pi
@dataclass(frozen=True, slots=True)
class KamilAdcProcessingParams:
@@ -133,9 +143,45 @@ class KamilAdcSweepProcessor:
Returns frequencies in *step order* (not sorted); see the module docstring
for the calibration law.
"""
phase = np.unwrap(np.angle(np.asarray(reference)))
phase = self._anchor_phase_to_calibration_branch(
np.unwrap(np.angle(np.asarray(reference)))
)
return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad
def _anchor_phase_to_calibration_branch(self, phase: np.ndarray) -> np.ndarray:
"""Collapse a stray 2*pi branch excursion back onto the physical branch.
``np.unwrap`` reconstructs a continuous phase ramp but pins its absolute
level to the raw angle of the first sample, which lives on the (-pi, pi]
branch. Trigger jitter occasionally lands that anchor on the wrong side of
the +/-pi cut, rigidly shifting the whole sweep by one
:data:`_PHASE_BRANCH_PERIOD_RAD` (~157 MHz on the rig) until it settles back
a sweep or two later. Such an excursion would otherwise wreck the frequency
axis, the band-coverage check, and the normalization.
The calibration's ``phase0_rad`` is the expected first-sample phase (its
median across many sweeps), so the physical branch is the one nearest it.
We round the first sample onto that branch and shift the whole ramp by the
same whole number of turns. This is:
* **Stateless** — each sweep is judged only against the fixed calibration,
so a glitch can never propagate into, or latch, later sweeps.
* **Self-correcting** — a glitched sweep is pulled back onto the band and
yields usable data instead of being rejected.
* **Span-invariant** — it keys on the first sample (a fixed sweep start),
not on how much band the sweep happens to span.
Genuine sweep-to-sweep float (well under pi against a calibration centered
on its median) rounds to zero turns and is left untouched. A float that
ever drifts past pi is a recalibration concern, not a per-sweep glitch.
"""
if phase.size == 0:
return phase
branch_turns = np.round((phase[0] - self._params.phase0_rad) / _PHASE_BRANCH_PERIOD_RAD)
if branch_turns:
phase = phase - branch_turns * _PHASE_BRANCH_PERIOD_RAD
return phase
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.