"""
Red-blue asymmetry utilities for IRIS spectrogram cubes.
"""
import warnings
from enum import IntEnum
import numpy as np
from scipy.interpolate import make_interp_spline
import astropy.units as u
from astropy import constants
from astropy.nddata import StdDevUncertainty
from astropy.wcs import WCS
from irispy.spectrograph import RasterCollection, SpectrogramCube
from irispy.utils._spectral import drop_extra_coords_dependent_on_axis, make_map_cube, make_spatial_template
__all__ = ["RBAQualityFlag", "calculate_red_blue_asymmetry"]
# Minimum fraction of wing bins that must have finite interpolated values.
_MIN_WING_COVERAGE = 0.8
[docs]
class RBAQualityFlag(IntEnum):
"""
Quality flags for the per-pixel RBA computation.
"""
OK = (0, "ok")
NO_FINITE_DATA = (1, "no finite data")
PEAK_AT_EDGE = (2, "peak at spectral edge")
TOO_FEW_POINTS = (3, "too few finite points")
INTERP_FAILED = (4, "interpolation failed")
PEAK_IS_ZERO = (5, "peak is zero or non-finite")
INCOMPLETE_WINGS = (6, "incomplete red or blue wing coverage")
LOW_SIGNAL = (7, "below min_intensity")
SATURATED = (8, "above saturation_limit")
def __new__(cls, value, description):
obj = int.__new__(cls, value)
obj._value_ = value
obj.description = description
return obj
def _make_velocity_wcs(array_shape, velocity_axis, velocity_grid):
"""
Return a profile WCS whose only meaningful world axis is Doppler velocity.
Do not copy the input cube WCS here: profile cubes may have fewer axes than
the source cube, so stale FITS-WCS axes can warn or fail during construction.
"""
naxis = len(array_shape)
wcs_axis = naxis - 1 - velocity_axis
cdelt = float(np.nanmean(np.diff(velocity_grid))) if velocity_grid.size > 1 else 1.0
wcs = WCS(naxis=naxis)
wcs.wcs.ctype[wcs_axis] = "VELO"
wcs.wcs.cunit[wcs_axis] = "km/s"
wcs.wcs.crpix[wcs_axis] = 1.0
wcs.wcs.crval[wcs_axis] = float(velocity_grid[0])
wcs.wcs.cdelt[wcs_axis] = cdelt
wcs.array_shape = tuple(array_shape)
wcs.wcs.set()
return wcs
def _make_profile_cube(cube, *, data, velocity_grid, wavelength_axis, meta, uncertainty=None, mask=None):
return SpectrogramCube(
data,
wcs=_make_velocity_wcs(data.shape, wavelength_axis, velocity_grid),
uncertainty=uncertainty,
unit=cube.unit,
meta=meta,
mask=mask,
extra_coords=drop_extra_coords_dependent_on_axis(cube.extra_coords, wavelength_axis, reindex=False),
)
def _prepare_data(cube, wavelengths, wavelength_axis, continuum_windows):
"""
Extract data/errors, mask negatives, subtract continuum, move spectral axis to -1.
"""
data = np.asarray(cube.data, dtype=float)
if cube.mask is not None:
data = np.where(cube.mask, np.nan, data)
data = np.where(data < 0, np.nan, data)
errors = np.asarray(cube.uncertainty.array, dtype=float) if cube.uncertainty is not None else None
if errors is not None and cube.mask is not None:
errors = np.where(cube.mask, np.nan, errors)
data = np.moveaxis(data, wavelength_axis, -1)
if errors is not None:
errors = np.moveaxis(errors, wavelength_axis, -1)
if continuum_windows is not None:
windows = u.Quantity(continuum_windows).to(wavelengths.unit)
if windows.shape == (2,):
windows = windows[np.newaxis, :]
if windows.ndim != 2 or windows.shape[1] != 2:
msg = "continuum_windows must have shape (2,) or (n, 2)"
raise ValueError(msg)
continuum_mask = np.zeros(wavelengths.shape, dtype=bool)
for low, high in windows:
continuum_mask |= (wavelengths >= low) & (wavelengths <= high)
if not continuum_mask.any():
msg = "No wavelength points found within continuum_windows"
raise ValueError(msg)
continuum_values = np.nanmean(data[..., continuum_mask], axis=-1)
data = data - continuum_values[..., np.newaxis]
if errors is not None:
n_finite = np.isfinite(errors[..., continuum_mask]).sum(axis=-1)
continuum_errors = np.sqrt(np.nansum(errors[..., continuum_mask] ** 2, axis=-1))
continuum_errors = np.where(n_finite > 0, continuum_errors / n_finite, np.nan)
errors = np.sqrt(errors**2 + continuum_errors[..., np.newaxis] ** 2)
return data, errors
[docs]
def calculate_red_blue_asymmetry(
cube,
*,
rest_wavelength=None,
velocity_range=(50, 150) * u.km / u.s,
dv=10 * u.km / u.s,
continuum_windows=None,
degree=3,
min_intensity=None,
saturation_limit=None,
return_profiles=True,
):
"""
Calculate red-blue asymmetry maps from a spectrogram cube.
The asymmetry is computed for each spatial pixel as
:math:`(I_R - I_B) / I_p`, where ``I_R`` and ``I_B`` are mean intensities
in matching red and blue velocity ranges and ``I_p`` is the interpolated
peak intensity.
Parameters
----------
cube : `irispy.spectrograph.SpectrogramCube`
Input spectrogram cube.
rest_wavelength : `astropy.units.Quantity`, optional
Rest wavelength used to convert the spectral axis to Doppler velocity.
If omitted, read from ``cube.meta.rest_wavelength`` (requires an
`~irispy.meta.SGMeta` instance with a ``TWAVE<n>`` FITS keyword).
velocity_range : `astropy.units.Quantity`, optional
Two positive velocities defining the wing range to average.
dv : `astropy.units.Quantity`, optional
Velocity spacing for the interpolated profile.
continuum_windows : `astropy.units.Quantity`, optional
One or more wavelength windows used to estimate and subtract a
continuum.
degree : `int`, optional
Spline degree for `scipy.interpolate.make_interp_spline`.
min_intensity : `float` or `astropy.units.Quantity`, optional
Minimum peak intensity required for a pixel to be processed.
Pixels below this threshold are assigned quality flag
`~irispy.utils.red_blue.RBAQualityFlag.LOW_SIGNAL`.
saturation_limit : `float` or `astropy.units.Quantity`, optional
Maximum allowed peak intensity. Pixels above this value are assigned
quality flag `~irispy.utils.red_blue.RBAQualityFlag.SATURATED`.
return_profiles : `bool`, optional
If `True`, include 3D ``"observed_profile"`` and
``"interpolated_profile"`` cubes in the output.
Returns
-------
`irispy.spectrograph.RasterCollection`
Always contains ``"red_blue_asymmetry"`` and ``"quality"`` 2D maps.
``"red_blue_asymmetry_error"`` is added when the input cube has uncertainty.
``"observed_profile"`` and ``"interpolated_profile"`` 3D cubes are added
when ``return_profiles=True``. The interpolated profile velocity axis is
peak-centred; the observed profile velocity axis is relative to
``rest_wavelength``.
"""
if rest_wavelength is None:
rest_wavelength = getattr(cube.meta, "rest_wavelength", None)
if rest_wavelength is None:
msg = (
"rest_wavelength was not provided and could not be read from cube.meta. "
"Pass rest_wavelength explicitly or provide a cube with SGMeta containing TWAVE keywords."
)
raise ValueError(msg)
rest_wavelength = u.Quantity(rest_wavelength).to(u.nm)
if rest_wavelength.shape != () or not np.isfinite(rest_wavelength.value) or rest_wavelength <= 0 * u.nm:
msg = "rest_wavelength must be a positive finite scalar wavelength"
raise ValueError(msg)
velocity_range = u.Quantity(velocity_range).to(u.km / u.s)
if velocity_range.shape != (2,):
msg = "velocity_range must contain two velocities"
raise ValueError(msg)
velocity_low, velocity_high = velocity_range
if velocity_low < 0 or velocity_high <= velocity_low:
msg = "velocity_range must be positive and increasing"
raise ValueError(msg)
dv = u.Quantity(dv).to_value(u.km / u.s)
if dv <= 0:
msg = "dv must be positive"
raise ValueError(msg)
degree = int(degree)
if degree < 0:
msg = "degree must be a non-negative integer"
raise ValueError(msg)
wavelength_axis = cube.wavelength_axis
wavelengths = cube.axis_world_coords(wavelength_axis)[0].to(u.nm)
rest_wavelength = rest_wavelength.to(wavelengths.unit)
velocity = ((wavelengths - rest_wavelength) / rest_wavelength * constants.c).to_value(u.km / u.s)
interp_extent = velocity_high.to_value(u.km / u.s) + dv
interp_velocity = np.arange(-interp_extent, interp_extent + dv, dv)
velocity_range_kms = (velocity_low.to_value(u.km / u.s), velocity_high.to_value(u.km / u.s))
data, errors = _prepare_data(cube, wavelengths, wavelength_axis, continuum_windows)
output_shape = data.shape[:-1]
low, high = velocity_range_kms
red_mask = (interp_velocity >= low) & (interp_velocity <= high)
blue_mask = (interp_velocity >= -high) & (interp_velocity <= -low)
red_blue = np.full(output_shape, np.nan)
red_blue_error = np.full(output_shape, np.nan)
quality = np.full(output_shape, RBAQualityFlag.OK, dtype=np.uint8)
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
raw_peak = np.nanmax(data, axis=-1)
if min_intensity is not None:
min_intensity_value = (
min_intensity.to_value(cube.unit) if isinstance(min_intensity, u.Quantity) else min_intensity
)
quality = np.where(raw_peak < min_intensity_value, RBAQualityFlag.LOW_SIGNAL, quality).astype(np.uint8)
if saturation_limit is not None:
saturation_limit_value = (
saturation_limit.to_value(cube.unit) if isinstance(saturation_limit, u.Quantity) else saturation_limit
)
quality = np.where(raw_peak > saturation_limit_value, RBAQualityFlag.SATURATED, quality).astype(np.uint8)
interpolated_profiles = (
np.full((*output_shape, interp_velocity.size), np.nan, dtype=float) if return_profiles else None
)
interpolated_errors = (
np.full((*output_shape, interp_velocity.size), np.nan, dtype=float)
if errors is not None and return_profiles
else None
)
min_points = degree + 1
for index in np.ndindex(output_shape):
if quality[index] in (RBAQualityFlag.LOW_SIGNAL, RBAQualityFlag.SATURATED):
continue
profile = data[index]
profile_error = None if errors is None else errors[index]
finite_profile = np.isfinite(profile)
if not finite_profile.any():
quality[index] = RBAQualityFlag.NO_FINITE_DATA
continue
peak_index = np.nanargmax(np.where(finite_profile, profile, np.nan))
if peak_index == 0 or peak_index == profile.size - 1:
quality[index] = RBAQualityFlag.PEAK_AT_EDGE
continue
shifted_velocity = velocity - velocity[peak_index]
finite = np.isfinite(shifted_velocity) & finite_profile
if finite.sum() < min_points:
quality[index] = RBAQualityFlag.TOO_FEW_POINTS
continue
order = np.argsort(shifted_velocity[finite])
ordered_velocity = shifted_velocity[finite][order]
ordered_profile = profile[finite][order]
try:
interp_profile = make_interp_spline(ordered_velocity, ordered_profile, k=degree)(
interp_velocity, extrapolate=False
)
except ValueError:
quality[index] = RBAQualityFlag.INTERP_FAILED
continue
interp_error = None
if profile_error is not None:
ordered_error = profile_error[finite][order]
finite_error = np.isfinite(ordered_error)
if finite_error.sum() >= min_points:
try:
interp_error = make_interp_spline(
ordered_velocity[finite_error], ordered_error[finite_error], k=degree
)(interp_velocity, extrapolate=False)
except ValueError:
quality[index] = RBAQualityFlag.INTERP_FAILED
continue
interp_error = np.where(interp_error >= 0, interp_error, np.nan)
if return_profiles:
interpolated_profiles[index] = interp_profile
if interpolated_errors is not None and interp_error is not None:
interpolated_errors[index] = interp_error
red_finite = red_mask & np.isfinite(interp_profile)
blue_finite = blue_mask & np.isfinite(interp_profile)
if red_finite.sum() < _MIN_WING_COVERAGE * red_mask.sum():
quality[index] = RBAQualityFlag.INCOMPLETE_WINGS
continue
if blue_finite.sum() < _MIN_WING_COVERAGE * blue_mask.sum():
quality[index] = RBAQualityFlag.INCOMPLETE_WINGS
continue
red_intensity = np.nanmean(interp_profile[red_finite]) if red_finite.any() else np.nan
blue_intensity = np.nanmean(interp_profile[blue_finite]) if blue_finite.any() else np.nan
peak = np.nanmax(interp_profile)
if not np.isfinite(peak) or np.isclose(peak, 0):
quality[index] = RBAQualityFlag.PEAK_IS_ZERO
continue
numerator = red_intensity - blue_intensity
red_blue[index] = numerator / peak
if interp_error is not None:
n_red = np.isfinite(interp_error[red_mask]).sum()
n_blue = np.isfinite(interp_error[blue_mask]).sum()
red_err = np.sqrt(np.nansum(interp_error[red_mask] ** 2)) / n_red if n_red > 0 else np.nan
blue_err = np.sqrt(np.nansum(interp_error[blue_mask] ** 2)) / n_blue if n_blue > 0 else np.nan
peak_err = interp_error[np.nanargmax(interp_profile)]
num_err = np.sqrt(red_err**2 + blue_err**2)
numerator_is_zero = np.isclose(numerator, 0)
if np.isfinite(num_err) and (numerator_is_zero or np.isfinite(peak_err)):
variance = (num_err / peak) ** 2
if not numerator_is_zero:
variance += (numerator * peak_err / peak**2) ** 2
red_blue_error[index] = np.sqrt(variance)
meta = {
"rba_rest_wavelength": rest_wavelength.to_value(u.nm),
"rba_rest_wavelength_unit": "nm",
"rba_velocity_range": velocity_range_kms,
"rba_dv": dv,
"rba_interpolation_degree": degree,
}
if continuum_windows is not None:
meta["rba_continuum_windows"] = str(continuum_windows)
template = make_spatial_template(cube, wavelength_axis)
def _make_cube(values, unit, *, mask=None):
c = make_map_cube(template, values, unit, mask=mask)
c.meta.update(meta)
return c
cubes = [
("red_blue_asymmetry", red_blue, u.dimensionless_unscaled),
("quality", quality, u.dimensionless_unscaled),
]
if errors is not None:
cubes.append(("red_blue_asymmetry_error", red_blue_error, u.dimensionless_unscaled))
result_cubes = []
for name, values, unit in cubes:
mask = None if name == "quality" else ~np.isfinite(values)
result_cubes.append((name, _make_cube(values, unit, mask=mask)))
if return_profiles:
observed_data = np.moveaxis(data, -1, wavelength_axis)
interpolated_data = np.moveaxis(interpolated_profiles, -1, wavelength_axis)
observed_mask = ~np.isfinite(observed_data)
interpolated_mask = ~np.isfinite(interpolated_data)
observed_uncertainty = (
StdDevUncertainty(np.moveaxis(errors, -1, wavelength_axis)) if errors is not None else None
)
interpolated_uncertainty = (
StdDevUncertainty(np.moveaxis(interpolated_errors, -1, wavelength_axis))
if interpolated_errors is not None
else None
)
result_cubes.extend(
[
(
"observed_profile",
_make_profile_cube(
cube,
data=observed_data,
velocity_grid=velocity,
wavelength_axis=wavelength_axis,
meta={**cube.meta, **meta, "rba_profile": "observed"},
uncertainty=observed_uncertainty,
mask=observed_mask,
),
),
(
"interpolated_profile",
_make_profile_cube(
cube,
data=interpolated_data,
velocity_grid=interp_velocity,
wavelength_axis=wavelength_axis,
meta={**cube.meta, **meta, "rba_profile": "interpolated_peak_centered"},
uncertainty=interpolated_uncertainty,
mask=interpolated_mask,
),
),
]
)
return RasterCollection(result_cubes)