getframes 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. getframes/__about__.py +4 -0
  2. getframes/__init__.py +91 -0
  3. getframes/analysis/__init__.py +18 -0
  4. getframes/analysis/apertures.py +92 -0
  5. getframes/analysis/ptc.py +109 -0
  6. getframes/calibrate.py +182 -0
  7. getframes/camera.py +649 -0
  8. getframes/cli.py +214 -0
  9. getframes/config.py +420 -0
  10. getframes/dataset.py +294 -0
  11. getframes/frame.py +107 -0
  12. getframes/noise.py +637 -0
  13. getframes/observation.py +162 -0
  14. getframes/presets/__init__.py +90 -0
  15. getframes/presets/data/__init__.py +3 -0
  16. getframes/presets/data/andor_ikon_m934.toml +22 -0
  17. getframes/presets/data/andor_ixon_ultra_888.toml +22 -0
  18. getframes/presets/data/generic_ccd.toml +18 -0
  19. getframes/presets/data/generic_cmos.toml +18 -0
  20. getframes/presets/data/generic_eapd.toml +20 -0
  21. getframes/presets/data/generic_emccd.toml +20 -0
  22. getframes/presets/data/generic_scmos.toml +21 -0
  23. getframes/presets/data/hamamatsu_orca_fusion.toml +25 -0
  24. getframes/presets/data/leonardo_saphira.toml +32 -0
  25. getframes/presets/data/zwo_asi2600mm.toml +20 -0
  26. getframes/py.typed +0 -0
  27. getframes/scene/__init__.py +51 -0
  28. getframes/scene/optics.py +180 -0
  29. getframes/scene/photometry.py +311 -0
  30. getframes/scene/psf.py +371 -0
  31. getframes/scene/scene.py +205 -0
  32. getframes/scene/sources.py +683 -0
  33. getframes/scene/thermal.py +114 -0
  34. getframes/scene/wcs.py +110 -0
  35. getframes/spectral.py +449 -0
  36. getframes-2.0.0.dist-info/METADATA +218 -0
  37. getframes-2.0.0.dist-info/RECORD +40 -0
  38. getframes-2.0.0.dist-info/WHEEL +4 -0
  39. getframes-2.0.0.dist-info/entry_points.txt +2 -0
  40. getframes-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,114 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Thermal (graybody) background for the infrared.
