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,553 @@
|
|
|
1
|
+
"""LIFT: low-order wavefront sensing from one astigmatic focal-plane image.
|
|
2
|
+
|
|
3
|
+
LIFT (LInearized Focal-plane Technique; Meimon, Fusco & Mugnier, Opt. Lett.
|
|
4
|
+
35, 3036, 2010; Plantet et al., Opt. Express 21, 16337, 2013) estimates a
|
|
5
|
+
handful of low-order modes (tip, tilt, defocus, astigmatism, coma ...) from a
|
|
6
|
+
single, possibly very faint, focal-plane image. A known astigmatism is added
|
|
7
|
+
in the optical path. Without it a single in-focus image cannot tell the sign
|
|
8
|
+
of the *even* part of the wavefront (defocus, astigmatism, spherical...): the
|
|
9
|
+
twin ``phi(x) -> -phi(-x)`` gives the same image. The astigmatism moves that
|
|
10
|
+
twin far from the true solution (for the total phase ``phi + phi_d`` the twin
|
|
11
|
+
needs ``phi_d -> -phi_d``), so a local maximum-likelihood search started near
|
|
12
|
+
zero lands on the true wavefront.
|
|
13
|
+
|
|
14
|
+
The estimator is maximum likelihood under photon and read noise, computed by
|
|
15
|
+
iterated linearization of the PSF around the current estimate (Gauss-Newton,
|
|
16
|
+
with the noise variance ``model + read_noise^2`` as weights). In solvephase
|
|
17
|
+
this is exactly :func:`solvephase.solve` with ``method="lm"`` on a
|
|
18
|
+
:class:`~solvephase.FocalPlaneProblem` with a Poisson (shifted-Poisson) loss,
|
|
19
|
+
a modal basis and a fitted flux; this module only sets that problem up, keeps
|
|
20
|
+
the model for repeated estimates (:class:`LIFT`), and computes the
|
|
21
|
+
Cramer-Rao lower bound of the modal coefficients (:func:`lift_crlb`), which
|
|
22
|
+
LIFT approaches at high flux.
|
|
23
|
+
|
|
24
|
+
Units: coefficients are RMS OPD in metres of unit-RMS Zernike modes (Noll
|
|
25
|
+
order, starting at tip by default); the astigmatism is RMS OPD in metres;
|
|
26
|
+
images are in photo-electrons (read noise in electrons RMS).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
import time
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from ..backend import BackendLike
|
|
38
|
+
from ..basis import Basis
|
|
39
|
+
from ..focal import FocalPlaneModel, zernike_diversity
|
|
40
|
+
from ..losses import GaussianLoss, Loss, PoissonLoss
|
|
41
|
+
from ..pupil import Pupil
|
|
42
|
+
from ..result import Result
|
|
43
|
+
from ..retrieval import FocalPlaneProblem, noise_weights, solve
|
|
44
|
+
|
|
45
|
+
__all__ = ["LIFT", "lift", "lift_crlb", "lift_diversity"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def lift_diversity(
|
|
49
|
+
pupil: Pupil, wavelength: float, astigmatism: float | None = None, *, noll: int = 5
|
|
50
|
+
) -> np.ndarray:
|
|
51
|
+
"""The known LIFT astigmatism as an OPD map ``(ny, nx)`` in metres.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
pupil:
|
|
56
|
+
The pupil.
|
|
57
|
+
wavelength:
|
|
58
|
+
Wavelength in metres (sets the default amplitude).
|
|
59
|
+
astigmatism:
|
|
60
|
+
RMS OPD of the astigmatism in metres. ``None`` gives
|
|
61
|
+
:func:`default_astigmatism` (``wavelength / 10`` RMS).
|
|
62
|
+
noll:
|
|
63
|
+
Noll index of the astigmatism mode, 5 (oblique) or 6 (vertical).
|
|
64
|
+
"""
|
|
65
|
+
amount = default_astigmatism(wavelength) if astigmatism is None else float(astigmatism)
|
|
66
|
+
if not math.isfinite(amount):
|
|
67
|
+
raise ValueError("astigmatism must be finite (RMS OPD in metres)")
|
|
68
|
+
return np.asarray(zernike_diversity(pupil, noll, [amount])[0])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def default_astigmatism(wavelength: float) -> float:
|
|
72
|
+
"""Default LIFT astigmatism: ``wavelength / 10`` RMS OPD (about half a wave peak to valley).
|
|
73
|
+
|
|
74
|
+
A unit-RMS Zernike astigmatism spans ``2 sqrt 6`` peak to valley over the
|
|
75
|
+
disk, so this is ``0.49`` waves PV. It is close to the 170 nm RMS at
|
|
76
|
+
1.6 um of VLT/IRLOS (Kuznetsov et al. 2024). The Cramer-Rao bound is flat
|
|
77
|
+
between about 0.05 and 0.15 waves RMS, while the capture range grows with
|
|
78
|
+
the astigmatism (the twin solution sits at ``-a_astig - 2 d``).
|
|
79
|
+
"""
|
|
80
|
+
return float(wavelength) / 10.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _image_stack(image: Any) -> Any:
|
|
84
|
+
shape = tuple(getattr(image, "shape", np.shape(image)))
|
|
85
|
+
if len(shape) == 3 and shape[0] == 1:
|
|
86
|
+
return image
|
|
87
|
+
if len(shape) != 2:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"LIFT uses one focal-plane image (my, mx); got shape {shape}. "
|
|
90
|
+
"For several diversity images use FocalPlaneProblem + solve (phase diversity)."
|
|
91
|
+
)
|
|
92
|
+
return image
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _norm(coefficients: np.ndarray, basis: Basis) -> float:
|
|
96
|
+
"""Pupil-weighted RMS of the wavefront ``B c``."""
|
|
97
|
+
opd = basis.synthesize(coefficients)
|
|
98
|
+
w = basis.pupil.amplitude**2
|
|
99
|
+
return float(np.sqrt(np.sum(w * opd**2) / np.sum(w)))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LIFT:
|
|
103
|
+
"""A reusable LIFT wavefront sensor: the model is built once, estimates are repeated.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
pupil:
|
|
108
|
+
The entrance pupil.
|
|
109
|
+
wavelength:
|
|
110
|
+
Wavelength in metres (or an array sampling a band, see
|
|
111
|
+
:class:`~solvephase.FocalPlaneModel`).
|
|
112
|
+
image_shape:
|
|
113
|
+
Detector image shape ``(my, mx)``.
|
|
114
|
+
sampling, pixel_scale:
|
|
115
|
+
Detector pixels per ``lambda / D`` or pixel scale in radians (give
|
|
116
|
+
one).
|
|
117
|
+
n_modes:
|
|
118
|
+
Number of Zernike modes estimated, Noll order from tip (Noll 2). Ignored
|
|
119
|
+
when ``basis`` is given.
|
|
120
|
+
basis:
|
|
121
|
+
Any modal :class:`~solvephase.Basis` instead of Zernikes.
|
|
122
|
+
astigmatism:
|
|
123
|
+
RMS OPD of the known astigmatism in metres (default
|
|
124
|
+
``wavelength / 10``, see :func:`default_astigmatism`).
|
|
125
|
+
astigmatism_mode:
|
|
126
|
+
Noll index of the diversity astigmatism (5 or 6).
|
|
127
|
+
read_noise:
|
|
128
|
+
Read-noise standard deviation in electrons.
|
|
129
|
+
loss:
|
|
130
|
+
``"poisson"`` (maximum likelihood under photon + read noise; the
|
|
131
|
+
default) or ``"gaussian"`` (weighted least squares with variances from
|
|
132
|
+
the image, :func:`~solvephase.noise_weights`, as in the original
|
|
133
|
+
LIFT); or a :class:`~solvephase.Loss`.
|
|
134
|
+
fit_flux, fit_background, background:
|
|
135
|
+
Nuisance parameters, see :class:`~solvephase.FocalPlaneProblem`.
|
|
136
|
+
max_iter:
|
|
137
|
+
Gauss-Newton (Levenberg-Marquardt) iteration limit per estimate.
|
|
138
|
+
resolve_twin:
|
|
139
|
+
After convergence, compare the solution with its exact twin (same
|
|
140
|
+
image, see :meth:`twin`) and report the one with the smaller
|
|
141
|
+
wavefront RMS. Only active when the twin is exact: centro-symmetric
|
|
142
|
+
pupil, centred image window and a basis closed under the twin
|
|
143
|
+
(Zernikes are).
|
|
144
|
+
oversample, offset:
|
|
145
|
+
Detector pixel integration and optical-axis offset, see
|
|
146
|
+
:class:`~solvephase.FocalPlaneModel`.
|
|
147
|
+
device, precision:
|
|
148
|
+
Backend selection.
|
|
149
|
+
|
|
150
|
+
Examples
|
|
151
|
+
--------
|
|
152
|
+
>>> sensor = LIFT(pupil, 1.6e-6, 32, sampling=2, n_modes=10) # doctest: +SKIP
|
|
153
|
+
>>> for frame in frames: # doctest: +SKIP
|
|
154
|
+
... result = sensor.estimate(frame)
|
|
155
|
+
... dm_command -= gain * result.coefficients
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
pupil: Pupil,
|
|
161
|
+
wavelength: Any,
|
|
162
|
+
image_shape: Any,
|
|
163
|
+
*,
|
|
164
|
+
sampling: float | None = None,
|
|
165
|
+
pixel_scale: float | None = None,
|
|
166
|
+
n_modes: int = 10,
|
|
167
|
+
basis: Basis | None = None,
|
|
168
|
+
astigmatism: float | None = None,
|
|
169
|
+
astigmatism_mode: int = 5,
|
|
170
|
+
read_noise: float = 0.0,
|
|
171
|
+
loss: str | Loss = "poisson",
|
|
172
|
+
fit_flux: bool = True,
|
|
173
|
+
fit_background: bool = False,
|
|
174
|
+
background: float = 0.0,
|
|
175
|
+
max_iter: int = 20,
|
|
176
|
+
resolve_twin: bool = True,
|
|
177
|
+
oversample: int = 1,
|
|
178
|
+
offset: Any = 0.0,
|
|
179
|
+
device: BackendLike = "cpu",
|
|
180
|
+
precision: str | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
if astigmatism_mode not in (5, 6):
|
|
183
|
+
raise ValueError("astigmatism_mode must be 5 (oblique) or 6 (vertical astigmatism)")
|
|
184
|
+
if read_noise < 0:
|
|
185
|
+
raise ValueError("read_noise must be >= 0 (electrons RMS)")
|
|
186
|
+
wl = np.atleast_1d(np.asarray(wavelength, dtype=np.float64))
|
|
187
|
+
ref = float(wl.mean())
|
|
188
|
+
self.astigmatism = default_astigmatism(ref) if astigmatism is None else float(astigmatism)
|
|
189
|
+
self.diversity = lift_diversity(pupil, ref, self.astigmatism, noll=astigmatism_mode)
|
|
190
|
+
self.model = FocalPlaneModel(
|
|
191
|
+
pupil,
|
|
192
|
+
wavelength,
|
|
193
|
+
image_shape,
|
|
194
|
+
sampling=sampling,
|
|
195
|
+
pixel_scale=pixel_scale,
|
|
196
|
+
diversity=[self.diversity],
|
|
197
|
+
oversample=oversample,
|
|
198
|
+
offset=offset,
|
|
199
|
+
device=device,
|
|
200
|
+
precision=precision,
|
|
201
|
+
)
|
|
202
|
+
if basis is None:
|
|
203
|
+
if n_modes < 1:
|
|
204
|
+
raise ValueError("n_modes must be >= 1")
|
|
205
|
+
basis = Basis.zernike(pupil, int(n_modes))
|
|
206
|
+
if basis.is_zonal:
|
|
207
|
+
raise ValueError("LIFT estimates a few modes; give a modal basis, not the zonal one")
|
|
208
|
+
self.basis = basis
|
|
209
|
+
self.basis.on(self.model.backend) # cache the mode matrix on the device once
|
|
210
|
+
self.read_noise = float(read_noise)
|
|
211
|
+
if isinstance(loss, str) and loss.lower() not in ("poisson", "gaussian"):
|
|
212
|
+
raise ValueError(f"unknown LIFT loss {loss!r}; use 'poisson' or 'gaussian'")
|
|
213
|
+
self.loss = loss
|
|
214
|
+
self.fit_flux = bool(fit_flux)
|
|
215
|
+
self.fit_background = bool(fit_background)
|
|
216
|
+
self.background = float(background)
|
|
217
|
+
self.max_iter = int(max_iter)
|
|
218
|
+
self._twin_map = self._twin_operator() if resolve_twin else None
|
|
219
|
+
self.resolve_twin = self._twin_map is not None
|
|
220
|
+
|
|
221
|
+
def _twin_operator(self) -> tuple[np.ndarray, np.ndarray] | None:
|
|
222
|
+
"""``(T, t)`` with twin coefficients ``T c + t``, or None if the twin is not exact.
|
|
223
|
+
|
|
224
|
+
For a centro-symmetric pupil and a centred detector window the pupil
|
|
225
|
+
phases ``phi + phi_d`` and ``-(phi + phi_d)(-x)`` give identical
|
|
226
|
+
images, so ``phi' = -(phi + phi_d)(-x) - phi_d`` fits the data as
|
|
227
|
+
well as ``phi``.
|
|
228
|
+
"""
|
|
229
|
+
pupil = self.model.pupil
|
|
230
|
+
amp = pupil.amplitude
|
|
231
|
+
centred = self.model.offset == (0.0, 0.0)
|
|
232
|
+
if not centred or not np.allclose(amp, amp[::-1, ::-1], atol=1e-12):
|
|
233
|
+
return None
|
|
234
|
+
maps = self.basis.mode_maps()
|
|
235
|
+
flipped = -maps[:, ::-1, ::-1]
|
|
236
|
+
div = self.diversity
|
|
237
|
+
shift = -div[::-1, ::-1] - div
|
|
238
|
+
targets = np.concatenate([flipped, shift[None]])
|
|
239
|
+
coeffs = self.basis.fit(targets) # (n + 1, n)
|
|
240
|
+
resid = self.basis.synthesize(coeffs) - targets
|
|
241
|
+
mask = pupil.mask
|
|
242
|
+
scale = max(float(np.sqrt(np.mean(targets[:, mask] ** 2))), 1e-300)
|
|
243
|
+
if float(np.sqrt(np.mean(resid[:, mask] ** 2))) > 1e-6 * scale:
|
|
244
|
+
return None
|
|
245
|
+
return np.asarray(coeffs[:-1].T), np.asarray(coeffs[-1])
|
|
246
|
+
|
|
247
|
+
def twin(self, coefficients: Any) -> np.ndarray:
|
|
248
|
+
"""The twin coefficients (metres) that produce exactly the same image.
|
|
249
|
+
|
|
250
|
+
Raises ``ValueError`` when the twin is not exact for this sensor (an
|
|
251
|
+
asymmetric pupil, an off-centre window, or a basis not closed under
|
|
252
|
+
``phi(x) -> -phi(-x)``).
|
|
253
|
+
"""
|
|
254
|
+
if self._twin_map is None:
|
|
255
|
+
self._twin_map = self._twin_operator()
|
|
256
|
+
if self._twin_map is None:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
"the twin is not exact here: it needs a centro-symmetric pupil, a centred "
|
|
259
|
+
"image window and a basis closed under phi(x) -> -phi(-x) (e.g. Zernikes)"
|
|
260
|
+
)
|
|
261
|
+
mat, shift = self._twin_map
|
|
262
|
+
return np.asarray(mat @ np.asarray(coefficients, dtype=np.float64) + shift)
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------- metadata
|
|
265
|
+
@property
|
|
266
|
+
def backend(self) -> Any:
|
|
267
|
+
"""The model's :class:`~solvephase.Backend`."""
|
|
268
|
+
return self.model.backend
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def n_modes(self) -> int:
|
|
272
|
+
"""Number of estimated modal coefficients."""
|
|
273
|
+
return self.basis.n_modes
|
|
274
|
+
|
|
275
|
+
def __repr__(self) -> str:
|
|
276
|
+
return (
|
|
277
|
+
f"LIFT(n_modes={self.n_modes}, astigmatism={self.astigmatism * 1e9:.1f} nm RMS, "
|
|
278
|
+
f"image_shape={self.model.image_shape}, sampling={self.model.sampling:.3g}, "
|
|
279
|
+
f"read_noise={self.read_noise:g}, backend={self.backend})"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------- problems
|
|
283
|
+
def problem(self, image: Any) -> FocalPlaneProblem:
|
|
284
|
+
"""The :class:`~solvephase.FocalPlaneProblem` LIFT solves for ``image``."""
|
|
285
|
+
image = _image_stack(image)
|
|
286
|
+
loss: str | Loss
|
|
287
|
+
weights = None
|
|
288
|
+
if isinstance(self.loss, Loss):
|
|
289
|
+
loss = self.loss
|
|
290
|
+
elif self.loss.lower() == "poisson":
|
|
291
|
+
loss = PoissonLoss(read_noise=self.read_noise)
|
|
292
|
+
else:
|
|
293
|
+
loss = GaussianLoss()
|
|
294
|
+
host = np.asarray(self.backend.to_numpy(image), dtype=np.float64)
|
|
295
|
+
weights = noise_weights(host - self.background, self.read_noise)
|
|
296
|
+
return FocalPlaneProblem(
|
|
297
|
+
self.model,
|
|
298
|
+
image,
|
|
299
|
+
basis=self.basis,
|
|
300
|
+
loss=loss,
|
|
301
|
+
weights=weights,
|
|
302
|
+
fit_flux=self.fit_flux,
|
|
303
|
+
fit_background=self.fit_background,
|
|
304
|
+
background=self.background,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def estimate(
|
|
308
|
+
self, image: Any, start: Any = None, *, max_iter: int | None = None, **options: Any
|
|
309
|
+
) -> Result:
|
|
310
|
+
"""Estimate the modal coefficients from one image.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
image:
|
|
315
|
+
``(my, mx)`` image in photo-electrons (NumPy or backend array).
|
|
316
|
+
start:
|
|
317
|
+
Starting point: ``None`` (flat), coefficients in metres, an OPD map
|
|
318
|
+
or a previous :class:`~solvephase.Result` (warm start in a loop).
|
|
319
|
+
max_iter:
|
|
320
|
+
Override the iteration limit.
|
|
321
|
+
**options:
|
|
322
|
+
Passed to the Levenberg-Marquardt solver (``ftol``, ``damping``).
|
|
323
|
+
|
|
324
|
+
Returns
|
|
325
|
+
-------
|
|
326
|
+
Result
|
|
327
|
+
``method="lift"``; ``coefficients`` in metres RMS per mode, the
|
|
328
|
+
known astigmatism is *not* included in ``opd``.
|
|
329
|
+
"""
|
|
330
|
+
t0 = time.perf_counter()
|
|
331
|
+
problem = self.problem(image)
|
|
332
|
+
options.setdefault("ftol", 1e-9)
|
|
333
|
+
n_iter = max_iter or self.max_iter
|
|
334
|
+
result = solve(problem, method="lm", start=start, max_iter=n_iter, **options)
|
|
335
|
+
swapped = False
|
|
336
|
+
if self.resolve_twin and result.coefficients is not None:
|
|
337
|
+
twin = self.twin(result.coefficients)
|
|
338
|
+
if _norm(twin, self.basis) < _norm(result.coefficients, self.basis):
|
|
339
|
+
# The twin fits the data equally well; prefer the smaller wavefront.
|
|
340
|
+
result = solve(problem, method="lm", start=twin, max_iter=n_iter, **options)
|
|
341
|
+
swapped = True
|
|
342
|
+
result.method = "lift"
|
|
343
|
+
result.extra["twin_swapped"] = swapped
|
|
344
|
+
result.elapsed = time.perf_counter() - t0
|
|
345
|
+
result.extra["astigmatism"] = self.astigmatism
|
|
346
|
+
result.extra["diversity_opd"] = self.diversity
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
# ----------------------------------------------------------- statistics
|
|
350
|
+
def expected_image(self, coefficients: Any = None, *, photons: float = 1.0) -> Any:
|
|
351
|
+
"""Noise-free image ``photons * PSF + background`` (backend array).
|
|
352
|
+
|
|
353
|
+
``coefficients`` are in metres (default: zero wavefront).
|
|
354
|
+
"""
|
|
355
|
+
if coefficients is None:
|
|
356
|
+
coefficients = np.zeros(self.n_modes)
|
|
357
|
+
opd = self.basis.synthesize(np.asarray(coefficients, dtype=np.float64))
|
|
358
|
+
return photons * self.model.images(opd)[0] + self.background
|
|
359
|
+
|
|
360
|
+
def fisher(self, coefficients: Any = None, *, photons: float) -> np.ndarray:
|
|
361
|
+
"""Fisher information matrix at ``coefficients`` (metres) for ``photons`` detected.
|
|
362
|
+
|
|
363
|
+
Covers the modal coefficients and, when fitted, the nuisance
|
|
364
|
+
parameters (log-flux, background), in that order, in SI units for the
|
|
365
|
+
coefficients (m^-2). Pixel noise is shifted Poisson: variance
|
|
366
|
+
``model + read_noise^2``.
|
|
367
|
+
"""
|
|
368
|
+
if photons <= 0:
|
|
369
|
+
raise ValueError("photons must be positive")
|
|
370
|
+
if coefficients is None:
|
|
371
|
+
coefficients = np.zeros(self.n_modes)
|
|
372
|
+
coeffs = np.asarray(coefficients, dtype=np.float64)
|
|
373
|
+
if coeffs.shape != (self.n_modes,):
|
|
374
|
+
raise ValueError(f"expected {self.n_modes} coefficients, got shape {coeffs.shape}")
|
|
375
|
+
image = self.expected_image(coeffs, photons=photons)
|
|
376
|
+
problem = FocalPlaneProblem(
|
|
377
|
+
self.model,
|
|
378
|
+
image,
|
|
379
|
+
basis=self.basis,
|
|
380
|
+
loss=PoissonLoss(read_noise=self.read_noise),
|
|
381
|
+
fit_flux=self.fit_flux,
|
|
382
|
+
fit_background=self.fit_background,
|
|
383
|
+
background=self.background,
|
|
384
|
+
)
|
|
385
|
+
x = problem.initial(coeffs)
|
|
386
|
+
_, _, hess = problem.gauss_newton(x)
|
|
387
|
+
fisher = np.asarray(self.backend.to_numpy(hess), dtype=np.float64)
|
|
388
|
+
k_wave = 2.0 * math.pi / self.model.wavelength
|
|
389
|
+
scale = np.ones(fisher.shape[0])
|
|
390
|
+
scale[problem.layout.coeffs] = k_wave # d/d(metres) = k d/d(radians)
|
|
391
|
+
return np.asarray(fisher * np.outer(scale, scale))
|
|
392
|
+
|
|
393
|
+
def crlb(self, coefficients: Any = None, *, photons: float) -> np.ndarray:
|
|
394
|
+
"""Cramer-Rao lower bound: covariance ``(n_modes, n_modes)`` of the coefficients in m^2.
|
|
395
|
+
|
|
396
|
+
Nuisance parameters (flux, background) are marginalized: the bound is
|
|
397
|
+
the coefficient block of the inverse Fisher matrix.
|
|
398
|
+
"""
|
|
399
|
+
fisher = self.fisher(coefficients, photons=photons)
|
|
400
|
+
cov = np.linalg.inv(fisher)
|
|
401
|
+
n = self.n_modes
|
|
402
|
+
return np.asarray(0.5 * (cov[:n, :n] + cov[:n, :n].T))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def lift(
|
|
406
|
+
image: Any,
|
|
407
|
+
pupil: Pupil,
|
|
408
|
+
wavelength: Any,
|
|
409
|
+
*,
|
|
410
|
+
sampling: float | None = None,
|
|
411
|
+
pixel_scale: float | None = None,
|
|
412
|
+
n_modes: int = 10,
|
|
413
|
+
basis: Basis | None = None,
|
|
414
|
+
astigmatism: float | None = None,
|
|
415
|
+
astigmatism_mode: int = 5,
|
|
416
|
+
read_noise: float = 0.0,
|
|
417
|
+
loss: str | Loss = "poisson",
|
|
418
|
+
fit_background: bool = False,
|
|
419
|
+
background: float = 0.0,
|
|
420
|
+
start: Any = None,
|
|
421
|
+
max_iter: int = 20,
|
|
422
|
+
device: BackendLike = "cpu",
|
|
423
|
+
precision: str | None = None,
|
|
424
|
+
**options: Any,
|
|
425
|
+
) -> Result:
|
|
426
|
+
"""Estimate low-order modes from one astigmatic focal-plane image (LIFT).
|
|
427
|
+
|
|
428
|
+
Parameters
|
|
429
|
+
----------
|
|
430
|
+
image:
|
|
431
|
+
``(my, mx)`` image in photo-electrons, taken with the known
|
|
432
|
+
astigmatism in the beam, background subtracted (or set ``background``
|
|
433
|
+
/ ``fit_background``).
|
|
434
|
+
pupil, wavelength, sampling, pixel_scale:
|
|
435
|
+
The optics, see :class:`~solvephase.FocalPlaneModel`.
|
|
436
|
+
n_modes, basis:
|
|
437
|
+
Zernike modes from tip (Noll 2), or any modal basis.
|
|
438
|
+
astigmatism:
|
|
439
|
+
Known astigmatism, RMS OPD in metres (default ``wavelength / 10``).
|
|
440
|
+
astigmatism_mode:
|
|
441
|
+
5 (oblique) or 6 (vertical astigmatism).
|
|
442
|
+
read_noise:
|
|
443
|
+
Read noise in electrons RMS.
|
|
444
|
+
loss:
|
|
445
|
+
``"poisson"`` (default, maximum likelihood) or ``"gaussian"``.
|
|
446
|
+
start:
|
|
447
|
+
Optional starting point (coefficients, OPD or Result).
|
|
448
|
+
max_iter:
|
|
449
|
+
Gauss-Newton iteration limit.
|
|
450
|
+
device, precision:
|
|
451
|
+
Backend selection.
|
|
452
|
+
**options:
|
|
453
|
+
Passed to the Levenberg-Marquardt solver.
|
|
454
|
+
|
|
455
|
+
Returns
|
|
456
|
+
-------
|
|
457
|
+
Result
|
|
458
|
+
With ``coefficients`` (metres RMS per mode) and ``opd`` (without the
|
|
459
|
+
known astigmatism).
|
|
460
|
+
|
|
461
|
+
For repeated estimates with the same optics use :class:`LIFT`, which
|
|
462
|
+
builds the model once.
|
|
463
|
+
"""
|
|
464
|
+
host_shape = tuple(getattr(image, "shape", np.shape(image)))[-2:]
|
|
465
|
+
sensor = LIFT(
|
|
466
|
+
pupil,
|
|
467
|
+
wavelength,
|
|
468
|
+
host_shape,
|
|
469
|
+
sampling=sampling,
|
|
470
|
+
pixel_scale=pixel_scale,
|
|
471
|
+
n_modes=n_modes,
|
|
472
|
+
basis=basis,
|
|
473
|
+
astigmatism=astigmatism,
|
|
474
|
+
astigmatism_mode=astigmatism_mode,
|
|
475
|
+
read_noise=read_noise,
|
|
476
|
+
loss=loss,
|
|
477
|
+
fit_background=fit_background,
|
|
478
|
+
background=background,
|
|
479
|
+
max_iter=max_iter,
|
|
480
|
+
device=device,
|
|
481
|
+
precision=precision,
|
|
482
|
+
)
|
|
483
|
+
return sensor.estimate(image, start, **options)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def lift_crlb(
|
|
487
|
+
pupil: Pupil,
|
|
488
|
+
wavelength: Any,
|
|
489
|
+
image_shape: Any,
|
|
490
|
+
coefficients: Any = None,
|
|
491
|
+
*,
|
|
492
|
+
photons: float,
|
|
493
|
+
read_noise: float = 0.0,
|
|
494
|
+
sampling: float | None = None,
|
|
495
|
+
pixel_scale: float | None = None,
|
|
496
|
+
n_modes: int = 10,
|
|
497
|
+
basis: Basis | None = None,
|
|
498
|
+
astigmatism: float | None = None,
|
|
499
|
+
astigmatism_mode: int = 5,
|
|
500
|
+
background: float = 0.0,
|
|
501
|
+
fit_flux: bool = True,
|
|
502
|
+
device: BackendLike = "cpu",
|
|
503
|
+
precision: str | None = None,
|
|
504
|
+
) -> np.ndarray:
|
|
505
|
+
"""Cramer-Rao lower bound on the LIFT modal coefficients.
|
|
506
|
+
|
|
507
|
+
The Fisher matrix ``J^T diag(1 / (m + read_noise^2)) J`` is built from the
|
|
508
|
+
model Jacobian ``J`` (forward-mode derivatives of the expected image ``m``
|
|
509
|
+
with respect to the coefficients and the flux), at the true coefficients.
|
|
510
|
+
Its inverse bounds the covariance of any unbiased estimator.
|
|
511
|
+
|
|
512
|
+
Parameters
|
|
513
|
+
----------
|
|
514
|
+
pupil, wavelength, image_shape, sampling, pixel_scale:
|
|
515
|
+
The optics.
|
|
516
|
+
coefficients:
|
|
517
|
+
True coefficients in metres (default: zero wavefront).
|
|
518
|
+
photons:
|
|
519
|
+
Total detected photo-electrons in the PSF.
|
|
520
|
+
read_noise:
|
|
521
|
+
Read noise in electrons RMS.
|
|
522
|
+
n_modes, basis, astigmatism, astigmatism_mode:
|
|
523
|
+
As for :func:`lift`.
|
|
524
|
+
background:
|
|
525
|
+
Known background in electrons per pixel (adds photon noise).
|
|
526
|
+
fit_flux:
|
|
527
|
+
Whether the flux is an unknown (marginalized) parameter.
|
|
528
|
+
device, precision:
|
|
529
|
+
Backend selection (double precision is advisable for the inverse).
|
|
530
|
+
|
|
531
|
+
Returns
|
|
532
|
+
-------
|
|
533
|
+
numpy.ndarray
|
|
534
|
+
``(n_modes, n_modes)`` covariance bound in m^2; the square root of its
|
|
535
|
+
diagonal is the smallest achievable RMS error per mode.
|
|
536
|
+
"""
|
|
537
|
+
sensor = LIFT(
|
|
538
|
+
pupil,
|
|
539
|
+
wavelength,
|
|
540
|
+
image_shape,
|
|
541
|
+
sampling=sampling,
|
|
542
|
+
pixel_scale=pixel_scale,
|
|
543
|
+
n_modes=n_modes,
|
|
544
|
+
basis=basis,
|
|
545
|
+
astigmatism=astigmatism,
|
|
546
|
+
astigmatism_mode=astigmatism_mode,
|
|
547
|
+
read_noise=read_noise,
|
|
548
|
+
fit_flux=fit_flux,
|
|
549
|
+
background=background,
|
|
550
|
+
device=device,
|
|
551
|
+
precision=precision,
|
|
552
|
+
)
|
|
553
|
+
return sensor.crlb(coefficients, photons=photons)
|