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,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
|
+
]
|