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