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 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
+ ]
@@ -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)
@@ -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
+ [![CI status](https://github.com/cgobat/specmangle/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/cgobat/specmangle/actions/workflows/tests.yml) [![Documentation Status](https://app.readthedocs.org/projects/specmangle/badge/?version=latest)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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