3
+
4
+ For warm instruments and IR detectors (the eAPD/HgCdTe arrays this library ships),
5
+ the dominant background is not the night sky but *thermal emission* from the warm
6
+ telescope, dewar window, and surroundings. :class:`Thermal` models that as a
7
+ graybody of a given temperature and emissivity, integrated over the band into the
8
+ photon rate reaching each pixel --- the IR counterpart of
9
+ :class:`~getframes.scene.sources.Sky`. Detector self-emission ("glow") is modelled
10
+ separately as :attr:`~getframes.config.CameraConfig.detector_glow_e_per_s`.
11
+
12
+ Pure NumPy, no randomness.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ import numpy as np
22
+ from numpy.typing import NDArray
23
+
24
+ from ..spectral import SED
25
+
26
+ if TYPE_CHECKING:
27
+ from .optics import Telescope
28
+
29
+ # NumPy 2.0 renamed ``trapz`` to ``trapezoid``; support both at runtime.
30
+ _trapezoid = getattr(np, "trapezoid", None) or np.trapz # type: ignore[attr-defined] # noqa: NPY201
31
+
32
+ # Physical constants (SI).
33
+ _H_PLANCK = 6.62607015e-34 # J s
34
+ _C_LIGHT = 2.99792458e8 # m / s
35
+ _K_BOLTZMANN = 1.380649e-23 # J / K
36
+
37
+ # Arcseconds to radians, for the per-pixel solid angle.
38
+ _ARCSEC_TO_RAD = math.pi / (180.0 * 3600.0)
39
+
40
+
41
+ def _photon_radiance(
42
+ wavelength_m: NDArray[np.float64], temperature_k: float
43
+ ) -> NDArray[np.float64]:
44
+ """Planck *photon* spectral radiance in photons/s/m^2/sr per metre of wavelength.
45
+
46
+ ``L_ph(lambda) = (2 c / lambda^4) / (exp(hc / lambda k T) - 1)`` --- the Planck
47
+ law per photon (energy radiance divided by the photon energy ``hc/lambda``).
48
+ """
49
+ x = _H_PLANCK * _C_LIGHT / (wavelength_m * _K_BOLTZMANN * temperature_k)
50
+ radiance: NDArray[np.float64] = 2.0 * _C_LIGHT / wavelength_m**4 / np.expm1(x)
51
+ return radiance
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Thermal:
56
+ """A graybody thermal background from warm optics/enclosure.
57
+
58
+ Models the thermal emission seen by the detector as a graybody of emissivity
59
+ :attr:`emissivity` at temperature :attr:`temperature_k`, integrated over the
60
+ telescope band into a per-pixel photon rate. Attach it to a
61
+ :class:`~getframes.scene.scene.Scene` (``scene.thermal = Thermal(...)``) and it
62
+ is added as a uniform background by :meth:`getframes.Camera.observe`, like the
63
+ sky but dominant in the thermal infrared.
64
+
65
+ Computing the rate requires the telescope band to carry a spectral
66
+ ``response`` (the graybody is integrated over it).
67
+
68
+ Parameters
69
+ ----------
70
+ temperature_k:
71
+ Graybody temperature in kelvin (e.g. ~273--293 K for a warm enclosure).
72
+ emissivity:
73
+ Effective emissivity in ``[0, 1]`` (the warm optics' grey emission factor).
74
+ """
75
+
76
+ temperature_k: float
77
+ emissivity: float = 1.0
78
+
79
+ def __post_init__(self) -> None:
80
+ if self.temperature_k <= 0:
81
+ raise ValueError("temperature_k must be positive.")
82
+ if not 0.0 <= self.emissivity <= 1.0:
83
+ raise ValueError("emissivity must be in [0, 1].")
84
+
85
+ def photon_rate(self, optics: Telescope) -> float:
86
+ """Thermal background in photons/s/pixel reaching the detector through ``optics``.
87
+
88
+ ``emissivity * Omega_pixel * A_collect * int L_ph(lambda, T) T_band(lambda)
89
+ dlambda``, with ``Omega_pixel`` the per-pixel solid angle and ``A_collect``
90
+ the collecting area. Requires a band with a spectral response.
91
+ """
92
+ band = optics.band
93
+ if band is None or band.response is None:
94
+ raise ValueError("Thermal.photon_rate requires the telescope band to have a response.")
95
+ resp = band.response.response
96
+ wl_m = resp.wavelength_nm * 1e-9
97
+ integrand = _photon_radiance(wl_m, self.temperature_k) * resp.value
98
+ radiance = float(_trapezoid(integrand, wl_m)) # photons/s/m^2/sr
99
+ omega_sr = (optics.plate_scale_arcsec_per_pixel * _ARCSEC_TO_RAD) ** 2
100
+ return self.emissivity * radiance * optics.collecting_area_m2 * omega_sr
101
+
102
+ def photon_sed(
103
+ self,
104
+ wavelength_min_nm: float = 300.0,
105
+ wavelength_max_nm: float = 3000.0,
106
+ n_samples: int = 256,
107
+ ) -> SED:
108
+ """A *relative* SED of the graybody photon spectrum (for spectral effective QE)."""
109
+ wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples)
110
+ radiance = _photon_radiance(wl_nm * 1e-9, self.temperature_k)
111
+ return SED.from_arrays(wl_nm, radiance / radiance.max())
112
+
113
+
114
+ __all__ = ["Thermal"]
getframes/scene/wcs.py ADDED
@@ -0,0 +1,110 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Optional world-coordinate-system (WCS) tagging for scenes and frames.
3
+
4
+ A :class:`WCSInfo` maps detector pixels to sky coordinates (right ascension and
5
+ declination) under a gnomonic (TAN) projection. It can emit the corresponding FITS
6
+ header cards with no third-party dependency, so an observed :class:`Frame` carries
7
+ a valid WCS into its FITS export. Pixel <-> sky conversions delegate to ``astropy``
8
+ and are available when it is installed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from astropy.wcs import WCS
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class WCSInfo:
23
+ """A tangent-plane (TAN) world coordinate system for a detector frame.
24
+
25
+ Parameters
26
+ ----------
27
+ crval_ra_deg, crval_dec_deg:
28
+ Sky coordinates (degrees) of the reference point.
29
+ crpix_x, crpix_y:
30
+ Pixel coordinates of the reference point, in 0-based array convention
31
+ (``crpix_x`` is the column, ``crpix_y`` the row). They are written to the
32
+ FITS header in the 1-based convention the standard requires.
33
+ plate_scale_arcsec_per_pixel:
34
+ Angular pixel size, matching the telescope's plate scale.
35
+ rotation_deg:
36
+ Position angle of the y-axis east of north, in degrees (``0`` puts north up
37
+ and east left, the usual astronomical orientation).
38
+ """
39
+
40
+ crval_ra_deg: float
41
+ crval_dec_deg: float
42
+ crpix_x: float
43
+ crpix_y: float
44
+ plate_scale_arcsec_per_pixel: float
45
+ rotation_deg: float = 0.0
46
+
47
+ def __post_init__(self) -> None:
48
+ if self.plate_scale_arcsec_per_pixel <= 0:
49
+ raise ValueError("plate_scale_arcsec_per_pixel must be positive.")
50
+ if not -90.0 <= self.crval_dec_deg <= 90.0:
51
+ raise ValueError("crval_dec_deg must be in [-90, 90].")
52
+
53
+ @property
54
+ def _cdelt_deg(self) -> float:
55
+ return self.plate_scale_arcsec_per_pixel / 3600.0
56
+
57
+ def header_cards(self) -> dict[str, Any]:
58
+ """FITS WCS header cards for a TAN projection (8-char keywords, no astropy).
59
+
60
+ RA increases to the left (east), so ``CD1_1`` carries the sign flip. The
61
+ rotation is folded into the CD matrix.
62
+ """
63
+ cd = self._cdelt_deg
64
+ theta = math.radians(self.rotation_deg)
65
+ cos_t, sin_t = math.cos(theta), math.sin(theta)
66
+ # RA runs east (to smaller pixel-x for north-up), hence the leading minus.
67
+ return {
68
+ "CTYPE1": "RA---TAN",
69
+ "CTYPE2": "DEC--TAN",
70
+ "CUNIT1": "deg",
71
+ "CUNIT2": "deg",
72
+ "CRVAL1": float(self.crval_ra_deg),
73
+ "CRVAL2": float(self.crval_dec_deg),
74
+ "CRPIX1": float(self.crpix_x) + 1.0,
75
+ "CRPIX2": float(self.crpix_y) + 1.0,
76
+ "CD1_1": -cd * cos_t,
77
+ "CD1_2": cd * sin_t,
78
+ "CD2_1": cd * sin_t,
79
+ "CD2_2": cd * cos_t,
80
+ }
81
+
82
+ def to_astropy(self) -> WCS:
83
+ """Build an :class:`astropy.wcs.WCS` (requires ``astropy``)."""
84
+ try:
85
+ from astropy.wcs import WCS
86
+ except ImportError as exc: # pragma: no cover - astropy is a core dependency
87
+ raise ImportError(
88
+ "WCS pixel/world conversion requires astropy (a core dependency of "
89
+ "getframes); reinstall with: pip install getframes"
90
+ ) from exc
91
+ wcs = WCS(naxis=2)
92
+ cards = self.header_cards()
93
+ wcs.wcs.ctype = [cards["CTYPE1"], cards["CTYPE2"]]
94
+ wcs.wcs.crval = [cards["CRVAL1"], cards["CRVAL2"]]
95
+ wcs.wcs.crpix = [cards["CRPIX1"], cards["CRPIX2"]]
96
+ wcs.wcs.cd = [[cards["CD1_1"], cards["CD1_2"]], [cards["CD2_1"], cards["CD2_2"]]]
97
+ return wcs
98
+
99
+ def pixel_to_world(self, x: float, y: float) -> tuple[float, float]:
100
+ """Convert a 0-based pixel ``(x, y)`` to ``(ra_deg, dec_deg)`` (needs astropy)."""
101
+ ra, dec = self.to_astropy().all_pix2world([[x, y]], 0)[0]
102
+ return float(ra), float(dec)
103
+
104
+ def world_to_pixel(self, ra_deg: float, dec_deg: float) -> tuple[float, float]:
105
+ """Convert ``(ra_deg, dec_deg)`` to a 0-based pixel ``(x, y)`` (needs astropy)."""
106
+ x, y = self.to_astropy().all_world2pix([[ra_deg, dec_deg]], 0)[0]
107
+ return float(x), float(y)
108
+
109
+
110
+ __all__ = ["WCSInfo"]
getframes/spectral.py ADDED
@@ -0,0 +1,449 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Wavelength-resolved primitives for the opt-in *spectral mode*.
3
+
4
+ The band-integrated model (a scalar quantum efficiency and a single photon zero
5
+ point per band) is accurate enough for exposure planning, but it cannot capture
6
+ how a detector's response *colour* interacts with a source's spectral energy
7
+ distribution (SED). Spectral mode adds that, additively, through three tabulated
8
+ curves on a shared wavelength axis (nanometres):
9
+
10
+ * :class:`SED` --- a source's spectral *photon* flux density (shape only; the
11
+ absolute level is still set by the source magnitude),
12
+ * :class:`SpectralBandpass` --- a filter/optics transmission response in ``[0, 1]``,
13
+ * :class:`QE` --- a detector quantum-efficiency curve in ``[0, 1]``.
14
+
15
+ The single physical quantity spectral mode computes is the **effective quantum
16
+ efficiency** a source sees,
17
+
18
+ .. math::
19
+
20
+ \\mathrm{QE}_\\mathrm{eff} =
21
+ \\frac{\\int S(\\lambda)\\,T(\\lambda)\\,\\mathrm{QE}(\\lambda)\\,d\\lambda}
22
+ {\\int S(\\lambda)\\,T(\\lambda)\\,d\\lambda},
23
+
24
+ a photon-weighted average of :math:`\\mathrm{QE}(\\lambda)` over the band. It is a
25
+ ratio, so it is invariant to the absolute normalisation of both the SED and the
26
+ bandpass --- which is why spectral mode needs no absolute reference spectrum and
27
+ leaves the magnitude-to-photon-rate conversion (governed by the band zero point)
28
+ untouched. Only the photon-to-electron conversion is refined.
29
+
30
+ Everything here is pure NumPy and free of randomness.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import math
36
+ from dataclasses import dataclass
37
+ from typing import Any, ClassVar
38
+
39
+ import numpy as np
40
+ from numpy.typing import ArrayLike, NDArray
41
+
42
+ # NumPy 2.0 renamed ``trapz`` to ``trapezoid``; support both at runtime.
43
+ _trapezoid = getattr(np, "trapezoid", None) or np.trapz # type: ignore[attr-defined] # noqa: NPY201
44
+
45
+ # Planck/physical constants (SI), used only for blackbody SED shapes.
46
+ _H_PLANCK = 6.62607015e-34 # J s
47
+ _C_LIGHT = 2.99792458e8 # m / s
48
+ _K_BOLTZMANN = 1.380649e-23 # J / K
49
+
50
+ # A generous default optical/near-IR window for synthesised curves (nm).
51
+ _DEFAULT_WL_MIN_NM = 300.0
52
+ _DEFAULT_WL_MAX_NM = 1100.0
53
+
54
+
55
+ def _to_nm(wavelength: ArrayLike) -> NDArray[np.float64]:
56
+ """Coerce a wavelength array to nanometres, honouring ``astropy.units``.
57
+
58
+ Optional ``astropy.units`` interop: an :class:`astropy.units.Quantity` is
59
+ converted to nm; a plain array/sequence is taken to already be in nanometres.
60
+ """
61
+ if hasattr(wavelength, "unit") and hasattr(wavelength, "to"):
62
+ return np.asarray(wavelength.to("nm").value, dtype=np.float64)
63
+ return np.asarray(wavelength, dtype=np.float64)
64
+
65
+
66
+ def _strip_units(value: ArrayLike) -> NDArray[np.float64]:
67
+ """Drop an ``astropy.units`` wrapper (if any), returning a plain float array."""
68
+ if hasattr(value, "unit") and hasattr(value, "value"):
69
+ return np.asarray(value.value, dtype=np.float64)
70
+ return np.asarray(value, dtype=np.float64)
71
+
72
+
73
+ def _as_grid(
74
+ wavelength_nm: ArrayLike, value: ArrayLike
75
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
76
+ wl = np.asarray(wavelength_nm, dtype=np.float64)
77
+ val = np.asarray(value, dtype=np.float64)
78
+ if wl.ndim != 1 or val.ndim != 1:
79
+ raise ValueError("wavelength_nm and value must be 1-D arrays.")
80
+ if wl.shape != val.shape:
81
+ raise ValueError("wavelength_nm and value must have the same length.")
82
+ if wl.size < 2:
83
+ raise ValueError("a spectrum needs at least two samples.")
84
+ if not np.all(np.diff(wl) > 0):
85
+ raise ValueError("wavelength_nm must be strictly increasing.")
86
+ if not (np.all(np.isfinite(wl)) and np.all(np.isfinite(val))):
87
+ raise ValueError("wavelength_nm and value must be finite.")
88
+ return wl, val
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Spectrum:
93
+ """A tabulated, non-negative curve ``value(wavelength_nm)``.
94
+
95
+ Values are linearly interpolated within the sampled range and treated as zero
96
+ outside it. The wavelength axis is in nanometres and must be strictly
97
+ increasing. This base class carries the shared sampling/integration machinery;
98
+ :class:`SED`, :class:`SpectralBandpass`, and :class:`QE` add units and
99
+ constructors.
100
+ """
101
+
102
+ wavelength_nm: NDArray[np.float64]
103
+ value: NDArray[np.float64]
104
+
105
+ def __post_init__(self) -> None:
106
+ wl, val = _as_grid(self.wavelength_nm, self.value)
107
+ if np.any(val < 0):
108
+ raise ValueError("spectrum values must be non-negative.")
109
+ object.__setattr__(self, "wavelength_nm", wl)
110
+ object.__setattr__(self, "value", val)
111
+
112
+ def __call__(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
113
+ """Interpolate the curve at ``wavelength_nm`` (zero outside the sampled range)."""
114
+ wl = np.asarray(wavelength_nm, dtype=np.float64)
115
+ return np.interp(wl, self.wavelength_nm, self.value, left=0.0, right=0.0)
116
+
117
+ @classmethod
118
+ def from_file(
119
+ cls,
120
+ path: str,
121
+ *,
122
+ wavelength_to_nm: float = 1.0,
123
+ delimiter: str | None = None,
124
+ skiprows: int = 0,
125
+ usecols: tuple[int, int] = (0, 1),
126
+ ) -> Spectrum:
127
+ """Load a two-column ``(wavelength, value)`` curve from a text file.
128
+
129
+ Reads ``path`` with :func:`numpy.loadtxt`. The first column is scaled by
130
+ ``wavelength_to_nm`` to nanometres (e.g. ``0.1`` for angstroms, ``1000`` for
131
+ microns); the second is taken verbatim. Handy for measured filter, QE, or
132
+ atmospheric-transmission curves --- combine several with :func:`product` or
133
+ :meth:`SpectralBandpass.from_product`.
134
+ """
135
+ data = np.loadtxt(path, delimiter=delimiter, skiprows=skiprows, usecols=usecols)
136
+ wl = np.asarray(data[:, 0], dtype=np.float64) * float(wavelength_to_nm)
137
+ val = np.asarray(data[:, 1], dtype=np.float64)
138
+ return cls(wl, val)
139
+
140
+ def integrate(self) -> float:
141
+ """Trapezoidal integral of the curve over wavelength (nm)."""
142
+ return float(_trapezoid(self.value, self.wavelength_nm))
143
+
144
+ @property
145
+ def wavelength_min_nm(self) -> float:
146
+ return float(self.wavelength_nm[0])
147
+
148
+ @property
149
+ def wavelength_max_nm(self) -> float:
150
+ return float(self.wavelength_nm[-1])
151
+
152
+
153
+ def _common_grid(*spectra: Spectrum) -> NDArray[np.float64]:
154
+ """A merged wavelength grid spanning the overlap of all ``spectra``.
155
+
156
+ Integration over a product of curves is exact on the union of their sample
157
+ points (the curves are piecewise-linear), so we evaluate on that union,
158
+ clipped to the common support where every curve is defined. Returns an empty
159
+ grid when the curves do not overlap.
160
+ """
161
+ lo = max(s.wavelength_min_nm for s in spectra)
162
+ hi = min(s.wavelength_max_nm for s in spectra)
163
+ if hi <= lo:
164
+ return np.empty(0, dtype=np.float64)
165
+ knots = np.concatenate([s.wavelength_nm for s in spectra])
166
+ grid = np.unique(np.concatenate([knots, [lo, hi]]))
167
+ clipped: NDArray[np.float64] = grid[(grid >= lo) & (grid <= hi)]
168
+ return clipped
169
+
170
+
171
+ def overlap_integral(*spectra: Spectrum) -> float:
172
+ """Integrate the pointwise product of several spectra over their common range.
173
+
174
+ Returns ``0.0`` when the spectra do not overlap (the product is zero there).
175
+ """
176
+ grid = _common_grid(*spectra)
177
+ if grid.size < 2:
178
+ return 0.0
179
+ values = np.ones_like(grid)
180
+ for s in spectra:
181
+ values = values * s(grid)
182
+ return float(_trapezoid(values, grid))
183
+
184
+
185
+ def product(*spectra: Spectrum) -> Spectrum:
186
+ """Pointwise product of several spectra as a new :class:`Spectrum`.
187
+
188
+ The result is sampled on the union of the inputs' knots within their common
189
+ wavelength support, where the product of piecewise-linear curves is exact ---
190
+ outside that support at least one factor is zero. The natural way to fold a
191
+ measured filter transmission, detector QE, and atmospheric transmission into a
192
+ single response curve. Raises if the inputs do not overlap.
193
+ """
194
+ grid = _common_grid(*spectra)
195
+ if grid.size < 2:
196
+ raise ValueError("spectra do not overlap; their product is empty.")
197
+ values = np.ones_like(grid)
198
+ for s in spectra:
199
+ values = values * s(grid)
200
+ return Spectrum(grid, values)
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class SED(Spectrum):
205
+ """A source's spectral *photon* flux density.
206
+
207
+ Two flavours, distinguished by :attr:`is_absolute`:
208
+
209
+ * **Relative** (the default; :meth:`from_arrays` and the parametric shapes).
210
+ Only the *shape* matters: spectral mode uses it to colour-weight the quantum
211
+ efficiency, a calculation invariant to overall scale (the source magnitude
212
+ still sets the absolute photon rate).
213
+ * **Absolute** (:meth:`from_flux_density`): values are a true photon flux density
214
+ in ``photons/s/m^2/nm`` above the atmosphere. Such an SED can *set the
215
+ integrated photon rate* directly --- pass it to a source as ``flux_sed`` and
216
+ the telescope integrates it over the band (see
217
+ :meth:`getframes.scene.photometry.Bandpass.photon_flux_from_sed`), instead of
218
+ deriving the rate from a magnitude.
219
+ """
220
+
221
+ is_absolute: bool = False
222
+
223
+ @classmethod
224
+ def from_arrays(cls, wavelength_nm: ArrayLike, photon_flux: ArrayLike) -> SED:
225
+ """A *relative* SED sampled at ``wavelength_nm`` with photon flux density (shape only)."""
226
+ return cls(_to_nm(wavelength_nm), _strip_units(photon_flux))
227
+
228
+ @classmethod
229
+ def from_flux_density(cls, wavelength_nm: ArrayLike, photon_flux_density: ArrayLike) -> SED:
230
+ """An *absolute* SED: ``photon_flux_density`` in ``photons/s/m^2/nm``.
231
+
232
+ Unlike :meth:`from_arrays`, the absolute scale is meaningful: integrated over
233
+ a band it yields a photon rate, so a source carrying this as ``flux_sed`` has
234
+ its brightness set by the spectrum itself (no magnitude needed). Wavelengths
235
+ and flux may be plain arrays (nm and photons/s/m^2/nm) or ``astropy.units``
236
+ quantities, which are converted.
237
+ """
238
+ return cls(_to_nm(wavelength_nm), _strip_units(photon_flux_density), is_absolute=True)
239
+
240
+ @classmethod
241
+ def flat(
242
+ cls,
243
+ wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
244
+ wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
245
+ ) -> SED:
246
+ """A flat photon spectrum (equal photons per unit wavelength).
247
+
248
+ The neutral default: with a flat SED the effective QE is simply the
249
+ bandpass-weighted mean of ``QE(lambda)``.
250
+ """
251
+ wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
252
+ return cls(wl, np.ones_like(wl))
253
+
254
+ @classmethod
255
+ def blackbody(
256
+ cls,
257
+ temperature_k: float,
258
+ wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
259
+ wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
260
+ n_samples: int = 256,
261
+ ) -> SED:
262
+ """A blackbody photon spectrum at ``temperature_k`` (relative units).
263
+
264
+ Photon spectral radiance ``~ lambda**-4 / (exp(hc / lambda k T) - 1)`` --- the
265
+ Planck law expressed per photon rather than per unit energy. Good for giving
266
+ a star a colour (e.g. ``5800`` K for a sun-like source, ``3500`` K for a cool
267
+ M dwarf, ``10000`` K for a hot blue star).
268
+ """
269
+ if temperature_k <= 0:
270
+ raise ValueError("temperature_k must be positive.")
271
+ wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples)
272
+ wl_m = wl_nm * 1e-9
273
+ x = _H_PLANCK * _C_LIGHT / (wl_m * _K_BOLTZMANN * temperature_k)
274
+ # Photon radiance density: ~ lambda^-4 / (exp(x) - 1). Constants drop out.
275
+ photons = wl_m**-4 / np.expm1(x)
276
+ return cls(wl_nm, photons / photons.max())
277
+
278
+ @classmethod
279
+ def power_law(
280
+ cls,
281
+ index: float,
282
+ reference_wavelength_nm: float = 550.0,
283
+ wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
284
+ wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
285
+ n_samples: int = 64,
286
+ ) -> SED:
287
+ """A power-law photon spectrum ``(lambda / lambda_ref)**index`` (relative)."""
288
+ wl = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples)
289
+ return cls(wl, (wl / reference_wavelength_nm) ** index)
290
+
291
+
292
+ class QE(Spectrum):
293
+ """A detector quantum-efficiency curve, ``QE(lambda)`` in ``[0, 1]``."""
294
+
295
+ def __post_init__(self) -> None:
296
+ super().__post_init__()
297
+ if np.any(self.value > 1.0):
298
+ raise ValueError("QE values must be in [0, 1].")
299
+
300
+ @classmethod
301
+ def from_arrays(cls, wavelength_nm: ArrayLike, qe: ArrayLike) -> QE:
302
+ """A QE curve sampled at ``wavelength_nm`` with values in ``[0, 1]``."""
303
+ return cls(_to_nm(wavelength_nm), _strip_units(qe))
304
+
305
+ @classmethod
306
+ def constant(
307
+ cls,
308
+ value: float,
309
+ wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
310
+ wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
311
+ ) -> QE:
312
+ """A flat QE curve --- equivalent to the scalar ``quantum_efficiency``."""
313
+ if not 0.0 <= value <= 1.0:
314
+ raise ValueError("QE value must be in [0, 1].")
315
+ wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
316
+ return cls(wl, np.full_like(wl, value))
317
+
318
+
319
+ @dataclass(frozen=True)
320
+ class SpectralBandpass:
321
+ """A filter/optics transmission response ``T(lambda)`` in ``[0, 1]``.
322
+
323
+ Carries the spectral *shape* of a band, used to colour-weight the effective QE.
324
+ It does not replace a :class:`~getframes.scene.photometry.Bandpass`'s scalar
325
+ photon zero point (which still sets the magnitude-to-photon conversion); it
326
+ refines the photon-to-electron step.
327
+ """
328
+
329
+ response: Spectrum
330
+
331
+ @classmethod
332
+ def from_arrays(cls, wavelength_nm: ArrayLike, throughput: ArrayLike) -> SpectralBandpass:
333
+ """A response curve sampled at ``wavelength_nm`` with throughput in ``[0, 1]``."""
334
+ spec = Spectrum(_to_nm(wavelength_nm), _strip_units(throughput))
335
+ if np.any(spec.value > 1.0):
336
+ raise ValueError("bandpass throughput must be in [0, 1].")
337
+ return cls(spec)
338
+
339
+ @classmethod
340
+ def from_file(cls, path: str, **kwargs: Any) -> SpectralBandpass:
341
+ """Load a two-column ``(wavelength, throughput)`` response from a text file.
342
+
343
+ Thin wrapper over :meth:`Spectrum.from_file` (same ``wavelength_to_nm``,
344
+ ``delimiter``, ``skiprows``, ``usecols`` options); throughput must be in
345
+ ``[0, 1]``.
346
+ """
347
+ spec = Spectrum.from_file(path, **kwargs)
348
+ if np.any(spec.value > 1.0):
349
+ raise ValueError("bandpass throughput must be in [0, 1].")
350
+ return cls(spec)
351
+
352
+ @classmethod
353
+ def from_product(cls, *items: SpectralBandpass | Spectrum) -> SpectralBandpass:
354
+ """Fold several transmission curves into one combined band response.
355
+
356
+ Each item is a :class:`SpectralBandpass` or a bare :class:`Spectrum` (e.g. a
357
+ :class:`QE` curve or an atmospheric-transmission curve); their pointwise
358
+ product over the common wavelength support becomes the new response. This is
359
+ how a *real* filter x QE x atmosphere transmission product is assembled.
360
+ """
361
+ specs = [it.response if isinstance(it, SpectralBandpass) else it for it in items]
362
+ combined = product(*specs)
363
+ if np.any(combined.value > 1.0):
364
+ raise ValueError("combined throughput exceeds 1; check the input curves.")
365
+ return cls(combined)
366
+
367
+ @classmethod
368
+ def tophat(cls, center_nm: float, width_nm: float, peak: float = 1.0) -> SpectralBandpass:
369
+ """A flat-topped band of full width ``width_nm`` centred on ``center_nm``.
370
+
371
+ Soft (one-sample) shoulders keep the curve continuous for integration.
372
+ """
373
+ if width_nm <= 0:
374
+ raise ValueError("width_nm must be positive.")
375
+ if not 0.0 < peak <= 1.0:
376
+ raise ValueError("peak must be in (0, 1].")
377
+ lo = center_nm - 0.5 * width_nm
378
+ hi = center_nm + 0.5 * width_nm
379
+ edge = max(width_nm * 1e-3, 1e-6)
380
+ wl = np.array([lo - edge, lo, hi, hi + edge], dtype=np.float64)
381
+ val = np.array([0.0, peak, peak, 0.0], dtype=np.float64)
382
+ return cls(Spectrum(wl, val))
383
+
384
+ # Representative Johnson-Cousins effective wavelengths and widths (nm). These
385
+ # are coarse tophat stand-ins for the real filter curves --- enough to give a
386
+ # sensible colour term; supply measured curves via ``from_arrays`` for rigour.
387
+ _JOHNSON_NM: ClassVar[dict[str, tuple[float, float]]] = {
388
+ "U": (365.0, 66.0),
389
+ "B": (445.0, 94.0),
390
+ "V": (551.0, 88.0),
391
+ "R": (658.0, 138.0),
392
+ "I": (806.0, 149.0),
393
+ }
394
+
395
+ @classmethod
396
+ def johnson(cls, band: str) -> SpectralBandpass:
397
+ """A tophat approximation of a Johnson-Cousins band (one of U, B, V, R, I)."""
398
+ key = band.strip().upper()
399
+ if key not in cls._JOHNSON_NM:
400
+ valid = ", ".join(cls._JOHNSON_NM)
401
+ raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
402
+ center, width = cls._JOHNSON_NM[key]
403
+ return cls.tophat(center, width)
404
+
405
+ @property
406
+ def pivot_wavelength_nm(self) -> float:
407
+ """The pivot wavelength: ``sqrt(int T dl / int T l^-2 dl)`` (nm)."""
408
+ wl = self.response.wavelength_nm
409
+ t = self.response.value
410
+ num = float(_trapezoid(t, wl))
411
+ den = float(_trapezoid(t / wl**2, wl))
412
+ return math.sqrt(num / den)
413
+
414
+ @property
415
+ def mean_wavelength_nm(self) -> float:
416
+ """The throughput-weighted mean wavelength (nm)."""
417
+ wl = self.response.wavelength_nm
418
+ t = self.response.value
419
+ return float(_trapezoid(t * wl, wl) / _trapezoid(t, wl))
420
+
421
+
422
+ def effective_qe(qe: QE, bandpass: SpectralBandpass, sed: SED | None = None) -> float:
423
+ """Photon-weighted effective quantum efficiency a source sees through a band.
424
+
425
+ Computes ``int S T QE dl / int S T dl`` over the wavelength range common to the
426
+ SED, bandpass, and QE curve. ``sed`` defaults to a flat photon spectrum, giving
427
+ the bandpass-weighted mean QE. The result is a dimensionless number in
428
+ ``[0, 1]`` and is invariant to the absolute scale of both ``S`` and ``T``.
429
+ """
430
+ source = sed if sed is not None else SED.flat()
431
+ response = bandpass.response
432
+ denom = overlap_integral(source, response)
433
+ if denom <= 0:
434
+ raise ValueError(
435
+ "SED and bandpass do not overlap the QE curve; cannot compute effective QE."
436
+ )
437
+ numer = overlap_integral(source, response, qe)
438
+ return numer / denom
439
+
440
+
441
+ __all__ = [
442
+ "QE",
443
+ "SED",
444
+ "SpectralBandpass",
445
+ "Spectrum",
446
+ "effective_qe",
447
+ "overlap_integral",
448
+ "product",
449
+ ]