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 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))