Source code for pyrocko.plot

# http://pyrocko.org - GPLv3
#
# The Pyrocko Developers, 21st Century
# ---|P------/S----------~Lg----------

'''
Utility functions and defintions for a common plot style throughout Pyrocko.

Functions with name prefix ``mpl_`` are Matplotlib specific. All others should
be toolkit-agnostic.

The following skeleton can be used to produce nice PDF figures, with absolute
sizes derived from paper and font sizes
(file :file:`/../../examples/plot_skeleton.py`
in the Pyrocko source directory)::

    from matplotlib import pyplot as plt

    from pyrocko.plot import mpl_init, mpl_margins, mpl_papersize
    # from pyrocko.plot import mpl_labelspace

    fontsize = 9.   # in points

    # set some Pyrocko style defaults
    mpl_init(fontsize=fontsize)

    fig = plt.figure(figsize=mpl_papersize('a4', 'landscape'))

    # let margins be proportional to selected font size, e.g. top and bottom
    # margin are set to be 5*fontsize = 45 [points]
    labelpos = mpl_margins(fig, w=7., h=5., units=fontsize)

    axes = fig.add_subplot(1, 1, 1)

    # positioning of axis labels
    # mpl_labelspace(axes)    # either: relative to axis tick labels
    labelpos(axes, 2., 1.5)   # or: relative to left/bottom paper edge

    axes.plot([0, 1], [0, 9])

    axes.set_xlabel('Time [s]')
    axes.set_ylabel('Amplitude [m]')

    fig.savefig('plot_skeleton.pdf')

    plt.show()

'''

import math
import random
import time
import calendar
import numpy as num

from pyrocko.util import parse_md, time_to_str, arange2, to_time_float
from pyrocko.guts import StringChoice, Float, Int, Bool, Tuple, Object


__doc__ += parse_md(__file__)


guts_prefix = 'pf'

point = 1.
inch = 72.
cm = 28.3465

units_dict = {
    'point': point,
    'inch': inch,
    'cm': cm,
}

_doc_units = "``'point'``, ``'inch'``, or ``'cm'``"


def apply_units(x, units):
    if isinstance(units, str):
        units = units_dict[units]

    if isinstance(x, (int, float)):
        return x / units
    else:
        if isinstance(x, tuple):
            return tuple(v / units for v in x)
        else:
            return list(v / units for v in x)


tango_colors = {
    'butter1':     (252, 233,  79),
    'butter2':     (237, 212,   0),
    'butter3':     (196, 160,   0),
    'chameleon1':  (138, 226,  52),
    'chameleon2':  (115, 210,  22),
    'chameleon3':  (78,  154,   6),
    'orange1':     (252, 175,  62),
    'orange2':     (245, 121,   0),
    'orange3':     (206,  92,   0),
    'skyblue1':    (114, 159, 207),
    'skyblue2':    (52,  101, 164),
    'skyblue3':    (32,   74, 135),
    'plum1':       (173, 127, 168),
    'plum2':       (117,  80, 123),
    'plum3':       (92,  53, 102),
    'chocolate1':  (233, 185, 110),
    'chocolate2':  (193, 125,  17),
    'chocolate3':  (143,  89,   2),
    'scarletred1': (239,  41,  41),
    'scarletred2': (204,   0,   0),
    'scarletred3': (164,   0,   0),
    'aluminium1':  (238, 238, 236),
    'aluminium2':  (211, 215, 207),
    'aluminium3':  (186, 189, 182),
    'aluminium4':  (136, 138, 133),
    'aluminium5':  (85,   87,  83),
    'aluminium6':  (46,   52,  54)}


graph_colors = [
    tango_colors[_x] for _x in (
        'scarletred2',
        'skyblue3',
        'chameleon3',
        'orange2',
        'plum2',
        'chocolate2',
        'butter2')]


def color(x=None):
    if x is None:
        return tuple([random.randint(0, 255) for _x in 'rgb'])

    if isinstance(x, int):
        if 0 <= x < len(graph_colors):
            return graph_colors[x]
        else:
            return (0, 0, 0)

    elif isinstance(x, str):
        if x in tango_colors:
            return tango_colors[x]

    elif isinstance(x, tuple):
        return x

    assert False, "Don't know what to do with this color definition: %s" % x


def to01(c):
    return tuple(x/255. for x in c)


[docs]def nice_value(x): ''' Round x to nice value. ''' if x == 0.0: return 0.0 exp = 1.0 sign = 1 if x < 0.0: x = -x sign = -1 while x >= 1.0: x /= 10.0 exp *= 10.0 while x < 0.1: x *= 10.0 exp /= 10.0 if x >= 0.75: return sign * 1.0 * exp if x >= 0.35: return sign * 0.5 * exp if x >= 0.15: return sign * 0.2 * exp return sign * 0.1 * exp
_papersizes_list = [ ('a0', (2380., 3368.)), ('a1', (1684., 2380.)), ('a2', (1190., 1684.)), ('a3', (842., 1190.)), ('a4', (595., 842.)), ('a5', (421., 595.)), ('a6', (297., 421.)), ('a7', (210., 297.)), ('a8', (148., 210.)), ('a9', (105., 148.)), ('a10', (74., 105.)), ('b0', (2836., 4008.)), ('b1', (2004., 2836.)), ('b2', (1418., 2004.)), ('b3', (1002., 1418.)), ('b4', (709., 1002.)), ('b5', (501., 709.)), ('archa', (648., 864.)), ('archb', (864., 1296.)), ('archc', (1296., 1728.)), ('archd', (1728., 2592.)), ('arche', (2592., 3456.)), ('flsa', (612., 936.)), ('halfletter', (396., 612.)), ('note', (540., 720.)), ('letter', (612., 792.)), ('legal', (612., 1008.)), ('11x17', (792., 1224.)), ('ledger', (1224., 792.))] papersizes = dict(_papersizes_list) _doc_papersizes = ', '.join("``'%s'``" % k for (k, _) in _papersizes_list)
[docs]def papersize(paper, orientation='landscape', units='point'): ''' Get paper size from string. :param paper: string selecting paper size. Choices: %s :param orientation: ``'landscape'``, or ``'portrait'`` :param units: Units to be returned. Choices: %s :returns: ``(width, height)`` ''' assert orientation in ('landscape', 'portrait') w, h = papersizes[paper.lower()] if orientation == 'landscape': w, h = h, w return apply_units((w, h), units)
papersize.__doc__ %= (_doc_papersizes, _doc_units)
[docs]class AutoScaleMode(StringChoice): ''' Mode of operation for auto-scaling. ================ ================================================== mode description ================ ================================================== ``'auto'``: Look at data range and choose one of the choices below. ``'min-max'``: Output range is selected to include data range. ``'0-max'``: Output range shall start at zero and end at data max. ``'min-0'``: Output range shall start at data min and end at zero. ``'symmetric'``: Output range shall by symmetric by zero. ``'off'``: Similar to ``'min-max'``, but snap and space are disabled, such that the output range always exactly matches the data range. ================ ================================================== ''' choices = ['auto', 'min-max', '0-max', 'min-0', 'symmetric', 'off']
[docs]class AutoScaler(Object): ''' Tunable 1D autoscaling based on data range. Instances of this class may be used to determine nice minima, maxima and increments for ax annotations, as well as suitable common exponents for notation. The autoscaling process is guided by the following public attributes: ''' approx_ticks = Float.T( default=7.0, help='Approximate number of increment steps (tickmarks) to generate.') mode = AutoScaleMode.T( default='auto', help='''Mode of operation for auto-scaling.''') exp = Int.T( optional=True, help='If defined, override automatically determined exponent for ' 'notation by the given value.') snap = Bool.T( default=False, help='If set to True, snap output range to multiples of increment. ' "This parameter has no effect, if mode is set to ``'off'``.") inc = Float.T( optional=True, help='If defined, override automatically determined tick increment by ' 'the given value.') space = Float.T( default=0.0, help='Add some padding to the range. The value given, is the fraction ' 'by which the output range is increased on each side. If mode is ' "``'0-max'`` or ``'min-0'``, the end at zero is kept fixed " 'at zero. This parameter has no effect if mode is set to ' "``'off'``.") exp_factor = Int.T( default=3, help='Exponent of notation is chosen to be a multiple of this value.') no_exp_interval = Tuple.T( 2, Int.T(), default=(-3, 5), help='Range of exponent, for which no exponential notation is a' 'allowed.') def __init__( self, approx_ticks=7.0, mode='auto', exp=None, snap=False, inc=None, space=0.0, exp_factor=3, no_exp_interval=(-3, 5)): ''' Create new AutoScaler instance. The parameters are described in the AutoScaler documentation. ''' Object.__init__( self, approx_ticks=approx_ticks, mode=mode, exp=exp, snap=snap, inc=inc, space=space, exp_factor=exp_factor, no_exp_interval=no_exp_interval)
[docs] def make_scale(self, data_range, override_mode=None): ''' Get nice minimum, maximum and increment for given data range. Returns ``(minimum, maximum, increment)`` or ``(maximum, minimum, -increment)``, depending on whether data_range is ``(data_min, data_max)`` or ``(data_max, data_min)``. If ``override_mode`` is defined, the mode attribute is temporarily overridden by the given value. ''' data_min = min(data_range) data_max = max(data_range) is_reverse = (data_range[0] > data_range[1]) a = self.mode if self.mode == 'auto': a = self.guess_autoscale_mode(data_min, data_max) if override_mode is not None: a = override_mode mi, ma = 0, 0 if a == 'off': mi, ma = data_min, data_max elif a == '0-max': mi = 0.0 if data_max > 0.0: ma = data_max else: ma = 1.0 elif a == 'min-0': ma = 0.0 if data_min < 0.0: mi = data_min else: mi = -1.0 elif a == 'min-max': mi, ma = data_min, data_max elif a == 'symmetric': m = max(abs(data_min), abs(data_max)) mi = -m ma = m nmi = mi if (mi != 0. or a == 'min-max') and a != 'off': nmi = mi - self.space*(ma-mi) nma = ma if (ma != 0. or a == 'min-max') and a != 'off': nma = ma + self.space*(ma-mi) mi, ma = nmi, nma if mi == ma and a != 'off': mi -= 1.0 ma += 1.0 # make nice tick increment if self.inc is not None: inc = self.inc else: if self.approx_ticks > 0.: inc = nice_value((ma-mi) / self.approx_ticks) else: inc = nice_value((ma-mi)*10.) if inc == 0.0: inc = 1.0 # snap min and max to ticks if this is wanted if self.snap and a != 'off': ma = inc * math.ceil(ma/inc) mi = inc * math.floor(mi/inc) if is_reverse: return ma, mi, -inc else: return mi, ma, inc
[docs] def make_exp(self, x): ''' Get nice exponent for notation of ``x``. For ax annotations, give tick increment as ``x``. ''' if self.exp is not None: return self.exp x = abs(x) if x == 0.0: return 0 if 10**self.no_exp_interval[0] <= x <= 10**self.no_exp_interval[1]: return 0 return math.floor(math.log10(x)/self.exp_factor)*self.exp_factor
[docs] def guess_autoscale_mode(self, data_min, data_max): ''' Guess mode of operation, based on data range. Used to map ``'auto'`` mode to ``'0-max'``, ``'min-0'``, ``'min-max'`` or ``'symmetric'``. ''' a = 'min-max' if data_min >= 0.0: if data_min < data_max/2.: a = '0-max' else: a = 'min-max' if data_max <= 0.0: if data_max > data_min/2.: a = 'min-0' else: a = 'min-max' if data_min < 0.0 and data_max > 0.0: if abs((abs(data_max)-abs(data_min)) / (abs(data_max)+abs(data_min))) < 0.5: a = 'symmetric' else: a = 'min-max' return a
# below, some convenience functions for matplotlib plotting
[docs]def mpl_init(fontsize=10): ''' Initialize Matplotlib rc parameters Pyrocko style. Returns the matplotlib.pyplot module for convenience. ''' import matplotlib matplotlib.rcdefaults() matplotlib.rc('font', size=fontsize) matplotlib.rc('axes', linewidth=1.5) matplotlib.rc('xtick', direction='out') matplotlib.rc('ytick', direction='out') ts = fontsize * 0.7071 matplotlib.rc('xtick.major', size=ts, width=0.5, pad=ts) matplotlib.rc('ytick.major', size=ts, width=0.5, pad=ts) matplotlib.rc('figure', facecolor='white') try: from cycler import cycler matplotlib.rc( 'axes', prop_cycle=cycler( 'color', [to01(x) for x in graph_colors])) except (ImportError, KeyError): try: matplotlib.rc('axes', color_cycle=[to01(x) for x in graph_colors]) except KeyError: pass from matplotlib import pyplot as plt return plt
[docs]def mpl_get_cmap_names(): ''' Compatibility function to get named MPL colormap names. ''' try: from matplotlib import colormaps names = list(colormaps.keys()) except ImportError: try: from matplotlib.cm import _cmap_registry except ImportError: from matplotlib.cm import cmap_d as _cmap_registry names = list(_cmap_registry.keys()) names.sort() return names
[docs]def mpl_get_cmap(name): ''' Compatibility function to get named MPL colormap. The function matplotlib.cm.get_cmap has been removed in MPL 3.8 but the suggested replacement is not available in slightly older versions of MPL, e.g. 3.3 (default on Debian 11). ''' try: from matplotlib import colormaps return colormaps[name] except ImportError: from matplotlib import cm return cm.get_cmap(name)
[docs]def mpl_margins( fig, left=1.0, top=1.0, right=1.0, bottom=1.0, wspace=None, hspace=None, w=None, h=None, nw=None, nh=None, all=None, units='inch'): ''' Adjust Matplotlib subplot params with absolute values in user units. Calls :py:meth:`matplotlib.figure.Figure.subplots_adjust` on ``fig`` with absolute margin widths/heights rather than relative values. If ``wspace`` or ``hspace`` are given, the number of subplots must be given in ``nw`` and ``nh`` because ``subplots_adjust()`` treats the spacing parameters relative to the subplot width and height. :param units: Unit multiplier or unit as string: %s :param left,right,top,bottom: margin space :param w: set ``left`` and ``right`` at once :param h: set ``top`` and ``bottom`` at once :param all: set ``left``, ``top``, ``right``, and ``bottom`` at once :param nw: number of subplots horizontally :param nh: number of subplots vertically :param wspace: horizontal spacing between subplots :param hspace: vertical spacing between subplots ''' left, top, right, bottom = map( float, (left, top, right, bottom)) if w is not None: left = right = float(w) if h is not None: top = bottom = float(h) if all is not None: left = right = top = bottom = float(all) ufac = units_dict.get(units, units) / inch left *= ufac right *= ufac top *= ufac bottom *= ufac width, height = fig.get_size_inches() rel_wspace = None rel_hspace = None if wspace is not None: wspace *= ufac if nw is None: raise ValueError('wspace must be given in combination with nw') wsub = (width - left - right - (nw-1) * wspace) / nw rel_wspace = wspace / wsub else: wsub = width - left - right if hspace is not None: hspace *= ufac if nh is None: raise ValueError('hspace must be given in combination with nh') hsub = (height - top - bottom - (nh-1) * hspace) / nh rel_hspace = hspace / hsub else: hsub = height - top - bottom fig.subplots_adjust( left=left/width, right=1.0 - right/width, bottom=bottom/height, top=1.0 - top/height, wspace=rel_wspace, hspace=rel_hspace) def labelpos(axes, xpos=0., ypos=0.): xpos *= ufac ypos *= ufac axes.get_yaxis().set_label_coords(-((left-xpos) / wsub), 0.5) axes.get_xaxis().set_label_coords(0.5, -((bottom-ypos) / hsub)) return labelpos
mpl_margins.__doc__ %= _doc_units
[docs]def mpl_labelspace(axes): ''' Add some extra padding between label and ax annotations. ''' xa = axes.get_xaxis() ya = axes.get_yaxis() for attr in ('labelpad', 'LABELPAD'): if hasattr(xa, attr): setattr(xa, attr, xa.get_label().get_fontsize()) setattr(ya, attr, ya.get_label().get_fontsize()) break
[docs]def mpl_papersize(paper, orientation='landscape'): ''' Get paper size in inch from string. Returns argument suitable to be passed to the ``figsize`` argument of :py:func:`matplotlib.pyplot.figure`. :param paper: string selecting paper size. Choices: %s :param orientation: ``'landscape'``, or ``'portrait'`` :returns: ``(width, height)`` ''' return papersize(paper, orientation=orientation, units='inch')
mpl_papersize.__doc__ %= _doc_papersizes
[docs]class InvalidColorDef(ValueError): ''' Raised for invalid color definitions. ''' pass
def mpl_graph_color(i): return to01(graph_colors[i % len(graph_colors)])
[docs]def mpl_color(x): ''' Convert string into color float tuple ranged 0-1 for use with Matplotlib. Accepts tango color names, matplotlib color names, and slash-separated strings. In the latter case, if values are larger than 1., the color is interpreted as 0-255 ranged. Single-valued (grayscale), three-valued (color) and four-valued (color with alpha) are accepted. An :py:exc:`InvalidColorDef` exception is raised when the convertion fails. ''' import matplotlib.colors if x in tango_colors: return to01(tango_colors[x]) s = x.split('/') if len(s) in (1, 3, 4): try: vals = list(map(float, s)) if all(0. <= v <= 1. for v in vals): return vals elif all(0. <= v <= 255. for v in vals): return to01(vals) except ValueError: try: return matplotlib.colors.colorConverter.to_rgba(x) except Exception: pass raise InvalidColorDef('invalid color definition: %s' % x)
hours = 3600. days = hours*24 approx_months = days*30.5 approx_years = days*365 nice_time_tinc_inc_approx_units = { 'seconds': 1, 'months': approx_months, 'years': approx_years} def nice_time_tick_inc(tinc_approx): if tinc_approx >= approx_years: return max(1.0, nice_value(tinc_approx / approx_years)), 'years' elif tinc_approx >= approx_months: nice = [1, 2, 3, 6] for tinc in nice: if tinc*approx_months >= tinc_approx or tinc == nice[-1]: return tinc, 'months' elif tinc_approx > days: return nice_value(tinc_approx / days) * days, 'seconds' elif tinc_approx >= 1.0: nice = [ 1., 2., 5., 10., 20., 30., 60., 120., 300., 600., 1200., 1800., 1*hours, 2*hours, 3*hours, 6*hours, 12*hours, days, 2*days] for tinc in nice: if tinc >= tinc_approx or tinc == nice[-1]: return tinc, 'seconds' else: return nice_value(tinc_approx), 'seconds' def nice_time_tick_inc_approx_secs(tinc_approx): v, unit = nice_time_tick_inc(tinc_approx) return v * nice_time_tinc_inc_approx_units[unit] def time_tick_labels(tmin, tmax, tinc, tinc_unit): if tinc_unit == 'years': tt = time.gmtime(int(tmin)) tmin_year = tt[0] if tt[1:6] != (1, 1, 0, 0, 0): tmin_year += 1 tmax_year = time.gmtime(int(tmax))[0] tick_times_year = arange2( math.ceil(tmin_year/tinc)*tinc, math.floor(tmax_year/tinc)*tinc, tinc).astype(int) times = [ to_time_float(calendar.timegm((year, 1, 1, 0, 0, 0))) for year in tick_times_year] labels = ['%04i' % year for year in tick_times_year] elif tinc_unit == 'months': tt = time.gmtime(int(tmin)) tmin_ym = tt[0] * 12 + (tt[1] - 1) if tt[2:6] != (1, 0, 0, 0): tmin_ym += 1 tt = time.gmtime(int(tmax)) tmax_ym = tt[0] * 12 + (tt[1] - 1) tick_times_ym = arange2( math.ceil(tmin_ym/tinc)*tinc, math.floor(tmax_ym/tinc)*tinc, tinc).astype(int) times = [ to_time_float(calendar.timegm((ym // 12, ym % 12 + 1, 1, 0, 0, 0))) for ym in tick_times_ym] labels = [ '%04i-%02i' % (ym // 12, ym % 12 + 1) for ym in tick_times_ym] elif tinc_unit == 'seconds': imin = int(num.ceil(tmin/tinc)) imax = int(num.floor(tmax/tinc)) nticks = imax - imin + 1 tmin_ticks = imin * tinc times = tmin_ticks + num.arange(nticks) * tinc times = times.tolist() if tinc < 1e-6: fmt = '%Y-%m-%d.%H:%M:%S.9FRAC' elif tinc < 1e-3: fmt = '%Y-%m-%d.%H:%M:%S.6FRAC' elif tinc < 1.0: fmt = '%Y-%m-%d.%H:%M:%S.3FRAC' elif tinc < 60: fmt = '%Y-%m-%d.%H:%M:%S' elif tinc < 3600.*24: fmt = '%Y-%m-%d.%H:%M' else: fmt = '%Y-%m-%d' nwords = len(fmt.split('.')) labels = [time_to_str(t, format=fmt) for t in times] labels_weeded = [] have_ymd = have_hms = False ymd = hms = '' for ilab, lab in reversed(list(enumerate(labels))): words = lab.split('.') if nwords > 2: words[2] = '.' + words[2] if float(words[2]) == 0.0: # or (ilab == 0 and not have_hms): have_hms = True else: hms = words[1] words[1] = '' else: have_hms = True if nwords > 1: if words[1] in ('00:00', '00:00:00'): # or (ilab == 0 and not have_ymd): # noqa have_ymd = True else: ymd = words[0] words[0] = '' else: have_ymd = True labels_weeded.append('\n'.join(reversed(words))) labels = list(reversed(labels_weeded)) if (not have_ymd or not have_hms) and (hms or ymd): words = ([''] if nwords > 2 else []) + [ hms if not have_hms else '', ymd if not have_ymd else ''] labels[0:0] = ['\n'.join(words)] times[0:0] = [tmin] return times, labels
[docs]def mpl_time_axis(axes, approx_ticks=5.): ''' Configure x axis of a matplotlib axes object for interactive time display. :param axes: Axes to be configured. :type axes: :py:class:`matplotlib.axes.Axes` :param approx_ticks: Approximate number of ticks to create. :type approx_ticks: float This function tries to use nice tick increments and tick labels for time ranges from microseconds to years, similar to how this is handled in Snuffler. ''' from matplotlib.ticker import Locator, Formatter class labeled_float(float): pass class TimeLocator(Locator): def __init__(self, approx_ticks=5.): self._approx_ticks = approx_ticks Locator.__init__(self) def __call__(self): vmin, vmax = self.axis.get_view_interval() return self.tick_values(vmin, vmax) def tick_values(self, vmin, vmax): if vmax < vmin: vmin, vmax = vmax, vmin if vmin == vmax: return [] tinc_approx = (vmax - vmin) / self._approx_ticks tinc, tinc_unit = nice_time_tick_inc(tinc_approx) times, labels = time_tick_labels(vmin, vmax, tinc, tinc_unit) ftimes = [] for t, label in zip(times, labels): ftime = labeled_float(t) ftime._mpl_label = label ftimes.append(ftime) return self.raise_if_exceeds(ftimes) class TimeFormatter(Formatter): def __call__(self, x, pos=None): if isinstance(x, labeled_float): return x._mpl_label else: return time_to_str(x, format='%Y-%m-%d %H:%M:%S.6FRAC') axes.xaxis.set_major_locator(TimeLocator(approx_ticks=approx_ticks)) axes.xaxis.set_major_formatter(TimeFormatter())