optixstuff 1.3.0__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0](https://github.com/CoreySpohn/optixstuff/compare/v1.4.0...v1.5.0) (2026-06-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * **detector:** wavelength-dependent QE via optional qe_curve ([bf7bc6f](https://github.com/CoreySpohn/optixstuff/commit/bf7bc6f8272ff2d011551f3b1b78243c8ada5572))
9
+ * **primary:** add SegmentedPrimary carrying hex segment geometry ([fe7cab9](https://github.com/CoreySpohn/optixstuff/commit/fe7cab9bb6153874b029ead7d2cc888ca9e8709b))
10
+
11
+ ## [1.4.0](https://github.com/CoreySpohn/optixstuff/compare/v1.3.0...v1.4.0) (2026-06-23)
12
+
13
+
14
+ ### Miscellaneous Chores
15
+
16
+ * release 1.4.0 ([39c1d25](https://github.com/CoreySpohn/optixstuff/commit/39c1d25741f3aaa47984d32e0e6c7917933a59ba))
17
+
3
18
  ## [1.3.0](https://github.com/CoreySpohn/optixstuff/compare/v1.2.0...v1.3.0) (2026-06-23)
4
19
 
5
20
 
@@ -10,4 +10,4 @@ authors:
10
10
  affiliation: "NASA Goddard Space Flight Center"
11
11
  repository-code: "https://github.com/CoreySpohn/optixstuff"
12
12
  license: MIT
13
- # doi: add the Zenodo concept ("all versions") DOI here after the first archived release
13
+ doi: "10.5281/zenodo.20819379"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optixstuff
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: Hardware abstractions for the HWO direct imaging simulation suite
5
5
  Project-URL: Homepage, https://github.com/CoreySpohn/optixstuff
6
6
  Project-URL: Issues, https://github.com/CoreySpohn/optixstuff/issues
@@ -19,7 +19,7 @@ from optixstuff.optical_elements import (
19
19
  SpectralThroughput,
20
20
  )
21
21
  from optixstuff.optical_path import OpticalPath
22
- from optixstuff.primary import AbstractPrimary, SimplePrimary
22
+ from optixstuff.primary import AbstractPrimary, SegmentedPrimary, SimplePrimary
23
23
  from optixstuff.yippy_coronagraph import YippyCoronagraph
24
24
 
25
25
  __all__ = [
@@ -36,6 +36,7 @@ __all__ = [
36
36
  "IdealDetector",
37
37
  "LensletDisperser",
38
38
  "OpticalPath",
39
+ "SegmentedPrimary",
39
40
  "SimplePrimary",
40
41
  "SpectralThroughput",
41
42
  "YippyCoronagraph",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.3.0'
22
- __version_tuple__ = version_tuple = (1, 3, 0)
21
+ __version__ = version = '1.5.0'
22
+ __version_tuple__ = version_tuple = (1, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -4,6 +4,7 @@ import abc
4
4
  from typing import final
5
5
 
6
6
  import equinox as eqx
7
+ import interpax
7
8
  import jax
8
9
  import jax.numpy as jnp
9
10
  from equinox import AbstractVar
@@ -392,6 +393,7 @@ class Detector(AbstractDetector):
392
393
  read_time_s: float
393
394
  dqe: float
394
395
  shape: tuple[int, int] = eqx.field(static=True)
396
+ _qe_interp: interpax.Interpolator1D | None
395
397
 
396
398
  def __init__(
397
399
  self,
@@ -404,8 +406,16 @@ class Detector(AbstractDetector):
404
406
  frame_time_s: float = 1.0,
405
407
  read_time_s: float = 0.05,
406
408
  dqe: float = 0.0,
409
+ qe_curve: tuple[ArrayLike, ArrayLike] | None = None,
407
410
  ) -> None:
408
- """Create a full detector with all noise sources."""
411
+ """Create a full detector with all noise sources.
412
+
413
+ Supplying ``qe_curve`` as a ``(wavelengths_nm, qe)`` pair makes
414
+ :meth:`get_qe` interpolate it (linear, zero outside the measured range)
415
+ for wavelength-dependent throughput/ETC use; the scalar
416
+ ``quantum_efficiency`` remains the operating-point QE used by the
417
+ per-call (monochromatic) readout path.
418
+ """
409
419
  self.pixel_scale_arcsec = pixel_scale_arcsec
410
420
  self.shape = shape
411
421
  self.quantum_efficiency = quantum_efficiency
@@ -417,10 +427,26 @@ class Detector(AbstractDetector):
417
427
  self.frame_time_s = frame_time_s
418
428
  self.read_time_s = read_time_s
419
429
  self.dqe = dqe
430
+ if qe_curve is None:
431
+ self._qe_interp = None
432
+ else:
433
+ wavelengths_nm, qe = qe_curve
434
+ self._qe_interp = interpax.Interpolator1D(
435
+ jnp.asarray(wavelengths_nm),
436
+ jnp.asarray(qe),
437
+ method="linear",
438
+ extrap=jnp.array([0.0, 0.0]),
439
+ )
420
440
 
421
441
  def get_qe(self, wavelength_nm: ArrayLike) -> ArrayLike:
422
- """Return constant QE, ignoring wavelength."""
423
- return self.quantum_efficiency
442
+ """Quantum efficiency at a wavelength.
443
+
444
+ Interpolates the ``qe_curve`` if one was supplied (linear, zero outside
445
+ the measured range); otherwise returns the scalar ``quantum_efficiency``.
446
+ """
447
+ if self._qe_interp is None:
448
+ return self.quantum_efficiency
449
+ return self._qe_interp(wavelength_nm)
424
450
 
425
451
  def scalar_noise_rate(self, n_pix: ArrayLike, t_photon: ArrayLike) -> ArrayLike:
426
452
  """Combined dark + CIC noise variance rate."""
@@ -463,9 +489,14 @@ class Detector(AbstractDetector):
463
489
  def __repr__(self) -> str:
464
490
  """One-line summary of shape, plate scale, QE, and noise sources."""
465
491
  ny, nx = self.shape
492
+ qe_str = (
493
+ "spectral"
494
+ if self._qe_interp is not None
495
+ else f"{self.quantum_efficiency:.2f}"
496
+ )
466
497
  return (
467
498
  f"Detector({ny}x{nx} @ {self.pixel_scale_arcsec:.3g} arcsec/px, "
468
- f"QE={self.quantum_efficiency:.2f}, "
499
+ f"QE={qe_str}, "
469
500
  f"dark={self.dark_current_rate_e_per_s:.2g} e-/s/px, "
470
501
  f"RN={self.read_noise_e:.2g} e-/read, "
471
502
  f"CIC={self.clock_induced_charge_rate_e_per_frame:.2g} e-/frame, "
@@ -0,0 +1,160 @@
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
59
+
60
+ def __repr__(self) -> str:
61
+ """One-line summary of diameter, obscuration, and effective area."""
62
+ return (
63
+ f"SimplePrimary(D={self._diameter_m:.3g} m, "
64
+ f"obs={self.obscuration:.3g}, "
65
+ f"shape_factor={self.shape_factor:.3g}, "
66
+ f"area={float(self.area_m2):.3g} m^2)"
67
+ )
68
+
69
+
70
+ def _hex_axial_coords(n_rings: int) -> list[tuple[int, int]]:
71
+ """Axial (q, r) centres of a hex-packed n-ring layout.
72
+
73
+ Yields ``1 + 3 n (n + 1)`` coordinates, centre first.
74
+ """
75
+ coords = [(0, 0)]
76
+ for q in range(-n_rings, n_rings + 1):
77
+ for r in range(-n_rings, n_rings + 1):
78
+ if max(abs(q), abs(r), abs(q + r)) <= n_rings and not (q == 0 and r == 0):
79
+ coords.append((q, r))
80
+ return coords
81
+
82
+
83
+ class SegmentedPrimary(AbstractPrimary):
84
+ """A segmented hex primary that carries pupil geometry, not just scalars.
85
+
86
+ Beyond the diameter and collecting area every primary provides, this
87
+ describes the segment layout -- ring count, segment count, gap, shape -- so a
88
+ diffraction backend (e.g. dLux) can build the pupil from it. optixstuff only
89
+ DESCRIBES the geometry; it does not propagate wavefronts.
90
+
91
+ Args:
92
+ diameter_m: Circumscribing diameter in metres.
93
+ area_m2: Effective collecting area in square metres (gap/fill corrected).
94
+ n_rings: Number of segment rings around the centre segment.
95
+ n_segments: Total segment count (``1 + 3 n (n + 1)`` for a full layout).
96
+ segment_gap_m: Inter-segment optical gap in metres.
97
+ segment_shape: Segment shape; only ``"hexagon"`` is supported today.
98
+ """
99
+
100
+ _diameter_m: float
101
+ _area_m2: float
102
+ segment_gap_m: float
103
+ n_rings: int = eqx.field(static=True)
104
+ n_segments: int = eqx.field(static=True)
105
+ segment_shape: str = eqx.field(static=True)
106
+
107
+ def __init__(
108
+ self,
109
+ diameter_m: float,
110
+ area_m2: float,
111
+ n_rings: int,
112
+ n_segments: int,
113
+ segment_gap_m: float,
114
+ segment_shape: str = "hexagon",
115
+ ) -> None:
116
+ """Create a segmented hex primary from its geometry."""
117
+ self._diameter_m = diameter_m
118
+ self._area_m2 = area_m2
119
+ self.segment_gap_m = segment_gap_m
120
+ self.n_rings = n_rings
121
+ self.n_segments = n_segments
122
+ self.segment_shape = segment_shape
123
+
124
+ @property
125
+ def diameter_m(self) -> float:
126
+ """Circumscribing diameter in metres."""
127
+ return self._diameter_m
128
+
129
+ @property
130
+ def area_m2(self) -> float:
131
+ """Effective collecting area in square metres."""
132
+ return self._area_m2
133
+
134
+ @property
135
+ def segment_flat_to_flat_m(self) -> float:
136
+ """Segment flat-to-flat size in metres for the circumscribing layout."""
137
+ return self._diameter_m / (2 * self.n_rings + 1)
138
+
139
+ @property
140
+ def segment_centres_m(self):
141
+ """``(n_segments, 2)`` array of segment centre (x, y) positions in metres."""
142
+ if self.segment_shape != "hexagon":
143
+ msg = f"segment_centres_m undefined for shape {self.segment_shape!r}"
144
+ raise NotImplementedError(msg)
145
+ axial = _hex_axial_coords(self.n_rings)
146
+ spacing = self.segment_flat_to_flat_m + self.segment_gap_m
147
+ qs = jnp.array([q for q, _ in axial])
148
+ rs = jnp.array([r for _, r in axial])
149
+ xs = spacing * (qs + rs / 2.0)
150
+ ys = spacing * (jnp.sqrt(3.0) / 2.0) * rs
151
+ return jnp.stack([xs, ys], axis=-1)
152
+
153
+ def __repr__(self) -> str:
154
+ """One-line summary of diameter, segment layout, and effective area."""
155
+ return (
156
+ f"SegmentedPrimary(D={self._diameter_m:.3g} m, "
157
+ f"{self.n_segments} {self.segment_shape} segs, "
158
+ f"{self.n_rings} rings, gap={self.segment_gap_m * 1e3:.3g} mm, "
159
+ f"area={float(self._area_m2):.3g} m^2)"
160
+ )
@@ -1,67 +0,0 @@
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
59
-
60
- def __repr__(self) -> str:
61
- """One-line summary of diameter, obscuration, and effective area."""
62
- return (
63
- f"SimplePrimary(D={self._diameter_m:.3g} m, "
64
- f"obs={self.obscuration:.3g}, "
65
- f"shape_factor={self.shape_factor:.3g}, "
66
- f"area={float(self.area_m2):.3g} m^2)"
67
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes