jaxedith 0.0.2__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.
jaxedith/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """JAX ETC -- public API.
2
+
3
+ This module provides the main user-facing functions for the JAX exposure time
4
+ calculator. It bridges :mod:`optixstuff` hardware objects (stored as
5
+ equinox modules) and astrophysical scene parameters to the pure-JAX count-rate
6
+ and solver functions.
7
+
8
+ Example:
9
+ -------
10
+ >>> from optixstuff import OpticalPath
11
+ >>> from jaxedith import calc_exptime, CONFIG
12
+ >>>
13
+ >>> # Build optical_path from optixstuff objects
14
+ >>> optical_path = OpticalPath(primary=..., coronagraph=..., detector=...)
15
+ >>> scene = ETCScene(F0=..., Fs_over_F0=..., Fp_over_Fs=..., ...)
16
+ >>>
17
+ >>> t_exp = calc_exptime(optical_path, scene, config=CONFIG)
18
+ """
19
+
20
+ try:
21
+ from ._version import __version__
22
+ except ImportError:
23
+ __version__ = "unknown"
24
+
25
+ # ── Re-exports ────────────────────────────────────────────────────────────────
26
+
27
+ from .config import (
28
+ AYO_CONFIG,
29
+ CONFIG,
30
+ EXOSIMS_CHARACTERIZATION_CONFIG,
31
+ EXOSIMS_DETECTION_CONFIG,
32
+ ETCConfig,
33
+ )
34
+ from .core import (
35
+ calc_count_rates,
36
+ calc_exptime,
37
+ calc_exptime_spectrum,
38
+ calc_snr,
39
+ )
40
+ from .observation import (
41
+ calc_exptime_from_observation,
42
+ calc_exptime_from_system,
43
+ calc_snr_from_observation,
44
+ calc_snr_from_system,
45
+ observation_geometry,
46
+ )
47
+ from .scene import ETCScene
48
+
49
+ __all__ = [
50
+ "AYO_CONFIG",
51
+ "CONFIG",
52
+ "EXOSIMS_CHARACTERIZATION_CONFIG",
53
+ "EXOSIMS_DETECTION_CONFIG",
54
+ "ETCConfig",
55
+ "ETCScene",
56
+ "__version__",
57
+ "calc_count_rates",
58
+ "calc_exptime",
59
+ "calc_exptime_from_observation",
60
+ "calc_exptime_from_system",
61
+ "calc_exptime_spectrum",
62
+ "calc_snr",
63
+ "calc_snr_from_observation",
64
+ "calc_snr_from_system",
65
+ "observation_geometry",
66
+ ]
jaxedith/_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.2'
22
+ __version_tuple__ = version_tuple = (0, 0, 2)
23
+
24
+ __commit_id__ = commit_id = None
jaxedith/config.py ADDED
@@ -0,0 +1,91 @@
1
+ """ETCConfig: equinox module that selects the ETC equation variant.
2
+
3
+ The ``variant`` field is a plain Python string used for trace-time dispatch —
4
+ it selects which solver function to call rather than branching inside a single
5
+ function. This means JIT compiles only the chosen code path and ``vmap``
6
+ traces only one solver, avoiding dead-branch overhead.
7
+ """
8
+
9
+ import equinox as eqx
10
+
11
+
12
+ class ETCConfig(eqx.Module):
13
+ """Configuration that determines which ETC equation variant to use.
14
+
15
+ The ``variant`` field drives solver dispatch at trace time:
16
+
17
+ - ``"ayo"`` — AYO / Stark et al. equations (ADI background doubling,
18
+ data-driven noise floor).
19
+ - ``"jaxedith"`` — Same as AYO plus exozodi noise floor extension.
20
+ - ``"exosims_det"`` — EXOSIMS detection (no ADI factor, speckle residual).
21
+ - ``"exosims_char"`` — EXOSIMS characterization (Cp added to Cb).
22
+
23
+ All other fields are static parameters passed directly to the selected
24
+ solver — no boolean flags or ``jnp.where`` branching.
25
+
26
+ Attributes:
27
+ variant: Equation variant string (see above).
28
+ bg_multiplier: ADI background multiplier. AYO/jaxedith: 2.0;
29
+ EXOSIMS: 1.0.
30
+ ppfact: Post-processing factor (EXOSIMS only).
31
+ stability_fact: Wavefront stability factor (EXOSIMS only).
32
+ overhead_multi: Multiplicative overhead factor.
33
+ overhead_fixed_s: Fixed overhead in seconds.
34
+ n_rolls: Number of telescope rolls.
35
+ include_exozodi_noise_floor: Whether the exozodi noise floor is
36
+ included (jaxedith extension).
37
+ npix_multiplier: Pixel count correction factor for the photometric
38
+ aperture. ETC-specific, not a hardware property.
39
+ """
40
+
41
+ # --- Solver variant (trace-time dispatch) ---
42
+ variant: str = "jaxedith"
43
+
44
+ # --- Equation parameters ---
45
+ bg_multiplier: float = 2.0
46
+ ppfact: float = 1.0
47
+ stability_fact: float = 1.0
48
+
49
+ # --- Overheads ---
50
+ overhead_multi: float = 1.0
51
+ overhead_fixed_s: float = 0.0
52
+ n_rolls: int = 1
53
+
54
+ # --- Exozodi noise floor (jaxedith extension) ---
55
+ include_exozodi_noise_floor: bool = True
56
+
57
+ # --- Aperture correction ---
58
+ npix_multiplier: float = 1.0
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Presets
63
+ # ---------------------------------------------------------------------------
64
+
65
+ AYO_CONFIG = ETCConfig(
66
+ variant="ayo",
67
+ bg_multiplier=2.0,
68
+ include_exozodi_noise_floor=False,
69
+ )
70
+ """AYO preset — original Stark et al. equations without exozodi noise floor."""
71
+
72
+ CONFIG = ETCConfig(
73
+ variant="jaxedith",
74
+ bg_multiplier=2.0,
75
+ include_exozodi_noise_floor=True,
76
+ )
77
+ """jaxedith preset — AYO equations plus exozodi noise floor."""
78
+
79
+ EXOSIMS_DETECTION_CONFIG = ETCConfig(
80
+ variant="exosims_det",
81
+ bg_multiplier=1.0,
82
+ include_exozodi_noise_floor=False,
83
+ )
84
+ """EXOSIMS detection preset — no ADI factor, speckle residual instead of noise floor."""
85
+
86
+ EXOSIMS_CHARACTERIZATION_CONFIG = ETCConfig(
87
+ variant="exosims_char",
88
+ bg_multiplier=1.0,
89
+ include_exozodi_noise_floor=False,
90
+ )
91
+ """EXOSIMS characterization preset — Cp added to Cb."""
jaxedith/core.py ADDED
@@ -0,0 +1,471 @@
1
+ """JAX ETC -- core orchestration functions.
2
+
3
+ This module contains the main user-facing functions for the JAX exposure time
4
+ calculator. It bridges :mod:`optixstuff` hardware objects and astrophysical
5
+ scene parameters to the pure-JAX count-rate and solver functions.
6
+
7
+ All public functions accept ``optixstuff.OpticalPath`` and ``ETCScene``
8
+ eqx.Modules directly, making the entire pipeline JIT-able end-to-end via
9
+ ``eqx.filter_jit``.
10
+ """
11
+
12
+ import jax
13
+ import jax.numpy as jnp
14
+ from hwoutils.constants import nm2m, rad2arcsec
15
+
16
+ from .config import CONFIG
17
+ from .count_rates import (
18
+ count_rate_binary,
19
+ count_rate_detector,
20
+ count_rate_exozodi,
21
+ count_rate_planet,
22
+ count_rate_stellar_leakage,
23
+ count_rate_thermal,
24
+ count_rate_zodi,
25
+ noise_floor_exozodi,
26
+ noise_floor_stellar,
27
+ noise_floor_total,
28
+ photon_counting_time,
29
+ speckle_residual,
30
+ )
31
+ from .solver import (
32
+ solve_exptime_ayo,
33
+ solve_exptime_exosims_char,
34
+ solve_exptime_exosims_det,
35
+ solve_snr_ayo,
36
+ solve_snr_exosims_char,
37
+ solve_snr_exosims_det,
38
+ )
39
+
40
+ # -- Trace-time dispatch ------------------------------------------------------
41
+
42
+
43
+ def _dispatch_exptime(Cp, Cb, Cnf, Csp, snr, config):
44
+ """Select and call the correct solve_exptime variant at trace time."""
45
+ kwargs = dict(
46
+ overhead_multi=config.overhead_multi,
47
+ overhead_fixed=config.overhead_fixed_s,
48
+ n_rolls=config.n_rolls,
49
+ )
50
+ if config.variant in ("ayo", "jaxedith"):
51
+ return solve_exptime_ayo(
52
+ Cp,
53
+ Cb,
54
+ Cnf,
55
+ snr,
56
+ bg_multiplier=config.bg_multiplier,
57
+ **kwargs,
58
+ )
59
+ elif config.variant == "exosims_det":
60
+ return solve_exptime_exosims_det(Cp, Cb, Csp, snr, **kwargs)
61
+ elif config.variant == "exosims_char":
62
+ return solve_exptime_exosims_char(Cp, Cb, Csp, snr, **kwargs)
63
+ else:
64
+ raise ValueError(f"Unknown config variant: {config.variant!r}")
65
+
66
+
67
+ def _dispatch_snr(Cp, Cb, Cnf_rate, Csp, t_obs, config):
68
+ """Select and call the correct solve_snr variant at trace time."""
69
+ kwargs = dict(
70
+ overhead_multi=config.overhead_multi,
71
+ overhead_fixed=config.overhead_fixed_s,
72
+ n_rolls=config.n_rolls,
73
+ )
74
+ if config.variant in ("ayo", "jaxedith"):
75
+ return solve_snr_ayo(
76
+ Cp,
77
+ Cb,
78
+ Cnf_rate,
79
+ t_obs,
80
+ bg_multiplier=config.bg_multiplier,
81
+ **kwargs,
82
+ )
83
+ elif config.variant == "exosims_det":
84
+ return solve_snr_exosims_det(Cp, Cb, Csp, t_obs, **kwargs)
85
+ elif config.variant == "exosims_char":
86
+ return solve_snr_exosims_char(Cp, Cb, Csp, t_obs, **kwargs)
87
+ else:
88
+ raise ValueError(f"Unknown config variant: {config.variant!r}")
89
+
90
+
91
+ # -- Count rate orchestration --------------------------------------------------
92
+
93
+
94
+ def _compute_count_rates(
95
+ optical_path,
96
+ scene,
97
+ wavelength_nm,
98
+ separation_lod,
99
+ dlambda_nm,
100
+ snr,
101
+ config,
102
+ eps_warm_T_cold=0.0,
103
+ ):
104
+ """Compute all count rates from an OpticalPath and scene.
105
+
106
+ This function reads directly from the ``OpticalPath`` pytree
107
+ (primary, coronagraph, detector) so the full computation is
108
+ JIT-traceable.
109
+
110
+ Args:
111
+ optical_path: ``optixstuff.OpticalPath`` eqx.Module.
112
+ scene: :class:`ETCScene` instance.
113
+ wavelength_nm: Observation wavelength [nm].
114
+ separation_lod: Planet separation in lam/D.
115
+ dlambda_nm: Bandwidth per spectral element [nm].
116
+ snr: Target SNR (or 1.0 for SNR-solve mode).
117
+ config: :class:`ETCConfig` instance.
118
+ eps_warm_T_cold: Warm-optics emissivity times cold transmission.
119
+ Defaults to 0.0 (no thermal background from optics).
120
+
121
+ Returns:
122
+ Tuple of (Cp, Cb, Cnf, Csp) -- all in [e/s].
123
+ """
124
+ coro = optical_path.coronagraph
125
+ primary = optical_path.primary
126
+ detector = optical_path.detector
127
+
128
+ # Telescope
129
+ area_m2 = primary.area_m2
130
+ throughput = optical_path.system_throughput(wavelength_nm)
131
+
132
+ # Coronagraph performance (interpax splines, fully JIT-safe)
133
+ core_throughput = coro.throughput(separation_lod, wavelength_nm)
134
+ core_area_lod2 = coro.core_area(separation_lod, wavelength_nm)
135
+ sky_trans = coro.occulter_transmission(separation_lod, wavelength_nm)
136
+ core_mean_intensity_val = coro.core_mean_intensity(separation_lod, wavelength_nm)
137
+ noisefloor_value = core_mean_intensity_val / config.ppfact
138
+
139
+ # Angular scales
140
+ lam_m = wavelength_nm * nm2m
141
+ lod_rad = lam_m / primary.diameter_m
142
+ lod_arcsec = lod_rad * rad2arcsec
143
+
144
+ # Detector-derived quantities
145
+ pixscale_lod = coro.pixel_scale_lod
146
+ n_pix = core_area_lod2 / (pixscale_lod**2) * config.npix_multiplier
147
+
148
+ # Planet signal
149
+ Cp = count_rate_planet(
150
+ scene.F0,
151
+ scene.Fs_over_F0,
152
+ scene.Fp_over_Fs,
153
+ area_m2,
154
+ throughput,
155
+ core_throughput,
156
+ dlambda_nm,
157
+ scene.n_channels,
158
+ )
159
+
160
+ # Stellar leakage
161
+ CRbs = count_rate_stellar_leakage(
162
+ scene.F0,
163
+ scene.Fs_over_F0,
164
+ area_m2,
165
+ throughput,
166
+ dlambda_nm,
167
+ scene.n_channels,
168
+ core_area_lod2,
169
+ core_mean_intensity_val,
170
+ )
171
+
172
+ # Zodiacal light
173
+ CRbz = count_rate_zodi(
174
+ scene.F0,
175
+ scene.Fzodi,
176
+ lod_arcsec,
177
+ sky_trans,
178
+ area_m2,
179
+ throughput,
180
+ dlambda_nm,
181
+ scene.n_channels,
182
+ core_area_lod2,
183
+ )
184
+
185
+ # Exozodiacal light
186
+ CRbez = count_rate_exozodi(
187
+ scene.F0,
188
+ scene.Fexozodi,
189
+ lod_arcsec,
190
+ sky_trans,
191
+ area_m2,
192
+ throughput,
193
+ dlambda_nm,
194
+ scene.n_channels,
195
+ core_area_lod2,
196
+ scene.dist_pc,
197
+ scene.sep_arcsec,
198
+ )
199
+
200
+ # Binary
201
+ CRbbin = count_rate_binary(
202
+ scene.F0,
203
+ scene.Fbinary,
204
+ sky_trans,
205
+ area_m2,
206
+ throughput,
207
+ dlambda_nm,
208
+ scene.n_channels,
209
+ core_area_lod2,
210
+ )
211
+
212
+ # Thermal
213
+ CRbth = count_rate_thermal(
214
+ wavelength_nm,
215
+ area_m2,
216
+ dlambda_nm,
217
+ scene.temp_K,
218
+ lod_rad,
219
+ eps_warm_T_cold,
220
+ detector.quantum_efficiency,
221
+ detector.dqe,
222
+ core_area_lod2,
223
+ )
224
+
225
+ # Detector noise
226
+ total_photon_cr = Cp + CRbs + CRbz + CRbez
227
+ t_photon = photon_counting_time(jnp.maximum(total_photon_cr, 1e-30), n_pix)
228
+ CRbd = count_rate_detector(
229
+ n_pix,
230
+ detector.dark_current_rate,
231
+ detector.read_noise_electrons,
232
+ detector.read_time,
233
+ detector.cic_rate,
234
+ t_photon,
235
+ )
236
+
237
+ # Total background
238
+ Cb = CRbs + CRbz + CRbez + CRbbin + CRbth + CRbd
239
+
240
+ # Noise floor (AYO/jaxedith path)
241
+ CRnf_star = noise_floor_stellar(
242
+ scene.F0,
243
+ scene.Fs_over_F0,
244
+ area_m2,
245
+ throughput,
246
+ dlambda_nm,
247
+ scene.n_channels,
248
+ snr,
249
+ noisefloor_value,
250
+ core_area_lod2,
251
+ )
252
+ CRnf_ez = noise_floor_exozodi(CRbez, snr, scene.ez_ppf)
253
+ Cnf = noise_floor_total(CRnf_star, CRnf_ez, config.include_exozodi_noise_floor)
254
+
255
+ # Speckle residual (EXOSIMS path)
256
+ Csp = speckle_residual(CRbs, config.ppfact, config.stability_fact)
257
+
258
+ return Cp, Cb, Cnf, Csp
259
+
260
+
261
+ # -- Public functions ----------------------------------------------------------
262
+
263
+
264
+ def calc_exptime(
265
+ optical_path,
266
+ scene,
267
+ wavelength_nm,
268
+ separation_lod,
269
+ dlambda_nm,
270
+ snr,
271
+ config=None,
272
+ eps_warm_T_cold=0.0,
273
+ ):
274
+ """Calculate exposure time for a single wavelength and separation.
275
+
276
+ This is the main entry point for the JAX ETC. All inputs are
277
+ ``eqx.Module`` pytrees, so the full computation is JIT-traceable
278
+ via ``eqx.filter_jit``.
279
+
280
+ Args:
281
+ optical_path: ``optixstuff.OpticalPath`` eqx.Module containing
282
+ primary, coronagraph, attenuating elements, and detector.
283
+ scene: :class:`ETCScene` instance with astrophysical parameters.
284
+ wavelength_nm: Observation wavelength [nm].
285
+ separation_lod: Planet separation in lam/D.
286
+ dlambda_nm: Bandwidth per spectral element [nm].
287
+ snr: Target signal-to-noise ratio.
288
+ config: :class:`ETCConfig` instance. Defaults to ``CONFIG``.
289
+ eps_warm_T_cold: Warm-optics emissivity times cold transmission.
290
+ Defaults to 0.0 (no thermal background from optics).
291
+
292
+ Returns:
293
+ Exposure time in seconds.
294
+ """
295
+ if config is None:
296
+ config = CONFIG
297
+
298
+ Cp, Cb, Cnf, Csp = _compute_count_rates(
299
+ optical_path,
300
+ scene,
301
+ wavelength_nm,
302
+ separation_lod,
303
+ dlambda_nm,
304
+ snr,
305
+ config,
306
+ eps_warm_T_cold,
307
+ )
308
+ return _dispatch_exptime(Cp, Cb, Cnf, Csp, snr, config)
309
+
310
+
311
+ def calc_snr(
312
+ optical_path,
313
+ scene,
314
+ wavelength_nm,
315
+ separation_lod,
316
+ dlambda_nm,
317
+ t_obs,
318
+ config=None,
319
+ eps_warm_T_cold=0.0,
320
+ ):
321
+ """Calculate achieved SNR for a given observation time.
322
+
323
+ Args:
324
+ optical_path: ``optixstuff.OpticalPath`` eqx.Module.
325
+ scene: :class:`ETCScene` instance with astrophysical parameters.
326
+ wavelength_nm: Observation wavelength [nm].
327
+ separation_lod: Planet separation in lam/D.
328
+ dlambda_nm: Bandwidth per spectral element [nm].
329
+ t_obs: Total observation time [s].
330
+ config: :class:`ETCConfig` instance. Defaults to ``CONFIG``.
331
+ eps_warm_T_cold: Warm-optics emissivity times cold transmission.
332
+ Defaults to 0.0.
333
+
334
+ Returns:
335
+ Achieved signal-to-noise ratio.
336
+ """
337
+ if config is None:
338
+ config = CONFIG
339
+
340
+ # For SNR-solve mode, compute noise floor rates with SNR=1
341
+ Cp, Cb, Cnf, Csp = _compute_count_rates(
342
+ optical_path,
343
+ scene,
344
+ wavelength_nm,
345
+ separation_lod,
346
+ dlambda_nm,
347
+ 1.0,
348
+ config,
349
+ eps_warm_T_cold,
350
+ )
351
+ return _dispatch_snr(Cp, Cb, Cnf, Csp, t_obs, config)
352
+
353
+
354
+ def calc_count_rates(
355
+ optical_path,
356
+ scene,
357
+ wavelength_nm,
358
+ separation_lod,
359
+ dlambda_nm,
360
+ snr=7.0,
361
+ config=None,
362
+ eps_warm_T_cold=0.0,
363
+ ):
364
+ """Compute all count rates without solving for exposure time.
365
+
366
+ Useful for debugging and comparing individual count rates against
367
+ jaxedith/AYO/EXOSIMS values.
368
+
369
+ Args:
370
+ optical_path: ``optixstuff.OpticalPath`` eqx.Module.
371
+ scene: :class:`ETCScene` instance.
372
+ wavelength_nm: Observation wavelength [nm].
373
+ separation_lod: Planet separation in lam/D.
374
+ dlambda_nm: Bandwidth per spectral element [nm].
375
+ snr: SNR used for noise floor calculations.
376
+ config: :class:`ETCConfig` instance. Defaults to ``CONFIG``.
377
+ eps_warm_T_cold: Warm-optics emissivity times cold transmission.
378
+ Defaults to 0.0.
379
+
380
+ Returns:
381
+ Tuple of (Cp, Cb, Cnf, Csp).
382
+ """
383
+ if config is None:
384
+ config = CONFIG
385
+
386
+ return _compute_count_rates(
387
+ optical_path,
388
+ scene,
389
+ wavelength_nm,
390
+ separation_lod,
391
+ dlambda_nm,
392
+ snr,
393
+ config,
394
+ eps_warm_T_cold,
395
+ )
396
+
397
+
398
+ def calc_exptime_spectrum(
399
+ optical_path,
400
+ scene,
401
+ wavelength_array_nm,
402
+ separation_lod,
403
+ dlambda_array_nm,
404
+ snr_array,
405
+ config=None,
406
+ eps_warm_T_cold=0.0,
407
+ ):
408
+ """Vectorized exposure time calculation over wavelength bins.
409
+
410
+ Uses ``jax.vmap`` to compute exposure times independently for each
411
+ wavelength bin. All inputs are eqx.Module pytrees, so this function
412
+ is JIT-traceable via ``eqx.filter_jit``.
413
+
414
+ Args:
415
+ optical_path: ``optixstuff.OpticalPath`` eqx.Module.
416
+ scene: :class:`ETCScene` instance. ``F0`` and ``Fs_over_F0`` should
417
+ be 1D arrays of the same length as ``wavelength_array_nm``.
418
+ wavelength_array_nm: Array of wavelengths [nm].
419
+ separation_lod: Scalar separation in lam/D (same for all bins).
420
+ dlambda_array_nm: Array of bandwidths [nm], one per wavelength bin.
421
+ snr_array: Array of target SNRs, one per wavelength bin.
422
+ config: :class:`ETCConfig` instance. Defaults to ``CONFIG``.
423
+ eps_warm_T_cold: Warm-optics emissivity times cold transmission.
424
+ Defaults to 0.0.
425
+
426
+ Returns:
427
+ Array of exposure times in seconds.
428
+
429
+ Raises:
430
+ ValueError: If array inputs have mismatched shapes or are not 1D.
431
+ """
432
+ if config is None:
433
+ config = CONFIG
434
+
435
+ # Convert to arrays and validate shapes (runs before tracing)
436
+ wavelength_array_nm = jnp.asarray(wavelength_array_nm)
437
+ dlambda_array_nm = jnp.asarray(dlambda_array_nm)
438
+ snr_array = jnp.asarray(snr_array)
439
+
440
+ if wavelength_array_nm.ndim != 1:
441
+ raise ValueError(
442
+ f"wavelength_array_nm must be 1D, got shape {wavelength_array_nm.shape}"
443
+ )
444
+
445
+ n_bins = wavelength_array_nm.shape[0]
446
+
447
+ if dlambda_array_nm.shape != (n_bins,):
448
+ raise ValueError(
449
+ f"dlambda_array_nm shape {dlambda_array_nm.shape} does not match "
450
+ f"wavelength_array_nm length {n_bins}"
451
+ )
452
+
453
+ if snr_array.shape != (n_bins,):
454
+ raise ValueError(
455
+ f"snr_array shape {snr_array.shape} does not match "
456
+ f"wavelength_array_nm length {n_bins}"
457
+ )
458
+
459
+ # vmap over wavelength axis -- scene parameters that vary must be 1D arrays
460
+ return jax.vmap(
461
+ lambda wl, dl, s_snr: calc_exptime(
462
+ optical_path,
463
+ scene,
464
+ wl,
465
+ separation_lod,
466
+ dl,
467
+ s_snr,
468
+ config,
469
+ eps_warm_T_cold,
470
+ )
471
+ )(wavelength_array_nm, dlambda_array_nm, snr_array)