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.
Files changed (84) hide show
  1. commkit/__init__.py +74 -0
  2. commkit/_cuda/__init__.py +321 -0
  3. commkit/_cuda/compiler.py +88 -0
  4. commkit/_cuda/src/bps_min_d2.cu +104 -0
  5. commkit/_cuda/src/cs_block.cu +119 -0
  6. commkit/_cuda/src/selftest.cu +14 -0
  7. commkit/analysis/__init__.py +55 -0
  8. commkit/analysis/_common.py +236 -0
  9. commkit/analysis/allan.py +108 -0
  10. commkit/analysis/drift.py +213 -0
  11. commkit/analysis/interferometry.py +887 -0
  12. commkit/analysis/linewidth.py +480 -0
  13. commkit/analysis/trajectory.py +91 -0
  14. commkit/backend.py +507 -0
  15. commkit/coding/__init__.py +23 -0
  16. commkit/coding/base.py +17 -0
  17. commkit/coding/bch.py +6 -0
  18. commkit/coding/convolutional.py +7 -0
  19. commkit/coding/crc.py +7 -0
  20. commkit/coding/galois.py +8 -0
  21. commkit/coding/hamming.py +6 -0
  22. commkit/coding/interleaving.py +7 -0
  23. commkit/coding/ldpc.py +8 -0
  24. commkit/coding/polar.py +8 -0
  25. commkit/coding/ratematch.py +6 -0
  26. commkit/coding/reed_solomon.py +6 -0
  27. commkit/coding/turbo.py +8 -0
  28. commkit/core/__init__.py +32 -0
  29. commkit/core/frame.py +992 -0
  30. commkit/core/generation.py +581 -0
  31. commkit/core/signal.py +725 -0
  32. commkit/equalization/__init__.py +49 -0
  33. commkit/equalization/_block.py +1855 -0
  34. commkit/equalization/_common.py +606 -0
  35. commkit/equalization/_kernels_jax.py +1720 -0
  36. commkit/equalization/_kernels_numba.py +1704 -0
  37. commkit/equalization/blind.py +223 -0
  38. commkit/equalization/linear.py +365 -0
  39. commkit/equalization/polarization.py +790 -0
  40. commkit/equalization/result.py +191 -0
  41. commkit/equalization/sequential.py +2805 -0
  42. commkit/filtering.py +1120 -0
  43. commkit/frequency.py +1191 -0
  44. commkit/helpers.py +489 -0
  45. commkit/impairments/__init__.py +43 -0
  46. commkit/impairments/channel/__init__.py +20 -0
  47. commkit/impairments/channel/linear.py +310 -0
  48. commkit/impairments/channel/nonlinear.py +11 -0
  49. commkit/impairments/frontend.py +229 -0
  50. commkit/impairments/noise.py +105 -0
  51. commkit/impairments/source.py +219 -0
  52. commkit/io.py +308 -0
  53. commkit/logger.py +103 -0
  54. commkit/mapping/__init__.py +46 -0
  55. commkit/mapping/bits.py +240 -0
  56. commkit/mapping/constellation.py +153 -0
  57. commkit/mapping/gray.py +429 -0
  58. commkit/mapping/llr.py +253 -0
  59. commkit/mapping/shaping.py +218 -0
  60. commkit/metrics.py +949 -0
  61. commkit/multirate.py +476 -0
  62. commkit/plotting/__init__.py +78 -0
  63. commkit/plotting/analysis.py +627 -0
  64. commkit/plotting/constellation.py +483 -0
  65. commkit/plotting/equalizer.py +390 -0
  66. commkit/plotting/eye.py +388 -0
  67. commkit/plotting/spectral.py +575 -0
  68. commkit/plotting/sync.py +953 -0
  69. commkit/plotting/theme.py +203 -0
  70. commkit/plotting/waveform.py +200 -0
  71. commkit/py.typed +0 -0
  72. commkit/recovery/__init__.py +51 -0
  73. commkit/recovery/bps.py +337 -0
  74. commkit/recovery/corrections.py +751 -0
  75. commkit/recovery/pilots.py +803 -0
  76. commkit/recovery/pll.py +482 -0
  77. commkit/recovery/tikhonov.py +424 -0
  78. commkit/recovery/viterbi_viterbi.py +227 -0
  79. commkit/spectral.py +560 -0
  80. commkit/timing.py +841 -0
  81. commkit-1.0.0.dist-info/METADATA +145 -0
  82. commkit-1.0.0.dist-info/RECORD +84 -0
  83. commkit-1.0.0.dist-info/WHEEL +4 -0
  84. 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
+ )