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,119 @@
1
+ // Per-symbol cycle-slip correction for one block_lms equalizer block.
2
+ //
3
+ // Faithful port of the Numba kernel `cs_block` (equalization.py): for each
4
+ // symbol, predict the expected phase from an online OLS regression over the
5
+ // last H corrected phases (relative coordinates, closed-form Sx/Sxx), snap
6
+ // the BPS phase by the nearest integer multiple of `quantum` when |diff|
7
+ // exceeds `threshold`, then push the corrected phase into the per-channel
8
+ // circular history using the O(1) rolling-sum identities:
9
+ //
10
+ // full buffer: Sxy_new = Sxy_old - Sy_old + y_old + (H-1) * y_new
11
+ // Sy_new = Sy_old - y_old + y_new
12
+ // filling: Sxy += n * y_new ; Sy += y_new
13
+ //
14
+ // Within a channel the recursion is strictly sequential; channels are
15
+ // independent. Total work is C*B trivial scalar iterations per launch, so
16
+ // the kernel runs as a single block with one thread per channel - the goal
17
+ // is not throughput but keeping the block loop free of host synchronization
18
+ // (it replaces a per-block D2H -> CPU Numba -> H2D round trip).
19
+ //
20
+ // Layout contract (enforced by the Python wrapper):
21
+ // phi_blk (C, B) float64 - BPS phase before correction (input)
22
+ // phi_corr (C, B) float64 - corrected phase (output)
23
+ // cs_buf_y (C, H) float64 - circular buffer of past corrected phases
24
+ // cs_buf_ptr (C,) int64 - write pointer (monotonically increasing)
25
+ // cs_buf_n (C,) int64 - number of valid entries (<= H)
26
+ // cs_stats (C, 4) float64 - [0]=Sy, [1]=Sxy (relative coords); [2..3] unused
27
+ //
28
+ // Launch contract: grid = (1, 1, 1), block = (C, 1, 1), C <= 1024.
29
+ // All arithmetic is float64, matching the Numba kernel - the GeForce FP64
30
+ // throughput cliff is irrelevant at C*B ~ 512 sequential iterations.
31
+
32
+ __global__ void cs_block(const double* __restrict__ phi_blk,
33
+ double* __restrict__ phi_corr,
34
+ double* cs_buf_y,
35
+ long long* cs_buf_ptr,
36
+ long long* cs_buf_n,
37
+ double* cs_stats,
38
+ const double quantum,
39
+ const double threshold,
40
+ const int cs_H,
41
+ const int B) {
42
+ const int ci = threadIdx.x; // one thread per channel; blockDim.x == C
43
+
44
+ const double H_f = static_cast<double>(cs_H);
45
+ const double Sx_full = H_f * (H_f - 1.0) / 2.0;
46
+ const double Sxx_full = H_f * (H_f - 1.0) * (2.0 * H_f - 1.0) / 6.0;
47
+ const double denom_full = H_f * Sxx_full - Sx_full * Sx_full;
48
+
49
+ // Channel-local state in registers; written back once after the loop.
50
+ long long n_b = cs_buf_n[ci];
51
+ long long ptr = cs_buf_ptr[ci];
52
+ double sy = cs_stats[ci * 4 + 0];
53
+ double sxy = cs_stats[ci * 4 + 1];
54
+ double* buf_y = cs_buf_y + static_cast<long long>(ci) * cs_H;
55
+ const double* phi_in = phi_blk + static_cast<long long>(ci) * B;
56
+ double* phi_out = phi_corr + static_cast<long long>(ci) * B;
57
+
58
+ for (int i = 0; i < B; ++i) {
59
+ double y_b = phi_in[i];
60
+ double phi_expected;
61
+
62
+ if (n_b == 0) {
63
+ phi_expected = y_b;
64
+ } else if (n_b < 10) {
65
+ const long long last_pos = (ptr - 1 + cs_H) % cs_H;
66
+ phi_expected = buf_y[last_pos];
67
+ } else {
68
+ const double n_f = static_cast<double>(n_b);
69
+ double Sx_c, denom;
70
+ if (n_b < cs_H) {
71
+ Sx_c = n_f * (n_f - 1.0) / 2.0;
72
+ const double Sxx_c = n_f * (n_f - 1.0) * (2.0 * n_f - 1.0) / 6.0;
73
+ denom = n_f * Sxx_c - Sx_c * Sx_c;
74
+ } else {
75
+ Sx_c = Sx_full;
76
+ denom = denom_full;
77
+ }
78
+ double slope, intercept;
79
+ if (fabs(denom) > 1e-30) {
80
+ slope = (n_f * sxy - Sx_c * sy) / denom;
81
+ intercept = (sy - slope * Sx_c) / n_f;
82
+ } else {
83
+ slope = 0.0;
84
+ intercept = sy / n_f;
85
+ }
86
+ phi_expected = slope * n_f + intercept;
87
+ }
88
+
89
+ const double diff = y_b - phi_expected;
90
+ // llrint = round-half-even, matching Python round() in the Numba kernel.
91
+ const long long k_slip = llrint(diff / quantum);
92
+ if (fabs(diff) > threshold && k_slip != 0) {
93
+ y_b -= static_cast<double>(k_slip) * quantum;
94
+ }
95
+ phi_out[i] = y_b;
96
+
97
+ // Update circular buffer - relative coords, only y needed.
98
+ const long long write_pos = ptr % cs_H;
99
+ if (n_b == cs_H) {
100
+ const double old_y = buf_y[write_pos];
101
+ const double old_sy = sy;
102
+ sxy = sxy - old_sy + old_y + (H_f - 1.0) * y_b;
103
+ sy = old_sy - old_y + y_b;
104
+ } else {
105
+ sxy += static_cast<double>(n_b) * y_b;
106
+ sy += y_b;
107
+ }
108
+ buf_y[write_pos] = y_b;
109
+ ptr += 1;
110
+ if (n_b < cs_H) {
111
+ n_b += 1;
112
+ }
113
+ }
114
+
115
+ cs_buf_n[ci] = n_b;
116
+ cs_buf_ptr[ci] = ptr;
117
+ cs_stats[ci * 4 + 0] = sy;
118
+ cs_stats[ci * 4 + 1] = sxy;
119
+ }
@@ -0,0 +1,14 @@
1
+ // Trivial templated kernel validating the _cuda compile/specialize/launch
2
+ // infrastructure (compiler.py + get_kernel). Not used by any DSP path.
3
+
4
+ template <typename T>
5
+ __global__ void selftest_scale(const T* __restrict__ x,
6
+ T* __restrict__ y,
7
+ T alpha,
8
+ long long n) {
9
+ const long long i =
10
+ static_cast<long long>(blockIdx.x) * blockDim.x + threadIdx.x;
11
+ if (i < n) {
12
+ y[i] = alpha * x[i];
13
+ }
14
+ }
@@ -0,0 +1,55 @@
1
+ """
2
+ Signal analysis and characterization.
3
+
4
+ Post-processing and diagnostic routines that operate on recovered signals to
5
+ quantify their properties, as opposed to the DSP stages that *produce* those
6
+ signals (synchronization, equalization, recovery, ...). Functions here are
7
+ grouped by the property they characterize; new analyses can be added as
8
+ independent groups without disturbing the others.
9
+
10
+ Backend policy
11
+ --------------
12
+ Every function dispatches on the input array type (NumPy -> CPU, CuPy -> GPU)
13
+ and all sample-rate work - phase extraction, unwrapping, filtering, Welch
14
+ PSDs, variance reductions - runs on that backend. Host-side NumPy inside this
15
+ package is deliberate and limited to three cases:
16
+
17
+ * **metadata**: lag lists, Welch segment sizing, tau grids - scalar shapes,
18
+ never data;
19
+ * **tiny post-reduction fits**: e.g. ``np.polyfit`` on an ``(n_lag, C)``
20
+ variance matrix after a single device->host transfer - cheaper than a device
21
+ least-squares launch;
22
+ * **report packaging**: summary functions (``linewidth_*``,
23
+ ``allan_deviation``, ``frequency_drift_metrics`` scalars) return Python
24
+ floats and *plot-sized* NumPy arrays (Welch/Allan grids, ≤ ``nperseg``
25
+ bins) after one transfer, because their consumers are prints and plots.
26
+
27
+ Sample-rate arrays returned to the caller (``carrier_phase_trajectory``,
28
+ ``separate_drift_phase_noise``, ``frequency_drift_metrics['df']``,
29
+ ``dsh_phase``, ``fm_noise_psd``, ``dsh_fm_noise_psd``) always stay on the
30
+ input backend - chain them without paying transfers.
31
+ """
32
+
33
+ from .allan import allan_deviation
34
+ from .drift import frequency_drift_metrics, separate_drift_phase_noise
35
+ from .interferometry import dsh_beat, dsh_fm_noise_psd, dsh_phase, linewidth_dsh
36
+ from .linewidth import (
37
+ fm_noise_psd,
38
+ linewidth_beta_separation,
39
+ linewidth_increment,
40
+ )
41
+ from .trajectory import carrier_phase_trajectory
42
+
43
+ __all__ = [
44
+ "allan_deviation",
45
+ "carrier_phase_trajectory",
46
+ "dsh_beat",
47
+ "dsh_fm_noise_psd",
48
+ "dsh_phase",
49
+ "fm_noise_psd",
50
+ "frequency_drift_metrics",
51
+ "linewidth_beta_separation",
52
+ "linewidth_dsh",
53
+ "linewidth_increment",
54
+ "separate_drift_phase_noise",
55
+ ]
@@ -0,0 +1,236 @@
1
+ """Shared helpers and constants for the carrier-phase analysis package."""
2
+
3
+ import numpy as np
4
+
5
+ __all__ = [
6
+ "_BETA_SLOPE",
7
+ "_FWHM_FROM_AREA",
8
+ "_as_2d",
9
+ "_pairing_variance",
10
+ "_plateau_mask",
11
+ "_scalar_or_array",
12
+ "_welch_median_bias",
13
+ ]
14
+
15
+ # β-separation-line slope (Di Domenico 2010): S_f(f) = (8 ln2 / π²) · f
16
+ _BETA_SLOPE = 8.0 * np.log(2.0) / (np.pi**2)
17
+ # FWHM linewidth from the integrated FM-noise area above the β-line:
18
+ # Δν = sqrt(8 ln2 · A)
19
+ _FWHM_FROM_AREA = 8.0 * np.log(2.0)
20
+
21
+
22
+ def _as_2d(arr):
23
+ """Promote SISO ``(N,)`` to ``(1, N)``; return ``(arr2d, was_1d)``."""
24
+ return (arr[None, :], True) if arr.ndim == 1 else (arr, False)
25
+
26
+
27
+ def _scalar_or_array(values):
28
+ """Collapse a length-1 per-channel result to a Python float."""
29
+ values = np.asarray(values, dtype=np.float64)
30
+ return float(values[0]) if values.size == 1 else values
31
+
32
+
33
+ def _welch_median_bias(n_samples: int, nperseg: int) -> tuple[float, int]:
34
+ r"""Median/mean ratio of mean-averaged Welch PSD bins (Hann, 50 % overlap).
35
+
36
+ Per-bin Welch estimates are ``S·χ²_ν/ν``-distributed with ``ν = 2·K_eff``
37
+ effective degrees of freedom, so every *median*-based floor summary
38
+ (plateau cell medians, fenced-band medians) reads the true level ``S``
39
+ low by ``median(χ²_ν)/ν`` - ``ln 2 ≈ 0.69`` for a single segment,
40
+ ``≈ 1 - 1/(3K)`` for ``K`` segments. Callers divide their median-based
41
+ floor by the returned factor.
42
+
43
+ ``K_eff = K/1.056`` accounts for the ``ρ = (1/6)²`` power correlation of
44
+ adjacent Hann half-overlapped segments (variance factor ``1 + 2ρ``); the
45
+ χ² median uses the Wilson-Hilferty approximation
46
+ ``median(χ²_ν) ≈ ν·(1 - 2/(9ν))³`` (< 1.3 % error even at ``ν = 2``).
47
+
48
+ Parameters
49
+ ----------
50
+ n_samples : int
51
+ Length of the sequence handed to Welch (for ``fm_noise_psd`` this is
52
+ the phase length minus one - the first difference).
53
+ nperseg : int
54
+ Welch segment length actually used.
55
+
56
+ Returns
57
+ -------
58
+ factor : float
59
+ ``median/mean`` ratio in ``(0, 1]``.
60
+ n_segments : int
61
+ Welch segment count ``K``.
62
+ """
63
+ step = max(nperseg // 2, 1)
64
+ k = max((int(n_samples) - int(nperseg)) // step + 1, 1)
65
+ k_eff = k / 1.056 if k > 1 else float(k)
66
+ nu = 2.0 * k_eff
67
+ return (1.0 - 2.0 / (9.0 * nu)) ** 3, k
68
+
69
+
70
+ def _plateau_mask(
71
+ f,
72
+ s2,
73
+ base2,
74
+ *,
75
+ points_per_octave=24,
76
+ tolerance=1.8,
77
+ min_cells=6,
78
+ max_rel_iqr=2.0,
79
+ ):
80
+ """Auto-detect the white-FM plateau of FM-noise PSDs (level + region).
81
+
82
+ Laser FM-noise PSDs are bathtub-shaped: drift/flicker rise toward DC and
83
+ the detection-noise ``f²`` tail rises toward Nyquist, so the white-FM
84
+ plateau is the **minimum-level region** in between. Detection works on a
85
+ **log-frequency grid**: linearly spaced Welch bins are median-reduced
86
+ into log-spaced cells, the floor is located as the minimum of the
87
+ median-filtered cell levels, the level is re-centered on the median of
88
+ the near-floor cells, and the plateau is every cell within a **two-sided**
89
+ ``tolerance`` factor of that level. The log-uniform weighting is
90
+ essential - a plain median over linear bins is dominated by the highest
91
+ decade, so the slowly-rising ``f²`` tail would bias it high even when it
92
+ is barely above the plateau. The two-sided acceptance keeps the region
93
+ tight: cells already visibly climbing the tail (or dipping below the
94
+ floor) are excluded, not merely down-weighted.
95
+
96
+ A per-cell **dispersion screen** (relative IQR) rejects cells whose bins
97
+ are not statistically homogeneous before the floor search. This guards
98
+ the DSH deconvolution against a slightly wrong delay: at high frequency
99
+ the assumed and true notch combs decorrelate (argument error ``πfΔτ``)
100
+ and the deconvolved PSD develops fake-low/fake-high stripes *within* a
101
+ cell - huge relative IQR - whereas genuine plateau cells carry uniform
102
+ Welch scatter (relative IQR ≲ 1.6 even for a single-average chi²₂).
103
+
104
+ Host-side NumPy by design (plot-sized Welch spectra, one prior transfer).
105
+
106
+ Parameters
107
+ ----------
108
+ f : ndarray, (nf,)
109
+ One-sided frequency axis.
110
+ s2 : ndarray, (C, nf)
111
+ FM-noise PSD per channel (NaN allowed at masked bins).
112
+ base2 : ndarray of bool, (C, nf)
113
+ Eligibility per bin (validity mask and any user fences).
114
+ points_per_octave : int, default 24
115
+ Log-grid density for the cell reduction.
116
+ tolerance : float, default 1.8
117
+ Two-sided cell-acceptance factor around the re-centered plateau
118
+ level (±2.6 dB): wide enough for Welch scatter of sparse low-f
119
+ cells, tight enough that cells visibly on the 1/f or f² slopes are
120
+ excluded.
121
+ min_cells : int, default 6
122
+ Minimum accepted cells; channels with fewer return an all-False row
123
+ and NaN level (caller falls back and warns).
124
+ max_rel_iqr : float, default 2.0
125
+ Dispersion screen: cells with ``IQR/median`` above this (evaluated
126
+ for cells holding ≥ 5 bins) are excluded from the floor search and
127
+ the plateau.
128
+
129
+ Returns
130
+ -------
131
+ used2 : ndarray of bool, (C, nf)
132
+ Linear bins belonging to accepted cells (all-False where detection
133
+ failed) - the display/consistency mask.
134
+ levels : ndarray, (C,)
135
+ Plateau level per channel: median of accepted cell levels (NaN where
136
+ detection failed). ``Δν = π · level``, after the caller divides out
137
+ the χ²-median bias of Welch bins (``_welch_median_bias``).
138
+ """
139
+ used2 = np.zeros_like(base2, dtype=bool)
140
+ levels = np.full(base2.shape[0], np.nan)
141
+
142
+ for c in range(base2.shape[0]):
143
+ el = base2[c] & np.isfinite(s2[c]) & (f > 0)
144
+ idx_el = np.flatnonzero(el)
145
+ if idx_el.size < min_cells:
146
+ continue
147
+ fe, se = f[idx_el], s2[c][idx_el]
148
+
149
+ n_cells = max(int(np.ceil(np.log2(fe[-1] / fe[0]) * points_per_octave)), 1)
150
+ edges = np.geomspace(fe[0], fe[-1], n_cells + 1)
151
+ cell_of = np.clip(np.searchsorted(edges, fe, side="right") - 1, 0, n_cells - 1)
152
+ occupied = np.unique(cell_of)
153
+
154
+ cell_med = np.empty(occupied.size)
155
+ homogeneous = np.ones(occupied.size, dtype=bool)
156
+ for i, k in enumerate(occupied):
157
+ vals = se[cell_of == k]
158
+ q1, q2, q3 = np.percentile(vals, (25.0, 50.0, 75.0))
159
+ cell_med[i] = q2
160
+ if vals.size >= 5 and q2 > 0.0:
161
+ homogeneous[i] = (q3 - q1) / q2 <= max_rel_iqr
162
+
163
+ occ_h, med_h = occupied[homogeneous], cell_med[homogeneous]
164
+ if med_h.size < min_cells or not (med_h > 0.0).all():
165
+ continue
166
+
167
+ # Robust floor: min of the median-filtered cell levels, so a single
168
+ # low-outlier cell (e.g. one noisy bin alone in a cell) cannot set it.
169
+ w = min(5, med_h.size)
170
+ filt = np.array(
171
+ [
172
+ np.median(med_h[max(0, i - w // 2) : i + w // 2 + 1])
173
+ for i in range(med_h.size)
174
+ ]
175
+ )
176
+ floor = float(filt.min())
177
+
178
+ # Re-center: the min is biased low by scatter; the plateau level is
179
+ # the median of the near-floor cells.
180
+ near = med_h <= 2.0 * floor
181
+ if int(near.sum()) < min_cells:
182
+ continue
183
+ level0 = float(np.median(med_h[near]))
184
+
185
+ # Adaptive two-sided acceptance: a cell joins the plateau only if its
186
+ # level is consistent with level0 *given its own standard error*
187
+ # (median SE ≈ 1.2533·σ_bin/√n). Densely populated cells get a tight
188
+ # gate (floor ±0.6 dB) that cuts the f² tail where it visibly departs
189
+ # from the plateau; sparse low-frequency cells keep a wide gate up to
190
+ # ``tolerance`` so genuine Welch scatter is not speckled out.
191
+ n_bins = np.array([int((cell_of == k).sum()) for k in occ_h])
192
+ rich = near & (n_bins >= 8)
193
+ if rich.any():
194
+ iqr_rel = np.array(
195
+ [
196
+ float(np.subtract(*np.percentile(se[cell_of == k], (75.0, 25.0))))
197
+ / med
198
+ for k, med in zip(occ_h[rich], med_h[rich])
199
+ ]
200
+ )
201
+ sigma_bin = float(np.median(iqr_rel)) / 1.349
202
+ else:
203
+ sigma_bin = 0.5 # sparse everywhere: fall back to wide gates
204
+ sem = 1.2533 * sigma_bin / np.sqrt(n_bins)
205
+ tol_log = np.clip(3.0 * sem, 0.15, np.log(tolerance))
206
+ accepted = np.abs(np.log(med_h / level0)) <= tol_log
207
+ if int(accepted.sum()) < min_cells:
208
+ continue
209
+
210
+ # Keep the largest connected accepted region (gaps of up to 6 cells -
211
+ # notch dropouts - are bridged) so isolated outlier cells far from
212
+ # the plateau cannot stretch the reported band.
213
+ idx_acc = np.flatnonzero(accepted)
214
+ splits = np.flatnonzero(np.diff(idx_acc) > 6) + 1
215
+ clusters = np.split(idx_acc, splits)
216
+ best = max(clusters, key=len)
217
+ if best.size < min_cells:
218
+ continue
219
+ final = np.zeros_like(accepted)
220
+ final[best] = True
221
+
222
+ levels[c] = float(np.median(med_h[final]))
223
+ used2[c, idx_el[np.isin(cell_of, occ_h[final])]] = True
224
+
225
+ return used2, levels
226
+
227
+
228
+ def _pairing_variance(y2, d2, xp):
229
+ """Total wrapped phase-error increment variance for a channel pairing.
230
+
231
+ Returns a 0-d array on the input backend (no host sync); the caller
232
+ compares the candidate pairings and pays a single device->host transfer
233
+ for the final boolean decision.
234
+ """
235
+ pe = xp.angle(y2 * xp.conj(d2)).astype(xp.float64)
236
+ return xp.sum(xp.var(xp.diff(pe, axis=-1), axis=-1))
@@ -0,0 +1,108 @@
1
+ """Overlapping Allan deviation of an instantaneous-frequency series."""
2
+
3
+ import numpy as np
4
+
5
+ from ..backend import ArrayType, dispatch, to_device
6
+ from ._common import _as_2d
7
+
8
+ __all__ = ["allan_deviation"]
9
+
10
+
11
+ def allan_deviation(
12
+ df: ArrayType,
13
+ symbol_rate: float,
14
+ *,
15
+ taus: np.ndarray | None = None,
16
+ n_taus: int = 30,
17
+ debug_plot: bool = False,
18
+ ) -> dict[str, np.ndarray]:
19
+ r"""Overlapping Allan deviation of an instantaneous-frequency series.
20
+
21
+ The log-log slope of sigma_y(tau) classifies the dominant noise
22
+ process by averaging time: white-FM proportional to tau^(-1/2),
23
+ flicker-FM proportional to tau^0, random-walk-FM proportional to tau^(+1/2),
24
+ linear drift proportional to tau^(+1).
25
+
26
+ Parameters
27
+ ----------
28
+ df : array_like
29
+ Instantaneous frequency samples in Hz (e.g. ``frequency_drift_metrics``
30
+ ``df``), ``(N,)`` or ``(C, N)``, sampled at ``symbol_rate``.
31
+ symbol_rate : float
32
+ Sample rate of ``df`` in Hz (``τ_0 = 1/symbol_rate``).
33
+ taus : array_like, optional
34
+ Explicit averaging times in seconds. Default: ``n_taus`` values
35
+ geometrically spaced from ``τ_0`` to ``N//4·τ_0``.
36
+ n_taus : int, default 30
37
+ Number of log-spaced averaging times when ``taus`` is None.
38
+ debug_plot : bool, default False
39
+ If True, plot the Allan deviation
40
+ (``allan_deviation``).
41
+
42
+ Returns
43
+ -------
44
+ dict
45
+ ``{'tau_s', 'adev'}`` where ``adev`` is ``(n_tau,)`` (SISO) or
46
+ ``(C, n_tau)`` (MIMO). NumPy arrays.
47
+
48
+ Notes
49
+ -----
50
+ **Limitations.**
51
+
52
+ * ``df`` is in **Hz**, not the dimensionless fractional frequency
53
+ ``y = δf/ν₀`` of clock metrology; ``adev`` is therefore in Hz. Divide
54
+ by the optical carrier frequency (~193 THz) to compare against
55
+ oscillator specs quoted fractionally.
56
+ * The overlapping estimator improves confidence but the largest-τ points
57
+ still average only ``~N/(2m)`` second differences - the last decade of
58
+ the curve is statistically fragile (hence the ``N//4`` default cap).
59
+ No confidence intervals are computed.
60
+ * ADEV cannot distinguish white-PM from flicker-PM noise (both fall as
61
+ ``~τ⁻¹`` after the frequency differencing); the modified Allan deviation
62
+ would be needed for that. For laser work this matters at small τ where
63
+ AWGN angle noise (white-PM) dominates.
64
+ * A deterministic sinusoidal wander of period ``T_m`` produces the classic
65
+ scalloped ADEV with nulls at ``τ = k·T_m`` - do not read those dips as
66
+ noise-floor improvements.
67
+ """
68
+ df_arr, xp, _ = dispatch(df)
69
+ y2, was_1d = _as_2d(df_arr)
70
+ c, n = y2.shape
71
+ tau0 = 1.0 / float(symbol_rate)
72
+
73
+ # Cumulative phase (time error) x_i = Σ y · τ0.
74
+ zeros_col = xp.zeros((c, 1), dtype=xp.float64)
75
+ x = xp.concatenate(
76
+ [zeros_col, xp.cumsum(y2.astype(xp.float64), axis=-1) * tau0], axis=-1
77
+ )
78
+
79
+ if taus is None:
80
+ m_max = max(1, n // 4)
81
+ ms = np.unique(np.round(np.geomspace(1, m_max, n_taus)).astype(int))
82
+ else:
83
+ taus_cpu = np.asarray(to_device(taus, "cpu"), dtype=np.float64)
84
+ ms = np.unique(np.maximum(1, np.round(taus_cpu / tau0).astype(int)))
85
+ ms = ms[ms <= max(1, (x.shape[-1] - 1) // 2)]
86
+
87
+ tau_s = ms * tau0
88
+ adev = xp.full((c, ms.size), xp.nan, dtype=xp.float64)
89
+ # One vectorized second-difference per τ across all channels (the C-loop
90
+ # would launch C·n_tau tiny kernels on GPU for no benefit).
91
+ for j, m in enumerate(ms):
92
+ m = int(m)
93
+ if x.shape[-1] - 2 * m < 1:
94
+ continue
95
+ d2 = x[:, 2 * m :] - 2.0 * x[:, m:-m] + x[:, : -2 * m]
96
+ avar = xp.mean(d2**2, axis=-1) / (2.0 * (m * tau0) ** 2)
97
+ adev[:, j] = xp.sqrt(avar)
98
+
99
+ tau_s_cpu = np.asarray(tau_s, dtype=np.float64)
100
+ adev_cpu = to_device(adev, "cpu")
101
+ adev_out = adev_cpu[0] if was_1d else adev_cpu
102
+
103
+ if debug_plot:
104
+ from .. import plotting as _plotting
105
+
106
+ _plotting.plot_allan_deviation(tau_s_cpu, adev_out, show=True)
107
+
108
+ return {"tau_s": tau_s_cpu, "adev": adev_out}