commkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commkit/__init__.py +74 -0
- commkit/_cuda/__init__.py +321 -0
- commkit/_cuda/compiler.py +88 -0
- commkit/_cuda/src/bps_min_d2.cu +104 -0
- commkit/_cuda/src/cs_block.cu +119 -0
- commkit/_cuda/src/selftest.cu +14 -0
- commkit/analysis/__init__.py +55 -0
- commkit/analysis/_common.py +236 -0
- commkit/analysis/allan.py +108 -0
- commkit/analysis/drift.py +213 -0
- commkit/analysis/interferometry.py +887 -0
- commkit/analysis/linewidth.py +480 -0
- commkit/analysis/trajectory.py +91 -0
- commkit/backend.py +507 -0
- commkit/coding/__init__.py +23 -0
- commkit/coding/base.py +17 -0
- commkit/coding/bch.py +6 -0
- commkit/coding/convolutional.py +7 -0
- commkit/coding/crc.py +7 -0
- commkit/coding/galois.py +8 -0
- commkit/coding/hamming.py +6 -0
- commkit/coding/interleaving.py +7 -0
- commkit/coding/ldpc.py +8 -0
- commkit/coding/polar.py +8 -0
- commkit/coding/ratematch.py +6 -0
- commkit/coding/reed_solomon.py +6 -0
- commkit/coding/turbo.py +8 -0
- commkit/core/__init__.py +32 -0
- commkit/core/frame.py +992 -0
- commkit/core/generation.py +581 -0
- commkit/core/signal.py +725 -0
- commkit/equalization/__init__.py +49 -0
- commkit/equalization/_block.py +1855 -0
- commkit/equalization/_common.py +606 -0
- commkit/equalization/_kernels_jax.py +1720 -0
- commkit/equalization/_kernels_numba.py +1704 -0
- commkit/equalization/blind.py +223 -0
- commkit/equalization/linear.py +365 -0
- commkit/equalization/polarization.py +790 -0
- commkit/equalization/result.py +191 -0
- commkit/equalization/sequential.py +2805 -0
- commkit/filtering.py +1120 -0
- commkit/frequency.py +1191 -0
- commkit/helpers.py +489 -0
- commkit/impairments/__init__.py +43 -0
- commkit/impairments/channel/__init__.py +20 -0
- commkit/impairments/channel/linear.py +310 -0
- commkit/impairments/channel/nonlinear.py +11 -0
- commkit/impairments/frontend.py +229 -0
- commkit/impairments/noise.py +105 -0
- commkit/impairments/source.py +219 -0
- commkit/io.py +308 -0
- commkit/logger.py +103 -0
- commkit/mapping/__init__.py +46 -0
- commkit/mapping/bits.py +240 -0
- commkit/mapping/constellation.py +153 -0
- commkit/mapping/gray.py +429 -0
- commkit/mapping/llr.py +253 -0
- commkit/mapping/shaping.py +218 -0
- commkit/metrics.py +949 -0
- commkit/multirate.py +476 -0
- commkit/plotting/__init__.py +78 -0
- commkit/plotting/analysis.py +627 -0
- commkit/plotting/constellation.py +483 -0
- commkit/plotting/equalizer.py +390 -0
- commkit/plotting/eye.py +388 -0
- commkit/plotting/spectral.py +575 -0
- commkit/plotting/sync.py +953 -0
- commkit/plotting/theme.py +203 -0
- commkit/plotting/waveform.py +200 -0
- commkit/py.typed +0 -0
- commkit/recovery/__init__.py +51 -0
- commkit/recovery/bps.py +337 -0
- commkit/recovery/corrections.py +751 -0
- commkit/recovery/pilots.py +803 -0
- commkit/recovery/pll.py +482 -0
- commkit/recovery/tikhonov.py +424 -0
- commkit/recovery/viterbi_viterbi.py +227 -0
- commkit/spectral.py +560 -0
- commkit/timing.py +841 -0
- commkit-1.0.0.dist-info/METADATA +145 -0
- commkit-1.0.0.dist-info/RECORD +84 -0
- commkit-1.0.0.dist-info/WHEEL +4 -0
- commkit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
r"""Delayed self-heterodyne / self-homodyne (DSH) laser characterization.
|
|
2
|
+
|
|
3
|
+
Estimate a CW laser's FM-noise PSD and linewidth from the digitized *beat* of
|
|
4
|
+
a delayed self-interference measurement: the laser under test is split in two,
|
|
5
|
+
one arm is delayed by ``τ_d`` (a fiber spool), the other is optionally
|
|
6
|
+
frequency-shifted by ``f_shift`` (an AOM), and the arms are recombined on a
|
|
7
|
+
photodetector. The beat at ``f_shift`` carries the **differential** laser
|
|
8
|
+
phase
|
|
9
|
+
|
|
10
|
+
Δφ(t) = φ(t) - φ(t - τ_d),
|
|
11
|
+
|
|
12
|
+
i.e. the interferometer converts the laser's absolute phase noise - which is
|
|
13
|
+
unobservable without a second, better laser - into a measurable quantity by
|
|
14
|
+
using the laser itself, delayed toward or beyond its own coherence time, as
|
|
15
|
+
the reference.
|
|
16
|
+
|
|
17
|
+
The AOM shift and the receiver are independent choices, and ``dsh_phase``
|
|
18
|
+
dispatches on the input *dtype* rather than on a named variant, so three
|
|
19
|
+
combinations are supported:
|
|
20
|
+
|
|
21
|
+
* **heterodyne, single photodetector** - ``f_shift ≠ 0``, *real* samples;
|
|
22
|
+
the analytic signal is formed internally (Hilbert), so the whole beat
|
|
23
|
+
lineshape must sit inside ``(0, f_s/2)``.
|
|
24
|
+
* **heterodyne, IQ receiver** - ``f_shift ≠ 0`` with a 90°-hybrid (coherent)
|
|
25
|
+
front-end giving *complex* samples, used directly: no Hilbert step, no
|
|
26
|
+
``(0, f_s/2)`` restriction (the line may sit anywhere in ``±f_s/2``), and
|
|
27
|
+
receiver DC / hybrid-image spurs land ``f_shift`` / ``2·f_shift`` away
|
|
28
|
+
from the line instead of on top of it.
|
|
29
|
+
* **homodyne, IQ receiver** - ``f_shift = 0``, complex samples. Real-valued
|
|
30
|
+
homodyne detection (single photodiode, ``cos Δφ`` only) is not invertible
|
|
31
|
+
to phase and is rejected.
|
|
32
|
+
|
|
33
|
+
All functions take ``(N,)`` or ``(C, N)`` records with time on the last
|
|
34
|
+
axis; the ``C`` channels are **independent captures** processed as a batch -
|
|
35
|
+
per-channel carrier removal, PSDs, and linewidths, no joint/MIMO processing
|
|
36
|
+
- e.g. the two outputs of a polarization-diverse receiver, several lasers,
|
|
37
|
+
or repeated captures stacked for a single GPU pass.
|
|
38
|
+
|
|
39
|
+
Estimators (see ``linewidth_dsh``):
|
|
40
|
+
|
|
41
|
+
* ``"fm_psd"`` - deconvolve the beat FM-noise PSD by the interferometer
|
|
42
|
+
response ``4 sin²(πfτ_d)`` to recover the laser FM-noise PSD; works in both
|
|
43
|
+
the coherent (short-delay) and incoherent regimes.
|
|
44
|
+
* ``"increment"`` - lag-slope of the differential-phase increment variance;
|
|
45
|
+
the AWGN-immune Wiener-linewidth estimate (DSH analogue of
|
|
46
|
+
``linewidth_increment``).
|
|
47
|
+
* ``"lorentzian"`` - classic spectral width of the beat line; valid only in
|
|
48
|
+
the incoherent regime ``τ_d ≫ τ_c = 1/(πΔν)``.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
import numpy as np
|
|
52
|
+
|
|
53
|
+
from ..backend import ArrayType, dispatch, to_device
|
|
54
|
+
from ..frequency import correct_static_frequency_offset
|
|
55
|
+
from ..logger import logger
|
|
56
|
+
from ..spectral import welch_psd
|
|
57
|
+
from ._common import _as_2d, _plateau_mask, _scalar_or_array, _welch_median_bias
|
|
58
|
+
from .linewidth import fm_noise_psd
|
|
59
|
+
|
|
60
|
+
__all__ = ["dsh_beat", "dsh_fm_noise_psd", "dsh_phase", "linewidth_dsh"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _analytic_beat(samples):
|
|
64
|
+
"""Complex analytic beat ``(C, N)`` from raw DSH samples (Hilbert if real)."""
|
|
65
|
+
z, xp, sp = dispatch(samples)
|
|
66
|
+
z2, was_1d = _as_2d(z)
|
|
67
|
+
if xp.iscomplexobj(z2):
|
|
68
|
+
return z2.astype(xp.complex128, copy=False), was_1d, xp
|
|
69
|
+
return sp.signal.hilbert(z2.astype(xp.float64), axis=-1), was_1d, xp
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dsh_beat(
|
|
73
|
+
phi: ArrayType,
|
|
74
|
+
sampling_rate: float,
|
|
75
|
+
delay: float,
|
|
76
|
+
*,
|
|
77
|
+
f_shift: float = 0.0,
|
|
78
|
+
) -> tuple[ArrayType, ArrayType]:
|
|
79
|
+
r"""Ideal interferometer beat for a laser phase trajectory (forward model).
|
|
80
|
+
|
|
81
|
+
Deterministic synthesis counterpart of ``dsh_phase`` - the model that the
|
|
82
|
+
estimators in this module invert. The laser field ``E(t) = exp(jφ(t))``
|
|
83
|
+
interferes with its own delayed replica; the detector output is the field
|
|
84
|
+
product
|
|
85
|
+
|
|
86
|
+
z(t) = E(t) · E*(t - τ_d) · exp(j 2π f_shift t)
|
|
87
|
+
= exp(j (2π f_shift t + Δφ(t))), Δφ(t) = φ(t) - φ(t - τ_d),
|
|
88
|
+
|
|
89
|
+
with ``τ_d`` rounded to the nearest whole sample. ``z`` is what an IQ
|
|
90
|
+
(90°-hybrid) receiver records at any ``f_shift`` - ``f_shift = 0`` is the
|
|
91
|
+
self-*homodyne* case, ``f_shift ≠ 0`` the AOM self-*heterodyne* case; a
|
|
92
|
+
single-photodetector heterodyne receiver records ``z.real`` instead.
|
|
93
|
+
|
|
94
|
+
Deliberately *not* included - chain separately to build a full
|
|
95
|
+
measurement:
|
|
96
|
+
|
|
97
|
+
* the phase trajectory itself: ``impairments.generate_phase_noise``;
|
|
98
|
+
* detection noise: ``impairments.apply_awgn`` on the returned beat.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
phi : array_like
|
|
103
|
+
Laser phase trajectory in radians, ``(N,)`` or ``(C, N)``
|
|
104
|
+
(``float64`` recommended; see ``generate_phase_noise``).
|
|
105
|
+
sampling_rate : float
|
|
106
|
+
Sampling rate in Hz.
|
|
107
|
+
delay : float
|
|
108
|
+
Interferometer delay ``τ_d`` in seconds (≈ 4.9 µs per km of SMF).
|
|
109
|
+
Rounded to ``m = round(delay · f_s)`` samples; must satisfy
|
|
110
|
+
``1 ≤ m < N``.
|
|
111
|
+
f_shift : float, default 0.0
|
|
112
|
+
AOM frequency shift in Hz (0 = homodyne).
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
z : array_like
|
|
117
|
+
Unit-amplitude complex beat, ``(..., N - m)``, ``complex128``, same
|
|
118
|
+
backend as ``phi``.
|
|
119
|
+
delta_phi : array_like
|
|
120
|
+
The true differential phase Δφ, ``(..., N - m)`` - ground truth for
|
|
121
|
+
validating the ``dsh_phase`` / ``linewidth_dsh`` estimates.
|
|
122
|
+
"""
|
|
123
|
+
x, xp, _ = dispatch(phi)
|
|
124
|
+
|
|
125
|
+
m = int(round(delay * sampling_rate))
|
|
126
|
+
n = x.shape[-1]
|
|
127
|
+
if not 1 <= m < n:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"delay of {m} samples must be in [1, {n}) for a length-{n} trajectory."
|
|
130
|
+
)
|
|
131
|
+
logger.info(
|
|
132
|
+
"DSH beat: delay %s samples (%.3g µs), f_shift %.4g Hz.",
|
|
133
|
+
m,
|
|
134
|
+
m / sampling_rate * 1e6,
|
|
135
|
+
f_shift,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
delta_phi = x[..., m:] - x[..., :-m]
|
|
139
|
+
beat_phase = delta_phi
|
|
140
|
+
if f_shift != 0.0:
|
|
141
|
+
t = xp.arange(n - m, dtype=xp.float64) / sampling_rate
|
|
142
|
+
beat_phase = delta_phi + 2.0 * np.pi * f_shift * t
|
|
143
|
+
return xp.exp(1j * beat_phase), delta_phi
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def dsh_phase(
|
|
147
|
+
samples: ArrayType,
|
|
148
|
+
sampling_rate: float,
|
|
149
|
+
*,
|
|
150
|
+
f_shift: float | None = None,
|
|
151
|
+
) -> tuple[ArrayType, float | np.ndarray]:
|
|
152
|
+
r"""Unwrapped differential laser phase Δφ(t) = φ(t) - φ(t-τ_d) from the beat.
|
|
153
|
+
|
|
154
|
+
Removes the beat carrier (the AOM shift plus any receiver frequency
|
|
155
|
+
offset) and unwraps the remaining angle in ``float64``. Real inputs are
|
|
156
|
+
made analytic with a Hilbert transform first; complex (IQ) inputs are
|
|
157
|
+
used directly at any carrier - both the self-*homodyne* capture
|
|
158
|
+
(``f_shift = 0`` with a 90° hybrid) and the *heterodyne* IQ capture
|
|
159
|
+
(AOM + coherent receiver) work transparently, free of the Hilbert
|
|
160
|
+
step's band restrictions.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
samples : array_like
|
|
165
|
+
Beat record, ``(N,)`` or ``(C, N)``. Real (single photodetector,
|
|
166
|
+
heterodyne) or complex (IQ front-end).
|
|
167
|
+
sampling_rate : float
|
|
168
|
+
Sampling rate in Hz.
|
|
169
|
+
f_shift : float, optional
|
|
170
|
+
Known beat carrier in Hz (AOM frequency). If None, the mean beat
|
|
171
|
+
frequency is estimated per channel in two stages - coarse Kay
|
|
172
|
+
(lag-1-autocorrelation) estimate, then exact least-squares slope
|
|
173
|
+
removal on the unwrapped phase - and removed.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
delta_phi : array_like
|
|
178
|
+
Unwrapped differential phase in radians (``float64``), same layout and
|
|
179
|
+
backend as the input.
|
|
180
|
+
f_shift_hz : float or ndarray
|
|
181
|
+
The removed carrier frequency (estimated or as passed), float (SISO)
|
|
182
|
+
or ``(C,)`` array.
|
|
183
|
+
|
|
184
|
+
Notes
|
|
185
|
+
-----
|
|
186
|
+
**Limitations.**
|
|
187
|
+
|
|
188
|
+
* A perfectly removed carrier is *not* required downstream: a constant
|
|
189
|
+
residual offset drops out of the increment variances and of the
|
|
190
|
+
(detrended) FM-noise PSD. Carrier removal mainly keeps ``delta_phi``
|
|
191
|
+
flat for inspection/plotting.
|
|
192
|
+
* The estimated carrier is the record's best-fit mean beat frequency - it
|
|
193
|
+
absorbs the mean laser drift over the capture into ``f_shift_hz`` and
|
|
194
|
+
*linearly detrends* ``delta_phi``. Pass the known AOM frequency to keep
|
|
195
|
+
drift visible in ``delta_phi``.
|
|
196
|
+
* **Real input**: the analytic-signal step requires the whole beat
|
|
197
|
+
lineshape inside ``(0, f_s/2)`` - i.e. ``f_shift`` larger than the beat
|
|
198
|
+
half-bandwidth and below Nyquist by the same margin; spectral folding
|
|
199
|
+
corrupts the phase silently. Real input with ``f_shift = 0`` is
|
|
200
|
+
rejected (see module docstring).
|
|
201
|
+
* ``unwrap`` needs the per-sample phase step below π: keep the beat SNR
|
|
202
|
+
moderate (≳ 10 dB in the beat bandwidth) and the sampling rate well
|
|
203
|
+
above the beat linewidth; slips appear as ±2π staircase jumps in
|
|
204
|
+
``delta_phi``.
|
|
205
|
+
* Everything the interferometer adds - fiber acoustic/thermal noise in
|
|
206
|
+
the delay arm, AOM RF-synthesizer phase noise - is indistinguishable
|
|
207
|
+
from laser phase noise here and adds to the low-frequency PSD.
|
|
208
|
+
"""
|
|
209
|
+
z, xp, sp = dispatch(samples)
|
|
210
|
+
z2, was_1d = _as_2d(z)
|
|
211
|
+
fs = float(sampling_rate)
|
|
212
|
+
|
|
213
|
+
if not xp.iscomplexobj(z2):
|
|
214
|
+
if f_shift is not None and float(f_shift) == 0.0:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
"Real-valued samples with f_shift=0 (self-homodyne on a single "
|
|
217
|
+
"photodetector) observe cos(Δφ) only and cannot be inverted to "
|
|
218
|
+
"phase. Use an AOM shift (heterodyne) or an IQ front-end."
|
|
219
|
+
)
|
|
220
|
+
z2 = sp.signal.hilbert(z2.astype(xp.float64), axis=-1)
|
|
221
|
+
else:
|
|
222
|
+
z2 = z2.astype(xp.complex128, copy=False)
|
|
223
|
+
|
|
224
|
+
estimate = f_shift is None
|
|
225
|
+
if f_shift is None:
|
|
226
|
+
# Coarse stage - Kay estimator: phase of the lag-1 autocorrelation,
|
|
227
|
+
# wrap-immune, one reduction per channel. (This is the M=1
|
|
228
|
+
# "generic blind" special case of
|
|
229
|
+
# ``frequency.estimate_frequency_offset_mengali_morelli``, which
|
|
230
|
+
# generalizes it to a multi-lag MVUE combination for lower coarse-
|
|
231
|
+
# stage variance. Not used here: with the fine LS-slope stage below
|
|
232
|
+
# already fitting the *entire* unwrapped record - the classic
|
|
233
|
+
# optimal two-stage tone-frequency estimator, asymptotically
|
|
234
|
+
# equivalent to the Cramér-Rao bound - the coarse stage only has to
|
|
235
|
+
# be accurate enough that ``unwrap`` does not slip; multi-lag
|
|
236
|
+
# averaging would not improve the final ``f_hat`` and costs two
|
|
237
|
+
# padded FFTs plus a Numba-JIT bootstrap dependency for no payoff.)
|
|
238
|
+
# Under strong phase noise the Kay estimate is biased by the sine
|
|
239
|
+
# nonlinearity (kHz-scale here), which the fine stage corrects.
|
|
240
|
+
acc = xp.sum(z2[:, 1:] * xp.conj(z2[:, :-1]), axis=-1)
|
|
241
|
+
f_hat = xp.angle(acc) * (fs / (2.0 * np.pi)) # (C,)
|
|
242
|
+
else:
|
|
243
|
+
f_hat = xp.full(z2.shape[0], float(f_shift), dtype=xp.float64)
|
|
244
|
+
|
|
245
|
+
n = z2.shape[-1]
|
|
246
|
+
n_idx = xp.arange(n, dtype=xp.float64)
|
|
247
|
+
z_bb = correct_static_frequency_offset(z2, fs, f_hat)
|
|
248
|
+
dphi = xp.unwrap(xp.angle(z_bb), axis=-1)
|
|
249
|
+
|
|
250
|
+
if estimate:
|
|
251
|
+
# Fine stage: remove the per-channel least-squares slope of the
|
|
252
|
+
# unwrapped phase (the exact linear-ramp / mean-frequency component the
|
|
253
|
+
# Kay stage leaves behind). Skipped when f_shift is user-supplied so
|
|
254
|
+
# genuine drift stays visible.
|
|
255
|
+
nc = n_idx - 0.5 * (n - 1)
|
|
256
|
+
slope = (dphi @ nc) / (n * (n * n - 1.0) / 12.0) # (C,) rad/sample
|
|
257
|
+
dphi = dphi - slope[:, None] * nc[None, :]
|
|
258
|
+
f_hat = f_hat + slope * (fs / (2.0 * np.pi))
|
|
259
|
+
|
|
260
|
+
f_used = _scalar_or_array(to_device(f_hat, "cpu"))
|
|
261
|
+
return (dphi[0] if was_1d else dphi), f_used
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def dsh_fm_noise_psd(
|
|
265
|
+
delta_phi: ArrayType,
|
|
266
|
+
sampling_rate: float,
|
|
267
|
+
delay: float,
|
|
268
|
+
*,
|
|
269
|
+
nperseg: int | None = None,
|
|
270
|
+
notch_guard: float = 0.1,
|
|
271
|
+
bias_correction: bool = True,
|
|
272
|
+
debug_plot: bool = False,
|
|
273
|
+
) -> tuple[ArrayType, ArrayType, ArrayType]:
|
|
274
|
+
r"""Laser FM-noise PSD from the differential phase (notch-guarded deconvolution).
|
|
275
|
+
|
|
276
|
+
The interferometer maps the laser phase PSD through
|
|
277
|
+
``|1 - e^{-j2πfτ_d}|² = 4 sin²(πfτ_d)``, so the *beat* FM-noise PSD relates
|
|
278
|
+
to the *laser* FM-noise PSD as
|
|
279
|
+
|
|
280
|
+
S_f,beat(f) = 4 sin²(πfτ_d) · S_f,laser(f).
|
|
281
|
+
|
|
282
|
+
This function computes ``S_f,beat`` from ``delta_phi`` (via
|
|
283
|
+
``fm_noise_psd``) and divides the response back out. Bins near the
|
|
284
|
+
response notches ``f = k/τ_d`` (including DC) are unrecoverable - they are
|
|
285
|
+
returned as NaN and flagged in ``valid``.
|
|
286
|
+
|
|
287
|
+
For ``f ≪ 1/τ_d`` the response reduces to ``(2πfτ_d)²``: the interferometer
|
|
288
|
+
acts as a frequency discriminator with a known gain, which is why the
|
|
289
|
+
method still works when the delay is far *shorter* than the coherence time
|
|
290
|
+
(where the Lorentzian-fit method fails).
|
|
291
|
+
|
|
292
|
+
Parameters
|
|
293
|
+
----------
|
|
294
|
+
delta_phi : array_like
|
|
295
|
+
Unwrapped differential phase from ``dsh_phase`` (radians), ``(N,)`` or
|
|
296
|
+
``(C, N)``.
|
|
297
|
+
sampling_rate : float
|
|
298
|
+
Sampling rate of ``delta_phi`` in Hz.
|
|
299
|
+
delay : float
|
|
300
|
+
Interferometer differential delay τ_d in seconds (fiber: τ_d ≈ n·L/c ≈
|
|
301
|
+
4.9 µs per km of SMF).
|
|
302
|
+
nperseg : int, optional
|
|
303
|
+
Welch segment length (see ``fm_noise_psd``).
|
|
304
|
+
notch_guard : float, default 0.1
|
|
305
|
+
Bins with ``sin²(πfτ_d)`` below this threshold are masked (NaN). The
|
|
306
|
+
default keeps ≈ 80 % of every response lobe.
|
|
307
|
+
bias_correction : bool, default True
|
|
308
|
+
Forwarded to ``fm_noise_psd`` (first-difference droop).
|
|
309
|
+
debug_plot : bool, default False
|
|
310
|
+
If True, plot the deconvolved PSD (``frequency_noise_psd``; masked
|
|
311
|
+
bins appear as gaps).
|
|
312
|
+
|
|
313
|
+
Returns
|
|
314
|
+
-------
|
|
315
|
+
f : array_like
|
|
316
|
+
One-sided frequency axis in Hz.
|
|
317
|
+
S_f : array_like
|
|
318
|
+
Laser FM-noise PSD in Hz²/Hz (NaN at masked bins), ``(nfreq,)`` or
|
|
319
|
+
``(C, nfreq)``.
|
|
320
|
+
valid : array_like
|
|
321
|
+
Boolean mask ``(nfreq,)`` of trustworthy bins.
|
|
322
|
+
|
|
323
|
+
All three stay on the input backend (no host transfer) - this is the
|
|
324
|
+
composable building block; ``linewidth_dsh`` is the summary layer that
|
|
325
|
+
returns host NumPy for reporting.
|
|
326
|
+
|
|
327
|
+
See Also
|
|
328
|
+
--------
|
|
329
|
+
allan_deviation : feed it ``delta_phi/(2π·delay)`` - the interferometer's
|
|
330
|
+
discriminator output - for the laser frequency stability at averaging
|
|
331
|
+
times ``τ ≫ delay`` (shorter τ are low-passed by the τ_d window).
|
|
332
|
+
|
|
333
|
+
Notes
|
|
334
|
+
-----
|
|
335
|
+
**Limitations.**
|
|
336
|
+
|
|
337
|
+
* Even inside the guard band the deconvolution *amplifies* estimation
|
|
338
|
+
noise by ``1/(4 sin²)`` - near-notch bins are noisier than mid-lobe
|
|
339
|
+
bins. Prefer median-based summaries (``linewidth_dsh`` does).
|
|
340
|
+
* Additive detector noise on the beat produces an ``f²`` tail in
|
|
341
|
+
``S_f,beat`` that deconvolution maps into every lobe; restrict analysis
|
|
342
|
+
to the first lobe (``f < 1/τ_d``) unless the beat SNR is very high.
|
|
343
|
+
* The delay must be known accurately: FM-PSD levels scale as ``1/τ_d²``
|
|
344
|
+
below the first notch, so a delay error maps 1:1 (x2) into the
|
|
345
|
+
linewidth. Calibrate τ_d from the measured notch spacing ``1/τ_d`` if
|
|
346
|
+
in doubt.
|
|
347
|
+
* **Long (decoherence) delays need resolution**: the deconvolution is
|
|
348
|
+
only valid when the Welch bin is much narrower than the response period
|
|
349
|
+
``1/τ_d`` - i.e. ``nperseg ≳ 8·f_s·τ_d``. A warning is logged
|
|
350
|
+
otherwise (bins that average across notches bias the PSD low).
|
|
351
|
+
"""
|
|
352
|
+
td = float(delay)
|
|
353
|
+
if td <= 0.0:
|
|
354
|
+
raise ValueError(f"delay={delay} must be positive (seconds).")
|
|
355
|
+
|
|
356
|
+
f, S_beat = fm_noise_psd(
|
|
357
|
+
delta_phi,
|
|
358
|
+
float(sampling_rate),
|
|
359
|
+
nperseg=nperseg,
|
|
360
|
+
bias_correction=bias_correction,
|
|
361
|
+
)
|
|
362
|
+
_, xp, _ = dispatch(f)
|
|
363
|
+
|
|
364
|
+
# The deconvolution samples the response at bin centers; if a Welch bin
|
|
365
|
+
# spans a sizable fraction of the response period 1/τ_d (long decoherence
|
|
366
|
+
# spools), each bin *averages* across lobes and notches and the result is
|
|
367
|
+
# biased low. Require ≥ 4 bins per period; recommend ≥ 8.
|
|
368
|
+
bin_hz = float(f[1])
|
|
369
|
+
if bin_hz > 0.25 / td:
|
|
370
|
+
rec = 1 << int(np.ceil(np.log2(8.0 * float(sampling_rate) * td)))
|
|
371
|
+
logger.warning(
|
|
372
|
+
"dsh_fm_noise_psd: Welch bin (%.3g Hz) exceeds a quarter of the "
|
|
373
|
+
"interferometer response period 1/τ_d = %.3g Hz - bins average "
|
|
374
|
+
"across notches and the deconvolved PSD is biased low. Increase "
|
|
375
|
+
"nperseg to ≳ %d (≥ 8 bins per period).",
|
|
376
|
+
bin_hz,
|
|
377
|
+
1.0 / td,
|
|
378
|
+
rec,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
s2 = xp.sin(np.pi * f * td) ** 2 # interferometer response / 4
|
|
382
|
+
valid = s2 >= float(notch_guard)
|
|
383
|
+
S_laser = xp.where(valid, S_beat / xp.maximum(4.0 * s2, 1e-300), xp.nan)
|
|
384
|
+
|
|
385
|
+
if debug_plot:
|
|
386
|
+
from .. import plotting as _plotting
|
|
387
|
+
|
|
388
|
+
_plotting.plot_frequency_noise_psd(
|
|
389
|
+
f, S_laser, show=True, title="DSH laser FM-noise PSD (deconvolved)"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return f, S_laser, valid
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _lorentzian_widths(f, p, level_lin):
|
|
396
|
+
"""Full width of a spectral line ``level_lin`` (linear ratio) below its peak.
|
|
397
|
+
|
|
398
|
+
Walks outward from the peak bin to the nearest below-threshold bin on each
|
|
399
|
+
side and interpolates the crossing in log-power. Returns NaN when the
|
|
400
|
+
threshold is never crossed (e.g. it sits below the noise floor).
|
|
401
|
+
|
|
402
|
+
Host-side NumPy by design: the caller hands it an ``nperseg``-sized Welch
|
|
403
|
+
spectrum already brought to the host with a single transfer, and the
|
|
404
|
+
crossing walk is scalar, data-dependent branching that a GPU cannot help
|
|
405
|
+
with.
|
|
406
|
+
"""
|
|
407
|
+
i_pk = int(np.argmax(p))
|
|
408
|
+
thr = p[i_pk] / level_lin
|
|
409
|
+
|
|
410
|
+
left = np.nonzero(p[:i_pk] < thr)[0]
|
|
411
|
+
right = np.nonzero(p[i_pk + 1 :] < thr)[0]
|
|
412
|
+
if left.size == 0 or right.size == 0:
|
|
413
|
+
return np.nan
|
|
414
|
+
|
|
415
|
+
i0 = left[-1] # crossing between i0 and i0+1
|
|
416
|
+
i1 = i_pk + 1 + right[0] # crossing between i1-1 and i1
|
|
417
|
+
l0, l1 = np.log(p[i0]), np.log(p[i0 + 1])
|
|
418
|
+
f_lo = f[i0] + (f[i0 + 1] - f[i0]) * (np.log(thr) - l0) / (l1 - l0)
|
|
419
|
+
r0, r1 = np.log(p[i1 - 1]), np.log(p[i1])
|
|
420
|
+
f_hi = f[i1 - 1] + (f[i1] - f[i1 - 1]) * (np.log(thr) - r0) / (r1 - r0)
|
|
421
|
+
return float(f_hi - f_lo)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def linewidth_dsh(
|
|
425
|
+
samples: ArrayType,
|
|
426
|
+
sampling_rate: float,
|
|
427
|
+
delay: float,
|
|
428
|
+
*,
|
|
429
|
+
f_shift: float | None = None,
|
|
430
|
+
method: str = "fm_psd",
|
|
431
|
+
lags: tuple[int, ...] | None = None,
|
|
432
|
+
nperseg: int | None = None,
|
|
433
|
+
f_min: float | None = None,
|
|
434
|
+
f_max: float | None = None,
|
|
435
|
+
notch_guard: float = 0.1,
|
|
436
|
+
level_db: float = 20.0,
|
|
437
|
+
debug_plot: bool = False,
|
|
438
|
+
) -> dict[str, object]:
|
|
439
|
+
r"""Laser linewidth from a delayed self-heterodyne / self-homodyne beat.
|
|
440
|
+
|
|
441
|
+
Three estimators with complementary validity regions (``τ_c = 1/(πΔν)`` is
|
|
442
|
+
the laser coherence time):
|
|
443
|
+
|
|
444
|
+
* ``method="fm_psd"`` (default) - ``dsh_phase`` -> ``dsh_fm_noise_psd`` ->
|
|
445
|
+
white-FM floor ``Δν = π · median(S_f,laser)`` over the valid band.
|
|
446
|
+
Works for **any** delay (coherent or incoherent regime); the notch
|
|
447
|
+
structure, not the regime, sets the usable band.
|
|
448
|
+
* ``method="increment"`` - variance of the lag-``ℓ`` increments of the
|
|
449
|
+
*measured* differential phase. For lag ``a = ℓ/f_s ≤ τ_d`` the two
|
|
450
|
+
Wiener increments are disjoint, so
|
|
451
|
+
|
|
452
|
+
Var[Δφ(t) - Δφ(t-a)] = 4π·Δν·a + 2σ_w²,
|
|
453
|
+
|
|
454
|
+
and a straight-line fit vs ``a`` gives ``Δν = slope/(4π)`` with the beat
|
|
455
|
+
angle noise cancelling into the intercept - no SNR estimate needed
|
|
456
|
+
(same trick as ``linewidth_increment``). White-FM (Wiener) assumption.
|
|
457
|
+
* ``method="lorentzian"`` - the textbook approach: in the incoherent
|
|
458
|
+
regime (``τ_d ≫ τ_c``) the beat line is Lorentzian with FWHM ``2Δν``.
|
|
459
|
+
The width is measured ``level_db`` below the peak and converted via
|
|
460
|
+
``Δν = W_L / (2·√(10^{L/10} - 1))`` (-> ``W₂₀/(2√99)`` for the customary
|
|
461
|
+
-20 dB width, which suppresses the Gaussian 1/f-noise core that
|
|
462
|
+
contaminates the -3 dB width).
|
|
463
|
+
|
|
464
|
+
Parameters
|
|
465
|
+
----------
|
|
466
|
+
samples : array_like
|
|
467
|
+
Beat record, ``(N,)`` or ``(C, N)``, real (heterodyne photocurrent) or
|
|
468
|
+
complex (IQ).
|
|
469
|
+
sampling_rate : float
|
|
470
|
+
Sampling rate in Hz.
|
|
471
|
+
delay : float
|
|
472
|
+
Interferometer differential delay τ_d in seconds.
|
|
473
|
+
f_shift : float, optional
|
|
474
|
+
Known AOM/beat carrier in Hz; estimated if None (see ``dsh_phase``).
|
|
475
|
+
Unused by ``method="lorentzian"``.
|
|
476
|
+
method : {"fm_psd", "increment", "lorentzian"}, default "fm_psd"
|
|
477
|
+
Estimator, as above.
|
|
478
|
+
lags : tuple of int, optional
|
|
479
|
+
Increment lags in *samples* for ``method="increment"``. Default:
|
|
480
|
+
five lags up to ``a_max = min(0.5·τ_d·f_s, N/2000)`` - inside the
|
|
481
|
+
disjoint window ``ℓ ≤ τ_d·f_s`` *and* small enough that the variance
|
|
482
|
+
estimator keeps many independent averages when the delay is long
|
|
483
|
+
(decoherence spools).
|
|
484
|
+
nperseg : int, optional
|
|
485
|
+
Welch segment length for the PSD-based methods.
|
|
486
|
+
f_min, f_max : float, optional
|
|
487
|
+
Manual analysis fence for ``method="fm_psd"``. When **both** are
|
|
488
|
+
None (default) the plateau is **auto-detected**: the white-FM floor
|
|
489
|
+
is located as the minimum of octave-band medians over all valid bins
|
|
490
|
+
and the median runs over every bin within 3x that floor - spanning
|
|
491
|
+
as many interferometer lobes as the detection-noise knee allows, and
|
|
492
|
+
automatically excluding a rising low-frequency (drift/flicker)
|
|
493
|
+
region. Passing either bound switches to the literal fence
|
|
494
|
+
(``f_min`` -> 0, ``f_max`` -> first notch ``1/τ_d`` when the other is
|
|
495
|
+
omitted), with the median over *all* valid bins inside.
|
|
496
|
+
notch_guard : float, default 0.1
|
|
497
|
+
Notch mask threshold for ``method="fm_psd"`` (``dsh_fm_noise_psd``).
|
|
498
|
+
level_db : float, default 20.0
|
|
499
|
+
Depth below the peak at which the ``lorentzian`` width is measured.
|
|
500
|
+
debug_plot : bool, default False
|
|
501
|
+
Per-method diagnostic plot: the deconvolved FM-noise PSD with the
|
|
502
|
+
fitted white-FM floor (``fm_psd``), the increment-variance fit
|
|
503
|
+
(``increment``), or the beat spectrum with the measured width
|
|
504
|
+
contours (``lorentzian``).
|
|
505
|
+
|
|
506
|
+
Returns
|
|
507
|
+
-------
|
|
508
|
+
dict
|
|
509
|
+
Always ``{'linewidth', 'method', ...}`` with linewidths as floats
|
|
510
|
+
(SISO) or ``(C,)`` arrays. Extra keys per method:
|
|
511
|
+
|
|
512
|
+
* ``fm_psd`` - ``f``, ``S_f`` (NaN-masked laser FM PSD), ``valid``,
|
|
513
|
+
``f_shift``, ``used`` (boolean mask of the bins under the accepted
|
|
514
|
+
plateau region; in auto mode the level is the median of
|
|
515
|
+
*per-log-cell medians* of these bins - one vote per cell, not per
|
|
516
|
+
bin - while a manual fence medians the raw bins directly),
|
|
517
|
+
``band`` (frequency extent of ``used``), ``n_segments`` (Welch
|
|
518
|
+
segment count ``K`` behind every PSD bin).
|
|
519
|
+
* ``increment`` - ``awgn_var`` (fitted intercept; equals the beat
|
|
520
|
+
angle-noise ``2σ_w²`` only when read with *small* lags - at the
|
|
521
|
+
large default lags the phase-noise term dominates every point and
|
|
522
|
+
the intercept is a noisy extrapolation), ``dphi_var`` (total
|
|
523
|
+
``Var[Δφ] ≈ 2πΔν·τ_d + σ_w²``), ``lags``, ``f_shift``.
|
|
524
|
+
* ``lorentzian`` - ``linewidth_3db`` (half-power width / 2, the
|
|
525
|
+
*effective* linewidth incl. 1/f broadening), ``lineshape_ratio``
|
|
526
|
+
(``W₂₀/W₃``: ≈ 9.95 pure Lorentzian, ≈ 2.6 pure Gaussian),
|
|
527
|
+
``coherence_factor`` (``τ_d/τ_c = π·Δν·τ_d``), ``f``, ``psd``,
|
|
528
|
+
``f_peak``.
|
|
529
|
+
|
|
530
|
+
As a *summary* function the dict holds host NumPy only - floats plus
|
|
531
|
+
plot-sized spectra (≤ ``nperseg`` bins, one device->host transfer).
|
|
532
|
+
To keep sample-rate results on the input backend, use ``dsh_phase``
|
|
533
|
+
and ``dsh_fm_noise_psd`` directly.
|
|
534
|
+
|
|
535
|
+
Notes
|
|
536
|
+
-----
|
|
537
|
+
**90°-hybrid (IQ) capture - which method?** The detection scheme, the
|
|
538
|
+
AOM shift, and the delay regime are independent choices: a 90° hybrid
|
|
539
|
+
(phase-diversity receiver) delivers the complex beat directly - with an
|
|
540
|
+
AOM (heterodyne IQ, line at ``f_shift``) or without one (homodyne, line
|
|
541
|
+
at 0 Hz; the AOM becomes optional) - and pairs equally well with a short
|
|
542
|
+
delay or a long *decoherence* spool (``τ_d ≫ τ_c``). Pick the estimator
|
|
543
|
+
by the coherence factor alone - incoherent regime: all three methods
|
|
544
|
+
apply; coherent regime: ``fm_psd`` / ``increment``. Two hybrid-specific
|
|
545
|
+
caveats, at their worst in the *homodyne* case where both spurs sit
|
|
546
|
+
exactly at the line center: photodiode DC offsets - calibrate them out
|
|
547
|
+
(arms blocked) *especially* before ``lorentzian``, where a DC spur
|
|
548
|
+
mid-line hijacks the peak and narrows the measured widths; and hybrid
|
|
549
|
+
amplitude/phase imbalance, which creates a conjugate image of the beat -
|
|
550
|
+
correct it first (``impairments.compensate_iq_imbalance_gram_schmidt`` /
|
|
551
|
+
``..._lowdin``). An AOM shift moves the line ``f_shift`` away from the
|
|
552
|
+
DC spur and ``2·f_shift`` away from its own image, so heterodyne IQ
|
|
553
|
+
captures are considerably more forgiving of both.
|
|
554
|
+
|
|
555
|
+
**Long-term stability**: for an Allan-deviation view, feed the
|
|
556
|
+
discriminator output ``delta_phi/(2π·delay)`` (from ``dsh_phase``) into
|
|
557
|
+
``allan_deviation`` - valid for averaging times ``τ ≫ delay``.
|
|
558
|
+
|
|
559
|
+
**Physical limitations (all methods).**
|
|
560
|
+
|
|
561
|
+
* The measurement floor is set by the interferometer, not the laser:
|
|
562
|
+
fiber acoustic/thermal noise (delay arm as a microphone), AOM driver
|
|
563
|
+
phase noise, and polarization fading (use a Faraday mirror or scrambler)
|
|
564
|
+
all add to the apparent laser noise.
|
|
565
|
+
* τ_d must be known (fiber: ``τ_d ≈ 4.9 µs/km``); errors propagate
|
|
566
|
+
linearly (``increment``) or quadratically via the discriminator gain
|
|
567
|
+
(``fm_psd`` below the first notch).
|
|
568
|
+
|
|
569
|
+
**Method-specific.**
|
|
570
|
+
|
|
571
|
+
* ``lorentzian`` requires ``coherence_factor ≳ 6`` (rule of thumb): below
|
|
572
|
+
that the spectrum develops a coherent carrier spike plus fringes at
|
|
573
|
+
``1/τ_d`` spacing and the width no longer reads 2Δν - a warning is
|
|
574
|
+
logged. It also needs the -``level_db`` contour above the noise floor
|
|
575
|
+
(peak dynamic range ≳ ``level_db`` + 10 dB) and ``≳ 10`` Welch bins
|
|
576
|
+
across the line. 1/f noise makes the -3 dB width grow with observation
|
|
577
|
+
time (Gaussian core); the deep-width estimate is the intrinsic
|
|
578
|
+
(Lorentzian/white-FM) linewidth.
|
|
579
|
+
* ``increment`` assumes white FM; flicker bends ``Var(a)`` super-linear
|
|
580
|
+
and biases Δν high (see ``linewidth_increment`` notes). Slow drift is
|
|
581
|
+
harmless up to linear frequency chirp (constant term after
|
|
582
|
+
differencing), quadratic beyond.
|
|
583
|
+
* ``fm_psd`` reports the white-FM *floor*. The default auto-detection
|
|
584
|
+
finds the plateau as the PSD's minimum-level region, so a 1/f rise at
|
|
585
|
+
low f and the detection-noise f² tail at high f are excluded without
|
|
586
|
+
manual fencing; it falls back to the first lobe (with a warning) when
|
|
587
|
+
no plateau is found. For **real** captures the eligible band is
|
|
588
|
+
additionally capped at the receiver's FM detection bandwidth
|
|
589
|
+
``min(f_shift, f_s/2 - f_shift)`` - beyond it the beat carries no
|
|
590
|
+
sidebands and the deconvolved PSD reads fake-low. If the *entire*
|
|
591
|
+
usable band is 1/f-dominated (very long τ_d, quiet laser), the
|
|
592
|
+
reported floor is the lowest resolved noise level - inspect the PSD
|
|
593
|
+
before quoting it as Δν. Welch bins are χ²-distributed, so a raw
|
|
594
|
+
median floor reads ``≈ 1 - 1/(3K)`` *below* the true level for ``K``
|
|
595
|
+
averaged segments; the reported linewidth divides that analytic
|
|
596
|
+
factor back out (relevant for the long ``nperseg`` a dense notch
|
|
597
|
+
comb demands - keep the record ≳ 5·nperseg so ``K ≳ 10``). The
|
|
598
|
+
plotted log-binned median curve is *not* corrected, so at small
|
|
599
|
+
``K`` the ``Δν/π`` floor guide sits visibly above it.
|
|
600
|
+
"""
|
|
601
|
+
fs = float(sampling_rate)
|
|
602
|
+
td = float(delay)
|
|
603
|
+
if td <= 0.0:
|
|
604
|
+
raise ValueError(f"delay={delay} must be positive (seconds).")
|
|
605
|
+
m_samp = td * fs
|
|
606
|
+
if m_samp < 1.0:
|
|
607
|
+
raise ValueError(
|
|
608
|
+
f"delay·sampling_rate = {m_samp:.3g} < 1 sample - the differential "
|
|
609
|
+
"delay is unresolvable at this sampling rate."
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
if method == "fm_psd":
|
|
613
|
+
dphi, f_hat = dsh_phase(samples, fs, f_shift=f_shift)
|
|
614
|
+
f, S_l, valid = dsh_fm_noise_psd(
|
|
615
|
+
dphi, fs, td, nperseg=nperseg, notch_guard=notch_guard
|
|
616
|
+
)
|
|
617
|
+
# Summary layer: one transfer, host-side plateau search + median
|
|
618
|
+
# (plot-sized spectra - see the package backend policy).
|
|
619
|
+
f_cpu = np.asarray(to_device(f, "cpu"), dtype=np.float64)
|
|
620
|
+
S_cpu = np.asarray(to_device(S_l, "cpu"), dtype=np.float64)
|
|
621
|
+
valid_cpu = np.asarray(to_device(valid, "cpu"), dtype=bool)
|
|
622
|
+
S2 = S_cpu[None, :] if S_cpu.ndim == 1 else S_cpu
|
|
623
|
+
n_ch = S2.shape[0]
|
|
624
|
+
|
|
625
|
+
# Welch bins are χ²-distributed, so every median-based floor below
|
|
626
|
+
# reads the true level low by median(χ²_ν)/ν ≈ 1 - 1/(3K); the factor
|
|
627
|
+
# is divided back out of the linewidth at the end.
|
|
628
|
+
nps_used = int(round(fs / float(f_cpu[1])))
|
|
629
|
+
m_med, k_seg = _welch_median_bias(dphi.shape[-1] - 1, nps_used)
|
|
630
|
+
if k_seg < 4:
|
|
631
|
+
logger.warning(
|
|
632
|
+
"linewidth_dsh(fm_psd): only %d Welch segment(s) at "
|
|
633
|
+
"nperseg=%d - the plateau median is noisy and the χ²-median "
|
|
634
|
+
"correction (÷%.3f) is asymptotic; extend the record (aim "
|
|
635
|
+
"for ≥ 5·nperseg samples).",
|
|
636
|
+
k_seg,
|
|
637
|
+
nps_used,
|
|
638
|
+
m_med,
|
|
639
|
+
)
|
|
640
|
+
else:
|
|
641
|
+
logger.info(
|
|
642
|
+
"linewidth_dsh(fm_psd): %d Welch segments - χ²-median floor "
|
|
643
|
+
"bias corrected by ÷%.4f.",
|
|
644
|
+
k_seg,
|
|
645
|
+
m_med,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
auto_band = f_min is None and f_max is None
|
|
649
|
+
if auto_band:
|
|
650
|
+
base = valid_cpu & (f_cpu > 0)
|
|
651
|
+
# Real (single-photodiode) captures carry FM sidebands only out
|
|
652
|
+
# to the beat carrier's distance from the band edges: offsets
|
|
653
|
+
# beyond min(f_shift, f_nyq - f_shift) have no physical support
|
|
654
|
+
# after the analytic-signal step and read as fake-low PSD - cap
|
|
655
|
+
# the eligible band there. Complex IQ captures have no such
|
|
656
|
+
# limit (full ±Nyquist).
|
|
657
|
+
x_in, xp_in, _ = dispatch(samples)
|
|
658
|
+
if not xp_in.iscomplexobj(x_in):
|
|
659
|
+
fh = float(np.min(np.asarray(f_hat)))
|
|
660
|
+
f_cap = min(fh, fs / 2.0 - fh)
|
|
661
|
+
base = base & (f_cpu <= f_cap)
|
|
662
|
+
base2 = np.broadcast_to(base, S2.shape)
|
|
663
|
+
used2, levels = _plateau_mask(f_cpu, S2, base2)
|
|
664
|
+
lw = np.pi * levels
|
|
665
|
+
# Channels where no plateau was found fall back to the first lobe.
|
|
666
|
+
for c in range(n_ch):
|
|
667
|
+
if not np.isfinite(lw[c]):
|
|
668
|
+
logger.warning(
|
|
669
|
+
"linewidth_dsh(fm_psd): no plateau detected "
|
|
670
|
+
"(channel %d) - falling back to the first-lobe band "
|
|
671
|
+
"[0, 1/τ_d]. Inspect the PSD before quoting Δν.",
|
|
672
|
+
c,
|
|
673
|
+
)
|
|
674
|
+
used2[c] = valid_cpu & (f_cpu > 0) & (f_cpu <= 1.0 / td)
|
|
675
|
+
if used2[c].any():
|
|
676
|
+
lw[c] = np.pi * np.median(S2[c][used2[c]])
|
|
677
|
+
else:
|
|
678
|
+
fmin = 0.0 if f_min is None else float(f_min)
|
|
679
|
+
fmax = (1.0 / td) if f_max is None else float(f_max)
|
|
680
|
+
used2 = np.broadcast_to(
|
|
681
|
+
valid_cpu & (f_cpu >= fmin) & (f_cpu <= fmax), S2.shape
|
|
682
|
+
)
|
|
683
|
+
if used2.any(axis=-1).all():
|
|
684
|
+
lw = np.array([np.pi * np.median(S2[c][used2[c]]) for c in range(n_ch)])
|
|
685
|
+
|
|
686
|
+
if not used2.any(axis=-1).all():
|
|
687
|
+
raise ValueError(
|
|
688
|
+
"No valid FM-PSD bins in the analysis band - widen "
|
|
689
|
+
"[f_min, f_max], lower notch_guard, or increase nperseg."
|
|
690
|
+
)
|
|
691
|
+
lw = lw / m_med
|
|
692
|
+
f_used = f_cpu[used2.any(axis=0)]
|
|
693
|
+
band_used = (float(f_used[0]), float(f_used[-1]))
|
|
694
|
+
|
|
695
|
+
used_cpu = used2[0] if S_cpu.ndim == 1 else used2
|
|
696
|
+
result = {
|
|
697
|
+
"linewidth": _scalar_or_array(lw),
|
|
698
|
+
"f": f_cpu,
|
|
699
|
+
"S_f": S_cpu,
|
|
700
|
+
"valid": valid_cpu,
|
|
701
|
+
"used": used_cpu,
|
|
702
|
+
"band": band_used,
|
|
703
|
+
"n_segments": k_seg,
|
|
704
|
+
"f_shift": f_hat,
|
|
705
|
+
"method": method,
|
|
706
|
+
}
|
|
707
|
+
if debug_plot:
|
|
708
|
+
from .. import plotting as _plotting
|
|
709
|
+
|
|
710
|
+
_plotting.plot_frequency_noise_psd(
|
|
711
|
+
f_cpu,
|
|
712
|
+
S_cpu,
|
|
713
|
+
floor=result["linewidth"],
|
|
714
|
+
band=band_used,
|
|
715
|
+
used=used_cpu,
|
|
716
|
+
show=True,
|
|
717
|
+
title="DSH laser FM-noise PSD (deconvolved)",
|
|
718
|
+
)
|
|
719
|
+
return result
|
|
720
|
+
|
|
721
|
+
if method == "increment":
|
|
722
|
+
dphi, f_hat = dsh_phase(samples, fs, f_shift=f_shift)
|
|
723
|
+
d2, _ = _as_2d(dphi)
|
|
724
|
+
_, xp, _ = dispatch(d2)
|
|
725
|
+
|
|
726
|
+
m_int = int(round(m_samp))
|
|
727
|
+
if lags is None:
|
|
728
|
+
# Largest default lag: half the delay (disjoint-increment bound),
|
|
729
|
+
# further capped by the record length - the Var estimator's
|
|
730
|
+
# correlation support scales with the lag, so for long decoherence
|
|
731
|
+
# spools small lags give far more independent averages (measured:
|
|
732
|
+
# ~3x lower spread at τ_d·f_s = 6·10⁴, N = 2·10⁶) while the AWGN
|
|
733
|
+
# intercept still cancels in the fit.
|
|
734
|
+
a_max = max(1.0, min(0.5 * m_samp, d2.shape[-1] / 2000.0))
|
|
735
|
+
ls = np.unique(
|
|
736
|
+
np.maximum(
|
|
737
|
+
1,
|
|
738
|
+
np.round(a_max * np.array([0.2, 0.4, 0.6, 0.8, 1.0])).astype(int),
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
else:
|
|
742
|
+
ls = np.unique(np.asarray([int(lag) for lag in lags]))
|
|
743
|
+
if ls.size and ls[0] < 1:
|
|
744
|
+
raise ValueError("lags must be positive sample counts.")
|
|
745
|
+
if ls.size < 2:
|
|
746
|
+
raise ValueError(
|
|
747
|
+
f"method='increment' needs ≥ 2 distinct lags (delay spans only "
|
|
748
|
+
f"{m_int} samples - increase the sampling rate or pass lags)."
|
|
749
|
+
)
|
|
750
|
+
if int(ls[-1]) > m_int:
|
|
751
|
+
logger.warning(
|
|
752
|
+
"linewidth_dsh: max lag %d exceeds the delay (%d samples); the "
|
|
753
|
+
"Wiener increments overlap and Var(a) is no longer linear - "
|
|
754
|
+
"the fit will be biased low.",
|
|
755
|
+
int(ls[-1]),
|
|
756
|
+
m_int,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
var_l = xp.stack(
|
|
760
|
+
[xp.var(d2[:, lag:] - d2[:, :-lag], axis=-1) for lag in ls], axis=0
|
|
761
|
+
) # (n_lag, C)
|
|
762
|
+
dphi_var = xp.var(d2, axis=-1)
|
|
763
|
+
# Tiny (n_lag, C) fit input - one D2H transfer + host polyfit.
|
|
764
|
+
var_cpu = np.asarray(to_device(var_l, "cpu"), dtype=np.float64)
|
|
765
|
+
a_sec = ls.astype(np.float64) / fs
|
|
766
|
+
coeffs = np.polyfit(a_sec, var_cpu, 1) # (2, C): [slope, intercept]
|
|
767
|
+
slope, intercept = coeffs[0], coeffs[1]
|
|
768
|
+
lw_cpu = np.maximum(slope, 0.0) / (4.0 * np.pi)
|
|
769
|
+
|
|
770
|
+
if debug_plot:
|
|
771
|
+
from .. import plotting as _plotting
|
|
772
|
+
|
|
773
|
+
_plotting.plot_increment_variance(
|
|
774
|
+
a_sec,
|
|
775
|
+
var_cpu.T,
|
|
776
|
+
slope=slope,
|
|
777
|
+
intercept=intercept,
|
|
778
|
+
show=True,
|
|
779
|
+
title="DSH differential-phase increment variance",
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
"linewidth": _scalar_or_array(lw_cpu),
|
|
784
|
+
"awgn_var": _scalar_or_array(intercept),
|
|
785
|
+
"dphi_var": _scalar_or_array(to_device(dphi_var, "cpu")),
|
|
786
|
+
"lags": ls,
|
|
787
|
+
"f_shift": f_hat,
|
|
788
|
+
"method": method,
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if method == "lorentzian":
|
|
792
|
+
z2, was_1d, xp = _analytic_beat(samples)
|
|
793
|
+
n = z2.shape[-1]
|
|
794
|
+
npseg = int(min(max(n // 8, 256), 1 << 14)) if nperseg is None else nperseg
|
|
795
|
+
npseg = min(npseg, n)
|
|
796
|
+
f, P = welch_psd(
|
|
797
|
+
z2, sampling_rate=fs, nperseg=npseg, return_onesided=False, axis=-1
|
|
798
|
+
)
|
|
799
|
+
# From here on the work is scalar peak/width searching on an
|
|
800
|
+
# nperseg-sized spectrum - host-side NumPy on purpose.
|
|
801
|
+
f_cpu = np.asarray(to_device(f, "cpu"), dtype=np.float64)
|
|
802
|
+
P_cpu = np.asarray(to_device(P, "cpu"), dtype=np.float64)
|
|
803
|
+
P2 = P_cpu[None, :] if P_cpu.ndim == 1 else P_cpu
|
|
804
|
+
c = P2.shape[0]
|
|
805
|
+
|
|
806
|
+
from scipy.ndimage import uniform_filter1d
|
|
807
|
+
|
|
808
|
+
r_deep = 10.0 ** (float(level_db) / 10.0)
|
|
809
|
+
dnu_deep = np.full(c, np.nan)
|
|
810
|
+
dnu_3db = np.full(c, np.nan)
|
|
811
|
+
ratio = np.full(c, np.nan)
|
|
812
|
+
f_peak = np.full(c, np.nan)
|
|
813
|
+
bin_hz = f_cpu[1] - f_cpu[0]
|
|
814
|
+
for ch in range(c):
|
|
815
|
+
p = P2[ch]
|
|
816
|
+
# Pass 1 - rough half-power width on the raw spectrum, only to
|
|
817
|
+
# size the smoothing window. The raw argmax bin rides the upward
|
|
818
|
+
# Welch fluctuations (max over many ±1/√K bins), which biases the
|
|
819
|
+
# peak high and every width low; smoothing over ≈ FWHM/5 removes
|
|
820
|
+
# that bias at < 3 % lineshape droop.
|
|
821
|
+
w3_rough = _lorentzian_widths(f_cpu, p, 2.0)
|
|
822
|
+
if np.isfinite(w3_rough):
|
|
823
|
+
w_bins = min(int(w3_rough / (5.0 * bin_hz)) | 1, 101)
|
|
824
|
+
if w_bins >= 3:
|
|
825
|
+
p = uniform_filter1d(p, w_bins, mode="nearest")
|
|
826
|
+
|
|
827
|
+
i_pk = int(np.argmax(p))
|
|
828
|
+
f_peak[ch] = f_cpu[i_pk]
|
|
829
|
+
dyn_db = 10.0 * np.log10(p[i_pk] / np.median(p))
|
|
830
|
+
if dyn_db < float(level_db) + 10.0:
|
|
831
|
+
logger.warning(
|
|
832
|
+
"linewidth_dsh: beat peak only %.1f dB above the PSD floor "
|
|
833
|
+
"(channel %d) - the -%g dB contour is noise-limited; "
|
|
834
|
+
"increase averaging or lower level_db.",
|
|
835
|
+
dyn_db,
|
|
836
|
+
ch,
|
|
837
|
+
float(level_db),
|
|
838
|
+
)
|
|
839
|
+
w3 = _lorentzian_widths(f_cpu, p, 2.0) # half-power width
|
|
840
|
+
w_deep = _lorentzian_widths(f_cpu, p, r_deep)
|
|
841
|
+
if np.isfinite(w3) and w3 < 6.0 * bin_hz:
|
|
842
|
+
logger.warning(
|
|
843
|
+
"linewidth_dsh: half-power width spans < 6 Welch bins "
|
|
844
|
+
"(channel %d) - increase nperseg for a resolved line.",
|
|
845
|
+
ch,
|
|
846
|
+
)
|
|
847
|
+
dnu_3db[ch] = w3 / 2.0
|
|
848
|
+
dnu_deep[ch] = w_deep / (2.0 * np.sqrt(r_deep - 1.0))
|
|
849
|
+
if np.isfinite(w3) and np.isfinite(w_deep) and w3 > 0.0:
|
|
850
|
+
ratio[ch] = w_deep / w3
|
|
851
|
+
|
|
852
|
+
coh = np.pi * dnu_deep * td # τ_d / τ_c
|
|
853
|
+
if np.any(np.isfinite(coh) & (coh < 6.0)):
|
|
854
|
+
logger.warning(
|
|
855
|
+
"linewidth_dsh: τ_d/τ_c = %s < 6 - coherent-regime fringes; the "
|
|
856
|
+
"Lorentzian width is unreliable. Use method='fm_psd' or "
|
|
857
|
+
"'increment'.",
|
|
858
|
+
np.array2string(coh, precision=2),
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
if debug_plot:
|
|
862
|
+
from .. import plotting as _plotting
|
|
863
|
+
|
|
864
|
+
_plotting.plot_dsh_beat_psd(
|
|
865
|
+
f_cpu,
|
|
866
|
+
P2[0] if was_1d else P2,
|
|
867
|
+
f_peak=f_peak,
|
|
868
|
+
linewidth=dnu_deep,
|
|
869
|
+
linewidth_3db=dnu_3db,
|
|
870
|
+
level_db=level_db,
|
|
871
|
+
show=True,
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
"linewidth": _scalar_or_array(dnu_deep),
|
|
876
|
+
"linewidth_3db": _scalar_or_array(dnu_3db),
|
|
877
|
+
"lineshape_ratio": _scalar_or_array(ratio),
|
|
878
|
+
"coherence_factor": _scalar_or_array(coh),
|
|
879
|
+
"f": f_cpu,
|
|
880
|
+
"psd": P2[0] if was_1d else P2,
|
|
881
|
+
"f_peak": _scalar_or_array(f_peak),
|
|
882
|
+
"method": method,
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
raise ValueError(
|
|
886
|
+
f"Unknown method {method!r} (use 'fm_psd', 'increment' or 'lorentzian')."
|
|
887
|
+
)
|