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.
- getframes/__about__.py +4 -0
- getframes/__init__.py +91 -0
- getframes/analysis/__init__.py +18 -0
- getframes/analysis/apertures.py +92 -0
- getframes/analysis/ptc.py +109 -0
- getframes/calibrate.py +182 -0
- getframes/camera.py +649 -0
- getframes/cli.py +214 -0
- getframes/config.py +420 -0
- getframes/dataset.py +294 -0
- getframes/frame.py +107 -0
- getframes/noise.py +637 -0
- getframes/observation.py +162 -0
- getframes/presets/__init__.py +90 -0
- getframes/presets/data/__init__.py +3 -0
- getframes/presets/data/andor_ikon_m934.toml +22 -0
- getframes/presets/data/andor_ixon_ultra_888.toml +22 -0
- getframes/presets/data/generic_ccd.toml +18 -0
- getframes/presets/data/generic_cmos.toml +18 -0
- getframes/presets/data/generic_eapd.toml +20 -0
- getframes/presets/data/generic_emccd.toml +20 -0
- getframes/presets/data/generic_scmos.toml +21 -0
- getframes/presets/data/hamamatsu_orca_fusion.toml +25 -0
- getframes/presets/data/leonardo_saphira.toml +32 -0
- getframes/presets/data/zwo_asi2600mm.toml +20 -0
- getframes/py.typed +0 -0
- getframes/scene/__init__.py +51 -0
- getframes/scene/optics.py +180 -0
- getframes/scene/photometry.py +311 -0
- getframes/scene/psf.py +371 -0
- getframes/scene/scene.py +205 -0
- getframes/scene/sources.py +683 -0
- getframes/scene/thermal.py +114 -0
- getframes/scene/wcs.py +110 -0
- getframes/spectral.py +449 -0
- getframes-2.0.0.dist-info/METADATA +218 -0
- getframes-2.0.0.dist-info/RECORD +40 -0
- getframes-2.0.0.dist-info/WHEEL +4 -0
- getframes-2.0.0.dist-info/entry_points.txt +2 -0
- 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))
|