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