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,180 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The telescope/instrument: collecting area, throughput, plate scale, and the
3
+ magnitude -> photon-rate conversion that feeds the detector."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ import numpy as np
12
+ from numpy.typing import NDArray
13
+
14
+ from .photometry import Bandpass
15
+
16
+ if TYPE_CHECKING:
17
+ from ..spectral import SED
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Vignetting:
22
+ """A radial illumination falloff toward the edges of the field.
23
+
24
+ Relative illumination is ``1 - strength * (r / r_corner)^power``, where ``r`` is
25
+ the distance from the optical centre and ``r_corner`` is the distance to the
26
+ farthest corner. ``strength`` is the fractional light loss at that corner;
27
+ ``power=2`` gives a gentle quadratic roll-off (``power=4`` approximates the cos^4
28
+ law). The map is clipped to ``[0, 1]``.
29
+ """
30
+
31
+ strength: float
32
+ power: float = 2.0
33
+
34
+ def __post_init__(self) -> None:
35
+ if not 0.0 <= self.strength <= 1.0:
36
+ raise ValueError("vignetting strength must be in [0, 1].")
37
+ if self.power <= 0:
38
+ raise ValueError("vignetting power must be positive.")
39
+
40
+ def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64]:
41
+ """Relative illumination in ``[0, 1]`` for a frame of ``(height, width)``."""
42
+ height, width = shape
43
+ cy, cx = (height - 1) / 2.0, (width - 1) / 2.0
44
+ yy, xx = np.mgrid[0:height, 0:width]
45
+ r = np.hypot(xx - cx, yy - cy)
46
+ r_corner = math.hypot(max(cx, width - 1 - cx), max(cy, height - 1 - cy))
47
+ if r_corner == 0:
48
+ return np.ones(shape, dtype=np.float64)
49
+ rel = 1.0 - self.strength * (r / r_corner) ** self.power
50
+ clipped: NDArray[np.float64] = np.clip(rel, 0.0, 1.0).astype(np.float64)
51
+ return clipped
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class RadialDistortion:
56
+ """A simple radial (barrel/pincushion) distortion about the field centre.
57
+
58
+ A source at pixel distance ``r`` from the centre is displaced to
59
+ ``r * (1 + k1 r^2 + k2 r^4)``. ``k1 < 0`` gives barrel distortion, ``k1 > 0``
60
+ pincushion; both coefficients carry inverse-pixel-power units (``k1`` is small,
61
+ e.g. ``1e-7`` per pixel^2 for a 2k detector).
62
+ """
63
+
64
+ k1: float
65
+ k2: float = 0.0
66
+
67
+ def apply(self, x: float, y: float, cx: float, cy: float) -> tuple[float, float]:
68
+ """Map pixel ``(x, y)`` to its distorted position about centre ``(cx, cy)``."""
69
+ dx, dy = x - cx, y - cy
70
+ r2 = dx * dx + dy * dy
71
+ factor = 1.0 + self.k1 * r2 + self.k2 * r2 * r2
72
+ return cx + dx * factor, cy + dy * factor
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Telescope:
77
+ """An optical system that turns source magnitudes into photon rates at the focal plane.
78
+
79
+ Parameters
80
+ ----------
81
+ aperture_diameter_m:
82
+ Primary aperture diameter in metres.
83
+ plate_scale_arcsec_per_pixel:
84
+ Angular size of one detector pixel, in arcseconds.
85
+ throughput:
86
+ End-to-end fraction of photons transmitted (optics x filter x atmosphere),
87
+ in ``[0, 1]``.
88
+ central_obstruction:
89
+ Diameter of the central obstruction as a fraction of the aperture diameter
90
+ (e.g. the secondary mirror); ``0`` for an unobstructed aperture.
91
+ band:
92
+ The :class:`~getframes.scene.photometry.Bandpass` used to convert
93
+ magnitudes to photon rates. Required only if any source is specified by
94
+ magnitude (rather than an explicit photon rate).
95
+ vignetting:
96
+ Optional :class:`Vignetting` illumination falloff applied to the rendered
97
+ source map (sources only, not the uniform sky).
98
+ distortion:
99
+ Optional :class:`RadialDistortion` displacing source positions about the
100
+ field centre before they are deposited.
101
+ """
102
+
103
+ aperture_diameter_m: float
104
+ plate_scale_arcsec_per_pixel: float
105
+ throughput: float = 1.0
106
+ central_obstruction: float = 0.0
107
+ band: Bandpass | None = None
108
+ vignetting: Vignetting | None = None
109
+ distortion: RadialDistortion | None = None
110
+
111
+ def __post_init__(self) -> None:
112
+ if self.aperture_diameter_m <= 0:
113
+ raise ValueError("aperture_diameter_m must be positive.")
114
+ if self.plate_scale_arcsec_per_pixel <= 0:
115
+ raise ValueError("plate_scale_arcsec_per_pixel must be positive.")
116
+ if not 0.0 <= self.throughput <= 1.0:
117
+ raise ValueError("throughput must be in [0, 1].")
118
+ if not 0.0 <= self.central_obstruction < 1.0:
119
+ raise ValueError("central_obstruction must be in [0, 1).")
120
+
121
+ def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64] | None:
122
+ """Relative illumination map for ``shape`` (``None`` if no vignetting set)."""
123
+ if self.vignetting is None:
124
+ return None
125
+ return self.vignetting.illumination_map(shape)
126
+
127
+ @classmethod
128
+ def unit(cls, plate_scale_arcsec_per_pixel: float = 1.0) -> Telescope:
129
+ """A trivial 1 m, unit-throughput telescope.
130
+
131
+ Handy when you supply source photon rates directly (already at the
132
+ detector) and only need a plate scale --- e.g. AO sub-aperture simulations.
133
+ """
134
+ return cls(
135
+ aperture_diameter_m=1.0,
136
+ plate_scale_arcsec_per_pixel=plate_scale_arcsec_per_pixel,
137
+ throughput=1.0,
138
+ )
139
+
140
+ @property
141
+ def collecting_area_m2(self) -> float:
142
+ """Unobstructed collecting area in square metres."""
143
+ d = self.aperture_diameter_m
144
+ return math.pi / 4.0 * (d**2 - (self.central_obstruction * d) ** 2)
145
+
146
+ @property
147
+ def pixel_solid_angle_arcsec2(self) -> float:
148
+ """Solid angle subtended by one pixel, in square arcseconds."""
149
+ return self.plate_scale_arcsec_per_pixel**2
150
+
151
+ def photon_rate_from_magnitude(self, magnitude: float) -> float:
152
+ """Photons/s reaching the detector from a point source of this magnitude."""
153
+ if self.band is None:
154
+ raise ValueError(
155
+ "Telescope.band is required to use magnitudes; set a Bandpass or "
156
+ "specify sources by photon_rate instead."
157
+ )
158
+ return self.band.photon_flux(magnitude) * self.collecting_area_m2 * self.throughput
159
+
160
+ def photon_rate_from_sed(self, sed: SED) -> float:
161
+ """Photons/s at the detector from a source described by an *absolute* SED.
162
+
163
+ Integrates the SED over the band's spectral response
164
+ (:meth:`~getframes.scene.photometry.Bandpass.photon_flux_from_sed`) and
165
+ scales by collecting area and throughput --- the spectral-flux-integration
166
+ counterpart of :meth:`photon_rate_from_magnitude`. Requires a band with a
167
+ spectral response and an absolute SED
168
+ (:meth:`getframes.spectral.SED.from_flux_density`).
169
+ """
170
+ if self.band is None:
171
+ raise ValueError(
172
+ "Telescope.band is required to integrate an SED; set a Bandpass with "
173
+ "a spectral response."
174
+ )
175
+ return self.band.photon_flux_from_sed(sed) * self.collecting_area_m2 * self.throughput
176
+
177
+ def surface_brightness_photon_rate(self, surface_brightness_mag_arcsec2: float) -> float:
178
+ """Photons/s/pixel from a uniform sky of the given surface brightness."""
179
+ per_arcsec2 = self.photon_rate_from_magnitude(surface_brightness_mag_arcsec2)
180
+ return per_arcsec2 * self.pixel_solid_angle_arcsec2
@@ -0,0 +1,311 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Photometric bandpasses: convert astronomical magnitudes to photon rates.
3
+
4
+ A :class:`Bandpass` carries a single band-integrated number --- the photon flux a
5
+ magnitude-zero source delivers above the atmosphere per unit collecting area. The
6
+ magnitude-to-photon-rate conversion is band-integrated (not spectral), which keeps
7
+ it simple while remaining accurate enough for exposure planning.
8
+
9
+ A band may *additionally* carry a spectral ``response`` curve
10
+ (:class:`~getframes.spectral.SpectralBandpass`). That does not change the
11
+ magnitude-to-photon conversion --- the scalar zero point still governs it --- but
12
+ it unlocks the opt-in spectral mode: combined with a detector
13
+ :class:`~getframes.spectral.QE` curve and a source
14
+ :class:`~getframes.spectral.SED`, it yields a colour-dependent *effective* quantum
15
+ efficiency (see :meth:`Bandpass.effective_qe`).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ import numpy as np
23
+ from numpy.typing import ArrayLike, NDArray
24
+
25
+ from ..spectral import QE, SED, SpectralBandpass, Spectrum, effective_qe, overlap_integral
26
+
27
+ # NumPy 2.0 renamed ``trapz`` to ``trapezoid``; support both at runtime.
28
+ _trapezoid = getattr(np, "trapezoid", None) or np.trapz # type: ignore[attr-defined] # noqa: NPY201
29
+
30
+ # Physical constants (SI), used for the AB zero point and extinction.
31
+ _H_PLANCK = 6.62607015e-34 # J s
32
+ _AB_FLUX_ZEROPOINT = 3631.0e-26 # W m^-2 Hz^-1 (the AB system's 3631 Jy reference)
33
+
34
+ # Approximate Vega-system photon zero points, in photons/s/m^2 for a
35
+ # magnitude-0 source, band-integrated (flux density x effective width). These are
36
+ # representative textbook values; supply your own for quantitative work.
37
+ _JOHNSON_PHOTON_ZEROPOINTS = {
38
+ "U": 4.99e9,
39
+ "B": 1.31e10,
40
+ "V": 8.76e9,
41
+ "R": 9.69e9,
42
+ "I": 6.73e9,
43
+ }
44
+
45
+ # Representative ``(effective wavelength nm, FWHM nm)`` for common survey filters,
46
+ # used to synthesise tophat band shapes for the AB system. Coarse stand-ins for the
47
+ # real filter curves --- enough for a sensible zero point and colour term; load a
48
+ # measured curve via ``SpectralBandpass.from_file`` / ``from_product`` for rigour.
49
+ # SDSS ugriz, Gaia (G/BP/RP), and 2MASS (J/H/Ks). Keyed lowercase.
50
+ _AB_BANDS: dict[str, tuple[str, float, float]] = {
51
+ "u": ("SDSS u", 354.3, 57.0),
52
+ "g": ("SDSS g", 477.0, 138.0),
53
+ "r": ("SDSS r", 623.1, 138.0),
54
+ "i": ("SDSS i", 762.5, 152.0),
55
+ "z": ("SDSS z", 913.4, 95.0),
56
+ "gaia_g": ("Gaia G", 639.0, 454.0),
57
+ "gaia_bp": ("Gaia BP", 518.0, 253.0),
58
+ "gaia_rp": ("Gaia RP", 782.0, 296.0),
59
+ "j": ("2MASS J", 1235.0, 162.0),
60
+ "h": ("2MASS H", 1662.0, 251.0),
61
+ "ks": ("2MASS Ks", 2159.0, 262.0),
62
+ }
63
+
64
+
65
+ def _ab_photon_zeropoint(response: SpectralBandpass) -> float:
66
+ r"""Photon zero point (photons/s/m^2 for ``m_AB = 0``) of an AB band.
67
+
68
+ For a flat-:math:`f_\nu` AB source of :math:`f_\nu = 3631\,\mathrm{Jy}`, the
69
+ band-integrated photon flux above the atmosphere is
70
+
71
+ .. math::
72
+
73
+ N_0 = \frac{f_{\nu,0}}{h} \int T(\lambda)\, \frac{d\lambda}{\lambda},
74
+
75
+ a standard result (the photon energy ``hc/lambda`` turns the energy flux into a
76
+ photon count). The integral is dimensionless, so it is evaluated directly on the
77
+ response grid in nanometres.
78
+ """
79
+ wl = response.response.wavelength_nm
80
+ t = response.response.value
81
+ integral = float(_trapezoid(t / wl, wl))
82
+ return _AB_FLUX_ZEROPOINT / _H_PLANCK * integral
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Bandpass:
87
+ """A photometric band, summarised by its photon zero point.
88
+
89
+ Parameters
90
+ ----------
91
+ name:
92
+ Human-readable label, e.g. ``"Johnson V"``.
93
+ photon_zeropoint:
94
+ Photons per second per square metre, above the atmosphere, from a
95
+ magnitude-0 source integrated over the band.
96
+ response:
97
+ Optional spectral transmission curve for the band. Enables spectral mode
98
+ (colour-dependent effective QE); ``None`` keeps the band-integrated model.
99
+ """
100
+
101
+ name: str
102
+ photon_zeropoint: float
103
+ response: SpectralBandpass | None = None
104
+
105
+ def __post_init__(self) -> None:
106
+ if self.photon_zeropoint <= 0:
107
+ raise ValueError("photon_zeropoint must be positive.")
108
+
109
+ @classmethod
110
+ def johnson(cls, band: str, *, spectral: bool = True) -> Bandpass:
111
+ """Return a standard Johnson-Cousins band (one of U, B, V, R, I).
112
+
113
+ By default the band also carries a tophat spectral ``response`` so spectral
114
+ mode works out of the box; pass ``spectral=False`` for the bare zero point.
115
+ """
116
+ key = band.strip().upper()
117
+ if key not in _JOHNSON_PHOTON_ZEROPOINTS:
118
+ valid = ", ".join(_JOHNSON_PHOTON_ZEROPOINTS)
119
+ raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
120
+ response = SpectralBandpass.johnson(key) if spectral else None
121
+ return cls(
122
+ name=f"Johnson {key}",
123
+ photon_zeropoint=_JOHNSON_PHOTON_ZEROPOINTS[key],
124
+ response=response,
125
+ )
126
+
127
+ @classmethod
128
+ def ab(cls, band: str) -> Bandpass:
129
+ """Return an **AB-system** band for a common survey filter.
130
+
131
+ The AB system references every band to a flat :math:`f_\\nu = 3631`
132
+ Jy source, so the zero point is *computed* from the band's transmission
133
+ shape (see :func:`_ab_photon_zeropoint`) rather than tabulated. Supported
134
+ ``band`` names (case-insensitive): SDSS ``u g r i z``, Gaia
135
+ ``gaia_g gaia_bp gaia_rp`` (also ``G BP RP``), and 2MASS ``J H Ks``. Each
136
+ carries a tophat spectral response, so spectral mode works out of the box;
137
+ supply a measured curve via :meth:`SpectralBandpass.from_file` for rigour.
138
+
139
+ Gaia bands are ``gaia_g``, ``gaia_bp``, ``gaia_rp`` (``bp``/``rp`` also
140
+ accepted); ``g`` is SDSS g. Use :meth:`johnson` for the Vega system instead.
141
+ """
142
+ key = _canonical_band(band)
143
+ if key not in _AB_BANDS:
144
+ valid = ", ".join(sorted(_AB_BANDS))
145
+ raise ValueError(f"Unknown AB band {band!r}. Expected one of: {valid}.")
146
+ label, center, width = _AB_BANDS[key]
147
+ response = SpectralBandpass.tophat(center, width)
148
+ return cls(
149
+ name=f"AB {label}",
150
+ photon_zeropoint=_ab_photon_zeropoint(response),
151
+ response=response,
152
+ )
153
+
154
+ def photon_flux(self, magnitude: float) -> float:
155
+ """Photons/s/m^2 above the atmosphere for a source of the given magnitude."""
156
+ return float(self.photon_zeropoint * 10.0 ** (-0.4 * magnitude))
157
+
158
+ def photon_flux_from_sed(self, sed: SED) -> float:
159
+ """Photons/s/m^2 above the atmosphere from an *absolute* SED through this band.
160
+
161
+ Integrates ``int S(lambda) T(lambda) dlambda`` over the band's spectral
162
+ response, where ``S`` is the absolute photon flux density
163
+ (``photons/s/m^2/nm``) of an SED built with
164
+ :meth:`getframes.spectral.SED.from_flux_density`. This is the "true spectral
165
+ flux integration" path: the spectrum itself sets the rate, rather than a
166
+ magnitude. Requires a spectral :attr:`response`.
167
+ """
168
+ if self.response is None:
169
+ raise ValueError(
170
+ f"Bandpass {self.name!r} has no spectral response; "
171
+ "photon_flux_from_sed needs one to integrate the SED over the band."
172
+ )
173
+ if not sed.is_absolute:
174
+ raise ValueError(
175
+ "photon_flux_from_sed needs an absolute SED (build it with SED.from_flux_density)."
176
+ )
177
+ return float(overlap_integral(sed, self.response.response))
178
+
179
+ def effective_qe(self, qe: QE, sed: SED | None = None) -> float:
180
+ """Photon-weighted effective QE for a source of SED ``sed`` seen through this band.
181
+
182
+ Requires a spectral :attr:`response`. ``sed`` defaults to a flat photon
183
+ spectrum (the bandpass-weighted mean QE). See
184
+ :func:`getframes.spectral.effective_qe`.
185
+ """
186
+ if self.response is None:
187
+ raise ValueError(
188
+ f"Bandpass {self.name!r} has no spectral response; "
189
+ "construct it with a response to use spectral mode."
190
+ )
191
+ return effective_qe(qe, self.response, sed)
192
+
193
+
194
+ def _canonical_band(band: str) -> str:
195
+ """Normalise a band name to an :data:`_AB_BANDS` key (case/space/alias-folding)."""
196
+ key = band.strip().lower().replace(" ", "_").replace("-", "_")
197
+ return {"bp": "gaia_bp", "rp": "gaia_rp", "k": "ks", "k_s": "ks"}.get(key, key)
198
+
199
+
200
+ def _ccm89_ab(x: NDArray[np.float64]) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
201
+ """CCM89 extinction coefficients ``a(x)``, ``b(x)`` for ``x = 1/lambda`` in 1/micron.
202
+
203
+ Cardelli, Clayton & Mathis (1989) parameterisation over the infrared
204
+ (``0.3 <= x < 1.1``) and optical/near-UV (``1.1 <= x <= 3.3``) regimes, which
205
+ together cover ~300--3300 nm. ``A(lambda)/A_V = a + b / R_V``.
206
+ """
207
+ a = np.zeros_like(x)
208
+ b = np.zeros_like(x)
209
+ ir = x < 1.1
210
+ a[ir] = 0.574 * x[ir] ** 1.61
211
+ b[ir] = -0.527 * x[ir] ** 1.61
212
+ opt = ~ir
213
+ y = x[opt] - 1.82
214
+ a[opt] = (
215
+ 1.0
216
+ + 0.17699 * y
217
+ - 0.50447 * y**2
218
+ - 0.02427 * y**3
219
+ + 0.72085 * y**4
220
+ + 0.01979 * y**5
221
+ - 0.77530 * y**6
222
+ + 0.32999 * y**7
223
+ )
224
+ b[opt] = (
225
+ 1.41338 * y
226
+ + 2.28305 * y**2
227
+ + 1.07233 * y**3
228
+ - 5.38434 * y**4
229
+ - 0.62251 * y**5
230
+ + 5.30260 * y**6
231
+ - 2.09002 * y**7
232
+ )
233
+ return a, b
234
+
235
+
236
+ @dataclass(frozen=True)
237
+ class Extinction:
238
+ """Interstellar extinction (reddening) by intervening dust.
239
+
240
+ A Cardelli, Clayton & Mathis (1989) extinction curve, parameterised by the
241
+ visual extinction ``a_v`` (magnitudes of attenuation in V) and the total-to-
242
+ selective ratio ``r_v`` (3.1 for the diffuse Galactic ISM). It dims and reddens a
243
+ source: redder dust passes more light, so a blue source is attenuated more.
244
+
245
+ Use :meth:`transmission` for the wavelength-dependent throughput
246
+ ``10**(-0.4 A(lambda))``, :meth:`redden` to apply it to an
247
+ :class:`~getframes.spectral.SED`, or :meth:`band_attenuation_mag` for the
248
+ band-integrated magnitude shift to add to a source magnitude.
249
+
250
+ Parameters
251
+ ----------
252
+ a_v:
253
+ Visual extinction ``A_V`` in magnitudes (non-negative).
254
+ r_v:
255
+ Total-to-selective extinction ratio ``R_V = A_V / E(B-V)`` (default 3.1).
256
+ """
257
+
258
+ a_v: float
259
+ r_v: float = 3.1
260
+
261
+ def __post_init__(self) -> None:
262
+ if self.a_v < 0:
263
+ raise ValueError("a_v must be non-negative.")
264
+ if self.r_v <= 0:
265
+ raise ValueError("r_v must be positive.")
266
+
267
+ def attenuation_mag(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
268
+ """Extinction ``A(lambda)`` in magnitudes at each wavelength (nm).
269
+
270
+ Wavelengths outside the CCM89 range (~303--3333 nm) are clamped to the
271
+ nearest valid value.
272
+ """
273
+ wl_um = np.asarray(wavelength_nm, dtype=np.float64) / 1000.0
274
+ x = np.clip(1.0 / wl_um, 0.3, 3.3)
275
+ a, b = _ccm89_ab(x)
276
+ return self.a_v * (a + b / self.r_v)
277
+
278
+ def transmission(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
279
+ """Fractional transmission ``10**(-0.4 A(lambda))`` at each wavelength (nm)."""
280
+ return np.asarray(10.0 ** (-0.4 * self.attenuation_mag(wavelength_nm)), dtype=np.float64)
281
+
282
+ def transmission_curve(self, wavelength_nm: ArrayLike) -> Spectrum:
283
+ """The transmission as a :class:`~getframes.spectral.Spectrum` (for :func:`product`)."""
284
+ wl = np.asarray(wavelength_nm, dtype=np.float64)
285
+ return Spectrum(wl, self.transmission(wl))
286
+
287
+ def redden(self, sed: SED) -> SED:
288
+ """Apply extinction to ``sed``, returning a reddened copy (units preserved)."""
289
+ reddened = sed.value * self.transmission(sed.wavelength_nm)
290
+ return SED(sed.wavelength_nm.copy(), reddened, is_absolute=sed.is_absolute)
291
+
292
+ def band_attenuation_mag(self, band: Bandpass, sed: SED | None = None) -> float:
293
+ """Band-integrated extinction in magnitudes through ``band`` for a source ``sed``.
294
+
295
+ The photon-weighted mean attenuation,
296
+ ``-2.5 log10(int S T 10^{-0.4 A} dl / int S T dl)``, evaluated on the band's
297
+ response grid. ``sed`` defaults to a flat photon spectrum. Add the result to
298
+ a source magnitude to dim it by the dust column. Requires a spectral
299
+ :attr:`~Bandpass.response`.
300
+ """
301
+ if band.response is None:
302
+ raise ValueError("band_attenuation_mag requires a band with a spectral response.")
303
+ wl = band.response.response.wavelength_nm
304
+ weight = band.response.response.value.astype(np.float64)
305
+ if sed is not None:
306
+ weight = weight * sed(wl)
307
+ denom = float(_trapezoid(weight, wl))
308
+ if denom <= 0:
309
+ raise ValueError("band response (times SED) integrates to zero; cannot weight.")
310
+ numer = float(_trapezoid(weight * self.transmission(wl), wl))
311
+ return float(-2.5 * np.log10(numer / denom))