Source code for proplot.axistools

#!/usr/bin/env python3
"""
Various axis `~matplotlib.ticker.Formatter` and `~matplotlib.scale.ScaleBase`
classes. Includes constructor functions so that these classes can be selected
with with a shorthand syntax.
"""
import re
from .utils import _warn_proplot, _notNone
from .rctools import rc
from numbers import Number
from fractions import Fraction
import numpy as np
import numpy.ma as ma
import matplotlib.dates as mdates
import matplotlib.projections.polar as mpolar
import matplotlib.ticker as mticker
import matplotlib.scale as mscale
import matplotlib.transforms as mtransforms
__all__ = [
    'formatters', 'locators', 'scales',
    'Formatter', 'Locator', 'Scale',
    'AutoFormatter', 'CutoffScale', 'ExpScale',
    'FracFormatter', 'FuncScale',
    'InverseScale',
    'LinearScale',
    'LogitScale',
    'LogScale',
    'MercatorLatitudeScale', 'PowerScale', 'SimpleFormatter',
    'SineLatitudeScale',
    'SymmetricalLogScale',
]

MAX_DIGITS = 32  # do not draw 1000 digits when LogScale limits include zero!
SCALE_PRESETS = {
    'quadratic': ('power', 2,),
    'cubic': ('power', 3,),
    'quartic': ('power', 4,),
    'height': ('exp', np.e, -1 / 7, 1013.25, True),
    'pressure': ('exp', np.e, -1 / 7, 1013.25, False),
    'db': ('exp', 10, 1, 0.1, True),
    'idb': ('exp', 10, 1, 0.1, False),
    'np': ('exp', np.e, 1, 1, True),
    'inp': ('exp', np.e, 1, 1, False),
}


