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,480 @@
1
+ """Linewidth estimators: phase-increment slope, FM-noise PSD, β-separation."""
2
+
3
+ import numpy as np
4
+
5
+ from ..backend import ArrayType, dispatch, to_device
6
+ from ..logger import logger
7
+ from ..spectral import welch_psd
8
+ from ._common import (
9
+ _BETA_SLOPE,
10
+ _FWHM_FROM_AREA,
11
+ _as_2d,
12
+ _plateau_mask,
13
+ _scalar_or_array,
14
+ _welch_median_bias,
15
+ )
16
+
17
+ __all__ = ["fm_noise_psd", "linewidth_beta_separation", "linewidth_increment"]
18
+
19
+
20
+ def linewidth_increment(
21
+ pn_phase: ArrayType,
22
+ symbol_rate: float,
23
+ *,
24
+ method: str = "slope",
25
+ lags: tuple[int, ...] = (1, 2, 3, 4, 5),
26
+ noise_var: float | None = None,
27
+ snr_db: float | np.ndarray | None = None,
28
+ ref_symbols: ArrayType | None = None,
29
+ edge_trim: int = 0,
30
+ debug_plot: bool = False,
31
+ ) -> dict[str, float | np.ndarray | bool]:
32
+ r"""Wiener linewidth from the phase-increment variance.
33
+
34
+ For a Wiener phase + AWGN angle noise, the variance of the lag-``k``
35
+ increment ``Δφ_k = φ(n) - φ(n-k)`` is **linear in ``k``**:
36
+
37
+ Var(delta_phi_k) = slope * k + intercept
38
+ where slope = 2 * pi * linewidth * T_sym and intercept = 2 * noise_var_phi
39
+
40
+ because the random-walk variance accumulates with ``k`` while the
41
+ *uncorrelated* AWGN angle noise contributes a fixed ``2σ_φ²`` regardless of
42
+ ``k``. Two estimators are provided:
43
+
44
+ * ``method="slope"`` (default, **rigorous & AWGN-free**): least-squares fit
45
+ of ``Var(Δφ_k)`` vs ``k`` over ``lags``; ``Δν = slope/(2π·T_sym)``. The
46
+ additive noise (AWGN *and* any residual white error from imperfect
47
+ equalization) cancels into the intercept, so **no noise estimate is
48
+ needed** - the key advantage over single-lag subtraction.
49
+ * ``method="subtract"``: single-lag (``k=1``) variance minus an explicit
50
+ AWGN term. With ``d`` at unit power, ``σ_n² = 1/ρ``; the flat correction
51
+ subtracts ``σ_n²`` (exact for QPSK, *under*-corrects QAM), while passing
52
+ ``ref_symbols`` applies the amplitude-aware ``σ_n²·E[1/|d|²]`` (rigorous
53
+ for QAM, since inner-ring symbols carry larger angle noise).
54
+
55
+ Note: ``method="subtract"`` needs the additive-noise variance only.
56
+ ``metrics.snr`` reports total residual (noise + phase noise + ISI) and
57
+ over-subtracts. Prefer ``method="slope"``.
58
+
59
+ Parameters
60
+ ----------
61
+ pn_phase : array_like
62
+ Phase-noise residual (radians) - the detrended phase - ``(N,)`` or
63
+ ``(C, N)``. (Use the drift-removed ``pn`` so a residual frequency ramp
64
+ does not add a spurious ``k²`` term to the slope fit.)
65
+ symbol_rate : float
66
+ Symbol rate in Baud.
67
+ method : {"slope", "subtract"}, default "slope"
68
+ Estimator, as above.
69
+ lags : tuple of int, default (1, 2, 3, 4, 5)
70
+ Increment lags ``k`` for the slope fit (``method="slope"``).
71
+ noise_var, snr_db, ref_symbols : optional
72
+ AWGN-correction inputs for ``method="subtract"`` (see above).
73
+ edge_trim : int, default 0
74
+ Samples discarded from each end before differencing.
75
+ debug_plot : bool, default False
76
+ If True, plot ``Var(Δφ_k)`` vs lag with the fitted line
77
+ (``increment_variance``); points only for ``method="subtract"``.
78
+
79
+ Returns
80
+ -------
81
+ dict
82
+ ``{'linewidth', 'dphi_var', 'awgn_var', 'method'}`` - linewidth /
83
+ variances are floats (SISO) or per-channel arrays. ``dphi_var`` is the
84
+ lag-1 increment variance; ``awgn_var`` is the fitted intercept
85
+ (``slope``) or the subtracted AWGN term (``subtract``).
86
+
87
+ Notes
88
+ -----
89
+ **Limitations.**
90
+
91
+ * The linearity of ``Var(Δφ_k)`` in ``k`` holds for **white-FM (Wiener)**
92
+ phase noise only. Flicker (1/f) FM noise makes the variance grow
93
+ *faster* than linear, biasing the fitted slope - and hence Δν - high;
94
+ what is reported is then an *effective* linewidth at the lag timescale,
95
+ not the intrinsic Lorentzian linewidth.
96
+ * ``xp.var`` subtracts the per-lag mean, so a **constant** residual
97
+ frequency offset does not bias the fit, but a frequency *ramp*
98
+ (nonlinear drift) adds a ``k²`` term. Detrend first
99
+ (``separate_drift_phase_noise``) and keep the largest lag ``k·T_sym``
100
+ well inside the drift timescale.
101
+ * Larger lags raise the phase-noise term above the AWGN intercept but
102
+ admit more drift/flicker contamination; the default 1-5 symbol lags
103
+ suit multi-MHz linewidths at GBaud rates. For sub-100-kHz linewidths at
104
+ high symbol rates the per-lag walk variance may sit orders of magnitude
105
+ below ``2σ_φ²`` - prefer the β-separation/PSD route there.
106
+ """
107
+ p, xp, _ = dispatch(pn_phase)
108
+ p2, was_1d = _as_2d(p)
109
+ n_full = p2.shape[-1]
110
+ sl = slice(edge_trim, n_full - edge_trim) if edge_trim > 0 else slice(None)
111
+ p2 = p2[:, sl].astype(xp.float64)
112
+ c = p2.shape[0]
113
+ t_sym = 1.0 / float(symbol_rate)
114
+
115
+ def _var_lag(k):
116
+ dk = p2[:, k:] - p2[:, :-k]
117
+ return xp.var(dk, axis=-1)
118
+
119
+ var1 = _var_lag(1)
120
+
121
+ if method == "slope":
122
+ ks = np.asarray(sorted(set(int(k) for k in lags if k >= 1)), dtype=np.float64)
123
+ if ks.size < 2:
124
+ raise ValueError("method='slope' needs at least two distinct lags ≥ 1.")
125
+ var_k = xp.stack([_var_lag(int(k)) for k in ks], axis=0) # (n_lag, C)
126
+ # The fit input is a tiny (n_lag, C) matrix - one D2H transfer and a
127
+ # host-side polyfit beat launching a device least-squares here.
128
+ var_k_cpu = np.asarray(to_device(var_k, "cpu"), dtype=np.float64)
129
+ coeffs = np.polyfit(ks, var_k_cpu, 1) # (2, C): [slope, intercept]
130
+ slope, intercept = coeffs[0], coeffs[1]
131
+ linewidth_cpu = np.maximum(slope, 0.0) / (2.0 * np.pi * t_sym)
132
+ awgn_var_cpu = intercept
133
+ elif method == "subtract":
134
+ if noise_var is not None:
135
+ sigma_n2 = xp.full(c, float(noise_var), dtype=xp.float64)
136
+ elif snr_db is not None:
137
+ snr_val = xp.atleast_1d(xp.asarray(snr_db, dtype=xp.float64))
138
+ sigma_n2 = 10.0 ** (-snr_val / 10.0)
139
+ if sigma_n2.size == 1:
140
+ sigma_n2 = xp.full(c, float(sigma_n2[0]))
141
+ else:
142
+ sigma_n2 = xp.zeros(c, dtype=xp.float64)
143
+
144
+ if ref_symbols is not None and xp.any(sigma_n2):
145
+ from ..helpers import normalize
146
+
147
+ d2, _ = _as_2d(xp.asarray(ref_symbols))
148
+ d2 = d2[:, :n_full][:, sl]
149
+ d2 = normalize(d2, mode="average_power", axis=-1)
150
+ inv = 1.0 / xp.maximum(xp.abs(d2) ** 2, 1e-12)
151
+ pair_mean = 0.5 * (inv[:, 1:] + inv[:, :-1])
152
+ e_inv = xp.mean(pair_mean, axis=-1)
153
+ awgn_var = sigma_n2 * e_inv
154
+ else:
155
+ awgn_var = sigma_n2.copy()
156
+ linewidth = xp.maximum(var1 - awgn_var, 0.0) / (2.0 * np.pi * t_sym)
157
+
158
+ linewidth_cpu = to_device(linewidth, "cpu")
159
+ awgn_var_cpu = to_device(awgn_var, "cpu")
160
+ else:
161
+ raise ValueError(f"Unknown method {method!r} (use 'slope' or 'subtract').")
162
+
163
+ var1_cpu = to_device(var1, "cpu")
164
+
165
+ if debug_plot:
166
+ from .. import plotting as _plotting
167
+
168
+ if method == "slope":
169
+ _plotting.plot_increment_variance(
170
+ ks * t_sym,
171
+ var_k_cpu.T,
172
+ slope=coeffs[0] / t_sym,
173
+ intercept=coeffs[1],
174
+ show=True,
175
+ )
176
+ else:
177
+ _plotting.plot_increment_variance(
178
+ np.array([t_sym]), np.atleast_1d(var1_cpu)[:, None], show=True
179
+ )
180
+
181
+ return {
182
+ "linewidth": _scalar_or_array(linewidth_cpu),
183
+ "dphi_var": _scalar_or_array(var1_cpu),
184
+ "awgn_var": _scalar_or_array(awgn_var_cpu),
185
+ "method": method,
186
+ }
187
+
188
+
189
+ def fm_noise_psd(
190
+ phi: ArrayType,
191
+ symbol_rate: float,
192
+ *,
193
+ nperseg: int | None = None,
194
+ detrend: str | bool = "constant",
195
+ bias_correction: bool = True,
196
+ debug_plot: bool = False,
197
+ ) -> tuple[ArrayType, ArrayType]:
198
+ r"""One-sided frequency-noise PSD S_f(f) [Hz²/Hz] from the phase.
199
+
200
+ Differentiates the phase to the instantaneous frequency
201
+ ``f_inst = diff(phi)/(2π·T_sym)`` (Hz) and estimates its one-sided PSD via
202
+ Welch's method (``welch_psd``). Distinct
203
+ impairments occupy distinct regions of S_f(f):
204
+
205
+ * **white-FM** (linewidth): flat plateau at ``S_f = Δν/π``,
206
+ * **drift / flicker**: steep ``1/f`` (and steeper) rise at low ``f``,
207
+ * **AWGN** angle noise: white phase noise -> ``S_f ∝ f²`` rise at high ``f``.
208
+
209
+ Parameters
210
+ ----------
211
+ phi : array_like
212
+ Unwrapped carrier phase (radians), ``(N,)`` or ``(C, N)``.
213
+ symbol_rate : float
214
+ Symbol rate in Baud (sampling rate of ``phi``).
215
+ nperseg : int, optional
216
+ Welch segment length. Defaults to ``min(N//8, 4096)`` (clipped ≥ 256).
217
+ detrend : str or bool, default "constant"
218
+ Per-segment detrend passed to Welch; ``"constant"`` removes the mean
219
+ residual frequency offset.
220
+ bias_correction : bool, default True
221
+ Undo the first-difference roll-off (see Notes).
222
+ debug_plot : bool, default False
223
+ If True, plot the PSD (``frequency_noise_psd``).
224
+
225
+ Returns
226
+ -------
227
+ f : array_like
228
+ One-sided frequency axis in Hz (length ``nperseg//2 + 1``).
229
+ S_f : array_like
230
+ Frequency-noise PSD in Hz²/Hz, shape ``(nfreq,)`` or ``(C, nfreq)``.
231
+ Both stay on the input backend (compute layer - no host transfer;
232
+ the ``linewidth_*`` summary functions are the reporting layer).
233
+
234
+ Notes
235
+ -----
236
+ The first difference is not an ideal differentiator: its magnitude
237
+ response is ``|2 sin(πfT)|`` versus the ideal ``2πfT``, so the raw
238
+ estimate is ``S_f,true(f) · sinc²(fT)`` - a -3.9 dB droop at Nyquist
239
+ (``R/2``). With ``bias_correction=True`` the PSD is divided by
240
+ ``sinc²(fT)`` so the white-FM plateau and the AWGN ``f²`` tail keep their
241
+ analytic levels all the way to Nyquist.
242
+
243
+ **Limitations.**
244
+
245
+ * Frequency resolution is ``R/nperseg``; noise processes slower than the
246
+ segment length (drift, flicker below the first bin) alias into the
247
+ lowest bins and are *not* resolved - extend the capture, not
248
+ ``nperseg``, to see them.
249
+ * Welch averaging trades variance for resolution: with ``K`` segments the
250
+ per-bin relative std is ``≈ 1/√K``. The default ``N//8`` with 50 %
251
+ overlap gives ``K ≈ 15``.
252
+ * ``detrend="constant"`` removes the *mean* frequency per segment; a
253
+ residual frequency ramp within a segment still leaks into the lowest
254
+ bins.
255
+ """
256
+ p, xp, _ = dispatch(phi)
257
+ p2, was_1d = _as_2d(p)
258
+ t_sym = 1.0 / float(symbol_rate)
259
+
260
+ f_inst = xp.diff(p2.astype(xp.float64), axis=-1) / (2.0 * np.pi * t_sym)
261
+ n = f_inst.shape[-1]
262
+ if nperseg is None:
263
+ nperseg = int(min(max(n // 8, 256), 4096))
264
+ nperseg = min(nperseg, n)
265
+
266
+ f, S_f = welch_psd(
267
+ f_inst,
268
+ sampling_rate=float(symbol_rate),
269
+ nperseg=nperseg,
270
+ detrend=detrend,
271
+ return_onesided=True,
272
+ axis=-1,
273
+ )
274
+ if bias_correction:
275
+ # S_f,est = S_f,true · sinc²(fT); undo the diff-differentiator droop.
276
+ S_f = S_f / (xp.sinc(f * t_sym) ** 2)
277
+ S_out = S_f[0] if was_1d else S_f
278
+
279
+ if debug_plot:
280
+ from .. import plotting as _plotting
281
+
282
+ _plotting.plot_frequency_noise_psd(f, S_out, show=True)
283
+
284
+ return f, S_out
285
+
286
+
287
+ def linewidth_beta_separation(
288
+ phi: ArrayType,
289
+ symbol_rate: float,
290
+ *,
291
+ nperseg: int | None = None,
292
+ f_min: float | None = None,
293
+ f_max: float | None = None,
294
+ debug_plot: bool = False,
295
+ ) -> dict[str, float | np.ndarray]:
296
+ r"""Linewidth via the Di Domenico β-separation line (canonical method).
297
+
298
+ Integrates the frequency-noise PSD S_f(f) only over the **region where it
299
+ lies above** the beta-separation line S_f = (8 * ln(2) / pi^2) * f - the
300
+ Heaviside-gated "surface" of the Di Domenico method, in general a union
301
+ of disjoint intervals rather than a contiguous band (the returned
302
+ ``above`` mask is the exact region used). The FWHM linewidth is
303
+ linewidth = sqrt(8 * ln(2) * A) with A the integrated area (Hz²). The
304
+ ``[f_min, f_max]`` window is only an outer *fence* on that region: it
305
+ excludes the unresolved DC bin and - with an appropriate f_max - the
306
+ high-frequency AWGN f^2 tail (which eventually climbs back above the
307
+ line and would otherwise be integrated as fake linewidth).
308
+
309
+ A white-FM-floor cross-check ``linewidth_floor = π · median(S_f)`` is
310
+ also returned. When **no fence is given**, the floor's median band is
311
+ auto-detected as the PSD's minimum-level (plateau) region - octave-band-
312
+ median floor, all bins within 3x of it - so a low-frequency drift/flicker
313
+ rise and the AWGN ``f²`` tail are excluded without manual fencing.
314
+ Explicit ``f_min``/``f_max`` switch the floor back to a literal-band
315
+ median (the β-area integral always uses the literal fence). The floor is
316
+ corrected for the χ²-median bias of Welch bins - a raw median reads
317
+ ``≈ 1 - 1/(3K)`` below the true level for ``K`` averaged segments (the
318
+ β-area integral is mean-based and needs no such correction).
319
+
320
+ Parameters
321
+ ----------
322
+ phi : array_like
323
+ Unwrapped carrier phase (radians), ``(N,)`` or ``(C, N)``.
324
+ symbol_rate : float
325
+ Symbol rate in Baud.
326
+ nperseg : int, optional
327
+ Welch segment length (see ``fm_noise_psd``).
328
+ f_min : float, optional
329
+ Lower fence of the analysis window in Hz (drops the residual-FOE DC
330
+ region). Defaults to the first non-zero Welch bin
331
+ (``symbol_rate/nperseg``). Note this is a *resolution* floor, not the
332
+ canonical ``1/T_obs`` of the method: FM-noise area between ``1/T_obs``
333
+ and the first Welch bin is unresolved and silently excluded, so for
334
+ drift/flicker-dominated sources the result depends on ``nperseg`` -
335
+ raise ``nperseg`` (or quote ``f_min``) accordingly.
336
+ f_max : float, optional
337
+ Upper fence of the analysis window in Hz. **Set this below the AWGN
338
+ ``f²`` knee** - the tail crosses back above the β-line and would be
339
+ integrated as fake linewidth; defaults to the Nyquist bin.
340
+ debug_plot : bool, default False
341
+ If True, plot the PSD with the β-line, white-FM floor, and the actual
342
+ integration region shaded (``plot_frequency_noise_psd``).
343
+
344
+ Returns
345
+ -------
346
+ dict
347
+ ``{'linewidth', 'linewidth_floor', 'area_hz2', 'f', 'S_f',
348
+ 'beta_line', 'above', 'used', 'band', 'n_segments'}`` - linewidths
349
+ are floats (SISO) / arrays (MIMO); ``f``/``S_f``/``beta_line`` are
350
+ NumPy arrays for plotting. ``above`` is the boolean mask of the bins
351
+ actually integrated (``S_f > β``-line within ``band``, generally a
352
+ **union of disjoint intervals**, not a contiguous band); ``used`` is
353
+ the mask of bins the floor median ran over (auto-detected plateau, or
354
+ the fenced band); ``band`` is the ``(f_min, f_max)`` fence applied;
355
+ ``n_segments`` is the Welch segment count ``K`` behind every bin.
356
+
357
+ Notes
358
+ -----
359
+ **Limitations.**
360
+
361
+ * The β-separation FWHM is an *approximation* (accurate to ~10 % for
362
+ lineshapes dominated by slow FM noise, and exact for pure white FM -
363
+ the line is constructed so a flat ``S_f = Δν/π`` integrates back to
364
+ ``Δν``). It is not a substitute for a full lineshape integral when the
365
+ noise sits near the β-line over a wide band.
366
+ * The result is **observation-time dependent** for flicker/drift-dominated
367
+ sources: lowering ``f_min`` (longer capture) adds low-frequency area and
368
+ grows ``Δν``. Always quote ``f_min`` (equivalently the measurement
369
+ time) with the number - there is no unique "linewidth" of a non-white
370
+ FM source.
371
+ * The AWGN ``f²`` tail eventually crosses back above the β-line and would
372
+ be integrated as *fake* linewidth: set ``f_max`` below the knee where
373
+ the plateau ``Δν/π`` meets the tail ``2σ_φ²T_sym·f²``, i.e.
374
+ ``f_knee = (Δν/(2π σ_φ² T_sym))^{1/2}``. Check ``debug_plot=True``.
375
+ * ``linewidth_floor`` (π·median of in-band ``S_f``) is the more robust
376
+ estimate when a clean white-FM plateau exists in the band; the two
377
+ should agree within tens of percent, otherwise inspect the PSD.
378
+ """
379
+ f, S_f = fm_noise_psd(phi, symbol_rate, nperseg=nperseg)
380
+ _, xp, _ = dispatch(f)
381
+ S2 = S_f[None, :] if S_f.ndim == 1 else S_f
382
+
383
+ beta = _BETA_SLOPE * f
384
+ # One-sided Welch axis: f[0] = 0, f[1] is the first non-zero bin.
385
+ fmin = float(f[1]) if f_min is None else float(f_min)
386
+ fmax = float(f[-1]) if f_max is None else float(f_max)
387
+ band = (f >= fmin) & (f <= fmax)
388
+
389
+ # Vectorized over channels: (C, nfreq) masks instead of a per-channel loop.
390
+ above = band[None, :] & (S2 > beta[None, :])
391
+ integrand = xp.where(above, S2, 0.0)
392
+ area = xp.trapezoid(integrand, f, axis=-1)
393
+ lw = xp.sqrt(_FWHM_FROM_AREA * area)
394
+
395
+ # Pack the two (C,) metrics into one D2H transfer; the floor median runs
396
+ # host-side so the plateau auto-detection is shared with linewidth_dsh.
397
+ lw_cpu, area_cpu = to_device(xp.stack([lw, area]), "cpu")
398
+ f_cpu = np.asarray(to_device(f, "cpu"), dtype=np.float64)
399
+ S_cpu = np.asarray(to_device(S_f, "cpu"), dtype=np.float64)
400
+ beta_cpu = np.asarray(to_device(beta, "cpu"), dtype=np.float64)
401
+ above_cpu = np.asarray(to_device(above, "cpu"), dtype=bool)
402
+
403
+ S2c = S_cpu[None, :] if S_cpu.ndim == 1 else S_cpu
404
+ n_ch = S2c.shape[0]
405
+ band_cpu = (f_cpu >= fmin) & (f_cpu <= fmax)
406
+ base2 = np.zeros(S2c.shape, dtype=bool)
407
+ base2[:] = band_cpu & (f_cpu > 0)
408
+ base2 &= np.isfinite(S2c)
409
+
410
+ # Welch bins are χ²-distributed: the median-based floor reads the true
411
+ # level low by median(χ²_ν)/ν ≈ 1 - 1/(3K); divided back out below.
412
+ nps_used = int(round(float(symbol_rate) / float(f_cpu[1])))
413
+ m_med, k_seg = _welch_median_bias(phi.shape[-1] - 1, nps_used)
414
+ if k_seg < 4:
415
+ logger.warning(
416
+ "linewidth_beta_separation: only %d Welch segment(s) at "
417
+ "nperseg=%d - the floor median is noisy and the χ²-median "
418
+ "correction (÷%.3f) is asymptotic; extend the record (aim for "
419
+ "≥ 5·nperseg samples).",
420
+ k_seg,
421
+ nps_used,
422
+ m_med,
423
+ )
424
+
425
+ if f_min is None and f_max is None:
426
+ used2, levels = _plateau_mask(f_cpu, S2c, base2)
427
+ lw_floor_cpu = np.pi * levels
428
+ for c in range(n_ch):
429
+ if not np.isfinite(lw_floor_cpu[c]):
430
+ logger.warning(
431
+ "linewidth_beta_separation: no plateau detected "
432
+ "(channel %d) - floor median falls back to the full "
433
+ "band. Inspect the PSD before quoting it.",
434
+ c,
435
+ )
436
+ used2[c] = base2[c]
437
+ lw_floor_cpu[c] = (
438
+ np.pi * np.median(S2c[c][used2[c]]) if used2[c].any() else 0.0
439
+ )
440
+ else:
441
+ used2 = base2
442
+ lw_floor_cpu = np.array(
443
+ [
444
+ np.pi * np.median(S2c[c][used2[c]]) if used2[c].any() else 0.0
445
+ for c in range(n_ch)
446
+ ]
447
+ )
448
+ lw_floor_cpu = lw_floor_cpu / m_med
449
+ used_cpu = used2[0] if S_cpu.ndim == 1 else used2
450
+ if S_cpu.ndim == 1:
451
+ above_cpu = above_cpu[0]
452
+
453
+ result = {
454
+ "linewidth": _scalar_or_array(lw_cpu),
455
+ "linewidth_floor": _scalar_or_array(lw_floor_cpu),
456
+ "n_segments": k_seg,
457
+ "area_hz2": _scalar_or_array(area_cpu),
458
+ "f": f_cpu,
459
+ "S_f": S_cpu,
460
+ "beta_line": beta_cpu,
461
+ "above": above_cpu,
462
+ "used": used_cpu,
463
+ "band": (fmin, fmax),
464
+ }
465
+
466
+ if debug_plot:
467
+ from .. import plotting as _plotting
468
+
469
+ _plotting.plot_frequency_noise_psd(
470
+ f_cpu,
471
+ S_cpu,
472
+ beta_line=beta_cpu,
473
+ floor=result["linewidth_floor"],
474
+ band=(fmin, fmax),
475
+ above=above_cpu,
476
+ used=used_cpu,
477
+ show=True,
478
+ )
479
+
480
+ return result
@@ -0,0 +1,91 @@
1
+ """Data-aided carrier-phase trajectory extraction.
2
+
3
+ The foundational extractor of the carrier-phase analysis chain: forms the
4
+ data-aided unwrapped phase that every downstream estimator (drift, linewidth,
5
+ Allan deviation) consumes.
6
+ """
7
+
8
+ from ..backend import ArrayType, dispatch
9
+ from ..logger import logger
10
+ from ._common import _as_2d, _pairing_variance
11
+
12
+ __all__ = ["carrier_phase_trajectory"]
13
+
14
+
15
+ def carrier_phase_trajectory(
16
+ y_eq: ArrayType,
17
+ ref_symbols: ArrayType,
18
+ *,
19
+ channel_pairing: str = "auto",
20
+ ) -> ArrayType:
21
+ r"""Data-aided unwrapped carrier phase from frozen-tap output + known symbols.
22
+
23
+ Forms ``angle(y · conj(d))`` (which cancels the data modulation - for QAM
24
+ ``|d|²`` is real-positive so the carrier angle is preserved) and unwraps it
25
+ in ``float64``. Because the symbols are *known*, the result carries only
26
+ carrier phase + AWGN angle noise and **never cycle-slips** - unlike a
27
+ blind/feed-forward estimate which would add its own estimator noise and
28
+ slips that corrupt a linewidth estimate. A constant offset or
29
+ +/- pi/2 ambiguity is irrelevant; only the time variation is used.
30
+
31
+ Parameters
32
+ ----------
33
+ y_eq : array_like
34
+ Equalized symbols at 1 sps (e.g. ``apply_taps``
35
+ output with the CPR **disabled** so the carrier phase is left intact).
36
+ Shape ``(N,)`` (SISO) or ``(C, N)`` (MIMO, time on last axis).
37
+ ref_symbols : array_like
38
+ Known transmitted symbols, same layout as ``y_eq``. The two are
39
+ truncated to their common length on the last axis.
40
+ channel_pairing : {"auto", "identity", "swap"}, default "auto"
41
+ For dual-pol (``C == 2``) inputs the equalizer may map pol 0<->1.
42
+ ``"auto"`` picks the pairing (identity vs swapped ``ref``) with the
43
+ lower total phase-error variance; ``"identity"`` / ``"swap"`` force it.
44
+ Ignored for SISO or ``C != 2``.
45
+
46
+ Returns
47
+ -------
48
+ array_like
49
+ Unwrapped carrier phase in radians (``float64``), shape matching the
50
+ truncated input, on the same backend as ``y_eq``.
51
+
52
+ Notes
53
+ -----
54
+ **Limitations.**
55
+
56
+ * ``y_eq`` and ``ref_symbols`` must be *symbol-aligned* (same start, same
57
+ ordering). A misalignment does not fail loudly - it turns the product
58
+ ``y·conj(d)`` into noise-like phase and inflates every downstream
59
+ linewidth estimate. ``channel_pairing="auto"`` only resolves the 0<->1
60
+ permutation, not a time shift.
61
+ * The per-symbol phase *step* must stay below π for ``unwrap`` to be
62
+ exact: ``|2πΔf·T_sym + Δφ_pn + Δφ_awgn| < π``. In practice this bounds
63
+ the residual frequency offset to ``|Δf| < R/2`` per symbol and requires
64
+ moderate SNR (≳ 5 dB); beyond that the trajectory itself slips.
65
+ * Residual equalizer ISI appears as extra white angle noise. It is
66
+ indistinguishable from AWGN here, which is why the downstream
67
+ ``linewidth_increment(method="slope")`` fits it into the intercept
68
+ instead of requiring an explicit noise estimate.
69
+ """
70
+ y, xp, _ = dispatch(y_eq)
71
+ d = xp.asarray(ref_symbols)
72
+
73
+ y2, was_1d = _as_2d(y)
74
+ d2, _ = _as_2d(d)
75
+
76
+ n = min(y2.shape[-1], d2.shape[-1])
77
+ y2, d2 = y2[:, :n], d2[:, :n]
78
+ c = y2.shape[0]
79
+
80
+ if c == 2 and channel_pairing == "auto":
81
+ var_id = _pairing_variance(y2, d2, xp)
82
+ var_sw = _pairing_variance(y2, d2[::-1], xp)
83
+ # Single host sync for the pairing decision (0-d device comparands).
84
+ if bool(var_sw < var_id):
85
+ d2 = d2[::-1]
86
+ logger.info("carrier_phase_trajectory: swapped pol pairing (lower var).")
87
+ elif c == 2 and channel_pairing == "swap":
88
+ d2 = d2[::-1]
89
+
90
+ phi = xp.unwrap(xp.angle(y2 * xp.conj(d2)).astype(xp.float64), axis=-1)
91
+ return phi[0] if was_1d else phi