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,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
|