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/api.py ADDED
@@ -0,0 +1,228 @@
1
+ """One-call focal-plane phase retrieval: :func:`retrieve`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time
7
+ from collections.abc import Sequence
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from .algorithms.gerchberg_saxton import gerchberg_saxton
13
+ from .backend import BackendLike, to_numpy
14
+ from .basis import Basis
15
+ from .focal import FocalPlaneModel, zernike_diversity
16
+ from .losses import Loss
17
+ from .pupil import Pupil
18
+ from .result import Result
19
+ from .retrieval import FocalPlaneProblem, solve
20
+
21
+ __all__ = ["retrieve"]
22
+
23
+
24
+ def _as_pupil(pupil: Pupil | int, diameter: float | None) -> Pupil:
25
+ if isinstance(pupil, Pupil):
26
+ return pupil
27
+ if isinstance(pupil, (int, np.integer)):
28
+ return Pupil.circular(int(pupil), diameter or 1.0)
29
+ raise TypeError("pupil must be a Pupil or an int (grid size of a circular pupil)")
30
+
31
+
32
+ def _as_diversity(pupil: Pupil, diversity: Any, n_images: int) -> np.ndarray | None:
33
+ if diversity is None:
34
+ if n_images > 1:
35
+ raise ValueError(
36
+ f"{n_images} images were given but no diversity: give one OPD map (or one "
37
+ "defocus RMS in metres) per image"
38
+ )
39
+ return None
40
+ if isinstance(diversity, dict):
41
+ noll = int(diversity.get("noll", 4))
42
+ return zernike_diversity(pupil, noll, diversity["amounts"])
43
+ if np.ndim(diversity) == 1 and all(np.isscalar(v) for v in diversity):
44
+ return zernike_diversity(pupil, 4, [float(v) for v in diversity])
45
+ maps = np.asarray(diversity, dtype=np.float64)
46
+ if maps.ndim == 2:
47
+ maps = maps[None]
48
+ return maps
49
+
50
+
51
+ def _as_basis(pupil: Pupil, basis: Any) -> Basis | None:
52
+ if basis is None or (isinstance(basis, str) and basis.lower() == "zonal"):
53
+ return None
54
+ if isinstance(basis, Basis):
55
+ return basis
56
+ if isinstance(basis, (int, np.integer)):
57
+ return Basis.zernike(pupil, int(basis))
58
+ raise TypeError("basis must be a Basis, an int (number of Zernike modes) or 'zonal'")
59
+
60
+
61
+ def retrieve(
62
+ images: Any,
63
+ pupil: Pupil | int,
64
+ wavelength: float | Sequence[float],
65
+ *,
66
+ sampling: float | None = None,
67
+ pixel_scale: float | None = None,
68
+ diversity: Any = None,
69
+ basis: Basis | int | str | None = 36,
70
+ zonal_refinement: bool = False,
71
+ method: str = "auto",
72
+ loss: str | Loss = "poisson",
73
+ read_noise: float = 0.0,
74
+ weights: Any = None,
75
+ fit_background: bool = True,
76
+ fit_tilt: bool = False,
77
+ spectral_weights: Sequence[float] | None = None,
78
+ oversample: int = 1,
79
+ diameter: float | None = None,
80
+ start: Any = None,
81
+ max_iter: int | None = None,
82
+ device: BackendLike = "cpu",
83
+ precision: str | None = None,
84
+ ) -> Result:
85
+ """Retrieve a pupil wavefront from focal-plane images in one call.
86
+
87
+ Parameters
88
+ ----------
89
+ images:
90
+ ``(K, my, mx)`` images (or one ``(my, mx)`` image), ideally in
91
+ photo-electrons, background included.
92
+ pupil:
93
+ A :class:`~solvephase.Pupil`, or an int ``n`` for an ``n x n``
94
+ circular pupil of ``diameter`` (default 1 m).
95
+ wavelength:
96
+ Wavelength in metres, or an array sampling the band.
97
+ sampling, pixel_scale:
98
+ Detector sampling in pixels per lambda/D, or pixel scale in radians
99
+ (give one).
100
+ diversity:
101
+ Known per-image aberrations: ``(K, ny, nx)`` OPD maps in metres; a
102
+ list of ``K`` numbers, read as defocus (Noll 4) RMS in metres per
103
+ image; or ``{"noll": j, "amounts": [...]}``. Required for more than one
104
+ image.
105
+ basis:
106
+ Wavefront parameterization: number of Zernike modes (default 36,
107
+ tip/tilt upward), a :class:`~solvephase.Basis`, or ``"zonal"``.
108
+ zonal_refinement:
109
+ After the modal solve, refine pixel by pixel (L-BFGS).
110
+ method:
111
+ ``"auto"`` (robust multi-start: Levenberg-Marquardt from a flat start
112
+ and from Gerchberg-Saxton, best kept, then a maximum-likelihood
113
+ polish), ``"lm"``, ``"lbfgs"``, ``"gs"`` (Gerchberg-Saxton/Misell only)
114
+ or ``"gs+lm"``.
115
+ loss:
116
+ Final data term: ``"poisson"`` (default; images in photo-electrons),
117
+ ``"gaussian"`` or ``"amplitude"``.
118
+ read_noise:
119
+ Read noise in electrons (Poisson loss).
120
+ weights:
121
+ Per-pixel weights; zero masks bad pixels.
122
+ fit_background:
123
+ Fit a constant background per image.
124
+ fit_tilt:
125
+ Fit a registration tip/tilt per image relative to the first.
126
+ spectral_weights:
127
+ Relative photon flux per wavelength for broadband data.
128
+ oversample:
129
+ Model pixels per detector pixel per axis (pixel integration).
130
+ diameter:
131
+ Diameter in metres when ``pupil`` is an int.
132
+ start:
133
+ Initial guess: OPD map, coefficients or a previous :class:`Result`.
134
+ max_iter:
135
+ Iteration limit of the final stage.
136
+ device, precision:
137
+ ``"cpu"``, ``"gpu"`` or ``"auto"``; ``"single"`` or ``"double"``.
138
+
139
+ Returns
140
+ -------
141
+ Result
142
+ ``result.opd`` (metres), ``result.coefficients``, model images,
143
+ flux/background, convergence history. ``result.extra["stages"]``
144
+ records each stage's loss and time.
145
+
146
+ Examples
147
+ --------
148
+ >>> import solvephase as sp
149
+ >>> pupil = sp.Pupil.circular(64, 8.0, obscuration=0.14)
150
+ >>> result = sp.retrieve(images, pupil, 1.6e-6, sampling=2.0,
151
+ ... diversity=[0.0, 0.4e-6]) # doctest: +SKIP
152
+ >>> result.opd, result.coefficients # doctest: +SKIP
153
+ """
154
+ t0 = time.perf_counter()
155
+ pupil = _as_pupil(pupil, diameter)
156
+ data = np.asarray(to_numpy(images), dtype=np.float64)
157
+ if data.ndim == 2:
158
+ data = data[None]
159
+ if data.ndim != 3:
160
+ raise ValueError(f"images must be (my, mx) or (K, my, mx), got shape {data.shape}")
161
+ div = _as_diversity(pupil, diversity, data.shape[0])
162
+ if div is not None and div.shape[0] != data.shape[0]:
163
+ raise ValueError(f"{div.shape[0]} diversity maps for {data.shape[0]} images")
164
+ model = FocalPlaneModel(
165
+ pupil,
166
+ wavelength,
167
+ data.shape[-2:],
168
+ sampling=sampling,
169
+ pixel_scale=pixel_scale,
170
+ weights=spectral_weights,
171
+ diversity=div,
172
+ oversample=oversample,
173
+ device=device,
174
+ precision=precision,
175
+ )
176
+ modal = _as_basis(pupil, basis)
177
+ common: dict[str, Any] = {
178
+ "weights": weights,
179
+ "fit_background": fit_background,
180
+ "fit_tilt": fit_tilt and data.shape[0] > 1,
181
+ "read_noise": read_noise,
182
+ }
183
+ final = FocalPlaneProblem(model, data, basis=modal, loss=loss, **common)
184
+ stages: list[dict[str, Any]] = []
185
+
186
+ def record(name: str, result: Result) -> Result:
187
+ stages.append(
188
+ {
189
+ "stage": name,
190
+ "loss": float(final.value(final.initial(result))),
191
+ "time": result.elapsed,
192
+ }
193
+ )
194
+ return result
195
+
196
+ method = method.lower()
197
+ if method == "gs":
198
+ result = gerchberg_saxton(model, data, basis=modal, weights=weights)
199
+ best = record("gs", result)
200
+ elif method in ("lm", "lbfgs"):
201
+ if method == "lm" and modal is None:
202
+ raise ValueError("method='lm' needs a modal basis; use 'lbfgs' for a zonal solve")
203
+ best = record(method, solve(final, method=method, start=start, max_iter=max_iter))
204
+ elif method in ("auto", "gs+lm"):
205
+ explorer = modal if modal is not None else Basis.zernike(pupil, 36)
206
+ capture = FocalPlaneProblem(model, data, basis=explorer, loss="amplitude", **common)
207
+ candidates: list[Result] = []
208
+ if method == "auto" or start is not None:
209
+ candidates.append(record("lm-amplitude", solve(capture, method="lm", start=start)))
210
+ gs = gerchberg_saxton(model, data, iterations=200, weights=weights, start=start)
211
+ candidates.append(record("gs->lm-amplitude", solve(capture, method="lm", start=gs)))
212
+ best = min(candidates, key=lambda r: final.value(final.initial(r)))
213
+ polish_method = "lm" if modal is not None else "lbfgs"
214
+ best = record("polish", solve(final, method=polish_method, start=best, max_iter=max_iter))
215
+ else:
216
+ raise ValueError(f"unknown method {method!r}; use 'auto', 'lm', 'lbfgs', 'gs' or 'gs+lm'")
217
+
218
+ if zonal_refinement and modal is not None:
219
+ zonal = FocalPlaneProblem(model, data, basis=None, loss=loss, **common)
220
+ refined = solve(zonal, method="lbfgs", start=best, max_iter=max_iter or 300)
221
+ stages.append({"stage": "zonal", "loss": refined.loss, "time": refined.elapsed})
222
+ best = refined
223
+ best.elapsed = time.perf_counter() - t0
224
+ best.extra["stages"] = stages
225
+ best.extra["model"] = model
226
+ if not math.isfinite(best.loss):
227
+ best.loss = float(final.value(final.initial(best)))
228
+ return best
solvephase/backend.py ADDED
@@ -0,0 +1,24 @@
1
+ """Array backends: re-exported from :mod:`aocore.backend` (the stack's shared backend).
2
+
3
+ See :mod:`aocore.backend` for the full documentation.
4
+ """
5
+
6
+ from aocore.backend import (
7
+ Backend,
8
+ BackendLike,
9
+ _cpu_workers,
10
+ backend_of,
11
+ get_backend,
12
+ gpu_available,
13
+ to_numpy,
14
+ )
15
+
16
+ __all__ = [
17
+ "Backend",
18
+ "BackendLike",
19
+ "_cpu_workers",
20
+ "backend_of",
21
+ "get_backend",
22
+ "gpu_available",
23
+ "to_numpy",
24
+ ]