[docs]def Locator(locator, *args, **kwargs): """ Returns a `~matplotlib.ticker.Locator` instance, used to interpret the `xlocator`, `xlocator_kw`, `ylocator`, `ylocator_kw`, `xminorlocator`, `xminorlocator_kw`, `yminorlocator`, and `yminorlocator_kw` arguments when passed to `~proplot.axes.XYAxes.format`, and the `locator`, `locator_kw` `minorlocator`, and `minorlocator_kw` arguments when passed to colorbar methods wrapped by `~proplot.wrappers.colorbar_wrapper`. Parameters ---------- locator : `~matplotlib.ticker.Locator`, str, float, or list of float If `~matplotlib.ticker.Locator`, the object is returned. If number, specifies the *multiple* used to define tick separation. Returns a `~matplotlib.ticker.MultipleLocator` instance. If list of numbers, these points are ticked. Returns a `~matplotlib.ticker.FixedLocator` instance. If string, a dictionary lookup is performed (see below table). ====================== ============================================ ========================================================================================= Key Class Description ====================== ============================================ ========================================================================================= ``'null'``, ``'none'`` `~matplotlib.ticker.NullLocator` No ticks ``'auto'`` `~matplotlib.ticker.AutoLocator` Major ticks at sensible locations ``'minor'`` `~matplotlib.ticker.AutoMinorLocator` Minor ticks at sensible locations ``'date'`` `~matplotlib.dates.AutoDateLocator` Default tick locations for datetime axes ``'log'`` `~matplotlib.ticker.LogLocator` preset For log-scale axes, ticks on each power of the base ``'logminor'`` `~matplotlib.ticker.LogLocator` preset For log-scale axes, ticks on the 1st through 9th multiples of each power of the base ``'maxn'`` `~matplotlib.ticker.MaxNLocator` No more than ``N`` ticks at sensible locations ``'linear'`` `~matplotlib.ticker.LinearLocator` Exactly ``N`` ticks encompassing the axis limits, spaced as ``numpy.linspace(lo, hi, N)`` ``'multiple'`` `~matplotlib.ticker.MultipleLocator` Ticks every ``N`` step away from zero ``'fixed'`` `~matplotlib.ticker.FixedLocator` Ticks at these exact locations ``'index'`` `~matplotlib.ticker.IndexLocator` Ticks on the non-negative integers ``'symlog'`` `~matplotlib.ticker.SymmetricalLogLocator` Ticks for symmetrical log-scale axes ``'logit'`` `~matplotlib.ticker.LogitLocator` Ticks for logit-scale axes ``'theta'`` `~matplotlib.projections.polar.ThetaLocator` Like the base locator but default locations are every `numpy.pi`/8 radians ``'year'`` `~matplotlib.dates.YearLocator` Ticks every ``N`` years ``'month'`` `~matplotlib.dates.MonthLocator` Ticks every ``N`` months ``'weekday'`` `~matplotlib.dates.WeekdayLocator` Ticks every ``N`` weekdays ``'day'`` `~matplotlib.dates.DayLocator` Ticks every ``N`` days ``'hour'`` `~matplotlib.dates.HourLocator` Ticks every ``N`` hours ``'minute'`` `~matplotlib.dates.MinuteLocator` Ticks every ``N`` minutes ``'second'`` `~matplotlib.dates.SecondLocator` Ticks every ``N`` seconds ``'microsecond'`` `~matplotlib.dates.MicrosecondLocator` Ticks every ``N`` microseconds ====================== ============================================ ========================================================================================= *args, **kwargs Passed to the `~matplotlib.ticker.Locator` class. Returns ------- `~matplotlib.ticker.Locator` A `~matplotlib.ticker.Locator` instance. """ # noqa if isinstance(locator, mticker.Locator): return locator # Pull out extra args if np.iterable(locator) and not isinstance(locator, str) and not all( isinstance(num, Number) for num in locator): locator, args = locator[0], (*locator[1:], *args) # Get the locator if isinstance(locator, str): # dictionary lookup # Shorthands and defaults if locator == 'logminor': locator = 'log' kwargs.setdefault('subs', np.arange(10)) elif locator == 'index': args = args or (1,) if len(args) == 1: args = (*args, 0) # Lookup if locator not in locators: raise ValueError( f'Unknown locator {locator!r}. Options are ' + ', '.join(map(repr, locators.keys())) + '.') locator = locators[locator](*args, **kwargs) elif isinstance(locator, Number): # scalar variable locator = mticker.MultipleLocator(locator, *args, **kwargs) elif np.iterable(locator): locator = mticker.FixedLocator( np.sort(locator), *args, **kwargs) # not necessary else: raise ValueError(f'Invalid locator {locator!r}.') return locator
[docs]def Formatter(formatter, *args, date=False, index=False, **kwargs): """ Returns a `~matplotlib.ticker.Formatter` instance, used to interpret the `xformatter`, `xformatter_kw`, `yformatter`, and `yformatter_kw` arguments when passed to `~proplot.axes.XYAxes.format`, and the `formatter` and `formatter_kw` arguments when passed to colorbar methods wrapped by `~proplot.wrappers.colorbar_wrapper`. Parameters ---------- formatter : `~matplotlib.ticker.Formatter`, str, list of str, or function If `~matplotlib.ticker.Formatter`, the object is returned. If list of strings, ticks are labeled with these strings. Returns a `~matplotlib.ticker.FixedFormatter` instance when `index` is ``False`` and an `~matplotlib.ticker.IndexFormatter` instance when `index` is ``True``. If function, labels will be generated using this function. Returns a `~matplotlib.ticker.FuncFormatter` instance. If string, there are 4 possibilities: 1. If string contains ``'%'`` and `date` is ``False``, ticks will be formatted using the C-notation ``string % number`` method. See `this page \ <https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting>`__ for a review. 2. If string contains ``'%'`` and `date` is ``True``, datetime ``string % number`` formatting is used. See `this page \ <https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes>`__ for a review. 3. If string contains ``{x}`` or ``{x:...}``, ticks will be formatted by calling ``string.format(x=number)``. 4. In all other cases, a dictionary lookup is performed (see below table). ====================== ============================================== =================================================================================================================================== Key Class Description ====================== ============================================== =================================================================================================================================== ``'null'``, ``'none'`` `~matplotlib.ticker.NullFormatter` No tick labels ``'auto'`` `AutoFormatter` New default tick labels for axes ``'simple'`` `SimpleFormatter` New default tick labels for e.g. contour labels ``'frac'`` `FracFormatter` Rational fractions ``'date'`` `~matplotlib.dates.AutoDateFormatter` Default tick labels for datetime axes ``'datestr'`` `~matplotlib.dates.DateFormatter` Date formatting with C-style ``string % format`` notation ``'concise'`` `~matplotlib.dates.ConciseDateFormatter` More concise date labels introduced in `matplotlib 3.1 <https://matplotlib.org/3.1.0/users/whats_new.html#concisedateformatter>`__ ``'scalar'`` `~matplotlib.ticker.ScalarFormatter` Old default tick labels for axes ``'strmethod'`` `~matplotlib.ticker.StrMethodFormatter` From the ``string.format`` method ``'formatstr'`` `~matplotlib.ticker.FormatStrFormatter` From C-style ``string % format`` notation ``'log'``, ``'sci'`` `~matplotlib.ticker.LogFormatterSciNotation` For log-scale axes with scientific notation ``'math'`` `~matplotlib.ticker.LogFormatterMathtext` For log-scale axes with math text ``'logit'`` `~matplotlib.ticker.LogitFormatter` For logistic-scale axes ``'eng'`` `~matplotlib.ticker.EngFormatter` Engineering notation ``'percent'`` `~matplotlib.ticker.PercentFormatter` Trailing percent sign ``'fixed'`` `~matplotlib.ticker.FixedFormatter` List of strings ``'index'`` `~matplotlib.ticker.IndexFormatter` List of strings corresponding to non-negative integer positions along the axis ``'theta'`` `~matplotlib.projections.polar.ThetaFormatter` Formats radians as degrees, with a degree symbol ``'pi'`` `FracFormatter` preset Fractions of :math:`\\pi` ``'e'`` `FracFormatter` preset Fractions of *e* ``'deg'`` `AutoFormatter` preset Trailing degree symbol ``'deglon'`` `AutoFormatter` preset Trailing degree symbol and cardinal "WE" indicator ``'deglat'`` `AutoFormatter` preset Trailing degree symbol and cardinal "SN" indicator ``'lon'`` `AutoFormatter` preset Cardinal "WE" indicator ``'lat'`` `AutoFormatter` preset Cardinal "SN" indicator ====================== ============================================== =================================================================================================================================== date : bool, optional Toggles the behavior when `formatter` contains a ``'%'`` sign (see above). index : bool, optional Controls the behavior when `formatter` is a list of strings (see above). *args, **kwargs Passed to the `~matplotlib.ticker.Formatter` class. Returns ------- `~matplotlib.ticker.Formatter` A `~matplotlib.ticker.Formatter` instance. """ # noqa if isinstance(formatter, mticker.Formatter): # formatter object return formatter # Pull out extra args if np.iterable(formatter) and not isinstance(formatter, str) and not all( isinstance(item, str) for item in formatter): formatter, args = formatter[0], (*formatter[1:], *args) # Get the formatter if isinstance(formatter, str): # assumption is list of strings # Format strings if re.search(r'{x?(:.+)?}', formatter): formatter = mticker.StrMethodFormatter( formatter, *args, **kwargs) # new-style .format() form elif '%' in formatter: if date: formatter = mdates.DateFormatter( formatter, *args, **kwargs) # %-style, dates else: formatter = mticker.FormatStrFormatter( formatter, *args, **kwargs) # %-style, numbers else: # Fraction shorthands if formatter in ('pi', 'e'): if formatter == 'pi': symbol, number = r'$\pi$', np.pi else: symbol, number = '$e$', np.e kwargs.setdefault('symbol', symbol) kwargs.setdefault('number', number) formatter = 'frac' # Cartographic shorthands if formatter in ('deg', 'deglon', 'deglat', 'lon', 'lat'): negpos, suffix = None, None if 'deg' in formatter: suffix = '\N{DEGREE SIGN}' if 'lat' in formatter: negpos = 'SN' if 'lon' in formatter: negpos = 'WE' kwargs.setdefault('suffix', suffix) kwargs.setdefault('negpos', negpos) formatter = 'auto' # Lookup if formatter not in formatters: raise ValueError( f'Unknown formatter {formatter!r}. Options are ' + ', '.join(map(repr, formatters.keys())) + '.') formatter = formatters[formatter](*args, **kwargs) elif callable(formatter): formatter = mticker.FuncFormatter(formatter, *args, **kwargs) elif np.iterable(formatter): # list of strings on the major ticks if index: formatter = mticker.IndexFormatter(formatter) else: formatter = mticker.FixedFormatter(formatter) else: raise ValueError(f'Invalid formatter {formatter!r}.') return formatter
[docs]def Scale(scale, *args, **kwargs): """ Returns a `~matplotlib.scale.ScaleBase` instance, used to interpret the `xscale`, `xscale_kw`, `yscale`, and `yscale_kw` arguments when passed to `~proplot.axes.XYAxes.format`. Parameters ---------- scale : `~matplotlib.scale.ScaleBase`, str, (str, ...), or class If `~matplotlib.scale.ScaleBase`, the object is returned. If string, this is the registered scale name or scale "preset" (see below table). If an iterable is passed with the scale name as the first element, the subsequent items are passed to the scale class as positional arguments. ================= =============================== ======================================================================= Key Class Description ================= =============================== ======================================================================= ``'linear'`` `~matplotlib.scale.LinearScale` Linear ``'log'`` `LogScale` Logarithmic ``'symlog'`` `SymmetricalLogScale` Logarithmic beyond finite space around zero ``'logit'`` `~matplotlib.scale.LogitScale` Logistic ``'inverse'`` `InverseScale` Inverse ``'function'`` `FuncScale` Scale from arbitrary forward and backwards functions ``'sine'`` `SineLatitudeScale` Sine function (in degrees) ``'mercator'`` `MercatorLatitudeScale` Mercator latitude function (in degrees) ``'exp'`` `ExpScale` Arbitrary exponential function ``'power'`` `PowerScale` Arbitrary power function ``'cutoff'`` `CutoffScale` Arbitrary piecewise linear transformations ``'quadratic'`` `PowerScale` (preset) Quadratic function ``'cubic'`` `PowerScale` (preset) Cubic function ``'quartic'`` `PowerScale` (preset) Cubic function ``'pressure'`` `ExpScale` (preset) Height (in km) expressed linear in pressure ``'height'`` `ExpScale` (preset) Pressure (in hPa) expressed linear in height ``'db'`` `ExpScale` (preset) Ratio expressed as `decibels <https://en.wikipedia.org/wiki/Decibel>`__ ``'np'`` `ExpScale` (preset) Ratio expressed as `nepers <https://en.wikipedia.org/wiki/Neper>`__ ``'idb'`` `ExpScale` (preset) `Decibels <https://en.wikipedia.org/wiki/Decibel>`__ expressed as ratio ``'inp'`` `ExpScale` (preset) `Nepers <https://en.wikipedia.org/wiki/Neper>`__ expressed as ratio ================= =============================== ======================================================================= *args, **kwargs Passed to the `~matplotlib.scale.ScaleBase` class. Returns ------- `~matplotlib.scale.ScaleBase` The scale instance. """ # noqa if isinstance(scale, mscale.ScaleBase): return scale # Pull out extra args if np.iterable(scale) and not isinstance(scale, str): scale, args = scale[0], (*scale[1:], *args) if not isinstance(scale, str): raise ValueError(f'Invalid scale name {scale!r}. Must be string.') # Get scale preset if scale in SCALE_PRESETS: if args or kwargs: _warn_proplot( f'Scale {scale!r} is a scale *preset*. Ignoring positional ' 'argument(s): {args} and keyword argument(s): {kwargs}. ') scale, *args = SCALE_PRESETS[scale] # Get scale scale = scale.lower() if scale in scales: scale = scales[scale] else: raise ValueError( f'Unknown scale or preset {scale!r}. Options are ' + ', '.join(map(repr, list(scales) + list(SCALE_PRESETS))) + '.') return scale(*args, **kwargs)
def _zerofix(x, string, precision=6): """ Try to fix non-zero tick labels formatted as ``'0'``. """ if string.rstrip('0').rstrip('.') == '0' and x != 0: string = ('{:.%df}' % precision).format(x) return string
[docs]class AutoFormatter(mticker.ScalarFormatter): """ The new default formatter, a simple wrapper around `~matplotlib.ticker.ScalarFormatter`. Differs from `~matplotlib.ticker.ScalarFormatter` in the following ways: 1. Trims trailing zeros if any exist. 2. Allows user to specify *range* within which major tick marks are labelled. 3. Allows user to add arbitrary prefix or suffix to every tick label string. """ def __init__(self, *args, zerotrim=None, precision=None, tickrange=None, prefix=None, suffix=None, negpos=None, **kwargs): """ Parameters ---------- zerotrim : bool, optional Whether to trim trailing zeros. Default is :rc:`axes.formatter.zerotrim`. tickrange : (float, float), optional Range within which major tick marks are labelled. prefix, suffix : str, optional Prefix and suffix for all strings. negpos : str, optional Length-2 string indicating the suffix for "negative" and "positive" numbers, meant to replace the minus sign. This is useful for indicating cardinal geographic coordinates. *args, **kwargs Passed to `~matplotlib.ticker.ScalarFormatter`. Warning ------- The matplotlib `~matplotlib.ticker.ScalarFormatter` determines the number of significant digits based on the axis limits, and therefore may *truncate* digits while formatting ticks on highly non-linear axis scales like `~proplot.axistools.LogScale`. We try to correct this behavior with a patch. """ tickrange = tickrange or (-np.inf, np.inf) super().__init__(*args, **kwargs) zerotrim = _notNone(zerotrim, rc.get('axes.formatter.zerotrim')) self._zerotrim = zerotrim self._tickrange = tickrange self._prefix = prefix or '' self._suffix = suffix or '' self._negpos = negpos or ''
[docs] def __call__(self, x, pos=None): """ Convert number to a string. Parameters ---------- x : float The value. pos : float, optional The position. """ # Tick range limitation eps = abs(x) / 1000 tickrange = self._tickrange if (x + eps) < tickrange[0] or (x - eps) > tickrange[1]: return '' # avoid some ticks # Negative positive handling if not self._negpos or x == 0: tail = '' elif x > 0: tail = self._negpos[1] else: x *= -1 tail = self._negpos[0] # Format the string string = super().__call__(x, pos) for i in range(2): # Try to fix non-zero values formatted as zero if self._zerotrim and '.' in string: string = string.rstrip('0').rstrip('.') string = string.replace('-', '\N{MINUS SIGN}') if string == '\N{MINUS SIGN}0': string = '0' if i == 0 and string == '0' and x != 0: # Hard limit of MAX_DIGITS sigfigs string = ('{:.%df}' % min( abs(np.log10(x) // 1), MAX_DIGITS)).format(x) continue break # Prefix and suffix sign = '' if string and string[0] == '\N{MINUS SIGN}': sign, string = string[0], string[1:] return sign + self._prefix + string + self._suffix + tail
[docs]def SimpleFormatter(*args, precision=6, zerotrim=True, **kwargs): """ Replicates the `zerotrim` feature from `AutoFormatter`, but as a simpler `~matplotlib.ticker.FuncFormatter` instance. This is more suitable for arbitrary number formatting not necessarily associated with any `~matplotlib.axis.Axis` instance, e.g. labeling contours. Parameters ---------- precision : int, optional The maximum number of digits after the decimal point. zerotrim : bool, optional Whether to trim trailing zeros. Default is :rc:`axes.formatter.zerotrim`. """ zerotrim = _notNone(zerotrim, rc['axes.formatter.zerotrim']) def f(x, pos): string = ('{:.%df}' % precision).format(x) if zerotrim and '.' in string: string = string.rstrip('0').rstrip('.') if string == '-0' or string == '\N{MINUS SIGN}0': string = '0' return string.replace('-', '\N{MINUS SIGN}') return mticker.FuncFormatter(f)
[docs]def FracFormatter(symbol='', number=1): r""" Returns a `~matplotlib.ticker.FuncFormatter` that formats numbers as fractions or multiples of some value, e.g. a physical constant. This is powered by the python builtin `~fractions.Fraction` class. We account for floating point errors using the `~fractions.Fraction.limit_denominator` method. Parameters ---------- symbol : str The symbol, e.g. ``r'$\pi$'``. Default is ``''``. number : float The value, e.g. `numpy.pi`. Default is ``1``. """ def f(x, pos): # must accept location argument frac = Fraction(x / number).limit_denominator() if x == 0: string = '0' elif frac.denominator == 1: # denominator is one if frac.numerator == 1 and symbol: string = f'{symbol:s}' elif frac.numerator == -1 and symbol: string = f'-{symbol:s}' else: string = f'{frac.numerator:d}{symbol:s}' else: if frac.numerator == 1 and symbol: # numerator is +/-1 string = f'{symbol:s}/{frac.denominator:d}' elif frac.numerator == -1 and symbol: string = f'-{symbol:s}/{frac.denominator:d}' else: # and again make sure we use unicode minus! string = f'{frac.numerator:d}{symbol:s}/{frac.denominator:d}' return string.replace('-', '\N{MINUS SIGN}') return mticker.FuncFormatter(f)
def _scale_factory(scale, axis, *args, **kwargs): """If `scale` is a `~matplotlib.scale.ScaleBase` instance, nothing is done. If it is a registered scale name, that scale is looked up and instantiated.""" if isinstance(scale, mscale.ScaleBase): if args or kwargs: _warn_proplot(f'Ignoring args {args} and keyword args {kwargs}.') return scale # do nothing else: scale = scale.lower() if scale not in scales: raise ValueError( f'Unknown scale {scale!r}. Options are ' + ', '.join(map(repr, scales.keys())) + '.') return scales[scale](*args, **kwargs) def _parse_logscale_args(kwargs, *keys): """Parses args for `LogScale` and `SymmetricalLogScale` that inexplicably require ``x`` and ``y`` suffixes by default.""" for key in keys: value = _notNone( # issues warning when multiple args passed! kwargs.pop(key, None), kwargs.pop(key + 'x', None), kwargs.pop(key + 'y', None), None, names=(key, key + 'x', key + 'y'), ) if value is not None: # dummy axis_name is 'x' kwargs[key + 'x'] = value return kwargs class _ScaleBase(object): """Mixin scale class that standardizes the `~matplotlib.scale.ScaleBase.set_default_locators_and_formatters` and `~matplotlib.scale.ScaleBase.get_transform` methods. Also overrides `__init__` so you no longer have to instantiate scales with an `~matplotlib.axis.Axis` instance.""" def __init__(self, *args, **kwargs): # Pass a dummy axis to the superclass axis = type('Axis', (object,), {'axis_name': 'x'})() return super().__init__(axis, *args, **kwargs) def set_default_locators_and_formatters(self, axis, only_if_default=False): """ Apply all locators and formatters defined as attributes on initialization, and define defaults for all scales. Parameters ---------- axis : `~matplotlib.axis.Axis` The axis. only_if_default : bool, optional Whether to refrain from updating the locators and formatters if the axis is currently using non-default versions. Useful if we want to avoid overwriting user customization when the scale is changed. """ # * We assert isDefault settings, because matplotlib does this in # axis._set_scale but sometimes we need to bypass this method! # * Minor locator can be "non default" even when user has not changed # it, due to "turning minor ticks" on and off, so always apply # default if it is currently AutoMinorLocator. if getattr(self, '_smart_bounds', None): axis.set_smart_bounds(True) # unnecessary? if not only_if_default or axis.isDefault_majloc: axis.set_major_locator( getattr(self, '_major_locator', None) or Locator('auto') ) axis.isDefault_majloc = True if not only_if_default or axis.isDefault_majfmt: axis.set_major_formatter( getattr(self, '_major_formatter', None) or Formatter('auto') ) axis.isDefault_majfmt = True if (not only_if_default or axis.isDefault_minloc or isinstance( axis.get_minor_locator(), mticker.AutoMinorLocator)): name = axis.axis_name if axis.axis_name in 'xy' else 'x' minor = 'minor' if rc.get(name + 'tick.minor.visible') else 'null' axis.set_minor_locator( getattr(self, '_minor_locator', None) or Locator(minor) ) axis.isDefault_minloc = True if (not only_if_default or axis.isDefault_minfmt or isinstance( axis.get_minor_formatter(), mticker.NullFormatter)): axis.set_minor_formatter( getattr(self, '_minor_formatter', None) or Formatter('null') ) axis.isDefault_minfmt = True def get_transform(self): """Returns the scale transform.""" return getattr(self, '_transform', mtransforms.IdentityTransform())
[docs]class LinearScale(_ScaleBase, mscale.LinearScale): """ As with `~matplotlib.scale.LinearScale`, but applies new default major formatter. """ name = 'linear' """The registered scale name."""
[docs]class LogitScale(_ScaleBase, mscale.LogitScale): """ As with `~matplotlib.scale.LogitScale`, but applies new default major formatter. """ name = 'logit' """The registered scale name."""
[docs]class LogScale(_ScaleBase, mscale.LogScale): """ As with `~matplotlib.scale.LogScale`, but applies new default major formatter and fixes the inexplicable choice to have separate "``x``" and "``y``" versions of each keyword argument. """ name = 'log' """The registered scale name.""" def __init__(self, **kwargs): """ Parameters ---------- base : float, optional The base of the logarithm. Default is ``10``. nonpos : {'mask', 'clip'}, optional Non-positive values in *x* or *y* can be masked as invalid, or clipped to a very small positive number. subs : list of int, optional Default tick locations are on these multiples of each power of the base. For example, ``subs=(1,2,5)`` draws ticks on 1, 2, 5, 10, 20, 50, etc. basex, basey, nonposx, nonposy, subsx, subsy Aliases for the above keywords. These used to be conditional on the *name* of the axis. """ kwargs = _parse_logscale_args(kwargs, 'base', 'nonpos', 'subs') super().__init__(**kwargs) # self._major_formatter = Formatter('log') self._major_locator = Locator('log', base=self.base) self._minor_locator = Locator('log', base=self.base, subs=self.subs)
[docs]class SymmetricalLogScale(_ScaleBase, mscale.SymmetricalLogScale): """ As with `~matplotlib.scale.SymmetricLogScale`, but applies new default major formatter and fixes the inexplicable choice to have separate "``x``" and "``y``" versions of each keyword argument. """ name = 'symlog' """The registered scale name.""" def __init__(self, **kwargs): """ Parameters ---------- base : float, optional The base of the logarithm. Default is ``10``. linthresh : float, optional Defines the range ``(-linthresh, linthresh)``, within which the plot is linear. This avoids having the plot go to infinity around zero. Defaults to 2. linscale : float, optional This allows the linear range ``(-linthresh, linthresh)`` to be stretched relative to the logarithmic range. Its value is the number of decades to use for each half of the linear range. For example, when `linscale` is ``1`` (the default), the space used for the positive and negative halves of the linear range will be equal to one decade in the logarithmic range. subs : sequence of int, optional Default minor tick locations are on these multiples of each power of the base. For example, ``subs=[1,2,5]`` draws ticks on 1, 2, 5, 10, 20, 50, 100, 200, 500, etc. basex, basey, linthreshx, linthreshy, linscalex, linscaley, \ subsx, subsy Aliases for the above keywords. These used to be conditional on the *name* of the axis. """ kwargs = _parse_logscale_args(kwargs, 'base', 'linthresh', 'linscale', 'subs') super().__init__(**kwargs) # Note the symlog locator gets base and linthresh from the transform # self._major_formatter = Formatter('symlog')) self._major_locator = Locator('symlog', transform=self.get_transform()) self._minor_locator = Locator('symlog', transform=self.get_transform(), subs=self.subs)
[docs]class FuncScale(_ScaleBase, mscale.ScaleBase): """ Arbitrary scale with user-supplied forward and inverse functions and arbitrary additional transform applied thereafter. Input is a tuple of functions and, optionally, a `~matplotlib.transforms.Transform` or `~matplotlib.scale.ScaleBase` instance. """ name = 'function' """The registered scale name.""" def __init__(self, functions, transform=None, scale=None, major_locator=None, minor_locator=None, major_formatter=None, minor_formatter=None, ): """ Parameters ---------- functions : (function, function) or `~matplotlib.scale.ScaleBase` Length-2 tuple of forward and inverse functions, or another `~matplotlib.scale.ScaleBase` from which the functions are drawn. transform : `~matplotlib.transforms.Transform`, optional Additional transform applied after the forward function and before the inverse function. major_locator, minor_locator : `~matplotlib.ticker.Locator`, optional The default major and minor locator. By default these are the same as `~matplotlib.scale.LinearScale`. major_formatter, minor_formatter : `~matplotlib.ticker.Formatter`, \ optional The default major and minor formatter. By default these are the same as `~matplotlib.scale.LinearScale`. """ if np.iterable(functions) and len(functions) == 2 and all( callable(ifunction) for ifunction in functions): forward, inverse = functions else: raise ValueError( f'scale needs length-2 list of forward and inverse ' f'functions, not {functions!r}.') functransform = FuncTransform(forward, inverse) if transform is not None: if isinstance(transform, mtransforms.Transform): functransform = functransform + transform else: raise ValueError( f'transform {transform!r} must be a Transform instance, ' f'not {type(transform)!r}.') self._transform = functransform if major_locator: self._major_locator = major_locator if minor_locator: self._minor_locator = minor_locator if major_formatter: self._major_formatter = major_formatter if minor_formatter: self._minor_formatter = minor_formatter
class FuncTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 is_separable = True has_inverse = True def __init__(self, forward, inverse): super().__init__() if callable(forward) and callable(inverse): self._forward = forward self._inverse = inverse else: raise ValueError('arguments to FuncTransform must be functions') def inverted(self): return FuncTransform(self._inverse, self._forward) def transform_non_affine(self, values): return self._forward(values)
[docs]class PowerScale(_ScaleBase, mscale.ScaleBase): r""" Returns a "power scale" that performs the transformation .. math:: x^{c} """ name = 'power' """The registered scale name.""" def __init__(self, power=1, inverse=False, *, minpos=1e-300, **kwargs): """ Parameters ---------- power : float, optional The power :math:`c` to which :math:`x` is raised. inverse : bool, optional If ``True``, the "forward" direction performs the inverse operation :math:`x^{1/c}`. minpos : float, optional The minimum permissible value, used to truncate negative values. """ super().__init__() if not inverse: self._transform = PowerTransform(power, minpos) else: self._transform = InvertedPowerTransform(power, minpos)
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): """Returns the range *vmin* and *vmax* limited to positive numbers.""" return max(vmin, minpos), max(vmax, minpos)
class PowerTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 has_inverse = True is_separable = True def __init__(self, power, minpos): super().__init__() self.minpos = minpos self._power = power def inverted(self): return InvertedPowerTransform(self._power, self.minpos) def transform_non_affine(self, a): aa = np.array(a) aa[aa <= self.minpos] = self.minpos # necessary return np.power(np.array(a), self._power) class InvertedPowerTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 has_inverse = True is_separable = True def __init__(self, power, minpos): super().__init__() self.minpos = minpos self._power = power def inverted(self): return PowerTransform(self._power, self.minpos) def transform_non_affine(self, a): aa = np.array(a) aa[aa <= self.minpos] = self.minpos # necessary return np.power(np.array(a), 1 / self._power)
[docs]class ExpScale(_ScaleBase, mscale.ScaleBase): r""" An "exponential scale". When `inverse` is ``False`` (the default), this performs the transformation .. math:: Ca^{bx} where the constants :math:`a`, :math:`b`, and :math:`C` are set by the input (see below). When `inverse` is ``True``, this performs the inverse transformation .. math:: (\log_a(x) - \log_a(C))/b which in appearence is equivalent to `LogScale` since it is just a linear transformation of the logarithm. """ name = 'exp' """The registered scale name.""" def __init__( self, a=np.e, b=1, c=1, inverse=False, minpos=1e-300, **kwargs): """ Parameters ---------- a : float, optional The base of the exponential, i.e. the :math:`a` in :math:`Ca^{bx}`. b : float, optional The scale for the exponent, i.e. the :math:`b` in :math:`Ca^{bx}`. c : float, optional The coefficient of the exponential, i.e. the :math:`C` in :math:`Ca^{bx}`. minpos : float, optional The minimum permissible value, used to truncate negative values. inverse : bool, optional If ``True``, the "forward" direction performs the inverse operation. """ super().__init__() if not inverse: self._transform = ExpTransform(a, b, c, minpos) else: self._transform = InvertedExpTransform(a, b, c, minpos)
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): """Returns the range *vmin* and *vmax* limited to positive numbers.""" return max(vmin, minpos), max(vmax, minpos)
class ExpTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 has_inverse = True is_separable = True def __init__(self, a, b, c, minpos): super().__init__() self.minpos = minpos self._a = a self._b = b self._c = c def inverted(self): return InvertedExpTransform(self._a, self._b, self._c, self.minpos) def transform_non_affine(self, a): return self._c * np.power(self._a, self._b * np.array(a)) class InvertedExpTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 has_inverse = True is_separable = True def __init__(self, a, b, c, minpos): super().__init__() self.minpos = minpos self._a = a self._b = b self._c = c def inverted(self): return ExpTransform(self._a, self._b, self._c, self.minpos) def transform_non_affine(self, a): aa = np.array(a) aa[aa <= self.minpos] = self.minpos # necessary return np.log(aa / self._c) / (self._b * np.log(self._a))
[docs]class MercatorLatitudeScale(_ScaleBase, mscale.ScaleBase): """ Scales axis as with latitude in the `Mercator projection \ <http://en.wikipedia.org/wiki/Mercator_projection>`__. Adapted from `this example \ <https://matplotlib.org/examples/api/custom_scale_example.html>`__. The scale function is as follows. .. math:: y = \\ln(\\tan(\\pi x/180) + \\sec(\\pi x/180)) The inverse scale function is as follows. .. math:: x = 180\\arctan(\\sinh(y))/\\pi """ name = 'mercator' """The registered scale name.""" def __init__(self, thresh=85.0): """ Parameters ---------- thresh : float, optional Threshold between 0 and 90, used to constrain axis limits between ``-thresh`` and ``+thresh``. """ super().__init__() if thresh >= 90.0: raise ValueError('Threshold "thresh" must be <=90.') self._thresh = thresh self._transform = MercatorLatitudeTransform(thresh) self._major_formatter = Formatter('deg') self._smart_bounds = True
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): """Returns the range *vmin* and *vmax* limited to some range within +/-90 degrees (exclusive).""" return max(vmin, -self._thresh), min(vmax, self._thresh)
class MercatorLatitudeTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 is_separable = True has_inverse = True def __init__(self, thresh): super().__init__() self._thresh = thresh def inverted(self): return InvertedMercatorLatitudeTransform(self._thresh) def transform_non_affine(self, a): # With safeguards # TODO: Can improve this? a = np.deg2rad(a) # convert to radians m = ma.masked_where((a < -self._thresh) | (a > self._thresh), a) if m.mask.any(): return ma.log(np.abs(ma.tan(m) + 1 / ma.cos(m))) else: return np.log(np.abs(np.tan(a) + 1 / np.cos(a))) class InvertedMercatorLatitudeTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 is_separable = True has_inverse = True def __init__(self, thresh): super().__init__() self._thresh = thresh def inverted(self): return MercatorLatitudeTransform(self._thresh) def transform_non_affine(self, a): # m = ma.masked_where((a < -self._thresh) | (a > self._thresh), a) # always assume in first/fourth quadrant, i.e. go from -pi/2 to pi/2 return np.rad2deg(np.arctan2(1, np.sinh(a)))
[docs]class SineLatitudeScale(_ScaleBase, mscale.ScaleBase): r""" Scales axis to be linear in the *sine* of *x* in degrees. The scale function is as follows. .. math:: y = \sin(\pi x/180) The inverse scale function is as follows. .. math:: x = 180\arcsin(y)/\pi """ name = 'sine' """The registered scale name.""" def __init__(self): super().__init__() self._transform = SineLatitudeTransform() self._major_formatter = Formatter('deg') self._smart_bounds = True
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): """Returns the range *vmin* and *vmax* limited to some range within +/-90 degrees (inclusive).""" return max(vmin, -90), min(vmax, 90)
class SineLatitudeTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 is_separable = True has_inverse = True def __init__(self): super().__init__() def inverted(self): return InvertedSineLatitudeTransform() def transform_non_affine(self, a): # With safeguards # TODO: Can improve this? with np.errstate(invalid='ignore'): # NaNs will always be False m = (a >= -90) & (a <= 90) if not m.all(): aa = ma.masked_where(~m, a) return ma.sin(np.deg2rad(aa)) else: return np.sin(np.deg2rad(a)) class InvertedSineLatitudeTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 is_separable = True has_inverse = True def __init__(self): super().__init__() def inverted(self): return SineLatitudeTransform() def transform_non_affine(self, a): # Clipping, instead of setting invalid # NOTE: Using ma.arcsin below caused super weird errors, dun do that aa = a.copy() return np.rad2deg(np.arcsin(aa))
[docs]class CutoffScale(_ScaleBase, mscale.ScaleBase): """ Axis scale composed of arbitrary piecewise linear transformations. The axis can undergo discrete jumps, "accelerations", or "decelerations" between successive thresholds. Adapted from `this stackoverflow post <https://stackoverflow.com/a/5669301/4970632>`__. """ name = 'cutoff' """The registered scale name.""" def __init__(self, *args, **kwargs): """ Parameters ---------- *args : (thresh_1, scale_1, ..., thresh_N, [scale_N]), optional Sequence of thresholds and scales. If the final scale is omitted (i.e. you passed an odd number of args) it is set to ``1``. * If ``scale_i < 1``, the axis is decelerated from ``thresh_i`` to ``thresh_i+1`` or, if ``i == N``, everywhere above ``thresh_i``. * If ``scale_i > 1``, the axis is accelerated from ``thresh_i`` to ``thresh_i+1`` or, if ``i == N``, everywhere above ``thresh_i``. * If ``scale_i == np.inf``, the axis *discretely jumps* from ``thresh_i`` to ``thresh_i+1``. Example ------- >>> import proplot as plot ... import numpy as np ... scale = plot.CutoffScale(10, 2) # go "twice as fast" after 10 ... scale = plot.CutoffScale(10, 0.5, 20) # zoom in between 10 and 20 ... scale = plot.CutoffScale(10, np.inf, 20) # jump from 10 to 20 """ super().__init__() args = list(args) if len(args) % 2 == 1: args.append(1) threshs = args[::2] scales = args[1::2] self._transform = CutoffTransform(threshs, scales)
class CutoffTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 has_inverse = True is_separable = True def __init__(self, threshs, scales, zero_dists=None): # The zero_dists array is used to fill in distances where scales and # threshold steps are zero. Used for inverting discrete transorms. super().__init__() dists = np.diff(threshs) scales = np.asarray(scales) threshs = np.asarray(threshs) if len(scales) != len(threshs): raise ValueError(f'Got {len(threshs)} but {len(scales)} scales.') if any(scales < 0): raise ValueError('Scales must be non negative.') if scales[-1] in (0, np.inf): raise ValueError('Final scale must be finite.') if any(dists < 0): raise ValueError('Thresholds must be monotonically increasing.') if any((dists == 0) | (scales == 0)) and ( any((dists == 0) != (scales == 0)) or zero_dists is None): raise ValueError( 'Got zero scales and distances in different places or ' 'zero_dists is None.') self._scales = scales self._threshs = threshs with np.errstate(divide='ignore', invalid='ignore'): dists = np.concatenate((threshs[:1], dists / scales[:-1])) if zero_dists is not None: dists[scales[:-1] == 0] = zero_dists self._dists = dists def inverted(self): # Use same algorithm for inversion! threshs = np.cumsum(self._dists) # thresholds in transformed space with np.errstate(divide='ignore', invalid='ignore'): scales = 1 / self._scales # new scales are inverse zero_dists = np.diff(self._threshs)[scales[:-1] == 0] return CutoffTransform(threshs, scales, zero_dists=zero_dists) def transform_non_affine(self, a): # Cannot do list comprehension because this method sometimes # received non-1d arrays dists = self._dists scales = self._scales threshs = self._threshs aa = np.array(a) # copy with np.errstate(divide='ignore', invalid='ignore'): for i, ai in np.ndenumerate(a): j = np.searchsorted(threshs, ai) if j > 0: aa[i] = dists[:j].sum() + ( ai - threshs[j - 1]) / scales[j - 1] return aa
[docs]class InverseScale(_ScaleBase, mscale.ScaleBase): r""" Scales axis to be linear in the *inverse* of *x*. The scale function and inverse scale function are as follows. .. math:: y = x^{-1} """ # Unlike log-scale, we can't just warp the space between # the axis limits -- have to actually change axis limits. Also this # scale will invert and swap the limits you provide. Weird! name = 'inverse' """The registered scale name.""" def __init__(self, **kwargs): super().__init__() self._transform = InverseTransform() self._major_locator = Locator('log', base=10, subs=(1, 2, 5)) self._minor_locator = Locator('log', base=10, subs='auto') self._smart_bounds = True # self._minor_formatter = Fromatter('log')
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): """Returns the range *vmin* and *vmax* limited to positive numbers.""" return max(vmin, minpos), max(vmax, minpos)
class InverseTransform(mtransforms.Transform): # Create transform object input_dims = 1 output_dims = 1 is_separable = True has_inverse = True def __init__(self): super().__init__() def inverted(self): return InverseTransform() def transform_non_affine(self, a): a = np.array(a) # f = np.abs(a) <= self.minpos # attempt for negative-friendly # aa[f] = np.sign(a[f])*self.minpos with np.errstate(divide='ignore', invalid='ignore'): return 1.0 / a #: The registered scale names and their associated #: `~matplotlib.scale.ScaleBase` classes. See `Scale` for a table. scales = mscale._scale_mapping #: Mapping of strings to `~matplotlib.ticker.Locator` classes. See #: `Locator` for a table.""" locators = { 'none': mticker.NullLocator, 'null': mticker.NullLocator, 'auto': mticker.AutoLocator, 'log': mticker.LogLocator, 'maxn': mticker.MaxNLocator, 'linear': mticker.LinearLocator, 'multiple': mticker.MultipleLocator, 'fixed': mticker.FixedLocator, 'index': mticker.IndexLocator, 'symlog': mticker.SymmetricalLogLocator, 'logit': mticker.LogitLocator, 'minor': mticker.AutoMinorLocator, 'date': mdates.AutoDateLocator, 'microsecond': mdates.MicrosecondLocator, 'second': mdates.SecondLocator, 'minute': mdates.MinuteLocator, 'hour': mdates.HourLocator, 'day': mdates.DayLocator, 'weekday': mdates.WeekdayLocator, 'month': mdates.MonthLocator, 'year': mdates.YearLocator, } if hasattr(mpolar, 'ThetaLocator'): locators['theta'] = mpolar.ThetaLocator #: Mapping of strings to `~matplotlib.ticker.Formatter` classes. See #: `Formatter` for a table. formatters = { # note default LogFormatter uses ugly e+00 notation 'auto': AutoFormatter, 'frac': FracFormatter, 'simple': SimpleFormatter, 'date': mdates.AutoDateFormatter, 'datestr': mdates.DateFormatter, 'scalar': mticker.ScalarFormatter, 'none': mticker.NullFormatter, 'null': mticker.NullFormatter, 'strmethod': mticker.StrMethodFormatter, 'formatstr': mticker.FormatStrFormatter, 'log': mticker.LogFormatterSciNotation, 'sci': mticker.LogFormatterSciNotation, 'math': mticker.LogFormatterMathtext, 'logit': mticker.LogitFormatter, 'eng': mticker.EngFormatter, 'percent': mticker.PercentFormatter, 'index': mticker.IndexFormatter, } if hasattr(mdates, 'ConciseDateFormatter'): formatters['concise'] = mdates.ConciseDateFormatter if hasattr(mpolar, 'ThetaFormatter'): formatters['theta'] = mpolar.ThetaFormatter # Monkey patch. Force scale_factory to accept ScaleBase instances, so that # set_xscale and set_yscale can accept scales returned by the Scale constructor if mscale.scale_factory is not _scale_factory: mscale.scale_factory = _scale_factory # Custom scales and overrides mscale.register_scale(CutoffScale) mscale.register_scale(ExpScale) mscale.register_scale(LogScale) mscale.register_scale(LinearScale) mscale.register_scale(LogitScale) mscale.register_scale(FuncScale) mscale.register_scale(PowerScale) mscale.register_scale(SymmetricalLogScale) mscale.register_scale(InverseScale) mscale.register_scale(SineLatitudeScale) mscale.register_scale(MercatorLatitudeScale)