optixstuff 1.4.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {optixstuff-1.4.0 → optixstuff-2.0.0}/.pre-commit-config.yaml +8 -0
  2. {optixstuff-1.4.0 → optixstuff-2.0.0}/CHANGELOG.md +26 -0
  3. {optixstuff-1.4.0 → optixstuff-2.0.0}/CITATION.cff +1 -1
  4. {optixstuff-1.4.0 → optixstuff-2.0.0}/PKG-INFO +5 -3
  5. {optixstuff-1.4.0 → optixstuff-2.0.0}/README.md +4 -2
  6. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/__init__.py +4 -1
  7. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/_version.py +2 -2
  8. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/detector.py +35 -4
  9. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/disperser.py +14 -0
  10. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/optical_path.py +11 -0
  11. optixstuff-2.0.0/src/optixstuff/primary.py +183 -0
  12. optixstuff-2.0.0/src/optixstuff/speckle.py +85 -0
  13. optixstuff-2.0.0/tools/check_internal_refs.py +80 -0
  14. optixstuff-1.4.0/src/optixstuff/primary.py +0 -67
  15. {optixstuff-1.4.0 → optixstuff-2.0.0}/.gitignore +0 -0
  16. {optixstuff-1.4.0 → optixstuff-2.0.0}/.readthedocs.yaml +0 -0
  17. {optixstuff-1.4.0 → optixstuff-2.0.0}/.zenodo.json +0 -0
  18. {optixstuff-1.4.0 → optixstuff-2.0.0}/LICENSE +0 -0
  19. {optixstuff-1.4.0 → optixstuff-2.0.0}/pyproject.toml +0 -0
  20. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/_repr.py +0 -0
  21. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/coronagraph.py +0 -0
  22. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/exposure.py +0 -0
  23. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/optical_elements.py +0 -0
  24. {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/yippy_coronagraph.py +0 -0
@@ -1,4 +1,12 @@
1
1
  repos:
2
+ - repo: local
3
+ hooks:
4
+ - id: no-internal-refs
5
+ name: no internal research references
6
+ entry: python3 tools/check_internal_refs.py
7
+ language: system
8
+ types: [text]
9
+ exclude: ^(CHANGELOG\.md|tools/check_internal_refs\.py)$
2
10
  - repo: https://github.com/pre-commit/pre-commit-hooks
3
11
  rev: "v6.0.0"
4
12
  hooks:
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.0](https://github.com/CoreySpohn/optixstuff/compare/v1.5.0...v2.0.0) (2026-07-10)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **disperser:** sky_pitch_arcsec replaces sky_pitch_lod -- the cube grid is fixed-angle, so the pitch is a plain angle
9
+
10
+ ### Features
11
+
12
+ * **disperser:** sky_pitch_lod + psflet_pack_path descriptor fields ([d2ae639](https://github.com/CoreySpohn/optixstuff/commit/d2ae6392a5f24a2c3b43413149dd7e89b7125600))
13
+ * **primary:** exact segment geometry -- optional segment_point_to_point_m and inscribed_diameter_m, segment_pitch_m, flat-top segment_centres_m matching the HWO EAC baseline pupils ([4c5b4b7](https://github.com/CoreySpohn/optixstuff/commit/4c5b4b71843450339c2a330d74833a5b53a5431d))
14
+ * **speckle:** add AbstractSpeckleField interface and OpticalPath.speckle ([fb7b501](https://github.com/CoreySpohn/optixstuff/commit/fb7b501a2b105350786f757c7c5c761f8bfef2b0))
15
+
16
+
17
+ ### Code Refactoring
18
+
19
+ * **disperser:** sky_pitch_arcsec replaces sky_pitch_lod -- the cube grid is fixed-angle, so the pitch is a plain angle ([31041e2](https://github.com/CoreySpohn/optixstuff/commit/31041e2b85402b083ce2e7913100cfc2919452f3))
20
+
21
+ ## [1.5.0](https://github.com/CoreySpohn/optixstuff/compare/v1.4.0...v1.5.0) (2026-06-27)
22
+
23
+
24
+ ### Features
25
+
26
+ * **detector:** wavelength-dependent QE via optional qe_curve ([bf7bc6f](https://github.com/CoreySpohn/optixstuff/commit/bf7bc6f8272ff2d011551f3b1b78243c8ada5572))
27
+ * **primary:** add SegmentedPrimary carrying hex segment geometry ([fe7cab9](https://github.com/CoreySpohn/optixstuff/commit/fe7cab9bb6153874b029ead7d2cc888ca9e8709b))
28
+
3
29
  ## [1.4.0](https://github.com/CoreySpohn/optixstuff/compare/v1.3.0...v1.4.0) (2026-06-23)
4
30
 
5
31
 
@@ -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.4.0
3
+ Version: 2.0.0
4
4
  Summary: Hardware abstractions for the HWO direct imaging simulation suite
5
5
  Project-URL: Homepage, https://github.com/CoreySpohn/optixstuff
6
6
  Project-URL: Issues, https://github.com/CoreySpohn/optixstuff/issues
@@ -94,8 +94,10 @@ Built on [JAX](https://github.com/google/jax) and
94
94
 
95
95
  - **Abstract interfaces** — `AbstractPrimary`, `AbstractOpticalElement`,
96
96
  `AbstractCoronagraph`, `AbstractDetector`, `AbstractDisperser`
97
- - **Concrete implementations** — `SimplePrimary`, `ConstantThroughput`,
98
- `IdealDetector`, `Detector`, `LensletDisperser`
97
+ - **Concrete implementations** — `SimplePrimary`, `SegmentedPrimary` (carries
98
+ hex segment geometry for a diffraction backend), `ConstantThroughput`,
99
+ `IdealDetector`, `Detector` (with optional wavelength-dependent QE),
100
+ `LensletDisperser`
99
101
  - **Container** — `OpticalPath`, a composable hardware configuration passed to all
100
102
  simulators
101
103
 
@@ -36,8 +36,10 @@ Built on [JAX](https://github.com/google/jax) and
36
36
 
37
37
  - **Abstract interfaces** — `AbstractPrimary`, `AbstractOpticalElement`,
38
38
  `AbstractCoronagraph`, `AbstractDetector`, `AbstractDisperser`
39
- - **Concrete implementations** — `SimplePrimary`, `ConstantThroughput`,
40
- `IdealDetector`, `Detector`, `LensletDisperser`
39
+ - **Concrete implementations** — `SimplePrimary`, `SegmentedPrimary` (carries
40
+ hex segment geometry for a diffraction backend), `ConstantThroughput`,
41
+ `IdealDetector`, `Detector` (with optional wavelength-dependent QE),
42
+ `LensletDisperser`
41
43
  - **Container** — `OpticalPath`, a composable hardware configuration passed to all
42
44
  simulators
43
45
 
@@ -19,7 +19,8 @@ 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
+ from optixstuff.speckle import AbstractSpeckleField
23
24
  from optixstuff.yippy_coronagraph import YippyCoronagraph
24
25
 
25
26
  __all__ = [
@@ -29,6 +30,7 @@ __all__ = [
29
30
  "AbstractOpticalElement",
30
31
  "AbstractPrimary",
31
32
  "AbstractScalarCoronagraph",
33
+ "AbstractSpeckleField",
32
34
  "AbstractUniformElement",
33
35
  "ConstantThroughput",
34
36
  "Detector",
@@ -36,6 +38,7 @@ __all__ = [
36
38
  "IdealDetector",
37
39
  "LensletDisperser",
38
40
  "OpticalPath",
41
+ "SegmentedPrimary",
39
42
  "SimplePrimary",
40
43
  "SpectralThroughput",
41
44
  "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.4.0'
22
- __version_tuple__ = version_tuple = (1, 4, 0)
21
+ __version__ = version = '2.0.0'
22
+ __version_tuple__ = version_tuple = (2, 0, 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, "
@@ -63,6 +63,18 @@ class LensletDisperser(AbstractDisperser):
63
63
  ``lambda f / D``, so at fixed pixel scale coronachrome scales the core width
64
64
  by ``lambda / psflet_ref_nm`` per wavelength (the shape parameters do not
65
65
  scale).
66
+
67
+ ``sky_pitch_arcsec`` is the lenslet pitch projected on sky, a plain angle
68
+ in arcseconds (the focal-plane cube a render layer consumes lives on a
69
+ fixed angular grid, so no reference wavelength is involved). It makes the
70
+ spatial sampling an instrument property: the render layer derives
71
+ focal-plane pixels per lenslet as the ratio of this pitch to the cube's
72
+ angular plate scale instead of taking a free knob. ``None`` means
73
+ unspecified.
74
+
75
+ ``psflet_pack_path`` is a reference to a frozen PSFlet template pack file
76
+ (used with ``psflet_kind="template"``), the way ``throughput_element``
77
+ carries the blaze curve: config only, the render layer loads it.
66
78
  """
67
79
 
68
80
  pitch_m: float
@@ -78,6 +90,8 @@ class LensletDisperser(AbstractDisperser):
78
90
  psflet_kind: str = eqx.field(static=True)
79
91
  detector_shape: tuple[int, int] = eqx.field(static=True)
80
92
  throughput_element: AbstractOpticalElement = ConstantThroughput(1.0)
93
+ sky_pitch_arcsec: float | None = None
94
+ psflet_pack_path: str | None = eqx.field(static=True, default=None)
81
95
 
82
96
  def _dispersion_px(self, wavelength_nm):
83
97
  """Spectral-axis detector offset [px] for the wavelength(s)."""
@@ -16,6 +16,7 @@ from optixstuff.optical_elements import (
16
16
  ConstantThroughput,
17
17
  )
18
18
  from optixstuff.primary import AbstractPrimary, SimplePrimary
19
+ from optixstuff.speckle import AbstractSpeckleField
19
20
 
20
21
 
21
22
  class OpticalPath(eqx.Module):
@@ -38,6 +39,9 @@ class OpticalPath(eqx.Module):
38
39
  not a spectral channel count). Default 1.0.
39
40
  npix_multiplier: IFS signal-spread multiplier on detector pixel
40
41
  counts. Default 1.0.
42
+ speckle: Optional time-varying speckle field (the stochastic
43
+ wavefront-error residual on top of the deterministic
44
+ coronagraph leakage floor); None for a perfect-wavefront path.
41
45
  """
42
46
 
43
47
  primary: AbstractPrimary
@@ -47,6 +51,7 @@ class OpticalPath(eqx.Module):
47
51
  disperser: AbstractDisperser | None = None
48
52
  n_channels: float = 1.0
49
53
  npix_multiplier: float = 1.0
54
+ speckle: AbstractSpeckleField | None = None
50
55
 
51
56
  @classmethod
52
57
  def from_default_setup(
@@ -62,6 +67,7 @@ class OpticalPath(eqx.Module):
62
67
  dark_current_rate_e_per_s: float = 0.0,
63
68
  n_channels: float = 1.0,
64
69
  npix_multiplier: float = 1.0,
70
+ speckle: AbstractSpeckleField | None = None,
65
71
  ) -> OpticalPath:
66
72
  """Build an OpticalPath with reasonable HWO-like defaults.
67
73
 
@@ -92,6 +98,8 @@ class OpticalPath(eqx.Module):
92
98
  callers add realistic noise when needed).
93
99
  n_channels: AYO parallel-path multiplier. Default ``1.0``.
94
100
  npix_multiplier: IFS signal-spread multiplier. Default ``1.0``.
101
+ speckle: Optional speckle field attached to the path. Default
102
+ ``None`` -- a perfect-wavefront path.
95
103
 
96
104
  Returns:
97
105
  A ready-to-use :class:`OpticalPath`.
@@ -124,6 +132,7 @@ class OpticalPath(eqx.Module):
124
132
  ),
125
133
  n_channels=n_channels,
126
134
  npix_multiplier=npix_multiplier,
135
+ speckle=speckle,
127
136
  )
128
137
 
129
138
  def system_throughput(self, wavelength_nm: float) -> float:
@@ -158,4 +167,6 @@ class OpticalPath(eqx.Module):
158
167
  lines.append(" attenuating_elements: ()")
159
168
  lines.append(indent("coronagraph: " + repr(self.coronagraph)))
160
169
  lines.append(indent("detector: " + repr(self.detector)))
170
+ if self.speckle is not None:
171
+ lines.append(indent("speckle: " + repr(self.speckle)))
161
172
  return "\n".join(lines)
@@ -0,0 +1,183 @@
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
+ segment_point_to_point_m: float | None
104
+ inscribed_diameter_m: float | None
105
+ n_rings: int = eqx.field(static=True)
106
+ n_segments: int = eqx.field(static=True)
107
+ segment_shape: str = eqx.field(static=True)
108
+
109
+ def __init__(
110
+ self,
111
+ diameter_m: float,
112
+ area_m2: float,
113
+ n_rings: int,
114
+ n_segments: int,
115
+ segment_gap_m: float,
116
+ segment_shape: str = "hexagon",
117
+ segment_point_to_point_m: float | None = None,
118
+ inscribed_diameter_m: float | None = None,
119
+ ) -> None:
120
+ """Create a segmented hex primary from its geometry."""
121
+ self._diameter_m = diameter_m
122
+ self._area_m2 = area_m2
123
+ self.segment_gap_m = segment_gap_m
124
+ self.segment_point_to_point_m = segment_point_to_point_m
125
+ self.inscribed_diameter_m = inscribed_diameter_m
126
+ self.n_rings = n_rings
127
+ self.n_segments = n_segments
128
+ self.segment_shape = segment_shape
129
+
130
+ @property
131
+ def diameter_m(self) -> float:
132
+ """Circumscribing diameter in metres."""
133
+ return self._diameter_m
134
+
135
+ @property
136
+ def area_m2(self) -> float:
137
+ """Effective collecting area in square metres."""
138
+ return self._area_m2
139
+
140
+ @property
141
+ def segment_flat_to_flat_m(self) -> float:
142
+ """Segment flat-to-flat size in metres.
143
+
144
+ Exact (``point_to_point * sqrt(3) / 2``) when the segment size is
145
+ given; otherwise the legacy circumscribing approximation
146
+ ``diameter / (2 n_rings + 1)``.
147
+ """
148
+ if self.segment_point_to_point_m is not None:
149
+ return self.segment_point_to_point_m * jnp.sqrt(3.0) / 2.0
150
+ return self._diameter_m / (2 * self.n_rings + 1)
151
+
152
+ @property
153
+ def segment_pitch_m(self) -> float:
154
+ """Centre-to-centre distance of adjacent segments (flat-to-flat + gap)."""
155
+ return self.segment_flat_to_flat_m + self.segment_gap_m
156
+
157
+ @property
158
+ def segment_centres_m(self):
159
+ """``(n_segments, 2)`` segment centre (x, y) positions in metres.
160
+
161
+ A flat-top hexagonal lattice (flats up, points along x), the
162
+ convention of the HWO EAC baseline pupils: the centre segment is
163
+ first, and adjacent centres are one ``segment_pitch_m`` apart.
164
+ """
165
+ if self.segment_shape != "hexagon":
166
+ msg = f"segment_centres_m undefined for shape {self.segment_shape!r}"
167
+ raise NotImplementedError(msg)
168
+ axial = _hex_axial_coords(self.n_rings)
169
+ pitch = self.segment_pitch_m
170
+ qs = jnp.array([q for q, _ in axial])
171
+ rs = jnp.array([r for _, r in axial])
172
+ xs = pitch * (jnp.sqrt(3.0) / 2.0) * (qs + rs)
173
+ ys = pitch * (rs - qs) / 2.0
174
+ return jnp.stack([xs, ys], axis=-1)
175
+
176
+ def __repr__(self) -> str:
177
+ """One-line summary of diameter, segment layout, and effective area."""
178
+ return (
179
+ f"SegmentedPrimary(D={self._diameter_m:.3g} m, "
180
+ f"{self.n_segments} {self.segment_shape} segs, "
181
+ f"{self.n_rings} rings, gap={self.segment_gap_m * 1e3:.3g} mm, "
182
+ f"area={float(self._area_m2):.3g} m^2)"
183
+ )
@@ -0,0 +1,85 @@
1
+ """Speckle field abstractions.
2
+
3
+ A speckle field is the stochastic, time-varying residual-starlight pattern
4
+ left by wavefront errors that wavefront control does not null. It sits *on
5
+ top of* the deterministic coronagraphic leakage floor (the YIP
6
+ ``stellar_intens`` map already applied in ``coronagraphoto.star_rate``), so
7
+ it is an instrument effect, not an astrophysical scene source -- it lives on
8
+ :class:`optixstuff.OpticalPath`, next to the coronagraph, rather than in the
9
+ skyscapes scene.
10
+
11
+ The standard linear speckle model is ``I(t) = |E_nom + G eps(t)|^2``, where
12
+ ``E_nom`` is the static coherent residual field (``|E_nom|^2`` is the
13
+ deterministic floor), ``G`` maps wavefront-error modes to the focal-plane
14
+ field, and ``eps(t)`` are the drifting mode coefficients. Concrete fields
15
+ differ only in how they source ``G`` and ``eps`` (analytic, replayed
16
+ intensity cubes, fitted reduced-order, or learned); they share the
17
+ :class:`AbstractSpeckleField` contract below.
18
+ """
19
+
20
+ import abc
21
+
22
+ import equinox as eqx
23
+ from equinox import AbstractVar
24
+ from jax.typing import ArrayLike
25
+ from jaxtyping import Array
26
+
27
+
28
+ class AbstractSpeckleField(eqx.Module):
29
+ """Abstract interface for time-varying speckle fields.
30
+
31
+ Implementations can be backed by an analytic generator (physicaloptix),
32
+ replayed designer/testbed intensity cubes, a fitted reduced-order model,
33
+ or a learned generator. All produce a coronagraph-plane contrast map
34
+ through :meth:`realize`.
35
+
36
+ All wavelength arguments are in nanometres; ``time_s`` is seconds since
37
+ mission start. The returned map is on the field's native coronagraph
38
+ plane at :attr:`pixel_scale_lod`; the caller resamples it to the
39
+ detector grid (as for the coronagraph ``stellar_intens`` map).
40
+ """
41
+
42
+ pixel_scale_lod: AbstractVar[float]
43
+ """Native pixel scale in lambda/D per pixel."""
44
+
45
+ epoch_jd: AbstractVar[float]
46
+ """Julian Date that maps to ``time_s = 0`` -- the realization's clock
47
+ origin. Consumers (e.g. ``coronagraphoto.speckle_rate``) convert an
48
+ observation's absolute JD to the elapsed seconds :meth:`realize` expects
49
+ via ``(start_time_jd - epoch_jd)``; anchoring the clock here keeps the
50
+ physical interface in elapsed seconds and avoids feeding large absolute
51
+ JDs into the temporal synthesis."""
52
+
53
+ @abc.abstractmethod
54
+ def realize(
55
+ self,
56
+ *,
57
+ wavelength_nm: ArrayLike,
58
+ time_s: ArrayLike = 0.0,
59
+ ) -> Array:
60
+ """Speckle contrast *delta* at a given time and wavelength.
61
+
62
+ Returns the wavefront-error-induced excess over the deterministic
63
+ coronagraphic floor -- i.e. ``I(t) - |E_nom|^2``, expanded as
64
+ ``2 Re(E_nom* . G eps(t)) + |G eps(t)|^2``, in contrast units
65
+ (fraction of the host-star flux per pixel). It must not include the
66
+ ``|E_nom|^2`` floor itself: ``star_rate`` already applies that via
67
+ the YIP ``stellar_intens`` map, so re-emitting it here would double
68
+ count. An incoherent-halo implementation returns only the strictly
69
+ positive ``|G eps(t)|^2`` term (no speckle pinning); a coherent
70
+ implementation adds the cross term, which carries the bright-tail
71
+ pinning and requires the complex ``E_nom``.
72
+
73
+ Evolution is driven by ``time_s``, not a per-call PRNG key: the
74
+ realization is fixed at construction so the map is deterministic and
75
+ differentiable, and temporal correlation survives across an exposure
76
+ / roll sequence.
77
+
78
+ Args:
79
+ wavelength_nm: Wavelength in nanometres.
80
+ time_s: Time since mission start in seconds.
81
+
82
+ Returns:
83
+ 2D float contrast-delta array on the native coronagraph plane.
84
+ """
85
+ ...
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ """Commit guard: block internal research references in library files.
3
+
4
+ Library code is public and self-contained; pointers into the private research
5
+ workspace (project brain documents, decision-history vocabulary, working-note
6
+ paths) do not belong in it. This script scans the given files line by line
7
+ against a small pattern list and exits nonzero on any hit, so it can run as a
8
+ pre-commit hook (see .pre-commit-config.yaml) and as a pytest.
9
+
10
+ A line that must legitimately contain such a string (e.g. a development-only
11
+ data fallback path in test fixtures) can be waived by appending the comment
12
+ ``internal-ref-ok`` to that line.
13
+
14
+ Usage: check_internal_refs.py FILE [FILE ...]
15
+ """
16
+
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ WAIVER = "internal-ref-ok"
22
+
23
+ PATTERNS = [
24
+ # Research-workspace paths and project homes.
25
+ r"mission-control",
26
+ r"research-brain",
27
+ r"physicaloptix-setup",
28
+ r"\bbrain/",
29
+ r"deep-thoughts",
30
+ # Underscored ALL-CAPS markdown names are internal working documents.
31
+ r"\b[A-Z0-9]+(?:_[A-Z0-9]+)+\.md\b",
32
+ # Project spike/script homes and decision-history vocabulary.
33
+ r"eac1_dlux",
34
+ r"Tier-G",
35
+ r"(?i)\bgreenfield\b",
36
+ ]
37
+
38
+ COMPILED = [(pattern, re.compile(pattern)) for pattern in PATTERNS]
39
+
40
+
41
+ def scan_file(path):
42
+ """Return (lineno, pattern, line) hits for one file."""
43
+ hits = []
44
+ try:
45
+ text = path.read_text(encoding="utf-8", errors="ignore")
46
+ except OSError as err:
47
+ print(f"{path}: unreadable ({err})")
48
+ return [(0, "<unreadable>", "")]
49
+ for lineno, line in enumerate(text.splitlines(), start=1):
50
+ if WAIVER in line:
51
+ continue
52
+ for pattern, regex in COMPILED:
53
+ if regex.search(line):
54
+ hits.append((lineno, pattern, line.strip()))
55
+ return hits
56
+
57
+
58
+ def main(argv):
59
+ """Scan every argument path; report hits; exit 1 on any."""
60
+ self_path = Path(__file__).resolve()
61
+ failed = False
62
+ for arg in argv:
63
+ path = Path(arg)
64
+ if path.resolve() == self_path:
65
+ continue
66
+ for lineno, pattern, line in scan_file(path):
67
+ failed = True
68
+ print(f"{path}:{lineno}: matches {pattern!r}: {line}")
69
+ if failed:
70
+ print(
71
+ "\nInternal research references do not belong in library code. "
72
+ f"Rephrase self-contained, or append '{WAIVER}' to a "
73
+ "deliberately functional line."
74
+ )
75
+ return 1
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main(sys.argv[1:]))
@@ -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