optixstuff 2.0.0__tar.gz → 3.0.0__tar.gz

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.
Files changed (24) hide show
  1. {optixstuff-2.0.0 → optixstuff-3.0.0}/CHANGELOG.md +11 -0
  2. {optixstuff-2.0.0 → optixstuff-3.0.0}/PKG-INFO +2 -1
  3. {optixstuff-2.0.0 → optixstuff-3.0.0}/pyproject.toml +1 -1
  4. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/__init__.py +8 -1
  5. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/_version.py +2 -2
  6. optixstuff-3.0.0/src/optixstuff/coronagraph.py +753 -0
  7. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/yippy_coronagraph.py +11 -8
  8. optixstuff-2.0.0/src/optixstuff/coronagraph.py +0 -193
  9. {optixstuff-2.0.0 → optixstuff-3.0.0}/.gitignore +0 -0
  10. {optixstuff-2.0.0 → optixstuff-3.0.0}/.pre-commit-config.yaml +0 -0
  11. {optixstuff-2.0.0 → optixstuff-3.0.0}/.readthedocs.yaml +0 -0
  12. {optixstuff-2.0.0 → optixstuff-3.0.0}/.zenodo.json +0 -0
  13. {optixstuff-2.0.0 → optixstuff-3.0.0}/CITATION.cff +0 -0
  14. {optixstuff-2.0.0 → optixstuff-3.0.0}/LICENSE +0 -0
  15. {optixstuff-2.0.0 → optixstuff-3.0.0}/README.md +0 -0
  16. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/_repr.py +0 -0
  17. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/detector.py +0 -0
  18. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/disperser.py +0 -0
  19. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/exposure.py +0 -0
  20. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/optical_elements.py +0 -0
  21. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/optical_path.py +0 -0
  22. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/primary.py +0 -0
  23. {optixstuff-2.0.0 → optixstuff-3.0.0}/src/optixstuff/speckle.py +0 -0
  24. {optixstuff-2.0.0 → optixstuff-3.0.0}/tools/check_internal_refs.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.0.0](https://github.com/CoreySpohn/optixstuff/compare/v2.0.0...v3.0.0) (2026-07-20)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **coronagraph:** sampling-explicit image contract -- wavelength-explicit stellar_map/source_psfs/background_transmission/extended_scene, table SPI base, MultiBandCoronagraph
9
+
10
+ ### Features
11
+
12
+ * **coronagraph:** sampling-explicit image contract -- wavelength-explicit stellar_map/source_psfs/background_transmission/extended_scene, table SPI base, MultiBandCoronagraph ([76c578b](https://github.com/CoreySpohn/optixstuff/commit/76c578be46ee79281dbb24ef44e3af4708fd7155))
13
+
3
14
  ## [2.0.0](https://github.com/CoreySpohn/optixstuff/compare/v1.5.0...v2.0.0) (2026-07-10)
4
15
 
5
16
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optixstuff
3
- Version: 2.0.0
3
+ Version: 3.0.0
4
4
  Summary: Hardware abstractions for the HWO direct imaging simulation suite
5
5
  Project-URL: Homepage, https://github.com/CoreySpohn/optixstuff
6
6
  Project-URL: Issues, https://github.com/CoreySpohn/optixstuff/issues
@@ -34,6 +34,7 @@ Classifier: Programming Language :: Python :: 3
34
34
  Classifier: Topic :: Scientific/Engineering :: Astronomy
35
35
  Requires-Python: >=3.11
36
36
  Requires-Dist: equinox>=0.12.0
37
+ Requires-Dist: hwoutils
37
38
  Requires-Dist: interpax
38
39
  Requires-Dist: jax>=0.4.1
39
40
  Requires-Dist: jaxlib>=0.4.1
@@ -16,7 +16,7 @@ classifiers = [
16
16
  "Programming Language :: Python :: 3",
17
17
  "Topic :: Scientific/Engineering :: Astronomy",
18
18
  ]
19
- dependencies = ["equinox>=0.12.0", "interpax", "jax>=0.4.1", "jaxlib>=0.4.1", "yippy"]
19
+ dependencies = ["equinox>=0.12.0", "hwoutils", "interpax", "jax>=0.4.1", "jaxlib>=0.4.1", "yippy"]
20
20
 
21
21
  [project.optional-dependencies]
22
22
  dev = ["pre-commit"]
@@ -1,7 +1,12 @@
1
1
  """optixstuff -- Hardware abstractions for the HWO simulation suite."""
2
2
 
3
3
  from optixstuff._version import __version__
4
- from optixstuff.coronagraph import AbstractCoronagraph, AbstractScalarCoronagraph
4
+ from optixstuff.coronagraph import (
5
+ AbstractCoronagraph,
6
+ AbstractScalarCoronagraph,
7
+ AbstractTableCoronagraph,
8
+ MultiBandCoronagraph,
9
+ )
5
10
  from optixstuff.detector import (
6
11
  AbstractDetector,
7
12
  Detector,
@@ -31,12 +36,14 @@ __all__ = [
31
36
  "AbstractPrimary",
32
37
  "AbstractScalarCoronagraph",
33
38
  "AbstractSpeckleField",
39
+ "AbstractTableCoronagraph",
34
40
  "AbstractUniformElement",
35
41
  "ConstantThroughput",
36
42
  "Detector",
37
43
  "ExposureConfig",
38
44
  "IdealDetector",
39
45
  "LensletDisperser",
46
+ "MultiBandCoronagraph",
40
47
  "OpticalPath",
41
48
  "SegmentedPrimary",
42
49
  "SimplePrimary",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.0.0'
22
- __version_tuple__ = version_tuple = (2, 0, 0)
21
+ __version__ = version = '3.0.0'
22
+ __version_tuple__ = version_tuple = (3, 0, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,753 @@
1
+ """Coronagraph abstractions."""
2
+
3
+ import abc
4
+
5
+ import equinox as eqx
6
+ import jax
7
+ import jax.numpy as jnp
8
+ from equinox import AbstractVar
9
+ from hwoutils.transforms import resample_flux
10
+ from jax.typing import ArrayLike
11
+ from jaxtyping import Array
12
+
13
+
14
+ def _convolve_quadrants(flux, psf_datacube):
15
+ """Convolve flux with a quarter-symmetric PSF datacube via fold-and-sum.
16
+
17
+ Handles padding dynamically to ensure all quadrants match the shape of the
18
+ first quadrant (which defines the PSF datacube shape).
19
+ """
20
+ ny, nx = flux.shape
21
+ cy, cx = (ny - 1) // 2, (nx - 1) // 2
22
+
23
+ # Q1: top-right (includes center pixel and axes) -> reference shape
24
+ q1 = flux[cy:, cx:]
25
+ target_h, target_w = q1.shape
26
+
27
+ # Q2: top-left -- flip X, pad inner-left + outer-right to target width
28
+ q2_raw = flux[cy:, :cx]
29
+ q2_flipped = q2_raw[:, ::-1]
30
+ pad_q2_right = max(0, target_w - (q2_flipped.shape[1] + 1))
31
+ q2 = jnp.pad(q2_flipped, ((0, 0), (1, pad_q2_right)))
32
+
33
+ # Q3: bottom-left -- flip both, pad inner-top, inner-left, outer-bottom/right
34
+ q3_raw = flux[:cy, :cx]
35
+ q3_flipped = q3_raw[::-1, ::-1]
36
+ pad_q3_bottom = max(0, target_h - (q3_flipped.shape[0] + 1))
37
+ pad_q3_right = max(0, target_w - (q3_flipped.shape[1] + 1))
38
+ q3 = jnp.pad(q3_flipped, ((1, pad_q3_bottom), (1, pad_q3_right)))
39
+
40
+ # Q4: bottom-right -- flip Y, pad inner-top + outer-bottom
41
+ q4_raw = flux[:cy, cx:]
42
+ q4_flipped = q4_raw[::-1, :]
43
+ pad_q4_bottom = max(0, target_h - (q4_flipped.shape[0] + 1))
44
+ q4 = jnp.pad(q4_flipped, ((1, pad_q4_bottom), (0, 0)))
45
+
46
+ flux_stack = jnp.stack([q1, q2, q3, q4])
47
+ partial_images = jnp.einsum("qij,ijxy->qxy", flux_stack, psf_datacube)
48
+
49
+ img_q1 = partial_images[0]
50
+ img_q2 = jnp.fliplr(partial_images[1])
51
+ img_q3 = jnp.flipud(jnp.fliplr(partial_images[2]))
52
+ img_q4 = jnp.flipud(partial_images[3])
53
+
54
+ return img_q1 + img_q2 + img_q3 + img_q4
55
+
56
+
57
+ class AbstractCoronagraph(eqx.Module):
58
+ """Abstract interface for coronagraph performance models.
59
+
60
+ Provides scalar performance curves (for ETC use), single-PSF
61
+ generation, and a sampling-explicit image contract (for image
62
+ simulation): ``stellar_map`` / ``source_psfs`` /
63
+ ``background_transmission`` / ``extended_scene`` serve maps at the
64
+ caller's requested pixel scale and shape, so backend-internal grids
65
+ never leak downstream. Implementations can be backed by pre-computed
66
+ interpolation tables (subclass :class:`AbstractTableCoronagraph`,
67
+ which serves the image contract from native-grid tables), physical
68
+ wavefront propagation, or analytical models.
69
+
70
+ All wavelength arguments are in nanometres throughout.
71
+ All separations and pixel scales are in lambda/D units -- the
72
+ contract is dimensionless optics; angle conversions are the
73
+ telescope primary's business.
74
+ """
75
+
76
+ pixel_scale_lod: AbstractVar[float]
77
+ """Native pixel scale in lambda/D per pixel."""
78
+
79
+ IWA: AbstractVar[float]
80
+ """Inner working angle in lambda/D."""
81
+
82
+ OWA: AbstractVar[float]
83
+ """Outer working angle in lambda/D."""
84
+
85
+ # ------------------------------------------------------------------
86
+ # Scalar interface -- consumed by jaxEDITH and yield estimators
87
+ # ------------------------------------------------------------------
88
+
89
+ @abc.abstractmethod
90
+ def throughput(
91
+ self,
92
+ separation_lod: ArrayLike,
93
+ wavelength_nm: ArrayLike,
94
+ *,
95
+ time_s: ArrayLike = 0.0,
96
+ ) -> ArrayLike:
97
+ """Core (off-axis planet) throughput.
98
+
99
+ Args:
100
+ separation_lod: Angular separation in lambda/D.
101
+ wavelength_nm: Wavelength in nanometres.
102
+ time_s: Time since mission start in seconds.
103
+
104
+ Returns:
105
+ Fractional throughput in [0, 1].
106
+ """
107
+ ...
108
+
109
+ @abc.abstractmethod
110
+ def core_area(
111
+ self,
112
+ separation_lod: ArrayLike,
113
+ wavelength_nm: ArrayLike,
114
+ *,
115
+ time_s: ArrayLike = 0.0,
116
+ ) -> ArrayLike:
117
+ """Photometric aperture area in (lambda/D)^2.
118
+
119
+ Args:
120
+ separation_lod: Angular separation in lambda/D.
121
+ wavelength_nm: Wavelength in nanometres.
122
+ time_s: Time since mission start in seconds.
123
+
124
+ Returns:
125
+ Core area in (lambda/D)^2.
126
+ """
127
+ ...
128
+
129
+ @abc.abstractmethod
130
+ def core_mean_intensity(
131
+ self,
132
+ separation_lod: ArrayLike,
133
+ wavelength_nm: ArrayLike,
134
+ *,
135
+ time_s: ArrayLike = 0.0,
136
+ ) -> ArrayLike:
137
+ """Mean stellar intensity within the photometric aperture.
138
+
139
+ Args:
140
+ separation_lod: Angular separation in lambda/D.
141
+ wavelength_nm: Wavelength in nanometres.
142
+ time_s: Time since mission start in seconds.
143
+
144
+ Returns:
145
+ Mean stellar leakage intensity in (lambda/D)^-2.
146
+ """
147
+ ...
148
+
149
+ @abc.abstractmethod
150
+ def occulter_transmission(
151
+ self,
152
+ separation_lod: ArrayLike,
153
+ wavelength_nm: ArrayLike,
154
+ *,
155
+ time_s: ArrayLike = 0.0,
156
+ ) -> ArrayLike:
157
+ """Off-axis (sky/zodi) transmission through the occulter.
158
+
159
+ Args:
160
+ separation_lod: Angular separation in lambda/D.
161
+ wavelength_nm: Wavelength in nanometres.
162
+ time_s: Time since mission start in seconds.
163
+
164
+ Returns:
165
+ Fractional sky transmission in [0, 1].
166
+ """
167
+ ...
168
+
169
+ # ------------------------------------------------------------------
170
+ # Single-PSF image interface
171
+ # ------------------------------------------------------------------
172
+ #
173
+ # Subsumed by the sampling-explicit contract below (``stellar_map``
174
+ # additionally carries the stellar diameter; ``source_psfs`` the 2D
175
+ # position) but retained for existing single-PSF consumers.
176
+
177
+ @abc.abstractmethod
178
+ def on_axis_psf(
179
+ self,
180
+ wavelength_nm: ArrayLike,
181
+ pixel_scale_rad: float,
182
+ npixels: int,
183
+ ) -> Array:
184
+ """On-axis (stellar leakage) PSF.
185
+
186
+ Returns the coronagraphic PSF for an on-axis point source,
187
+ normalized to unit stellar flux before the coronagraph. For a
188
+ finite stellar diameter use :meth:`stellar_map`, which carries
189
+ the diameter axis this signature lacks.
190
+
191
+ Args:
192
+ wavelength_nm: Wavelength in nanometres.
193
+ pixel_scale_rad: Output pixel scale in radians/pixel.
194
+ npixels: Output array side length in pixels. Must be a
195
+ Python int (not a JAX array) as it determines the
196
+ output shape at compile time.
197
+
198
+ Returns:
199
+ 2D float array of shape (npixels, npixels).
200
+ """
201
+ ...
202
+
203
+ @abc.abstractmethod
204
+ def off_axis_psf(
205
+ self,
206
+ wavelength_nm: ArrayLike,
207
+ separation_lod: ArrayLike,
208
+ pixel_scale_rad: float,
209
+ npixels: int,
210
+ ) -> Array:
211
+ """Off-axis PSF at a given angular separation.
212
+
213
+ Args:
214
+ wavelength_nm: Wavelength in nanometres.
215
+ separation_lod: Source separation in lambda/D.
216
+ pixel_scale_rad: Output pixel scale in radians/pixel.
217
+ npixels: Output array side length in pixels. Must be a
218
+ Python int (not a JAX array) as it determines the
219
+ output shape at compile time.
220
+
221
+ Returns:
222
+ 2D float array of shape (npixels, npixels).
223
+ """
224
+ ...
225
+
226
+ # ------------------------------------------------------------------
227
+ # Sampling-explicit image contract -- consumed by coronagraphoto
228
+ # ------------------------------------------------------------------
229
+ #
230
+ # Maps are served AT the requested target sampling (in practice the
231
+ # detector grid), so callers never see a backend's internal grid.
232
+
233
+ @abc.abstractmethod
234
+ def stellar_map(
235
+ self,
236
+ wavelength_nm: ArrayLike,
237
+ stellar_diam_lod: ArrayLike,
238
+ *,
239
+ pixel_scale_lod: ArrayLike,
240
+ shape: tuple[int, int],
241
+ ) -> Array:
242
+ """On-axis stellar leakage map at the requested sampling.
243
+
244
+ Args:
245
+ wavelength_nm: Wavelength in nanometres.
246
+ stellar_diam_lod: Stellar angular diameter in lambda/D.
247
+ pixel_scale_lod: Target pixel scale in lambda/D per pixel.
248
+ shape: Target array shape (ny, nx).
249
+
250
+ Returns:
251
+ Map of shape ``shape`` in fraction of pre-coronagraph
252
+ stellar photons per target pixel.
253
+ """
254
+ ...
255
+
256
+ @abc.abstractmethod
257
+ def source_psfs(
258
+ self,
259
+ wavelength_nm: ArrayLike,
260
+ x_lod: ArrayLike,
261
+ y_lod: ArrayLike,
262
+ *,
263
+ pixel_scale_lod: ArrayLike,
264
+ shape: tuple[int, int],
265
+ ) -> Array:
266
+ """Off-axis point-source PSFs at the requested sampling.
267
+
268
+ Args:
269
+ wavelength_nm: Wavelength in nanometres.
270
+ x_lod: Source x-coordinates in lambda/D, shape (K,).
271
+ y_lod: Source y-coordinates in lambda/D, shape (K,).
272
+ pixel_scale_lod: Target pixel scale in lambda/D per pixel.
273
+ shape: Target array shape (ny, nx).
274
+
275
+ Returns:
276
+ Stack of shape (K, ny, nx) in fraction of source photons
277
+ per target pixel.
278
+ """
279
+ ...
280
+
281
+ @abc.abstractmethod
282
+ def background_transmission(
283
+ self,
284
+ wavelength_nm: ArrayLike,
285
+ *,
286
+ pixel_scale_lod: ArrayLike,
287
+ shape: tuple[int, int],
288
+ ) -> Array:
289
+ """Per-pixel transmission map for a spatially uniform background.
290
+
291
+ Args:
292
+ wavelength_nm: Wavelength in nanometres.
293
+ pixel_scale_lod: Target pixel scale in lambda/D per pixel.
294
+ shape: Target array shape (ny, nx).
295
+
296
+ Returns:
297
+ Dimensionless transmission map of shape ``shape`` (value
298
+ semantics, not a flux).
299
+ """
300
+ ...
301
+
302
+ @abc.abstractmethod
303
+ def extended_scene(
304
+ self,
305
+ scene_map: Array,
306
+ map_pixel_scale_lod: ArrayLike,
307
+ wavelength_nm: ArrayLike,
308
+ *,
309
+ pixel_scale_lod: ArrayLike,
310
+ shape: tuple[int, int],
311
+ rotation_deg: ArrayLike = 0.0,
312
+ ) -> Array:
313
+ """Render an extended incoherent scene through the coronagraph.
314
+
315
+ Args:
316
+ scene_map: Per-pixel source intensity in any units; the
317
+ output carries the same units per target pixel.
318
+ map_pixel_scale_lod: Pixel scale of ``scene_map`` in
319
+ lambda/D per pixel.
320
+ wavelength_nm: Wavelength in nanometres.
321
+ pixel_scale_lod: Target pixel scale in lambda/D per pixel.
322
+ shape: Target array shape (ny, nx).
323
+ rotation_deg: CCW rotation applied when mapping the scene
324
+ into the coronagraph frame (sky-to-detector roll).
325
+
326
+ Returns:
327
+ The scene redistributed by the per-position off-axis PSFs,
328
+ shape ``shape``.
329
+ """
330
+ ...
331
+
332
+
333
+ class AbstractTableCoronagraph(AbstractCoronagraph):
334
+ """Base for coronagraphs backed by native-grid interpolation tables.
335
+
336
+ Declares the table SPI -- the native-grid maps a table backend holds
337
+ (``stellar_intens``, ``create_psfs``, ``sky_trans``, ``psf_shape``,
338
+ ``psf_datacube``) -- and serves the sampling-explicit image contract
339
+ from it with flux-conserving resamples to the requested target grid.
340
+ Scalar performance curves remain the subclass's business (a table
341
+ backend interpolates its own curve tables).
342
+
343
+ The tables are monochromatic by construction (a YIP is a per-band
344
+ file), so the served maps ignore ``wavelength_nm`` beyond the
345
+ caller's own lambda/D sampling conversion; stack per-band instances
346
+ in a :class:`MultiBandCoronagraph` for a broadband model.
347
+ """
348
+
349
+ # -- table SPI ---------------------------------------------------------
350
+ # AbstractVar members may be satisfied by a dataclass field or a
351
+ # property (a concrete base property would shadow subclass fields).
352
+
353
+ psf_shape: AbstractVar[tuple]
354
+ """Native map shape (ny, nx) shared by the table members."""
355
+
356
+ sky_trans: AbstractVar[Array]
357
+ """Native-grid transmission map for a spatially uniform background."""
358
+
359
+ psf_datacube: AbstractVar[Array | None]
360
+ """Optional per-source-position PSF library for extended scenes.
361
+
362
+ ``None`` means the table was built without one; :meth:`extended_scene`
363
+ then raises. Full ``(ny, nx, ny, nx)`` and quarter-symmetric
364
+ ``(ny//2+1, nx//2+1, ny, nx)`` layouts are supported.
365
+ """
366
+
367
+ @abc.abstractmethod
368
+ def stellar_intens(self, stellar_diam_lod: ArrayLike) -> Array:
369
+ """Native-grid stellar leakage map for a stellar angular diameter.
370
+
371
+ The diameter axis is a genuine table capability (a YIP tabulates
372
+ finite-size stellar maps); implementations interpolate over it.
373
+ """
374
+ ...
375
+
376
+ @abc.abstractmethod
377
+ def create_psfs(self, x_lod: ArrayLike, y_lod: ArrayLike) -> Array:
378
+ """Native-grid off-axis PSF stack at (x_lod, y_lod), shape (K, ny, nx)."""
379
+ ...
380
+
381
+ # -- single-PSF interface served from the tables -----------------------
382
+
383
+ def on_axis_psf(
384
+ self,
385
+ wavelength_nm: ArrayLike,
386
+ pixel_scale_rad: float,
387
+ npixels: int,
388
+ ) -> Array:
389
+ """Point-source stellar leakage map on the native grid.
390
+
391
+ Table backends serve their native sampling; the requested
392
+ ``pixel_scale_rad`` / ``npixels`` are accepted for interface
393
+ conformance only. Use :meth:`stellar_map` for target-sampled
394
+ maps and finite stellar diameters.
395
+ """
396
+ return self.stellar_intens(0.0)
397
+
398
+ def off_axis_psf(
399
+ self,
400
+ wavelength_nm: ArrayLike,
401
+ separation_lod: ArrayLike,
402
+ pixel_scale_rad: float,
403
+ npixels: int,
404
+ ) -> Array:
405
+ """Off-axis PSF at +x separation on the native grid.
406
+
407
+ Sampling arguments are accepted for interface conformance only;
408
+ use :meth:`source_psfs` for target-sampled maps.
409
+ """
410
+ return self.create_psfs(jnp.atleast_1d(separation_lod), jnp.zeros(1))[0]
411
+
412
+ # -- sampling-explicit contract served from the tables -----------------
413
+
414
+ def stellar_map(
415
+ self,
416
+ wavelength_nm: ArrayLike,
417
+ stellar_diam_lod: ArrayLike,
418
+ *,
419
+ pixel_scale_lod: ArrayLike,
420
+ shape: tuple[int, int],
421
+ ) -> Array:
422
+ """Native ``stellar_intens`` resampled (flux-conserving) to target."""
423
+ return resample_flux(
424
+ self.stellar_intens(stellar_diam_lod),
425
+ self.pixel_scale_lod,
426
+ pixel_scale_lod,
427
+ shape,
428
+ 0.0,
429
+ )
430
+
431
+ def source_psfs(
432
+ self,
433
+ wavelength_nm: ArrayLike,
434
+ x_lod: ArrayLike,
435
+ y_lod: ArrayLike,
436
+ *,
437
+ pixel_scale_lod: ArrayLike,
438
+ shape: tuple[int, int],
439
+ ) -> Array:
440
+ """Native ``create_psfs`` stack resampled (flux-conserving) to target."""
441
+
442
+ def _to_target(psf):
443
+ return resample_flux(psf, self.pixel_scale_lod, pixel_scale_lod, shape, 0.0)
444
+
445
+ return jax.vmap(_to_target)(self.create_psfs(x_lod, y_lod))
446
+
447
+ def background_transmission(
448
+ self,
449
+ wavelength_nm: ArrayLike,
450
+ *,
451
+ pixel_scale_lod: ArrayLike,
452
+ shape: tuple[int, int],
453
+ ) -> Array:
454
+ """Native ``sky_trans`` resampled to target with value semantics.
455
+
456
+ A transmission is a per-pixel value, not a flux, so the
457
+ pixel-area scaling of the flux-conserving resample is undone.
458
+ """
459
+ resampled = resample_flux(
460
+ self.sky_trans, self.pixel_scale_lod, pixel_scale_lod, shape, 0.0
461
+ )
462
+ return resampled * (self.pixel_scale_lod / pixel_scale_lod) ** 2
463
+
464
+ def extended_scene(
465
+ self,
466
+ scene_map: Array,
467
+ map_pixel_scale_lod: ArrayLike,
468
+ wavelength_nm: ArrayLike,
469
+ *,
470
+ pixel_scale_lod: ArrayLike,
471
+ shape: tuple[int, int],
472
+ rotation_deg: ArrayLike = 0.0,
473
+ ) -> Array:
474
+ """Scene convolved with the ``psf_datacube`` PSF library.
475
+
476
+ Resamples the scene to the native source grid (applying the
477
+ sky-to-detector rotation), convolves via the full or
478
+ quarter-symmetric datacube, and resamples the result to the
479
+ target grid.
480
+
481
+ Raises:
482
+ ValueError: if :attr:`psf_datacube` is ``None``, or its
483
+ source grid matches neither the full nor the quarter
484
+ PSF shape.
485
+ """
486
+ datacube = self.psf_datacube
487
+ if datacube is None:
488
+ raise ValueError(
489
+ "extended_scene requires a coronagraph with a PSF "
490
+ f"datacube; got {type(self).__name__}.psf_datacube=None. "
491
+ "The table implementation convolves the scene with the "
492
+ "per-source-position PSFs and cannot run without it."
493
+ )
494
+ ny, nx = self.psf_shape
495
+ src = resample_flux(
496
+ scene_map,
497
+ map_pixel_scale_lod,
498
+ self.pixel_scale_lod,
499
+ (ny, nx),
500
+ rotation_deg,
501
+ )
502
+ n_src_y, n_src_x = datacube.shape[:2]
503
+ if (n_src_y, n_src_x) == (ny, nx):
504
+ native = jnp.einsum("ij,ijxy->xy", src, datacube)
505
+ elif (n_src_y, n_src_x) == (ny // 2 + 1, nx // 2 + 1):
506
+ native = _convolve_quadrants(src, datacube)
507
+ else:
508
+ raise ValueError(
509
+ "extended_scene: psf_datacube source-grid shape "
510
+ f"({n_src_y}, {n_src_x}) does not match either the full "
511
+ f"PSF shape ({ny}, {nx}) or the quarter PSF shape "
512
+ f"({ny // 2 + 1}, {nx // 2 + 1}). Coronagraphs must "
513
+ "publish a full or quarter datacube."
514
+ )
515
+ return resample_flux(native, self.pixel_scale_lod, pixel_scale_lod, shape, 0.0)
516
+
517
+
518
+ class AbstractScalarCoronagraph(AbstractCoronagraph):
519
+ """Base for ETC-only coronagraph models that lack 2D PSF generation.
520
+
521
+ Stubs out the single-PSF interface and the sampling-explicit image
522
+ contract with zero arrays so the class satisfies AbstractCoronagraph
523
+ without requiring a full optical model. Do NOT use this base for a
524
+ model that carries native-grid tables -- subclass
525
+ :class:`AbstractTableCoronagraph` instead, or the image pipeline
526
+ will silently render zeros.
527
+ """
528
+
529
+ def on_axis_psf(
530
+ self,
531
+ wavelength_nm: ArrayLike,
532
+ pixel_scale_rad: float,
533
+ npixels: int,
534
+ ) -> Array:
535
+ """Return a zero PSF (not implemented for scalar-only models)."""
536
+ return jnp.zeros((npixels, npixels))
537
+
538
+ def off_axis_psf(
539
+ self,
540
+ wavelength_nm: ArrayLike,
541
+ separation_lod: ArrayLike,
542
+ pixel_scale_rad: float,
543
+ npixels: int,
544
+ ) -> Array:
545
+ """Return a zero PSF (not implemented for scalar-only models)."""
546
+ return jnp.zeros((npixels, npixels))
547
+
548
+ def stellar_map(
549
+ self,
550
+ wavelength_nm: ArrayLike,
551
+ stellar_diam_lod: ArrayLike,
552
+ *,
553
+ pixel_scale_lod: ArrayLike,
554
+ shape: tuple[int, int],
555
+ ) -> Array:
556
+ """Return a zero map (not implemented for scalar-only models)."""
557
+ return jnp.zeros(shape)
558
+
559
+ def source_psfs(
560
+ self,
561
+ wavelength_nm: ArrayLike,
562
+ x_lod: ArrayLike,
563
+ y_lod: ArrayLike,
564
+ *,
565
+ pixel_scale_lod: ArrayLike,
566
+ shape: tuple[int, int],
567
+ ) -> Array:
568
+ """Return zero PSFs (not implemented for scalar-only models)."""
569
+ k = jnp.atleast_1d(jnp.asarray(x_lod)).shape[0]
570
+ return jnp.zeros((k, *shape))
571
+
572
+ def background_transmission(
573
+ self,
574
+ wavelength_nm: ArrayLike,
575
+ *,
576
+ pixel_scale_lod: ArrayLike,
577
+ shape: tuple[int, int],
578
+ ) -> Array:
579
+ """Return a zero map (not implemented for scalar-only models)."""
580
+ return jnp.zeros(shape)
581
+
582
+ def extended_scene(
583
+ self,
584
+ scene_map: Array,
585
+ map_pixel_scale_lod: ArrayLike,
586
+ wavelength_nm: ArrayLike,
587
+ *,
588
+ pixel_scale_lod: ArrayLike,
589
+ shape: tuple[int, int],
590
+ rotation_deg: ArrayLike = 0.0,
591
+ ) -> Array:
592
+ """Return a zero map (not implemented for scalar-only models)."""
593
+ return jnp.zeros(shape)
594
+
595
+
596
+ class MultiBandCoronagraph(AbstractCoronagraph):
597
+ """A broadband coronagraph as a stack of per-band models.
598
+
599
+ Every contract call dispatches to the band whose declared center is
600
+ nearest the requested ``wavelength_nm`` and forwards the call
601
+ unchanged. Because the contract is sampling-explicit in lambda/D,
602
+ chromatic magnification needs no handling here: the caller already
603
+ converts its detector grid to lambda/D at each wavelength.
604
+
605
+ The per-band models can be anything implementing
606
+ ``AbstractCoronagraph`` -- per-band YIP tables
607
+ (:class:`optixstuff.YippyCoronagraph`) or per-wavelength compiled
608
+ views of a live propagation model -- and can be mixed.
609
+
610
+ ``wavelength_nm`` must be concrete (a Python/NumPy scalar, not a
611
+ traced JAX value) at dispatch time: bands are distinct pytrees with
612
+ distinct shapes, so the selection is Python control flow. This
613
+ matches the per-bin structure of the rate pipeline, where the
614
+ wavelength is a static observation parameter.
615
+
616
+ Scalar metadata (``pixel_scale_lod`` / ``IWA`` / ``OWA``) is served
617
+ from the band nearest ``reference_wavelength_nm`` (default: the
618
+ first band).
619
+ """
620
+
621
+ band_centers_nm: tuple = eqx.field(static=True)
622
+ bands: tuple
623
+ reference_wavelength_nm: float = eqx.field(static=True)
624
+
625
+ def __init__(self, band_centers_nm, bands, *, reference_wavelength_nm=None):
626
+ """Build a multi-band stack.
627
+
628
+ Args:
629
+ band_centers_nm: Band center wavelengths in nanometres, one
630
+ per band.
631
+ bands: The per-band ``AbstractCoronagraph`` models, aligned
632
+ with ``band_centers_nm``.
633
+ reference_wavelength_nm: Wavelength whose band serves the
634
+ scalar metadata. Default: the first band's center.
635
+ """
636
+ centers = tuple(float(c) for c in band_centers_nm)
637
+ bands = tuple(bands)
638
+ if len(centers) != len(bands):
639
+ raise ValueError(
640
+ f"{len(centers)} band centers but {len(bands)} band models"
641
+ )
642
+ if not bands:
643
+ raise ValueError("MultiBandCoronagraph needs at least one band")
644
+ self.band_centers_nm = centers
645
+ self.bands = bands
646
+ self.reference_wavelength_nm = float(
647
+ centers[0] if reference_wavelength_nm is None else reference_wavelength_nm
648
+ )
649
+
650
+ def band(self, wavelength_nm) -> AbstractCoronagraph:
651
+ """The band model whose center is nearest ``wavelength_nm``."""
652
+ wl = float(wavelength_nm)
653
+ index = min(
654
+ range(len(self.band_centers_nm)),
655
+ key=lambda i: abs(self.band_centers_nm[i] - wl),
656
+ )
657
+ return self.bands[index]
658
+
659
+ # -- scalar metadata from the reference band ---------------------------
660
+
661
+ @property
662
+ def pixel_scale_lod(self) -> float:
663
+ """Native pixel scale of the reference band."""
664
+ return self.band(self.reference_wavelength_nm).pixel_scale_lod
665
+
666
+ @property
667
+ def IWA(self) -> float:
668
+ """Inner working angle of the reference band."""
669
+ return self.band(self.reference_wavelength_nm).IWA
670
+
671
+ @property
672
+ def OWA(self) -> float:
673
+ """Outer working angle of the reference band."""
674
+ return self.band(self.reference_wavelength_nm).OWA
675
+
676
+ # -- every interface dispatches on wavelength --------------------------
677
+
678
+ def throughput(self, separation_lod, wavelength_nm, *, time_s=0.0):
679
+ """Core throughput from the nearest band."""
680
+ return self.band(wavelength_nm).throughput(
681
+ separation_lod, wavelength_nm, time_s=time_s
682
+ )
683
+
684
+ def core_area(self, separation_lod, wavelength_nm, *, time_s=0.0):
685
+ """Photometric aperture area from the nearest band."""
686
+ return self.band(wavelength_nm).core_area(
687
+ separation_lod, wavelength_nm, time_s=time_s
688
+ )
689
+
690
+ def core_mean_intensity(self, separation_lod, wavelength_nm, *, time_s=0.0):
691
+ """Mean stellar leakage from the nearest band."""
692
+ return self.band(wavelength_nm).core_mean_intensity(
693
+ separation_lod, wavelength_nm, time_s=time_s
694
+ )
695
+
696
+ def occulter_transmission(self, separation_lod, wavelength_nm, *, time_s=0.0):
697
+ """Sky transmission from the nearest band."""
698
+ return self.band(wavelength_nm).occulter_transmission(
699
+ separation_lod, wavelength_nm, time_s=time_s
700
+ )
701
+
702
+ def on_axis_psf(self, wavelength_nm, pixel_scale_rad, npixels):
703
+ """On-axis PSF from the nearest band."""
704
+ return self.band(wavelength_nm).on_axis_psf(
705
+ wavelength_nm, pixel_scale_rad, npixels
706
+ )
707
+
708
+ def off_axis_psf(self, wavelength_nm, separation_lod, pixel_scale_rad, npixels):
709
+ """Off-axis PSF from the nearest band."""
710
+ return self.band(wavelength_nm).off_axis_psf(
711
+ wavelength_nm, separation_lod, pixel_scale_rad, npixels
712
+ )
713
+
714
+ def stellar_map(self, wavelength_nm, stellar_diam_lod, *, pixel_scale_lod, shape):
715
+ """Stellar leakage map from the nearest band."""
716
+ return self.band(wavelength_nm).stellar_map(
717
+ wavelength_nm,
718
+ stellar_diam_lod,
719
+ pixel_scale_lod=pixel_scale_lod,
720
+ shape=shape,
721
+ )
722
+
723
+ def source_psfs(self, wavelength_nm, x_lod, y_lod, *, pixel_scale_lod, shape):
724
+ """Off-axis PSF stack from the nearest band."""
725
+ return self.band(wavelength_nm).source_psfs(
726
+ wavelength_nm, x_lod, y_lod, pixel_scale_lod=pixel_scale_lod, shape=shape
727
+ )
728
+
729
+ def background_transmission(self, wavelength_nm, *, pixel_scale_lod, shape):
730
+ """Background transmission map from the nearest band."""
731
+ return self.band(wavelength_nm).background_transmission(
732
+ wavelength_nm, pixel_scale_lod=pixel_scale_lod, shape=shape
733
+ )
734
+
735
+ def extended_scene(
736
+ self,
737
+ scene_map,
738
+ map_pixel_scale_lod,
739
+ wavelength_nm,
740
+ *,
741
+ pixel_scale_lod,
742
+ shape,
743
+ rotation_deg=0.0,
744
+ ):
745
+ """Extended-scene render from the nearest band."""
746
+ return self.band(wavelength_nm).extended_scene(
747
+ scene_map,
748
+ map_pixel_scale_lod,
749
+ wavelength_nm,
750
+ pixel_scale_lod=pixel_scale_lod,
751
+ shape=shape,
752
+ rotation_deg=rotation_deg,
753
+ )
@@ -1,23 +1,26 @@
1
1
  """YippyCoronagraph -- AbstractCoronagraph backed by a yippy EqxCoronagraph."""
2
2
 
3
-
4
3
  from typing import final
5
4
 
6
5
  from jax.typing import ArrayLike
7
6
  from jaxtyping import Array
8
7
  from yippy import EqxCoronagraph
9
8
 
10
- from optixstuff.coronagraph import AbstractCoronagraph
9
+ from optixstuff.coronagraph import AbstractTableCoronagraph
11
10
 
12
11
 
13
12
  @final
14
- class YippyCoronagraph(AbstractCoronagraph):
13
+ class YippyCoronagraph(AbstractTableCoronagraph):
15
14
  """Coronagraph performance model backed by a yippy YIP interpolation table.
16
15
 
17
- Wraps a yippy ``EqxCoronagraph`` via composition, adapting its methods
18
- to the ``AbstractCoronagraph`` interface. The ``_backend`` field is
19
- itself an ``eqx.Module``, so its internal JAX arrays flow through
20
- ``filter_jit`` and ``filter_grad`` normally.
16
+ Wraps a yippy ``EqxCoronagraph`` via composition: the scalar curves
17
+ adapt the backend's interpolation tables, and the native-grid table
18
+ SPI (``stellar_intens`` / ``create_psfs`` / ``sky_trans`` /
19
+ ``psf_shape`` / ``psf_datacube``) delegates to the backend, from
20
+ which :class:`AbstractTableCoronagraph` serves the sampling-explicit
21
+ image contract. The ``_backend`` field is itself an ``eqx.Module``,
22
+ so its internal JAX arrays flow through ``filter_jit`` and
23
+ ``filter_grad`` normally.
21
24
 
22
25
  Construction mirrors ``EqxCoronagraph`` -- pass either a YIP path or
23
26
  an existing ``EqxCoronagraph`` instance::
@@ -133,7 +136,7 @@ class YippyCoronagraph(AbstractCoronagraph):
133
136
  """
134
137
  return self._backend.create_psf(separation_lod, 0.0, npixels)
135
138
 
136
- # -- Convenience methods (not on AbstractCoronagraph) ------------------
139
+ # -- Convenience methods and the table SPI ----------------------------
137
140
 
138
141
  def noise_floor_ayo(
139
142
  self,
@@ -1,193 +0,0 @@
1
- """Coronagraph abstractions."""
2
-
3
- import abc
4
-
5
- import equinox as eqx
6
- import jax.numpy as jnp
7
- from equinox import AbstractVar
8
- from jax.typing import ArrayLike
9
- from jaxtyping import Array
10
-
11
-
12
- class AbstractCoronagraph(eqx.Module):
13
- """Abstract interface for coronagraph performance models.
14
-
15
- Provides both scalar performance curves (for ETC use) and 2D PSF
16
- generation (for image simulation). Implementations can be backed by
17
- pre-computed interpolation tables (yippy), physical wavefront
18
- propagation, or analytical models.
19
-
20
- All wavelength arguments are in nanometres throughout.
21
- All separations are in lambda/D units.
22
- """
23
-
24
- pixel_scale_lod: AbstractVar[float]
25
- """Native pixel scale in lambda/D per pixel."""
26
-
27
- IWA: AbstractVar[float]
28
- """Inner working angle in lambda/D."""
29
-
30
- OWA: AbstractVar[float]
31
- """Outer working angle in lambda/D."""
32
-
33
- # ------------------------------------------------------------------
34
- # Scalar interface -- consumed by jaxEDITH and yield estimators
35
- # ------------------------------------------------------------------
36
-
37
- @abc.abstractmethod
38
- def throughput(
39
- self,
40
- separation_lod: ArrayLike,
41
- wavelength_nm: ArrayLike,
42
- *,
43
- time_s: ArrayLike = 0.0,
44
- ) -> ArrayLike:
45
- """Core (off-axis planet) throughput.
46
-
47
- Args:
48
- separation_lod: Angular separation in lambda/D.
49
- wavelength_nm: Wavelength in nanometres.
50
- time_s: Time since mission start in seconds.
51
-
52
- Returns:
53
- Fractional throughput in [0, 1].
54
- """
55
- ...
56
-
57
- @abc.abstractmethod
58
- def core_area(
59
- self,
60
- separation_lod: ArrayLike,
61
- wavelength_nm: ArrayLike,
62
- *,
63
- time_s: ArrayLike = 0.0,
64
- ) -> ArrayLike:
65
- """Photometric aperture area in (lambda/D)^2.
66
-
67
- Args:
68
- separation_lod: Angular separation in lambda/D.
69
- wavelength_nm: Wavelength in nanometres.
70
- time_s: Time since mission start in seconds.
71
-
72
- Returns:
73
- Core area in (lambda/D)^2.
74
- """
75
- ...
76
-
77
- @abc.abstractmethod
78
- def core_mean_intensity(
79
- self,
80
- separation_lod: ArrayLike,
81
- wavelength_nm: ArrayLike,
82
- *,
83
- time_s: ArrayLike = 0.0,
84
- ) -> ArrayLike:
85
- """Mean stellar intensity within the photometric aperture.
86
-
87
- Args:
88
- separation_lod: Angular separation in lambda/D.
89
- wavelength_nm: Wavelength in nanometres.
90
- time_s: Time since mission start in seconds.
91
-
92
- Returns:
93
- Mean stellar leakage intensity in (lambda/D)^-2.
94
- """
95
- ...
96
-
97
- @abc.abstractmethod
98
- def occulter_transmission(
99
- self,
100
- separation_lod: ArrayLike,
101
- wavelength_nm: ArrayLike,
102
- *,
103
- time_s: ArrayLike = 0.0,
104
- ) -> ArrayLike:
105
- """Off-axis (sky/zodi) transmission through the occulter.
106
-
107
- Args:
108
- separation_lod: Angular separation in lambda/D.
109
- wavelength_nm: Wavelength in nanometres.
110
- time_s: Time since mission start in seconds.
111
-
112
- Returns:
113
- Fractional sky transmission in [0, 1].
114
- """
115
- ...
116
-
117
- # ------------------------------------------------------------------
118
- # Image interface -- consumed by coronagraphoto
119
- # ------------------------------------------------------------------
120
-
121
- @abc.abstractmethod
122
- def on_axis_psf(
123
- self,
124
- wavelength_nm: ArrayLike,
125
- pixel_scale_rad: float,
126
- npixels: int,
127
- ) -> Array:
128
- """On-axis (stellar leakage) PSF.
129
-
130
- Returns the coronagraphic PSF for an on-axis point source,
131
- normalized to unit stellar flux before the coronagraph.
132
-
133
- Args:
134
- wavelength_nm: Wavelength in nanometres.
135
- pixel_scale_rad: Output pixel scale in radians/pixel.
136
- npixels: Output array side length in pixels. Must be a
137
- Python int (not a JAX array) as it determines the
138
- output shape at compile time.
139
-
140
- Returns:
141
- 2D float array of shape (npixels, npixels).
142
- """
143
- ...
144
-
145
- @abc.abstractmethod
146
- def off_axis_psf(
147
- self,
148
- wavelength_nm: ArrayLike,
149
- separation_lod: ArrayLike,
150
- pixel_scale_rad: float,
151
- npixels: int,
152
- ) -> Array:
153
- """Off-axis PSF at a given angular separation.
154
-
155
- Args:
156
- wavelength_nm: Wavelength in nanometres.
157
- separation_lod: Source separation in lambda/D.
158
- pixel_scale_rad: Output pixel scale in radians/pixel.
159
- npixels: Output array side length in pixels. Must be a
160
- Python int (not a JAX array) as it determines the
161
- output shape at compile time.
162
-
163
- Returns:
164
- 2D float array of shape (npixels, npixels).
165
- """
166
- ...
167
-
168
-
169
- class AbstractScalarCoronagraph(AbstractCoronagraph):
170
- """Base for ETC-only coronagraph models that lack 2D PSF generation.
171
-
172
- Stubs out the image interface with zero arrays so the class satisfies
173
- AbstractCoronagraph without requiring a full optical model.
174
- """
175
-
176
- def on_axis_psf(
177
- self,
178
- wavelength_nm: ArrayLike,
179
- pixel_scale_rad: float,
180
- npixels: int,
181
- ) -> Array:
182
- """Return a zero PSF (not implemented for scalar-only models)."""
183
- return jnp.zeros((npixels, npixels))
184
-
185
- def off_axis_psf(
186
- self,
187
- wavelength_nm: ArrayLike,
188
- separation_lod: ArrayLike,
189
- pixel_scale_rad: float,
190
- npixels: int,
191
- ) -> Array:
192
- """Return a zero PSF (not implemented for scalar-only models)."""
193
- return jnp.zeros((npixels, npixels))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes