"""
Base class for a datacube

----

.. include license and copyright
.. include:: ../include/copy.rst

----

.. include common links, assuming primary doc root is up one directory
.. include:: ../include/links.rst
"""

# TODO: Pilfer the pypeit.DataContainer for this.

# TODO: Force the datacube data arrays to be read-only?

import warnings

from IPython import embed

import numpy

from scipy import sparse, interpolate

from astropy.io import fits
import astropy.constants

from ..util.bitmask import BitMask
from ..util.mapping import permute_wcs_axes
from ..util.covariance import Covariance
from ..util.pixelmask import SpectralPixelMask
from ..util.sampling import angstroms_per_pixel
from ..util.geometry import polygon_area

class DataCube:
    r"""
    Base container class for a rectilinear datacube.

    Datacubes have three axes: two spatial coordinates and one spectral. The
    datacubes are expected to be rectilinear; i.e., each spatial position has
    the same spectral range and each wavelength channel has the same spatial
    coordinates.

    On input, the ordering of the three dimensions can be arbitrary; however,
    the axes are re-ordered for the internal attributes such that wavelengths
    are ordered along the last axis, with the first two axes being the
    spatial coordinates. Nominally the spatial coordinates are ordered
    predominantly coincident with right-ascension (E toward smaller pixels
    numbers in axis 0) and declination (N toward larger pixel numbers in axis
    1); however, rotation of the datacube spatial coordinates can be non-zero
    with respect to the celestial coordinates, as given by the provided
    world-coordinate system.

    The wavelength vector applicable to all spatial positions can either be
    provided directly or constructed from the provided WCS. Any directly
    provided ``wave`` vector takes precedence over the WCS.

    Spatial covariance/correlation can be provided via the ``covar`` keyword
    argument. If provided as a single array, the provided array is used to
    define the *correlation* matrix and is assumed to be identical for all
    wavelength channels. More options are available if the input is provided
    as a :class:`~mangadap.util.covariance.Covariance` object. The ordering
    of the covariance/correlation matrix is expected to provide the
    correlation between the row-major flattened version of the spatial
    coordinates. That is, for a datacube with spatial shape
    :math:`(N_x,N_y)`, the covariance/correlation value at location
    :math:`i,j` is the correlation between pixels at 2D locations
    :math:`(i_x,i_y)` and :math:`(j_x,j_y)`, where

    .. math::

        \begin{array}{rcl}
        i_x & = & \lfloor i/N_y \rfloor, \\
        i_y & = & i - i_x N_y, \\
        j_x & = & \lfloor j/N_y \rfloor, {\rm and} \\
        j_y & = & j - j_x N_y.
        \end{array}

    If the values of ``axes`` indicates that the provided flux array should
    have it's spatial axes transposed (i.e. ``axes[0] > axes[1]``), the
    provided covariance/correlation data is appropriately restructured to
    correspond to the wavelength channels in :attr:`flux` (again assuming a
    flattened row-major memory block); see
    :func:`~mangadap.util.covariance.Covariance.transpose_raw_shape`.
    
    See :ref:`datacube_subclass` for instructions on subclassing this object for
    non-MaNGA datacubes.

    Args:
        flux (`numpy.ndarray`_):
            Data array with the flux as a function of spatial and
            spectral position. Shape should be :math:`(N_x, N_y,
            N_\lambda)`.
        wave (`numpy.ndarray`_, optional):
            The wavelength vector associated with each spatial
            position. Shape is :math:`(N_\lambda,)`. If None, ``wcs``
            must be provided such that the wavelength vector can be
            constructed.
        ivar (`numpy.ndarray`_, optional):
            The inverse variance of the flux array. Shape must be the
            same as ``flux``.  If None, errors are ignored.
        mask (`numpy.ndarray`_, optional):
            Mask array for the flux measurements. Can be a boolean
            mask (False=unmasked,good; True=masked,bad) or an integer
            array associated with the provided
            :class:`mangadap.util.bitmask.BitMask` object (see
            ``bitmask``). Shape must be the same as ``flux``.
        bitmask (:class:`mangadap.util.bitmask.BitMask`, optional):
            Object used to select and toggle masked pixels from
            :attr:`mask`. If None, any provided ``mask`` must be a
            boolean array or can be successfully converted to one.
        sres (`numpy.ndarray`_, optional):
            The spectral resolution, :math:`R = \lambda /
            \Delta\lambda`. Can be a single vector with shape
            :math:`(N_\lambda,)`, if the spectral resolution is
            independent of spatial position, or a 3D datacube with a
            spatially and spectrally dependent resolution with a
            shape that matches ``flux``. If None, spectral resolution
            is ignored.
        covar (array-like, :class:`mangadap.util.covariance.Covariance`, optional):
            The spatial covariance in one or more wavelength
            channels. If None, no covariance is assumed. See usage
            and interpretation in class description.
        axes (:obj:`list`, optional):
            The axes with the :math:`x`, :math:`y`, and
            :math:`\lambda` in the provided flux array. For example,
            ``[2,1,0]`` means the spectra are organized along the
            first axis.
        wcs (`astropy.wcs.WCS`_, optional):
            World-coordinate system for the 3D datacube. If None,
            spatial coordinates are set to be centered with a
            pixelscale in arcsec (see ``pixelscale``).
        pixelscale (:obj:`int`, :obj:`float`, optional):
            Spatial extent in arcsec of each spaxel in the datacube,
            assumed to be square. Superseded by ``wcs``, if the
            latter is provided. If the ``wcs`` is not provided and
            the pixelscale is not provided upon instantiation, it is
            set to unity.
        log (:obj:`bool`, optional):
            Flag that the datacube spectral pixels are binned
            logarithmically in wavelength.
        meta (:obj:`dict`, optional):
            A free-form dictionary used to hold metadata relevant to the
            datacube. Metadata required by analysis modules are indicated where
            relevant. If None, :attr:`meta` is instantiated as an empty
            dictionary and populated with the bare minimum needed to execute the
            DAP.  NOTE: Currently this is a required argument because an
            estimate of the redshift is needed to execute the DAP.
        prihdr (`astropy.io.fits.Header`_, optional):
            Primary header read from datacube fits file. If None,
            instantiated as an empty `astropy.io.fits.Header`_.
        fluxhdr (`astropy.io.fits.Header`_, optional):
            Header specifically for the flux extension of the
            datacube fits file. If None, set to be a copy of the
            primary header.

    Raises:
        ValueError:
            Raised if the wavelength vector cannot be produced, if
            the WCS has the wrong dimensionality, or if there are any
            shape mismatches between the input data arrays.
        TypeError:
            Raised if the input metadata are not provided as a
            dictionary.

    Attributes:
        original_axes (`numpy.ndarray`_):
            Original axis order.
        shape (:obj:`tuple`):
            Datacube shape.
        spatial_shape (:obj:`tuple`):
            Shape of the datacube spatial axes .
        nwave (:obj:`int`):
            Number of wavelength channels.
        spatial_index (`numpy.ndarray`_):
            Array of tuples with the spatial indices of each spectrum
            in the flattened datacube.
        wcs (`astropy.wcs.WCS`_):
            Datacube world-coordinate system.  Can be None.
        pixelscale (:obj:`float`):
            Spatial extent in arcsec of each spaxel in the datacube,
            assumed to be square. Only used if :attr:`wcs` is None.
        bitmask (:class:`mangadap.util.bitmask.BitMask`):
            Object used to select and toggle masked pixels from
            :attr:`mask`.  Can be None.
        log (:obj:`bool`):
            Flag that the datacube spectral pixels are binned
            logarithmically in wavelength.
        meta (:obj:`dict`):
            A free-form dictionary used to hold meta data relevant to
            the datacube. Metadata required by analysis modules are
            indicated where relevant. If no metadata has been
            defined, :attr:`meta` is instantiated as an empty
            dictionary.
        wave (`numpy.ndarray`_):
            Wavelength vector applicable to all spatial positions.
        flux (`numpy.ndarray`_):
            Datacube flux array.
        ivar (`numpy.ndarray`_):
            Datacube flux inverse variance.  Can be None.
        mask (`numpy.ndarray`_):
            Datacube mask.  Can be None.
        sres (`numpy.ndarray`_):
            Datacube spectral resolution.  Can be None.
        covar (:class:`mangadap.util.covariance.Covariance`):
            Datacube spatial covariance.  Can be None.
        prihdr (`astropy.io.fits.Header`_):
            Primary header for the datacube. If not provided on
            instantiation, set to an empty `astropy.io.fits.Header`_.
        fluxhdr (`astropy.io.fits.Header`_):
            Header specifically for the flux array. If not provided
            on instantiation, set to be a copy of :attr:`prihdr`.
        rss (:class:`mangadap.spectra.rowstackedspectra.RowStackedSpectra`):
            The source row-stacked spectra used to build the
            datacube.
        sigma_rho (:obj:`float`):
            The :math:`\sigma_{\rho}` of the Gaussian function used
            to approximate the trend of the correlation coefficient
            with spaxel separation. Used to construct the approximate
            correlation matrix (see
            :func:`approximate_correlation_matrix`).
        correl_rlim (:obj:`float`):
            The limiting radius of the image reconstruction
            (Gaussian) kernel in arcseconds. Used to construct the
            approximate correlation matrix (see
            :func:`approximate_correlation_matrix`).
        approx_correl (:class:`~mangadap.util.covariance.Covariance`):
            Approximate correlation matrix; see
            :func:`approximate_correlation_matrix`.
    """

    instrument = None
    """
    The name of the instrument used to collect the data.  This is *only* used in
    construction of the root name for output files.  See
    :func:`~mangadap.datacube.datacube.DataCube.output_root`.
    """

    # TODO: Add reconstructed PSF?
    def __init__(self, flux, wave=None, ivar=None, mask=None, bitmask=None, sres=None, covar=None,
                 axes=[0,1,2], wcs=None, pixelscale=None, log=True, meta=None, prihdr=None,
                 fluxhdr=None, name=None):

        # Set an identifier for the datacube
        self.name = name

        if wcs is None and wave is None:
            raise ValueError('Must either provide a single wavelength vector or a WCS that can '
                             'be used to construct it.')
        if wcs is not None and wcs.wcs.naxis != 3:
            raise ValueError('Provided WCS object must define the coordinate system for each of '
                             'the three datacube axes.')
        self.wcs = None if wcs is None else permute_wcs_axes(wcs, axes)
        self.pixelscale = self._get_pixelscale() if self.wcs is not None and pixelscale is None \
                                else (1 if pixelscale is None else float(pixelscale))

        self.original_axes = numpy.atleast_1d(axes).copy()
        # Re-order so that axes are x, y, lambda
        self.flux = flux.transpose(self.original_axes)
        self.shape = self.flux.shape
        self.spatial_shape = self.shape[:-1]
        self.nwave = self.shape[-1]

        # TODO: Not sure this is useful
        self.spatial_index = numpy.array([(i,j) for i,j in zip(*numpy.unravel_index(
                                                numpy.arange(self.nspec), self.spatial_shape))])

        self.meta = {} if meta is None else meta
        if not isinstance(self.meta, dict):
            raise TypeError('Metadata must be provided as a dictionary.')
        self.populate_metadata()

        self.wave = None if wave is None else numpy.atleast_1d(wave)
        if self.wave is None:
            self.wave = self._get_wavelength_vector()
        if self.wave.shape != (self.nwave,):
            raise ValueError('Wavelength vector shape is incorrect ')
        self.log = log
        # TODO: Check the above against the wavelength vector and/or
        # set the value automatically based on the input.
        
        self.ivar = None 
        if ivar is not None:
            self.ivar = ivar.transpose(self.original_axes)
            if self.ivar.shape != self.shape:
                raise ValueError('Inverse variance array has incorrect shape.')

        self.mask = None
        if mask is not None:
            self.mask = mask.transpose(self.original_axes)
            if self.mask.shape != self.shape:
                raise ValueError('Mask array has incorrect shape.')
        self.bitmask = None
        if bitmask is not None:
            if self.mask is None:
                warnings.warn('Bitmask provided but no mask data available.')
            if isinstance(bitmask, BitMask):
                self.bitmask = bitmask
            else:
                warnings.warn('Bitmasks must be BitMask objects; provided bitmask is ignored.')
        if self.bitmask is None:
            # Ensure mask is a boolean array
            self.mask = self.mask.astype(bool)

        self.redux_bitmask = None
        self.redux_qual_key = None
        self.redux_qual_flag = None
        
        self.sres = None
        if sres is not None:
            self.sres = numpy.tile(sres, self.spatial_shape+(1,)) \
                            if sres.ndim == 1 else sres.transpose(self.original_axes)
            if self.sres.shape != self.shape:
                raise ValueError('Spectral resolution array has incorrect shape.')

        self.covar = None
        if covar is not None:
            spatial_transpose = self.original_axes[0] > self.original_axes[1]
            raw_shape = self.spatial_shape[::-1] if spatial_transpose else self.spatial_shape
            self.covar = covar if isinstance(covar, Covariance) \
                            else Covariance.from_array(covar, raw_shape=raw_shape)
            if spatial_transpose:
                self.covar = self.covar.transpose_raw_shape()

        # Allocate attributes for primary and flux array fits headers
        self.prihdr = fits.Header() if prihdr is None else prihdr
        self.fluxhdr = self.prihdr.copy() if fluxhdr is None else fluxhdr

        # Allow for a RowStackedSpectrum counterpart
        self.rss = None

        # For the approximate covariance matrix calculations
        self.sigma_rho = None
        self.correl_rlim = None
        self.approx_correl = None

    @property
    def file_path(self):
        """
        Return the full path to the input file.

        .. warning::

            The required attributes for this method are *not* defined by the
            base class.  If the attributes ``directory_path`` or ``file_name``
            do not exist or if either of them are None, this returns None.
        """
        try:
            return self.directory_path / self.file_name
        except:
            warnings.warn('File path for input cube not defined!')
            return None

        # TODO: Unsure why, but the code below can lead to maximum recursion depth error!
