"""
Helpers for IRIS density diagnostics.
"""
from importlib import import_module
import numpy as np
from scipy.interpolate import make_interp_spline
import astropy.units as u
__all__ = ["density_diagnostic", "map_ratio_to_quantity"]
[docs]
def map_ratio_to_quantity(observed_ratio, quantity, theoretical_ratio, *, bounds_error=False, fill_value=np.nan):
"""
Map an observed line ratio to the associated physical quantity.
Parameters
----------
observed_ratio : array-like or `~astropy.units.Quantity`
Observed line ratio values. Must be dimensionless.
quantity : `~astropy.units.Quantity`
Quantity grid associated with ``theoretical_ratio``, e.g. an electron
density grid.
theoretical_ratio : array-like or `~astropy.units.Quantity`
Theoretical line ratio sampled on ``quantity``. Must be dimensionless
and monotonic.
bounds_error : `bool`, optional
If `True`, raise an exception when ``observed_ratio`` falls outside the
theoretical ratio range. By default, points outside the range are set by
``fill_value``.
fill_value : scalar, tuple, or `~astropy.units.Quantity`, optional
Value used outside of the interpolation interval. If a quantity is
given, it must be convertible to the units of ``quantity``.
"""
observed_ratio = u.Quantity(observed_ratio, u.dimensionless_unscaled)
quantity = np.ravel(u.Quantity(quantity))
theoretical_ratio = np.ravel(u.Quantity(theoretical_ratio, u.dimensionless_unscaled).value)
if quantity.shape != theoretical_ratio.shape:
msg = "quantity and theoretical_ratio must have the same shape."
raise ValueError(msg)
finite = np.isfinite(quantity.value) & np.isfinite(theoretical_ratio)
quantity = quantity[finite]
theoretical_ratio = theoretical_ratio[finite]
if quantity.size < 2:
msg = "At least two finite samples are required to interpolate a ratio curve."
raise ValueError(msg)
diff = np.diff(theoretical_ratio)
nonzero = diff != 0.0
if not np.any(nonzero):
msg = "theoretical_ratio must vary over the provided quantity grid."
raise ValueError(msg)
if np.all(diff[nonzero] < 0.0):
quantity = quantity[::-1]
theoretical_ratio = theoretical_ratio[::-1]
elif not np.all(diff[nonzero] > 0.0):
msg = (
"theoretical_ratio must be monotonic to map ratios back to a quantity. "
"Restrict the grid to a monotonic interval first."
)
raise ValueError(msg)
order = np.argsort(theoretical_ratio)
theoretical_ratio = theoretical_ratio[order]
quantity = quantity[order]
theoretical_ratio, unique_index = np.unique(theoretical_ratio, return_index=True)
quantity = quantity[unique_index]
if quantity.size < 2:
msg = "Theoretical ratio must contain at least two unique samples."
raise ValueError(msg)
if isinstance(fill_value, tuple):
fill_value = tuple(
value.to_value(quantity.unit) if isinstance(value, u.Quantity) else value for value in fill_value
)
elif isinstance(fill_value, u.Quantity):
fill_value = fill_value.to_value(quantity.unit)
observed_values = observed_ratio.to_value(u.dimensionless_unscaled)
outside = (observed_values < theoretical_ratio[0]) | (observed_values > theoretical_ratio[-1])
if bounds_error and np.any(outside):
msg = "observed_ratio contains values outside the theoretical ratio range."
raise ValueError(msg)
if isinstance(fill_value, tuple):
left, right = fill_value
else:
left = right = fill_value
return u.Quantity(
np.interp(observed_values, theoretical_ratio, quantity.value, left=left, right=right), quantity.unit
)
[docs]
def density_diagnostic(
intensity_numerator,
intensity_denominator,
density_grid,
*,
ion,
numerator,
denominator,
intensity_numerator_uncertainty=None,
intensity_denominator_uncertainty=None,
temperature=None,
bounds_error=False,
fill_value=np.nan,
line_ratio_kwargs=None,
):
"""
Map an observed IRIS line ratio onto a theoretical density curve.
Parameters
----------
intensity_numerator, intensity_denominator : array-like or `~astropy.units.Quantity`
Measured integrated intensities for the numerator and denominator lines.
density_grid : `~astropy.units.Quantity`
Density grid on which to evaluate the theoretical ratio curve.
ion : `fiasco.Ion`
Ion used to compute the theoretical ratio curve.
numerator, denominator : `~astropy.units.Quantity`
Required wavelengths of the numerator and denominator transitions.
intensity_numerator_uncertainty, intensity_denominator_uncertainty :
array-like or `~astropy.units.Quantity`, optional
Uncertainties on the measured integrated intensities.
temperature : `~astropy.units.Quantity`, optional
Temperature at which to evaluate the theoretical ratio curve from
``ion``. If omitted and scalar, uses the ion temperature; if omitted
and non-scalar, falls back to ``ion.formation_temperature``.
bounds_error : `bool`, optional
Passed through to `map_ratio_to_quantity`.
fill_value : scalar or `~astropy.units.Quantity`, optional
Passed through to `map_ratio_to_quantity`.
line_ratio_kwargs : `dict`, optional
Extra keyword arguments forwarded to ``fiasco.line_ratio``.
"""
if (intensity_numerator_uncertainty is None) != (intensity_denominator_uncertainty is None):
msg = "Both numerator and denominator uncertainties must be provided together."
raise ValueError(msg)
if any(value is None for value in (ion, numerator, denominator)):
msg = "ion, numerator, and denominator are required."
raise ValueError(msg)
try:
fiasco = import_module("fiasco")
except ImportError as exc:
msg = "IRIS density diagnostics require the optional dependency 'fiasco'."
raise ImportError(msg) from exc
if line_ratio_kwargs is None:
line_ratio_kwargs = {}
intensity_numerator = u.Quantity(intensity_numerator)
intensity_denominator = u.Quantity(intensity_denominator)
numerator_value, denominator_value = np.broadcast_arrays(intensity_numerator.value, intensity_denominator.value)
ratio_value = np.divide(
numerator_value,
denominator_value,
out=np.full(numerator_value.shape, np.nan),
where=denominator_value != 0.0,
)
ratio = u.Quantity(ratio_value, intensity_numerator.unit / intensity_denominator.unit).to(u.dimensionless_unscaled)
ratio_uncertainty = None
if intensity_numerator_uncertainty is not None:
numerator_uncertainty = u.Quantity(intensity_numerator_uncertainty)
denominator_uncertainty = u.Quantity(intensity_denominator_uncertainty)
numerator_uncertainty_value, numerator_value = np.broadcast_arrays(
numerator_uncertainty.to_value(intensity_numerator.unit),
intensity_numerator.value,
)
denominator_uncertainty_value, denominator_value = np.broadcast_arrays(
denominator_uncertainty.to_value(intensity_denominator.unit),
intensity_denominator.value,
)
numerator_fraction = np.divide(
numerator_uncertainty_value,
numerator_value,
out=np.full(numerator_uncertainty_value.shape, np.nan),
where=numerator_value != 0.0,
)
denominator_fraction = np.divide(
denominator_uncertainty_value,
denominator_value,
out=np.full(denominator_uncertainty_value.shape, np.nan),
where=denominator_value != 0.0,
)
ratio_uncertainty = np.abs(ratio) * np.sqrt(numerator_fraction**2 + denominator_fraction**2)
try:
ratio_uncertainty = np.broadcast_to(ratio_uncertainty.value, ratio.shape)
except ValueError as exc:
msg = (
f"Uncertainty shapes {numerator_uncertainty.shape} and {denominator_uncertainty.shape} "
f"are not broadcastable to intensity shape {ratio.shape}."
)
raise ValueError(msg) from exc
ratio_uncertainty = u.Quantity(ratio_uncertainty, ratio.unit)
theoretical_ratio = fiasco.line_ratio(
ion,
numerator,
denominator,
density_grid,
**line_ratio_kwargs,
)
theoretical_ratio = u.Quantity(theoretical_ratio, u.dimensionless_unscaled).squeeze()
if theoretical_ratio.ndim > 1:
ion_temperature = u.Quantity(ion.temperature)
if theoretical_ratio.shape[0] != ion_temperature.size:
msg = "theoretical_ratio temperature axis does not match ion.temperature"
raise ValueError(msg)
if temperature is None:
ratio_temperature = u.Quantity(ion.temperature)
if ratio_temperature.size != 1:
ratio_temperature = u.Quantity(ion.formation_temperature)
else:
ratio_temperature = u.Quantity(temperature)
if ratio_temperature.size != 1:
msg = "temperature must be scalar."
raise ValueError(msg)
ratio_temperature = ratio_temperature.flat[0]
order = np.argsort(ion_temperature.to_value(u.K))
temperature_grid = ion_temperature.to_value(u.K)[order]
ratio_values = theoretical_ratio.value[order]
interp = make_interp_spline(temperature_grid, ratio_values, k=1, axis=0)
theoretical_ratio = u.Quantity(
interp(ratio_temperature.to_value(u.K), extrapolate=False),
theoretical_ratio.unit,
).squeeze()
density_grid = u.Quantity(density_grid)
theoretical_ratio = u.Quantity(theoretical_ratio, u.dimensionless_unscaled)
finite = np.isfinite(density_grid.value) & np.isfinite(theoretical_ratio.value)
density_grid = density_grid[finite]
theoretical_ratio = theoretical_ratio[finite]
segments = []
if density_grid.size < 2:
segments.append((density_grid, theoretical_ratio))
else:
start = 0
previous_sign = None
for i, delta in enumerate(np.diff(theoretical_ratio.value)):
sign = np.sign(delta)
if sign == 0:
continue
if previous_sign is None:
previous_sign = sign
continue
if sign != previous_sign:
segments.append((density_grid[start : i + 1], theoretical_ratio[start : i + 1]))
start = i
previous_sign = sign
segments.append((density_grid[start:], theoretical_ratio[start:]))
segments = [(quantity, ratio_segment) for quantity, ratio_segment in segments if quantity.size >= 2]
if len(segments) == 1:
density_grid, theoretical_ratio = segments[0]
elif segments:
observed_ratio = ratio.value[np.isfinite(ratio.value)]
if ratio_uncertainty is not None:
ratio_min_obs = (ratio - ratio_uncertainty).value[np.isfinite((ratio - ratio_uncertainty).value)]
ratio_max_obs = (ratio + ratio_uncertainty).value[np.isfinite((ratio + ratio_uncertainty).value)]
observed_ratio_range = np.concatenate([ratio_min_obs, ratio_max_obs])
else:
observed_ratio_range = observed_ratio
best_score = None
for segment in segments:
quantity, ratio_segment = segment
ratio_min = np.nanmin(ratio_segment.value)
ratio_max = np.nanmax(ratio_segment.value)
in_range = np.logical_and(observed_ratio_range >= ratio_min, observed_ratio_range <= ratio_max).sum()
score = (in_range, quantity.size, ratio_max - ratio_min)
if best_score is None or score > best_score:
density_grid, theoretical_ratio = segment
best_score = score
density = map_ratio_to_quantity(
ratio,
density_grid,
theoretical_ratio,
bounds_error=bounds_error,
fill_value=fill_value,
)
density_lower = None
density_upper = None
if ratio_uncertainty is not None:
density_minus = map_ratio_to_quantity(
ratio - ratio_uncertainty,
density_grid,
theoretical_ratio,
bounds_error=bounds_error,
fill_value=fill_value,
)
density_plus = map_ratio_to_quantity(
ratio + ratio_uncertainty,
density_grid,
theoretical_ratio,
bounds_error=bounds_error,
fill_value=fill_value,
)
density_lower = u.Quantity(
np.fmin(density_minus.to_value(density.unit), density_plus.to_value(density.unit)),
density.unit,
)
density_upper = u.Quantity(
np.fmax(density_minus.to_value(density.unit), density_plus.to_value(density.unit)),
density.unit,
)
return {
"ratio": ratio,
"ratio_uncertainty": ratio_uncertainty,
"density": density,
"density_lower": density_lower,
"density_upper": density_upper,
"density_grid": u.Quantity(density_grid),
"theoretical_ratio": u.Quantity(theoretical_ratio, u.dimensionless_unscaled),
}