Source code for sunpy.map.mapbase

"""
Map is a generic Map class from which all other Map classes inherit from.
"""
import re
import copy
import html
import inspect
import numbers
import textwrap
import itertools
import webbrowser
from tempfile import NamedTemporaryFile
from collections import namedtuple

import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.backend_bases import FigureCanvasBase
from matplotlib.figure import Figure

try:
    from dask.array import Array as DaskArray
    DASK_INSTALLED = True
except ImportError:
    DASK_INSTALLED = False

import astropy.units as u
import astropy.wcs
from astropy.coordinates import BaseCoordinateFrame, Longitude, SkyCoord, UnitSphericalRepresentation
from astropy.nddata import NDData
from astropy.utils.metadata import MetaData
from astropy.visualization import HistEqStretch, ImageNormalize
from astropy.visualization.wcsaxes import Quadrangle, WCSAxes

# The next two are not used but are called to register functions with external modules
import sunpy.coordinates
import sunpy.visualization.colormaps
from sunpy import config, log
from sunpy.coordinates import HeliographicCarrington, get_earth, sun
from sunpy.coordinates.utils import get_rectangle_coordinates
from sunpy.image.resample import resample as sunpy_image_resample
from sunpy.image.resample import reshape_image_to_4d_superpixel
from sunpy.image.transform import _get_transform_method, _rotation_function_names, affine_transform
from sunpy.io._file_tools import write_file
from sunpy.io._fits import extract_waveunit, header_to_fits
from sunpy.map.maputils import _clip_interval, _handle_norm
from sunpy.sun import constants
from sunpy.time import is_time, parse_time
from sunpy.util import MetaDict, expand_list
from sunpy.util.decorators import (
    ACTIVE_CONTEXTS,
    add_common_docstring,
    cached_property_based_on,
    check_arithmetic_compatibility,
    deprecate_positional_args_since,
    deprecated,
)
from sunpy.util.exceptions import warn_metadata, warn_user
from sunpy.util.functools import seconddispatch
from sunpy.util.util import _figure_to_base64, fix_duplicate_notes
from sunpy.visualization import axis_labels_from_ctype, peek_show, wcsaxes_compat
from sunpy.visualization.colormaps import cm as sunpy_cm

TIME_FORMAT = config.get("general", "time_format")
PixelPair = namedtuple('PixelPair', 'x y')
SpatialPair = namedtuple('SpatialPair', 'axis1 axis2')
_NUMPY_COPY_IF_NEEDED = False if np.__version__.startswith("1.") else None
_META_FIX_URL = 'https://docs.sunpy.org/en/stable/how_to/fix_map_metadata.html'

# Manually specify the ``.meta`` docstring. This is assigned to the .meta
# class attribute in GenericMap.__init__()
_meta_doc = """
The map metadata.

This is used to interpret the map data. It may
have been modified from the original metadata by sunpy. See the
`~sunpy.util.MetaDict.added_items`, `~sunpy.util.MetaDict.removed_items`
and `~sunpy.util.MetaDict.modified_items` properties of MetaDict
to query how the metadata has been modified.
"""

# The notes live here so we can reuse it in the source maps
_notes_doc = """

Notes
-----

A number of the properties of this class are returned as two-value named
tuples that can either be indexed by position ([0] or [1]) or be accessed
by the names (.x and .y) or (.axis1 and .axis2). Things that refer to pixel
axes use the ``.x``, ``.y`` convention, where x and y refer to the FITS
axes (x for columns y for rows). Spatial axes use ``.axis1`` and ``.axis2``
which correspond to the first and second axes in the header. ``axis1``
corresponds to the coordinate axis for ``x`` and ``axis2`` corresponds to
``y``.

This class assumes that the metadata adheres to the FITS 4 standard.
Where the CROTA2 metadata is provided (without PC_ij) it assumes a conversion
to the standard PC_ij described in section 6.1 of .
`Calabretta & Greisen (2002) <https://doi.org/10.1051/0004-6361:20021327>`_

.. warning::
    If a header has CD_ij values but no PC_ij values, CDELT values are required
    for this class to construct the WCS.
    If a file with more than two dimensions is feed into the class,
    only the first two dimensions (NAXIS1, NAXIS2) will be loaded and the
    rest will be discarded.
"""

__all__ = ['GenericMap', 'MapMetaValidationError', 'PixelPair']