#        if not hasattr(self, 'directory_path') or not hasattr(self, 'file_path') \
#                or self.directory_path is None or self.file_name is None:
#            return None
#        return self.directory_path / self.file_name

    @property
    def output_root(self):
        if self.instrument is None and self.name is None:
            raise ValueError('Output root is undefined.  Must provide instrument and/or name.')
        if self.instrument is not None and self.name is not None:
            return f'{self.instrument}-{self.name}'
        if self.instrument is not None:
            return f'{self.instrument}'
        return f'{self.name}'

    @property
    def can_analyze(self):
        """
        Confirm that the DAP can analyze the datacube.

        The only requirement is:

            - the :math:`cz` velocity (``'vel'`` in :attr:`meta`) must be
              greater than -500 km/s.

        Returns:
            :obj:`bool`: Flag whether or not the DAP can analyze the datacube.

        """
        return self.meta['vel'] is not None and self.meta['vel'] > -500

    def populate_metadata(self):
        r"""
        Populate and validate the :class:`DataCube` metadata (in :attr:`meta`)
        to ensure the it can be analyzed by the DAP.

        The only required metadata keyword is ``z``, which sets the initial
        guess for the bulk redshift of the galaxy.  If this key is not available
        or its value doesn't meet the criterion below, this function will raise
        an exception, meaning the DAP will fault before it starts processing the
        DataCube.

        The metadata provided must meet the following **critical criteria** or
        the method will fault:

            - Velocity (:math:`cz`) must be greater than -500.

        For the remainder of the metadata, if the keyword does not exist, if the
        value is None, or if the value is outside the accepted range, a default
        is chosen. The metadata keywords, acceptable ranges, and defaults are
        provided below.

        +-------------+--------------------------------+---------+
        |   Keyword   |                          Range | Default |
        +=============+================================+=========+
        | ``vdisp``   |             :math:`\sigma > 0` |     100 |
        +-------------+--------------------------------+---------+
        | ``ell``     | :math:`0 \leq \varepsilon < 1` |    None |
        +-------------+--------------------------------+---------+
        | ``pa``      |    :math:`0 \leq \phi_0 < 360` |    None |
        +-------------+--------------------------------+---------+
        | ``reff``    |        :math:`R_{\rm eff} > 0` |    None |
        +-------------+--------------------------------+---------+
        | ``ebv``     |          :math:`E(B-V) \geq 0` |    None |
        +-------------+--------------------------------+---------+
        | ``drpcrit`` |                  True or False |   False |
        +-------------+--------------------------------+---------+

        All other metadata in :attr:`meta` are ignored.

        If they do not already exist in :attr:`meta`, this method adds the
        following keywords:

            - ``z``: The bulk redshift of the galaxy, used to calculate
              :math:`cz`.

            - ``vel``: The initial guess velocity (:math:`cz`) in km/s.

            - ``vdisp``: The initial guess velocity dispersion in km/s.

            - ``ell``: The isophotal ellipticity (:math:`1-b/a`) to use when
                calculating semi-major axis coordinates.

            - ``pa``: The isophotal position angle in deg from N through E, used
              when calculating semi-major axis coordinates.

            - ``reff``: The effective radius in arcsec (DataCube WCS coordinates
              are expected to be in deg), used as a normalization of the
              semi-major axis radius in various output data.

            - ``ebv``: The E(B-V) Galactic reddening along the line-of-sight to
              the galaxy.

            - ``drpcrit``: A flag that indicates critical failures in the data
              reduction (True implies poor data quality).  In MaNGA, the DRP will
              flag datacubes as having CRITICAL failures, but the survey
              approach was to run the DAP on these datacubes anyway.  This flag
              is then used to propagate the caution in using the data analysis
              products to the user.
        """
        keys = self.meta.keys()
        if 'z' not in keys or self.meta['z'] is None:
            warnings.warn('The bulk redshift has not been defined.  The DAP will *not* be able '
                          'to analyze this datacube.')
            self.meta['z'] = None
        self.meta['vel'] = None if self.meta['z'] is None \
                            else astropy.constants.c.to('km/s').value * self.meta['z']

        if 'vdisp' not in keys or self.meta['vdisp'] is None or not self.meta['vdisp'] > 0:
            warnings.warn('Velocity dispersion not provided or not greater than 0 km/s.  '
                        'Adopting initial guess of 100 km/s.')
            self.meta['vdisp'] = 100.0

        if 'ell' not in keys or self.meta['ell'] is None or self.meta['ell'] < 0 \
                or self.meta['ell'] > 1:
            warnings.warn('Ellipticity not provided or not in the range 0 <= ell <= 1.  '
                        'Setting to None.')
            self.meta['ell'] = None

        if 'pa' not in keys or self.meta['pa'] is None:
            warnings.warn('Position angle not provided. Setting to None.')
            self.meta['pa'] = None

        # Impose expected range
        if self.meta['pa'] is not None and (self.meta['pa'] < 0 or self.meta['pa'] >= 360):
            warnings.warn('Imposing 0-360 range on position angle.')
            while self.meta['pa'] < 0:
                self.meta['pa'] += 360
            while self.meta['pa'] >= 360:
                self.meta['pa'] -= 360

        if 'reff' not in keys or self.meta['reff'] is None or not self.meta['reff'] > 0:
            warnings.warn('Effective radius not provided or not greater than 0. Setting to None.')
            self.meta['reff'] = None

        if 'ebv' not in keys or self.meta['ebv'] is None or self.meta['ebv'] < 0:
            warnings.warn('Galactic reddening not provided or not >= 0. Setting to None.')
            self.meta['ebv'] = None

        if 'drpcrit' not in keys or not isinstance(self.meta['drpcrit'], bool):
            warnings.warn('Data reduction quality flag not provided or not a boolean.  Setting '
                          'quality flag to False (i.e., data reduction is expected to be high '
                          'quality).')
            self.meta['drpcrit'] = False

        if not self.can_analyze:
            warnings.warn('The DAP will not be able to analyze this datacube.  See '
                          '"DataCube.can_analyze".')

    @classmethod
    def from_config(cls, cfgfile, **kwargs):
        """
        Construct a datacube object using a configuration file.

        The method is undefined for the base class. If called, an
        exception is raised. Derived classes must override this
        method to allow for a configuration file to be used to
        instantiate the relevant :class:`DataCube` subclass.

        Args:
            cfgfile (:obj:`str`):
                Configuration file. See `configparser.ConfigParser`_.
            **kwargs:
                Any other keyword arguments that invoke optional
                instantiation methods. Note that these arguments will
                *never* be used in a command-line level execution of
                the DAP. They should only be available for custom
                scripts.
        """
        raise NotImplementedError('No from_config method is available for {0}.'.format(
                                  cls.__name__))

    # TODO: write a from_rss classmethod

    def _get_pixelscale(self):
        """
        Measure the pixel scale using the WCS.

        The method uses the coordinates of 4 adjacent pixels to
        compute the pixel area. Assuming the pixels are square and
        that the pixel scale is constant in square arcsec over the
        full image, the square-root of the area is the pixel scale.

        The method will fault if :attr:`wcs` is None.

        Returns:
            :obj:`float`: The estimated pixel scale.
        """
        # TODO: Instead get the mean over the full image?
        coo = numpy.array([[1,1,2,2], [1,2,2,1], [1,1,1,1]]).T
        x, y, _ = self.wcs.all_pix2world(coo, 1).T
        x = (x - x[0])*numpy.cos(numpy.radians(y[0]))
        return numpy.sqrt(polygon_area(x, y))*3600

    def _get_wavelength_vector(self):
        """
        Use the `astropy.wcs.WCS`_ attribute (:attr:`wcs`) to
        generate the datacube wavelength vector.

        :attr:`wcs` cannot be None and the wavelength coordinate
        system must be defined along its third axis.

        Returns:
            `numpy.ndarray`_: Vector with wavelengths along the third
            axis defined by :attr:`wcs`.

        Raises:
            ValueError:
                Raised if :attr:`wcs` or :attr:`nwave` is not defined.
        """
        if self.nwave is None:
            raise ValueError('Length of the spectral axis (nwave) must be defined.')
        if self.wcs is None:
            raise ValueError('World coordinate system required to construct wavelength vector.')
        coo = numpy.array([numpy.ones(self.nwave), numpy.ones(self.nwave),
                           numpy.arange(self.nwave)+1]).T
        return self.wcs.all_pix2world(coo, 1)[:,2]*self.wcs.wcs.cunit[2].to('angstrom')

    @property
    def nspec(self):
        """Number of spectra in the datacube."""
        return numpy.prod(self.spatial_shape)

    @staticmethod
    def propagate_flags():
        """
        Flags that should be propagated from the observed data to the analyzed data.

        The base class returns ``None``.
        """
        return None

    @staticmethod
    def do_not_use_flags():
        """
        Return the maskbit names that should not be used.

        The base class returns ``None``.
        """
        return None

    @staticmethod
    def do_not_fit_flags():
        """
        Return the maskbit names that should not be fit.

        The base class returns ``None``.
        """
        return None

    @staticmethod
    def do_not_stack_flags():
        """
        Return the maskbit names that should not be stacked.
        
        The base class returns ``None``.
        """
        return None

    def metakeys(self):
        """Get a :obj:`list` of the keys in the datacube metadata."""
        return list(self.meta.keys())

    # TODO: Add a getitem method that returns the datacube flux?

    @property
    def can_compute_covariance(self):
        """
        Determine if the object can be used to compute the spatial
        covariance.

        If :attr:`covar` is currently not defined, the method tries
        to load the row-stacked spectra used to build the datacube;
        see :func:`load_rss`. If that is successful or if
        :attr:`covar` is already defined, the method returns True. If
        :attr:`covar` is None and the row-stacked spectra cannot be
        loaded, the method returns False.

        Returns:
            :obj:`bool`: Flag that the object can be used to
            calculate the spatial covariance.
        """
        if self.covar is None:
            self.load_rss()
        if self.covar is None and self.rss is None:
            return False
        return True

    def load_rss(self, **kwargs):
        """
        Try to load the source row-stacked spectra for this datacube.

        This method is undefined in the base class, and simply passes.

        Derived classes should override this method if it's possible
        to load the row-stacked spectra. If the load is successful,
        :attr:`rss` should no longer be None.
        """
        pass

    def copy_to_array(self, attr='flux', waverange=None, nbins=None, select_bins=None,
                      missing_bins=None, unique_bins=None):
        r"""
        Return a copy of the selected data array with a flattened
        spatial axis.

        The array size is always :math:`N_{\rm spec} \times N_{\rm
        wavelength}`. The spatial positions within the original
        datacube for each spectrum are given by tuples in
        :attr:`spatial_index`.

        See :func:`copy_to_masked_array` for argument descriptions.

        .. warning::
            Any masking is ignored in this function call. To
            incorporate the mask use :func:`copy_to_masked_array`.

        Returns:
            `numpy.ndarray`_: A 2D array with a copy of the data from the
            selected attribute.
        """
        masked_data = self.copy_to_masked_array(attr=attr, use_mask=False, waverange=waverange,
                                                nbins=nbins, select_bins=select_bins,
                                                missing_bins=missing_bins, unique_bins=unique_bins)
        # For this approach, the wavelengths masked should be
        # *identical* for all spectra
        nwave = numpy.sum(numpy.invert(numpy.ma.getmaskarray(masked_data)), axis=1)
        # No masking should be present except for the wavelength range
        # meaning that the number of unmasked pixels at all spatial
        # positions should be the same.
        if numpy.any(nwave != nwave[0]):
            raise ValueError('Masking in copy_to_mask should only for the wavelength range.')
        if numpy.all(nwave == 0):
            raise ValueError('Full wavelength range has been masked!')
        # Compression returns a flattened array, so it needs to be
        # reshaped for the new number of (unmasked) wavelength channels
        return masked_data.compressed().reshape(-1,nwave[0])

    def copy_to_masked_array(self, attr='flux', use_mask=True, flag=None, waverange=None,
                             nbins=None, select_bins=None, missing_bins=None, unique_bins=None):
        r"""
        Return a copy of the selected data array as a masked array
        with a flattened spatial axis.

        The array size is always :math:`N_{\rm spec} \times N_{\rm
        wavelength}`. The spatial positions within the original
        datacube for each spectrum are given by tuples in
        :attr:`spatial_index`.

        This is functionally identical to :func:`copy_to_array`,
        except the output format is a `numpy.ma.MaskedArray`_. The
        pixels that are considered to be masked can be specified
        using the `flag` option.
        
        Args:
            attr (:obj:`str`, optional):
                The attribute for the returned array. Can be 'flux',
                'ivar', 'sres', or 'mask'. For 'mask', you're likely
                better off using :func:`copy_to_array`. Strings are
                always set to be lower-case, so capitalization
                shouldn't matter.
            use_mask (:obj:`bool`, optional):
                Use the internal mask to mask the data. This is
                largely here to allow for :func:`copy_to_array` to
                wrap this function while not applying the internal
                mask.
            waverange (array-like, optional):
                Two-element array with the first and last wavelength
                to include in the computation. Default is to use the
                full wavelength range.
            flag (:obj:`str`, :obj:`list`, optional):
                One or more bitmask flags used to select bad pixels.
                The names *must* be a valid bit name as defined by
                :attr:`bitmask` (see
                :class:`mangadap.util.bitmask.BitMask`). If not
                provided, *any* non-zero mask bit is omitted.
            nbins (:obj:`int`, optional):
                The total number of defined bins. Default is to find
                the maximum number in the unique_bins list.
            select_bins (array-like, optional):
                A boolean array selecting spectra in the flattened
                cube (with shape :math:`N_{\rm spec} \times
                N_\lambda`) to return.
            missing_bins (:obj:`list`, optional):
                A list of bin numbers that are missing from the
                output selection and that will be replaced with
                masked spectra. If specified, must also provide
                ``unique_bins``.
            unique_bins (array-like, optional):
                The indices of the bins that have valid spectra. The
                length of this array should match the number of
                selected spectra from ``select_bins``.

        Returns:
            `numpy.ma.MaskedArray`_: A 2D array with a copy of the
            data from the selected extension, masked where
            :attr:`mask` is either True or with the selected bitmask
            flags.

        Raises:
            AttributeError:
                Raised if ``attr`` is not a valid attribute.
            ValueError:
                Raised if the unique bins are not specified, but the
                missing bins are, or if the number of unique bins
                does not match the number of spectra in the datacube.
        """
        # Make sure masks and flags make sense
        if flag is not None and self.bitmask is None:
            warnings.warn('No bitmask defined.  Input flags ignored.')

        nspec = self.nspec

        # Create the wavelength mask (will be all true if
        # waverange=None)
        mask = SpectralPixelMask(waverange=waverange).boolean(self.wave, nspec=nspec)

        # Add in any masked data
        if use_mask:
            mask |= self.mask.reshape(nspec,-1) if self.bitmask is None \
                    else self.bitmask.flagged(self.mask.reshape(nspec,-1), flag=flag)

        # Create the output MaskedArray
        # TODO: Handle when attr is None
        a = numpy.ma.MaskedArray(getattr(self, attr.lower()).reshape(nspec,-1), mask=mask)

        # Apply any bin selection
        if select_bins is not None:
            a = a[select_bins,:]

        # No missing bins are specified, so return the array
        if missing_bins is None or len(missing_bins) == 0:
            return a

        # Missing bins have been specified, so the unique_bins also need
        # to be specified
        if unique_bins is None:
            raise ValueError('Must specify the unique bins if missing bins are specified.')
        if len(unique_bins) != a.shape[0]:
            raise ValueError('Number of unique bins does not match the number of spectra.')

        # If the number of bins hasn't been provided, construct it based
        # on the unique bins specified
        _nbins = max(unique_bins)+1 if nbins is None else nbins

        # Create the array with the right shape and mask everything
        _a = numpy.ma.MaskedArray(numpy.zeros((_nbins, a.shape[-1]), dtype=a.dtype),
                                  mask=numpy.ones((_nbins, a.shape[-1]), dtype=bool))
        # Copy the data to the correct bins, which also unmasks these
        # pixels
        _a[unique_bins,:] = a
        # Return the masked array; "missing bins" are fully masked
        return _a

    def interpolate_to_match(self, func, fill_value=0.0):
        r"""
        Interpolate a function to match the datacube wavelength sampling.

        Args:
            func (`numpy.ndarray`_):
                Function to linear interpolate. Shape is
                :math:`(N_{\rm wave}, 2)`, where the first column has
                the wavelength and the second has the function to
                interpolate. If None, simply returns a unity vector
                of the correct length.
            fill_value (:obj:`float`, optional):
                The value to use for spectral regions not sampled by
                the provided function.

        Returns:
            `numpy.ndarray`_: The function interpolated (**not**
            resampled) to match :attr:`wave`. Any spectral regions
            not within the wavelength range of the provided function
            is set to 0.
        """
        if func is None:
            return numpy.ones(self.nwave, dtype=float)
        return interpolate.interp1d(func[:,0], func[:,1], bounds_error=False,
                                    fill_value=fill_value, assume_sorted=True)(self.wave)

    def mean_sky_coordinates(self, center_coo=None):
        r"""
        Compute the mean sky coordinates for each spectrum.

        If the WCS is available, the coordinates can be returned in
        RA and declination (degrees). If center coordinates are
        provided (see ``center_coo``), however, the coordinates are
        offset set as follows:

        .. math::

            x &= (\alpha - \alpha_0) \cos \delta_0 \\
            y &= (\delta - \delta_0),

        where :math:`(\alpha_0, \delta_0)` are the provided
        coordinates.

        If the WCS is not available, the returned coordinates are in
        arcsec from the center of the image (regardless of the value
        of ``center_coo``) determined using the pixelscale. At least
        in this case, the coordinates are assumed to be relative to
        the pixel center (not, e.g., its edge).

        Args:
            center_coo (:obj:`tuple`, optional):
                A two-tuple with the coordinates in right-ascension
                and declination for the coordinate-frame origin. If
                None, no offset is performed.

        Returns:
            :obj:`tuple`: Two `numpy.ndarray`_ objects with the RA
            and declination of each pixel in degrees, or its offset
            from the center in arcseconds. In both cases the shape of
            the returned arrays matches the spatial shape of the
            datacube.
        """
        i, j = numpy.meshgrid(numpy.arange(self.spatial_shape[0]),
                              numpy.arange(self.spatial_shape[1]), indexing='ij')
        if self.wcs is None:
            return (i-self.spatial_shape[0]//2+(self.spatial_shape[0]%2)*0.5)*self.pixelscale, \
                   (j-self.spatial_shape[1]//2+(self.spatial_shape[1]%2)*0.5)*self.pixelscale

        # Generate pixel coordinates to pass to the WCS; just use the
        # first wavelength channel.
        coo = numpy.array([i.ravel()+1, j.ravel()+1, numpy.ones(self.nspec)]).T
        x, y, _ = [c.reshape(self.spatial_shape) for c in self.wcs.all_pix2world(coo, 1).T]
        if center_coo is None:
            # Not offsetting, so we're done
            return x, y

        # TODO: Use astropy.coordinates.SkyOffset 
        # Offset and return
        if len(center_coo) != 2:
            raise ValueError('Provided coordinates are expected to be a single x,y pair.')
        return (x-center_coo[0]) * numpy.cos(numpy.radians(center_coo[1])) * 3600., \
                    (y-center_coo[1]) * 3600.

    def binned_on_sky_area(self, bin_indx):
        r"""
        Compute the on-sky area of a set of binned spectra.

        For each bin, this is just the number of spaxels in the bin
        multiplied by the spaxel area.

        Args:
            bin_indx (array-like):
                An array with size :math:`N_{\rm spec}` that gives
                which spaxels were included in each bin. Valid bins
                have indices of :math:`\geq 0`.

        Returns:
            :obj:`tuple`: Two `numpy.ndarray`_ objects are returned.
            The first has the unique (non-negative) bin indices, and
            the second provides the on-sky area of that bin.
        """
        unique_bins, bin_count = numpy.unique(bin_indx, return_counts=True)
        indx = unique_bins > -1
        return unique_bins[indx], (bin_count[indx]*numpy.square(self.pixelscale)).astype(float)

    def central_wavelength(self, waverange=None, response_func=None, per_pixel=True, flag=None,
                           fluxwgt=False):
        """
        Determine the mean central wavelength for all spectra under
        various conditions.

        The wavelength channel is set to be the center of the
        bandpass selected by ``waverange``, weighted by the response
        function and the flux (if requested/provided). The mask is
        also incorporated in these calculations. By default (i.e., no
        wavelength limits or weighting) the wavelength channel is
        just the central wavelength of the full spectral range. (Note
        that if the spectra are binned logarithmically, this isn't
        necessarily the central wavelength *channel*.)

        Args:
            waverange (array-like, optional):
                Starting and ending wavelength over which to
                calculate the statistics. Default is to use the full
                wavelength range.
            response_func (array-like, optional):
                A two-column array with the wavelength and
                transmission of a broad-band response function to use
                as a weighting function for the calculation.
            per_pixel (:obj:`bool`, optional):
                When providing a response function, base the
                calculation on per pixel measurements, instead of per
                angstrom. Set to False for a per-angstrom
                calculation.
            flag (:obj:`str`, :obj:`list`, optional):
                One or more flag names that are considered when
                deciding if a pixel should be masked. The names
                *must* be a valid bit name as defined by
                :attr:`bitmask`. If :attr:`bitmask` is None, these
                are ignored.
            fluxwgt (:obj:`bool`, optional):
                Flag to weight by the flux when determining the mean
                coordinates.

        Returns:
            :obj:`float`: The mean central wavelength of all spectra.
        """
        if waverange is None and response_func is None and not fluxwgt:
            return (self.wave[0] + self.wave[-1])/2.

        flux = self.copy_to_masked_array(waverange=waverange, flag=flag)
        dw = numpy.ones(self.nwave, dtype=float) if per_pixel \
                else angstroms_per_pixel(self.wave, log=self.log)
        _response_func = self.interpolate_to_match(response_func)

        if fluxwgt:
            norm = numpy.ma.sum(flux*_response_func[None,:]*dw[None,:], axis=1)
            cen_wave = numpy.ma.sum(flux*self.wave[None,:]*_response_func[None,:]*dw[None,:],
                                    axis=1) / norm
            return float(numpy.ma.mean(cen_wave))

        norm = numpy.ma.sum(numpy.invert(numpy.ma.getmaskarray(flux))
                            * _response_func[None,:] * dw[None,:], axis=1)
        cen_wave = numpy.ma.sum(numpy.invert(numpy.ma.getmaskarray(flux)) * self.wave[None,:]
                                * _response_func[None,:] * dw[None,:], axis=1) / norm
        return float(numpy.ma.mean(cen_wave))

    def flux_stats(self, waverange=None, response_func=None, per_pixel=True, flag=None):
        r"""
        Compute the mean flux, propagated error in the mean flux, and
        mean S/N over the specified wavelength range.

        If the wavelength range is not specified, the quantities are
        calculated over the full spectral range.

        Args:
            waverange (array-like, optional):
                Starting and ending wavelength over which to
                calculate the statistics. Default is to use the full
                wavelength range.
            response_func (array-like, optional):
                A two-column array with the wavelength and
                transmission of a broad-band response function to use
                as a weighting function for the calculation.
            per_pixel (:obj:`bool`, optional):
                When providing a response function, continue to
                calculate the statistics per pixel. Set to False for
                a per-angstrom calculation.
            flag (:obj:`str`, :obj:`list`, optional):
                One or more flag names that are considered when
                deciding if a pixel should be masked. The names
                *must* be a valid bit name as defined by
                :attr:`bitmask`.

        Returns:
            `numpy.ndarray`_: Three objects are returned: the mean
            flux, the propagated variance in the mean flux, and the
            mean S/N. The shape of each is the same as the shape of a
            single wavelength channel in the datacube (i.e.,
            :attr:`spatial_shape`).
    
        Raises:
            ValueError:
                Raised of a provided wavelength range object does not
                have two elements or if a response function is
                provided and has an incorrect shape.
        """
        if waverange is not None and len(waverange) != 2:
            raise ValueError('Provided wavelength range must be a two-element vector.')
        if response_func is not None:
            if len(response_func.shape) != 2:
                raise ValueError('Response function object must be two dimensional.')
            if response_func.shape[1] != 2:
                raise ValueError('Response function object must only have two columns.')

        # Grab the masked arrays
        flux = self.copy_to_masked_array(waverange=waverange, flag=flag)
        ivar = self.copy_to_masked_array(attr='ivar', waverange=waverange, flag=flag)
        snr = flux*numpy.ma.sqrt(ivar)

        # Set the response function
        dw = numpy.ones(self.nwave, dtype=float) if per_pixel \
                else angstroms_per_pixel(self.wave, log=self.log)
        _response_func = self.interpolate_to_match(response_func)

        # Calculate the statistics and return
        response_integral = numpy.sum(numpy.invert(numpy.ma.getmaskarray(flux))
                                        * (_response_func*dw)[None,:], axis=1)
        signal = numpy.ma.divide(numpy.ma.sum(flux*(_response_func*dw)[None,:], axis=1),
                                 response_integral).reshape(self.spatial_shape)
        variance = numpy.ma.divide(numpy.ma.sum(numpy.ma.power(ivar, -1.) \
                                    * (_response_func*dw)[None,:], axis=1),
                                   response_integral).reshape(self.spatial_shape)
        snr = numpy.ma.divide(numpy.ma.sum(snr*(_response_func*dw)[None,:], axis=1),
                              response_integral).reshape(self.spatial_shape)
        return signal, variance, snr

    def covariance_matrix(self, channel, **kwargs):
        """
        Construct the formal covariance matrix for the provided
        channel.

        This is a simple wrapper for a call to
        :func:`mangadap.spectra.rowstackedspectra.RowStackedSpectra.covariance_matrix`
        executed using :attr:`rss`, which cannot be None. See that
        method for the argument description.

        .. warning::

            The provided rectifications parameters should be the same
            as used to construct the datacube. If not, the calculated
            covariance matrix will not be correct.

        """
        if self.rss is None:
            raise ValueError('RowStackedSpectra object not available for this datacube!')
        return self.rss.covariance_matrix(channel, **kwargs)

    def covariance_cube(self, **kwargs):
        """
        Construct the formal covariance matrix for one or more
        channels.

        This is a simple wrapper for a call to
        :func:`mangadap.spectra.rowstackedspectra.RowStackedSpectra.covariance_cube`
        executed using :attr:`rss`, which cannot be None. See that
        method for the argument description.

        .. warning::

            The provided rectifications parameters should be the same
            as used to construct the datacube. If not, the calculated
            covariance matrix will not be correct.

        """
        if self.rss is None:
            raise ValueError('RowStackedSpectra object not available for this datacube!')
        return self.rss.covariance_cube(**kwargs)

    def approximate_correlation_matrix(self, sigma_rho, rlim, rho_tol=None, redo=False):
        r"""
        Construct a correlation matrix with correlation coefficients
        that follow a Gaussian in 2D pixel separation.

        This method constructs a correlation matrix with correlation
        coefficients defined as

        .. math::

            \rho_{ij} = \exp(-D_{ij}^2 / 2\sigma_{\rho}^2)

        where :math:`D_{ij}` is the distance between two spaxels in
        the spatial dimension of the datacube (in the number spaxels,
        not arcsec). Any pixels with :math:`D_{ij} > R_{\rm lim}` is
        set to zero, where :math:`R_{\rm lim}` is the limiting radius
        of the kernel used in the datacube rectification; this is
        provided here as ``rlim`` in arcsec, which is converted to
        spaxels using :attr:`pixelscale`.

        We found in Westfall et al. (2019, AJ, 158, 231) that this is
        a reasonable approximation for the formal covariance that
        results from Shepard's rectification method.

        There is an unknown relation between the dispersion of the
        kernel used by Shepard's method and the value of
        :math:`\sigma_{\rho}`. Tests show that it is tantalizingly
        close to :math:`\sigma_{\rho} = \sqrt{2}\sigma`, where
        :math:`\sigma` is in pixels instead of arcsec; however, a
        formal derivation of this hasn't been done and is complicated
        by the focal-plane sampling of the row-stacked spectra.

        Within the limits of how the focal-plane sampling changes
        with wavelength, we found the value of :math:`\sigma_{\rho}`
        varies little with wavelength in MaNGA. Here, we assume
        :math:`\sigma_{\rho}` is fully wavelength independent.
        Therefore, once this method is run once, it doesn't need to
        be run again for different wavelength channels. To force the
        correlation matrix to be recreated, use ``redo`` or change
        the provided :math:`\sigma_{\rho}`.

        Args:
            sigma_rho (:obj:`float`):
                The :math:`\sigma_{\rho}` of the Gaussian function
                used to approximate the trend of the correlation
                coefficient with spaxel separation.
            rlim (:obj:`float`):
                The limiting radius of the image reconstruction
                (Gaussian) kernel in arcseconds.
            rho_tol (:obj:`float`, optional):
                Any correlation coefficient less than this is assumed
                to be equivalent to (and set to) 0.
            redo (:obj:`bool`, optional):
                Force the recalculation of the cube dimensions if
                they are already defined and :math:`\sigma_{\rho}` has
                not changed.

        Returns:
            :class:`mangadap.util.covariance.Covariance`: Correlation
            matrix
        """
        if self.approx_correl is not None \
                and any([sigma_rho == this for this in [None, self.sigma_rho]]) \
                and any([rlim == this for this in [None, self.correl_rlim]]):
            return self.approx_correl

        if sigma_rho is None or rlim is None:
            raise ValueError('Must provide sigma_rho and rlim for approximate correlation '
                             'matrix calculation.')

        # Get the full covariance grid
        ii, jj = map(lambda x: x.ravel(),
                     numpy.meshgrid(numpy.arange(self.nspec), numpy.arange(self.nspec),
                                    indexing='ij'))

        # Convert covariance pixels to spatial pixels along both dimensions
        i_i, i_j = numpy.unravel_index(ii, self.spatial_shape)
        j_i, j_j = numpy.unravel_index(jj, self.spatial_shape)

        # Get the (square of the) distances from each spaxel to every
        # other spaxel
        dij = numpy.square(j_i-i_i) + numpy.square(j_j-i_j)
        indx = dij <= numpy.square(2*rlim/self.pixelscale)

        # Calculate the correlation coefficient
        ii = ii[indx]
        jj = jj[indx]
        rhoij = numpy.exp(-dij[indx]/numpy.square(sigma_rho)/2)

        if rho_tol is not None:
            indx = rhoij > rho_tol
            ii = ii[indx]
            jj = jj[indx]
            rhoij = rhoij[indx]

        # Construct the Covariance object and save the input
        self.sigma_rho = sigma_rho
        self.correl_rlim = rlim
        self.approx_correl = Covariance(sparse.coo_matrix((rhoij, (ii,jj)),
                                                          shape=(self.nspec,self.nspec)).tocsr(),
                                        impose_triu=True, correlation=True,
                                        raw_shape=self.spatial_shape)
        return self.approx_correl

    def approximate_covariance_matrix(self, channel, sigma_rho=None, rlim=None, rho_tol=None,
                                      csr=False, quiet=False):
        r"""
        Return an approximate calculation of the covariance matrix
        assuming
        
        .. math::

            C_{ij} = \rho_{ij}(V_{i} V_{j})^{1/2}

        where :math:`\rho_{ij}` is approximated by
        :func:`approximate_correlation_matrix` and :math:`V_i\equiv C_{ii}` are
        the variances provided by the inverse of :attr:`ivar`.

        The method first calculates :math:`\rho_{ij}` if it hasn't
        been yet or the provided ``sigma_rho`` and/or ``rlim`` values
        are different to previous calls, which builds
        :attr:`approx_correl`. If :attr:`approx_correl` is not yet
        constructed, ``sigma_rho`` and ``rlim`` must be provided.

        The returned covariance matrix is the correlation matrix
        rescaled by the variance provided by :attr:`ivar` for the
        specified channel.
        
        Args:
            channel (:obj:`int`):
                Index of the spectral channel for which to calculate
                the covariance matrix.
            sigma_rho (:obj:`float`, optional):
                The :math:`\sigma_{\rho}` of the Gaussian function
                used to approximate the trend of the correlation
                coefficient with spaxel separation.
            rlim (:obj:`float`, optional):
                The limiting radius of the image reconstruction
                (Gaussian) kernel in arcseconds.
            rho_tol (:obj:`float`, optional):
                Any correlation coefficient less than this is assumed
                to be equivalent to (and set to) 0.
            csr (:obj:`bool`, optional):
                Instead of returning a
                :class:`mangadap.util.covariance.Covariance` object,
                return the covariance matrix as a
                `scipy.sparse.csr_matrix`_ object. Primarily used by
                :func:`approximate_covariance_cube` for collating the
                covariance matrix of each wavelength channel before
                combining them into a single
                :class:`mangadap.util.covariance.Covariance` object.
            quiet (:obj:`bool`, optional):
                Suppress terminal output

        Returns:
            :class:`mangadap.util.covariance.Covariance`,
            `scipy.sparse.csr_matrix`_: The approximate covariance
            matrix for the designated wavelength channel. The return
            type depends on `csr`.
        """
        self.approximate_correlation_matrix(sigma_rho, rlim, rho_tol=rho_tol)
        var = numpy.ma.power(self.ivar[...,channel], -1).filled(0.0).ravel()
        covar = self.approx_correl.apply_new_variance(var)
        covar.revert_correlation()
        return covar.full() if csr else covar

    def approximate_covariance_cube(self, channels=None, sigma_rho=None, rlim=None, rho_tol=None,
                                    csr=False, quiet=False):
        r"""
        Return the approximate covariance matrices for many
        wavelength channels.

        This is a simple wrapper for
        :func:`approximate_covariance_matrix` that iteratively builds
        the covariance matrix for each wavelength channel.

        If :attr:`approx_correl` is not yet constructed (see
        :func:`approximate_correlation_matrix`), ``sigma_rho`` and
        ``rlim`` must be provided.
        
        Args:
            channels (:obj:`int`, array-like, optional):
                Indices of the spectral channels for which to
                calculate the covariance matrix. If None, the
                covariance matrix is calculated for *all* channels.
            sigma_rho (:obj:`float`, optional):
                The :math:`\sigma_{\rho}` of the Gaussian function
                used to approximate the trend of the correlation
                coefficient with spaxel separation.
            rlim (:obj:`float`, optional):
                The limiting radius of the image reconstruction
                (Gaussian) kernel in arcseconds.
            rho_tol (:obj:`float`, optional):
                Any correlation coefficient less than this is assumed
                to be equivalent to (and set to) 0.
            csr (:obj:`bool`, optional):
                Instead of returning a
                :class:`mangadap.util.covariance.Covariance` object,
                return the covariance matrix as a
                `scipy.sparse.csr_matrix`_ object. Primarily used by
                :func:`approximate_covariance_cube` for collating the
                covariance matrix of each wavelength channel before
                combining them into a single
                :class:`mangadap.util.covariance.Covariance` object.
            quiet (:obj:`bool`, optional):
                Suppress terminal output

        Returns:
            :class:`mangadap.util.covariance.Covariance`,
            `numpy.ndarray`_: If ``csr`` is True, the returned object
            is an `numpy.ndarray`_ of `scipy.sparse.csr_matrix`_
            types.
        """
        # Set the channels
        _channels = numpy.arange(self.nwave) if channels is None \
                        else numpy.atleast_1d(channels)
        nc = len(_channels)

        # Build and return the covariance matrices
        CovCube = numpy.empty(nc, dtype=sparse.csr.csr_matrix)
        for i in range(nc):
            if not quiet:
                print('Calculating covariance matrix: {0}/{1}'.format(i+1,nc), end='\r')
            CovCube[i] = self.approximate_covariance_matrix(_channels[i], sigma_rho=sigma_rho,
                                                            rlim=rlim, rho_tol=rho_tol, csr=True,
                                                            quiet=True)
        if not quiet:
            print('Calculating covariance matrix: {0}/{0}'.format(nc))
        return Covariance(CovCube, input_indx=_channels) if not csr else CovCube

