optixstuff 1.5.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 (23) hide show
  1. {optixstuff-1.5.0 → optixstuff-2.0.0}/.pre-commit-config.yaml +8 -0
  2. {optixstuff-1.5.0 → optixstuff-2.0.0}/CHANGELOG.md +18 -0
  3. {optixstuff-1.5.0 → optixstuff-2.0.0}/PKG-INFO +5 -3
  4. {optixstuff-1.5.0 → optixstuff-2.0.0}/README.md +4 -2
  5. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/__init__.py +2 -0
  6. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/_version.py +2 -2
  7. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/disperser.py +14 -0
  8. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/optical_path.py +11 -0
  9. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/primary.py +28 -5
  10. optixstuff-2.0.0/src/optixstuff/speckle.py +85 -0
  11. optixstuff-2.0.0/tools/check_internal_refs.py +80 -0
  12. {optixstuff-1.5.0 → optixstuff-2.0.0}/.gitignore +0 -0
  13. {optixstuff-1.5.0 → optixstuff-2.0.0}/.readthedocs.yaml +0 -0
  14. {optixstuff-1.5.0 → optixstuff-2.0.0}/.zenodo.json +0 -0
  15. {optixstuff-1.5.0 → optixstuff-2.0.0}/CITATION.cff +0 -0
  16. {optixstuff-1.5.0 → optixstuff-2.0.0}/LICENSE +0 -0
  17. {optixstuff-1.5.0 → optixstuff-2.0.0}/pyproject.toml +0 -0
  18. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/_repr.py +0 -0
  19. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/coronagraph.py +0 -0
  20. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/detector.py +0 -0
  21. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/exposure.py +0 -0
  22. {optixstuff-1.5.0 → optixstuff-2.0.0}/src/optixstuff/optical_elements.py +0 -0
  23. {optixstuff-1.5.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,23 @@
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
+
3
21
  ## [1.5.0](https://github.com/CoreySpohn/optixstuff/compare/v1.4.0...v1.5.0) (2026-06-27)
4
22
 
5
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optixstuff
3
- Version: 1.5.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
 
@@ -20,6 +20,7 @@ from optixstuff.optical_elements import (
20
20
  )
21
21
  from optixstuff.optical_path import OpticalPath
22
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",
@@ -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.5.0'
22
- __version_tuple__ = version_tuple = (1, 5, 0)
21
+ __version__ = version = '2.0.0'
22
+ __version_tuple__ = version_tuple = (2, 0, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -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)
@@ -100,6 +100,8 @@ class SegmentedPrimary(AbstractPrimary):
100
100
  _diameter_m: float
101
101
  _area_m2: float
102
102
  segment_gap_m: float
103
+ segment_point_to_point_m: float | None
104
+ inscribed_diameter_m: float | None
103
105
  n_rings: int = eqx.field(static=True)
104
106
  n_segments: int = eqx.field(static=True)
105
107
  segment_shape: str = eqx.field(static=True)
@@ -112,11 +114,15 @@ class SegmentedPrimary(AbstractPrimary):
112
114
  n_segments: int,
113
115
  segment_gap_m: float,
114
116
  segment_shape: str = "hexagon",
117
+ segment_point_to_point_m: float | None = None,
118
+ inscribed_diameter_m: float | None = None,
115
119
  ) -> None:
116
120
  """Create a segmented hex primary from its geometry."""
117
121
  self._diameter_m = diameter_m
118
122
  self._area_m2 = area_m2
119
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
120
126
  self.n_rings = n_rings
121
127
  self.n_segments = n_segments
122
128
  self.segment_shape = segment_shape
@@ -133,21 +139,38 @@ class SegmentedPrimary(AbstractPrimary):
133
139
 
134
140
  @property
135
141
  def segment_flat_to_flat_m(self) -> float:
136
- """Segment flat-to-flat size in metres for the circumscribing layout."""
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
137
150
  return self._diameter_m / (2 * self.n_rings + 1)
138
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
+
139
157
  @property
140
158
  def segment_centres_m(self):
141
- """``(n_segments, 2)`` array of segment centre (x, y) positions in metres."""
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
+ """
142
165
  if self.segment_shape != "hexagon":
143
166
  msg = f"segment_centres_m undefined for shape {self.segment_shape!r}"
144
167
  raise NotImplementedError(msg)
145
168
  axial = _hex_axial_coords(self.n_rings)
146
- spacing = self.segment_flat_to_flat_m + self.segment_gap_m
169
+ pitch = self.segment_pitch_m
147
170
  qs = jnp.array([q for q, _ in axial])
148
171
  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
172
+ xs = pitch * (jnp.sqrt(3.0) / 2.0) * (qs + rs)
173
+ ys = pitch * (rs - qs) / 2.0
151
174
  return jnp.stack([xs, ys], axis=-1)
152
175
 
153
176
  def __repr__(self) -> str:
@@ -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:]))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes