Source code for libb.chart

import logging
import math
from io import BytesIO

logger = logging.getLogger(__name__)

__all__ = ['numpy_timeseries_plot']


class NiceScale:
    """Calculate nice axis scale values for charts.

    The "nicest" numbers in decimal are 1, 2, and 5, and all power-of-ten
    multiples of these numbers. This class uses only such numbers for tick
    spacing and places tick marks at multiples of the tick spacing.

    :param float minv: Minimum value of the data range.
    :param float maxv: Maximum value of the data range.

    Example::

        >>> scale = NiceScale(0.5, 10.5)
        >>> scale.nice_min
        0.0
        >>> scale.nice_max
        12.0
        >>> scale.tick_spacing
        2.0

    .. note::
        Algorithm from https://stackoverflow.com/a/16363437
    """

    def __init__(self, minv, maxv):
        self.max_ticks = 8
        self.tick_spacing = 0
        self.lst = 10
        self.nice_min = 0
        self.nice_max = 0
        self.min_point = minv
        self.max_point = maxv
        self.calculate()

    def calculate(self):
        self.lst = self.nice_num(self.max_point - self.min_point, False)
        self.tick_spacing = self.nice_num(self.lst / (self.max_ticks - 1), True)
        self.nice_min = math.floor(self.min_point / self.tick_spacing) * self.tick_spacing
        self.nice_max = math.ceil(self.max_point / self.tick_spacing) * self.tick_spacing

    def nice_num(self, lst, rround):
        self.lst = lst
        if self.lst <= 0:
            return 1
        exponent = math.floor(math.log10(self.lst))
        fraction = self.lst / math.pow(10, exponent)
        if rround:
            if fraction < 1.5:
                nice_fraction = 1
            elif fraction < 3:
                nice_fraction = 2
            elif fraction < 7:
                nice_fraction = 5
            else:
                nice_fraction = 10
        else:
            if fraction <= 1:
                nice_fraction = 1
            elif fraction <= 2:
                nice_fraction = 2
            elif fraction <= 5:
                nice_fraction = 5
            else:
                nice_fraction = 10
        return nice_fraction * math.pow(10, exponent)


DEFAULT_TIMESERIES_COLORS = (
    'tab:blue',
    'tab:green',
    'tab:orange',
    'tab:red',
    'tab:olive',
)


[docs] def numpy_timeseries_plot(title, dates, series=None, labels=None, formats=None): """Create a matplotlib timeseries plot with automatic subplot layout. The layout adapts based on the number of series: - 1 series: single plot - 2 series: same plot with dual y-axes (overlapping) - 3+ series: stacked vertically as subplots :param str title: Plot title. :param dates: Array of dates for x-axis. :param list series: List of y-value arrays. :param list labels: Labels for each series. :param list formats: Formatter functions for each y-axis. :returns: BytesIO buffer containing the PNG image. :rtype: BytesIO """ import matplotlib.pyplot as plt import matplotlib.ticker as mtick import numpy as np from matplotlib import dates as mpl_dates if formats is None: formats = [] if labels is None: labels = [] if series is None: series = [] plt.rcParams['figure.figsize'] = (6.4 * 1.5, 4.8 * 1.5) numy = len(series) assert numy == len(labels) == len(formats) dates = np.array(dates) if numy <= 2: fig, axes = plt.subplots(1) axes = [axes] else: fig, axes = plt.subplots(numy) axes = list(axes) colors = (c for c in DEFAULT_TIMESERIES_COLORS) ax = None for ts, lbl, fmt in zip(series, labels, formats): ts = np.array(ts) if ax is None or numy > 2: ax = axes.pop(0) elif numy <= 2: ax = ax.twinx() mask = np.isfinite(ts) color = next(colors) try: ax.plot(dates[mask], ts[mask], color=color) except Exception as exc: logger.warning(exc) continue ax.tick_params(axis='y', labelcolor=color) if numy > 2: ax.title.set_text(lbl) ax.grid(True) t = NiceScale(min(ts[mask]), max(ts[mask])) ax.set_yticks(np.arange(t.nice_min, t.nice_max, t.tick_spacing)) else: ax.set_ylabel(lbl) plt.title(title) ax.yaxis.set_major_formatter(mtick.FuncFormatter(fmt)) ax.xaxis.set_major_formatter(mpl_dates.DateFormatter('%m/%d/%y')) plt.gcf().autofmt_xdate() plt.tight_layout() buf = BytesIO() fig.savefig(buf, format='png') plt.clf() plt.rcParams['figure.figsize'] = plt.rcParamsDefault['figure.figsize'] return buf