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.
@@ -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
+ )