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,482 @@
1
+ """Decision-directed PLL carrier phase recovery."""
2
+
3
+ import logging
4
+
5
+ import numpy as np
6
+
7
+ from ..backend import ArrayType, dispatch, to_device
8
+ from ..logger import logger
9
+ from .corrections import correct_cycle_slips
10
+
11
+ _NUMBA_PLL: dict = {}
12
+
13
+
14
+ def _get_numba_dd_pll():
15
+ """JIT-compile and cache the Numba DD-PLL sample-wise loop kernel.
16
+
17
+ Returns
18
+ -------
19
+ callable
20
+ Numba-compiled ``_dd_pll_loop``.
21
+ """
22
+ if "dd_pll" not in _NUMBA_PLL:
23
+ import numba
24
+
25
+ @numba.njit(cache=True, fastmath=True, nogil=True)
26
+ def _dd_pll_loop(
27
+ sym_r,
28
+ sym_i,
29
+ const_r,
30
+ const_i,
31
+ mu,
32
+ beta,
33
+ phi0,
34
+ freq0,
35
+ is_sq_qam,
36
+ levels,
37
+ d_grid,
38
+ lev_min,
39
+ side,
40
+ ):
41
+ """Inner DD-PLL loop compiled to machine code by Numba.
42
+
43
+ Parameters
44
+ ----------
45
+ sym_r, sym_i : (N,) float64
46
+ Real and imaginary parts of received symbols.
47
+ const_r, const_i : (M,) float64
48
+ Real and imaginary parts of reference constellation.
49
+ mu : float64
50
+ Proportional (phase) gain - corrects the instantaneous phase error.
51
+ beta : float64
52
+ Integral (frequency) gain - tracks residual frequency drift.
53
+ Set to 0.0 for a 1st-order loop.
54
+ phi0 : float64
55
+ Initial phase state in radians.
56
+ freq0 : float64
57
+ Initial frequency correction state in radians/symbol.
58
+ is_sq_qam : bool
59
+ True when the constellation is a square QAM grid. Enables the
60
+ O(1) rounding decision path instead of the O(M) linear search.
61
+ levels : (side,) float64
62
+ Sorted unique axis levels for square QAM (ignored when not sq_qam).
63
+ d_grid : float64
64
+ Grid spacing (levels[1] - levels[0]).
65
+ lev_min : float64
66
+ Minimum level value (levels[0]).
67
+ side : int
68
+ Number of points per axis (sqrt of constellation order).
69
+
70
+ Returns
71
+ -------
72
+ phase_est : (N,) float64
73
+ Per-symbol phase trajectory φ[n].
74
+ """
75
+ N = len(sym_r)
76
+ M = len(const_r)
77
+ phase_est = np.empty(N, dtype=np.float64)
78
+ phi = phi0
79
+ freq = freq0
80
+
81
+ for n in range(N):
82
+ # Rotate received symbol by current phase estimate:
83
+ # y[n] = s[n] · exp(-jφ[n])
84
+ cos_phi = np.cos(phi)
85
+ sin_phi = np.sin(phi)
86
+ yr = sym_r[n] * cos_phi + sym_i[n] * sin_phi
87
+ yi = -sym_r[n] * sin_phi + sym_i[n] * cos_phi
88
+
89
+ # Hard decision: argmin_{c ∈ C} |y - c|²
90
+ if is_sq_qam:
91
+ # O(1) grid rounding for square QAM
92
+ r_idx = int(round((yr - lev_min) / d_grid))
93
+ if r_idx < 0:
94
+ r_idx = 0
95
+ elif r_idx >= side:
96
+ r_idx = side - 1
97
+ d_r = levels[r_idx]
98
+ i_idx = int(round((yi - lev_min) / d_grid))
99
+ if i_idx < 0:
100
+ i_idx = 0
101
+ elif i_idx >= side:
102
+ i_idx = side - 1
103
+ d_i = levels[i_idx]
104
+ else:
105
+ min_d2 = (yr - const_r[0]) ** 2 + (yi - const_i[0]) ** 2
106
+ d_r = const_r[0]
107
+ d_i = const_i[0]
108
+ for k in range(1, M):
109
+ d2 = (yr - const_r[k]) ** 2 + (yi - const_i[k]) ** 2
110
+ if d2 < min_d2:
111
+ min_d2 = d2
112
+ d_r = const_r[k]
113
+ d_i = const_i[k]
114
+
115
+ # Cross-product phase error: e = Im(y · d*) = yi·d_r - yr·d_i
116
+ e = yi * d_r - yr * d_i
117
+
118
+ # Record the phase used to derotate symbol n - before the update.
119
+ phase_est[n] = phi
120
+
121
+ # 2nd-order loop filter (reduces to 1st order when beta=0):
122
+ # φ[n+1] = φ[n] + μ·e[n] + ν[n]
123
+ # ν[n] = ν[n-1] + β·e[n]
124
+ phi = phi + mu * e + freq
125
+ freq = freq + beta * e
126
+
127
+ return phase_est
128
+
129
+ _NUMBA_PLL["dd_pll"] = _dd_pll_loop
130
+
131
+ return _NUMBA_PLL["dd_pll"]
132
+
133
+
134
+ def _get_numba_dd_pll_joint():
135
+ """JIT-compile and cache the joint-channel DD-PLL PI kernel.
136
+
137
+ Averages the cross-product phase error across C channels at each symbol
138
+ before updating the single shared phase/frequency state. This is the
139
+ MVUE joint estimator for shared-LO systems.
140
+
141
+ Returns
142
+ -------
143
+ callable
144
+ Numba-compiled ``_dd_pll_joint_loop``.
145
+ """
146
+ if "dd_pll_joint" not in _NUMBA_PLL:
147
+ import numba
148
+
149
+ @numba.njit(cache=True, fastmath=True, nogil=True)
150
+ def _dd_pll_joint_loop(
151
+ sym_r,
152
+ sym_i,
153
+ const_r,
154
+ const_i,
155
+ mu,
156
+ beta,
157
+ phi0,
158
+ freq0,
159
+ is_sq_qam,
160
+ levels,
161
+ d_grid,
162
+ lev_min,
163
+ side,
164
+ ):
165
+ """Joint-channel DD-PLL with PI loop filter.
166
+
167
+ Parameters
168
+ ----------
169
+ sym_r, sym_i : (C, N) float64
170
+ Real and imaginary parts of received symbols, all channels.
171
+ const_r, const_i : (M,) float64
172
+ Reference constellation.
173
+ mu, beta, phi0, freq0 : float64
174
+ Loop parameters - same semantics as ``_dd_pll_loop``.
175
+ is_sq_qam : bool
176
+ Enables O(1) rounding decision for square QAM grids.
177
+ levels : (side,) float64
178
+ d_grid, lev_min : float64
179
+ side : int
180
+
181
+ Returns
182
+ -------
183
+ phase_est : (N,) float64
184
+ Single shared phase trajectory (broadcast to all channels by caller).
185
+ """
186
+ C = sym_r.shape[0]
187
+ N = sym_r.shape[1]
188
+ M = len(const_r)
189
+ phase_est = np.empty(N, dtype=np.float64)
190
+ phi = phi0
191
+ freq = freq0
192
+
193
+ for n in range(N):
194
+ cos_phi = np.cos(phi)
195
+ sin_phi = np.sin(phi)
196
+ e_sum = 0.0
197
+ for c in range(C):
198
+ yr = sym_r[c, n] * cos_phi + sym_i[c, n] * sin_phi
199
+ yi = -sym_r[c, n] * sin_phi + sym_i[c, n] * cos_phi
200
+ if is_sq_qam:
201
+ r_idx = int(round((yr - lev_min) / d_grid))
202
+ if r_idx < 0:
203
+ r_idx = 0
204
+ elif r_idx >= side:
205
+ r_idx = side - 1
206
+ d_r = levels[r_idx]
207
+ i_idx = int(round((yi - lev_min) / d_grid))
208
+ if i_idx < 0:
209
+ i_idx = 0
210
+ elif i_idx >= side:
211
+ i_idx = side - 1
212
+ d_i = levels[i_idx]
213
+ else:
214
+ min_d2 = (yr - const_r[0]) ** 2 + (yi - const_i[0]) ** 2
215
+ d_r = const_r[0]
216
+ d_i = const_i[0]
217
+ for k in range(1, M):
218
+ d2 = (yr - const_r[k]) ** 2 + (yi - const_i[k]) ** 2
219
+ if d2 < min_d2:
220
+ min_d2 = d2
221
+ d_r = const_r[k]
222
+ d_i = const_i[k]
223
+ e_sum += yi * d_r - yr * d_i
224
+ # Average error across channels - MVUE for shared LO
225
+ e = e_sum / float(C)
226
+ phase_est[n] = phi
227
+ phi = phi + mu * e + freq
228
+ freq = freq + beta * e
229
+
230
+ return phase_est
231
+
232
+ _NUMBA_PLL["dd_pll_joint"] = _dd_pll_joint_loop
233
+
234
+ return _NUMBA_PLL["dd_pll_joint"]
235
+
236
+
237
+ def recover_carrier_phase_pll(
238
+ symbols: ArrayType,
239
+ modulation: str,
240
+ order: int,
241
+ mu: float | None = 1e-2,
242
+ beta: float | None = None,
243
+ phase_init: float = 0.0,
244
+ loop_bandwidth_normalized: float = 1e-3,
245
+ joint_channels: bool = False,
246
+ cycle_slip_correction: bool = False,
247
+ cycle_slip_history: int = 100,
248
+ cycle_slip_threshold: float = np.pi / 4,
249
+ debug_plot: bool = False,
250
+ ) -> ArrayType:
251
+ r"""
252
+ Carrier phase recovery via a Decision-Directed Phase-Locked Loop (DD-PLL).
253
+
254
+ Tracks the carrier phase symbol-by-symbol using hard decisions as phase
255
+ references. A 1st-order loop (``beta=0``) corrects static or slowly
256
+ varying phase noise; a 2nd-order loop (``beta > 0``) additionally tracks
257
+ a residual frequency offset left over after coarse FOE.
258
+
259
+ This is the standard streaming CPR for hardware implementations: it is
260
+ modulation-format agnostic (works for any QAM/PSK order) and converges
261
+ much faster than block-based methods (VV, BPS) after equalizer pull-in.
262
+
263
+ Note: the DD-PLL requires reliable decisions at the input. For a cold
264
+ start the first ~1/mu symbols may show slow convergence; a common strategy
265
+ is to pre-converge with BPS or a short preamble and pass the phase as
266
+ ``phase_init``.
267
+
268
+ Parameters
269
+ ----------
270
+ symbols : array_like
271
+ 1-SPS complex symbols after matched filtering and FOE.
272
+ Shape: ``(N,)`` or ``(C, N)``.
273
+ modulation : str
274
+ Modulation scheme (case-insensitive): ``'qam'``, ``'psk'``, etc.
275
+ Used to fetch the reference constellation via
276
+ ``gray_constellation``.
277
+ order : int
278
+ Modulation order (4, 16, 64, ...).
279
+ mu : float or None, default 1e-2
280
+ Proportional gain - controls convergence speed and steady-state
281
+ jitter. Larger ``mu`` converges faster but amplifies noise.
282
+ Typical range: ``1e-3`` (high-SNR, high-order QAM) to ``5e-2``
283
+ (QPSK, low latency). Set ``mu=None`` to opt into the
284
+ ``loop_bandwidth_normalized`` shortcut instead (see below). These
285
+ gains are interchangeable with the inline equalizer PLL's
286
+ ``cpr_pll_mu``/``cpr_pll_beta`` (``lms``/``rls``, ``cpr_type='pll'``).
287
+ beta : float or None, default None
288
+ Integral gain - enables 2nd-order frequency tracking. ``None`` (or
289
+ ``0.0``) gives a 1st-order loop; set ``beta > 0`` when a residual
290
+ frequency offset remains after FOE (e.g. ``beta ≈ mu² / 4``).
291
+ Requires ``mu`` to be set (passing ``beta`` with ``mu=None`` raises
292
+ ``ValueError``).
293
+ phase_init : float, default 0.0
294
+ Initial phase state in radians. Use the last sample of a
295
+ preceding BPS or pilot-aided estimate to warm-start the loop.
296
+ loop_bandwidth_normalized : float, default 1e-3
297
+ Critically-damped (ζ=1) loop bandwidth shortcut, used only when
298
+ ``mu is None``. Normalized one-sided bandwidth in ``(0, 0.5)``;
299
+ gains are derived as mu = 4*B_L, beta = 4*B_L^2.
300
+ joint_channels : bool, default False
301
+ For MIMO inputs (C > 1): if ``True``, average the cross-product
302
+ phase error across all channels at each symbol before updating the
303
+ shared loop state. Both polarisations drive a single phase/frequency
304
+ trajectory, giving ~√C variance reduction for shared-LO systems.
305
+ The output ``phi_full[ch]`` rows are all identical.
306
+ Has no effect for SISO (C = 1).
307
+ cycle_slip_correction : bool, default False
308
+ If ``True``, apply ``correct_cycle_slips`` to the per-symbol
309
+ phase trajectory after the loop, to detect and fix sudden ``π/2``
310
+ jumps caused by incorrect hard decisions near the branch boundary.
311
+ cycle_slip_history : int, default 100
312
+ ``history_length`` passed to ``correct_cycle_slips``.
313
+ Default is higher than for block-phase methods because the trajectory
314
+ is per-symbol (not per-block).
315
+ cycle_slip_threshold : float, default π/4
316
+ ``threshold`` passed to ``correct_cycle_slips`` (radians).
317
+
318
+ Returns
319
+ -------
320
+ array_like
321
+ Per-symbol phase estimate φ[n] in radians.
322
+ Shape matches ``symbols``. Same backend as input.
323
+
324
+ Notes
325
+ -----
326
+ Inner loop: derotate by phi_hat, hard-decide, compute cross-product error
327
+ e[n] = Im(y[n] * d_hat*[n]), update phi_hat[n+1] = phi_hat[n] + mu*e + nu,
328
+ nu += beta*e. Numba-compiled on CPU; GPU inputs are offloaded transparently.
329
+
330
+ A global M-fold phase ambiguity always remains - resolve via a pilot or
331
+ preamble reference after CPR.
332
+ """
333
+ from ..helpers import normalize, resolve_pll_gains
334
+ from ..mapping import gray_constellation
335
+
336
+ # Resolve PI gains: raw mu/beta if given, else the critically-damped
337
+ # bandwidth shortcut (mu=None). Validate the bandwidth only on that path.
338
+ if mu is None and not (0.0 < loop_bandwidth_normalized < 0.5):
339
+ raise ValueError(
340
+ f"loop_bandwidth_normalized must be in (0, 0.5), got {loop_bandwidth_normalized}."
341
+ )
342
+ mu, beta = resolve_pll_gains(loop_bandwidth_normalized, mu, beta)
343
+
344
+ symbols, xp, _ = dispatch(symbols)
345
+ was_1d = symbols.ndim == 1
346
+ if was_1d:
347
+ symbols = symbols[None, :]
348
+ C, N = symbols.shape
349
+
350
+ # Normalise to unit average power so the effective loop gain is mu regardless
351
+ # of input amplitude. The error signal is e[n] = Im(y[n]*d_hat*), which
352
+ # scales with signal amplitude; without this, the effective gain is mu*A
353
+ # (where A is the RMS amplitude), making loop bandwidth input-dependent.
354
+ symbols = normalize(symbols, mode="average_power", axis=-1)
355
+
356
+ # Constellation on CPU (decisions are scalar operations in the loop)
357
+ const_np = gray_constellation(modulation, order).astype(np.complex128)
358
+ const_r = const_np.real.copy()
359
+ const_i = const_np.imag.copy()
360
+
361
+ # Square-QAM O(1) decision parameters. For square QAM (order a perfect
362
+ # square, e.g. 4/16/64/256/1024) the constellation is a uniform grid and
363
+ # the nearest point can be found by rounding to the closest axis level.
364
+ import math as _math
365
+
366
+ _sq_root = _math.isqrt(order)
367
+ _is_sq_qam = ("qam" in modulation.lower()) and (_sq_root * _sq_root == order)
368
+ if _is_sq_qam:
369
+ _levels = np.unique(const_np.real).astype(np.float64)
370
+ _d_grid = float(_levels[1] - _levels[0]) if len(_levels) > 1 else 1.0
371
+ _lev_min = float(_levels[0])
372
+ _side = _sq_root
373
+ else:
374
+ _levels = np.empty(0, dtype=np.float64)
375
+ _d_grid = 1.0
376
+ _lev_min = 0.0
377
+ _side = 0
378
+
379
+ # Move to CPU for sequential processing
380
+ if xp is not np:
381
+ symbols_cpu = to_device(symbols, "cpu")
382
+ else:
383
+ symbols_cpu = symbols
384
+
385
+ phi_full = np.zeros((C, N), dtype=np.float64)
386
+ use_joint = joint_channels and C > 1
387
+
388
+ # Pre-build (C, N) float64 views used by joint kernels
389
+ if use_joint:
390
+ symbols_np = symbols_cpu.astype(np.complex128)
391
+ sym_r_all = np.ascontiguousarray(symbols_np.real) # (C, N) float64
392
+ sym_i_all = np.ascontiguousarray(symbols_np.imag)
393
+
394
+ if use_joint:
395
+ j_kernel = _get_numba_dd_pll_joint()
396
+ phi_joint = j_kernel(
397
+ sym_r_all,
398
+ sym_i_all,
399
+ const_r,
400
+ const_i,
401
+ float(mu),
402
+ float(beta),
403
+ float(phase_init),
404
+ 0.0,
405
+ _is_sq_qam,
406
+ _levels,
407
+ _d_grid,
408
+ _lev_min,
409
+ _side,
410
+ )
411
+ for ch in range(C):
412
+ phi_full[ch] = phi_joint
413
+ else:
414
+ pi_kernel = _get_numba_dd_pll()
415
+ for ch in range(C):
416
+ sym = symbols_cpu[ch].astype(np.complex128)
417
+ phi_full[ch] = pi_kernel(
418
+ sym.real.copy(),
419
+ sym.imag.copy(),
420
+ const_r,
421
+ const_i,
422
+ float(mu),
423
+ float(beta),
424
+ float(phase_init),
425
+ 0.0,
426
+ _is_sq_qam,
427
+ _levels,
428
+ _d_grid,
429
+ _lev_min,
430
+ _side,
431
+ )
432
+ loop_order = "2nd" if beta > 0.0 else "1st"
433
+ loop_desc = f"PI {loop_order}-order, mu={mu}, beta={beta}"
434
+
435
+ if cycle_slip_correction:
436
+ if use_joint:
437
+ # All rows are identical - correct once and broadcast
438
+ phi_full[0] = correct_cycle_slips(
439
+ phi_full[0],
440
+ symmetry=4,
441
+ history_length=cycle_slip_history,
442
+ threshold=cycle_slip_threshold,
443
+ )
444
+ for ch in range(1, C):
445
+ phi_full[ch] = phi_full[0]
446
+ else:
447
+ for ch in range(C):
448
+ phi_full[ch] = correct_cycle_slips(
449
+ phi_full[ch],
450
+ symmetry=4,
451
+ history_length=cycle_slip_history,
452
+ threshold=cycle_slip_threshold,
453
+ )
454
+
455
+ # Move result back to original device
456
+ if xp is not np:
457
+ phi_full = xp.asarray(phi_full)
458
+
459
+ if logger.isEnabledFor(logging.INFO):
460
+ # Two reductions + host syncs, needed only for the summary below.
461
+ phi_mean_deg = float(np.mean(phi_full)) * 180.0 / np.pi
462
+ phi_std_deg = float(np.std(phi_full)) * 180.0 / np.pi
463
+ logger.info(
464
+ "CPR (DD-PLL, %s): phase mean=%.2f°, std=%.2f° [C=%s]",
465
+ loop_desc,
466
+ phi_mean_deg,
467
+ phi_std_deg,
468
+ C,
469
+ )
470
+
471
+ if debug_plot:
472
+ from .. import plotting as _plotting
473
+
474
+ _plotting.plot_carrier_phase_trajectory(
475
+ phi_full=phi_full if xp is np else to_device(phi_full, "cpu"),
476
+ show=True,
477
+ title=f"CPR - DD-PLL ({loop_desc})",
478
+ )
479
+
480
+ if was_1d:
481
+ return phi_full[0]
482
+ return phi_full