pyturb 0.2.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.
- pyturb/__init__.py +97 -0
- pyturb/_accel.py +175 -0
- pyturb/analysis.py +246 -0
- pyturb/atmosphere.py +1103 -0
- pyturb/backend.py +121 -0
- pyturb/benchmark.py +77 -0
- pyturb/extrude.py +725 -0
- pyturb/flow.py +139 -0
- pyturb/fourier.py +296 -0
- pyturb/infinite.py +387 -0
- pyturb/io.py +145 -0
- pyturb/profiles.py +553 -0
- pyturb/py.typed +0 -0
- pyturb/utils.py +183 -0
- pyturb-0.2.0.dist-info/METADATA +146 -0
- pyturb-0.2.0.dist-info/RECORD +18 -0
- pyturb-0.2.0.dist-info/WHEEL +4 -0
- pyturb-0.2.0.dist-info/licenses/LICENSE +21 -0
pyturb/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""pyturb — fast, GPU-optional atmospheric phase screens for adaptive optics.
|
|
2
|
+
|
|
3
|
+
Three levels of API cover the common AO simulation needs:
|
|
4
|
+
|
|
5
|
+
- :class:`Atmosphere` — a full layered atmosphere: many turbulent layers with
|
|
6
|
+
per-layer wind summed into pupil OPD, with frozen-flow time evolution,
|
|
7
|
+
off-axis directions, and standard site profiles. This is the high-level
|
|
8
|
+
entry point most users want.
|
|
9
|
+
- :class:`PhaseScreen` — statistically independent Kolmogorov / von Kármán
|
|
10
|
+
screens via the FFT method with subharmonic low-frequency correction.
|
|
11
|
+
- :class:`InfinitePhaseScreen` — an endless frozen-flow screen extruded row
|
|
12
|
+
by row (Assémat & Wilson 2006) for closed-loop temporal simulation.
|
|
13
|
+
|
|
14
|
+
Pass ``device="gpu"`` to any of them to run on CUDA via CuPy. Phase screens
|
|
15
|
+
are returned in radians; :class:`Atmosphere` returns OPD in metres (achromatic)
|
|
16
|
+
unless a ``wavelength`` is given.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import PackageNotFoundError
|
|
20
|
+
from importlib.metadata import version as _pkg_version
|
|
21
|
+
|
|
22
|
+
from . import analysis
|
|
23
|
+
from .analysis import zernike_basis, zernike_decompose
|
|
24
|
+
from .atmosphere import Atmosphere, PeriodicWrapWarning
|
|
25
|
+
from .backend import get_array_module, get_fft_workers, set_fft_workers, to_numpy
|
|
26
|
+
from .benchmark import benchmark
|
|
27
|
+
from .flow import FourierFlowScreen
|
|
28
|
+
from .fourier import PhaseScreen
|
|
29
|
+
from .infinite import InfinitePhaseScreen, phase_covariance
|
|
30
|
+
from .io import load, save
|
|
31
|
+
from .profiles import (
|
|
32
|
+
Layer,
|
|
33
|
+
bufton_wind,
|
|
34
|
+
coherence_time,
|
|
35
|
+
discretize_cn2,
|
|
36
|
+
effective_wind_speed,
|
|
37
|
+
get_profile,
|
|
38
|
+
greenwood_frequency,
|
|
39
|
+
hufnagel_valley,
|
|
40
|
+
isoplanatic_angle,
|
|
41
|
+
list_profiles,
|
|
42
|
+
mean_turbulence_height,
|
|
43
|
+
)
|
|
44
|
+
from .utils import (
|
|
45
|
+
air_refractivity,
|
|
46
|
+
opd_to_phase,
|
|
47
|
+
phase_to_opd,
|
|
48
|
+
r0_at_wavelength,
|
|
49
|
+
r0_from_seeing,
|
|
50
|
+
seeing_from_r0,
|
|
51
|
+
structure_function,
|
|
52
|
+
water_vapour_refractivity,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
try: # single source of truth is the installed package metadata (pyproject.toml)
|
|
56
|
+
__version__ = _pkg_version("pyturb")
|
|
57
|
+
except PackageNotFoundError: # not installed (e.g. running from a source tree)
|
|
58
|
+
__version__ = "0.0.0+unknown"
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"Atmosphere",
|
|
62
|
+
"PeriodicWrapWarning",
|
|
63
|
+
"Layer",
|
|
64
|
+
"PhaseScreen",
|
|
65
|
+
"InfinitePhaseScreen",
|
|
66
|
+
"FourierFlowScreen",
|
|
67
|
+
"phase_covariance",
|
|
68
|
+
"structure_function",
|
|
69
|
+
"get_profile",
|
|
70
|
+
"list_profiles",
|
|
71
|
+
"hufnagel_valley",
|
|
72
|
+
"bufton_wind",
|
|
73
|
+
"discretize_cn2",
|
|
74
|
+
"isoplanatic_angle",
|
|
75
|
+
"coherence_time",
|
|
76
|
+
"greenwood_frequency",
|
|
77
|
+
"mean_turbulence_height",
|
|
78
|
+
"effective_wind_speed",
|
|
79
|
+
"r0_from_seeing",
|
|
80
|
+
"seeing_from_r0",
|
|
81
|
+
"r0_at_wavelength",
|
|
82
|
+
"opd_to_phase",
|
|
83
|
+
"phase_to_opd",
|
|
84
|
+
"air_refractivity",
|
|
85
|
+
"water_vapour_refractivity",
|
|
86
|
+
"save",
|
|
87
|
+
"load",
|
|
88
|
+
"analysis",
|
|
89
|
+
"zernike_basis",
|
|
90
|
+
"zernike_decompose",
|
|
91
|
+
"benchmark",
|
|
92
|
+
"to_numpy",
|
|
93
|
+
"get_array_module",
|
|
94
|
+
"set_fft_workers",
|
|
95
|
+
"get_fft_workers",
|
|
96
|
+
"__version__",
|
|
97
|
+
]
|
pyturb/_accel.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Optional Numba-accelerated CPU kernels (transparent, with NumPy fallback).
|
|
2
|
+
|
|
3
|
+
The CPU hot paths — the spectral engine's per-frame layer sum and the
|
|
4
|
+
extruder's per-frame bicubic readout — are memory-bandwidth bound in plain
|
|
5
|
+
NumPy because each fuses several broadcast multiplies into large ``(L, n, n)``
|
|
6
|
+
temporaries. A single fused, ``prange``-parallel pass over the data does the
|
|
7
|
+
same arithmetic reading the inputs once and writing the output once, across all
|
|
8
|
+
cores. These mirror the GPU's fused kernels so both backends do the same work
|
|
9
|
+
per frame.
|
|
10
|
+
|
|
11
|
+
Numba is an *optional* dependency: if it is not importable, :data:`HAVE_NUMBA`
|
|
12
|
+
is ``False`` and callers fall back to the NumPy expressions. Nothing here
|
|
13
|
+
changes results beyond float round-off (the fused reductions accumulate in the
|
|
14
|
+
input dtype for the spectral sum and in double for the readout, matching each
|
|
15
|
+
NumPy path).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from importlib.util import find_spec
|
|
21
|
+
|
|
22
|
+
HAVE_NUMBA = find_spec("numba") is not None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if HAVE_NUMBA:
|
|
26
|
+
import numpy as np
|
|
27
|
+
from numba import njit, prange
|
|
28
|
+
|
|
29
|
+
@njit(fastmath=True, cache=True)
|
|
30
|
+
def spectral_layer_sum(spectra, px, py, out): # pragma: no cover - jit
|
|
31
|
+
"""``out[i,j] = sum_l spectra[l,i,j] * px[l,i] * py[l,j]``.
|
|
32
|
+
|
|
33
|
+
The separable frozen-flow shift and layer sum in one fused pass: the
|
|
34
|
+
``(L, n, n)`` spectrum stack is read once and the ``(n, n)`` shifted sum
|
|
35
|
+
written once, instead of materialising two ``(L, n, n)`` complex
|
|
36
|
+
products and reducing them.
|
|
37
|
+
|
|
38
|
+
Kept single-threaded on purpose: reducing a power-of-two-sized complex
|
|
39
|
+
stack over the leading (layer) axis strides the inner loop by a whole
|
|
40
|
+
2-D plane, so a ``prange`` over pixels hits cache-set aliasing exactly
|
|
41
|
+
at the power-of-two grids AO uses (256/512/1024). The serial fused pass
|
|
42
|
+
is a robust, monotonic win over NumPy at every size; the far larger
|
|
43
|
+
parallel win goes to the extruder readout, whose gather is
|
|
44
|
+
data-dependent and does not alias.
|
|
45
|
+
"""
|
|
46
|
+
n_layers, n_rows, n_cols = spectra.shape
|
|
47
|
+
for i in range(n_rows):
|
|
48
|
+
for j in range(n_cols):
|
|
49
|
+
acc = spectra[0, i, j] * px[0, i] * py[0, j]
|
|
50
|
+
for lyr in range(1, n_layers):
|
|
51
|
+
acc += spectra[lyr, i, j] * px[lyr, i] * py[lyr, j]
|
|
52
|
+
out[i, j] = acc
|
|
53
|
+
|
|
54
|
+
@njit(parallel=True, fastmath=True, cache=True)
|
|
55
|
+
def extrude_cubic_readout(buf, along, perp, sa, sp, fillm1, out): # pragma: no cover
|
|
56
|
+
"""Fused multi-layer Catmull-Rom pupil readout, summed over layers.
|
|
57
|
+
|
|
58
|
+
``buf`` is the ``(L, cap, W)`` ring-buffer stack; ``along``/``perp`` are
|
|
59
|
+
the ``(L, n, n)`` rotated pupil grids; ``sa``/``sp`` the per-layer
|
|
60
|
+
along/perp readout shifts; ``fillm1`` the per-layer last valid row.
|
|
61
|
+
Accumulates in double, matching the tap-broadcast gather it replaces.
|
|
62
|
+
"""
|
|
63
|
+
n_layers = buf.shape[0]
|
|
64
|
+
width = buf.shape[2]
|
|
65
|
+
n = out.shape[0]
|
|
66
|
+
for i in prange(n):
|
|
67
|
+
for j in range(n):
|
|
68
|
+
acc = 0.0
|
|
69
|
+
for lyr in range(n_layers):
|
|
70
|
+
row = along[lyr, i, j] + sa[lyr]
|
|
71
|
+
col = perp[lyr, i, j] + sp[lyr]
|
|
72
|
+
r0 = int(np.floor(row))
|
|
73
|
+
c0 = int(np.floor(col))
|
|
74
|
+
fr = row - r0
|
|
75
|
+
fc = col - c0
|
|
76
|
+
fr2 = fr * fr
|
|
77
|
+
fr3 = fr2 * fr
|
|
78
|
+
fc2 = fc * fc
|
|
79
|
+
fc3 = fc2 * fc
|
|
80
|
+
wr = (
|
|
81
|
+
0.5 * (-fr + 2.0 * fr2 - fr3),
|
|
82
|
+
0.5 * (2.0 - 5.0 * fr2 + 3.0 * fr3),
|
|
83
|
+
0.5 * (fr + 4.0 * fr2 - 3.0 * fr3),
|
|
84
|
+
0.5 * (-fr2 + fr3),
|
|
85
|
+
)
|
|
86
|
+
wc = (
|
|
87
|
+
0.5 * (-fc + 2.0 * fc2 - fc3),
|
|
88
|
+
0.5 * (2.0 - 5.0 * fc2 + 3.0 * fc3),
|
|
89
|
+
0.5 * (fc + 4.0 * fc2 - 3.0 * fc3),
|
|
90
|
+
0.5 * (-fc2 + fc3),
|
|
91
|
+
)
|
|
92
|
+
fmax = fillm1[lyr]
|
|
93
|
+
val = 0.0
|
|
94
|
+
for a in range(4):
|
|
95
|
+
rr = r0 + (a - 1)
|
|
96
|
+
if rr < 0:
|
|
97
|
+
rr = 0
|
|
98
|
+
elif rr > fmax:
|
|
99
|
+
rr = fmax
|
|
100
|
+
rs = 0.0
|
|
101
|
+
for b in range(4):
|
|
102
|
+
cc = c0 + (b - 1)
|
|
103
|
+
if cc < 0:
|
|
104
|
+
cc = 0
|
|
105
|
+
elif cc > width - 1:
|
|
106
|
+
cc = width - 1
|
|
107
|
+
rs += wc[b] * buf[lyr, rr, cc]
|
|
108
|
+
val += wr[a] * rs
|
|
109
|
+
acc += val
|
|
110
|
+
out[i, j] = acc
|
|
111
|
+
|
|
112
|
+
@njit(fastmath=True, inline="always")
|
|
113
|
+
def _sincpi(x): # pragma: no cover - jit helper
|
|
114
|
+
if x == 0.0:
|
|
115
|
+
return 1.0
|
|
116
|
+
px = 3.141592653589793 * x
|
|
117
|
+
return np.sin(px) / px
|
|
118
|
+
|
|
119
|
+
@njit(parallel=True, fastmath=True, cache=True)
|
|
120
|
+
def extrude_lanczos_readout(buf, along, perp, sa, sp, fillm1, out): # noqa: E501 pragma: no cover
|
|
121
|
+
"""Fused multi-layer Lanczos-3 (6-tap) pupil readout, summed over layers.
|
|
122
|
+
|
|
123
|
+
The higher-fidelity counterpart of :func:`extrude_cubic_readout`: a
|
|
124
|
+
flatter sub-Nyquist windowed-sinc kernel (6 taps per axis) that halves
|
|
125
|
+
the extruder's finest-scale structure-function deficit. Accumulates in
|
|
126
|
+
double, matching the tap-broadcast Lanczos gather it replaces.
|
|
127
|
+
"""
|
|
128
|
+
n_layers = buf.shape[0]
|
|
129
|
+
width = buf.shape[2]
|
|
130
|
+
n = out.shape[0]
|
|
131
|
+
for i in prange(n):
|
|
132
|
+
for j in range(n):
|
|
133
|
+
acc = 0.0
|
|
134
|
+
for lyr in range(n_layers):
|
|
135
|
+
row = along[lyr, i, j] + sa[lyr]
|
|
136
|
+
col = perp[lyr, i, j] + sp[lyr]
|
|
137
|
+
r0 = int(np.floor(row))
|
|
138
|
+
c0 = int(np.floor(col))
|
|
139
|
+
tr = row - r0
|
|
140
|
+
tc = col - c0
|
|
141
|
+
wr0 = _sincpi(tr + 2.0) * _sincpi((tr + 2.0) / 3.0)
|
|
142
|
+
wr1 = _sincpi(tr + 1.0) * _sincpi((tr + 1.0) / 3.0)
|
|
143
|
+
wr2 = _sincpi(tr) * _sincpi(tr / 3.0)
|
|
144
|
+
wr3 = _sincpi(tr - 1.0) * _sincpi((tr - 1.0) / 3.0)
|
|
145
|
+
wr4 = _sincpi(tr - 2.0) * _sincpi((tr - 2.0) / 3.0)
|
|
146
|
+
wr5 = _sincpi(tr - 3.0) * _sincpi((tr - 3.0) / 3.0)
|
|
147
|
+
sr = wr0 + wr1 + wr2 + wr3 + wr4 + wr5
|
|
148
|
+
wr = (wr0 / sr, wr1 / sr, wr2 / sr, wr3 / sr, wr4 / sr, wr5 / sr)
|
|
149
|
+
wc0 = _sincpi(tc + 2.0) * _sincpi((tc + 2.0) / 3.0)
|
|
150
|
+
wc1 = _sincpi(tc + 1.0) * _sincpi((tc + 1.0) / 3.0)
|
|
151
|
+
wc2 = _sincpi(tc) * _sincpi(tc / 3.0)
|
|
152
|
+
wc3 = _sincpi(tc - 1.0) * _sincpi((tc - 1.0) / 3.0)
|
|
153
|
+
wc4 = _sincpi(tc - 2.0) * _sincpi((tc - 2.0) / 3.0)
|
|
154
|
+
wc5 = _sincpi(tc - 3.0) * _sincpi((tc - 3.0) / 3.0)
|
|
155
|
+
sc = wc0 + wc1 + wc2 + wc3 + wc4 + wc5
|
|
156
|
+
wc = (wc0 / sc, wc1 / sc, wc2 / sc, wc3 / sc, wc4 / sc, wc5 / sc)
|
|
157
|
+
fmax = fillm1[lyr]
|
|
158
|
+
val = 0.0
|
|
159
|
+
for a in range(6):
|
|
160
|
+
rr = r0 + (a - 2)
|
|
161
|
+
if rr < 0:
|
|
162
|
+
rr = 0
|
|
163
|
+
elif rr > fmax:
|
|
164
|
+
rr = fmax
|
|
165
|
+
rs = 0.0
|
|
166
|
+
for b in range(6):
|
|
167
|
+
cc = c0 + (b - 2)
|
|
168
|
+
if cc < 0:
|
|
169
|
+
cc = 0
|
|
170
|
+
elif cc > width - 1:
|
|
171
|
+
cc = width - 1
|
|
172
|
+
rs += wc[b] * buf[lyr, rr, cc]
|
|
173
|
+
val += wr[a] * rs
|
|
174
|
+
acc += val
|
|
175
|
+
out[i, j] = acc
|
pyturb/analysis.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Analysis and validation utilities: Zernikes, temporal PSDs, decorrelation.
|
|
2
|
+
|
|
3
|
+
These turn a pile of phase screens into the diagnostics AO people actually
|
|
4
|
+
check turbulence against:
|
|
5
|
+
|
|
6
|
+
- :func:`zernike_basis` / :func:`zernike_decompose` — a Noll-ordered Zernike
|
|
7
|
+
basis on a circular pupil and a least-squares projection onto it.
|
|
8
|
+
- :func:`noll_variance` / :func:`noll_residual_variance` — the Kolmogorov
|
|
9
|
+
Zernike-mode variances and post-correction residuals of Noll (1976), the
|
|
10
|
+
textbook thing to validate a decomposition against.
|
|
11
|
+
- :func:`temporal_psd` / :func:`fit_power_law` — a one-sided temporal power
|
|
12
|
+
spectrum and a log-log slope fit (frozen-flow gives power-law regimes).
|
|
13
|
+
- :func:`differential_variance` — angular (anisoplanatism) decorrelation.
|
|
14
|
+
|
|
15
|
+
Everything works on NumPy or CuPy input (device arrays are brought to the host
|
|
16
|
+
for the reductions). Reference: Noll, R. J. (1976), JOSA 66, 207.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from math import factorial
|
|
22
|
+
from typing import Optional, Tuple
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
from numpy.typing import ArrayLike
|
|
26
|
+
|
|
27
|
+
from .backend import to_numpy
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"noll_to_zernike",
|
|
31
|
+
"zernike_basis",
|
|
32
|
+
"zernike_decompose",
|
|
33
|
+
"noll_variance",
|
|
34
|
+
"noll_residual_variance",
|
|
35
|
+
"temporal_psd",
|
|
36
|
+
"fit_power_law",
|
|
37
|
+
"differential_variance",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Noll (1976) Table IV: residual wavefront variance after perfectly correcting
|
|
41
|
+
# the first J Zernike modes, in units of (D/r0)^{5/3} rad^2. Delta[1] is the
|
|
42
|
+
# residual after removing piston (i.e. the total minus piston).
|
|
43
|
+
_NOLL_RESIDUAL = {
|
|
44
|
+
1: 1.0299, 2: 0.582, 3: 0.134, 4: 0.111, 5: 0.0880, 6: 0.0648,
|
|
45
|
+
7: 0.0587, 8: 0.0525, 9: 0.0463, 10: 0.0401, 11: 0.0377, 12: 0.0352,
|
|
46
|
+
13: 0.0328, 14: 0.0304, 15: 0.0279, 16: 0.0267, 17: 0.0255, 18: 0.0243,
|
|
47
|
+
19: 0.0232, 20: 0.0220, 21: 0.0208,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _noll_residual_coeff(j):
|
|
52
|
+
"""Delta_j in (D/r0)^{5/3} units, tabulated then asymptotic (Noll 1976)."""
|
|
53
|
+
if j < 1:
|
|
54
|
+
raise ValueError("Noll index j must be >= 1")
|
|
55
|
+
if j in _NOLL_RESIDUAL:
|
|
56
|
+
return _NOLL_RESIDUAL[j]
|
|
57
|
+
# Large-J asymptote Delta_J ~ 0.2944 J^{-sqrt(3)/2}.
|
|
58
|
+
return 0.2944 * j ** (-np.sqrt(3.0) / 2.0)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def noll_to_zernike(j: int) -> Tuple[int, int]:
|
|
62
|
+
"""Radial/azimuthal orders ``(n, m)`` for Noll single index ``j`` (>= 1)."""
|
|
63
|
+
if j < 1:
|
|
64
|
+
raise ValueError("Noll index j must be >= 1")
|
|
65
|
+
n = 0
|
|
66
|
+
j1 = j - 1
|
|
67
|
+
while j1 > n:
|
|
68
|
+
n += 1
|
|
69
|
+
j1 -= n
|
|
70
|
+
m = (-1) ** j * ((n % 2) + 2 * ((j1 + ((n + 1) % 2)) // 2))
|
|
71
|
+
return n, m
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _radial(n, m, rho):
|
|
75
|
+
m = abs(m)
|
|
76
|
+
out = np.zeros_like(rho)
|
|
77
|
+
for k in range((n - m) // 2 + 1):
|
|
78
|
+
c = ((-1) ** k * factorial(n - k)) / (
|
|
79
|
+
factorial(k)
|
|
80
|
+
* factorial((n + m) // 2 - k)
|
|
81
|
+
* factorial((n - m) // 2 - k)
|
|
82
|
+
)
|
|
83
|
+
out += c * rho ** (n - 2 * k)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def zernike_basis(
|
|
88
|
+
n_modes: int, n_pixels: int, diameter_pixels: Optional[float] = None
|
|
89
|
+
) -> np.ndarray:
|
|
90
|
+
"""Noll-ordered Zernike basis over a circular pupil.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
n_modes : int
|
|
95
|
+
Number of modes, starting at Noll ``j = 1`` (piston).
|
|
96
|
+
n_pixels : int
|
|
97
|
+
Grid size; the returned array is ``(n_modes, n_pixels, n_pixels)``.
|
|
98
|
+
diameter_pixels : float, optional
|
|
99
|
+
Pupil diameter in pixels (default ``n_pixels``). The pupil is the
|
|
100
|
+
inscribed disc; values are 0 outside it.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
basis : ndarray
|
|
105
|
+
``(n_modes, n_pixels, n_pixels)``. Each mode is orthonormalised so that
|
|
106
|
+
its variance over the pupil is 1 (Noll normalisation), and the pupil
|
|
107
|
+
mask is ``basis[0] != 0`` (piston).
|
|
108
|
+
"""
|
|
109
|
+
if n_modes < 1 or n_pixels < 2:
|
|
110
|
+
raise ValueError("n_modes >= 1 and n_pixels >= 2 required")
|
|
111
|
+
radius = (n_pixels if diameter_pixels is None else diameter_pixels) / 2.0
|
|
112
|
+
grid = (np.arange(n_pixels) - (n_pixels - 1) / 2.0) / radius
|
|
113
|
+
xx, yy = np.meshgrid(grid, grid, indexing="ij")
|
|
114
|
+
rho = np.hypot(xx, yy)
|
|
115
|
+
theta = np.arctan2(yy, xx)
|
|
116
|
+
mask = rho <= 1.0
|
|
117
|
+
|
|
118
|
+
basis = np.zeros((n_modes, n_pixels, n_pixels))
|
|
119
|
+
for idx in range(n_modes):
|
|
120
|
+
j = idx + 1
|
|
121
|
+
n, m = noll_to_zernike(j)
|
|
122
|
+
norm = np.sqrt(n + 1.0) * (1.0 if m == 0 else np.sqrt(2.0))
|
|
123
|
+
ang = np.cos(m * theta) if m >= 0 else np.sin(-m * theta)
|
|
124
|
+
z = norm * _radial(n, m, rho) * ang
|
|
125
|
+
basis[idx] = np.where(mask, z, 0.0)
|
|
126
|
+
return basis
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def zernike_decompose(
|
|
130
|
+
phase: ArrayLike, n_modes: int, basis: Optional[np.ndarray] = None
|
|
131
|
+
) -> np.ndarray:
|
|
132
|
+
"""Least-squares Zernike coefficients of ``phase`` over the pupil.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
phase : ndarray
|
|
137
|
+
``(n, n)`` screen or ``(count, n, n)`` stack (NumPy or CuPy).
|
|
138
|
+
n_modes : int
|
|
139
|
+
Number of Noll modes to fit.
|
|
140
|
+
basis : ndarray, optional
|
|
141
|
+
A precomputed :func:`zernike_basis` (reused across many calls to avoid
|
|
142
|
+
rebuilding it). Must match ``n_modes`` and the screen size.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
coeffs : ndarray
|
|
147
|
+
``(n_modes,)`` or ``(count, n_modes)`` coefficients in the same units
|
|
148
|
+
as ``phase``.
|
|
149
|
+
"""
|
|
150
|
+
phase = to_numpy(phase).astype(np.float64)
|
|
151
|
+
single = phase.ndim == 2
|
|
152
|
+
if single:
|
|
153
|
+
phase = phase[None]
|
|
154
|
+
n_pixels = phase.shape[-1]
|
|
155
|
+
if basis is None:
|
|
156
|
+
basis = zernike_basis(n_modes, n_pixels)
|
|
157
|
+
mask = basis[0] != 0
|
|
158
|
+
design = basis[:, mask].T # (n_pupil, n_modes)
|
|
159
|
+
data = phase[:, mask].T # (n_pupil, count)
|
|
160
|
+
coeffs, *_ = np.linalg.lstsq(design, data, rcond=None)
|
|
161
|
+
coeffs = coeffs.T # (count, n_modes)
|
|
162
|
+
return coeffs[0] if single else coeffs
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def noll_variance(j: int, diameter: float, r0: float) -> float:
|
|
166
|
+
"""Kolmogorov variance of Zernike mode ``j`` [rad^2] (Noll 1976).
|
|
167
|
+
|
|
168
|
+
The per-mode variance is ``Delta_{j-1} - Delta_j`` in ``(D/r0)^{5/3}``
|
|
169
|
+
units. ``j = 1`` (piston) has no finite variance, so ``j >= 2``.
|
|
170
|
+
"""
|
|
171
|
+
if j < 2:
|
|
172
|
+
raise ValueError("piston (j=1) has no finite variance; use j >= 2")
|
|
173
|
+
coeff = _noll_residual_coeff(j - 1) - _noll_residual_coeff(j)
|
|
174
|
+
return coeff * (diameter / r0) ** (5.0 / 3.0)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def noll_residual_variance(j: int, diameter: float, r0: float) -> float:
|
|
178
|
+
"""Residual wavefront variance [rad^2] after correcting the first ``j``
|
|
179
|
+
Zernike modes (Noll 1976)."""
|
|
180
|
+
return _noll_residual_coeff(j) * (diameter / r0) ** (5.0 / 3.0)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def temporal_psd(series: ArrayLike, dt: float) -> Tuple[np.ndarray, np.ndarray]:
|
|
184
|
+
"""One-sided temporal power spectral density of a time series.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
series : array_like
|
|
189
|
+
Samples along the **last** axis (e.g. a pupil pixel or a Zernike
|
|
190
|
+
coefficient over frames); leading axes are treated as independent
|
|
191
|
+
series and averaged.
|
|
192
|
+
dt : float
|
|
193
|
+
Sample spacing [s].
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
freq, psd : ndarray
|
|
198
|
+
Positive frequencies [Hz] (excluding DC) and the averaged PSD, scaled
|
|
199
|
+
so that ``sum(psd) * df`` approximates the series variance.
|
|
200
|
+
"""
|
|
201
|
+
series = to_numpy(series).astype(np.float64)
|
|
202
|
+
series = series - series.mean(axis=-1, keepdims=True)
|
|
203
|
+
n = series.shape[-1]
|
|
204
|
+
spectrum = np.fft.rfft(series, axis=-1)
|
|
205
|
+
psd = (np.abs(spectrum) ** 2) * (2.0 * dt / n)
|
|
206
|
+
psd = psd.reshape(-1, psd.shape[-1]).mean(axis=0)
|
|
207
|
+
freq = np.fft.rfftfreq(n, d=dt)
|
|
208
|
+
return freq[1:], psd[1:]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def fit_power_law(
|
|
212
|
+
freq: ArrayLike,
|
|
213
|
+
psd: ArrayLike,
|
|
214
|
+
fmin: Optional[float] = None,
|
|
215
|
+
fmax: Optional[float] = None,
|
|
216
|
+
) -> Tuple[float, float]:
|
|
217
|
+
"""Fit ``psd ~ freq**slope`` over ``[fmin, fmax]`` (log-log least squares).
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
slope, amplitude : float
|
|
222
|
+
Such that ``psd ≈ amplitude * freq**slope`` in the band.
|
|
223
|
+
"""
|
|
224
|
+
freq = np.asarray(freq, dtype=np.float64)
|
|
225
|
+
psd = np.asarray(psd, dtype=np.float64)
|
|
226
|
+
band = np.ones(freq.shape, dtype=bool)
|
|
227
|
+
if fmin is not None:
|
|
228
|
+
band &= freq >= fmin
|
|
229
|
+
if fmax is not None:
|
|
230
|
+
band &= freq <= fmax
|
|
231
|
+
band &= psd > 0
|
|
232
|
+
if band.sum() < 2:
|
|
233
|
+
raise ValueError("need at least two positive points in the band")
|
|
234
|
+
slope, intercept = np.polyfit(np.log(freq[band]), np.log(psd[band]), 1)
|
|
235
|
+
return float(slope), float(np.exp(intercept))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def differential_variance(reference: ArrayLike, other: ArrayLike) -> float:
|
|
239
|
+
"""Variance of ``other - reference`` [same units squared].
|
|
240
|
+
|
|
241
|
+
With ``reference`` the on-axis OPD/phase and ``other`` an off-axis one, this
|
|
242
|
+
is the angular (anisoplanatism) error; it grows as ``(theta/theta0)^{5/3}``
|
|
243
|
+
and reaches ~1 rad^2 at the isoplanatic angle.
|
|
244
|
+
"""
|
|
245
|
+
diff = to_numpy(other).astype(np.float64) - to_numpy(reference).astype(np.float64)
|
|
246
|
+
return float(np.var(diff))
|