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,627 @@
1
+ """Laser/carrier characterization plots (drift, Allan, linewidth)."""
2
+
3
+ from typing import Any
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+
8
+ from ..backend import to_device
9
+ from .sync import plot_carrier_phase_decomposition
10
+ from .theme import (
11
+ _as_channels,
12
+ _grid_figsize,
13
+ _set_eng_formatter,
14
+ )
15
+
16
+
17
+ def plot_frequency_drift(
18
+ df,
19
+ *,
20
+ symbol_rate: float,
21
+ amp_ref: float | None = None,
22
+ ax=None,
23
+ show: bool = False,
24
+ title: str = "Residual frequency drift",
25
+ ) -> tuple[Any, Any] | None:
26
+ """
27
+ Plots the instantaneous residual frequency offset vs time.
28
+
29
+ ``df`` is the per-symbol frequency wander from
30
+ ``analysis.frequency_drift_metrics`` - the slope of the smoothed (drift)
31
+ phase. This is the spin the carrier-phase recovery must track.
32
+
33
+ Parameters
34
+ ----------
35
+ df : array_like
36
+ Residual frequency in Hz. Shape ``(M,)`` or ``(C, M)``.
37
+ symbol_rate : float
38
+ Symbol rate in Baud (time axis).
39
+ amp_ref : float, optional
40
+ If given, draws dashed ``±amp_ref`` reference lines (e.g. the
41
+ injected wander amplitude in a simulation).
42
+ ax : Axes, optional
43
+ show : bool, default False
44
+ title : str
45
+
46
+ Returns
47
+ -------
48
+ (fig, ax) or None
49
+ """
50
+ df_c = _as_channels(df)
51
+ C, M = df_c.shape
52
+
53
+ if ax is None:
54
+ fig, axi = plt.subplots(1, 1)
55
+ else:
56
+ axi = ax
57
+ fig = axi.figure
58
+
59
+ t = np.arange(M) / float(symbol_rate)
60
+ for i in range(C):
61
+ axi.plot(t, df_c[i], color=f"C{i}", label=f"Pol {i}" if C > 1 else None)
62
+
63
+ if amp_ref is not None:
64
+ axi.axhline(amp_ref, color="white", ls="--", label="±amplitude")
65
+ axi.axhline(-amp_ref, color="white", ls="--")
66
+
67
+ _set_eng_formatter(axi, "x", "s")
68
+ _set_eng_formatter(axi, "y", "Hz")
69
+ axi.set_xlabel("Time [s]")
70
+ axi.set_ylabel(r"$\Delta f$ [Hz]")
71
+ axi.set_title(title)
72
+ if C > 1 or amp_ref is not None:
73
+ axi.legend(loc="best")
74
+
75
+ if show:
76
+ plt.show()
77
+ return None
78
+ return fig, axi
79
+
80
+
81
+ def _log_cell_median(f_pos, s, sel, points_per_octave=24):
82
+ """Median-reduce a PSD onto a log-frequency grid (NaN for empty cells).
83
+
84
+ Returns geometric cell-center frequencies and the per-cell median of the
85
+ ``sel``-selected bins - the readable trace for dense Welch spectra, and
86
+ exactly the reduction the plateau detector medians over.
87
+ """
88
+ if not sel.any():
89
+ return np.array([]), np.array([])
90
+ f_lo, f_hi = float(f_pos[sel][0]), float(f_pos[sel][-1])
91
+ if f_hi <= f_lo:
92
+ return np.array([f_lo]), np.array([float(np.median(s[sel]))])
93
+ n_cells = max(int(np.ceil(np.log2(f_hi / f_lo) * points_per_octave)), 1)
94
+ edges = np.geomspace(f_lo, f_hi, n_cells + 1)
95
+ centers = np.sqrt(edges[:-1] * edges[1:])
96
+ idx = np.clip(np.searchsorted(edges, f_pos, side="right") - 1, 0, n_cells - 1)
97
+ med = np.full(n_cells, np.nan)
98
+ for k in range(n_cells):
99
+ cell_sel = sel & (idx == k)
100
+ if cell_sel.any():
101
+ med[k] = float(np.median(s[cell_sel]))
102
+ return centers, med
103
+
104
+
105
+ def plot_frequency_noise_psd(
106
+ f,
107
+ S_f,
108
+ *,
109
+ beta_line=None,
110
+ floor=None,
111
+ band: tuple[float, float] | None = None,
112
+ above=None,
113
+ used=None,
114
+ ax=None,
115
+ show: bool = False,
116
+ title: str = "Frequency-noise PSD",
117
+ ) -> tuple[Any, Any] | None:
118
+ """
119
+ Plots the frequency-noise PSD S_f(f) on log-log axes.
120
+
121
+ Overlays the optional Di Domenico β-separation line and the white-FM-noise
122
+ floor. Two distinct region annotations, matching the two estimator
123
+ families:
124
+
125
+ * ``band`` - the ``[f_min, f_max]`` **analysis fence** (light span with
126
+ edge lines): the window the white-FM-floor *median* is read from, or
127
+ the outer fence of the β-integration. It is *not* itself the
128
+ integration region.
129
+ * ``above`` - the **actual β-integration region** ``{f : S_f(f) > β(f)}``
130
+ (in general a union of disjoint intervals): the area between the β-line
131
+ and the PSD is filled wherever the mask is true. Pass
132
+ ``linewidth_beta_separation(...)['above']``.
133
+
134
+ See ``analysis.fm_noise_psd`` and ``analysis.linewidth_beta_separation``.
135
+
136
+ Parameters
137
+ ----------
138
+ f : array_like
139
+ One-sided frequency axis in Hz, shape ``(nfreq,)``.
140
+ S_f : array_like
141
+ Frequency-noise PSD in Hz²/Hz, shape ``(nfreq,)`` or ``(C, nfreq)``.
142
+ beta_line : array_like, optional
143
+ β-separation line ``(8 ln2/π²)·f``, shape ``(nfreq,)``. Drawn dashed.
144
+ floor : float or array_like, optional
145
+ White-FM linewidth estimate(s) in Hz; a horizontal guide is drawn at the
146
+ corresponding PSD level ``S_f = Δν/π``.
147
+ band : (float, float), optional
148
+ ``(f_min, f_max)`` analysis fence. The lower edge is clamped to the
149
+ first positive frequency bin for display (a 0 Hz fence is a
150
+ resolution statement, not a plottable frequency on a log axis).
151
+ above : array_like of bool, optional
152
+ Integration-region mask aligned with ``f``, shape ``(nfreq,)`` or
153
+ ``(C, nfreq)`` (channel 0 is drawn). Requires ``beta_line``.
154
+ used : array_like of bool, optional
155
+ Mask of the bins a floor *median* actually ran over (the
156
+ auto-detected plateau). Sparse masks (≤ 400 bins) are drawn as
157
+ markers on the PSD trace; dense masks as a highlighted **log-binned
158
+ median curve** over the accepted region (per-bin markers would
159
+ splatter the figure). Pass ``linewidth_dsh(...)['used']`` /
160
+ ``linewidth_beta_separation(...)['used']``. Channel 0 is drawn.
161
+ ax : Axes, optional
162
+ show : bool, default False
163
+ title : str
164
+
165
+ Returns
166
+ -------
167
+ (fig, ax) or None
168
+ """
169
+ f_c = np.asarray(to_device(f, "cpu"), dtype=np.float64)
170
+ S_c = _as_channels(S_f)
171
+ C = S_c.shape[0]
172
+ pos = f_c > 0
173
+ fp = f_c[pos]
174
+ # Dense Welch spectra (low-averaged DSH deconvolutions especially) are
175
+ # unreadable raw: draw them faint and put a log-binned median curve on
176
+ # top - the same reduction the plateau detector runs on.
177
+ dense = int(pos.sum()) > 4000
178
+
179
+ if ax is None:
180
+ fig, axi = plt.subplots(1, 1)
181
+ else:
182
+ axi = ax
183
+ fig = axi.figure
184
+
185
+ for i in range(C):
186
+ s_i = S_c[i, pos]
187
+ lbl = f"$S_f$ pol {i}" if C > 1 else "$S_f(f)$"
188
+ if dense:
189
+ axi.loglog(fp, s_i, color=f"C{i}", alpha=0.4)
190
+ fg, sg = _log_cell_median(fp, s_i, np.isfinite(s_i))
191
+ axi.loglog(fg, sg, color=f"C{i}", label=lbl + " (log-binned)")
192
+ else:
193
+ axi.loglog(fp, s_i, color=f"C{i}", label=lbl)
194
+
195
+ b_c = None
196
+ if beta_line is not None:
197
+ b_c = np.asarray(to_device(beta_line, "cpu"), dtype=np.float64)
198
+ axi.loglog(
199
+ f_c[pos],
200
+ b_c[pos],
201
+ color="#ff5555",
202
+ ls="--",
203
+ label=r"$\beta$-separation line",
204
+ )
205
+
206
+ if above is not None and b_c is not None:
207
+ a_c = np.asarray(to_device(above, "cpu"), dtype=bool)
208
+ if a_c.ndim > 1:
209
+ a_c = a_c[0]
210
+ axi.fill_between(
211
+ f_c[pos],
212
+ b_c[pos],
213
+ S_c[0, pos],
214
+ where=a_c[pos].tolist(),
215
+ color="#ff5555",
216
+ alpha=0.4,
217
+ lw=0,
218
+ label=r"$\beta$-area (integrated region)",
219
+ )
220
+
221
+ if used is not None:
222
+ u_c = np.asarray(to_device(used, "cpu"), dtype=bool)
223
+ if u_c.ndim > 1:
224
+ u_c = u_c[0]
225
+ sel = u_c[pos] & np.isfinite(S_c[0, pos])
226
+ if int(sel.sum()) <= 400:
227
+ axi.loglog(
228
+ fp[sel],
229
+ S_c[0, pos][sel],
230
+ ".",
231
+ color="#06d6a0",
232
+ ms=3.0,
233
+ ls="none",
234
+ label="Plateau bins (median)",
235
+ )
236
+ else:
237
+ # Dense mask: highlight the log-binned median over the accepted
238
+ # region instead of splattering one marker per bin.
239
+ fg, sg = _log_cell_median(fp, S_c[0, pos], sel)
240
+ axi.loglog(fg, sg, color="#06d6a0", label="Plateau (median region)")
241
+
242
+ if floor is not None:
243
+ floors = np.atleast_1d(np.asarray(floor, dtype=np.float64))
244
+ floor_mean = float(np.mean(floors))
245
+ axi.axhline(
246
+ floor_mean / np.pi,
247
+ color="#ffd166",
248
+ ls=":",
249
+ label=r"White-FM floor $\Delta\nu/\pi$",
250
+ )
251
+
252
+ if band is not None:
253
+ lo = max(float(band[0]), float(fp[0])) if fp.size else float(band[0])
254
+ if used is None:
255
+ # With a used-region overlay the span is redundant clutter; keep
256
+ # the full shading only for fence-defined (manual) bands.
257
+ axi.axvspan(
258
+ lo,
259
+ band[1],
260
+ color="#06d6a0",
261
+ alpha=0.4,
262
+ label="Analysis band [f_min, f_max]",
263
+ )
264
+ for edge in (lo, float(band[1])):
265
+ axi.axvline(edge, color="#06d6a0")
266
+
267
+ _set_eng_formatter(axi, "x", "Hz")
268
+ axi.set_xlabel("Frequency [Hz]")
269
+ axi.set_ylabel("$S_f$ [Hz²/Hz]")
270
+ axi.set_title(title)
271
+ axi.legend(loc="best")
272
+ axi.grid(True, which="both")
273
+
274
+ if show:
275
+ plt.show()
276
+ return None
277
+ return fig, axi
278
+
279
+
280
+ def plot_allan_deviation(
281
+ tau_s,
282
+ adev,
283
+ *,
284
+ reference_slopes: bool = True,
285
+ ax=None,
286
+ show: bool = False,
287
+ title: str = "Allan deviation",
288
+ ) -> tuple[Any, Any] | None:
289
+ """
290
+ Plots the (overlapping) Allan deviation vs averaging time on log-log axes.
291
+
292
+ The local slope classifies the dominant frequency-noise process:
293
+ white-FM ~ tau^(-1/2), flicker-FM ~ tau^0 (flat), random-walk-FM ~ tau^(+1/2),
294
+ linear drift ~ tau^(+1). See ``analysis.allan_deviation``.
295
+
296
+ Parameters
297
+ ----------
298
+ tau_s : array_like
299
+ Averaging times in seconds, shape ``(n_tau,)``.
300
+ adev : array_like
301
+ Allan deviation in Hz, shape ``(n_tau,)`` or ``(C, n_tau)``.
302
+ reference_slopes : bool, default True
303
+ If True, overlays a faint ``τ^{-1/2}`` (white-FM) guide line.
304
+ ax : Axes, optional
305
+ show : bool, default False
306
+ title : str
307
+
308
+ Returns
309
+ -------
310
+ (fig, ax) or None
311
+ """
312
+ tau = np.asarray(to_device(tau_s, "cpu"), dtype=np.float64)
313
+ adv = _as_channels(adev)
314
+ C = adv.shape[0]
315
+
316
+ if ax is None:
317
+ fig, axi = plt.subplots(1, 1)
318
+ else:
319
+ axi = ax
320
+ fig = axi.figure
321
+
322
+ for i in range(C):
323
+ axi.loglog(
324
+ tau,
325
+ adv[i],
326
+ "o-",
327
+ ms=3,
328
+ color=f"C{i}",
329
+ label=f"Pol {i}" if C > 1 else r"$\sigma_y(\tau)$",
330
+ )
331
+
332
+ if reference_slopes:
333
+ good = np.isfinite(adv[0]) & (adv[0] > 0)
334
+ if np.any(good):
335
+ tau0, a0 = tau[good][0], adv[0][good][0]
336
+ guide = a0 * np.sqrt(tau0 / tau) # τ^{-1/2} anchored at first point
337
+ axi.loglog(
338
+ tau,
339
+ guide,
340
+ color="gray",
341
+ ls=":",
342
+ label=r"$\tau^{-1/2}$ (white-FM)",
343
+ )
344
+
345
+ _set_eng_formatter(axi, "x", "s")
346
+ _set_eng_formatter(axi, "y", "Hz")
347
+ axi.set_xlabel(r"Averaging Time $\tau$ [s]")
348
+ axi.set_ylabel(r"Allan Deviation $\sigma_y(\tau)$ [Hz]")
349
+ axi.set_title(title)
350
+ axi.legend(loc="best")
351
+ axi.grid(True, which="both")
352
+
353
+ if show:
354
+ plt.show()
355
+ return None
356
+ return fig, axi
357
+
358
+
359
+ def plot_increment_variance(
360
+ lag_s,
361
+ var,
362
+ *,
363
+ slope=None,
364
+ intercept=None,
365
+ ax=None,
366
+ show: bool = False,
367
+ title: str = "Phase-increment variance",
368
+ ) -> tuple[Any, Any] | None:
369
+ """
370
+ Plots Var of phase increments vs lag with the fitted linear model.
371
+
372
+ The measured points should follow ``Var = slope·lag + intercept`` for
373
+ white-FM (Wiener) phase noise; curvature signals flicker or drift
374
+ contamination. See ``analysis.linewidth_increment`` and
375
+ ``analysis.linewidth_dsh(method="increment")``.
376
+
377
+ Parameters
378
+ ----------
379
+ lag_s : array_like
380
+ Increment lags in seconds, shape ``(n_lag,)``.
381
+ var : array_like
382
+ Measured increment variance in rad², ``(n_lag,)`` or ``(C, n_lag)``.
383
+ slope : float or array_like, optional
384
+ Fitted slope(s) in rad²/s (per channel). Drawn with ``intercept``.
385
+ intercept : float or array_like, optional
386
+ Fitted intercept(s) in rad² - the additive-noise term ``2σ_φ²``.
387
+ ax : Axes, optional
388
+ show : bool, default False
389
+ title : str
390
+
391
+ Returns
392
+ -------
393
+ (fig, ax) or None
394
+ """
395
+ lag = np.asarray(to_device(lag_s, "cpu"), dtype=np.float64)
396
+ v = _as_channels(var)
397
+ C = v.shape[0]
398
+
399
+ if ax is None:
400
+ fig, axi = plt.subplots(1, 1)
401
+ else:
402
+ axi = ax
403
+ fig = axi.figure
404
+
405
+ for i in range(C):
406
+ axi.plot(
407
+ lag,
408
+ v[i],
409
+ "o",
410
+ ms=4,
411
+ color=f"C{i}",
412
+ label=f"Measured pol {i}" if C > 1 else "Measured",
413
+ )
414
+ if slope is not None and intercept is not None:
415
+ sl = np.atleast_1d(np.asarray(to_device(slope, "cpu"), dtype=np.float64))
416
+ ic = np.atleast_1d(np.asarray(to_device(intercept, "cpu"), dtype=np.float64))
417
+ for i in range(C):
418
+ axi.plot(
419
+ lag,
420
+ sl[min(i, sl.size - 1)] * lag + ic[min(i, ic.size - 1)],
421
+ "-",
422
+ color=f"C{i}",
423
+ alpha=0.4,
424
+ label="Fit" if i == 0 else None,
425
+ )
426
+ axi.axhline(
427
+ float(np.mean(ic)),
428
+ color="gray",
429
+ ls=":",
430
+ label=r"Intercept (noise $2\sigma_\phi^2$)",
431
+ )
432
+
433
+ _set_eng_formatter(axi, "x", "s")
434
+ axi.set_xlabel("Increment Lag [s]")
435
+ axi.set_ylabel("Variance [rad²]")
436
+ axi.set_title(title)
437
+ axi.legend(loc="best")
438
+
439
+ if show:
440
+ plt.show()
441
+ return None
442
+ return fig, axi
443
+
444
+
445
+ def plot_dsh_beat_psd(
446
+ f,
447
+ psd,
448
+ *,
449
+ f_peak=None,
450
+ linewidth=None,
451
+ linewidth_3db=None,
452
+ level_db: float = 20.0,
453
+ ax=None,
454
+ show: bool = False,
455
+ title: str = "DSH beat spectrum",
456
+ ) -> tuple[Any, Any] | None:
457
+ """
458
+ Plots the self-heterodyne beat PSD (dB rel. peak) with width annotations.
459
+
460
+ Overlays the half-power and ``-level_db`` contours and shades the full
461
+ widths implied by the linewidth estimates (``FWHM = 2Δν``,
462
+ ``W_L = 2√(10^{L/10}-1)·Δν``). See
463
+ ``analysis.linewidth_dsh(method="lorentzian")``.
464
+
465
+ Parameters
466
+ ----------
467
+ f : array_like
468
+ Two-sided frequency axis in Hz, shape ``(nfreq,)``.
469
+ psd : array_like
470
+ Beat PSD (linear), ``(nfreq,)`` or ``(C, nfreq)``.
471
+ f_peak : float or array_like, optional
472
+ Beat carrier location(s) in Hz; the x-axis is centered on the mean.
473
+ linewidth : float or array_like, optional
474
+ Deep-width linewidth estimate(s) Δν in Hz.
475
+ linewidth_3db : float or array_like, optional
476
+ Half-power linewidth estimate(s) in Hz.
477
+ level_db : float, default 20.0
478
+ Depth of the deep-width contour.
479
+ ax : Axes, optional
480
+ show : bool, default False
481
+ title : str
482
+
483
+ Returns
484
+ -------
485
+ (fig, ax) or None
486
+ """
487
+ f_c = np.asarray(to_device(f, "cpu"), dtype=np.float64)
488
+ p = _as_channels(psd)
489
+ C = p.shape[0]
490
+ f0 = 0.0
491
+ if f_peak is not None:
492
+ f0 = float(np.mean(np.atleast_1d(np.asarray(f_peak, dtype=np.float64))))
493
+
494
+ if ax is None:
495
+ fig, axi = plt.subplots(1, 1)
496
+ else:
497
+ axi = ax
498
+ fig = axi.figure
499
+
500
+ for i in range(C):
501
+ p_db = 10.0 * np.log10(p[i] / p[i].max())
502
+ axi.plot(
503
+ f_c - f0,
504
+ p_db,
505
+ color=f"C{i}",
506
+ label=f"Pol {i}" if C > 1 else "Beat PSD",
507
+ )
508
+
509
+ if linewidth_3db is not None:
510
+ w3 = 2.0 * float(np.mean(np.atleast_1d(linewidth_3db)))
511
+ axi.axhline(-3.01, color="C1", ls=":")
512
+ axi.axvspan(
513
+ -w3 / 2, w3 / 2, color="C1", alpha=0.4, label=r"FWHM = $2\Delta\nu$"
514
+ )
515
+ if linewidth is not None:
516
+ r = 10.0 ** (float(level_db) / 10.0)
517
+ w_deep = 2.0 * np.sqrt(r - 1.0) * float(np.mean(np.atleast_1d(linewidth)))
518
+ axi.axhline(-float(level_db), color="C3", ls=":")
519
+ axi.axvspan(
520
+ -w_deep / 2,
521
+ w_deep / 2,
522
+ color="C3",
523
+ alpha=0.4,
524
+ label=f"$W_{{-{level_db:g}\\,dB}}$",
525
+ )
526
+
527
+ _set_eng_formatter(axi, "x", "Hz")
528
+ axi.set_xlabel(
529
+ "Frequency Offset from Beat Peak [Hz]"
530
+ if f_peak is not None
531
+ else "Frequency [Hz]"
532
+ )
533
+ axi.set_ylabel("PSD [dB rel. peak]")
534
+ axi.set_title(title)
535
+ axi.legend(loc="best")
536
+
537
+ if show:
538
+ plt.show()
539
+ return None
540
+ return fig, axi
541
+
542
+
543
+ def plot_carrier_phase_characterization(
544
+ report: dict,
545
+ *,
546
+ symbol_rate: float,
547
+ drift_cutoff: float | None = None,
548
+ band: tuple[float, float] | None = None,
549
+ floor=None,
550
+ amp_ref: float | None = None,
551
+ show: bool = False,
552
+ title: str | None = None,
553
+ ) -> tuple[Any, Any] | None:
554
+ """
555
+ Full 2x2 carrier-phase characterization dashboard.
556
+
557
+ Combines ``carrier_phase_decomposition``, ``frequency_drift``,
558
+ ``frequency_noise_psd``, and ``allan_deviation`` into one figure from a
559
+ report dict assembled by the caller (see
560
+ ``examples/carrier_phase_analysis.py`` for the full chain).
561
+
562
+ Parameters
563
+ ----------
564
+ report : dict
565
+ ``{'phi', 'drift', 'drift_metrics', 'linewidth_beta', 'allan'}`` -
566
+ the outputs of ``carrier_phase_trajectory``,
567
+ ``separate_drift_phase_noise``, ``frequency_drift_metrics``,
568
+ ``linewidth_beta_separation``, and ``allan_deviation``.
569
+ symbol_rate : float
570
+ Symbol rate in Baud.
571
+ drift_cutoff : float, optional
572
+ Annotated in the phase-decomposition panel title.
573
+ band : (float, float), optional
574
+ ``(f_min, f_max)`` integration band, shaded on the PSD panel.
575
+ floor : float or array_like, optional
576
+ White-FM floor guide; defaults to the report's estimated floor.
577
+ amp_ref : float, optional
578
+ Injected wander amplitude reference for the drift panel.
579
+ show : bool, default False
580
+ title : str, optional
581
+
582
+ Returns
583
+ -------
584
+ (fig, axes) or None
585
+ """
586
+ fig, axes = plt.subplots(2, 2, figsize=_grid_figsize(2, 2))
587
+
588
+ lp = f" (LP {drift_cutoff / 1e6:.1f} MHz)" if drift_cutoff else ""
589
+ plot_carrier_phase_decomposition(
590
+ report["phi"],
591
+ report.get("drift"),
592
+ symbol_rate=symbol_rate,
593
+ ax=axes[0, 0],
594
+ title=f"Recovered carrier phase{lp}",
595
+ )
596
+ plot_frequency_drift(
597
+ report["drift_metrics"]["df"],
598
+ symbol_rate=symbol_rate,
599
+ amp_ref=amp_ref,
600
+ ax=axes[0, 1],
601
+ )
602
+
603
+ lw_beta = report["linewidth_beta"]
604
+ if floor is None:
605
+ floor = lw_beta.get("linewidth_floor")
606
+ plot_frequency_noise_psd(
607
+ lw_beta["f"],
608
+ lw_beta["S_f"],
609
+ beta_line=lw_beta.get("beta_line"),
610
+ floor=floor,
611
+ band=band,
612
+ above=lw_beta.get("above"),
613
+ used=lw_beta.get("used"),
614
+ ax=axes[1, 0],
615
+ )
616
+ plot_allan_deviation(
617
+ report["allan"]["tau_s"],
618
+ report["allan"]["adev"],
619
+ ax=axes[1, 1],
620
+ )
621
+
622
+ if title:
623
+ fig.suptitle(title)
624
+ if show:
625
+ plt.show()
626
+ return None
627
+ return fig, axes