Source code for moniplot.lib.fig_draw_trend

#
# https://github.com/rmvanhees/moniplot.git
#
# Copyright (c) 2022 SRON - Netherlands Institute for Space Research
#
# License:  GPLv3
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""
This module contains `add_subplot` and `add_hk_subplot`
which are used by `draw_trend`.
"""
from __future__ import annotations

__all__ = ['add_subplot', 'add_hk_subplot']

from numbers import Integral
from typing import Iterable

import numpy as np
import xarray as xr
from matplotlib.ticker import AutoMinorLocator, MultipleLocator

from ..tol_colors import tol_cset
from .fig_legend import blank_legend_handle

# - global parameters ------------------------------
CSET = tol_cset('bright')


# - local functions --------------------------------
def set_labels_colors(xarr: xr.DataArray) -> tuple[str, str, str, str]:
    """Determine name and units of housekeeping data and line and fill color.

    Parameters
    ----------
    xarr :  xarray.DataArray

    Returns
    -------
    tuple
       plot parameters: hk_title, hk_label, lcolor, fcolor
    """
    hk_unit = xarr.attrs['units']
    if isinstance(hk_unit, bytes):
        hk_unit = hk_unit.decode()

    hk_title = xarr.attrs['long_name']
    if isinstance(hk_title, bytes):
        hk_title = hk_title.decode()

    if hk_unit == 'K':
        if (ii := hk_title.find(' temperature')) > 0:
            hk_title = hk_title[:ii]
        hk_label = f'temperature [{hk_unit}]'
        lcolor = CSET.blue
        fcolor = '#BBCCEE'
    elif hk_unit in ('A', 'mA'):
        if (ii := hk_title.find(' current')) > 0:
            hk_title = hk_title[:ii]
        hk_label = f'current [{hk_unit}]'
        lcolor = CSET.green
        fcolor = '#CCDDAA'
    elif hk_unit == '%':
        if (ii := hk_title.find(' duty')) > 0:
            hk_title = hk_title[:ii]
        hk_label = f'duty cycle [{hk_unit}]'
        lcolor = CSET.red
        fcolor = '#FFCCCC'
    else:
        hk_label = f'value [{hk_unit}]'
        lcolor = CSET.purple
        fcolor = '#EEBBDD'

    # overwrite ylabel
    if '_ylabel' in xarr.attrs:
        hk_label = xarr.attrs['_ylabel']

    return hk_title, hk_label, lcolor, fcolor


def adjust_ylim(data: np.ndarray | Iterable, err1: np.ndarray | Iterable | None,
                err2: np.ndarray | Iterable | None, vperc: list[int, int],
                vrange_last_orbits: int) -> tuple[float, float]:
    """Set minimum and maximum values of ylim.

    Parameters
    ----------
    data :  array_like
       Values of the data to be plotted
    err1 :  array_like
       Values of the data minus its uncertainty, None if without uncertainty
    err2 :  array_like
       Values of the data plus its uncertainty, None if without uncertainty
    vperc : list
       Limit the data range to the given percentiles
    vrange_last_orbits: int
       Use only data of the last N orbits

    Returns
    -------
    tuple of floats
       Return the limits of the Y-coordinate
    """
    if err1 is not None and err2 is not None:
        indx = np.isfinite(err1) & np.isfinite(err2)
        if np.all(~indx):
            ylim = [0., 0.]
        elif np.sum(indx) > vrange_last_orbits > 0:
            ni = vrange_last_orbits
            ylim = [min(err1[indx][0:ni].min(), err1[indx][-ni:].min()),
                    max(err2[indx][0:ni].max(), err2[indx][-ni:].max())]
        elif isinstance(vperc, list) and len(vperc) == 2:
            ylim = [np.percentile(err1[indx], vperc[0]),
                    np.percentile(err2[indx], vperc[1])]
        else:
            ylim = [err1[indx].min(), err2[indx].max()]
        factor = 10
    else:
        indx = np.isfinite(data)
        if np.all(~indx):
            ylim = [0., 0.]
        elif np.sum(indx) > vrange_last_orbits > 0:
            ni = vrange_last_orbits
            ylim = [min(data[indx][0:ni].min(), data[indx][-ni:].min()),
                    max(data[indx][0:ni].max(), data[indx][-ni:].max())]
        elif isinstance(vperc, list) and len(vperc) == 2:
            ylim = np.percentile(data[indx], vperc)
        else:
            ylim = [data[indx].min(), data[indx].max()]
        factor = 5

    if ylim[0] == ylim[1]:
        delta = 0.01 if ylim[0] == 0 else ylim[0] / 20
    else:
        delta = (ylim[1] - ylim[0]) / factor

    return float(ylim[0] - delta), float(ylim[1] + delta)


def adjust_units(zunit: str) -> str:
    """Adjust units: electron to 'e' and Volt to 'V'.

    Parameters
    ----------
    zunit :  str
       Units of the image data

    Returns
    -------
    str
       Units with consistent abbreviation of electron(s) and Volt
    """
    if zunit is None or zunit == '1':
        return '1'

    if zunit.find('electron') >= 0:
        zunit = zunit.replace('electron', 'e')
    if zunit.find('Volt') >= 0:
        zunit = zunit.replace('Volt', 'V')
    if zunit.find('.s-1') >= 0:
        zunit = zunit.replace('.s-1', ' s$^{-1}$')

    return zunit


def get_gap_list(xdata: np.ndarray) -> tuple:
    """Identify data gaps for data where xdata = offs + N * xstep.

    Parameters
    ----------
    xdata: numpy.ndarray
       Independent variable where the data is measured

    Returns
    -------
    list
       Indices to xdata where np.diff(xdata) greater than xstep
    """
    if not issubclass(xdata.dtype.type, Integral):
        return ()

    uvals, counts = np.unique(np.diff(xdata), return_counts=True)
    if counts.size > 1 and counts.max() / xdata.size > 0.5:
        xstep = uvals[counts.argmax()]
        return tuple(i for i in (np.diff(xdata) > xstep).nonzero()[0])

    return ()


# - main functions ---------------------------------
[docs]def add_subplot(axx, xarr: xr.DataArray) -> None: """Add a subplot for measurement data. Parameters ---------- axx : matplotlib.Axes Matplotlib Axes object of the current panel xarr : xarray.DataArray Object holding measurement data and attributes """ ylabel = xarr.attrs['long_name'] if 'units' in xarr.attrs and xarr.attrs['units'] != '1': ylabel += f' [{adjust_units(xarr.attrs["units"])}]' lcolor = xarr.attrs['_color'] if '_color' in xarr.attrs else CSET.blue fcolor = '#BBCCEE' # define xdata and determine gap_list (always at least one element!) if 'orbit' in xarr.coords: xdata = xarr.coords['orbit'].values isel = np.s_[:] else: xdata = xarr.coords['time'].values isel = np.s_[0, :] gap_list = get_gap_list(xdata) gap_list += (xdata.size - 1,) # define avg, err1, err2 # check if xarr.values is a structured array: # xarr.values.dtype.names is None # check if xarr contains quality data # check if err1 and err2 are present if xarr.values.dtype.names is None: avg = xarr.values[isel] err1 = err2 = None else: avg = xarr.values['mean'][isel] err1 = xarr.values['err1'][isel] err2 = xarr.values['err2'][isel] ii = 0 for jj in gap_list: isel = np.s_[ii:jj+1] if err1 is not None: axx.fill_between(xdata[isel], err1[isel], err2[isel], step='post', linewidth=0, facecolor=fcolor) axx.step(np.append(xdata[isel], xdata[jj]), np.append(avg[isel], avg[jj]), where='post', linewidth=1.5, color=lcolor) else: axx.plot(xdata[isel], avg[isel], linewidth=1.5, color=lcolor) if 'legend' in xarr.attrs: legenda = axx.legend([blank_legend_handle()], [xarr.attrs['legend']], loc='upper left') legenda.draw_frame(False) ii = jj + 1 # adjust data X-coordinate if 'time' in xarr.coords: axx.xaxis.set_major_locator(MultipleLocator(3)) axx.xaxis.set_minor_locator(MultipleLocator(1)) else: axx.xaxis.set_minor_locator(AutoMinorLocator()) axx.set_xlim([xdata[0], xdata[-1]]) # adjust data X-coordinate axx.locator_params(axis='y', nbins=5) if 'orbit' in xarr.coords: axx.set_ylim(adjust_ylim(avg, err1, err2, [], -1)) axx.set_ylabel(ylabel) axx.grid(True)
[docs]def add_hk_subplot(axx, xarr: xr.DataArray, vperc: list | None = None, vrange_last_orbits: int = -1) -> None: """Add a subplot for housekeeping data. Parameters ---------- axx : matplotlib.Axes Matplotlib Axes object of the current panel xarr : xarray.DataArray Object holding housekeeping data and attributes. Dimension must be 'orbit', 'hours' or 'time'. vperc : list | None, optional Reject outliers before determining vrange (neglected when vrange_last_orbits is used) vrange_last_orbits : int Use the last N orbits to determine vrange (orbit coordinate only) """ hk_title, hk_label, lcolor, fcolor = set_labels_colors(xarr) # define xdata and determine gap_list (always one element!) if 'orbit' in xarr.coords: xdata = xarr.coords['orbit'].values isel = np.s_[:] gap_list = get_gap_list(xdata) elif 'hours' in xarr.coords: xdata = xarr.coords['hours'].values isel = np.s_[0, :] gap_list = get_gap_list(np.round(3600 * xdata).astype(int)) else: xdata = xarr.coords['time'].values isel = np.s_[0, :] gap_list = get_gap_list(xdata) gap_list += (xdata.size - 1,) # define avg, err1, err2 avg = xarr.values['mean'][isel] err1 = xarr.values['err1'][isel] err2 = xarr.values['err2'][isel] # plot data ii = 0 for jj in gap_list: isel = np.s_[ii:jj+1] axx.fill_between(xdata[isel], err1[isel], err2[isel], step='post', linewidth=0, facecolor=fcolor) axx.plot(xdata[isel], avg[isel], linewidth=1.5, color=lcolor) ii = jj + 1 # adjust data X-coordinate if 'hours' in xarr.coords: axx.xaxis.set_major_locator(MultipleLocator(3)) axx.xaxis.set_minor_locator(MultipleLocator(1)) else: axx.xaxis.set_minor_locator(AutoMinorLocator()) axx.set_xlim([xdata[0], xdata[-1]]) # adjust data Y-coordinate axx.locator_params(axis='y', nbins=4) if 'orbit' in xarr.coords: axx.set_ylim(adjust_ylim(avg, err1, err2, vperc, vrange_last_orbits)) axx.set_ylabel(hk_label) axx.grid(True) # add hk_title inside current subplots legend = axx.legend([blank_legend_handle()], [hk_title], loc='upper left') legend.draw_frame(False)