specmangle 0.1.0b1__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.
- specmangle/__init__.py +22 -0
- specmangle/bandpasses.py +214 -0
- specmangle/mangling.py +459 -0
- specmangle/photometry.py +384 -0
- specmangle-0.1.0b1.dist-info/METADATA +252 -0
- specmangle-0.1.0b1.dist-info/RECORD +9 -0
- specmangle-0.1.0b1.dist-info/WHEEL +5 -0
- specmangle-0.1.0b1.dist-info/licenses/LICENSE +29 -0
- specmangle-0.1.0b1.dist-info/top_level.txt +1 -0
specmangle/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Photometry-constrained mangling of astronomical spectra."""
|
|
2
|
+
|
|
3
|
+
from .bandpasses import Bandpass
|
|
4
|
+
from .mangling import MangleResult, mangle
|
|
5
|
+
from .photometry import (
|
|
6
|
+
InsufficientCoverageError,
|
|
7
|
+
SyntheticPhotometry,
|
|
8
|
+
spectrum_to_magnitude,
|
|
9
|
+
synthetic_ab_magnitude,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0b1"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Bandpass",
|
|
16
|
+
"InsufficientCoverageError",
|
|
17
|
+
"MangleResult",
|
|
18
|
+
"SyntheticPhotometry",
|
|
19
|
+
"mangle",
|
|
20
|
+
"spectrum_to_magnitude",
|
|
21
|
+
"synthetic_ab_magnitude",
|
|
22
|
+
]
|
specmangle/bandpasses.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import astropy.units as u
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.integrate import trapezoid
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DetectorType = Literal["photon", "energy"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Bandpass:
|
|
16
|
+
"""A bandpass response curve."""
|
|
17
|
+
|
|
18
|
+
filter_id: str
|
|
19
|
+
wavelength: u.Quantity
|
|
20
|
+
transmission: np.ndarray
|
|
21
|
+
detector_type: DetectorType
|
|
22
|
+
components: tuple[str, ...] = ()
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
transmission = np.asarray(self.transmission, dtype=float)
|
|
26
|
+
object.__setattr__(self, "transmission", transmission)
|
|
27
|
+
|
|
28
|
+
if not self.filter_id:
|
|
29
|
+
raise ValueError("filter_id must not be empty")
|
|
30
|
+
if self.wavelength.ndim != 1 or transmission.ndim != 1:
|
|
31
|
+
raise ValueError("bandpass wavelength and transmission must be one-dimensional")
|
|
32
|
+
if len(self.wavelength) != len(transmission):
|
|
33
|
+
raise ValueError("bandpass wavelength and transmission must have the same length")
|
|
34
|
+
if len(self.wavelength) < 2:
|
|
35
|
+
raise ValueError("a bandpass must contain at least two samples")
|
|
36
|
+
if not self.wavelength.unit.is_equivalent(u.AA):
|
|
37
|
+
raise u.UnitConversionError("bandpass wavelength must have units of length")
|
|
38
|
+
if self.detector_type not in ("photon", "energy"):
|
|
39
|
+
raise ValueError("detector_type must be either \"photon\" or \"energy\"")
|
|
40
|
+
if not np.all(np.isfinite(transmission)):
|
|
41
|
+
raise ValueError("bandpass transmission must be finite")
|
|
42
|
+
if np.any(transmission < 0.0):
|
|
43
|
+
raise ValueError("bandpass transmission must not be negative")
|
|
44
|
+
if not np.any(transmission > 0.0):
|
|
45
|
+
raise ValueError("bandpass transmission must contain a positive value")
|
|
46
|
+
|
|
47
|
+
wavelength = self.wavelength.to_value(u.AA)
|
|
48
|
+
if not np.all(np.isfinite(wavelength)) or np.any(wavelength <= 0.0):
|
|
49
|
+
raise ValueError("bandpass wavelengths must be positive and finite")
|
|
50
|
+
if np.any(np.diff(wavelength) <= 0.0):
|
|
51
|
+
raise ValueError("bandpass wavelengths must be strictly increasing")
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_svo(
|
|
55
|
+
cls,
|
|
56
|
+
filter_id: str,
|
|
57
|
+
*,
|
|
58
|
+
detector_type: DetectorType | None = None,
|
|
59
|
+
) -> Bandpass:
|
|
60
|
+
"""
|
|
61
|
+
Retrieve a bandpass from the SVO Filter Profile Service.
|
|
62
|
+
|
|
63
|
+
Astroquery's own query caching is used; specmangle does not implement
|
|
64
|
+
an additional cache.
|
|
65
|
+
"""
|
|
66
|
+
from astroquery.svo_fps import SvoFps
|
|
67
|
+
|
|
68
|
+
table = SvoFps.get_transmission_data(filter_id)
|
|
69
|
+
wavelength = u.Quantity(table["Wavelength"])
|
|
70
|
+
transmission = np.asarray(table["Transmission"], dtype=float)
|
|
71
|
+
|
|
72
|
+
metadata = SvoFps.get_filter_metadata(filter_id)
|
|
73
|
+
components = _parse_svo_components(metadata.get("components"))
|
|
74
|
+
if detector_type is None:
|
|
75
|
+
detector_type = _normalize_svo_detector_type(metadata["DetectorType"])
|
|
76
|
+
|
|
77
|
+
order = np.argsort(wavelength.to_value(u.AA))
|
|
78
|
+
return cls(
|
|
79
|
+
filter_id=filter_id,
|
|
80
|
+
wavelength=wavelength[order],
|
|
81
|
+
transmission=transmission[order],
|
|
82
|
+
detector_type=detector_type,
|
|
83
|
+
components=components,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def includes_atmosphere(self) -> bool:
|
|
88
|
+
"""Whether the response includes an atmospheric component."""
|
|
89
|
+
return any("atmospher" in component.casefold() for component in self.components)
|
|
90
|
+
|
|
91
|
+
def with_atmosphere(
|
|
92
|
+
self,
|
|
93
|
+
wavelength: u.Quantity,
|
|
94
|
+
transmission: np.ndarray,
|
|
95
|
+
) -> Bandpass:
|
|
96
|
+
"""Return a copy of the bandpass including atmospheric transmission.
|
|
97
|
+
|
|
98
|
+
The output grid is the union of the bandpass and atmospheric wavelength
|
|
99
|
+
samples so that structure in either response curve is retained.
|
|
100
|
+
"""
|
|
101
|
+
if self.includes_atmosphere:
|
|
102
|
+
raise ValueError(f"bandpass {self.filter_id} already includes atmosphere")
|
|
103
|
+
|
|
104
|
+
atmosphere_wavelength = u.Quantity(wavelength)
|
|
105
|
+
atmosphere_transmission = np.asarray(transmission, dtype=float)
|
|
106
|
+
|
|
107
|
+
if atmosphere_wavelength.ndim != 1 or atmosphere_transmission.ndim != 1:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"atmospheric wavelength and transmission must be one-dimensional"
|
|
110
|
+
)
|
|
111
|
+
if len(atmosphere_wavelength) != len(atmosphere_transmission):
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"atmospheric wavelength and transmission must have the same length"
|
|
114
|
+
)
|
|
115
|
+
if len(atmosphere_wavelength) < 2:
|
|
116
|
+
raise ValueError("atmospheric transmission must contain at least two samples")
|
|
117
|
+
if not atmosphere_wavelength.unit.is_equivalent(u.AA):
|
|
118
|
+
raise u.UnitConversionError("atmospheric wavelength must have units of length")
|
|
119
|
+
if not np.all(np.isfinite(atmosphere_transmission)):
|
|
120
|
+
raise ValueError("atmospheric transmission must be finite")
|
|
121
|
+
if np.any(atmosphere_transmission < 0.0):
|
|
122
|
+
raise ValueError("atmospheric transmission must not be negative")
|
|
123
|
+
|
|
124
|
+
unit = self.wavelength.unit
|
|
125
|
+
band_wavelength = self.wavelength.to_value(unit)
|
|
126
|
+
atmosphere_wavelength_value = atmosphere_wavelength.to_value(unit)
|
|
127
|
+
if (
|
|
128
|
+
not np.all(np.isfinite(atmosphere_wavelength_value))
|
|
129
|
+
or np.any(atmosphere_wavelength_value <= 0.0)
|
|
130
|
+
):
|
|
131
|
+
raise ValueError("atmospheric wavelengths must be positive and finite")
|
|
132
|
+
if np.any(np.diff(atmosphere_wavelength_value) <= 0.0):
|
|
133
|
+
raise ValueError("atmospheric wavelengths must be strictly increasing")
|
|
134
|
+
|
|
135
|
+
positive = np.flatnonzero(self.transmission > 0.0)
|
|
136
|
+
lower_index = max(positive[0] - 1, 0)
|
|
137
|
+
upper_index = min(positive[-1] + 1, len(band_wavelength) - 1)
|
|
138
|
+
if (
|
|
139
|
+
atmosphere_wavelength_value[0] > band_wavelength[lower_index]
|
|
140
|
+
or atmosphere_wavelength_value[-1] < band_wavelength[upper_index]
|
|
141
|
+
):
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"atmospheric transmission must cover the nonzero bandpass response"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
atmosphere_samples = atmosphere_wavelength_value[
|
|
147
|
+
(atmosphere_wavelength_value >= band_wavelength[0])
|
|
148
|
+
& (atmosphere_wavelength_value <= band_wavelength[-1])
|
|
149
|
+
]
|
|
150
|
+
combined_wavelength = np.unique(
|
|
151
|
+
np.concatenate((band_wavelength, atmosphere_samples))
|
|
152
|
+
)
|
|
153
|
+
combined_transmission = np.interp(
|
|
154
|
+
combined_wavelength,
|
|
155
|
+
band_wavelength,
|
|
156
|
+
self.transmission,
|
|
157
|
+
) * np.interp(
|
|
158
|
+
combined_wavelength,
|
|
159
|
+
atmosphere_wavelength_value,
|
|
160
|
+
atmosphere_transmission,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return Bandpass(
|
|
164
|
+
filter_id=self.filter_id,
|
|
165
|
+
wavelength=combined_wavelength * unit,
|
|
166
|
+
transmission=combined_transmission,
|
|
167
|
+
detector_type=self.detector_type,
|
|
168
|
+
components=(*self.components, "Atmosphere"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def pivot_wavelength(self) -> u.Quantity:
|
|
173
|
+
"""Return the pivot wavelength of the response curve."""
|
|
174
|
+
wavelength = self.wavelength.to_value(u.AA)
|
|
175
|
+
|
|
176
|
+
if self.detector_type == "photon":
|
|
177
|
+
numerator = trapezoid(self.transmission * wavelength, wavelength)
|
|
178
|
+
denominator = trapezoid(self.transmission / wavelength, wavelength)
|
|
179
|
+
else:
|
|
180
|
+
numerator = trapezoid(self.transmission, wavelength)
|
|
181
|
+
denominator = trapezoid(self.transmission / wavelength**2, wavelength)
|
|
182
|
+
|
|
183
|
+
return np.sqrt(numerator / denominator) * u.AA
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_svo_components(value: object | None) -> tuple[str, ...]:
|
|
187
|
+
if value is None:
|
|
188
|
+
return ()
|
|
189
|
+
return tuple(
|
|
190
|
+
component.strip()
|
|
191
|
+
for component in str(value).split("+")
|
|
192
|
+
if component.strip()
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _normalize_svo_detector_type(value: object) -> DetectorType:
|
|
197
|
+
text = str(value).strip().lower()
|
|
198
|
+
if text in ("photon", "photon counter", "photon-counter"):
|
|
199
|
+
return "photon"
|
|
200
|
+
if text in ("energy", "energy counter", "energy-counter"):
|
|
201
|
+
return "energy"
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
numeric = int(value)
|
|
205
|
+
except (TypeError, ValueError):
|
|
206
|
+
numeric = None
|
|
207
|
+
|
|
208
|
+
# IVOA Photometry Data Model convention: 0 = energy counter, 1 = photon counter.
|
|
209
|
+
if numeric == 0:
|
|
210
|
+
return "energy"
|
|
211
|
+
if numeric == 1:
|
|
212
|
+
return "photon"
|
|
213
|
+
|
|
214
|
+
raise ValueError(f"unrecognized SVO DetectorType value: {value!r}")
|
specmangle/mangling.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Mapping
|
|
6
|
+
|
|
7
|
+
import astropy.units as u
|
|
8
|
+
from astropy.nddata import InverseVariance, StdDevUncertainty, VarianceUncertainty
|
|
9
|
+
from astropy.table import Table
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.interpolate import CubicSpline
|
|
12
|
+
from scipy.optimize import OptimizeResult, least_squares
|
|
13
|
+
from specutils import Spectrum
|
|
14
|
+
|
|
15
|
+
from .bandpasses import Bandpass
|
|
16
|
+
from .photometry import (
|
|
17
|
+
SyntheticPhotometry,
|
|
18
|
+
InsufficientCoverageError,
|
|
19
|
+
synthetic_ab_magnitude,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class MangleResult:
|
|
25
|
+
"""Result of a photometry-constrained spectral mangling fit."""
|
|
26
|
+
|
|
27
|
+
spectrum: Spectrum
|
|
28
|
+
correction: np.ndarray
|
|
29
|
+
correction_uncertainty: np.ndarray | None
|
|
30
|
+
anchor_wavelengths: u.Quantity
|
|
31
|
+
log_correction_parameters: np.ndarray
|
|
32
|
+
parameter_covariance: np.ndarray | None
|
|
33
|
+
photometry: Table
|
|
34
|
+
bandpasses: dict[str, Bandpass]
|
|
35
|
+
optimizer: OptimizeResult
|
|
36
|
+
|
|
37
|
+
def correction_at(self, wavelength: u.Quantity) -> float | np.ndarray:
|
|
38
|
+
"""Evaluate the fitted multiplicative correction at arbitrary wavelengths."""
|
|
39
|
+
try:
|
|
40
|
+
wavelength_values = np.asarray(wavelength.to_value(u.AA), dtype=float)
|
|
41
|
+
except AttributeError as exc:
|
|
42
|
+
raise TypeError("wavelength must be an Astropy Quantity") from exc
|
|
43
|
+
|
|
44
|
+
if (
|
|
45
|
+
np.any(~np.isfinite(wavelength_values))
|
|
46
|
+
or np.any(wavelength_values <= 0.0)
|
|
47
|
+
):
|
|
48
|
+
raise ValueError("wavelength must be positive and finite")
|
|
49
|
+
|
|
50
|
+
scalar = wavelength_values.ndim == 0
|
|
51
|
+
values = np.atleast_1d(wavelength_values)
|
|
52
|
+
correction = np.exp(
|
|
53
|
+
_evaluate_log_correction(
|
|
54
|
+
values,
|
|
55
|
+
self.anchor_wavelengths.to_value(u.AA),
|
|
56
|
+
self.log_correction_parameters,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
if scalar:
|
|
60
|
+
return float(correction[0])
|
|
61
|
+
return correction
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class _PhotometryRow:
|
|
66
|
+
band: str
|
|
67
|
+
mag: float
|
|
68
|
+
mag_err: float | None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def mangle(
|
|
72
|
+
spectrum: Spectrum,
|
|
73
|
+
photometry: Table,
|
|
74
|
+
*,
|
|
75
|
+
bandpasses: Mapping[str, Bandpass] | None = None,
|
|
76
|
+
min_coverage: float = 0.98,
|
|
77
|
+
smoothness: float = 0.0,
|
|
78
|
+
max_nfev: int | None = None,
|
|
79
|
+
) -> MangleResult:
|
|
80
|
+
"""
|
|
81
|
+
Mangle a spectrum to match contemporaneous AB photometry.
|
|
82
|
+
|
|
83
|
+
``photometry`` must contain ``band`` and ``mag`` columns and may contain a
|
|
84
|
+
``mag_err`` column. Each ``band`` value is interpreted as an SVO filter ID
|
|
85
|
+
unless a matching :class:`~specmangle.Bandpass` is supplied through
|
|
86
|
+
``bandpasses``. All magnitudes are interpreted as AB magnitudes.
|
|
87
|
+
|
|
88
|
+
The positive multiplicative correction is represented by a natural cubic
|
|
89
|
+
spline in log wavelength and log flux scale, with one free anchor per
|
|
90
|
+
unique passband. Outside the bluest and reddest anchors the correction is
|
|
91
|
+
held constant. Repeated measurements in a passband are supported.
|
|
92
|
+
|
|
93
|
+
Photometric and synthetic-photometry uncertainties, when available, are
|
|
94
|
+
combined in quadrature when weighting residuals. The uncertainty attached
|
|
95
|
+
to ``result.spectrum`` contains only the input spectral uncertainty scaled
|
|
96
|
+
by the best-fit correction. Uncertainty in the mangling function itself is
|
|
97
|
+
returned separately in ``result.correction_uncertainty`` because it is
|
|
98
|
+
correlated with wavelength.
|
|
99
|
+
"""
|
|
100
|
+
rows = _photometry_rows(photometry)
|
|
101
|
+
if smoothness < 0.0 or not np.isfinite(smoothness):
|
|
102
|
+
raise ValueError("smoothness must be finite and non-negative")
|
|
103
|
+
if not 0.0 < min_coverage <= 1.0:
|
|
104
|
+
raise ValueError("min_coverage must be in the interval (0, 1]")
|
|
105
|
+
if not isinstance(spectrum, Spectrum):
|
|
106
|
+
raise TypeError("spectrum must be a specutils.Spectrum")
|
|
107
|
+
if spectrum.flux.ndim != 1:
|
|
108
|
+
raise ValueError("only one-dimensional Spectrum objects are supported")
|
|
109
|
+
|
|
110
|
+
supplied_bandpasses = {} if bandpasses is None else dict(bandpasses)
|
|
111
|
+
filter_ids = list(dict.fromkeys(row.band for row in rows))
|
|
112
|
+
|
|
113
|
+
loaded_bandpasses: dict[str, Bandpass] = {}
|
|
114
|
+
for filter_id in filter_ids:
|
|
115
|
+
if filter_id in supplied_bandpasses:
|
|
116
|
+
loaded_bandpasses[filter_id] = supplied_bandpasses[filter_id]
|
|
117
|
+
else:
|
|
118
|
+
loaded_bandpasses[filter_id] = Bandpass.from_svo(filter_id)
|
|
119
|
+
|
|
120
|
+
original_synthetic = {}
|
|
121
|
+
coverage_by_filter = {}
|
|
122
|
+
skip_reason_by_filter = {}
|
|
123
|
+
usable_filter_ids = []
|
|
124
|
+
for filter_id in filter_ids:
|
|
125
|
+
try:
|
|
126
|
+
original_synthetic[filter_id] = synthetic_ab_magnitude(
|
|
127
|
+
spectrum,
|
|
128
|
+
loaded_bandpasses[filter_id],
|
|
129
|
+
min_coverage=min_coverage,
|
|
130
|
+
)
|
|
131
|
+
except InsufficientCoverageError as exc:
|
|
132
|
+
coverage_by_filter[filter_id] = exc.coverage
|
|
133
|
+
skip_reason_by_filter[filter_id] = str(exc)
|
|
134
|
+
continue
|
|
135
|
+
coverage_by_filter[filter_id] = original_synthetic[filter_id].coverage
|
|
136
|
+
usable_filter_ids.append(filter_id)
|
|
137
|
+
|
|
138
|
+
if not usable_filter_ids:
|
|
139
|
+
reasons = "; ".join(skip_reason_by_filter.values())
|
|
140
|
+
raise ValueError(
|
|
141
|
+
"no photometric passbands have sufficient spectral coverage: " + reasons
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
filter_ids = usable_filter_ids
|
|
145
|
+
rows_for_fit = [row for row in rows if row.band in original_synthetic]
|
|
146
|
+
loaded_bandpasses = {
|
|
147
|
+
filter_id: loaded_bandpasses[filter_id] for filter_id in filter_ids
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
anchor_pairs = sorted(
|
|
151
|
+
(
|
|
152
|
+
loaded_bandpasses[filter_id].pivot_wavelength.to_value(u.AA),
|
|
153
|
+
filter_id,
|
|
154
|
+
)
|
|
155
|
+
for filter_id in filter_ids
|
|
156
|
+
)
|
|
157
|
+
anchor_wavelength_values = np.array([pair[0] for pair in anchor_pairs])
|
|
158
|
+
ordered_filter_ids = [pair[1] for pair in anchor_pairs]
|
|
159
|
+
|
|
160
|
+
if np.any(np.diff(anchor_wavelength_values) <= 0.0):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"two passbands have identical pivot wavelengths; independent mangling "
|
|
163
|
+
"anchors require distinct pivot wavelengths"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
parameter_index = {
|
|
167
|
+
filter_id: index for index, filter_id in enumerate(ordered_filter_ids)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
initial_parameters = np.zeros(len(ordered_filter_ids), dtype=float)
|
|
171
|
+
for filter_id in ordered_filter_ids:
|
|
172
|
+
estimates = []
|
|
173
|
+
weights = []
|
|
174
|
+
synthetic = original_synthetic[filter_id]
|
|
175
|
+
for row in rows_for_fit:
|
|
176
|
+
if row.band != filter_id:
|
|
177
|
+
continue
|
|
178
|
+
estimates.append(0.4 * np.log(10.0) * (synthetic.magnitude - row.mag))
|
|
179
|
+
sigma = _combined_magnitude_uncertainty(row, synthetic)
|
|
180
|
+
weights.append(1.0 if sigma is None else 1.0 / sigma**2)
|
|
181
|
+
initial_parameters[parameter_index[filter_id]] = np.average(
|
|
182
|
+
estimates,
|
|
183
|
+
weights=weights,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
spectrum_wave = spectrum.wavelength.to_value(u.AA)
|
|
187
|
+
if len(spectrum_wave) != len(spectrum.flux):
|
|
188
|
+
raise ValueError("spectral axis must specify one coordinate per flux sample")
|
|
189
|
+
if np.any(~np.isfinite(spectrum_wave)) or np.any(spectrum_wave <= 0.0):
|
|
190
|
+
raise ValueError("spectrum wavelengths must be positive and finite")
|
|
191
|
+
|
|
192
|
+
def residual_function(parameters: np.ndarray) -> np.ndarray:
|
|
193
|
+
correction = np.exp(
|
|
194
|
+
_evaluate_log_correction(
|
|
195
|
+
spectrum_wave,
|
|
196
|
+
anchor_wavelength_values,
|
|
197
|
+
parameters,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
corrected_spectrum = _apply_correction(spectrum, correction)
|
|
201
|
+
synthetic_by_filter = {
|
|
202
|
+
filter_id: synthetic_ab_magnitude(
|
|
203
|
+
corrected_spectrum,
|
|
204
|
+
loaded_bandpasses[filter_id],
|
|
205
|
+
min_coverage=min_coverage,
|
|
206
|
+
)
|
|
207
|
+
for filter_id in filter_ids
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
residuals = []
|
|
211
|
+
for row in rows_for_fit:
|
|
212
|
+
synthetic = synthetic_by_filter[row.band]
|
|
213
|
+
sigma = _combined_magnitude_uncertainty(row, synthetic)
|
|
214
|
+
scale = 1.0 if sigma is None else sigma
|
|
215
|
+
residuals.append((synthetic.magnitude - row.mag) / scale)
|
|
216
|
+
|
|
217
|
+
if smoothness > 0.0 and len(parameters) >= 3:
|
|
218
|
+
log_anchor = np.log(anchor_wavelength_values)
|
|
219
|
+
slopes = np.diff(parameters) / np.diff(log_anchor)
|
|
220
|
+
residuals.extend(np.sqrt(smoothness) * np.diff(slopes))
|
|
221
|
+
|
|
222
|
+
return np.asarray(residuals, dtype=float)
|
|
223
|
+
|
|
224
|
+
optimizer = least_squares(
|
|
225
|
+
residual_function,
|
|
226
|
+
initial_parameters,
|
|
227
|
+
max_nfev=max_nfev,
|
|
228
|
+
)
|
|
229
|
+
if not optimizer.success:
|
|
230
|
+
raise RuntimeError(f"spectral mangling fit failed: {optimizer.message}")
|
|
231
|
+
|
|
232
|
+
best_log_correction = _evaluate_log_correction(
|
|
233
|
+
spectrum_wave,
|
|
234
|
+
anchor_wavelength_values,
|
|
235
|
+
optimizer.x,
|
|
236
|
+
)
|
|
237
|
+
correction = np.exp(best_log_correction)
|
|
238
|
+
corrected_spectrum = _apply_correction(spectrum, correction)
|
|
239
|
+
|
|
240
|
+
final_synthetic_by_filter = {
|
|
241
|
+
filter_id: synthetic_ab_magnitude(
|
|
242
|
+
corrected_spectrum,
|
|
243
|
+
loaded_bandpasses[filter_id],
|
|
244
|
+
min_coverage=min_coverage,
|
|
245
|
+
)
|
|
246
|
+
for filter_id in filter_ids
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
parameter_covariance = _parameter_covariance(optimizer, spectrum, rows_for_fit)
|
|
250
|
+
correction_uncertainty = None
|
|
251
|
+
if parameter_covariance is not None:
|
|
252
|
+
basis = _correction_basis(
|
|
253
|
+
spectrum_wave,
|
|
254
|
+
anchor_wavelength_values,
|
|
255
|
+
)
|
|
256
|
+
log_variance = np.einsum(
|
|
257
|
+
"ij,jk,ik->i",
|
|
258
|
+
basis,
|
|
259
|
+
parameter_covariance,
|
|
260
|
+
basis,
|
|
261
|
+
)
|
|
262
|
+
correction_uncertainty = correction * np.sqrt(
|
|
263
|
+
np.clip(log_variance, 0.0, None)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
diagnostics = photometry.copy(copy_data=True)
|
|
267
|
+
diagnostics["used"] = [row.band in final_synthetic_by_filter for row in rows]
|
|
268
|
+
diagnostics["coverage"] = [coverage_by_filter[row.band] for row in rows]
|
|
269
|
+
diagnostics["skip_reason"] = [
|
|
270
|
+
skip_reason_by_filter.get(row.band, "") for row in rows
|
|
271
|
+
]
|
|
272
|
+
diagnostics["synthetic_mag"] = [
|
|
273
|
+
np.nan
|
|
274
|
+
if row.band not in final_synthetic_by_filter
|
|
275
|
+
else final_synthetic_by_filter[row.band].magnitude
|
|
276
|
+
for row in rows
|
|
277
|
+
]
|
|
278
|
+
diagnostics["synthetic_mag_err"] = [
|
|
279
|
+
np.nan
|
|
280
|
+
if (
|
|
281
|
+
row.band not in final_synthetic_by_filter
|
|
282
|
+
or final_synthetic_by_filter[row.band].uncertainty is None
|
|
283
|
+
)
|
|
284
|
+
else final_synthetic_by_filter[row.band].uncertainty
|
|
285
|
+
for row in rows
|
|
286
|
+
]
|
|
287
|
+
diagnostics["residual"] = diagnostics["synthetic_mag"] - np.array(
|
|
288
|
+
[row.mag for row in rows]
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return MangleResult(
|
|
292
|
+
spectrum=corrected_spectrum,
|
|
293
|
+
correction=correction,
|
|
294
|
+
correction_uncertainty=correction_uncertainty,
|
|
295
|
+
anchor_wavelengths=anchor_wavelength_values * u.AA,
|
|
296
|
+
log_correction_parameters=np.array(optimizer.x, copy=True),
|
|
297
|
+
parameter_covariance=parameter_covariance,
|
|
298
|
+
photometry=diagnostics,
|
|
299
|
+
bandpasses=loaded_bandpasses,
|
|
300
|
+
optimizer=optimizer,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _photometry_rows(photometry: Table) -> list[_PhotometryRow]:
|
|
305
|
+
if not isinstance(photometry, Table):
|
|
306
|
+
raise TypeError("photometry must be an astropy.table.Table")
|
|
307
|
+
if len(photometry) == 0:
|
|
308
|
+
raise ValueError("at least one photometry measurement is required")
|
|
309
|
+
|
|
310
|
+
missing = {"band", "mag"} - set(photometry.colnames)
|
|
311
|
+
if missing:
|
|
312
|
+
missing_text = ", ".join(sorted(missing))
|
|
313
|
+
raise ValueError(f"photometry is missing required column(s): {missing_text}")
|
|
314
|
+
|
|
315
|
+
rows = []
|
|
316
|
+
for index in range(len(photometry)):
|
|
317
|
+
band_value = photometry["band"][index]
|
|
318
|
+
if np.ma.is_masked(band_value):
|
|
319
|
+
raise ValueError(f"photometry band is masked at row {index}")
|
|
320
|
+
band = str(band_value).strip()
|
|
321
|
+
if not band:
|
|
322
|
+
raise ValueError(f"photometry band is empty at row {index}")
|
|
323
|
+
|
|
324
|
+
mag_value = photometry["mag"][index]
|
|
325
|
+
if np.ma.is_masked(mag_value):
|
|
326
|
+
raise ValueError(f"photometry mag is masked at row {index}")
|
|
327
|
+
if isinstance(mag_value, u.Quantity):
|
|
328
|
+
mag_value = mag_value.value
|
|
329
|
+
mag = float(mag_value)
|
|
330
|
+
if not np.isfinite(mag):
|
|
331
|
+
raise ValueError(f"photometry mag must be finite at row {index}")
|
|
332
|
+
|
|
333
|
+
mag_err = None
|
|
334
|
+
if "mag_err" in photometry.colnames:
|
|
335
|
+
mag_err_value = photometry["mag_err"][index]
|
|
336
|
+
if not np.ma.is_masked(mag_err_value):
|
|
337
|
+
if isinstance(mag_err_value, u.Quantity):
|
|
338
|
+
mag_err_value = mag_err_value.value
|
|
339
|
+
mag_err = float(mag_err_value)
|
|
340
|
+
if not np.isfinite(mag_err) or mag_err <= 0.0:
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"photometry mag_err must be positive and finite at row {index}"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
rows.append(_PhotometryRow(band=band, mag=mag, mag_err=mag_err))
|
|
346
|
+
|
|
347
|
+
return rows
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _apply_correction(spectrum: Spectrum, correction: np.ndarray) -> Spectrum:
|
|
351
|
+
if correction.shape != spectrum.flux.shape:
|
|
352
|
+
raise ValueError("correction must match the one-dimensional spectrum shape")
|
|
353
|
+
|
|
354
|
+
uncertainty = _scale_uncertainty(spectrum.uncertainty, correction)
|
|
355
|
+
mask = None if spectrum.mask is None else np.array(spectrum.mask, copy=True)
|
|
356
|
+
|
|
357
|
+
return Spectrum(
|
|
358
|
+
spectral_axis=spectrum.spectral_axis,
|
|
359
|
+
flux=spectrum.flux * correction,
|
|
360
|
+
uncertainty=uncertainty,
|
|
361
|
+
mask=mask,
|
|
362
|
+
meta=deepcopy(spectrum.meta),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _scale_uncertainty(uncertainty, correction: np.ndarray):
|
|
367
|
+
if uncertainty is None:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
unit = uncertainty.unit
|
|
371
|
+
if isinstance(uncertainty, StdDevUncertainty):
|
|
372
|
+
return StdDevUncertainty(uncertainty.array * correction, unit=unit)
|
|
373
|
+
if isinstance(uncertainty, VarianceUncertainty):
|
|
374
|
+
return VarianceUncertainty(uncertainty.array * correction**2, unit=unit)
|
|
375
|
+
if isinstance(uncertainty, InverseVariance):
|
|
376
|
+
return InverseVariance(uncertainty.array / correction**2, unit=unit)
|
|
377
|
+
|
|
378
|
+
raise TypeError(
|
|
379
|
+
"spectrum uncertainty must be StdDevUncertainty, VarianceUncertainty, "
|
|
380
|
+
"or InverseVariance"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _evaluate_log_correction(
|
|
385
|
+
wavelength_angstrom: np.ndarray,
|
|
386
|
+
anchor_wavelength_angstrom: np.ndarray,
|
|
387
|
+
parameters: np.ndarray,
|
|
388
|
+
) -> np.ndarray:
|
|
389
|
+
if len(parameters) == 1:
|
|
390
|
+
return np.full_like(wavelength_angstrom, parameters[0], dtype=float)
|
|
391
|
+
|
|
392
|
+
log_anchor = np.log(anchor_wavelength_angstrom)
|
|
393
|
+
log_wavelength = np.log(wavelength_angstrom)
|
|
394
|
+
spline = CubicSpline(log_anchor, parameters, bc_type="natural")
|
|
395
|
+
result = spline(np.clip(log_wavelength, log_anchor[0], log_anchor[-1]))
|
|
396
|
+
result[log_wavelength < log_anchor[0]] = parameters[0]
|
|
397
|
+
result[log_wavelength > log_anchor[-1]] = parameters[-1]
|
|
398
|
+
return result
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _correction_basis(
|
|
402
|
+
wavelength_angstrom: np.ndarray,
|
|
403
|
+
anchor_wavelength_angstrom: np.ndarray,
|
|
404
|
+
) -> np.ndarray:
|
|
405
|
+
basis = np.empty(
|
|
406
|
+
(len(wavelength_angstrom), len(anchor_wavelength_angstrom)),
|
|
407
|
+
dtype=float,
|
|
408
|
+
)
|
|
409
|
+
for index in range(len(anchor_wavelength_angstrom)):
|
|
410
|
+
parameter = np.zeros(len(anchor_wavelength_angstrom), dtype=float)
|
|
411
|
+
parameter[index] = 1.0
|
|
412
|
+
basis[:, index] = _evaluate_log_correction(
|
|
413
|
+
wavelength_angstrom,
|
|
414
|
+
anchor_wavelength_angstrom,
|
|
415
|
+
parameter,
|
|
416
|
+
)
|
|
417
|
+
return basis
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _combined_magnitude_uncertainty(
|
|
421
|
+
row: _PhotometryRow,
|
|
422
|
+
synthetic: SyntheticPhotometry,
|
|
423
|
+
) -> float | None:
|
|
424
|
+
variance = 0.0
|
|
425
|
+
has_uncertainty = False
|
|
426
|
+
|
|
427
|
+
if row.mag_err is not None:
|
|
428
|
+
variance += row.mag_err**2
|
|
429
|
+
has_uncertainty = True
|
|
430
|
+
if synthetic.uncertainty is not None:
|
|
431
|
+
variance += synthetic.uncertainty**2
|
|
432
|
+
has_uncertainty = True
|
|
433
|
+
|
|
434
|
+
if not has_uncertainty or variance == 0.0:
|
|
435
|
+
return None
|
|
436
|
+
return float(np.sqrt(variance))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _parameter_covariance(
|
|
440
|
+
optimizer: OptimizeResult,
|
|
441
|
+
spectrum: Spectrum,
|
|
442
|
+
rows: list[_PhotometryRow],
|
|
443
|
+
) -> np.ndarray | None:
|
|
444
|
+
has_spectral_uncertainty = (
|
|
445
|
+
spectrum.uncertainty is not None
|
|
446
|
+
and np.any(np.asarray(spectrum.uncertainty.array) > 0.0)
|
|
447
|
+
)
|
|
448
|
+
has_absolute_uncertainties = all(
|
|
449
|
+
row.mag_err is not None or has_spectral_uncertainty
|
|
450
|
+
for row in rows
|
|
451
|
+
)
|
|
452
|
+
if not has_absolute_uncertainties:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
jacobian = np.asarray(optimizer.jac, dtype=float)
|
|
456
|
+
information = jacobian.T @ jacobian
|
|
457
|
+
if np.linalg.matrix_rank(information) < information.shape[0]:
|
|
458
|
+
return None
|
|
459
|
+
return np.linalg.inv(information)
|
specmangle/photometry.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import astropy.units as u
|
|
6
|
+
from astropy.nddata import StdDevUncertainty
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.integrate import trapezoid
|
|
9
|
+
from specutils import Spectrum
|
|
10
|
+
|
|
11
|
+
from .bandpasses import Bandpass, DetectorType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
FLAM = u.erg / (u.s * u.cm**2 * u.AA)
|
|
15
|
+
AB_ZERO_FNU = (0. * u.ABmag).to(u.Jy)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InsufficientCoverageError(ValueError):
|
|
19
|
+
"""Raised when a bandpass lacks sufficient coverage for some operation."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, coverage: float) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.coverage = coverage
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class SyntheticPhotometry:
|
|
28
|
+
"""Synthetic AB magnitude and associated diagnostics."""
|
|
29
|
+
|
|
30
|
+
magnitude: float
|
|
31
|
+
uncertainty: float | None
|
|
32
|
+
coverage: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def spectrum_to_magnitude(
|
|
36
|
+
spectrum: Spectrum,
|
|
37
|
+
band: str | Bandpass,
|
|
38
|
+
*,
|
|
39
|
+
min_coverage: float = 0.98,
|
|
40
|
+
) -> u.Magnitude | tuple[u.Magnitude, u.Magnitude]:
|
|
41
|
+
"""Compute synthetic AB photometry for a spectrum in an SVO bandpass.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
spectrum
|
|
46
|
+
Input spectrum. If it includes an uncertainty, the propagated
|
|
47
|
+
magnitude uncertainty is returned as well.
|
|
48
|
+
band
|
|
49
|
+
SVO FPS filter identifier or an already loaded `Bandpass`.
|
|
50
|
+
min_coverage
|
|
51
|
+
Minimum fraction of the bandpass reference signal that must overlap
|
|
52
|
+
the valid spectrum.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
magnitude
|
|
57
|
+
Synthetic AB magnitude. If the input spectrum has an uncertainty,
|
|
58
|
+
returns ``(magnitude, magnitude_uncertainty)`` instead.
|
|
59
|
+
"""
|
|
60
|
+
if isinstance(band, str):
|
|
61
|
+
bandpass = Bandpass.from_svo(band)
|
|
62
|
+
elif isinstance(band, Bandpass):
|
|
63
|
+
bandpass = band
|
|
64
|
+
else:
|
|
65
|
+
raise TypeError("band must be an SVO FPS filter ID or Bandpass")
|
|
66
|
+
|
|
67
|
+
synthetic = synthetic_ab_magnitude(
|
|
68
|
+
spectrum,
|
|
69
|
+
bandpass,
|
|
70
|
+
min_coverage=min_coverage,
|
|
71
|
+
)
|
|
72
|
+
magnitude = synthetic.magnitude * u.ABmag
|
|
73
|
+
if synthetic.uncertainty is None:
|
|
74
|
+
return magnitude
|
|
75
|
+
|
|
76
|
+
return magnitude, synthetic.uncertainty * u.mag
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def synthetic_ab_magnitude(
|
|
80
|
+
spectrum: Spectrum,
|
|
81
|
+
bandpass: Bandpass,
|
|
82
|
+
*,
|
|
83
|
+
min_coverage: float = 0.98,
|
|
84
|
+
) -> SyntheticPhotometry:
|
|
85
|
+
"""
|
|
86
|
+
Compute synthetic AB photometry for a spectrum through a bandpass.
|
|
87
|
+
|
|
88
|
+
The calculation compares the integrated detector signal from the spectrum
|
|
89
|
+
with that from a zero-magnitude AB reference spectrum. Photon-counting
|
|
90
|
+
response curves include the expected additional factor of wavelength in
|
|
91
|
+
the signal integral; energy-counting response curves do not.
|
|
92
|
+
|
|
93
|
+
Masked spectral samples are omitted. Spectral uncertainties, if present,
|
|
94
|
+
are converted to standard deviations and assumed to be independent between
|
|
95
|
+
input wavelength samples. Wavelength cells around masked samples are
|
|
96
|
+
excluded from the coverage and signal integrals.
|
|
97
|
+
"""
|
|
98
|
+
if not 0.0 < min_coverage <= 1.0:
|
|
99
|
+
raise ValueError("min_coverage must be in the interval (0, 1]")
|
|
100
|
+
|
|
101
|
+
wavelength, flux, uncertainty, masked_intervals = _spectrum_samples(spectrum)
|
|
102
|
+
wave_value = wavelength.to_value(u.AA)
|
|
103
|
+
|
|
104
|
+
band_wave = bandpass.wavelength.to_value(u.AA)
|
|
105
|
+
response = bandpass.transmission
|
|
106
|
+
|
|
107
|
+
lower = max(wave_value[0], band_wave[0])
|
|
108
|
+
upper = min(wave_value[-1], band_wave[-1])
|
|
109
|
+
if lower >= upper:
|
|
110
|
+
raise InsufficientCoverageError(
|
|
111
|
+
f"spectrum does not overlap passband {bandpass.filter_id}",
|
|
112
|
+
coverage=0.0,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
integration_segments = []
|
|
116
|
+
segment_start = lower
|
|
117
|
+
for gap_start, gap_end in masked_intervals:
|
|
118
|
+
if gap_end <= lower or gap_start >= upper:
|
|
119
|
+
continue
|
|
120
|
+
gap_start = max(gap_start, lower)
|
|
121
|
+
gap_end = min(gap_end, upper)
|
|
122
|
+
if segment_start < gap_start:
|
|
123
|
+
integration_segments.append((segment_start, gap_start))
|
|
124
|
+
segment_start = max(segment_start, gap_end)
|
|
125
|
+
if segment_start < upper:
|
|
126
|
+
integration_segments.append((segment_start, upper))
|
|
127
|
+
|
|
128
|
+
# Use the same resolved wavelength grid for the source and AB reference.
|
|
129
|
+
integration_knots = np.unique(
|
|
130
|
+
np.concatenate((band_wave, wave_value, np.ravel(integration_segments)))
|
|
131
|
+
)
|
|
132
|
+
integration_knots = integration_knots[
|
|
133
|
+
(integration_knots >= band_wave[0]) & (integration_knots <= band_wave[-1])
|
|
134
|
+
]
|
|
135
|
+
full_reference_signal = _reference_signal(
|
|
136
|
+
integration_knots,
|
|
137
|
+
np.interp(integration_knots, band_wave, response),
|
|
138
|
+
bandpass.detector_type,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
overlap_reference_signal = 0.0
|
|
142
|
+
source_signal = 0.0
|
|
143
|
+
coefficients = np.zeros(len(wavelength), dtype=float)
|
|
144
|
+
flux_values = flux.to_value(FLAM)
|
|
145
|
+
reference_flux_values = _reference_flux(wave_value)
|
|
146
|
+
source_ratio = flux_values / reference_flux_values
|
|
147
|
+
for segment_start, segment_end in integration_segments:
|
|
148
|
+
interior = integration_knots[
|
|
149
|
+
(integration_knots > segment_start) & (integration_knots < segment_end)
|
|
150
|
+
]
|
|
151
|
+
integration_wave = np.unique(
|
|
152
|
+
np.concatenate(([segment_start], interior, [segment_end]))
|
|
153
|
+
)
|
|
154
|
+
integration_response = np.interp(integration_wave, band_wave, response)
|
|
155
|
+
|
|
156
|
+
overlap_reference_signal += _reference_signal(
|
|
157
|
+
integration_wave,
|
|
158
|
+
integration_response,
|
|
159
|
+
bandpass.detector_type,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
reference_flux = _reference_flux(integration_wave)
|
|
163
|
+
interpolated_ratio = np.interp(
|
|
164
|
+
integration_wave,
|
|
165
|
+
wave_value,
|
|
166
|
+
source_ratio,
|
|
167
|
+
)
|
|
168
|
+
detector_weight = _detector_weight(
|
|
169
|
+
integration_wave,
|
|
170
|
+
bandpass.detector_type,
|
|
171
|
+
)
|
|
172
|
+
source_signal += trapezoid(
|
|
173
|
+
interpolated_ratio
|
|
174
|
+
* reference_flux
|
|
175
|
+
* integration_response
|
|
176
|
+
* detector_weight,
|
|
177
|
+
integration_wave,
|
|
178
|
+
)
|
|
179
|
+
if uncertainty is not None:
|
|
180
|
+
coefficients += _linear_interpolation_integral_coefficients(
|
|
181
|
+
wave_value,
|
|
182
|
+
integration_wave,
|
|
183
|
+
reference_flux * integration_response * detector_weight,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
coverage = overlap_reference_signal / full_reference_signal
|
|
187
|
+
if coverage < min_coverage:
|
|
188
|
+
raise InsufficientCoverageError(
|
|
189
|
+
f"spectrum covers only {coverage:.3f} of passband "
|
|
190
|
+
f"{bandpass.filter_id}; required coverage is {min_coverage:.3f}",
|
|
191
|
+
coverage=float(coverage),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if not np.isfinite(source_signal) or source_signal <= 0.0:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"integrated flux through passband {bandpass.filter_id} is not positive"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
magnitude = -2.5 * np.log10(source_signal / overlap_reference_signal)
|
|
200
|
+
|
|
201
|
+
magnitude_uncertainty = None
|
|
202
|
+
if uncertainty is not None:
|
|
203
|
+
signal_uncertainty = np.sqrt(
|
|
204
|
+
np.sum(
|
|
205
|
+
(
|
|
206
|
+
coefficients
|
|
207
|
+
/ reference_flux_values
|
|
208
|
+
* uncertainty.to_value(FLAM)
|
|
209
|
+
)
|
|
210
|
+
** 2
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
magnitude_uncertainty = (
|
|
214
|
+
2.5 / np.log(10.0) * signal_uncertainty / source_signal
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return SyntheticPhotometry(
|
|
218
|
+
magnitude=float(magnitude),
|
|
219
|
+
uncertainty=(
|
|
220
|
+
None if magnitude_uncertainty is None else float(magnitude_uncertainty)
|
|
221
|
+
),
|
|
222
|
+
coverage=float(coverage),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _spectrum_samples(
|
|
227
|
+
spectrum: Spectrum,
|
|
228
|
+
) -> tuple[u.Quantity, u.Quantity, u.Quantity | None, list[tuple[float, float]]]:
|
|
229
|
+
if not isinstance(spectrum, Spectrum):
|
|
230
|
+
raise TypeError("spectrum must be a specutils.Spectrum")
|
|
231
|
+
if spectrum.flux.ndim != 1:
|
|
232
|
+
raise ValueError("only one-dimensional Spectrum objects are supported")
|
|
233
|
+
if len(spectrum.flux) < 2:
|
|
234
|
+
raise ValueError("a spectrum must contain at least two samples")
|
|
235
|
+
|
|
236
|
+
wavelength = spectrum.wavelength.to(u.AA)
|
|
237
|
+
if len(wavelength) != len(spectrum.flux):
|
|
238
|
+
raise ValueError("spectral axis must specify one coordinate per flux sample")
|
|
239
|
+
|
|
240
|
+
flux = spectrum.flux.to(FLAM, equivalencies=u.spectral_density(wavelength))
|
|
241
|
+
uncertainty = _standard_deviation(spectrum, wavelength)
|
|
242
|
+
|
|
243
|
+
valid = np.ones(len(flux), dtype=bool)
|
|
244
|
+
if spectrum.mask is not None:
|
|
245
|
+
mask = np.asarray(spectrum.mask, dtype=bool)
|
|
246
|
+
if mask.shape != flux.shape:
|
|
247
|
+
raise ValueError("spectrum mask must match the one-dimensional flux shape")
|
|
248
|
+
valid &= ~mask
|
|
249
|
+
|
|
250
|
+
wave_values = wavelength.to_value(u.AA)
|
|
251
|
+
flux_values = flux.to_value(FLAM)
|
|
252
|
+
if np.any(~np.isfinite(wave_values)) or np.any(wave_values <= 0.0):
|
|
253
|
+
raise ValueError("spectrum wavelengths must be positive and finite")
|
|
254
|
+
if np.any(~np.isfinite(flux_values[valid])):
|
|
255
|
+
raise ValueError("unmasked spectrum flux must be finite")
|
|
256
|
+
|
|
257
|
+
if uncertainty is not None:
|
|
258
|
+
uncertainty_values = uncertainty.to_value(FLAM)
|
|
259
|
+
if np.any(~np.isfinite(uncertainty_values[valid])):
|
|
260
|
+
raise ValueError("unmasked spectrum uncertainty must be finite")
|
|
261
|
+
if np.any(uncertainty_values[valid] < 0.0):
|
|
262
|
+
raise ValueError("spectrum uncertainty must not be negative")
|
|
263
|
+
|
|
264
|
+
if np.count_nonzero(valid) < 2:
|
|
265
|
+
raise ValueError("spectrum must contain at least two valid, unmasked samples")
|
|
266
|
+
|
|
267
|
+
order = np.argsort(wave_values)
|
|
268
|
+
wave_values = wave_values[order]
|
|
269
|
+
sorted_mask = ~valid[order]
|
|
270
|
+
if np.any(np.diff(wave_values) <= 0.0):
|
|
271
|
+
raise ValueError("spectrum wavelengths must be unique")
|
|
272
|
+
|
|
273
|
+
masked_intervals = []
|
|
274
|
+
for index in np.flatnonzero(sorted_mask):
|
|
275
|
+
left = wave_values[index - 1] if index > 0 else wave_values[index]
|
|
276
|
+
right = (
|
|
277
|
+
wave_values[index + 1]
|
|
278
|
+
if index + 1 < len(wave_values)
|
|
279
|
+
else wave_values[index]
|
|
280
|
+
)
|
|
281
|
+
masked_intervals.append(
|
|
282
|
+
(
|
|
283
|
+
0.5 * (left + wave_values[index]),
|
|
284
|
+
0.5 * (wave_values[index] + right),
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
wavelength = wavelength[valid]
|
|
289
|
+
flux = flux[valid]
|
|
290
|
+
if uncertainty is not None:
|
|
291
|
+
uncertainty = uncertainty[valid]
|
|
292
|
+
|
|
293
|
+
valid_order = np.argsort(wavelength.to_value(u.AA))
|
|
294
|
+
wavelength = wavelength[valid_order]
|
|
295
|
+
flux = flux[valid_order]
|
|
296
|
+
if uncertainty is not None:
|
|
297
|
+
uncertainty = uncertainty[valid_order]
|
|
298
|
+
|
|
299
|
+
return wavelength, flux, uncertainty, masked_intervals
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _standard_deviation(
|
|
303
|
+
spectrum: Spectrum,
|
|
304
|
+
wavelength: u.Quantity,
|
|
305
|
+
) -> u.Quantity | None:
|
|
306
|
+
if spectrum.uncertainty is None:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
uncertainty = spectrum.uncertainty.represent_as(StdDevUncertainty)
|
|
311
|
+
except (AttributeError, TypeError) as exc:
|
|
312
|
+
raise TypeError(
|
|
313
|
+
"spectrum uncertainty must support conversion to StdDevUncertainty"
|
|
314
|
+
) from exc
|
|
315
|
+
|
|
316
|
+
if uncertainty.unit is None:
|
|
317
|
+
quantity = u.Quantity(uncertainty.array, spectrum.flux.unit)
|
|
318
|
+
else:
|
|
319
|
+
quantity = uncertainty.quantity
|
|
320
|
+
|
|
321
|
+
return quantity.to(FLAM, equivalencies=u.spectral_density(wavelength))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _detector_weight(
|
|
325
|
+
wavelength_angstrom: np.ndarray,
|
|
326
|
+
detector_type: DetectorType,
|
|
327
|
+
) -> np.ndarray:
|
|
328
|
+
if detector_type == "photon":
|
|
329
|
+
return wavelength_angstrom
|
|
330
|
+
return np.ones_like(wavelength_angstrom)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _reference_signal(
|
|
334
|
+
wavelength_angstrom: np.ndarray,
|
|
335
|
+
response: np.ndarray,
|
|
336
|
+
detector_type: DetectorType,
|
|
337
|
+
) -> float:
|
|
338
|
+
reference_flam = _reference_flux(wavelength_angstrom)
|
|
339
|
+
detector_weight = _detector_weight(wavelength_angstrom, detector_type)
|
|
340
|
+
return float(
|
|
341
|
+
trapezoid(
|
|
342
|
+
reference_flam * response * detector_weight,
|
|
343
|
+
wavelength_angstrom,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _reference_flux(wavelength_angstrom: np.ndarray) -> np.ndarray:
|
|
349
|
+
wavelength = u.Quantity(wavelength_angstrom, u.AA)
|
|
350
|
+
return AB_ZERO_FNU.to(
|
|
351
|
+
FLAM,
|
|
352
|
+
equivalencies=u.spectral_density(wavelength),
|
|
353
|
+
).value
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _trapezoid_weights(x: np.ndarray) -> np.ndarray:
|
|
357
|
+
weights = np.empty_like(x, dtype=float)
|
|
358
|
+
weights[0] = 0.5 * (x[1] - x[0])
|
|
359
|
+
weights[-1] = 0.5 * (x[-1] - x[-2])
|
|
360
|
+
if len(x) > 2:
|
|
361
|
+
weights[1:-1] = 0.5 * (x[2:] - x[:-2])
|
|
362
|
+
return weights
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _linear_interpolation_integral_coefficients(
|
|
366
|
+
source_x: np.ndarray,
|
|
367
|
+
integration_x: np.ndarray,
|
|
368
|
+
kernel: np.ndarray,
|
|
369
|
+
) -> np.ndarray:
|
|
370
|
+
integration_weights = _trapezoid_weights(integration_x) * kernel
|
|
371
|
+
|
|
372
|
+
left = np.searchsorted(source_x, integration_x, side="right") - 1
|
|
373
|
+
left = np.clip(left, 0, len(source_x) - 2)
|
|
374
|
+
right = left + 1
|
|
375
|
+
|
|
376
|
+
fraction = (
|
|
377
|
+
(integration_x - source_x[left])
|
|
378
|
+
/ (source_x[right] - source_x[left])
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
coefficients = np.zeros(len(source_x), dtype=float)
|
|
382
|
+
np.add.at(coefficients, left, integration_weights * (1.0 - fraction))
|
|
383
|
+
np.add.at(coefficients, right, integration_weights * fraction)
|
|
384
|
+
return coefficients
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: specmangle
|
|
3
|
+
Version: 0.1.0b1
|
|
4
|
+
Summary: Photometry-based 'mangling' (calibration) of astronomical spectra
|
|
5
|
+
Author-email: Caden Gobat <cgobat@sdsu.edu>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Documentation, https://specmangle.readthedocs.io/
|
|
8
|
+
Project-URL: Repository, https://github.com/cgobat/specmangle
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: astropy
|
|
13
|
+
Requires-Dist: astroquery>=0.4.12.dev10784
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Requires-Dist: scipy
|
|
16
|
+
Requires-Dist: specutils
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest; extra == "test"
|
|
19
|
+
Provides-Extra: docs
|
|
20
|
+
Requires-Dist: sphinx-astropy[confv3]>=1.11; extra == "docs"
|
|
21
|
+
Requires-Dist: sphinx-design; extra == "docs"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# specmangle
|
|
25
|
+
|
|
26
|
+
[](https://github.com/cgobat/specmangle/actions/workflows/tests.yml) [](https://specmangle.readthedocs.io/en/latest/)
|
|
27
|
+
|
|
28
|
+
`specmangle` applies a smooth, wavelength-dependent multiplicative correction to
|
|
29
|
+
an observed astronomical spectrum such that synthetic broadband photometry based
|
|
30
|
+
on that spectrum matches contemporaneous observations.
|
|
31
|
+
|
|
32
|
+
The package is deliberately narrow in scope, and uses existing
|
|
33
|
+
Astropy-ecosystem data structures and APIs:
|
|
34
|
+
|
|
35
|
+
- spectra are [`specutils.Spectrum`](https://specutils.readthedocs.io/en/stable/types_of_spectra.html) objects
|
|
36
|
+
- photometric measurements are stored in [`astropy.table.Table`](https://docs.astropy.org/en/stable/table/index.html) objects
|
|
37
|
+
- passband profiles are retrieved from the [SVO Filter Profile Service](https://svo2.cab.inta-csic.es/theory/fps/index.php) via [`astroquery.svo_fps`](https://astroquery.readthedocs.io/en/latest/svo_fps/svo_fps.html), or user-defined
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
From the project directory:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python -m pip install .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For development:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m pip install -e .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Basic use
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import astropy.units as u
|
|
57
|
+
from astropy.nddata import StdDevUncertainty
|
|
58
|
+
from astropy.table import Table
|
|
59
|
+
from specutils import Spectrum
|
|
60
|
+
|
|
61
|
+
from specmangle import mangle
|
|
62
|
+
|
|
63
|
+
spectrum = Spectrum(
|
|
64
|
+
spectral_axis=wavelength * u.AA,
|
|
65
|
+
flux=flux * u.erg / u.s / u.cm**2 / u.AA,
|
|
66
|
+
uncertainty=StdDevUncertainty(
|
|
67
|
+
flux_error,
|
|
68
|
+
unit=u.erg / u.s / u.cm**2 / u.AA,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
photometry = Table(
|
|
73
|
+
rows=[
|
|
74
|
+
("SLOAN/SDSS.g", 18.42, 0.03),
|
|
75
|
+
("SLOAN/SDSS.r", 18.17, 0.02),
|
|
76
|
+
("SLOAN/SDSS.i", 18.05, 0.04),
|
|
77
|
+
],
|
|
78
|
+
names=("band", "mag", "mag_err"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
result = mangle(spectrum, photometry)
|
|
82
|
+
|
|
83
|
+
mangled = result.spectrum
|
|
84
|
+
correction = result.correction
|
|
85
|
+
print(result.photometry)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The input photometry table must contain:
|
|
89
|
+
|
|
90
|
+
- `band`: passband identifier, normally an SVO FPS filter ID
|
|
91
|
+
- `mag`: AB magnitude
|
|
92
|
+
|
|
93
|
+
It may additionally contain:
|
|
94
|
+
|
|
95
|
+
- `mag_err`: 1-sigma magnitude uncertainty. Masked values are treated as missing
|
|
96
|
+
uncertainties.
|
|
97
|
+
|
|
98
|
+
Additional columns are preserved in `result.photometry`, which adds `used`,
|
|
99
|
+
`coverage`, `skip_reason`, `synthetic_mag`, `synthetic_mag_err`, and `residual`
|
|
100
|
+
columns. Bands that do not meet `min_coverage` are excluded from the fit and
|
|
101
|
+
reported explicitly in these diagnostic columns.
|
|
102
|
+
|
|
103
|
+
## Mangling model
|
|
104
|
+
|
|
105
|
+
The correction is positive and multiplicative. It is represented by a natural
|
|
106
|
+
cubic spline in log wavelength and log flux scale, with one free anchor at the
|
|
107
|
+
pivot wavelength of each unique passband. The correction is held constant
|
|
108
|
+
beyond the bluest and reddest anchors rather than extrapolating the spline.
|
|
109
|
+
|
|
110
|
+
The fit minimizes the difference between the mangled spectrum's synthetic AB
|
|
111
|
+
magnitudes and the supplied photometry. When uncertainties are available,
|
|
112
|
+
photometric uncertainties and synthetic-photometry uncertainties propagated from
|
|
113
|
+
the spectrum are combined in quadrature.
|
|
114
|
+
|
|
115
|
+
A nonzero `smoothness` can be supplied to penalize changes in slope between
|
|
116
|
+
neighboring spline anchors:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
result = mangle(spectrum, photometry, smoothness=0.1)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The default is `smoothness=0.0`, so no additional regularization is imposed.
|
|
123
|
+
|
|
124
|
+
The fitted correction can be evaluated on any wavelength grid:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
correction = result.correction_at(new_wavelength)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## History & heritage
|
|
131
|
+
|
|
132
|
+
The term "spectral mangling" is commonly used for smooth wavelength-dependent
|
|
133
|
+
corrections that are applied to a spectrum to make it reproduce contemporaneous
|
|
134
|
+
broadband photometry. The approach has roots in earlier spectral "warping"
|
|
135
|
+
methods (e.g.
|
|
136
|
+
[Tonry *et al.* 2003](https://scixplorer.org/abs/2003ApJ...594....1T)), was
|
|
137
|
+
formalized under the "mangling" nomenclature by
|
|
138
|
+
[Hsiao *et al.* (2007)](https://scixplorer.org/abs/2007ApJ...663.1187H) and
|
|
139
|
+
[Conley *et al.* (2008)](https://scixplorer.org/abs/2008ApJ...681..482C), and
|
|
140
|
+
was later applied directly to spectrophotometric calibration of observed
|
|
141
|
+
core-collapse supernova spectra by
|
|
142
|
+
[Vincenzi *et al.* (2019)](https://scixplorer.org/abs/2019MNRAS.489.5802V).
|
|
143
|
+
|
|
144
|
+
## Spectral uncertainties
|
|
145
|
+
|
|
146
|
+
`specmangle` supports the same uncertainty representations supported by
|
|
147
|
+
`specutils` for arithmetic propagation:
|
|
148
|
+
|
|
149
|
+
- `astropy.nddata.StdDevUncertainty`;
|
|
150
|
+
- `astropy.nddata.VarianceUncertainty`;
|
|
151
|
+
- `astropy.nddata.InverseVariance`.
|
|
152
|
+
|
|
153
|
+
For synthetic photometry, uncertainties are converted to standard deviations and
|
|
154
|
+
spectral samples are assumed to be statistically independent. Masked spectral
|
|
155
|
+
samples are omitted.
|
|
156
|
+
|
|
157
|
+
The uncertainty stored on `result.spectrum` is the input spectral uncertainty
|
|
158
|
+
scaled by the best-fit mangling correction. Uncertainty in the correction
|
|
159
|
+
itself is returned separately as `result.correction_uncertainty`, because errors
|
|
160
|
+
in the fitted correction are correlated across wavelength.
|
|
161
|
+
|
|
162
|
+
## Bandpasses
|
|
163
|
+
|
|
164
|
+
By default, values in the `band` column are passed to the SVO Filter Profile
|
|
165
|
+
Service:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from specmangle import Bandpass
|
|
169
|
+
|
|
170
|
+
bandpass = Bandpass.from_svo("SLOAN/SDSS.g")
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Custom response curves can be supplied instead:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
import astropy.units as u
|
|
177
|
+
from specmangle import Bandpass, mangle
|
|
178
|
+
|
|
179
|
+
custom = Bandpass(
|
|
180
|
+
filter_id="my/filter",
|
|
181
|
+
wavelength=filter_wavelength * u.AA,
|
|
182
|
+
transmission=filter_transmission,
|
|
183
|
+
detector_type="photon",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
result = mangle(
|
|
187
|
+
spectrum,
|
|
188
|
+
photometry,
|
|
189
|
+
bandpasses={"my/filter": custom},
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The synthetic-photometry calculation distinguishes photon-counting and
|
|
194
|
+
energy-counting response curves. SVO's `DetectorType` metadata is used
|
|
195
|
+
automatically for SVO filters.
|
|
196
|
+
|
|
197
|
+
## Passband coverage
|
|
198
|
+
|
|
199
|
+
By default, at least 98% of the passband's AB-reference signal must fall within
|
|
200
|
+
the wavelength range covered by the valid spectrum samples. This prevents a
|
|
201
|
+
synthetic magnitude from silently relying on a substantially incomplete
|
|
202
|
+
bandpass.
|
|
203
|
+
|
|
204
|
+
The threshold can be changed with `min_coverage`:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
result = mangle(spectrum, photometry, min_coverage=0.95)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Direct synthetic photometry
|
|
211
|
+
|
|
212
|
+
For the common/general case of computing an AB magnitude directly from an SVO
|
|
213
|
+
FPS identifier:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from specmangle import spectrum_to_magnitude
|
|
217
|
+
|
|
218
|
+
mag = spectrum_to_magnitude(spectrum, "SLOAN/SDSS.r")
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
If the input `Spectrum` includes an uncertainty, the function returns
|
|
222
|
+
`(mag, mag_err)`. Both values carry Astropy magnitude units. An already loaded
|
|
223
|
+
`Bandpass` can be supplied instead of an SVO identifier.
|
|
224
|
+
|
|
225
|
+
The lower-level synthetic-photometry result is also exposed when coverage and
|
|
226
|
+
other diagnostics are useful:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from specmangle import Bandpass, synthetic_ab_magnitude
|
|
230
|
+
|
|
231
|
+
bandpass = Bandpass.from_svo("SLOAN/SDSS.r")
|
|
232
|
+
synthetic = synthetic_ab_magnitude(spectrum, bandpass)
|
|
233
|
+
|
|
234
|
+
print(synthetic.magnitude)
|
|
235
|
+
print(synthetic.uncertainty)
|
|
236
|
+
print(synthetic.coverage)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Result object
|
|
240
|
+
|
|
241
|
+
`mangle` returns a `MangleResult` containing:
|
|
242
|
+
|
|
243
|
+
- `spectrum`: the mangled `specutils.Spectrum`
|
|
244
|
+
- `correction`: multiplicative correction evaluated at each input spectral sample
|
|
245
|
+
- `correction_at(wavelength)`: evaluate the fitted correction at arbitrary wavelengths
|
|
246
|
+
- `correction_uncertainty`: estimated 1-sigma uncertainty in that correction, when identifiable
|
|
247
|
+
- `anchor_wavelengths`: spline-anchor pivot wavelengths
|
|
248
|
+
- `log_correction_parameters`: fitted log-correction values at the anchors
|
|
249
|
+
- `parameter_covariance`: fitted parameter covariance when absolute uncertainties are available
|
|
250
|
+
- `photometry`: the input table with synthetic-photometry diagnostics appended
|
|
251
|
+
- `bandpasses`: the bandpasses used in the fit
|
|
252
|
+
- `optimizer`: the underlying `scipy.optimize.OptimizeResult`
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
specmangle/__init__.py,sha256=7V3k3GutZSwtJt6xZfHMW1GWH8DbzxrVoD4UyNaPMnE,488
|
|
2
|
+
specmangle/bandpasses.py,sha256=u4fr6m7J4aubrRniCYkfDHRdr_q-XoTTuFY11pem5Uo,8378
|
|
3
|
+
specmangle/mangling.py,sha256=DhYKvDVMgKkWzPC3V-sHQKfZxgntKiqHYyRQtUOzo24,16364
|
|
4
|
+
specmangle/photometry.py,sha256=E_4h_HOPecZZbKmckuBuRRCxSXdnYGpkB_P3xAsZjUs,12758
|
|
5
|
+
specmangle-0.1.0b1.dist-info/licenses/LICENSE,sha256=pIcUbfsbuKN2xzSZmJCGKEvJxCOh_EWu5Yk7D0QQXEc,1519
|
|
6
|
+
specmangle-0.1.0b1.dist-info/METADATA,sha256=Ohc2YJS4DSljrJGC7spI_KV90uPxeM2f7AG-M-yq1NI,8465
|
|
7
|
+
specmangle-0.1.0b1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
specmangle-0.1.0b1.dist-info/top_level.txt,sha256=SlMKojoe8ukDy_Yje-ukZPEiaLulnVIKWKfr8e2DGm8,11
|
|
9
|
+
specmangle-0.1.0b1.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Caden Gobat
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
17
|
+
may be used to endorse or promote products derived from this software
|
|
18
|
+
without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
21
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
22
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
specmangle
|