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 +43 -0
- optixstuff/_version.py +24 -0
- optixstuff/coronagraph.py +193 -0
- optixstuff/detector.py +324 -0
- optixstuff/optical_elements.py +132 -0
- optixstuff/optical_path.py +47 -0
- optixstuff/primary.py +58 -0
- optixstuff/yippy_coronagraph.py +167 -0
- optixstuff-0.0.1.dist-info/METADATA +136 -0
- optixstuff-0.0.1.dist-info/RECORD +12 -0
- optixstuff-0.0.1.dist-info/WHEEL +4 -0
- optixstuff-0.0.1.dist-info/licenses/LICENSE +21 -0
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,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.
|