import copy
import warnings
import astropy.units as u
import drms
import numpy as np
from astropy.io import fits
from astropy.wcs.utils import pixel_to_pixel
from sunpy.map.mapbase import PixelPair
from sunpy.map.sources.sdo import AIAMap
from aiapy.util import AiapyUserWarning
__all__ = ["respike", "fetch_spikes"]
[docs]
def respike(smap, *, spikes=None):
"""
Re-insert "spikes" or "hot pixels" into level 1 AIA images.
Level 1 AIA images are, by default, "de-spiked"
to remove erroneously high intensity values, e.g. due to cosmic
ray hits. This function re-inserts these "spikes" back into the
image using spike location and intensity values either provided
by the user or obtained automatically from
`JSOC <http://jsoc.stanford.edu/>`_.
.. note:: This function should only be applied to level 1 images (i.e.
before calling `aiapy.calibrate.register`). If the input
image has been interpolated in any way from the original
level 1 data, the spikes will be reinserted at the wrong
locations.
.. note:: This function modifies the ``LVL_NUM``, ``NSPIKES``, and ``COMMENTS``
header keywords such that the resulting FITS header will differ
from the original file.
.. note:: If the image series of interest is large, it is advised to
obtain the spike data via JSOC externally and specify them
via the ``spikes`` keyword argument. To retrieve the coordinates
of the positions of the spikes use the function
`aiapy.calibrate.fetch_spikes`.
Parameters
----------
smap : `~sunpy.map.sources.AIAMap`
Level 1 AIA image. This can be a cutout or a full-frame image.
spikes : array-like, with shape ``(2, N)``, optional
Tuple of pixel positions of the spikes in the coordinate system of
the level 1 AIA image in ``smap`` (first entry) and original intensity
values (second entry). This can be calculated using `fetch_spikes`. If
not specified, the spike positions and intensities are automatically
queried from the JSOC.
Returns
-------
`~sunpy.map.sources.AIAMap`
A level 0.5 version of ``smap`` with the spike data re-inserted at the
appropriate pixels.
See Also
--------
`fetch_spikes`
"""
if not isinstance(smap, AIAMap):
msg = "Input must be an AIAMap."
raise TypeError(msg)
if smap.meta["lvl_num"] != 1.0:
msg = "Can only apply respike procedure to level 1 data"
raise ValueError(msg)
# Approximate check to make sure the input map has not been interpolated
# in any way. Note that the level 1 plate scales are not exactly 0.6
# ''/pixel, but should not differ by more than 0.1%. This is only a
# warning because there is no exact way of determining whether an image
# has been interpolated or not.
nominal_scale = 0.6 * u.arcsec / u.pixel
tol = 1e-3 * u.arcsec / u.pixel
if not all(u.allclose(s, nominal_scale, rtol=0, atol=tol) for s in smap.scale):
warnings.warn(
(
f"{smap.scale} is significantly different from the expected level "
"1 plate scale {nominal_scale}. If this map has been interpolated "
"in any way from the level 1 image, the spike data will likely be "
"reinserted in the incorrect pixel positions."
),
AiapyUserWarning,
stacklevel=3,
)
# FIXME: Should raise an exception? Or just return with a warning?
# Or better yet, why can't the logic below just handle the case of
# no spikes?
if smap.meta["nspikes"] == 0:
msg = "No spikes were present in the level 0 data."
raise ValueError(msg)
if spikes is None:
coords, values = fetch_spikes(smap, as_coords=False)
else:
coords, values = spikes
new_data = np.copy(smap.data)
# NOTE: the round() is needed as pixel coordinates returned by the WCS
# transformation may be very slightly off their integer values and
# casting them as int will sometimes result in an off-by-one error
new_data[coords.y.value.round().astype(int), coords.x.value.round().astype(int)] = values
new_meta = copy.deepcopy(smap.meta)
new_meta["lvl_num"] = 0.5
new_meta["comments"] = f"Respike applied; {values.shape[0]} hot pixels reinserted."
new_meta["nspikes"] = 0
return smap._new_instance( # NOQA: SLF001
new_data,
new_meta,
plot_settings=smap.plot_settings,
)
[docs]
def fetch_spikes(smap, *, as_coords=False):
"""
Returns coordinates and values of removed spikes.
Returns coordinates and values of removed spikes which were removed in a
level 1 AIA image. The locations of spikes are automatically retrieved
from the JSOC.
Parameters
----------
smap : `~sunpy.map.Map`
Level 1 AIA image. This can be a cutout or a full-disk image, but
it should be at the original level 1 resolution.
as_coords : `bool`, optional
If `True`, the pixel locations are returned as a
`~astropy.coordinates.SkyCoord` object in the projected coordinate
system of the image.
Returns
-------
`~astropy.coordinates.SkyCoord` or `~sunpy.map.mapbase.PixelPair`
Locations of the removed spikes. By default, these are represented as
pixel coordinates. If ``as_coords=True``, the locations are returned in
the projected coordinate system of the image.
array-like
Original intensity values of the spikes
"""
series = r"aia.lev1_euv_12s"
if smap.wavelength in (1600, 1700, 4500) * u.angstrom:
series = r"aia.lev1_uv_24s"
file = drms.Client().query(
f'{series}[{smap.date}/12s][WAVELNTH={smap.meta["wavelnth"]}]',
seg="spikes",
)
_, spikes = fits.open(f'http://jsoc.stanford.edu{file["spikes"][0]}')
# Loaded as floats, but they are actually integers
spikes = spikes.data.astype(np.int32)
shape_full_frame = (4096, 4096)
values = spikes[1, :]
y_coords, x_coords = np.unravel_index(spikes[0, :], shape=shape_full_frame)
# If this is a cutout, need to transform the full-frame pixel
# coordinates into the cutout pixel coordinates and then only select
# those in the FOV of the cutout
if not all(d == (s * u.pixel) for d, s in zip(smap.dimensions, shape_full_frame, strict=True)):
# Construct WCS for full frame
wcs_full_frame = copy.deepcopy(smap.wcs)
wcs_full_frame.wcs.crval = np.array([0.0, 0.0])
# NOTE: The x0_mp and y0_mp keywords denote the location of the center
# of the Sun in array coordinates (0-based), but FITS WCS indexing is
# 1-based. See Section 2.2 of
# http://jsoc.stanford.edu/~jsoc/keywords/AIA/AIA02840_K_AIA-SDO_FITS_Keyword_Document.pdf
wcs_full_frame.wcs.crpix = np.array([smap.meta["x0_mp"], smap.meta["y0_mp"]]) + 1
# Translate pixel coordinates from full-frame to cutout
x_coords, y_coords = pixel_to_pixel(wcs_full_frame, smap.wcs, x_coords, y_coords)
# Find those indices which are still in the FOV
match = np.where(
np.logical_and(
np.logical_and(x_coords >= 0, y_coords >= 0),
np.logical_and(
x_coords < smap.dimensions.x.value,
y_coords < smap.dimensions.y.value,
),
),
)
x_coords = x_coords[match]
y_coords = y_coords[match]
values = values[match]
coords = PixelPair(x_coords * u.pixel, y_coords * u.pixel)
if as_coords:
coords = smap.pixel_to_world(*coords)
return coords, values