[docs] class MapMetaValidationError(AttributeError): pass
[docs] class GenericMap(NDData): """ A Generic spatially-aware 2D data array Parameters ---------- data : `numpy.ndarray`, list A 2d list or ndarray containing the map data. header : dict A dictionary of the original image header tags. plot_settings : dict, optional Plot settings. Other Parameters ---------------- **kwargs : Additional keyword arguments are passed to `~astropy.nddata.NDData` init. Examples -------- >>> import sunpy.map >>> import sunpy.data.sample # doctest: +REMOTE_DATA >>> aia = sunpy.map.Map(sunpy.data.sample.AIA_171_IMAGE) # doctest: +REMOTE_DATA >>> aia # doctest: +REMOTE_DATA <sunpy.map.sources.sdo.AIAMap object at ...> SunPy Map --------- Observatory: SDO Instrument: AIA 3 Detector: AIA Measurement: 171.0 Angstrom Wavelength: 171.0 Angstrom Observation Date: 2011-06-07 06:33:02 Exposure Time: 0.234256 s Dimension: [1024. 1024.] pix Coordinate System: helioprojective Scale: [2.402792 2.402792] arcsec / pix Reference Pixel: [511.5 511.5] pix Reference Coord: [3.22309951 1.38578135] arcsec array([[ -95.92475 , 7.076416 , -1.9656711, ..., -127.96519 , -127.96519 , -127.96519 ], [ -96.97533 , -5.1167884, 0. , ..., -98.924576 , -104.04137 , -127.919716 ], [ -93.99607 , 1.0189276, -4.0757103, ..., -5.094638 , -37.95505 , -127.87541 ], ..., [-128.01454 , -128.01454 , -128.01454 , ..., -128.01454 , -128.01454 , -128.01454 ], [-127.899666 , -127.899666 , -127.899666 , ..., -127.899666 , -127.899666 , -127.899666 ], [-128.03072 , -128.03072 , -128.03072 , ..., -128.03072 , -128.03072 , -128.03072 ]], dtype=float32) >>> aia.spatial_units # doctest: +REMOTE_DATA SpatialPair(axis1=Unit("arcsec"), axis2=Unit("arcsec")) >>> aia.peek() # doctest: +SKIP """ _registry = dict() # This overrides the default doc for the meta attribute meta = MetaData(doc=_meta_doc, copy=False) # Enabling the GenericMap reflected operators is a bit subtle. The GenericMap # reflected operator will be used only if the Quantity non-reflected operator # returns NotImplemented. The Quantity operator strips the unit from the # Quantity and tries to combine the value with the GenericMap using NumPy's # __array_ufunc__(). If NumPy believes that it can proceed, this will result # in an error. We explicitly set __array_ufunc__ = None so that the NumPy # call, and consequently the Quantity operator, will return NotImplemented. __array_ufunc__ = None def __init_subclass__(cls, **kwargs): """ An __init_subclass__ hook initializes all of the subclasses of a given class. So for each subclass, it will call this block of code on import. This replicates some metaclass magic without the need to be aware of metaclasses. Here we use this to register each subclass in a dict that has the ``is_datasource_for`` attribute. This is then passed into the Map Factory so we can register them. """ super().__init_subclass__(**kwargs) if cls.__doc__ is None: # Set an empty string, to prevent an error adding None to str in the next line cls.__doc__ = '' cls.__doc__ = fix_duplicate_notes(_notes_doc, cls.__doc__) if hasattr(cls, 'is_datasource_for'): # NOTE: This conditional is due to overlapping map sources in sunpy and pfsspy that # lead to a MultipleMatchError if sunpy.map and pfsspy.map are imported. # See https://github.com/sunpy/sunpy/issues/7294 for more information. # This also applies to older versions of sunkit-magex with ADAPTMap. if f'{cls.__module__}.{cls.__name__}' not in ["pfsspy.map.GongSynopticMap", "sunkit_magex.pfss.map.ADAPTMap"]: cls._registry[cls] = cls.is_datasource_for def __init__(self, data, header, plot_settings=None, **kwargs): # If the data has more than two dimensions, the first dimensions # (NAXIS1, NAXIS2) are used and the rest are discarded. ndim = data.ndim if ndim > 2: # We create a slice that removes all but the 'last' two # dimensions. (Note dimensions in ndarray are in reverse order) new_2d_slice = [0]*(ndim-2) new_2d_slice.extend([slice(None), slice(None)]) data = data[tuple(new_2d_slice)] # Warn the user that the data has been truncated warn_user("This file contains more than 2 dimensions. " "Data will be truncated to the first two dimensions.") params = list(inspect.signature(NDData).parameters) nddata_kwargs = {x: kwargs.pop(x) for x in params & kwargs.keys()} super().__init__(data, meta=MetaDict(header), **nddata_kwargs) # Setup some attributes self._nickname = None # These are placeholders for default attributes, which are only set # once if their data isn't present in the map metadata. self._default_time = None self._default_dsun = None self._default_carrington_longitude = None self._default_heliographic_latitude = None self._default_heliographic_longitude = None # Validate header # TODO: This should be a function of the header, not of the map self._validate_meta() self.plot_settings = {'cmap': 'gray', 'interpolation': 'nearest', 'origin': 'lower' } if self.dtype != np.uint8: # Put import here to reduce sunpy.map import time from matplotlib import colors self.plot_settings['norm'] = colors.Normalize() if plot_settings: self.plot_settings.update(plot_settings) # Try and set the colormap. This is not always possible if this method # is run before map sources fix some of their metadata, so # just ignore any exceptions raised. try: cmap = self._get_cmap_name() if cmap in sunpy_cm.cmlist: self.plot_settings['cmap'] = cmap except Exception: pass def __getitem__(self, key): """ This should allow indexing by physical coordinate """ raise NotImplementedError( "The ability to index Map by physical" " coordinate is not yet implemented.") def _text_summary(self): dt = self.exposure_time wave = self.wavelength measurement = self.measurement dt = 'Unknown' if dt is None else dt wave = 'Unknown' if wave is None else wave measurement = 'Unknown' if measurement is None else measurement return textwrap.dedent("""\ SunPy Map --------- Observatory:\t\t {obs} Instrument:\t\t {inst} Detector:\t\t {det} Measurement:\t\t {meas} Wavelength:\t\t {wave} Observation Date:\t {date} Exposure Time:\t\t {dt} Dimension:\t\t {dim} Coordinate System:\t {coord} Scale:\t\t\t {scale} Reference Pixel:\t {refpix} Reference Coord:\t {refcoord}\ """).format(obs=self.observatory, inst=self.instrument, det=self.detector, meas=measurement, wave=wave, date=self.date.strftime(TIME_FORMAT), dt=dt, dim=u.Quantity(self.dimensions), scale=u.Quantity(self.scale), coord=self._coordinate_frame_name, refpix=u.Quantity(self.reference_pixel), refcoord=u.Quantity((self._reference_longitude, self._reference_latitude)), tmf=TIME_FORMAT) def __str__(self): return f"{self._text_summary()}\n{self.data.__repr__()}" def __repr__(self): return f"{object.__repr__(self)}\n{self}" def _repr_html_(self, compute_dask=False): """ Produce an HTML summary with plots for use in Jupyter notebooks. """ # Convert the text repr to an HTML table partial_html = self._text_summary()[20:].replace('\n', '</td></tr><tr><th>')\ .replace(':\t', '</th><td>') text_to_table = textwrap.dedent(f"""\ <table style='text-align:left'> <tr><th>{partial_html}</td></tr> </table>""").replace('\n', '') # Handle bad values (infinite and NaN) in the data array finite_data = self.data[np.isfinite(self.data)] count_nan = np.isnan(self.data).sum() count_inf = np.isinf(self.data).sum() if DASK_INSTALLED and isinstance(finite_data, DaskArray): # This will fetch the entire data array into memory and only happens for the quicklook method if compute_dask: finite_data = finite_data.compute() else: dask_html = self.data._repr_html_() return textwrap.dedent(f"""\ <pre>{html.escape(object.__repr__(self))}</pre> <table> <tr> <td>{text_to_table}</td> <td> {dask_html} </td> </tr> <tr> </tr> </table>""") # Assemble an informational string with the counts of bad pixels bad_pixel_text = "" if count_nan + count_inf > 0: bad_pixel_text = "Bad pixels are shown in red: " text_list = [] if count_nan > 0: text_list.append(f"{count_nan} NaN") if count_inf > 0: text_list.append(f"{count_inf} infinite") bad_pixel_text += ", ".join(text_list) # Use a grayscale colormap with histogram equalization (and red for bad values) # Make a copy of the colormap to avoid modifying the matplotlib instance when # doing set_bad() (copy not needed when min mpl is 3.5, as already a copy) cmap = copy.copy(matplotlib.colormaps['gray']) cmap.set_bad(color='red') norm = ImageNormalize(stretch=HistEqStretch(finite_data)) # Plot the image in pixel space fig = Figure(figsize=(5.2, 4.8)) # Figure instances in matplotlib<3.1 do not create a canvas by default if fig.canvas is None: FigureCanvasBase(fig) ax = fig.subplots() ax.imshow(self.data, origin='lower', interpolation='nearest', cmap=cmap, norm=norm) ax.set_xlabel('X pixel') ax.set_ylabel('Y pixel') ax.set_title('In pixel space') pixel_src = _figure_to_base64(fig) bounds = ax.get_position().bounds # save these axes bounds for later use # Plot the image using WCS information, with the same axes bounds as above fig = Figure(figsize=(5.2, 4.8)) # Figure instances in matplotlib<3.1 do not create a canvas by default if fig.canvas is None: FigureCanvasBase(fig) # Create the WCSAxes manually because we need to avoid using pyplot ax = WCSAxes(fig, bounds, aspect='equal', wcs=self.wcs) fig.add_axes(ax) self.plot(axes=ax, cmap=cmap, norm=norm) ax.set_title('In coordinate space using WCS information') wcs_src = _figure_to_base64(fig) # Plot the histogram of pixel values fig = Figure(figsize=(4.8, 2.4), constrained_layout=True) # Figure instances in matplotlib<3.1 do not create a canvas by default if fig.canvas is None: FigureCanvasBase(fig) ax = fig.subplots() values, bins, patches = ax.hist(finite_data.ravel(), bins=100) norm_centers = norm(0.5 * (bins[:-1] + bins[1:])).data for c, p in zip(norm_centers, patches): plt.setp(p, "facecolor", cmap(c)) ax.plot(np.array([bins[:-1], bins[1:]]).T.ravel(), np.array([values, values]).T.ravel()) ax.set_facecolor('white') ax.semilogy() # Explicitly set the power limits for the X axis formatter to avoid text overlaps ax.xaxis.get_major_formatter().set_powerlimits((-3, 4)) ax.set_xlabel(f"Pixel value{' (' + str(self.unit) + ')' if self.unit else ''} in linear bins") ax.set_ylabel('# of pixels') ax.set_title('Distribution of pixel values [click for cumulative]') hist_src = _figure_to_base64(fig) # Plot the CDF of the pixel values using a symmetric-log horizontal scale fig = Figure(figsize=(4.8, 2.4), constrained_layout=True) # TODO: Figure instances in matplotlib<3.1 do not create a canvas by default if fig.canvas is None: FigureCanvasBase(fig) ax = fig.subplots() n_bins = 256 bins = norm.inverse(np.arange(n_bins + 1) / n_bins) values, _, patches = ax.hist(finite_data.ravel(), bins=bins, cumulative=True) for i, p in enumerate(patches): plt.setp(p, "facecolor", cmap((i + 0.5) / n_bins)) ax.plot(np.array([bins[:-1], bins[1:]]).T.ravel(), np.array([values, values]).T.ravel()) ax.set_facecolor('white') ax.set_xscale('symlog') ax.set_yscale('log') ax.set_xlabel(f"Pixel value{' (' + str(self.unit) + ')' if self.unit else ''} in equalized bins") ax.set_ylabel('Cumulative # of pixels') ax.set_title('Cumulative distribution of pixel values') cdf_src = _figure_to_base64(fig) return textwrap.dedent(f"""\ <pre>{html.escape(object.__repr__(self))}</pre> <table> <tr> <td>{text_to_table}</td> <td rowspan=3> <div align=center> Image colormap uses histogram equalization<br> Click on the image to toggle between units </div> <img src='data:image/png;base64,{wcs_src}' src2='data:image/png;base64,{pixel_src}' onClick='var temp = this.src; this.src = this.getAttribute("src2"); this.setAttribute("src2", temp)' /> <div align=center> {bad_pixel_text} </div> </td> </tr> <tr> </tr> <tr> <td><img src='data:image/png;base64,{hist_src}' src2='data:image/png;base64,{cdf_src}' onClick='var temp = this.src; this.src = this.getAttribute("src2"); this.setAttribute("src2", temp)' /> </td> </tr> </table>""")
[docs] def quicklook(self): """ Display a quicklook summary of the Map instance using the default web browser. Notes ----- The image colormap uses `histogram equalization <https://en.wikipedia.org/wiki/Histogram_equalization>`__. Clicking on the image to switch between pixel space and coordinate space requires Javascript support to be enabled in the web browser. Examples -------- >>> from sunpy.map import Map >>> import sunpy.data.sample # doctest: +REMOTE_DATA >>> smap = Map(sunpy.data.sample.AIA_171_IMAGE) # doctest: +REMOTE_DATA >>> smap.quicklook() # doctest: +SKIP (which will open the following content in the default web browser) .. generate:: html :html_border: from sunpy.map import Map import sunpy.data.sample smap = Map(sunpy.data.sample.AIA_171_IMAGE) print(smap._repr_html_()) """ with NamedTemporaryFile('w', delete=False, prefix='sunpy.map.', suffix='.html') as f: url = 'file://' + f.name f.write(textwrap.dedent(f"""\ <html> <title>Quicklook summary for {html.escape(object.__repr__(self))}</title> <body>{self._repr_html_(compute_dask=True)}</body> </html>""")) webbrowser.open_new_tab(url)
@classmethod def _new_instance(cls, data, meta, plot_settings=None, **kwargs): """ Instantiate a new instance of this class using given data. This is a shortcut for ``type(self)(data, meta, plot_settings)``. """ new_map = cls(data, meta, **kwargs) # plot_settings are set explicitly here as some map sources # explicitly set some of the plot_settings in the constructor # and we want to preserve the plot_settings of the previous # instance. if plot_settings is not None: new_map.plot_settings.update(plot_settings) return new_map def _get_lon_lat(self, frame): """ Given a coordinate frame, extract the lon and lat by casting to SphericalRepresentation first. """ r = frame.represent_as(UnitSphericalRepresentation) return r.lon.to(self.spatial_units[0]), r.lat.to(self.spatial_units[1]) @property def quantity(self): """Unitful representation of the map data.""" return u.Quantity(self.data, self.unit, copy=_NUMPY_COPY_IF_NEEDED) def _new_instance_from_op(self, new_data): """ Helper function for creating new map instances after arithmetic operations. """ new_meta = copy.deepcopy(self.meta) new_meta['bunit'] = new_data.unit.to_string() return self._new_instance(new_data.value, new_meta, plot_settings=self.plot_settings) def __neg__(self): return self._new_instance(-self.data, self.meta, plot_settings=self.plot_settings) @check_arithmetic_compatibility def __pow__(self, value): new_data = self.quantity ** value return self._new_instance_from_op(new_data) @check_arithmetic_compatibility def __add__(self, value): new_data = self.quantity + value return self._new_instance_from_op(new_data) def __radd__(self, value): return self.__add__(value) def __sub__(self, value): return self.__add__(-value) def __rsub__(self, value): return self.__neg__().__add__(value) @check_arithmetic_compatibility def __mul__(self, value): new_data = self.quantity * value return self._new_instance_from_op(new_data) def __rmul__(self, value): return self.__mul__(value) @check_arithmetic_compatibility def __truediv__(self, value): return self.__mul__(1/value) @check_arithmetic_compatibility def __rtruediv__(self, value): new_data = value / self.quantity return self._new_instance_from_op(new_data) @property def _meta_hash(self): return self.meta.item_hash() def _set_symmetric_vmin_vmax(self): """ Set symmetric vmin and vmax about zero """ threshold = np.nanmax(abs(self.data)) self.plot_settings['norm'].vmin = -threshold self.plot_settings['norm'].vmax = threshold @property @cached_property_based_on('_meta_hash') def wcs(self): """ The `~astropy.wcs.WCS` property of the map. Notes ----- ``dateobs`` is always populated with the "canonical" observation time as provided by the `.date` property. This will commonly be the DATE-OBS key if it is in the metadata, but see that property for the logic otherwise. ``dateavg`` is always populated with the reference date of the coordinate system as provided by the `.reference_date` property. This will commonly be the DATE-AVG key if it is in the metadata, but see that property for the logic otherwise. ``datebeg`` is conditonally populated with the start of the observation period as provided by the `.date_start` property, which normally returns a value only if the DATE-BEG key is in the metadata. ``dateend`` is conditonally populated with the end of the observation period as provided by the `.date_end` property, which normally returns a value only if the DATE-END key is in the metadata. """ w2 = astropy.wcs.WCS(naxis=2) # Add one to go from zero-based to one-based indexing w2.wcs.crpix = u.Quantity(self.reference_pixel) + 1 * u.pix # Make these a quantity array to prevent the numpy setting element of # array with sequence error. # Explicitly call ``.to()`` to check that scale is in the correct units w2.wcs.cdelt = u.Quantity([self.scale[0].to(self.spatial_units[0] / u.pix), self.scale[1].to(self.spatial_units[1] / u.pix)]) w2.wcs.crval = u.Quantity([self._reference_longitude, self._reference_latitude]) w2.wcs.ctype = self.coordinate_system w2.wcs.pc = self.rotation_matrix w2.wcs.set_pv(self._pv_values) # FITS standard doesn't allow both PC_ij *and* CROTA keywords w2.wcs.crota = (0, 0) w2.wcs.cunit = self.spatial_units w2.wcs.aux.rsun_ref = self.rsun_meters.to_value(u.m) w2.wcs.dateobs = self.date.utc.isot w2.wcs.dateavg = self.reference_date.utc.isot if self.date_start is not None: w2.wcs.datebeg = self.date_start.utc.isot if self.date_end is not None: w2.wcs.dateend = self.date_end.utc.isot # Set observer coordinate information except when we know it is not appropriate (e.g., HGS) sunpy_frame = sunpy.coordinates.wcs_utils._sunpy_frame_class_from_ctypes(w2.wcs.ctype) if sunpy_frame is None or hasattr(sunpy_frame, 'observer'): # Clear all the aux information that was set earlier. This is to avoid # issues with maps that store multiple observer coordinate keywords. # Note that we have to create a new WCS as it's not possible to modify # wcs.wcs.aux in place. header = w2.to_header() for kw in ['crln_obs', 'dsun_obs', 'hgln_obs', 'hglt_obs']: header.pop(kw, None) w2 = astropy.wcs.WCS(header) # Get observer coord, and set the aux information obs_coord = self.observer_coordinate sunpy.coordinates.wcs_utils._set_wcs_aux_obs_coord(w2, obs_coord) # Set the shape of the data array w2.array_shape = self.data.shape # Validate the WCS here. w2.wcs.set() return w2 @property def coordinate_frame(self): """ An `astropy.coordinates.BaseCoordinateFrame` instance created from the coordinate information for this Map, or None if the frame cannot be determined. Notes ----- The ``obstime`` for the coordinate frame uses the `.reference_date` property, which may be different from the `.date` property. """ try: return astropy.wcs.utils.wcs_to_celestial_frame(self.wcs) except ValueError as e: warn_user(f'Could not determine coordinate frame from map metadata.\n{e}') return None @property def _coordinate_frame_name(self): if self.coordinate_frame is None: return 'Unknown' return self.coordinate_frame.name def _as_mpl_axes(self): """ Compatibility hook for Matplotlib and WCSAxes. This functionality requires the WCSAxes package to work. The reason we include this here is that it allows users to use WCSAxes without having to explicitly import WCSAxes With this method, one can do:: import matplotlib.pyplot as plt import sunpy.map amap = sunpy.map.Map('filename.fits') fig = plt.figure() ax = plt.subplot(projection=amap) ... and this will generate a plot with the correct WCS coordinates on the axes. See <https://docs.astropy.org/en/stable/visualization/wcsaxes/index.html> for more information. """ # This code is reused from Astropy return WCSAxes, {'wcs': self.wcs} # Some numpy extraction @property def dimensions(self): """ The dimensions of the array (x axis first, y axis second). """ return PixelPair(*u.Quantity(np.flipud(self.data.shape), 'pixel')) @property def dtype(self): """ The `numpy.dtype` of the array of the map. """ return self.data.dtype @property def ndim(self): """ The value of `numpy.ndarray.ndim` of the data array of the map. """ return self.data.ndim
[docs] def std(self, *args, **kwargs): """ Calculate the standard deviation of the data array, ignoring NaNs. """ return np.nanstd(self.data, *args, **kwargs)
[docs] def mean(self, *args, **kwargs): """ Calculate the mean of the data array, ignoring NaNs. """ return np.nanmean(self.data, *args, **kwargs)
[docs] def min(self, *args, **kwargs): """ Calculate the minimum value of the data array, ignoring NaNs. """ return np.nanmin(self.data, *args, **kwargs)
[docs] def max(self, *args, **kwargs): """ Calculate the maximum value of the data array, ignoring NaNs. """ return np.nanmax(self.data, *args, **kwargs)
@staticmethod def _parse_fits_unit(unit_str): replacements = {'gauss': 'G', 'counts / pixel': 'ct/pix',} if unit_str.lower() in replacements: unit_str = replacements[unit_str.lower()] unit = u.Unit(unit_str, format='fits', parse_strict='silent') if isinstance(unit, u.UnrecognizedUnit): unit = u.Unit(unit_str, parse_strict='silent') # NOTE: Special case DN here as it is not part of the FITS standard, but # is widely used and is also a recognized astropy unit if u.DN not in unit.bases: warn_metadata(f'Could not parse unit string "{unit_str}" as a valid FITS unit.\n' f'See {_META_FIX_URL} for how to fix metadata before loading it ' 'with sunpy.map.Map.\n' 'See https://fits.gsfc.nasa.gov/fits_standard.html for ' 'the FITS unit standards.') unit = None return unit @property def unit(self): """ Unit of the map data. This is taken from the 'BUNIT' FITS keyword. If no 'BUNIT' entry is present in the metadata then this returns `None`. If the 'BUNIT' value cannot be parsed into a unit a warning is raised, and `None` returned. """ unit_str = self.meta.get('bunit', None) if unit_str is None: return return self._parse_fits_unit(unit_str) # #### Keyword attribute and other attribute definitions #### # def _base_name(self): """Abstract the shared bit between name and latex_name""" if self.measurement is None: format_str = "{nickname} {date}" else: format_str = "{nickname} {{measurement}} {date}" return format_str.format(nickname=self.nickname, date=parse_time(self.date).strftime(TIME_FORMAT)) @property def name(self): """Human-readable description of the Map.""" return self._base_name().format(measurement=self.measurement) @property def latex_name(self): """LaTeX formatted description of the Map.""" if isinstance(self.measurement, u.Quantity): return self._base_name().format(measurement=self.measurement._repr_latex_()) else: return self.name @property def nickname(self): """An abbreviated human-readable description of the map-type; part of the Helioviewer data model.""" return self._nickname if self._nickname else self.detector @nickname.setter def nickname(self, n): self._nickname = n def _get_date(self, key): time = self.meta.get(key, None) if not time: return # Get the time scale if 'TAI' in time: # SDO specifies the 'TAI' scale in their time string, which is parsed # by parse_time(). If a different timescale is also present, warn the # user that it will be ignored. timesys = 'TAI' timesys_meta = self.meta.get('timesys', '').upper() if timesys_meta not in ('', 'TAI'): warn_metadata('Found "TAI" in time string, ignoring TIMESYS keyword ' f'which is set to "{timesys_meta}".') else: timesys = self._timesys return parse_time(time, scale=timesys.lower()) @property def _timesys(self): """ Time system. """ # UTC is the FITS standard default return self.meta.get('timesys', 'UTC') @property def date_start(self): """ Time of the beginning of the image acquisition. Taken from the DATE-BEG FITS keyword. """ return self._get_date('date-beg') @property def date_end(self): """ Time of the end of the image acquisition. Taken from the DATE-END FITS keyword. """ return self._get_date('date-end') @property def date_average(self): """ Average time of the image acquisition. Taken from the DATE-AVG FITS keyword if present, otherwise halfway between `date_start` and `date_end` if both pieces of metadata are present. """ avg = self._get_date('date-avg') if avg is None: start, end = self.date_start, self.date_end if start is not None and end is not None: avg = start + (end - start) / 2 return avg @property def _date_obs(self): # Get observation date from date-obs, falling back to date_obs time = self._get_date('date-obs') if is_time(self.meta.get('date_obs', None)): time = time or self._get_date('date_obs') return time @property def reference_date(self): """ The reference date for the coordinate system. This date is used to define the ``obstime`` of the coordinate frame and often the ``obstime`` of the observer. Be aware that this date can be different from the "canonical" observation time (see the `.GenericMap.date` property). The reference date is determined using this order of preference: 1. The ``DATE-AVG`` key in the meta. 2. The ``DATE-OBS`` key in the meta. 3. The ``DATE-BEG`` key in the meta. 4. The ``DATE-END`` key in the meta. 5. The `.GenericMap.date` property as a fallback (which, if not overridden, would be the current time if the above keywords are missing). See Also -------- date : The observation time. date_start : The start time of the observation. date_end : The end time of the observation. date_average : The average time of the observation. Notes ----- The FITS standard implies that, but does not expressly require, the DATE-AVG keyword to be the reference date. """ return ( self._get_date('date-avg') or self._get_date('date-obs') or self._get_date('date-beg') or self._get_date('date-end') or self.date ) def _set_reference_date(self, date): """ Set the reference date using the same priority as `.GenericMap.reference_date`. If a source subclass overrides `.GenericMap.reference_date`, it should override this private method as well. """ for keyword in ['date-avg', 'date-obs', 'date-beg', 'date-end']: if keyword in self.meta: self.meta[keyword] = parse_time(date).utc.isot return self._set_date(date) @property def date(self): """ The observation time. This time is the "canonical" way to refer to an observation, which is commonly the start of the observation, but can be a different time. In comparison, the `.GenericMap.date_start` property is unambigiously the start of the observation. The observation time is determined using this order of preference: 1. The ``DATE-OBS`` or ``DATE_OBS`` FITS keywords 2. `.GenericMap.date_start` 3. `.GenericMap.date_average` 4. `.GenericMap.date_end` 5. The current time See Also -------- reference_date : The reference date for the the coordinate system date_start : The start time of the observation. date_end : The end time of the observation. date_average : The average time of the observation. """ time = ( self._date_obs or self.date_start or self.date_average or self.date_end ) if time is None: if self._default_time is None: warn_metadata("Missing metadata for observation time, " "setting observation time to current time. " "Set the 'DATE-OBS' FITS keyword to prevent this warning.") self._default_time = parse_time('now') time = self._default_time return time def _set_date(self, date): """ Set the observation time by setting DATE-OBS. If a source subclass overrides `.GenericMap.date`, it should override this private method as well. Notes ----- This method will additionally always remove DATE_OBS (note the underscore), if present. """ if 'date_obs' in self.meta: del self.meta['date_obs'] self.meta['date-obs'] = parse_time(date).utc.isot @property def detector(self): """ Detector name. This is taken from the 'DETECTOR' FITS keyword. """ return self.meta.get('detector', "") @property def timeunit(self): """ The `~astropy.units.Unit` of the exposure time of this observation. Taken from the "TIMEUNIT" FITS keyword, and defaults to seconds (as per) the FITS standard). """ return u.Unit(self.meta.get('timeunit', 's')) @property def exposure_time(self): """ Exposure time of the image. This is taken from the 'XPOSURE' keyword or the 'EXPTIME' FITS keyword, in that order. """ exptime = self.meta.get('xposure') or self.meta.get('exptime') if exptime is not None: return exptime * self.timeunit @property def instrument(self): """Instrument name.""" return self.meta.get('instrume', "").replace("_", " ") @property def measurement(self): """ Measurement wavelength. This is taken from the 'WAVELNTH' FITS keywords. If the keyword is not present, defaults to `None`. If 'WAVEUNIT' keyword isn't present, defaults to dimensionless units. """ return self.wavelength @property def waveunit(self): """ The `~astropy.units.Unit` of the wavelength of this observation. This is taken from the 'WAVEUNIT' FITS keyword. If the keyword is not present, defaults to `None` """ if 'waveunit' in self.meta: return u.Unit(self.meta['waveunit']) else: wunit = extract_waveunit(self.meta) if wunit is not None: return u.Unit(wunit) @property def wavelength(self): """ Wavelength of the observation. This is taken from the 'WAVELNTH' FITS keywords. If the keyword is not present, defaults to `None`. If 'WAVEUNIT' keyword isn't present, defaults to dimensionless units. """ if 'wavelnth' in self.meta: return u.Quantity(self.meta['wavelnth'], self.waveunit) @property def observatory(self): """ Observatory or Telescope name. This is taken from the 'OBSRVTRY' FITS keyword. """ return self.meta.get('obsrvtry', self.meta.get('telescop', "")).replace("_", " ") @property def processing_level(self): """ Returns the FITS processing level if present. This is taken from the 'LVL_NUM' FITS keyword. """ return self.meta.get('lvl_num', None) @property def bottom_left_coord(self): """ The physical coordinate at the center of the bottom left ([0, 0]) pixel. """ return self.wcs.pixel_to_world(0, 0) @property def top_right_coord(self): """ The physical coordinate at the center of the the top right ([-1, -1]) pixel. """ top_right = np.array([self.dimensions.x.value, self.dimensions.y.value]) - 1 return self.wcs.pixel_to_world(*top_right) @property def center(self): """ Return a coordinate object for the center pixel of the array. If the array has an even number of pixels in a given dimension, the coordinate returned lies on the edge between the two central pixels. """ center = (np.array([self.dimensions.x.value, self.dimensions.y.value]) - 1) / 2. return self.wcs.pixel_to_world(*center)
[docs] @u.quantity_input def shift_reference_coord(self, axis1: u.deg, axis2: u.deg): """ Returns a map shifted by a specified amount to, for example, correct for a bad map location. These values are applied directly to the `~sunpy.map.GenericMap.reference_coordinate`. To check how much the reference coordinate has been modified, see ``sunpy.map.GenericMap.meta.modified_items['CRVAL1']`` and ``sunpy.map.GenericMap.meta.modified_items['CRVAL2']``. Parameters ---------- axis1 : `~astropy.units.Quantity` The shift to apply to the Longitude (solar-x) coordinate. axis2 : `~astropy.units.Quantity` The shift to apply to the Latitude (solar-y) coordinate Returns ------- out : `~sunpy.map.GenericMap` or subclass A new shifted Map. """ new_meta = self.meta.copy() # Update crvals new_meta['crval1'] = ((self._reference_longitude + axis1).to(self.spatial_units[0])).value new_meta['crval2'] = ((self._reference_latitude + axis2).to(self.spatial_units[1])).value # Create new map with the modification new_map = self._new_instance(self.data, new_meta, self.plot_settings) return new_map
def _rsun_meters(self, dsun=None): """ This property exists to avoid circular logic in constructing the observer coordinate, by allowing a custom 'dsun' to be specified, instead of one extracted from the `.observer_coordinate` property. """ rsun = self.meta.get('rsun_ref', None) if rsun is not None: return rsun * u.m elif self._rsun_obs_no_default is not None: if dsun is None: dsun = self.dsun return sun._radius_from_angular_radius(self.rsun_obs, dsun) else: log.info("Missing metadata for solar radius: assuming " "the standard radius of the photosphere.") return constants.radius @property def rsun_meters(self): """ Assumed radius of observed emission from the Sun center. This is taken from the RSUN_REF FITS keyword, if present. If not, and angular radius metadata is present, it is calculated from `~sunpy.map.GenericMap.rsun_obs` and `~sunpy.map.GenericMap.dsun`. If neither pieces of metadata are present, defaults to the standard photospheric radius. """ return self._rsun_meters() @property def _rsun_obs_no_default(self): """ Get the angular radius value from FITS keywords without defaulting. Exists to avoid circular logic in `rsun_meters()` above. """ return self.meta.get('rsun_obs', self.meta.get('solar_r', self.meta.get('radius', None))) @property def rsun_obs(self): """ Angular radius of the observation from Sun center. This value is taken (in order of preference) from the 'RSUN_OBS', 'SOLAR_R', or 'RADIUS' FITS keywords. If none of these keys are present, the angular radius is calculated from `~sunpy.map.GenericMap.rsun_meters` and `~sunpy.map.GenericMap.dsun`. """ rsun_arcseconds = self._rsun_obs_no_default if rsun_arcseconds is not None: return rsun_arcseconds * u.arcsec else: return sun._angular_radius(self.rsun_meters, self.dsun) @property def coordinate_system(self): """ Coordinate system used for x and y axes (ctype1/2). If not present, defaults to (HPLN-TAN, HPLT-TAN), and emits a warning. """ ctype1 = self.meta.get('ctype1', None) if not ctype1: warn_metadata("Missing CTYPE1 from metadata, assuming CTYPE1 is HPLN-TAN") ctype1 = 'HPLN-TAN' ctype2 = self.meta.get('ctype2', None) if not ctype2: warn_metadata("Missing CTYPE2 from metadata, assuming CTYPE2 is HPLT-TAN") ctype2 = 'HPLT-TAN' # Astropy WCS does not understand the SOHO default of "solar-x" and # "solar-y" ctypes. This overrides the default assignment and # changes it to a ctype that is understood. See Thompson, 2006, A.&A., # 449, 791. if ctype1.lower() in ("solar-x", "solar_x"): ctype1 = 'HPLN-TAN' if ctype2.lower() in ("solar-y", "solar_y"): ctype2 = 'HPLT-TAN' return SpatialPair(ctype1, ctype2) @property def _supported_observer_coordinates(self): """ A list of supported coordinate systems. This is a list so it can easily maintain a strict order. The list of two element tuples, the first item in the tuple is the keys that need to be in the header to use this coordinate system and the second is the kwargs to SkyCoord. """ return [(('hgln_obs', 'hglt_obs', 'dsun_obs'), {'lon': self.meta.get('hgln_obs'), 'lat': self.meta.get('hglt_obs'), 'radius': self.meta.get('dsun_obs'), 'unit': (u.deg, u.deg, u.m), 'frame': "heliographic_stonyhurst"}), (('crln_obs', 'crlt_obs', 'dsun_obs'), {'lon': self.meta.get('crln_obs'), 'lat': self.meta.get('crlt_obs'), 'radius': self.meta.get('dsun_obs'), 'unit': (u.deg, u.deg, u.m), 'frame': "heliographic_carrington"}), ] @property def _default_observer_coordinate(self): """ The default observer coordinate to use when there is insufficient information in the metadata. This should be overridden by map sources as appropriate. """ def _remove_existing_observer_location(self): """ Remove all keys that this map might use for observer location. """ all_keys = expand_list([e[0] for e in self._supported_observer_coordinates]) for key in all_keys: self.meta.pop(key) @property @cached_property_based_on('_meta_hash') def observer_coordinate(self): """ The Heliographic Stonyhurst Coordinate of the observer. Notes ----- The ``obstime`` for this coordinate uses the `.reference_date` property, which may be different from the `.date` property. """ warning_message = [] for keys, kwargs in self._supported_observer_coordinates: missing_keys = set(keys) - self.meta.keys() if not missing_keys: sc = SkyCoord(obstime=self.reference_date, **kwargs) # If the observer location is supplied in Carrington coordinates, # the coordinate's `observer` attribute should be set to "self" if isinstance(sc.frame, HeliographicCarrington): sc.frame._observer = "self" sc = sc.heliographic_stonyhurst # We set rsun after constructing the coordinate, as we need # the observer-Sun distance (sc.radius) to calculate this, which # may not be provided directly in metadata (if e.g. the # observer coordinate is specified in a cartesian # representation) return SkyCoord(sc.replicate(rsun=self._rsun_meters(sc.radius))) elif missing_keys != keys: frame = kwargs['frame'] if isinstance(kwargs['frame'], str) else kwargs['frame'].name warning_message.append(f"For frame '{frame}' the following metadata is missing: " f"{','.join(missing_keys)}") default = self._default_observer_coordinate if default is not None: # If a map source specifies a default observer, we log a message at the debug level warning_message = (["Missing metadata for observer: assuming custom default observer."] + warning_message) log.debug("\n".join(warning_message)) return default else: # If a map source does not specify a default observer, we assume Earth center and warn warning_message = (["Missing metadata for observer: assuming Earth-based observer."] + warning_message + [""]) warn_metadata("\n".join(warning_message), stacklevel=3) return get_earth(self.reference_date) @property def heliographic_latitude(self): """Observer heliographic latitude.""" return self.observer_coordinate.lat @property def heliographic_longitude(self): """Observer heliographic longitude.""" return self.observer_coordinate.lon @property def carrington_latitude(self): """Observer Carrington latitude.""" hgc_frame = HeliographicCarrington(observer=self.observer_coordinate, obstime=self.reference_date, rsun=self.rsun_meters) return self.observer_coordinate.transform_to(hgc_frame).lat @property def carrington_longitude(self): """Observer Carrington longitude.""" hgc_frame = HeliographicCarrington(observer=self.observer_coordinate, obstime=self.reference_date, rsun=self.rsun_meters) return self.observer_coordinate.transform_to(hgc_frame).lon @property def dsun(self): """Observer distance from the center of the Sun.""" return self.observer_coordinate.radius.to('m') @property def _reference_longitude(self): """ FITS-WCS compatible longitude. Used in self.wcs and self.reference_coordinate. """ return self.meta.get('crval1', 0.) * self.spatial_units[0] @property def _reference_latitude(self): return self.meta.get('crval2', 0.) * self.spatial_units[1] @property def reference_coordinate(self): """Reference point WCS axes in data units (i.e. crval1, crval2). This value includes a shift if one is set.""" return SkyCoord(self._reference_longitude, self._reference_latitude, frame=self.coordinate_frame) @property def reference_pixel(self): """ Pixel of reference coordinate. The pixel returned uses zero-based indexing, so will be 1 pixel less than the FITS CRPIX values. """ naxis1 = self.meta.get('naxis1', self.data.shape[1]) naxis2 = self.meta.get('naxis2', self.data.shape[0]) return PixelPair((self.meta.get('crpix1', (naxis1 + 1) / 2.) - 1) * u.pixel, (self.meta.get('crpix2', (naxis2 + 1) / 2.) - 1) * u.pixel) @property def scale(self): """ Image scale along the x and y axes in units/pixel (i.e. cdelt1, cdelt2). If the CDij matrix is defined but no CDELTi values are explicitly defined, effective CDELTi values are constructed from the CDij matrix. The effective CDELTi values are chosen so that each row of the PCij matrix has unity norm. This choice is optimal if the PCij matrix is a pure rotation matrix, but may not be as optimal if the PCij matrix includes any skew. """ if 'cd1_1' in self.meta and 'cdelt1' not in self.meta and 'cdelt2' not in self.meta: cdelt1 = np.sqrt(self.meta['cd1_1']**2 + self.meta['cd1_2']**2) cdelt2 = np.sqrt(self.meta['cd2_1']**2 + self.meta['cd2_2']**2) else: cdelt1 = self.meta.get('cdelt1', 1.) cdelt2 = self.meta.get('cdelt2', 1.) return SpatialPair(cdelt1 * self.spatial_units[0] / u.pixel, cdelt2 * self.spatial_units[1] / u.pixel) @property def spatial_units(self): """ Image coordinate units along the x and y axes (i.e. cunit1, cunit2). """ units = self.meta.get('cunit1', None), self.meta.get('cunit2', None) units = [None if unit is None else u.Unit(unit.lower()) for unit in units] return SpatialPair(units[0], units[1]) @property def rotation_matrix(self): r""" Matrix describing the transformation needed to align the reference pixel with the coordinate axes. The order or precedence of FITS keywords which this is taken from is: - PC\*_\* - CD\*_\* - CROTA\* Notes ----- In many cases this is a simple rotation matrix, hence the property name. It general it does not have to be a pure rotation matrix, and can encode other transformations e.g., skews for non-orthgonal coordinate systems. """ if any(key in self.meta for key in ['PC1_1', 'PC1_2', 'PC2_1', 'PC2_2']): return np.array( [ [self.meta.get('PC1_1', 1), self.meta.get('PC1_2', 0)], [self.meta.get('PC2_1', 0), self.meta.get('PC2_2', 1)] ] ) elif any(key in self.meta for key in ['CD1_1', 'CD1_2', 'CD2_1', 'CD2_2']): cd = np.array( [ [self.meta.get('CD1_1', 0), self.meta.get('CD1_2', 0)], [self.meta.get('CD2_1', 0), self.meta.get('CD2_2', 0)] ] ) cdelt = u.Quantity(self.scale).value # Divide each row by each CDELT return cd / np.expand_dims(cdelt, axis=1) else: return self._rotation_matrix_from_crota() @staticmethod def _pc_matrix(lam, angle): """ Returns PC matrix from the scale ration (lam) and rotation angle in radians (angle). """ return np.array([[np.cos(angle), -1 * lam * np.sin(angle)], [1/lam * np.sin(angle), np.cos(angle)]]) def _rotation_matrix_from_crota(self, crota_key='CROTA2'): """ This method converts the deprecated CROTA FITS kwargs to the new PC rotation matrix. This method can be overridden if an instruments header does not use this conversion. Parameters ---------- crota_key : str, optional The key to use for CROTA2. Defaults to 'CROTA2'. Notes ----- If the specified key isn't present in the metadata, a default rotation of 0deg is returned. """ lam = self.scale[1] / self.scale[0] p = np.deg2rad(self.meta.get(crota_key, 0)) return self._pc_matrix(lam, p) @property def _pv_values(self): """ Return any PV values in the metadata. """ pattern = re.compile('pv[0-9]_[0-9]a', re.IGNORECASE) pv_keys = [k for k in self.meta.keys() if pattern.match(k)] pv_values = [] for k in pv_keys: i, m = int(k[2]), int(k[4]) pv_values.append((i, m, self.meta[k])) return pv_values @property def fits_header(self): """ A `~astropy.io.fits.Header` representation of the ``meta`` attribute. """ return header_to_fits(self.meta) # #### Miscellaneous #### # def _get_cmap_name(self): """Build the default color map name.""" cmap_string = (self.observatory + self.detector + str(int(self.wavelength.to('angstrom').value))) return cmap_string.lower() def _validate_meta(self): """ Validates some meta-information associated with a Map. This method includes very basic validation checks which apply to all of the kinds of files that sunpy can read. Datasource-specific validation should be handled in the relevant file in the sunpy.map.sources package. """ msg = ('Image coordinate units for axis {} not present in metadata.') err_message = [] for i in [0, 1]: if self.spatial_units[i] is None: err_message.append(msg.format(i+1, i+1)) if err_message: err_message.append( f'See {_META_FIX_URL} for instructions on how to add missing metadata.') raise MapMetaValidationError('\n'.join(err_message)) for meta_property in ('waveunit', ): if (self.meta.get(meta_property) and u.Unit(self.meta.get(meta_property), parse_strict='silent').physical_type == 'unknown'): warn_metadata(f"Unknown value for {meta_property.upper()}.") if (self.coordinate_system[0].startswith(('SOLX', 'SOLY')) or self.coordinate_system[1].startswith(('SOLX', 'SOLY'))): warn_user("sunpy Map does not support three dimensional data " "and therefore cannot represent heliocentric coordinates. Proceed at your own risk.") if not all(su.is_equivalent(u.arcsec) for su in self.spatial_units): units = [su.to_string() for su in self.spatial_units] raise MapMetaValidationError( 'Map only supports spherical coordinate systems with angular units ' f'(ie. equivalent to arcsec), but this map has units {units}') # #### Data conversion routines #### #
[docs] def world_to_pixel(self, coordinate): """ Convert a world (data) coordinate to a pixel coordinate. Parameters ---------- coordinate : `~astropy.coordinates.SkyCoord` or `~astropy.coordinates.BaseCoordinateFrame` The coordinate object to convert to pixel coordinates. Returns ------- x : `~astropy.units.Quantity` Pixel coordinate on the CTYPE1 axis. y : `~astropy.units.Quantity` Pixel coordinate on the CTYPE2 axis. """ x, y = self.wcs.world_to_pixel(coordinate) return PixelPair(x * u.pixel, y * u.pixel)
[docs] @u.quantity_input def pixel_to_world(self, x: u.pixel, y: u.pixel): """ Convert a pixel coordinate to a data (world) coordinate. Parameters ---------- x : `~astropy.units.Quantity` Pixel coordinate of the CTYPE1 axis. (Normally solar-x). y : `~astropy.units.Quantity` Pixel coordinate of the CTYPE2 axis. (Normally solar-y). Returns ------- coord : `astropy.coordinates.SkyCoord` A coordinate object representing the output coordinate. """ return self.wcs.pixel_to_world(x, y)
# #### I/O routines #### #
[docs] def save(self, filepath, filetype='auto', **kwargs): """ Save a map to a file. Parameters ---------- filepath : `str` Location to save the file to. If ``filepath`` ends with ".asdf" and ``filetype="auto"``, an ASDF file will be created. filetype : `str`, optional The file format to save the map in. Defaults to ``"auto"`` which infers the format from the file extension. Supported formats include FITS, JP2, and ASDF. hdu_type : `~astropy.io.fits.hdu.base.ExtensionHDU` instance or class, optional For FITS files, this specifies the type of HDU to write. By default, the map is saved in the primary HDU. If an HDU type or instance is provided, the map data and header will be written to that HDU. For example, `astropy.io.fits.CompImageHDU` can be used to compress the map. kwargs : Any additional keyword arguments are passed to `~sunpy.io._file_tools.write_file` or `asdf.AsdfFile.write_to`. Notes ----- Saving with the jp2 extension will write a modified version of the given data casted to uint8 values in order to support the JPEG2000 format. Saving with the ``.asdf`` extension will save the map as an ASDF file, storing the map's attributes under the key ``'sunpymap'`` in the ASDF tree. Examples -------- >>> from astropy.io.fits import CompImageHDU >>> from sunpy.map import Map >>> import sunpy.data.sample # doctest: +REMOTE_DATA >>> aia_map = Map(sunpy.data.sample.AIA_171_IMAGE) # doctest: +REMOTE_DATA >>> aia_map.save("aia171.fits", hdu_type=CompImageHDU) # doctest: +REMOTE_DATA >>> aia_map.save("aia171.asdf") # doctest: +REMOTE_DATA """ if filetype.lower() == "asdf" or (filetype.lower() == "auto" and str(filepath).lower().endswith(".asdf")): import asdf asdf.AsdfFile({'sunpymap': self}).write_to(str(filepath), **kwargs) else: write_file(filepath, self.data, self.meta, filetype=filetype, **kwargs)
# #### Image processing routines #### #
[docs] @u.quantity_input def resample(self, dimensions: u.pixel, method='linear'): """ Resample to new dimension sizes. Uses the same parameters and creates the same coordinate lookup points as IDL''s congrid routine, which apparently originally came from a VAX/VMS routine of the same name. Parameters ---------- dimensions : `~astropy.units.Quantity` Output pixel dimensions. The first argument corresponds to the 'x' axis and the second argument corresponds to the 'y' axis. method : str Method to use for resampling interpolation. * ``'nearest'`` and ``'linear'`` - Use n x 1-D interpolations using `scipy.interpolate.interp1d`. * ``'spline'`` - Uses piecewise polynomials (splines) for mapping the input array to new coordinates by interpolation using `scipy.ndimage.map_coordinates`. Returns ------- out : `~sunpy.map.GenericMap` or subclass Resampled map References ---------- `Rebinning <https://scipy-cookbook.readthedocs.io/items/Rebinning.html>`_ """ # Note: because the underlying ndarray is transposed in sense when # compared to the Map, the ndarray is transposed, resampled, then # transposed back # Note: "center" defaults to True in this function because data # coordinates in a Map are at pixel centers # Make a copy of the original data and perform resample new_data = sunpy_image_resample(self.data.copy().T, dimensions, method, center=True) new_data = new_data.T scale_factor_x = float(self.dimensions[0] / dimensions[0]) scale_factor_y = float(self.dimensions[1] / dimensions[1]) # Update image scale and number of pixels new_meta = self.meta.copy() # Update metadata for key in {'cdelt1', 'cd1_1', 'cd2_1'} & self.meta.keys(): new_meta[key] *= scale_factor_x for key in {'cdelt2', 'cd1_2', 'cd2_2'} & self.meta.keys(): new_meta[key] *= scale_factor_y if 'pc1_1' in self.meta: new_meta['pc1_2'] *= scale_factor_y / scale_factor_x new_meta['pc2_1'] *= scale_factor_x / scale_factor_y new_meta['crpix1'] = (self.reference_pixel.x.to_value(u.pix) + 0.5) / scale_factor_x + 0.5 new_meta['crpix2'] = (self.reference_pixel.y.to_value(u.pix) + 0.5) / scale_factor_y + 0.5 new_meta['naxis1'] = new_data.shape[1] new_meta['naxis2'] = new_data.shape[0] # Create new map instance new_map = self._new_instance(new_data, new_meta, self.plot_settings) return new_map
[docs] @add_common_docstring(rotation_function_names=_rotation_function_names) @u.quantity_input def rotate(self, angle: u.deg = None, rmatrix=None, order=3, scale=1.0, recenter=False, missing=np.nan, *, method='scipy', clip=True): """ Returns a new rotated and rescaled map. Specify either a rotation angle or a rotation matrix, but not both. If neither an angle or a rotation matrix are specified, the map will be rotated by the rotation information in the metadata, which should derotate the map so that the pixel axes are aligned with world-coordinate axes. Parameters ---------- angle : `~astropy.units.Quantity` The angle (degrees) to rotate counterclockwise. rmatrix : array-like 2x2 linear transformation rotation matrix. order : int Interpolation order to be used. The precise meaning depends on the rotation method specified by ``method``. Default: 3 scale : float A scale factor for the image, default is no scaling recenter : bool If `True`, position the reference coordinate at the center of the new map Default: `False` missing : float The value to use for pixels in the output map that are beyond the extent of the input map. Default: `numpy.nan` method : {{{rotation_function_names}}}, optional Rotation function to use. Defaults to ``'scipy'``. clip : `bool`, optional If `True`, clips the pixel values of the output image to the range of the input image (including the value of ``missing``, if used). Defaults to `True`. Returns ------- out : `~sunpy.map.GenericMap` or subclass A new Map instance containing the rotated and rescaled data of the original map. See Also -------- sunpy.image.transform.affine_transform : The routine this method calls for the rotation. Notes ----- The rotation information in the metadata of the new map is modified appropriately from that of the original map to account for the applied rotation. It will solely be in the form of a PCi_j matrix, even if the original map used the CROTA2 keyword or a CDi_j matrix. If the map does not already contain pixels with `numpy.nan` values, setting ``missing`` to an appropriate number for the data (e.g., zero) will reduce the computation time. For each NaN pixel in the input image, one or more pixels in the output image will be set to NaN, with the size of the pixel region affected depending on the interpolation order. All currently implemented rotation methods require a convolution step to handle image NaNs. This convolution normally uses :func:`scipy.signal.convolve2d`, but if `OpenCV <https://opencv.org>`__ is installed, the faster |cv2_filter2D|_ is used instead. See :func:`sunpy.image.transform.affine_transform` for details on each of the rotation functions. """ if angle is not None and rmatrix is not None: raise ValueError("You cannot specify both an angle and a rotation matrix.") elif angle is None and rmatrix is None: # Be aware that self.rotation_matrix may not actually be a pure rotation matrix rmatrix = self.rotation_matrix if order not in range(6): raise ValueError("Order must be between 0 and 5.") method = _get_transform_method(method) # The FITS-WCS transform is by definition defined around the # reference coordinate in the header. lon, lat = self._get_lon_lat(self.reference_coordinate.frame) rotation_center = u.Quantity([lon, lat]) # Copy meta data new_meta = self.meta.copy() if angle is not None: # Calculate the parameters for the affine_transform c = np.cos(np.deg2rad(angle)) s = np.sin(np.deg2rad(angle)) rmatrix = np.array([[c, -s], [s, c]]) # The data will be rotated by the inverse of the rotation matrix. Because rmatrix may not # actually be a pure rotation matrix, we calculate the inverse in a general manner. inv_rmatrix = np.linalg.inv(rmatrix) # Calculate the shape in pixels to contain all of the image data corners = itertools.product([-0.5, self.data.shape[1]-0.5], [-0.5, self.data.shape[0]-0.5]) rot_corners = np.vstack([rmatrix @ c for c in corners]) * scale extent = np.max(rot_corners, axis=0) - np.min(rot_corners, axis=0) # Calculate the needed padding or unpadding diff = np.asarray(np.ceil((extent - np.flipud(self.data.shape)) / 2), dtype=int) pad_x = np.max((diff[0], 0)) pad_y = np.max((diff[1], 0)) unpad_x = -np.min((diff[0], 0)) unpad_y = -np.min((diff[1], 0)) # Raise an informative error message if trying to pad an integer array with NaNs if (pad_x > 0 or pad_y > 0) and issubclass(self.data.dtype.type, numbers.Integral) and (missing % 1 != 0): raise ValueError("The underlying data is integers, but the fill value for missing " "pixels cannot be cast to an integer, which is the case for the " "default fill value of NaN. Set the `missing` keyword to an " "appropriate integer value for the data set.") new_data = np.pad(self.data, ((pad_y, pad_y), (pad_x, pad_x)), mode='constant', constant_values=(missing, missing)) # All of the following pixel calculations use a pixel origin of 0 pixel_array_center = (np.flipud(new_data.shape) - 1) / 2.0 pixel_rotation_center = u.Quantity(self.reference_pixel).value + [pad_x, pad_y] if recenter: pixel_center = pixel_rotation_center else: pixel_center = pixel_array_center # Apply the rotation to the image data new_data = affine_transform(new_data, np.asarray(inv_rmatrix), order=order, scale=scale, image_center=pixel_center, recenter=recenter, missing=missing, method=method, clip=clip) if recenter: new_reference_pixel = pixel_array_center else: # Calculate new pixel coordinates for the rotation center new_reference_pixel = pixel_center + np.dot(rmatrix * scale, pixel_rotation_center - pixel_center) new_reference_pixel = np.array(new_reference_pixel).ravel() # Define the new reference_pixel new_meta['crval1'] = rotation_center[0].value new_meta['crval2'] = rotation_center[1].value new_meta['crpix1'] = new_reference_pixel[0] + 1 # FITS pixel origin is 1 new_meta['crpix2'] = new_reference_pixel[1] + 1 # FITS pixel origin is 1 new_meta['NAXIS1'] = new_data.shape[1] new_meta['NAXIS2'] = new_data.shape[0] # Unpad the array if necessary if unpad_x > 0: new_data = new_data[:, unpad_x:-unpad_x] new_meta['crpix1'] -= unpad_x if unpad_y > 0: new_data = new_data[unpad_y:-unpad_y, :] new_meta['crpix2'] -= unpad_y # Calculate the new rotation matrix to store in the header by # "subtracting" the rotation matrix used in the rotate from the old one # That being calculate the dot product of the old header data with the # inverse of the rotation matrix. pc_C = np.dot(self.rotation_matrix, inv_rmatrix) new_meta['PC1_1'] = pc_C[0, 0] new_meta['PC1_2'] = pc_C[0, 1] new_meta['PC2_1'] = pc_C[1, 0] new_meta['PC2_2'] = pc_C[1, 1] # Update pixel size if image has been scaled. if scale != 1.0: new_meta['cdelt1'] = (self.scale[0] / scale).value new_meta['cdelt2'] = (self.scale[1] / scale).value # Remove old CROTA kwargs because we have saved a new PCi_j matrix. new_meta.pop('CROTA1', None) new_meta.pop('CROTA2', None) # Remove CDi_j header new_meta.pop('CD1_1', None) new_meta.pop('CD1_2', None) new_meta.pop('CD2_1', None) new_meta.pop('CD2_2', None) # Create new map with the modification new_map = self._new_instance(new_data, new_meta, self.plot_settings) return new_map
[docs] @u.quantity_input def submap(self, bottom_left, *, top_right=None, width: (u.deg, u.pix) = None, height: (u.deg, u.pix) = None): """ Returns a submap defined by a rectangle. Any pixels which have at least part of their area inside the rectangle are returned. If the rectangle is defined in world coordinates, the smallest array which contains all four corners of the rectangle as defined in world coordinates is returned. Parameters ---------- bottom_left : `astropy.units.Quantity` or `~astropy.coordinates.SkyCoord` The bottom-left coordinate of the rectangle. If a `~astropy.coordinates.SkyCoord` it can have shape ``(2,)`` and simultaneously define ``top_right``. If specifying pixel coordinates it must be given as an `~astropy.units.Quantity` object with units of pixels. top_right : `astropy.units.Quantity` or `~astropy.coordinates.SkyCoord`, optional The top-right coordinate of the rectangle. If ``top_right`` is specified ``width`` and ``height`` must be omitted. width : `astropy.units.Quantity`, optional The width of the rectangle. Required if ``top_right`` is omitted. height : `astropy.units.Quantity` The height of the rectangle. Required if ``top_right`` is omitted. Returns ------- out : `~sunpy.map.GenericMap` or subclass A new map instance is returned representing to specified sub-region. Notes ----- When specifying pixel coordinates, they are specified in Cartesian order not in numpy order. So, for example, the ``bottom_left=`` argument should be ``[left, bottom]``. Examples -------- >>> import astropy.units as u >>> from astropy.coordinates import SkyCoord >>> import sunpy.map >>> import sunpy.data.sample # doctest: +REMOTE_DATA >>> aia = sunpy.map.Map(sunpy.data.sample.AIA_171_IMAGE) # doctest: +REMOTE_DATA >>> bl = SkyCoord(-300*u.arcsec, -300*u.arcsec, frame=aia.coordinate_frame) # doctest: +REMOTE_DATA >>> tr = SkyCoord(500*u.arcsec, 500*u.arcsec, frame=aia.coordinate_frame) # doctest: +REMOTE_DATA >>> aia.submap(bl, top_right=tr) # doctest: +REMOTE_DATA <sunpy.map.sources.sdo.AIAMap object at ...> SunPy Map --------- Observatory: SDO Instrument: AIA 3 Detector: AIA Measurement: 171.0 Angstrom Wavelength: 171.0 Angstrom Observation Date: 2011-06-07 06:33:02 Exposure Time: 0.234256 s Dimension: [335. 335.] pix Coordinate System: helioprojective Scale: [2.402792 2.402792] arcsec / pix Reference Pixel: [126.5 125.5] pix Reference Coord: [3.22309951 1.38578135] arcsec ... >>> aia.submap([0,0]*u.pixel, top_right=[5,5]*u.pixel) # doctest: +REMOTE_DATA <sunpy.map.sources.sdo.AIAMap object at ...> SunPy Map --------- Observatory: SDO Instrument: AIA 3 Detector: AIA Measurement: 171.0 Angstrom Wavelength: 171.0 Angstrom Observation Date: 2011-06-07 06:33:02 Exposure Time: 0.234256 s Dimension: [6. 6.] pix Coordinate System: helioprojective Scale: [2.402792 2.402792] arcsec / pix Reference Pixel: [511.5 511.5] pix Reference Coord: [3.22309951 1.38578135] arcsec ... >>> width = 10 * u.arcsec >>> height = 10 * u.arcsec >>> aia.submap(bl, width=width, height=height) # doctest: +REMOTE_DATA <sunpy.map.sources.sdo.AIAMap object at ...> SunPy Map --------- Observatory: SDO Instrument: AIA 3 Detector: AIA Measurement: 171.0 Angstrom Wavelength: 171.0 Angstrom Observation Date: 2011-06-07 06:33:02 Exposure Time: 0.234256 s Dimension: [5. 5.] pix Coordinate System: helioprojective Scale: [2.402792 2.402792] arcsec / pix Reference Pixel: [125.5 125.5] pix Reference Coord: [3.22309951 1.38578135] arcsec ... >>> bottom_left_vector = SkyCoord([0, 10] * u.deg, [0, 10] * u.deg, frame='heliographic_stonyhurst') >>> aia.submap(bottom_left_vector) # doctest: +REMOTE_DATA <sunpy.map.sources.sdo.AIAMap object at ...> SunPy Map --------- Observatory: SDO Instrument: AIA 3 Detector: AIA Measurement: 171.0 Angstrom Wavelength: 171.0 Angstrom Observation Date: 2011-06-07 06:33:02 Exposure Time: 0.234256 s Dimension: [70. 69.] pix Coordinate System: helioprojective Scale: [2.402792 2.402792] arcsec / pix Reference Pixel: [1.5 0.5] pix Reference Coord: [3.22309951 1.38578135] arcsec ... """ # Check that we have been given a valid combination of inputs # [False, False, False] is valid if bottom_left contains the two corner coords if ([arg is not None for arg in (top_right, width, height)] not in [[True, False, False], [False, False, False], [False, True, True]]): raise ValueError("Either top_right alone or both width and height must be specified.") # parse input arguments pixel_corners = u.Quantity(self._parse_submap_input( bottom_left, top_right, width, height)).T msg = ( "The provided input coordinates to ``submap`` when transformed to the target " "coordinate frame contain NaN values and cannot be used to crop the map. " "The most common reason for NaN values is transforming off-disk 2D " "coordinates without specifying an assumption (e.g., via the" "`sunpy.coordinates.SphericalScreen()` context manager) that allows " "such coordinates to be interpreted as 3D coordinates." ) if np.any(np.isnan(pixel_corners)): raise ValueError(msg) # The pixel corners result is in Cartesian order, so the first index is # columns and the second is rows. bottom = np.min(pixel_corners[1]).to_value(u.pix) top = np.max(pixel_corners[1]).to_value(u.pix) left = np.min(pixel_corners[0]).to_value(u.pix) right = np.max(pixel_corners[0]).to_value(u.pix) # Round the lower left pixel to the nearest integer # We want 0.5 to be rounded up to 1, so use floor(x + 0.5) bottom = np.floor(bottom + 0.5) left = np.floor(left + 0.5) # Round the top right pixel to the nearest integer, then add 1 for array indexing # We want e.g. 2.5 to be rounded down to 2, so use ceil(x - 0.5) top = np.ceil(top - 0.5) + 1 right = np.ceil(right - 0.5) + 1 # Clip pixel values to max of array, prevents negative # indexing bottom = int(np.clip(bottom, 0, self.data.shape[0])) top = int(np.clip(top, 0, self.data.shape[0])) left = int(np.clip(left, 0, self.data.shape[1])) right = int(np.clip(right, 0, self.data.shape[1])) arr_slice = np.s_[bottom:top, left:right] # Get ndarray representation of submap new_data = self.data[arr_slice].copy() # Make a copy of the header with updated centering information new_meta = self.meta.copy() # Add one to go from zero-based to one-based indexing new_meta['crpix1'] = self.reference_pixel.x.to_value(u.pix) + 1 - left new_meta['crpix2'] = self.reference_pixel.y.to_value(u.pix) + 1 - bottom new_meta['naxis1'] = new_data.shape[1] new_meta['naxis2'] = new_data.shape[0] # Create new map instance if self.mask is not None: new_mask = self.mask[arr_slice].copy() # Create new map with the modification new_map = self._new_instance(new_data, new_meta, self.plot_settings, mask=new_mask) return new_map # Create new map with the modification new_map = self._new_instance(new_data, new_meta, self.plot_settings) return new_map
@seconddispatch def _parse_submap_input(self, bottom_left, top_right, width, height): """ Should take any valid input to submap() and return bottom_left and top_right in pixel coordinates. """ @_parse_submap_input.register(u.Quantity) def _parse_submap_quantity_input(self, bottom_left, top_right, width, height): if top_right is None and width is None: raise ValueError('Either top_right alone or both width and height must be specified ' 'when bottom_left is a Quantity') if bottom_left.shape != (2, ): raise ValueError('bottom_left must have shape (2, ) when specified as a Quantity') if top_right is not None: if top_right.shape != (2, ): raise ValueError('top_right must have shape (2, ) when specified as a Quantity') if not top_right.unit.is_equivalent(u.pix): raise TypeError("When bottom_left is a Quantity, top_right " "must be a Quantity in units of pixels.") # Have bottom_left and top_right in pixels already, so no need to do # anything else else: if not (width.unit.is_equivalent(u.pix) and height.unit.is_equivalent(u.pix)): raise TypeError("When bottom_left is a Quantity, width and height " "must be a Quantity in units of pixels.") # Add width and height to get top_right top_right = u.Quantity([bottom_left[0] + width, bottom_left[1] + height]) top_left = u.Quantity([top_right[0], bottom_left[1]]) bottom_right = u.Quantity([bottom_left[0], top_right[1]]) return bottom_left, top_left, top_right, bottom_right @_parse_submap_input.register(SkyCoord) @_parse_submap_input.register(BaseCoordinateFrame) def _parse_submap_coord_input(self, bottom_left, top_right, width, height): # Use helper function to get top_right as a SkyCoord bottom_left, top_right = get_rectangle_coordinates(bottom_left, top_right=top_right, width=width, height=height) if isinstance(bottom_left, SkyCoord): frame = bottom_left.frame frame = bottom_left left_lon, bottom_lat = self._get_lon_lat(bottom_left) right_lon, top_lat = self._get_lon_lat(top_right) corners = SkyCoord([left_lon, left_lon, right_lon, right_lon], [bottom_lat, top_lat, top_lat, bottom_lat], frame=frame) return tuple(u.Quantity(self.wcs.world_to_pixel(corners), u.pix).T)
[docs] @u.quantity_input def superpixel(self, dimensions: u.pixel, offset: u.pixel = (0, 0)*u.pixel, func=np.sum): """Returns a new map consisting of superpixels formed by applying 'func' to the original map data. Parameters ---------- dimensions : tuple One superpixel in the new map is equal to (dimension[0], dimension[1]) pixels of the original map. The first argument corresponds to the 'x' axis and the second argument corresponds to the 'y' axis. If non-integer values are provided, they are rounded using `int`. offset : tuple Offset from (0,0) in original map pixels used to calculate where the data used to make the resulting superpixel map starts. If non-integer value are provided, they are rounded using `int`. func Function applied to the original data. The function 'func' must take a numpy array as its first argument, and support the axis keyword with the meaning of a numpy axis keyword (see the description of `~numpy.sum` for an example.) The default value of 'func' is `~numpy.sum`; using this causes superpixel to sum over (dimension[0], dimension[1]) pixels of the original map. Returns ------- out : `~sunpy.map.GenericMap` or subclass A new Map which has superpixels of the required size. """ # Note: because the underlying ndarray is transposed in sense when # compared to the Map, the ndarray is transposed, resampled, then # transposed back. # Note: "center" defaults to True in this function because data # coordinates in a Map are at pixel centers. if (offset.value[0] < 0) or (offset.value[1] < 0): raise ValueError("Offset is strictly non-negative.") # These are rounded by int() in reshape_image_to_4d_superpixel, # so round here too for use in constructing metadata later. dimensions = [int(dim) for dim in dimensions.to_value(u.pix)] offset = [int(off) for off in offset.to_value(u.pix)] # Make a copy of the original data, perform reshaping, and apply the # function. if self.mask is not None: data = np.ma.array(self.data.copy(), mask=self.mask) else: data = self.data.copy() reshaped = reshape_image_to_4d_superpixel(data, [dimensions[1], dimensions[0]], [offset[1], offset[0]]) new_array = func(func(reshaped, axis=3), axis=1) # Update image scale and number of pixels # create copy of new meta data new_meta = self.meta.copy() # Update metadata for key in {'cdelt1', 'cd1_1', 'cd2_1'} & self.meta.keys(): new_meta[key] *= dimensions[0] for key in {'cdelt2', 'cd1_2', 'cd2_2'} & self.meta.keys(): new_meta[key] *= dimensions[1] if 'pc1_1' in self.meta: new_meta['pc1_2'] *= dimensions[1] / dimensions[0] new_meta['pc2_1'] *= dimensions[0] / dimensions[1] new_meta['crpix1'] = ((self.reference_pixel.x.to_value(u.pix) + 0.5 - offset[0]) / dimensions[0]) + 0.5 new_meta['crpix2'] = ((self.reference_pixel.y.to_value(u.pix) + 0.5 - offset[1]) / dimensions[1]) + 0.5 new_meta['naxis1'] = new_array.shape[1] new_meta['naxis2'] = new_array.shape[0] # Create new map instance if self.mask is not None: new_data = np.ma.getdata(new_array) new_mask = np.ma.getmask(new_array) else: new_data = new_array new_mask = None # Create new map with the modified data new_map = self._new_instance(new_data, new_meta, self.plot_settings, mask=new_mask) return new_map
# #### Visualization #### # @property def cmap(self): """ Return the `matplotlib.colors.Colormap` instance this map uses. """ cmap = self.plot_settings['cmap'] if isinstance(cmap, str): cmap = plt.get_cmap(cmap) # Set the colormap to be this specific instance so we are not # returning a copy self.plot_settings['cmap'] = cmap return cmap
[docs] @u.quantity_input def draw_grid(self, axes=None, grid_spacing: u.deg = 15*u.deg, annotate=True, system='stonyhurst', **kwargs): """ Draws a coordinate overlay on the plot in the Heliographic Stonyhurst coordinate system. To overlay other coordinate systems see the `WCSAxes Documentation <https://docs.astropy.org/en/stable/visualization/wcsaxes/overlaying_coordinate_systems.html>`_ Parameters ---------- axes : `~matplotlib.axes` or `None` Axes to plot limb on, or `None` to use current axes. grid_spacing : `~astropy.units.Quantity` Spacing for longitude and latitude grid, if length two it specifies (lon, lat) spacing. annotate : `bool` Passing `False` disables the axes labels and the ticks on the top and right axes. system : str Coordinate system for the grid. Must be 'stonyhurst' or 'carrington'. kwargs : Additional keyword arguments are passed to `~sunpy.visualization.wcsaxes_compat.wcsaxes_heliographic_overlay`. Returns ------- overlay: `~astropy.visualization.wcsaxes.CoordinatesMap` The wcsaxes coordinate overlay instance. Notes ----- Keyword arguments are passed onto the `sunpy.visualization.wcsaxes_compat.wcsaxes_heliographic_overlay` function. """ axes = self._check_axes(axes) return wcsaxes_compat.wcsaxes_heliographic_overlay(axes, grid_spacing=grid_spacing, annotate=annotate, obstime=self.reference_date, rsun=self.rsun_meters, observer=self.observer_coordinate, system=system, **kwargs)
[docs] def draw_limb(self, axes=None, *, resolution=1000, **kwargs): """ Draws the solar limb as seen by the map's observer. The limb is a circle for only the simplest plots. If the coordinate frame of the limb is different from the coordinate frame of the plot axes, not only may the limb not be a true circle, a portion of the limb may be hidden from the observer. In that case, the circle is divided into visible and hidden segments, represented by solid and dotted lines, respectively. Parameters ---------- axes : `~matplotlib.axes` or ``None`` Axes to plot limb on or ``None`` to use current axes. resolution : `int` The number of points to use to represent the limb. Returns ------- visible : `~matplotlib.patches.Polygon` or `~matplotlib.patches.Circle` The patch added to the axes for the visible part of the limb (i.e., the "near" side of the Sun). hidden : `~matplotlib.patches.Polygon` or None The patch added to the axes for the hidden part of the limb (i.e., the "far" side of the Sun). Notes ----- Keyword arguments are passed onto the patches. If the limb is a true circle, ``visible`` will instead be `~matplotlib.patches.Circle` and ``hidden`` will be ``None``. If there are no visible points (e.g., on a synoptic map any limb is fully) visible ``hidden`` will be ``None``. To avoid triggering Matplotlib auto-scaling, these patches are added as artists instead of patches. One consequence is that the plot legend is not populated automatically when the limb is specified with a text label. See :ref:`sphx_glr_gallery_text_labels_and_annotations_custom_legends.py` in the Matplotlib documentation for examples of creating a custom legend. """ # Put imports here to reduce sunpy.map import time import sunpy.visualization.drawing axes = self._check_axes(axes) return sunpy.visualization.drawing.limb( axes, self.observer_coordinate, resolution=resolution, rsun=self.rsun_meters, **kwargs )
[docs] @u.quantity_input def draw_quadrangle(self, bottom_left, *, width: (u.deg, u.pix) = None, height: (u.deg, u.pix) = None, axes=None, top_right=None, **kwargs): """ Draw a quadrangle defined in world coordinates on the plot using Astropy's `~astropy.visualization.wcsaxes.Quadrangle`. This draws a quadrangle that has corners at ``(bottom_left, top_right)``, and has sides aligned with the coordinate axes of the frame of ``bottom_left``, which may be different from the coordinate axes of the map. If ``width`` and ``height`` are specified, they are respectively added to the longitude and latitude of the ``bottom_left`` coordinate to calculate a ``top_right`` coordinate. Parameters ---------- bottom_left : `~astropy.coordinates.SkyCoord` or `~astropy.units.Quantity` The bottom-left coordinate of the rectangle. If a `~astropy.coordinates.SkyCoord` it can have shape ``(2,)`` and simultaneously define ``top_right``. If specifying pixel coordinates it must be given as an `~astropy.units.Quantity` object with pixel units (e.g., ``pix``). top_right : `~astropy.coordinates.SkyCoord` or `~astropy.units.Quantity`, optional The top-right coordinate of the quadrangle. If ``top_right`` is specified ``width`` and ``height`` must be omitted. width : `astropy.units.Quantity`, optional The width of the quadrangle. Required if ``top_right`` is omitted. height : `astropy.units.Quantity` The height of the quadrangle. Required if ``top_right`` is omitted. axes : `matplotlib.axes.Axes` The axes on which to plot the quadrangle. Defaults to the current axes. Returns ------- quad : `~astropy.visualization.wcsaxes.Quadrangle` The added patch. Notes ----- Extra keyword arguments to this function are passed through to the `~astropy.visualization.wcsaxes.Quadrangle` instance. Examples -------- .. minigallery:: sunpy.map.GenericMap.draw_quadrangle """ axes = self._check_axes(axes) if isinstance(bottom_left, u.Quantity): anchor, _, top_right, _ = self._parse_submap_quantity_input(bottom_left, top_right, width, height) width, height = top_right - anchor transform = axes.get_transform(self.wcs if self.wcs is not axes.wcs else 'pixel') kwargs.update({"vertex_unit": u.pix}) else: bottom_left, top_right = get_rectangle_coordinates( bottom_left, top_right=top_right, width=width, height=height) width = Longitude(top_right.spherical.lon - bottom_left.spherical.lon) height = top_right.spherical.lat - bottom_left.spherical.lat anchor = self._get_lon_lat(bottom_left) transform = axes.get_transform(bottom_left.replicate_without_data()) kwergs = { "transform": transform, "edgecolor": "white", "fill": False, } kwergs.update(kwargs) quad = Quadrangle(anchor, width, height, **kwergs) axes.add_patch(quad) return quad
def _process_levels_arg(self, levels): """ Accept a percentage or dimensionless or map unit input for contours. """ levels = np.atleast_1d(levels) if not hasattr(levels, 'unit'): if self.unit is None: # No map units, so allow non-quantity through return levels else: raise TypeError("The levels argument has no unit attribute, " "it should be an Astropy Quantity object.") if levels.unit == u.percent: return 0.01 * levels.to_value('percent') * np.nanmax(self.data) elif self.unit is not None: return levels.to_value(self.unit) elif levels.unit.is_equivalent(u.dimensionless_unscaled): # Handle case where map data has no units return levels.to_value(u.dimensionless_unscaled) else: # Map data has no units, but levels doesn't have dimensionless units raise u.UnitsError("This map has no unit, so levels can only be specified in percent " "or in u.dimensionless_unscaled units.")
[docs] def draw_contours(self, levels, axes=None, *, fill=False, **contour_args): """ Draw contours of the data. Parameters ---------- levels : `~astropy.units.Quantity` A list of numbers indicating the contours to draw. These are given as a percentage of the maximum value of the map data, or in units equivalent to the `~sunpy.map.GenericMap.unit` attribute. axes : `matplotlib.axes.Axes` The axes on which to plot the contours. Defaults to the current axes. fill : `bool`, optional Determines the style of the contours: - If `False` (default), contours are drawn as lines using :meth:`~matplotlib.axes.Axes.contour`. - If `True`, contours are drawn as filled regions using :meth:`~matplotlib.axes.Axes.contourf`. Returns ------- cs : `list` The `~matplotlib.contour.QuadContourSet` object, after it has been added to ``axes``. Notes ----- Extra keyword arguments to this function are passed through to the corresponding matplotlib method. """ axes = self._check_axes(axes) levels = self._process_levels_arg(levels) # Pixel indices y, x = np.indices(self.data.shape) # Prepare a local variable in case we need to mask values data = self.data # Transform the indices if plotting to a different WCS # We do this instead of using the `transform` keyword argument so that Matplotlib does not # get confused about the bounds of the contours if self.wcs is not axes.wcs: if "transform" in contour_args: transform_orig = contour_args.pop("transform") else: transform_orig = axes.get_transform(self.wcs) transform = transform_orig - axes.transData # pixel->pixel transform x_1d, y_1d = transform.transform(np.stack([x.ravel(), y.ravel()]).T).T x, y = np.reshape(x_1d, x.shape), np.reshape(y_1d, y.shape) # Mask out the data array anywhere the coordinate arrays are not finite data = np.ma.array(data, mask=~np.logical_and(np.isfinite(x), np.isfinite(y))) if fill: # Ensure we have more than one level if fill is True if len(levels) == 1: max_val = np.nanmax(self.data) # Ensure the existing level is less than max_val if levels[0] < max_val: levels = np.append(levels, max_val) else: raise ValueError( f"The provided level ({levels[0]}) is not smaller than the maximum data value ({max_val}). " "Contour level must be smaller than the maximum data value to use `fill=True`.") cs = axes.contourf(x, y, data, levels, **contour_args) else: cs = axes.contour(x, y, data, levels, **contour_args) return cs
[docs] @peek_show def peek(self, draw_limb=False, draw_grid=False, colorbar=True, **matplot_args): """ Displays a graphical overview of the data in this object for user evaluation. For the creation of plots, users should instead use the `~sunpy.map.GenericMap.plot` method and Matplotlib's pyplot framework. Parameters ---------- draw_limb : bool Whether the solar limb should be plotted. draw_grid : bool or `~astropy.units.Quantity` Whether solar meridians and parallels are plotted. If `~astropy.units.Quantity` then sets degree difference between parallels and meridians. colorbar : bool Whether to display a colorbar next to the plot. **matplot_args : dict Matplotlib Any additional imshow arguments that should be used when plotting. """ figure = plt.figure() axes = wcsaxes_compat.gca_wcs(self.wcs) im = self.plot(axes=axes, **matplot_args) grid_spacing = None # Handle case where draw_grid is actually the grid sapcing if isinstance(draw_grid, u.Quantity): grid_spacing = draw_grid draw_grid = True elif not isinstance(draw_grid, bool): raise TypeError("draw_grid should be a bool or an astropy Quantity.") if colorbar: if draw_grid: pad = 0.12 # Pad to compensate for ticks and axes labels else: pad = 0.05 # Default value for vertical colorbar colorbar_label = str(self.unit) if self.unit is not None else "" figure.colorbar(im, pad=pad).set_label(colorbar_label, rotation=0, labelpad=-50, y=-0.02, size=12) if draw_limb: self.draw_limb(axes=axes) if draw_grid: if grid_spacing is None: self.draw_grid(axes=axes) else: self.draw_grid(axes=axes, grid_spacing=grid_spacing) return figure
[docs] @deprecate_positional_args_since(since="6.0.0") @u.quantity_input def plot(self, *, annotate=True, axes=None, title=True, autoalign=False, clip_interval: u.percent = None, **imshow_kwargs): """ Plots the map object using matplotlib, in a method equivalent to :meth:`~matplotlib.axes.Axes.imshow` using nearest neighbor interpolation. Parameters ---------- annotate : `bool`, optional If `True`, the data is plotted at its natural scale; with title and axis labels. axes : `~matplotlib.axes.Axes` or None If provided the image will be plotted on the given axes. Else the current Matplotlib axes will be used. title : `str`, `bool`, optional The plot title. If `True`, uses the default title for this map. clip_interval : two-element `~astropy.units.Quantity`, optional If provided, the data will be clipped to the percentile interval bounded by the two numbers. autoalign : `bool` or `str`, optional If other than `False`, the plotting accounts for any difference between the WCS of the map and the WCS of the `~astropy.visualization.wcsaxes.WCSAxes` axes (e.g., a difference in rotation angle). If ``pcolormesh``, this method will use :meth:`~matplotlib.axes.Axes.pcolormesh` instead of the default :meth:`~matplotlib.axes.Axes.imshow`. Specifying `True` is equivalent to specifying ``pcolormesh``. **imshow_kwargs : `dict` Any additional imshow arguments are passed to :meth:`~matplotlib.axes.Axes.imshow`. Examples -------- >>> # Simple Plot with color bar >>> aia.plot() # doctest: +SKIP >>> plt.colorbar() # doctest: +SKIP >>> # Add a limb line and grid >>> aia.plot() # doctest: +SKIP >>> aia.draw_limb() # doctest: +SKIP >>> aia.draw_grid() # doctest: +SKIP Notes ----- The ``autoalign`` functionality is computationally intensive. If the plot will be interactive, the alternative approach of preprocessing the map (e.g., de-rotating it) to match the desired axes will result in better performance. When combining ``autoalign`` functionality with `~sunpy.coordinates.Helioprojective` coordinates, portions of the map that are beyond the solar disk may not appear, which may also inhibit Matplotlib's autoscaling of the plot limits. The plot limits can be set manually. To preserve the off-disk parts of the map, using the `~sunpy.coordinates.SphericalScreen` context manager may be appropriate. """ # Todo: remove this when deprecate_positional_args_since is removed # Users sometimes assume that the first argument is `axes` instead of `annotate` if not isinstance(annotate, bool): raise TypeError("You have provided a non-boolean value for the `annotate` parameter. " "If you are specifying the axes, use `axes=...` to pass it in.") # Set the default approach to autoalignment if autoalign not in [False, True, 'pcolormesh']: raise ValueError("The value for `autoalign` must be False, True, or 'pcolormesh'.") if autoalign is True: autoalign = 'pcolormesh' axes = self._check_axes(axes, warn_different_wcs=autoalign is False) # Normal plot plot_settings = copy.deepcopy(self.plot_settings) if 'title' in plot_settings: plot_settings_title = plot_settings.pop('title') else: plot_settings_title = self.latex_name # Anything left in plot_settings is given to imshow imshow_args = plot_settings if annotate: if title is True: title = plot_settings_title if title: axes.set_title(title) # WCSAxes has unit identifiers on the tick labels, so no need # to add unit information to the label spatial_units = [None, None] ctype = axes.wcs.wcs.ctype axes.set_xlabel(axis_labels_from_ctype(ctype[0], spatial_units[0])) axes.set_ylabel(axis_labels_from_ctype(ctype[1], spatial_units[1])) # Take a deep copy here so that a norm in imshow_kwargs doesn't get modified # by setting it's vmin and vmax imshow_args.update(copy.deepcopy(imshow_kwargs)) if clip_interval is not None: vmin, vmax = _clip_interval(self.data, clip_interval) imshow_args['vmin'] = vmin imshow_args['vmax'] = vmax if (norm := imshow_args.get('norm', None)) is not None: _handle_norm(norm, imshow_args) if self.mask is None: data = self.data else: data = np.ma.array(np.asarray(self.data), mask=self.mask) if autoalign == 'pcolormesh': # We have to handle an `aspect` keyword separately axes.set_aspect(imshow_args.get('aspect', 1)) # pcolormesh does not do interpolation if imshow_args.get('interpolation', None) not in [None, 'none', 'nearest']: warn_user("The interpolation keyword argument is ignored when using autoalign " "functionality.") # Remove imshow keyword arguments that are not accepted by pcolormesh for item in ['aspect', 'extent', 'interpolation', 'origin']: if item in imshow_args: del imshow_args[item] imshow_args.setdefault('transform', axes.get_transform(self.wcs)) # The quadrilaterals of pcolormesh can slightly overlap, which creates the appearance # of a grid pattern when alpha is not 1. These settings minimize the overlap. if imshow_args.get('alpha', 1) != 1: imshow_args.setdefault('antialiased', True) imshow_args.setdefault('linewidth', 0) ret = axes.pcolormesh(np.arange(data.shape[1] + 1) - 0.5, np.arange(data.shape[0] + 1) - 0.5, data, **imshow_args) else: ret = axes.imshow(data, **imshow_args) wcsaxes_compat.default_wcs_grid(axes) # Set current axes/image if pyplot is being used (makes colorbar work) for i in plt.get_fignums(): if axes in plt.figure(i).axes: plt.sca(axes) plt.sci(ret) return ret
[docs] @deprecated(since="6.1", alternative="sunpy.map.GenericMap.get_contours") def contour(self, level, **kwargs): """ Returns coordinates of the contours for a given level value. For details of the contouring algorithm see `skimage.measure.find_contours`. Parameters ---------- level : float, `~astropy.units.Quantity` Value along which to find contours in the array. If the map unit attribute is not `None`, this must be a `~astropy.units.Quantity` with units equivalent to the map data units. kwargs : Additional keyword arguments are passed to `skimage.measure.find_contours`. Returns ------- contours: list of (n,2) `~astropy.coordinates.SkyCoord` Coordinates of each contour. Examples -------- >>> import astropy.units as u >>> import sunpy.map >>> import sunpy.data.sample # doctest: +REMOTE_DATA >>> aia = sunpy.map.Map(sunpy.data.sample.AIA_171_IMAGE) # doctest: +REMOTE_DATA >>> contours = aia.contour(50000 * u.DN) # doctest: +REMOTE_DATA +IGNORE_WARNINGS >>> contours[0] # doctest: +REMOTE_DATA +IGNORE_WARNINGS <SkyCoord (Helioprojective: obstime=2011-06-07T06:33:02.880, rsun=696000.0 km, observer=<HeliographicStonyhurst Coordinate (obstime=2011-06-07T06:33:02.880, rsun=696000.0 km): (lon, lat, radius) in (deg, deg, m) (-0.00406429, 0.04787238, 1.51846026e+11)>): (Tx, Ty) in arcsec [(719.59798458, -352.60839064), (717.19243987, -353.75348121), (715.8820808 , -354.75140718), (714.78652558, -355.05102034), (712.68209174, -357.14645009), (712.68639008, -359.54923801), (713.14112796, -361.95311455), (714.76598031, -363.53013567), (717.17229147, -362.06880784), (717.27714042, -361.9631112 ), (718.43620686, -359.56313541), (718.8672722 , -357.1614 ), (719.58811599, -356.68119768), (721.29217122, -354.76448374), (719.59798458, -352.60839064)]> See Also -------- skimage.measure.find_contours """ from skimage import measure level = self._process_levels_arg(level) if level.size != 1: raise ValueError("level must be a single scalar value") else: # _process_levels_arg converts level to a 1D array, but # find_contours expects a scalar below level = level[0] contours = measure.find_contours(self.data, level=level, **kwargs) contours = [self.wcs.array_index_to_world(c[:, 0], c[:, 1]) for c in contours] return contours
[docs] def get_contours(self, level, method='contourpy', **kwargs): """ Returns coordinates of the contours for a given level value. For details of the contouring algorithm, see :func:`contourpy.contour_generator` or :func:`contourpy.contour_generator`. Parameters ---------- level : float, astropy.units.Quantity Value along which to find contours in the array. If the map unit attribute is not `None`, this must be a `~astropy.units.Quantity` with units equivalent to the map data units. method : {'contourpy', 'skimage'} Determines which contouring method is used and should be specified as either 'contourpy' or 'skimage'. Defaults to 'contourpy'. kwargs : Additional keyword arguments passed to either :func:`contourpy.contour_generator` or :func:`skimage.measure.find_contours`, depending on the value of the ``method`` argument. Returns ------- contours: list of (n,2) `~astropy.coordinates.SkyCoord` Coordinates of each contour. Examples -------- >>> import astropy.units as u >>> import sunpy.map >>> import sunpy.data.sample # doctest: +REMOTE_DATA >>> aia = sunpy.map.Map(sunpy.data.sample.AIA_171_IMAGE) # doctest: +REMOTE_DATA >>> contours = aia.get_contours(50000 * u.DN, method='contourpy') # doctest: +REMOTE_DATA >>> contours[0] # doctest: +REMOTE_DATA <SkyCoord (Helioprojective: obstime=2011-06-07T06:33:02.880, rsun=696000.0 km, observer=<HeliographicStonyhurst Coordinate (obstime=2011-06-07T06:33:02.880, rsun=696000.0 km): (lon, lat, radius) in (deg, deg, m) (-0.00406429, 0.04787238, 1.51846026e+11)>): (Tx, Ty) in arcsec [(713.14112796, -361.95311455), (714.76598031, -363.53013567), (717.17229147, -362.06880784), (717.27714042, -361.9631112 ), (718.43620686, -359.56313541), (718.8672722 , -357.1614 ), (719.58811599, -356.68119768), (721.29217122, -354.76448374), (722.00110323, -352.46446792), (722.08933899, -352.36363319), (722.00223989, -351.99536019), (721.67724425, -352.36263712), (719.59798458, -352.60839064), (717.19243987, -353.75348121), (715.8820808 , -354.75140718), (714.78652558, -355.05102034), (712.68209174, -357.14645009), (712.68639008, -359.54923801), (713.14112796, -361.95311455)]> See Also -------- :func:`contourpy.contour_generator` :func:`skimage.measure.find_contours` """ level = self._process_levels_arg(level) if level.size != 1: raise ValueError("level must be a single scalar value") else: # _process_levels_arg converts level to a 1D array, but # find_contours expects a scalar below level = level[0] if method == 'contourpy': from contourpy import contour_generator gen = contour_generator(z=self.data, **kwargs) contours = gen.lines(level) contours = [self.wcs.array_index_to_world(c[:, 1], c[:, 0]) for c in contours] elif method == 'skimage': from skimage import measure contours = measure.find_contours(self.data, level=level, **kwargs) contours = [self.wcs.array_index_to_world(c[:, 0], c[:, 1]) for c in contours] else: raise ValueError(f"Unknown method '{method}'. Use 'contourpy' or 'skimage'.") return contours
def _check_axes(self, axes, warn_different_wcs=False): """ - If axes is None, get the current Axes object. - Error if not a WCSAxes. - Return axes. Parameters ---------- axes : matplotlib.axes.Axes Axes to validate. warn_different_wcs : bool If `True`, warn if the Axes WCS is different from the Map WCS. This is only used for `.plot()`, and can be removed once support is added for plotting a map on a different WCSAxes. """ if not axes: axes = wcsaxes_compat.gca_wcs(self.wcs) if not wcsaxes_compat.is_wcsaxes(axes): raise TypeError("The axes need to be an instance of WCSAxes. " "To fix this pass set the `projection` keyword " "to this map when creating the axes.") elif warn_different_wcs and not axes.wcs.wcs.compare(self.wcs.wcs, tolerance=0.01): warn_user('The map world coordinate system (WCS) is different from the axes WCS. ' 'The map data axes may not correctly align with the coordinate axes. ' 'To automatically transform the data to the coordinate axes, specify ' '`autoalign=True`.') return axes
[docs] def reproject_to(self, target_wcs, *, algorithm='interpolation', return_footprint=False, **reproject_args): """ Reproject the map to a different world coordinate system (WCS) .. note:: This method requires the optional package `reproject` to be installed. Additional keyword arguments are passed through to the reprojection function. Parameters ---------- target_wcs : `dict` or `~astropy.wcs.WCS` The destination FITS WCS header or WCS instance algorithm : `str` One of the supported `reproject` algorithms (see below) return_footprint : `bool` If ``True``, the footprint is returned in addition to the new map. Defaults to ``False``. Returns ------- outmap : `~sunpy.map.GenericMap` The reprojected map footprint : `~numpy.ndarray` Footprint of the input arary in the output array. Values of 0 indicate no coverage or valid values in the input image, while values of 1 indicate valid values. Intermediate values indicate partial coverage. Only returned if ``return_footprint`` is ``True``. Notes ----- The reprojected map does not preserve any metadata beyond the WCS-associated metadata. The supported `reproject` algorithms are: * 'interpolation' for :func:`~reproject.reproject_interp` * 'adaptive' for :func:`~reproject.reproject_adaptive` * 'exact' for :func:`~reproject.reproject_exact` See the respective documentation for these functions for additional keyword arguments that are allowed. .. minigallery:: sunpy.map.GenericMap.reproject_to """ # Check if both context managers are active if ACTIVE_CONTEXTS.get('propagate_with_solar_surface', False) and ACTIVE_CONTEXTS.get('assume_spherical_screen', False): warn_user("Using propagate_with_solar_surface and SphericalScreen together result in loss of off-disk data.") try: import reproject except ImportError as exc: raise ImportError("This method requires the optional package `reproject`.") from exc if not isinstance(target_wcs, astropy.wcs.WCS): target_wcs = astropy.wcs.WCS(target_wcs) # Select the desired reprojection algorithm functions = {'interpolation': reproject.reproject_interp, 'adaptive': reproject.reproject_adaptive, 'exact': reproject.reproject_exact} if algorithm not in functions: raise ValueError(f"The specified algorithm must be one of: {list(functions.keys())}") func = functions[algorithm] # reproject does not automatically grab the array shape from the WCS instance if target_wcs.array_shape is not None: reproject_args.setdefault('shape_out', target_wcs.array_shape) # Reproject the array output_array = func(self, target_wcs, return_footprint=return_footprint, **reproject_args) if return_footprint: output_array, footprint = output_array # Create and return a new GenericMap outmap = GenericMap(output_array, target_wcs.to_header(), plot_settings=self.plot_settings) # Check rsun mismatch if self.rsun_meters != outmap.rsun_meters: warn_user("rsun mismatch detected: " f"{self.name}.rsun_meters={self.rsun_meters}; {outmap.name}.rsun_meters={outmap.rsun_meters}. " "This might cause unexpected results during reprojection.") if return_footprint: return outmap, footprint return outmap
GenericMap.__doc__ += textwrap.indent(_notes_doc, " ") class InvalidHeaderInformation(ValueError): """Exception to raise when an invalid header tag value is encountered for a FITS/JPEG 2000 file."""