Source code for irispy.utils.cosmic_rays

"""
Utilities for removing cosmic rays from IRIS data.
"""

from typing import Any
from importlib import import_module
from collections.abc import Mapping

import numpy as np

__all__ = ["remove_cosmic_rays"]


def _import_optional_backend(module_name: str, *, method: str):
    try:
        return import_module(module_name)
    except ImportError as exc:
        msg = (
            f"{module_name} is an optional dependency required for method='{method}'. "
            f"Install it with `pip install {module_name}` or "
            "`pip install 'irispy-lmsal[cosmic-rays]'`."
        )
        raise ImportError(msg) from exc


def _remove_cosmic_rays_rsliding(
    data: np.ndarray,
    mask: np.ndarray,
    *,
    sigma: float | None,
    max_iters: int | None,
    method_kwargs: dict[str, Any],
) -> tuple[np.ndarray, np.ndarray]:
    slidingsigmaclipping = _import_optional_backend("rsliding", method="rsliding").SlidingSigmaClipping
    kwargs = {
        "kernel": 3,
        "center_choice": "median",
        "borders": "reflect",
        "threads": 1,
        **method_kwargs,
    }
    kwargs["sigma"] = sigma if sigma is not None else kwargs.get("sigma", 3.0)
    kwargs["max_iters"] = max_iters if max_iters is not None else kwargs.get("max_iters", 5)
    kwargs["masked_array"] = True
    working_data = data.astype(np.float64, copy=True)
    working_data[mask] = np.nan
    clipped = slidingsigmaclipping(data=working_data, **kwargs).clipped
    cleaned_data = np.ma.getdata(clipped)
    cosmic_ray_mask = np.asarray(np.ma.getmaskarray(clipped), dtype=bool)
    return cleaned_data, cosmic_ray_mask


def _remove_cosmic_rays_astroscrappy(
    data: np.ndarray,
    mask: np.ndarray,
    *,
    sigma: float | None,
    max_iters: int | None,
    method_kwargs: dict[str, Any],
) -> tuple[np.ndarray, np.ndarray]:
    astroscrappy = _import_optional_backend("astroscrappy", method="astroscrappy")
    kwargs = {"verbose": False, **method_kwargs}
    kwargs["sigclip"] = sigma if sigma is not None else kwargs.get("sigclip", 4.5)
    kwargs["niter"] = max_iters if max_iters is not None else kwargs.get("niter", 4)
    inmask = kwargs.pop("inmask", None)
    if inmask is not None:
        mask = mask | np.asarray(inmask, dtype=bool)
    working_data = data.copy()
    working_data[mask] = 0.0
    cleaned_data = np.empty_like(working_data)
    cosmic_ray_mask = np.zeros(working_data.shape, dtype=bool)
    if working_data.ndim == 2:
        cosmic_ray_mask, cleaned_data = astroscrappy.detect_cosmics(working_data, inmask=mask, **kwargs)
        return cleaned_data, np.asarray(cosmic_ray_mask, dtype=bool)
    for index in np.ndindex(working_data.shape[:-2]):
        frame_mask, cleaned_frame = astroscrappy.detect_cosmics(working_data[index], inmask=mask[index], **kwargs)
        cosmic_ray_mask[index] = frame_mask
        cleaned_data[index] = cleaned_frame
    return cleaned_data, cosmic_ray_mask


[docs] def remove_cosmic_rays( cube, *, method: str = "rsliding", sigma: float | None = None, max_iters: int | None = None, method_kwargs: Mapping[str, Any] | None = None, ): """ Remove cosmic rays from a cube and return a cleaned cube. Parameters ---------- cube : irispy.sji.SJICube or irispy.spectrograph.SpectrogramCube Cube object to clean. method : ``{"rsliding", "astroscrappy"}``, optional Cosmic ray removal backend. ``"rsliding"`` is the default and operates on the full array. ``"astroscrappy"`` is applied frame-by-frame over the last two axes. sigma : `float`, optional Shared clipping threshold override. This maps to ``sigma`` for ``rsliding`` and ``sigclip`` for ``astroscrappy``. max_iters : `int`, optional Shared iteration-count override. This maps to ``max_iters`` for ``rsliding`` and ``niter`` for ``astroscrappy``. method_kwargs : `dict`, optional Additional keyword arguments passed to the selected backend. Returns ------- irispy.sji.SJICube or irispy.spectrograph.SpectrogramCube A new cube with cleaned data and copied metadata/coordinates. """ method = method.lower() working_mask = np.zeros(cube.data.shape, dtype=bool) if cube.mask is None else np.asarray(cube.mask, dtype=bool) if np.issubdtype(cube.data.dtype, np.floating): working_mask = working_mask | np.isnan(cube.data) kwargs = dict(method_kwargs or {}) backends = { "rsliding": lambda: _remove_cosmic_rays_rsliding( cube.data, working_mask, sigma=sigma, max_iters=max_iters, method_kwargs=kwargs, ), "astroscrappy": lambda: _remove_cosmic_rays_astroscrappy( cube.data, working_mask, sigma=sigma, max_iters=max_iters, method_kwargs=kwargs, ), } if method not in backends: msg = f"Unsupported method {method!r}. Supported methods are: {sorted(backends)}." raise ValueError(msg) cleaned_data, _ = backends[method]() cleaned_cube_kwargs = { "data": cleaned_data, "mask": "copy", "nddata_type": type(cube), "extra_coords": "copy", "global_coords": "copy", } if hasattr(cube, "scaled"): cleaned_cube_kwargs["scaled"] = "copy" if hasattr(cube, "_basic_wcs"): cleaned_cube_kwargs["_basic_wcs"] = "copy" cleaned_cube = cube.to_nddata(**cleaned_cube_kwargs) if hasattr(cleaned_cube, "dust_masked") and hasattr(cube, "dust_masked"): cleaned_cube.dust_masked = cube.dust_masked return cleaned_cube