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.
- {optixstuff-1.4.0 → optixstuff-2.0.0}/.pre-commit-config.yaml +8 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/CHANGELOG.md +26 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/CITATION.cff +1 -1
- {optixstuff-1.4.0 → optixstuff-2.0.0}/PKG-INFO +5 -3
- {optixstuff-1.4.0 → optixstuff-2.0.0}/README.md +4 -2
- {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/__init__.py +4 -1
- {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/_version.py +2 -2
- {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/detector.py +35 -4
- {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/disperser.py +14 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/optical_path.py +11 -0
- optixstuff-2.0.0/src/optixstuff/primary.py +183 -0
- optixstuff-2.0.0/src/optixstuff/speckle.py +85 -0
- optixstuff-2.0.0/tools/check_internal_refs.py +80 -0
- optixstuff-1.4.0/src/optixstuff/primary.py +0 -67
- {optixstuff-1.4.0 → optixstuff-2.0.0}/.gitignore +0 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/.readthedocs.yaml +0 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/.zenodo.json +0 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/LICENSE +0 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/pyproject.toml +0 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/_repr.py +0 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/coronagraph.py +0 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/exposure.py +0 -0
- {optixstuff-1.4.0 → optixstuff-2.0.0}/src/optixstuff/optical_elements.py +0 -0
- {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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: optixstuff
|
|
3
|
-
Version:
|
|
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`, `
|
|
98
|
-
|
|
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`, `
|
|
40
|
-
|
|
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 = '
|
|
22
|
-
__version_tuple__ = version_tuple = (
|
|
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
|
-
"""
|
|
423
|
-
|
|
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={
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|