Source code for specsr_roman.data.transforms

"""Resampling and normalisation primitives.

Small functions, but each encodes a correctness lesson that cost a debugging
session, so they live in one place with the reasoning attached.
"""

from __future__ import annotations

import numpy as np

from ..grids import GRISM_RESOLUTION

__all__ = ["normalize", "fluxconserve_resample", "smooth_to_grism",
           "interp_ascending"]


[docs] def normalize(x: np.ndarray, eps: float = 1e-25): """Per-spectrum standardisation -> ``(normalised, mean, std)``. Every spectrum is normalised individually rather than by a global scale. That is not just conditioning: the grizli extraction is systematically ~1.7x brighter than the input SED (aperture losses), so an absolute flux scale would teach the model a calibration error. Normalising per row removes it and makes the task purely about *shape*. ``std`` is floored because a genuinely constant row would otherwise divide by zero --- though such rows should never reach here; see :func:`specsr_roman.data.datasets.RomanFixedGridDataset`. """ mean = np.nanmean(x) std = np.nanstd(x) if std < eps: std = eps return (x - mean) / std, mean, std
[docs] def fluxconserve_resample(wave: np.ndarray, flux: np.ndarray, new_wave: np.ndarray) -> np.ndarray: """Rebin via the cumulative integral, conserving integrated flux. Required for the Diffsky SEDs, whose wavelength grid is *adaptive*: sub-Angstrom bins at the emission lines and very coarse sampling elsewhere. Point-interpolating that onto a uniform grid drops or duplicates line flux depending on where the bins land --- an emission line can simply vanish. Integrating and differencing preserves it exactly. """ cum = np.concatenate([[0.0], np.cumsum(np.diff(wave) * 0.5 * (flux[1:] + flux[:-1]))]) edges = np.concatenate([[new_wave[0] - (new_wave[1] - new_wave[0]) / 2], 0.5 * (new_wave[1:] + new_wave[:-1]), [new_wave[-1] + (new_wave[-1] - new_wave[-2]) / 2]]) cum_e = np.interp(edges, wave, cum) return np.diff(cum_e) / np.diff(edges)
[docs] def smooth_to_grism(wave_obs: np.ndarray, flux: np.ndarray, resolution: float = GRISM_RESOLUTION) -> np.ndarray: """Degrade a spectrum to Roman grism resolution. R(lambda) = 461 * lambda[um], so FWHM = lambda/R = 1/461 um = 21.7 A --- near-constant in wavelength, hence a constant-sigma Gaussian in lambda rather than a varying kernel. (This constancy is also why the ZHead needs an explicit wavelength ramp: unlike the JWST prism, line width here says nothing about where in the band you are.) """ from scipy.ndimage import gaussian_filter1d fwhm_A = 1.0e4 / resolution dlam = np.median(np.diff(wave_obs)) return gaussian_filter1d(flux, (fwhm_A / 2.355) / dlam)
[docs] def interp_ascending(new_wave: np.ndarray, wave: np.ndarray, flux: np.ndarray, left=np.nan, right=np.nan) -> np.ndarray: """``np.interp`` that refuses to be fooled by a descending input grid. Roman's ``DLDP_A_1`` is negative, so grizli's ``optimal_extract`` returns wavelengths in *descending* order. ``np.interp`` does not check, and for unsorted ``xp`` it silently returns the edge value everywhere --- producing a perfectly flat "spectrum" that carries zero information and trains a model straight into the prior mean. This bug cost one full dataset build. """ order = np.argsort(wave) return np.interp(new_wave, wave[order], flux[order], left=left, right=right)