solvephase 0.1.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.
- solvephase/__about__.py +3 -0
- solvephase/__init__.py +110 -0
- solvephase/algorithms/__init__.py +4 -0
- solvephase/algorithms/cdi.py +967 -0
- solvephase/algorithms/fast_furious.py +494 -0
- solvephase/algorithms/gerchberg_saxton.py +255 -0
- solvephase/algorithms/lift.py +553 -0
- solvephase/algorithms/phase_diversity.py +658 -0
- solvephase/algorithms/tie.py +693 -0
- solvephase/algorithms/wirtinger.py +865 -0
- solvephase/api.py +228 -0
- solvephase/backend.py +24 -0
- solvephase/basis.py +516 -0
- solvephase/cli.py +131 -0
- solvephase/focal.py +367 -0
- solvephase/interop.py +173 -0
- solvephase/losses.py +169 -0
- solvephase/metrics.py +5 -0
- solvephase/operators.py +391 -0
- solvephase/optimize.py +404 -0
- solvephase/propagation.py +26 -0
- solvephase/pupil.py +5 -0
- solvephase/py.typed +0 -0
- solvephase/result.py +228 -0
- solvephase/retrieval.py +627 -0
- solvephase/simulate.py +113 -0
- solvephase/unwrap.py +5 -0
- solvephase-0.1.0.dist-info/METADATA +198 -0
- solvephase-0.1.0.dist-info/RECORD +32 -0
- solvephase-0.1.0.dist-info/WHEEL +4 -0
- solvephase-0.1.0.dist-info/entry_points.txt +2 -0
- solvephase-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Fast & Furious: sequential small-phase focal-plane wavefront sensing.
|
|
2
|
+
|
|
3
|
+
Fast & Furious (F&F; Keller et al., Proc. SPIE 8447, 2012; Korkiakoski et
|
|
4
|
+
al., Appl. Opt. 53, 4565, 2014; on sky: Bos et al., A&A 639, A52, 2020)
|
|
5
|
+
measures a pupil-plane wavefront from focal-plane images in a closed loop with
|
|
6
|
+
a deformable mirror (DM). It builds on the weak-phase solution of Gonsalves
|
|
7
|
+
(Opt. Lett. 26, 684, 2001).
|
|
8
|
+
|
|
9
|
+
For a weak phase ``phi = phi_e + phi_o`` (even and odd parts under
|
|
10
|
+
``x -> -x``) and a real, centro-symmetric pupil amplitude ``A``, the focal
|
|
11
|
+
field is ``E = a + i v - y`` to first order, with ``a = F{A}``,
|
|
12
|
+
``v = F{A phi_e}`` (real, even) and ``i y = F{A phi_o}`` (``y`` real, odd), so
|
|
13
|
+
the image is
|
|
14
|
+
|
|
15
|
+
p = a^2 + v^2 + y^2 - 2 a y.
|
|
16
|
+
|
|
17
|
+
* Its odd part ``p_o = -2 a y`` gives the odd phase directly.
|
|
18
|
+
* Its even part ``p_e = a^2 + v^2 + y^2`` gives ``|v|`` but not its sign.
|
|
19
|
+
* The sign comes from the previous image, taken with a known phase
|
|
20
|
+
difference ``phi_d`` (the DM change between the two frames):
|
|
21
|
+
``p_e,prev - p_e = 2 v v_d + v_d^2 + 2 y y_d + y_d^2``.
|
|
22
|
+
|
|
23
|
+
The sign is taken from that difference and the magnitude from the current
|
|
24
|
+
image alone (Korkiakoski et al. 2014, Eq. 19), which keeps the noise of the
|
|
25
|
+
two-image difference out of the estimate.
|
|
26
|
+
|
|
27
|
+
Implemented with the modifications of Korkiakoski et al. (2014):
|
|
28
|
+
|
|
29
|
+
* images are normalized to the energy of the unaberrated PSF ``a^2`` over the
|
|
30
|
+
detector window (Eq. 12), and a scaled ``a^2`` is added so the image peak
|
|
31
|
+
matches ``max a^2`` (Eq. 13), an improved first-order model that accounts
|
|
32
|
+
for the Strehl loss;
|
|
33
|
+
* the division by ``a`` is regularized, ``y = -a p_o / (2 a^2 + epsilon)``
|
|
34
|
+
(Eq. 16; their ``y`` has the opposite sign convention);
|
|
35
|
+
* ``|v| = |p_e - a^2 - y^2|^(1/2)`` (absolute value inside the root, Eq. 19);
|
|
36
|
+
* the focal-plane field ``v + i y`` is filtered by a concave parabola before
|
|
37
|
+
the inverse transform (Eq. 21), damping noisy high spatial frequencies.
|
|
38
|
+
|
|
39
|
+
Each step costs one forward FFT (of the DM change) and one inverse FFT, all on
|
|
40
|
+
the backend. Units: OPD in metres; images in any linear unit (they are
|
|
41
|
+
normalized).
|
|
42
|
+
|
|
43
|
+
The first frame has no predecessor. :class:`FastAndFurious` then estimates the
|
|
44
|
+
odd part and, with ``first_even=True``, the even part with positive signs
|
|
45
|
+
everywhere: a valid even phase whose correction is the even diversity the
|
|
46
|
+
next frame needs (with only an odd correction the DM change would have no
|
|
47
|
+
even part and the signs could never be resolved).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import math
|
|
53
|
+
import time
|
|
54
|
+
from dataclasses import dataclass, field
|
|
55
|
+
from typing import Any
|
|
56
|
+
|
|
57
|
+
import numpy as np
|
|
58
|
+
|
|
59
|
+
from ..backend import Backend, BackendLike, get_backend
|
|
60
|
+
from ..basis import Basis
|
|
61
|
+
from ..focal import FocalPlaneModel
|
|
62
|
+
from ..propagation import FFTPropagator, _shape
|
|
63
|
+
from ..pupil import Pupil
|
|
64
|
+
|
|
65
|
+
__all__ = ["ClosedLoopResult", "FastAndFurious", "simulate_closed_loop"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FastAndFurious:
|
|
69
|
+
"""Sequential F&F wavefront sensor with its own previous-image state.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
pupil:
|
|
74
|
+
The pupil. F&F assumes a centro-symmetric amplitude (circular,
|
|
75
|
+
annular, symmetric spiders).
|
|
76
|
+
wavelength:
|
|
77
|
+
Monochromatic wavelength in metres.
|
|
78
|
+
image_shape:
|
|
79
|
+
Detector image shape ``(my, mx)``; the optical axis is at the window
|
|
80
|
+
centre.
|
|
81
|
+
sampling, pixel_scale:
|
|
82
|
+
Detector pixels per ``lambda / D`` or pixel scale in radians (give
|
|
83
|
+
one). ``wavelength / (pupil.pitch * pixel_scale)`` must be an integer
|
|
84
|
+
FFT size at least as large as the pupil and image grids, e.g.
|
|
85
|
+
``sampling=2`` with a pupil filling its grid.
|
|
86
|
+
epsilon:
|
|
87
|
+
Regularization of the division by ``a`` (Korkiakoski et al. Eq. 16),
|
|
88
|
+
relative to the unaberrated peak ``max a^2 = 1``. Korkiakoski et al.
|
|
89
|
+
recommend 50-500 times the per-pixel noise level of the normalized
|
|
90
|
+
image. Smaller values recover more of the faint-ring signal (higher
|
|
91
|
+
spatial frequencies) but amplify noise there.
|
|
92
|
+
filter_radius:
|
|
93
|
+
Radius of the parabolic focal-plane filter ``max(0, 1 - (r / R)^2)``
|
|
94
|
+
in ``lambda / D``. Default: the largest circle inside the detector
|
|
95
|
+
window.
|
|
96
|
+
strehl_compensation:
|
|
97
|
+
Add the scaled ``a^2`` term of Korkiakoski et al. Eq. (13).
|
|
98
|
+
first_even:
|
|
99
|
+
On the first frame, estimate the even part with positive signs (see
|
|
100
|
+
the module docstring). With False the first estimate is odd only.
|
|
101
|
+
device, precision:
|
|
102
|
+
Backend selection.
|
|
103
|
+
|
|
104
|
+
Examples
|
|
105
|
+
--------
|
|
106
|
+
>>> ff = FastAndFurious(pupil, 1.6e-6, 64, sampling=2) # doctest: +SKIP
|
|
107
|
+
>>> change = None
|
|
108
|
+
>>> for _ in range(20): # doctest: +SKIP
|
|
109
|
+
... estimate = ff.step(camera.read(), change)
|
|
110
|
+
... change = -0.5 * estimate # wavefront change made by the DM
|
|
111
|
+
... dm.apply(change)
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
pupil: Pupil,
|
|
117
|
+
wavelength: float,
|
|
118
|
+
image_shape: Any,
|
|
119
|
+
*,
|
|
120
|
+
sampling: float | None = None,
|
|
121
|
+
pixel_scale: float | None = None,
|
|
122
|
+
epsilon: float = 1e-4,
|
|
123
|
+
filter_radius: float | None = None,
|
|
124
|
+
strehl_compensation: bool = True,
|
|
125
|
+
first_even: bool = True,
|
|
126
|
+
device: BackendLike = "cpu",
|
|
127
|
+
precision: str | None = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
self.backend: Backend = get_backend(device, precision)
|
|
130
|
+
be = self.backend
|
|
131
|
+
self.pupil = pupil
|
|
132
|
+
self.wavelength = float(wavelength)
|
|
133
|
+
if not (self.wavelength > 0 and math.isfinite(self.wavelength)):
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"wavelength must be a positive number of metres (F&F is monochromatic)"
|
|
136
|
+
)
|
|
137
|
+
self.image_shape = _shape(image_shape, "image_shape")
|
|
138
|
+
if (pixel_scale is None) == (sampling is None):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"give exactly one of pixel_scale (radians) or sampling (pixels per lambda/D)"
|
|
141
|
+
)
|
|
142
|
+
if sampling is not None:
|
|
143
|
+
if sampling <= 0:
|
|
144
|
+
raise ValueError("sampling must be positive")
|
|
145
|
+
pixel_scale = self.wavelength / pupil.diameter / float(sampling)
|
|
146
|
+
assert pixel_scale is not None
|
|
147
|
+
if pixel_scale <= 0:
|
|
148
|
+
raise ValueError("pixel_scale must be positive")
|
|
149
|
+
self.pixel_scale = float(pixel_scale)
|
|
150
|
+
samples = self.wavelength / (pupil.pitch * self.pixel_scale)
|
|
151
|
+
n_fft = round(samples)
|
|
152
|
+
if abs(samples - n_fft) > 1e-6 * samples or n_fft < max(*pupil.shape, *self.image_shape):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"Fast & Furious inverts the pupil-to-focal FFT, so wavelength / (pupil pitch x "
|
|
155
|
+
f"pixel scale) = {samples:.6g} must be an integer at least as large as the pupil "
|
|
156
|
+
f"{pupil.shape} and image {self.image_shape} grids; adjust sampling or the "
|
|
157
|
+
"pupil grid"
|
|
158
|
+
)
|
|
159
|
+
self.n_fft = n_fft
|
|
160
|
+
if epsilon < 0:
|
|
161
|
+
raise ValueError("epsilon must be >= 0")
|
|
162
|
+
self.epsilon = float(epsilon)
|
|
163
|
+
self.strehl_compensation = bool(strehl_compensation)
|
|
164
|
+
self.first_even = bool(first_even)
|
|
165
|
+
|
|
166
|
+
amp = pupil.amplitude
|
|
167
|
+
if not np.allclose(amp, amp[::-1, ::-1], atol=1e-6 * float(amp.max())):
|
|
168
|
+
raise ValueError(
|
|
169
|
+
"Fast & Furious assumes a centro-symmetric pupil amplitude (Korkiakoski et al. "
|
|
170
|
+
"2014); this pupil is not symmetric under a 180-degree rotation"
|
|
171
|
+
)
|
|
172
|
+
self.propagator = FFTPropagator(pupil.shape, self.image_shape, n_fft, backend=be)
|
|
173
|
+
self._k = 2.0 * math.pi / self.wavelength
|
|
174
|
+
a_field = self.propagator.forward(be.asarray(amp, dtype="complex"))
|
|
175
|
+
a_host = np.asarray(be.to_numpy(a_field.real), dtype=np.float64)
|
|
176
|
+
peak = float(np.max(np.abs(a_host)))
|
|
177
|
+
self._scale = 1.0 / peak # F_n = scale * F has max |a| = 1
|
|
178
|
+
a = a_host * self._scale
|
|
179
|
+
self._a = be.asarray(a, dtype="real")
|
|
180
|
+
self._a2 = be.asarray(a * a, dtype="real")
|
|
181
|
+
self._sum_a2 = float(np.sum(a * a))
|
|
182
|
+
self._max_a2 = float(np.max(a * a))
|
|
183
|
+
self._y_den = be.asarray(2.0 * a * a + self.epsilon, dtype="real")
|
|
184
|
+
|
|
185
|
+
my, mx = self.image_shape
|
|
186
|
+
samp = self.sampling
|
|
187
|
+
fy = (np.arange(my) - (my - 1) / 2.0)[:, None] / samp
|
|
188
|
+
fx = (np.arange(mx) - (mx - 1) / 2.0)[None, :] / samp
|
|
189
|
+
radius = (min(my, mx) / 2.0) / samp if filter_radius is None else float(filter_radius)
|
|
190
|
+
if radius <= 0:
|
|
191
|
+
raise ValueError("filter_radius must be positive (lambda / D)")
|
|
192
|
+
self.filter_radius = radius
|
|
193
|
+
window = np.maximum(0.0, 1.0 - (fy**2 + fx**2) / radius**2)
|
|
194
|
+
self._window = be.asarray(window / self._scale, dtype="real") # folds in 1 / scale
|
|
195
|
+
|
|
196
|
+
self._amp = be.asarray(amp, dtype="real")
|
|
197
|
+
floor = (0.25 * float(amp.max())) ** 2
|
|
198
|
+
inv = np.where(amp > 0, amp / np.maximum(amp * amp, floor), 0.0)
|
|
199
|
+
self._inv_amp = be.asarray(inv / self._k, dtype="real") # also radians -> metres
|
|
200
|
+
self._prev_even: Any = None
|
|
201
|
+
self.n_steps = 0
|
|
202
|
+
self.last_odd_opd: Any = None
|
|
203
|
+
self.last_even_opd: Any = None
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------- metadata
|
|
206
|
+
@property
|
|
207
|
+
def sampling(self) -> float:
|
|
208
|
+
"""Detector pixels per ``lambda / D``."""
|
|
209
|
+
return self.wavelength / self.pupil.diameter / self.pixel_scale
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def has_previous(self) -> bool:
|
|
213
|
+
"""Whether a previous image is stored (the even-part signs can be resolved)."""
|
|
214
|
+
return self._prev_even is not None
|
|
215
|
+
|
|
216
|
+
def __repr__(self) -> str:
|
|
217
|
+
return (
|
|
218
|
+
f"FastAndFurious(image_shape={self.image_shape}, sampling={self.sampling:.3g}, "
|
|
219
|
+
f"n_fft={self.n_fft}, epsilon={self.epsilon:g}, steps={self.n_steps}, "
|
|
220
|
+
f"backend={self.backend})"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def reset(self) -> None:
|
|
224
|
+
"""Forget the previous image (e.g. after opening the loop)."""
|
|
225
|
+
self._prev_even = None
|
|
226
|
+
self.n_steps = 0
|
|
227
|
+
self.last_odd_opd = None
|
|
228
|
+
self.last_even_opd = None
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------ the step
|
|
231
|
+
def _normalize(self, image: Any) -> Any:
|
|
232
|
+
"""Korkiakoski et al. Eqs. (12)-(13): energy normalization and Strehl compensation."""
|
|
233
|
+
xp = self.backend.xp
|
|
234
|
+
p = self.backend.asarray(image, dtype="real")
|
|
235
|
+
if p.ndim == 3 and p.shape[0] == 1:
|
|
236
|
+
p = p[0]
|
|
237
|
+
if tuple(p.shape) != self.image_shape:
|
|
238
|
+
raise ValueError(f"image shape {tuple(p.shape)} does not match {self.image_shape}")
|
|
239
|
+
total = xp.sum(p)
|
|
240
|
+
p = p * (self._sum_a2 / xp.where(total > 0, total, 1.0))
|
|
241
|
+
if self.strehl_compensation:
|
|
242
|
+
p = p + (1.0 - xp.max(p) / self._max_a2) * self._a2
|
|
243
|
+
return p
|
|
244
|
+
|
|
245
|
+
def _pupil_opd(self, focal: Any) -> Any:
|
|
246
|
+
"""OPD in metres from the focal field ``F{A phi}`` (one inverse FFT, divide by ``A``)."""
|
|
247
|
+
a_phi = self.propagator.adjoint(focal).real
|
|
248
|
+
return a_phi * self._inv_amp
|
|
249
|
+
|
|
250
|
+
def step(self, image: Any, dm_change_opd: Any = None) -> Any:
|
|
251
|
+
"""Process one image and return the estimated pupil OPD.
|
|
252
|
+
|
|
253
|
+
Parameters
|
|
254
|
+
----------
|
|
255
|
+
image:
|
|
256
|
+
``(my, mx)`` focal-plane image (any linear unit, background
|
|
257
|
+
subtracted), NumPy or backend array.
|
|
258
|
+
dm_change_opd:
|
|
259
|
+
``(ny, nx)`` change of the wavefront OPD in metres made between
|
|
260
|
+
the previous image and this one (the DM correction applied after
|
|
261
|
+
the previous step, as the camera sees it). Ignored on the first
|
|
262
|
+
frame; ``None`` means no change, so the even-part signs are not
|
|
263
|
+
resolved and the even estimate is zero.
|
|
264
|
+
|
|
265
|
+
Returns
|
|
266
|
+
-------
|
|
267
|
+
Backend array ``(ny, nx)``: the estimated wavefront OPD in metres at
|
|
268
|
+
this image, zero outside the pupil. Apply ``-gain`` times it with the
|
|
269
|
+
DM to correct. The odd and even parts are kept in
|
|
270
|
+
:attr:`last_odd_opd` and :attr:`last_even_opd`.
|
|
271
|
+
"""
|
|
272
|
+
xp = self.backend.xp
|
|
273
|
+
p = self._normalize(image)
|
|
274
|
+
p_flip = p[::-1, ::-1]
|
|
275
|
+
p_even = 0.5 * (p + p_flip)
|
|
276
|
+
p_odd = 0.5 * (p - p_flip)
|
|
277
|
+
y = -self._a * p_odd / self._y_den
|
|
278
|
+
v_abs = xp.sqrt(xp.abs(p_even - self._a2 - y * y))
|
|
279
|
+
if self._prev_even is None:
|
|
280
|
+
sign = 1.0 if self.first_even else 0.0
|
|
281
|
+
v = v_abs * sign
|
|
282
|
+
elif dm_change_opd is None:
|
|
283
|
+
v = xp.zeros_like(v_abs)
|
|
284
|
+
else:
|
|
285
|
+
change = self.backend.asarray(dm_change_opd, dtype="real")
|
|
286
|
+
if tuple(change.shape) != self.pupil.shape:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"dm_change_opd shape {tuple(change.shape)} does not match the pupil "
|
|
289
|
+
f"{self.pupil.shape}"
|
|
290
|
+
)
|
|
291
|
+
# Previous frame relative to this one: phi_d = -k * change.
|
|
292
|
+
div = (
|
|
293
|
+
self.propagator.forward(
|
|
294
|
+
(self._amp * (-self._k) * change).astype(self.backend.complex_dtype)
|
|
295
|
+
)
|
|
296
|
+
* self._scale
|
|
297
|
+
)
|
|
298
|
+
v_d, y_d = div.real, div.imag
|
|
299
|
+
diff = self._prev_even - p_even - v_d * v_d - y_d * y_d - 2.0 * y * y_d
|
|
300
|
+
v = v_abs * xp.sign(diff * v_d)
|
|
301
|
+
self._prev_even = p_even
|
|
302
|
+
self.n_steps += 1
|
|
303
|
+
# v + i y = F{A phi_e} + F{A phi_o}: one inverse FFT gives A phi, whose even and odd
|
|
304
|
+
# parts are separated again by symmetry.
|
|
305
|
+
opd = self._pupil_opd((v + 1j * y) * self._window)
|
|
306
|
+
opd_flip = opd[::-1, ::-1]
|
|
307
|
+
self.last_odd_opd = 0.5 * (opd - opd_flip)
|
|
308
|
+
self.last_even_opd = 0.5 * (opd + opd_flip)
|
|
309
|
+
return opd
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class ClosedLoopResult:
|
|
314
|
+
"""Record of :func:`simulate_closed_loop`.
|
|
315
|
+
|
|
316
|
+
Attributes
|
|
317
|
+
----------
|
|
318
|
+
residual_rms:
|
|
319
|
+
``(n_iter + 1,)`` piston-removed, intensity-weighted RMS residual OPD
|
|
320
|
+
in metres before each frame (entry 0 is the open-loop aberration).
|
|
321
|
+
strehl:
|
|
322
|
+
Marechal Strehl ratio of each :attr:`residual_rms`.
|
|
323
|
+
residual_opd:
|
|
324
|
+
Final residual OPD map in metres (backend array).
|
|
325
|
+
dm_opd:
|
|
326
|
+
Final DM OPD in metres (backend array; the correction it applies).
|
|
327
|
+
step_times:
|
|
328
|
+
Wall-clock seconds of each F&F step (synchronized on the GPU).
|
|
329
|
+
history:
|
|
330
|
+
:attr:`residual_rms` as a list.
|
|
331
|
+
n_iter:
|
|
332
|
+
Loop iterations run.
|
|
333
|
+
converged:
|
|
334
|
+
Whether the residual fell below ``tol``.
|
|
335
|
+
message:
|
|
336
|
+
Why the loop stopped.
|
|
337
|
+
elapsed:
|
|
338
|
+
Total wall-clock seconds.
|
|
339
|
+
device:
|
|
340
|
+
``"cpu"`` or ``"gpu"``.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
residual_rms: np.ndarray
|
|
344
|
+
strehl: np.ndarray
|
|
345
|
+
residual_opd: Any
|
|
346
|
+
dm_opd: Any
|
|
347
|
+
step_times: np.ndarray
|
|
348
|
+
history: list[float] = field(default_factory=list)
|
|
349
|
+
n_iter: int = 0
|
|
350
|
+
converged: bool = False
|
|
351
|
+
message: str = ""
|
|
352
|
+
elapsed: float = 0.0
|
|
353
|
+
device: str = "cpu"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def simulate_closed_loop(
|
|
357
|
+
ff: FastAndFurious,
|
|
358
|
+
true_opd: Any,
|
|
359
|
+
n_iterations: int = 20,
|
|
360
|
+
*,
|
|
361
|
+
gain: float = 0.5,
|
|
362
|
+
leak: float = 1.0,
|
|
363
|
+
photons: float | None = None,
|
|
364
|
+
read_noise: float = 0.0,
|
|
365
|
+
seed: Any = None,
|
|
366
|
+
basis: Basis | None = None,
|
|
367
|
+
tol: float | None = None,
|
|
368
|
+
reset: bool = True,
|
|
369
|
+
) -> ClosedLoopResult:
|
|
370
|
+
"""Run F&F in a simulated closed loop against a static aberration.
|
|
371
|
+
|
|
372
|
+
Each iteration images the residual ``true_opd + dm`` (monochromatic,
|
|
373
|
+
:class:`~solvephase.FocalPlaneModel` with the sensor's sampling), adds
|
|
374
|
+
noise, calls :meth:`FastAndFurious.step` with the previous DM change, and
|
|
375
|
+
updates the DM with the leaky integrator of Korkiakoski et al. (Eq. 22),
|
|
376
|
+
``dm <- leak * dm - gain * estimate``.
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
ff:
|
|
381
|
+
The sensor (its state is reset first when ``reset``).
|
|
382
|
+
true_opd:
|
|
383
|
+
``(ny, nx)`` static aberration in metres (e.g. NCPA).
|
|
384
|
+
n_iterations:
|
|
385
|
+
Number of frames.
|
|
386
|
+
gain, leak:
|
|
387
|
+
Integrator gain and leak (``leak = 1`` is a pure integrator).
|
|
388
|
+
photons:
|
|
389
|
+
Detected photons per frame (full PSF) for Poisson noise; ``None``
|
|
390
|
+
gives noise-free images.
|
|
391
|
+
read_noise:
|
|
392
|
+
Gaussian read noise per pixel in photo-electrons (with ``photons``).
|
|
393
|
+
seed:
|
|
394
|
+
Seed or :class:`numpy.random.Generator` for the noise (host draws).
|
|
395
|
+
basis:
|
|
396
|
+
Optional DM modal basis: each estimate is projected onto it (a DM
|
|
397
|
+
cannot make arbitrary shapes). ``None`` is a zonal DM at pupil
|
|
398
|
+
resolution.
|
|
399
|
+
tol:
|
|
400
|
+
Stop once the residual RMS (metres) is below this.
|
|
401
|
+
reset:
|
|
402
|
+
Call :meth:`FastAndFurious.reset` first.
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
ClosedLoopResult
|
|
407
|
+
"""
|
|
408
|
+
t0 = time.perf_counter()
|
|
409
|
+
if n_iterations < 1:
|
|
410
|
+
raise ValueError("n_iterations must be >= 1")
|
|
411
|
+
if not 0.0 < gain <= 2.0 or not 0.0 <= leak <= 1.0:
|
|
412
|
+
raise ValueError("gain must be in (0, 2] and leak in [0, 1]")
|
|
413
|
+
if photons is not None and photons <= 0:
|
|
414
|
+
raise ValueError("photons must be positive (or None for noise-free images)")
|
|
415
|
+
be, xp = ff.backend, ff.backend.xp
|
|
416
|
+
if reset:
|
|
417
|
+
ff.reset()
|
|
418
|
+
model = FocalPlaneModel(
|
|
419
|
+
ff.pupil, ff.wavelength, ff.image_shape, pixel_scale=ff.pixel_scale, device=be
|
|
420
|
+
)
|
|
421
|
+
rng = be.random(seed)
|
|
422
|
+
truth = be.asarray(true_opd, dtype="real")
|
|
423
|
+
if tuple(truth.shape) != ff.pupil.shape:
|
|
424
|
+
raise ValueError(
|
|
425
|
+
f"true_opd shape {tuple(truth.shape)} does not match the pupil {ff.pupil.shape}"
|
|
426
|
+
)
|
|
427
|
+
mask = be.asarray(ff.pupil.mask, dtype="real")
|
|
428
|
+
weight = be.asarray(ff.pupil.amplitude**2, dtype="real")
|
|
429
|
+
w_sum = float(np.sum(ff.pupil.amplitude**2))
|
|
430
|
+
projector = None
|
|
431
|
+
if basis is not None:
|
|
432
|
+
if basis.is_zonal:
|
|
433
|
+
basis = None
|
|
434
|
+
else:
|
|
435
|
+
modes = basis.modes
|
|
436
|
+
assert modes is not None
|
|
437
|
+
w = (ff.pupil.amplitude**2)[ff.pupil.mask]
|
|
438
|
+
pinv = np.linalg.pinv((modes * np.sqrt(w)).T) * np.sqrt(w)[None, :]
|
|
439
|
+
projector = (be.asarray(pinv.T, dtype="real"), basis.on(be))
|
|
440
|
+
flat_index = xp.asarray(np.flatnonzero(ff.pupil.mask.ravel()))
|
|
441
|
+
|
|
442
|
+
def residual_rms(opd: Any) -> Any:
|
|
443
|
+
mean = xp.sum(weight * opd) / w_sum
|
|
444
|
+
return xp.sqrt(xp.sum(weight * (opd - mean) ** 2) / w_sum)
|
|
445
|
+
|
|
446
|
+
dm = be.zeros(ff.pupil.shape)
|
|
447
|
+
change: Any = None
|
|
448
|
+
rms_values = [residual_rms(truth)]
|
|
449
|
+
step_times: list[float] = []
|
|
450
|
+
converged, message = False, f"completed {n_iterations} iterations"
|
|
451
|
+
it = 0
|
|
452
|
+
for it in range(1, n_iterations + 1):
|
|
453
|
+
residual = truth + dm
|
|
454
|
+
image = model.images(residual * mask)[0]
|
|
455
|
+
if photons is not None:
|
|
456
|
+
host = np.asarray(be.to_numpy(image), dtype=np.float64) * photons
|
|
457
|
+
noisy = rng.poisson(np.maximum(host, 0.0)).astype(np.float64)
|
|
458
|
+
if read_noise:
|
|
459
|
+
noisy = noisy + rng.normal(0.0, read_noise, noisy.shape)
|
|
460
|
+
image = be.asarray(noisy, dtype="real")
|
|
461
|
+
be.synchronize()
|
|
462
|
+
ts = time.perf_counter()
|
|
463
|
+
estimate = ff.step(image, change)
|
|
464
|
+
be.synchronize()
|
|
465
|
+
step_times.append(time.perf_counter() - ts)
|
|
466
|
+
if projector is not None:
|
|
467
|
+
pinv_t, modes_d = projector
|
|
468
|
+
coeffs = estimate.reshape(-1)[flat_index] @ pinv_t
|
|
469
|
+
flat = xp.zeros(estimate.size, dtype=estimate.dtype)
|
|
470
|
+
flat[flat_index] = coeffs @ modes_d
|
|
471
|
+
estimate = flat.reshape(estimate.shape)
|
|
472
|
+
new_dm = leak * dm - gain * estimate * mask
|
|
473
|
+
change = new_dm - dm
|
|
474
|
+
dm = new_dm
|
|
475
|
+
rms_values.append(residual_rms(truth + dm))
|
|
476
|
+
if tol is not None and float(rms_values[-1]) < tol:
|
|
477
|
+
converged, message = True, f"residual RMS below tol after {it} iterations"
|
|
478
|
+
break
|
|
479
|
+
be.synchronize()
|
|
480
|
+
rms_host = np.array([float(r) for r in rms_values])
|
|
481
|
+
strehl = np.exp(-((2.0 * math.pi * rms_host / ff.wavelength) ** 2))
|
|
482
|
+
return ClosedLoopResult(
|
|
483
|
+
residual_rms=rms_host,
|
|
484
|
+
strehl=strehl,
|
|
485
|
+
residual_opd=(truth + dm) * mask,
|
|
486
|
+
dm_opd=dm,
|
|
487
|
+
step_times=np.asarray(step_times),
|
|
488
|
+
history=list(rms_host),
|
|
489
|
+
n_iter=it,
|
|
490
|
+
converged=converged,
|
|
491
|
+
message=message,
|
|
492
|
+
elapsed=time.perf_counter() - t0,
|
|
493
|
+
device=be.device,
|
|
494
|
+
)
|