optixstuff 0.0.1__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.
optixstuff/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """optixstuff -- Hardware abstractions for the HWO simulation suite."""
2
+
3
+ from optixstuff._version import __version__
4
+ from optixstuff.coronagraph import AbstractCoronagraph, AbstractScalarOnlyCoronagraph
5
+ from optixstuff.detector import (
6
+ AbstractDetector,
7
+ Detector,
8
+ SimpleDetector,
9
+ simulate_cic,
10
+ simulate_dark_current,
11
+ simulate_read_noise,
12
+ )
13
+ from optixstuff.optical_elements import (
14
+ AbstractOpticalElement,
15
+ AbstractUniformElement,
16
+ ConstantThroughputElement,
17
+ LinearThroughputElement,
18
+ OpticalFilter,
19
+ )
20
+ from optixstuff.optical_path import OpticalPath
21
+ from optixstuff.primary import AbstractPrimary, SimplePrimary
22
+ from optixstuff.yippy_coronagraph import YippyCoronagraph
23
+
24
+ __all__ = [
25
+ "AbstractCoronagraph",
26
+ "AbstractDetector",
27
+ "AbstractOpticalElement",
28
+ "AbstractPrimary",
29
+ "AbstractScalarOnlyCoronagraph",
30
+ "AbstractUniformElement",
31
+ "ConstantThroughputElement",
32
+ "Detector",
33
+ "LinearThroughputElement",
34
+ "OpticalFilter",
35
+ "OpticalPath",
36
+ "SimpleDetector",
37
+ "SimplePrimary",
38
+ "YippyCoronagraph",
39
+ "__version__",
40
+ "simulate_cic",
41
+ "simulate_dark_current",
42
+ "simulate_read_noise",
43
+ ]
optixstuff/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,193 @@
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 AbstractScalarOnlyCoronagraph(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))
optixstuff/detector.py ADDED
@@ -0,0 +1,324 @@
1
+ """Detector abstractions and concrete implementations."""
2
+
3
+
4
+ import abc
5
+ from typing import final
6
+
7
+ import equinox as eqx
8
+ import jax
9
+ import jax.numpy as jnp
10
+ from equinox import AbstractVar
11
+ from jax.typing import ArrayLike
12
+ from jaxtyping import Array
13
+
14
+
15
+ class AbstractDetector(eqx.Module):
16
+ """Abstract interface for a focal-plane detector.
17
+
18
+ Provides both scalar noise rates (for ETC use) and stochastic noise
19
+ realization (for image simulation). All concrete implementations
20
+ must define the hardware parameters listed as ``AbstractVar`` fields.
21
+ """
22
+
23
+ pixel_scale: AbstractVar[float]
24
+ """Detector plate scale in arcsec/pixel."""
25
+
26
+ quantum_efficiency: AbstractVar[float]
27
+ """Baseline quantum efficiency as a fraction in [0, 1]."""
28
+
29
+ dark_current_rate: AbstractVar[float]
30
+ """Dark current rate in electrons/pixel/second."""
31
+
32
+ read_noise_electrons: AbstractVar[float]
33
+ """Read noise in electrons RMS per pixel per read."""
34
+
35
+ cic_rate: AbstractVar[float]
36
+ """Clock-induced charge in electrons/pixel/frame."""
37
+
38
+ frame_time: AbstractVar[float]
39
+ """Integration time per frame/read in seconds."""
40
+
41
+ read_time: AbstractVar[float]
42
+ """Time per read cycle in seconds (for RN^2/t_read in ETC)."""
43
+
44
+ dqe: AbstractVar[float]
45
+ """QE degradation factor (multiplicative correction over mission life)."""
46
+
47
+ shape: AbstractVar[tuple[int, int]]
48
+ """Detector dimensions (ny, nx) in pixels."""
49
+
50
+ @abc.abstractmethod
51
+ def get_qe(self, wavelength_nm: ArrayLike) -> ArrayLike:
52
+ """Quantum efficiency at a given wavelength.
53
+
54
+ Args:
55
+ wavelength_nm: Wavelength in nanometres.
56
+
57
+ Returns:
58
+ QE as a fraction in [0, 1].
59
+ """
60
+ ...
61
+
62
+ @abc.abstractmethod
63
+ def scalar_noise_rate(self, n_pix: ArrayLike, t_photon: ArrayLike) -> ArrayLike:
64
+ """Total scalar noise variance rate for the ETC.
65
+
66
+ Returns the combined noise variance per unit time (electrons^2/s)
67
+ for a photometric aperture of n_pix pixels.
68
+
69
+ Args:
70
+ n_pix: Number of pixels in the photometric aperture.
71
+ t_photon: Photon counting integration time in seconds.
72
+
73
+ Returns:
74
+ Noise variance rate in electrons^2/second.
75
+ """
76
+ ...
77
+
78
+ @abc.abstractmethod
79
+ def add_noise(
80
+ self,
81
+ image_rate: Array,
82
+ exposure_time: ArrayLike,
83
+ prng_key: Array,
84
+ ) -> Array:
85
+ """Apply stochastic noise realization to a photon rate image.
86
+
87
+ Converts photon rates to detected electrons including QE,
88
+ dark current, CIC, and read noise.
89
+
90
+ Args:
91
+ image_rate: Incident photon rate array in ph/s/pixel.
92
+ exposure_time: Exposure time in seconds.
93
+ prng_key: JAX PRNG key (required, no default).
94
+
95
+ Returns:
96
+ Detected electrons array, same shape as image_rate.
97
+ """
98
+ ...
99
+
100
+
101
+ # -- Pure noise simulation functions -----------------------------------------
102
+
103
+
104
+ def simulate_dark_current(
105
+ dark_current_rate: float,
106
+ exposure_time: ArrayLike,
107
+ shape: tuple[int, int],
108
+ prng_key: Array,
109
+ ) -> Array:
110
+ """Draw dark current electrons from a Poisson distribution.
111
+
112
+ Args:
113
+ dark_current_rate: Dark current rate in electrons/s/pixel.
114
+ exposure_time: Exposure time in seconds.
115
+ shape: Detector shape (ny, nx).
116
+ prng_key: PRNG key.
117
+
118
+ Returns:
119
+ Dark current electrons, shape (ny, nx).
120
+ """
121
+ return jax.random.poisson(prng_key, dark_current_rate * exposure_time, shape=shape)
122
+
123
+
124
+ def simulate_cic(
125
+ cic_rate: float,
126
+ num_frames: ArrayLike,
127
+ shape: tuple[int, int],
128
+ prng_key: Array,
129
+ ) -> Array:
130
+ """Draw clock-induced charge electrons from a Poisson distribution.
131
+
132
+ Args:
133
+ cic_rate: CIC rate in electrons/pixel/frame.
134
+ num_frames: Number of frames (kept as float for JIT safety).
135
+ shape: Detector shape (ny, nx).
136
+ prng_key: PRNG key.
137
+
138
+ Returns:
139
+ CIC electrons, shape (ny, nx).
140
+ """
141
+ return jax.random.poisson(prng_key, cic_rate * num_frames, shape=shape)
142
+
143
+
144
+ def simulate_read_noise(
145
+ read_noise: float,
146
+ num_frames: ArrayLike,
147
+ shape: tuple[int, int],
148
+ prng_key: Array,
149
+ ) -> Array:
150
+ """Draw read noise from a Gaussian distribution.
151
+
152
+ Total read noise sigma = sqrt(num_frames) * read_noise_per_read.
153
+
154
+ Args:
155
+ read_noise: Read noise in electrons/pixel/read.
156
+ num_frames: Number of frames.
157
+ shape: Detector shape (ny, nx).
158
+ prng_key: PRNG key.
159
+
160
+ Returns:
161
+ Read noise electrons, shape (ny, nx).
162
+ """
163
+ sigma = read_noise * jnp.sqrt(num_frames)
164
+ return sigma * jax.random.normal(prng_key, shape=shape)
165
+
166
+
167
+ # -- Concrete implementations -----------------------------------------------
168
+
169
+
170
+ @final
171
+ class SimpleDetector(AbstractDetector):
172
+ """Detector with constant QE and minimal noise sources.
173
+
174
+ Suitable for broadband imager studies where wavelength-dependent
175
+ QE variation is not important and CIC/read noise are negligible.
176
+ """
177
+
178
+ pixel_scale: float
179
+ quantum_efficiency: float
180
+ dark_current_rate: float
181
+ read_noise_electrons: float
182
+ cic_rate: float
183
+ frame_time: float
184
+ read_time: float
185
+ dqe: float
186
+ shape: tuple[int, int] = eqx.field(static=True)
187
+
188
+ def __init__(
189
+ self,
190
+ pixel_scale: float,
191
+ shape: tuple[int, int],
192
+ quantum_efficiency: float = 1.0,
193
+ dark_current_rate: float = 0.0,
194
+ read_noise_electrons: float = 0.0,
195
+ cic_rate: float = 0.0,
196
+ frame_time: float = 1.0,
197
+ read_time: float = 0.05,
198
+ dqe: float = 0.0,
199
+ ) -> None:
200
+ """Create a simple constant-QE detector."""
201
+ self.pixel_scale = pixel_scale
202
+ self.shape = shape
203
+ self.quantum_efficiency = quantum_efficiency
204
+ self.dark_current_rate = dark_current_rate
205
+ self.read_noise_electrons = read_noise_electrons
206
+ self.cic_rate = cic_rate
207
+ self.frame_time = frame_time
208
+ self.read_time = read_time
209
+ self.dqe = dqe
210
+
211
+ def get_qe(self, wavelength_nm: ArrayLike) -> ArrayLike:
212
+ """Return constant QE, ignoring wavelength."""
213
+ return self.quantum_efficiency
214
+
215
+ def scalar_noise_rate(self, n_pix: ArrayLike, t_photon: ArrayLike) -> ArrayLike:
216
+ """Combined dark + CIC noise variance rate.
217
+
218
+ Read noise is not included here as it scales per-read, not per-second.
219
+ Callers add (read_noise^2 * n_reads) / t_exp separately.
220
+ """
221
+ dark_variance_rate = self.dark_current_rate * n_pix
222
+ cic_variance_rate = self.cic_rate * n_pix / t_photon
223
+ return dark_variance_rate + cic_variance_rate
224
+
225
+ def add_noise(
226
+ self,
227
+ image_rate: Array,
228
+ exposure_time: ArrayLike,
229
+ prng_key: Array,
230
+ ) -> Array:
231
+ """Apply dark current noise to a photon rate image."""
232
+ key_phot, key_qe, key_dark = jax.random.split(prng_key, 3)
233
+
234
+ inc_photons = jax.random.poisson(key_phot, image_rate * exposure_time)
235
+ qe = self.quantum_efficiency
236
+ photo_electrons = jax.random.binomial(key_qe, inc_photons, qe)
237
+
238
+ dark = simulate_dark_current(
239
+ self.dark_current_rate, exposure_time, self.shape, key_dark
240
+ )
241
+ return photo_electrons + dark
242
+
243
+
244
+ @final
245
+ class Detector(AbstractDetector):
246
+ """Full detector model with dark current, CIC, and read noise.
247
+
248
+ Suitable for detailed noise simulations where all detector noise
249
+ sources matter. Uses Poisson statistics for dark/CIC and Gaussian
250
+ for read noise, matching the coronagraphoto convention.
251
+
252
+ Warning: ``num_frames = jnp.ceil(exposure_time / frame_time)`` is
253
+ kept as a float. Never cast it to int inside JIT -- that triggers a
254
+ ConcretizationTypeError when exposure_time is traced.
255
+ """
256
+
257
+ pixel_scale: float
258
+ quantum_efficiency: float
259
+ dark_current_rate: float
260
+ read_noise_electrons: float
261
+ cic_rate: float
262
+ frame_time: float
263
+ read_time: float
264
+ dqe: float
265
+ shape: tuple[int, int] = eqx.field(static=True)
266
+
267
+ def __init__(
268
+ self,
269
+ pixel_scale: float,
270
+ shape: tuple[int, int],
271
+ quantum_efficiency: float = 1.0,
272
+ dark_current_rate: float = 0.0,
273
+ read_noise_electrons: float = 0.0,
274
+ cic_rate: float = 0.0,
275
+ frame_time: float = 1.0,
276
+ read_time: float = 0.05,
277
+ dqe: float = 0.0,
278
+ ) -> None:
279
+ """Create a full detector with all noise sources."""
280
+ self.pixel_scale = pixel_scale
281
+ self.shape = shape
282
+ self.quantum_efficiency = quantum_efficiency
283
+ self.dark_current_rate = dark_current_rate
284
+ self.read_noise_electrons = read_noise_electrons
285
+ self.cic_rate = cic_rate
286
+ self.frame_time = frame_time
287
+ self.read_time = read_time
288
+ self.dqe = dqe
289
+
290
+ def get_qe(self, wavelength_nm: ArrayLike) -> ArrayLike:
291
+ """Return constant QE, ignoring wavelength."""
292
+ return self.quantum_efficiency
293
+
294
+ def scalar_noise_rate(self, n_pix: ArrayLike, t_photon: ArrayLike) -> ArrayLike:
295
+ """Combined dark + CIC noise variance rate."""
296
+ dark_variance_rate = self.dark_current_rate * n_pix
297
+ cic_variance_rate = self.cic_rate * n_pix / t_photon
298
+ return dark_variance_rate + cic_variance_rate
299
+
300
+ def add_noise(
301
+ self,
302
+ image_rate: Array,
303
+ exposure_time: ArrayLike,
304
+ prng_key: Array,
305
+ ) -> Array:
306
+ """Apply all noise sources: QE, dark current, CIC, and read noise."""
307
+ key_phot, key_qe, key_dark, key_cic, key_read = jax.random.split(prng_key, 5)
308
+
309
+ inc_photons = jax.random.poisson(key_phot, image_rate * exposure_time)
310
+ qe = self.quantum_efficiency
311
+ photo_electrons = jax.random.binomial(key_qe, inc_photons, qe)
312
+
313
+ dark = simulate_dark_current(
314
+ self.dark_current_rate, exposure_time, self.shape, key_dark
315
+ )
316
+
317
+ # num_frames stays as a traced float -- never cast to int
318
+ num_frames = jnp.ceil(exposure_time / self.frame_time)
319
+ cic = simulate_cic(self.cic_rate, num_frames, self.shape, key_cic)
320
+ read = simulate_read_noise(
321
+ self.read_noise_electrons, num_frames, self.shape, key_read
322
+ )
323
+
324
+ return photo_electrons + dark + cic + read
@@ -0,0 +1,132 @@
1
+ """Optical element abstractions (throughput, filters, field stops)."""
2
+
3
+
4
+ import abc
5
+ from typing import final
6
+
7
+ import equinox as eqx
8
+ import interpax
9
+ import jax.numpy as jnp
10
+ from jax.typing import ArrayLike
11
+ from jaxtyping import Array
12
+
13
+
14
+ class AbstractOpticalElement(eqx.Module):
15
+ """Abstract interface for an optical element in the beam path.
16
+
17
+ Elements reduce photon flux via wavelength-dependent throughput.
18
+ The ETC calls get_throughput() for scalar efficiency calculations.
19
+ The simulator calls apply() to attenuate 2D photon arrays.
20
+
21
+ Both methods are abstract: use AbstractUniformElement for elements
22
+ with spatially uniform throughput, which provides a default apply().
23
+ """
24
+
25
+ @abc.abstractmethod
26
+ def get_throughput(self, wavelength_nm: ArrayLike) -> ArrayLike:
27
+ """Fractional throughput at a given wavelength.
28
+
29
+ Args:
30
+ wavelength_nm: Wavelength in nanometres.
31
+
32
+ Returns:
33
+ Scalar throughput in [0, 1].
34
+ """
35
+ ...
36
+
37
+ @abc.abstractmethod
38
+ def apply(self, arr: Array, wavelength_nm: ArrayLike) -> Array:
39
+ """Apply this element to a 2D photon array.
40
+
41
+ Args:
42
+ arr: Input photon rate array [ph/s/pixel].
43
+ wavelength_nm: Wavelength in nanometres.
44
+
45
+ Returns:
46
+ Attenuated photon rate array, same shape as arr.
47
+ """
48
+ ...
49
+
50
+
51
+ class AbstractUniformElement(AbstractOpticalElement):
52
+ """Base for elements with spatially uniform throughput.
53
+
54
+ Provides a default apply() that multiplies the array by the scalar
55
+ throughput. Override apply() for elements with spatially varying
56
+ transmission (e.g., field-dependent filter transmission maps).
57
+ """
58
+
59
+ def apply(self, arr: Array, wavelength_nm: ArrayLike) -> Array:
60
+ """Apply uniform throughput to a photon array."""
61
+ return arr * self.get_throughput(wavelength_nm)
62
+
63
+
64
+ @final
65
+ class ConstantThroughputElement(AbstractUniformElement):
66
+ """An optical element with wavelength-independent throughput.
67
+
68
+ Useful for modeling simple attenuators, beamsplitters, or as a
69
+ placeholder during instrument design studies.
70
+ """
71
+
72
+ throughput: float
73
+ name: str = eqx.field(default="element", static=True)
74
+
75
+ def get_throughput(self, wavelength_nm: ArrayLike) -> ArrayLike:
76
+ """Return constant throughput, ignoring wavelength."""
77
+ return self.throughput
78
+
79
+
80
+ @final
81
+ class LinearThroughputElement(AbstractUniformElement):
82
+ """An optical element with linearly interpolated wavelength-dependent throughput.
83
+
84
+ Throughput is specified at a set of wavelengths and linearly
85
+ interpolated between them. Extrapolation returns zero outside the
86
+ defined wavelength range.
87
+ """
88
+
89
+ wavelengths_nm: Array
90
+ throughputs: Array
91
+ interp: interpax.Interpolator1D
92
+
93
+ def __init__(self, wavelengths_nm: Array, throughputs: Array) -> None:
94
+ """Create a throughput element from sampled wavelength/throughput pairs."""
95
+ self.wavelengths_nm = wavelengths_nm
96
+ self.throughputs = throughputs
97
+ self.interp = interpax.Interpolator1D(
98
+ wavelengths_nm, throughputs, method="linear", extrap=jnp.array([0.0, 0.0])
99
+ )
100
+
101
+ def get_throughput(self, wavelength_nm: ArrayLike) -> ArrayLike:
102
+ """Interpolate throughput at the requested wavelength."""
103
+ return self.interp(wavelength_nm)
104
+
105
+
106
+ @final
107
+ class OpticalFilter(AbstractUniformElement):
108
+ """A bandpass filter with linearly interpolated transmittance.
109
+
110
+ Structurally identical to LinearThroughputElement but semantically
111
+ distinct -- represents a spectral bandpass selection rather than a
112
+ reflective coating or attenuator.
113
+ """
114
+
115
+ wavelengths_nm: Array
116
+ transmittances: Array
117
+ interp: interpax.Interpolator1D
118
+
119
+ def __init__(self, wavelengths_nm: Array, transmittances: Array) -> None:
120
+ """Create an optical filter from sampled wavelength/transmittance pairs."""
121
+ self.wavelengths_nm = wavelengths_nm
122
+ self.transmittances = transmittances
123
+ self.interp = interpax.Interpolator1D(
124
+ wavelengths_nm,
125
+ transmittances,
126
+ method="linear",
127
+ extrap=jnp.array([0.0, 0.0]),
128
+ )
129
+
130
+ def get_throughput(self, wavelength_nm: ArrayLike) -> ArrayLike:
131
+ """Interpolate filter transmittance at the requested wavelength."""
132
+ return self.interp(wavelength_nm)
@@ -0,0 +1,47 @@
1
+ """OpticalPath container -- the universal hardware configuration."""
2
+
3
+ import functools
4
+
5
+ import equinox as eqx
6
+
7
+ from optixstuff.coronagraph import AbstractCoronagraph
8
+ from optixstuff.detector import AbstractDetector
9
+ from optixstuff.optical_elements import AbstractOpticalElement
10
+ from optixstuff.primary import AbstractPrimary
11
+
12
+
13
+ class OpticalPath(eqx.Module):
14
+ """Universal hardware container for a coronagraphic telescope.
15
+
16
+ Bundles a primary mirror, ordered chain of attenuating elements,
17
+ a coronagraph, and a detector into a single configuration object.
18
+ This is the interface passed to simulators (coronagraphoto),
19
+ exposure time calculators (jaxEDITH), and IFS instruments (coronachrome).
20
+
21
+ Args:
22
+ primary: Primary mirror description.
23
+ attenuating_elements: Ordered tuple of throughput elements
24
+ between the primary and coronagraph (mirrors, filters, etc.).
25
+ coronagraph: Coronagraph performance model.
26
+ detector: Focal-plane detector model.
27
+ """
28
+
29
+ primary: AbstractPrimary
30
+ attenuating_elements: tuple[AbstractOpticalElement, ...]
31
+ coronagraph: AbstractCoronagraph
32
+ detector: AbstractDetector
33
+
34
+ def system_throughput(self, wavelength_nm: float) -> float:
35
+ """Total throughput of all attenuating elements.
36
+
37
+ Args:
38
+ wavelength_nm: Wavelength in nanometres.
39
+
40
+ Returns:
41
+ Combined fractional throughput in [0, 1].
42
+ """
43
+ return functools.reduce(
44
+ lambda acc, el: acc * el.get_throughput(wavelength_nm),
45
+ self.attenuating_elements,
46
+ 1.0,
47
+ )
optixstuff/primary.py ADDED
@@ -0,0 +1,58 @@
1
+ """Primary mirror abstractions."""
2
+
3
+ import equinox as eqx
4
+ import jax.numpy as jnp
5
+ from equinox import AbstractVar
6
+
7
+
8
+ class AbstractPrimary(eqx.Module):
9
+ """Abstract interface for a primary aperture.
10
+
11
+ Any concrete implementation must provide the diameter and collecting
12
+ area of the primary mirror as scalar values in SI units. These are
13
+ consumed by exposure time calculators and simulation tools alike.
14
+ """
15
+
16
+ diameter_m: AbstractVar[float]
17
+ """Primary mirror diameter in metres."""
18
+
19
+ area_m2: AbstractVar[float]
20
+ """Effective collecting area in square metres."""
21
+
22
+
23
+ class SimplePrimary(AbstractPrimary):
24
+ """A simple circular primary mirror with a central obscuration.
25
+
26
+ Args:
27
+ diameter_m: Primary mirror diameter in metres.
28
+ obscuration: Linear obscuration fraction (0 = no obscuration).
29
+ shape_factor: Fraction of unobscured area that is collecting
30
+ (accounts for struts, segment gaps, etc.). Default 1.0.
31
+ """
32
+
33
+ _diameter_m: float
34
+ obscuration: float
35
+ shape_factor: float
36
+
37
+ def __init__(
38
+ self,
39
+ diameter_m: float,
40
+ obscuration: float = 0.0,
41
+ shape_factor: float = 1.0,
42
+ ) -> None:
43
+ """Create a simple circular primary mirror."""
44
+ self._diameter_m = diameter_m
45
+ self.obscuration = obscuration
46
+ self.shape_factor = shape_factor
47
+
48
+ @property
49
+ def diameter_m(self) -> float:
50
+ """Primary mirror diameter in metres."""
51
+ return self._diameter_m
52
+
53
+ @property
54
+ def area_m2(self) -> float:
55
+ """Effective collecting area in square metres."""
56
+ r = self._diameter_m / 2.0
57
+ gross_area = jnp.pi * r**2
58
+ return gross_area * (1.0 - self.obscuration**2) * self.shape_factor
@@ -0,0 +1,167 @@
1
+ """YippyCoronagraph -- AbstractCoronagraph backed by a yippy EqxCoronagraph."""
2
+
3
+
4
+ from typing import final
5
+
6
+ from jax.typing import ArrayLike
7
+ from jaxtyping import Array
8
+ from yippy import EqxCoronagraph
9
+
10
+ from optixstuff.coronagraph import AbstractCoronagraph
11
+
12
+
13
+ @final
14
+ class YippyCoronagraph(AbstractCoronagraph):
15
+ """Coronagraph performance model backed by a yippy YIP interpolation table.
16
+
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.
21
+
22
+ Construction mirrors ``EqxCoronagraph`` -- pass either a YIP path or
23
+ an existing ``EqxCoronagraph`` instance::
24
+
25
+ coro = YippyCoronagraph("/path/to/yip")
26
+ coro = YippyCoronagraph(backend=existing_eqx_coro)
27
+ """
28
+
29
+ _backend: EqxCoronagraph
30
+
31
+ def __init__(
32
+ self,
33
+ yip_path: str | None = None,
34
+ *,
35
+ backend: EqxCoronagraph | None = None,
36
+ **kwargs,
37
+ ) -> None:
38
+ """Create a YippyCoronagraph from a YIP path or existing backend.
39
+
40
+ Args:
41
+ yip_path: Path to a Yield Input Package directory.
42
+ backend: Pre-built EqxCoronagraph. Takes precedence over yip_path.
43
+ **kwargs: Forwarded to ``EqxCoronagraph`` when building from yip_path.
44
+ """
45
+ if backend is not None:
46
+ self._backend = backend
47
+ elif yip_path is not None:
48
+ self._backend = EqxCoronagraph(yip_path, **kwargs)
49
+ else:
50
+ msg = "Provide either yip_path or backend"
51
+ raise ValueError(msg)
52
+
53
+ # -- AbstractVar fields satisfied via properties ----------------------
54
+
55
+ @property
56
+ def pixel_scale_lod(self) -> float:
57
+ """Native pixel scale in lambda/D per pixel."""
58
+ return self._backend.pixel_scale_lod
59
+
60
+ @property
61
+ def IWA(self) -> float:
62
+ """Inner working angle in lambda/D."""
63
+ return self._backend.IWA
64
+
65
+ @property
66
+ def OWA(self) -> float:
67
+ """Outer working angle in lambda/D."""
68
+ return self._backend.OWA
69
+
70
+ # -- Scalar interface -------------------------------------------------
71
+
72
+ def throughput(
73
+ self,
74
+ separation_lod: ArrayLike,
75
+ wavelength_nm: ArrayLike,
76
+ *,
77
+ time_s: ArrayLike = 0.0,
78
+ ) -> ArrayLike:
79
+ """Core throughput from the YIP interpolation table."""
80
+ return self._backend.throughput(separation_lod)
81
+
82
+ def core_area(
83
+ self,
84
+ separation_lod: ArrayLike,
85
+ wavelength_nm: ArrayLike,
86
+ *,
87
+ time_s: ArrayLike = 0.0,
88
+ ) -> ArrayLike:
89
+ """Photometric aperture area from the YIP interpolation table."""
90
+ return self._backend.core_area(separation_lod)
91
+
92
+ def core_mean_intensity(
93
+ self,
94
+ separation_lod: ArrayLike,
95
+ wavelength_nm: ArrayLike,
96
+ *,
97
+ time_s: ArrayLike = 0.0,
98
+ ) -> ArrayLike:
99
+ """Mean stellar leakage from the YIP interpolation table."""
100
+ return self._backend.core_mean_intensity(separation_lod)
101
+
102
+ def occulter_transmission(
103
+ self,
104
+ separation_lod: ArrayLike,
105
+ wavelength_nm: ArrayLike,
106
+ *,
107
+ time_s: ArrayLike = 0.0,
108
+ ) -> ArrayLike:
109
+ """Sky transmission from the YIP interpolation table."""
110
+ return self._backend.occulter_transmission(separation_lod)
111
+
112
+ # -- Image interface --------------------------------------------------
113
+
114
+ def on_axis_psf(
115
+ self,
116
+ wavelength_nm: ArrayLike,
117
+ pixel_scale_rad: float,
118
+ npixels: int,
119
+ ) -> Array:
120
+ """Stellar leakage PSF from the YIP stellar intensity model."""
121
+ return self._backend.stellar_intens(0.0)
122
+
123
+ def off_axis_psf(
124
+ self,
125
+ wavelength_nm: ArrayLike,
126
+ separation_lod: ArrayLike,
127
+ pixel_scale_rad: float,
128
+ npixels: int,
129
+ ) -> Array:
130
+ """Off-axis planet PSF from the YIP PSF interpolator.
131
+
132
+ Places the planet along the +x axis by convention.
133
+ """
134
+ return self._backend.create_psf(separation_lod, 0.0, npixels)
135
+
136
+ # -- Convenience methods (not on AbstractCoronagraph) ------------------
137
+
138
+ def noise_floor_ayo(
139
+ self,
140
+ separation_lod: ArrayLike,
141
+ ppf: float = 30.0,
142
+ ) -> ArrayLike:
143
+ """AYO noise floor: core_mean_intensity / ppf.
144
+
145
+ This is a convenience passthrough to the backend. Not part of the
146
+ AbstractCoronagraph contract -- downstream ETCs should compute
147
+ noise floors as pure functions.
148
+ """
149
+ return self._backend.noise_floor_ayo(separation_lod, ppf)
150
+
151
+ def raw_contrast(self, separation_lod: ArrayLike) -> ArrayLike:
152
+ """Raw contrast from the YIP interpolation table."""
153
+ return self._backend.raw_contrast(separation_lod)
154
+
155
+ def stellar_intens(self, stellar_diam_lod: float) -> Array:
156
+ """Stellar intensity map for a given stellar angular diameter."""
157
+ return self._backend.stellar_intens(stellar_diam_lod)
158
+
159
+ @property
160
+ def psf_shape(self) -> tuple[int, int]:
161
+ """Shape of the PSF arrays from the YIP file."""
162
+ return self._backend.psf_shape
163
+
164
+ @property
165
+ def sky_trans(self) -> Array:
166
+ """Full sky transmission map."""
167
+ return self._backend.sky_trans
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: optixstuff
3
+ Version: 0.0.1
4
+ Summary: Hardware abstractions for the HWO direct imaging simulation suite
5
+ Project-URL: Homepage, https://github.com/CoreySpohn/optixstuff
6
+ Project-URL: Issues, https://github.com/CoreySpohn/optixstuff/issues
7
+ Author-email: Corey Spohn <corey.a.spohn@nasa.gov>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Corey Spohn
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Classifier: Development Status :: 2 - Pre-Alpha
31
+ Classifier: Intended Audience :: Science/Research
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
35
+ Requires-Python: >=3.11
36
+ Requires-Dist: equinox>=0.12.0
37
+ Requires-Dist: interpax
38
+ Requires-Dist: jax>=0.4.1
39
+ Requires-Dist: jaxlib>=0.4.1
40
+ Requires-Dist: yippy
41
+ Provides-Extra: dev
42
+ Requires-Dist: pre-commit; extra == 'dev'
43
+ Provides-Extra: docs
44
+ Requires-Dist: ipython; extra == 'docs'
45
+ Requires-Dist: matplotlib; extra == 'docs'
46
+ Requires-Dist: myst-nb; extra == 'docs'
47
+ Requires-Dist: sphinx; extra == 'docs'
48
+ Requires-Dist: sphinx-autoapi; extra == 'docs'
49
+ Requires-Dist: sphinx-autodoc-typehints; extra == 'docs'
50
+ Requires-Dist: sphinx-book-theme; extra == 'docs'
51
+ Provides-Extra: test
52
+ Requires-Dist: hypothesis; extra == 'test'
53
+ Requires-Dist: nox; extra == 'test'
54
+ Requires-Dist: pytest; extra == 'test'
55
+ Requires-Dist: pytest-cov; extra == 'test'
56
+ Description-Content-Type: text/markdown
57
+
58
+ # optixstuff
59
+
60
+ Flux-level optical system abstractions for the HWO direct imaging simulation suite.
61
+
62
+ ## What optixstuff is
63
+
64
+ `optixstuff` is the **radiometric hardware layer** for HWO simulations. It operates on
65
+ **flux** — photon rates, throughput fractions, and detector-level noise — providing the
66
+ shared interfaces that simulation tools and exposure time calculators build on top of.
67
+
68
+ The same `OpticalPath` object drives both scalar ETC calculations (`jaxEDITH`) and full
69
+ 2D image generation (`coronagraphoto`), ensuring that the hardware model is consistent
70
+ across all downstream science products.
71
+
72
+ ## What optixstuff is *not*
73
+
74
+ `optixstuff` does not model diffraction, wavefront propagation, or E-field interference.
75
+ That level of physical optics belongs to tools like [dLux](https://github.com/LouisDesdoigts/dLux)
76
+ and [HCIPy](https://github.com/ehpor/hcipy), which generate PSFs from first principles.
77
+
78
+ `optixstuff` and these wavefront tools are **complementary**: dLux/HCIPy compute the PSFs,
79
+ which are delivered as yield input packages (YIPs). `optixstuff` consumes those PSFs as
80
+ flux patterns (via [yippy](https://github.com/CoreySpohn/yippy)) and composes them with
81
+ the rest of the observatory — throughput chain, detector QE, noise — to produce
82
+ science-level outputs.
83
+
84
+ ## Architecture
85
+
86
+ Built on [JAX](https://github.com/google/jax) and
87
+ [Equinox](https://github.com/patrick-kidger/equinox), `optixstuff` provides:
88
+
89
+ - **Abstract interfaces** — `AbstractPrimary`, `AbstractOpticalElement`,
90
+ `AbstractCoronagraph`, `AbstractDetector`
91
+ - **Concrete implementations** — `SimplePrimary`, `ConstantThroughputElement`,
92
+ `SimpleDetector`
93
+ - **Container** — `OpticalPath`, a composable hardware configuration passed to all
94
+ simulators
95
+
96
+ Every abstract method accepts three fidelity axes — **wavelength**, **position**, and
97
+ **time** — with defaults so that simple implementations can ignore unused axes while
98
+ future high-fidelity models (wavelength-dependent coatings, position-dependent vignetting,
99
+ time-dependent detector degradation) can use them without breaking the interface.
100
+
101
+ ### Ecosystem position
102
+
103
+ ```
104
+ ┌───────────────────────────┐
105
+ │ Physical optics │
106
+ │ (dLux, HCIPy, PROPER) │
107
+ │ E-fields → PSFs │
108
+ └──────────┬────────────────┘
109
+ │ YIP
110
+ ┌──────────▼────────────────┐
111
+ │ yippy │
112
+ │ PSF interpolation │
113
+ └──────────┬────────────────┘
114
+ │ flux patterns
115
+ ┌──────────────────────▼────────────────────────┐
116
+ │ optixstuff │
117
+ │ Telescope • Coronagraph • Detector • OpticalPath │
118
+ │ Throughput chains • QE • Noise rates │
119
+ └────────┬──────────────────────┬───────────────┘
120
+ │ │
121
+ ┌──────────▼──────────┐ ┌────────▼───────────────┐
122
+ │ jaxEDITH │ │ coronagraphoto │
123
+ │ Scalar count rates │ │ 2D image simulation │
124
+ │ Exposure times │ │ Multi-epoch scenes │
125
+ └─────────────────────┘ └────────────────────────┘
126
+ ```
127
+
128
+ ## Installation
129
+
130
+ ```bash
131
+ pip install optixstuff
132
+ ```
133
+
134
+ ## Status
135
+
136
+ This package is in early development (pre-v0.1.0).
@@ -0,0 +1,12 @@
1
+ optixstuff/__init__.py,sha256=oAuCgjZZA2Ja0sYqLR06-Y5i9ZUzUAT5SYuPMCp_r2k,1180
2
+ optixstuff/_version.py,sha256=8OsTLsIVB9D0HdPTmt5rVwyVUBe9xTVGkRslXicxzkM,520
3
+ optixstuff/coronagraph.py,sha256=Y_L47A8tUnjthqv1riWwCSruy1jfpqOIKPqkBHQOCTc,5728
4
+ optixstuff/detector.py,sha256=md-r0HWd4PKGC4F8GqfBwoVg2_7wPcFx-eLumzsyluQ,10275
5
+ optixstuff/optical_elements.py,sha256=czPjlBb5bPWjUO0s6Tq_Mc6V3qIJprLnUudTpeqaWuc,4391
6
+ optixstuff/optical_path.py,sha256=_5fE7saX3DXoJsqrToRkzGosCFaBvjKKQ9Wg6dO2ltg,1611
7
+ optixstuff/primary.py,sha256=gqzR5tF37A2dghItQR4IsfkKPcmkp2CyhElcwtImo-s,1752
8
+ optixstuff/yippy_coronagraph.py,sha256=RPGRvW1ECv7I5yeLK3dIzIzBTaxFr7dHfzk8mXTrfiE,5355
9
+ optixstuff-0.0.1.dist-info/METADATA,sha256=DzsNtkE2KB0MLv3STb2ZDE-RjvhYNd7KwhRR-cdiRTY,6846
10
+ optixstuff-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ optixstuff-0.0.1.dist-info/licenses/LICENSE,sha256=66ed08OMdjt-G3WoW0mfxmaAt-zqSwZ2A0kvPklkwwQ,1068
12
+ optixstuff-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Corey Spohn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.