Source code for proplot.scale

#!/usr/bin/env python3
"""
Various axis `~matplotlib.scale.ScaleBase` classes.
"""
import copy

import matplotlib.scale as mscale
import matplotlib.ticker as mticker
import matplotlib.transforms as mtransforms
import numpy as np
import numpy.ma as ma

from . import ticker as pticker
from .internals import ic  # noqa: F401
from .internals import _not_none, _version_mpl, warnings

__all__ = [
    'CutoffScale',
    'ExpScale',
    'FuncScale',
    'InverseScale',
    'LinearScale',
    'LogitScale',
    'LogScale',
    'MercatorLatitudeScale',
    'PowerScale',
    'SineLatitudeScale',
    'SymmetricalLogScale',
]


def _parse_logscale_args(*keys, **kwargs):
    """
    Parse arguments for `LogScale` and `SymmetricalLogScale` that
    inexplicably require ``x`` and ``y`` suffixes by default. Also
    change the default `linthresh` to ``1``.
    """
    # NOTE: Scale classes ignore unused arguments with warnings, but matplotlib 3.3
    # version changes the keyword args. Since we can't do a try except clause, only
    # way to avoid warnings with 3.3 upgrade is to test version string.
    kwsuffix = '' if _version_mpl >= '3.3' else 'x'
    for key in keys:
        # Remove duplicates
        opts = {
            key: kwargs.pop(key, None),
            key + 'x': kwargs.pop(key + 'x', None),
            key + 'y': kwargs.pop(key + 'y', None),
        }
        value = _not_none(**opts)  # issues warning if multiple values passed

        # Apply defaults and adjust
        # NOTE: If linthresh is *exactly* on a power of the base, can end
        # up with additional log-locator step inside the threshold, e.g. major
        # ticks on -10, -1, -0.1, 0.1, 1, 10 for linthresh of 1. Adding slight
        # offset to *desired* linthresh prevents this.
        if key == 'subs':
            if value is None:
                value = np.arange(1, 10)
        if key == 'linthresh':
            if value is None:
                value = 1
            power = np.log10(value)
            if power % 1 == 0:  # exact power of 10
                value = value + 10 ** (power - 10)
        if value is not None:  # dummy axis_name is 'x'
            kwargs[key + kwsuffix] = value

    return kwargs


class _Scale(object):
    """
    Mix-in class that standardizes the behavior of
    `~matplotlib.scale.ScaleBase.set_default_locators_and_formatters`
    and `~matplotlib.scale.ScaleBase.get_transform`. 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'})()
        super().__init__(axis, *args, **kwargs)
        self._default_major_locator = mticker.AutoLocator()
        self._default_minor_locator = mticker.AutoMinorLocator()
        self._default_major_formatter = pticker.AutoFormatter()
        self._default_minor_formatter = mticker.NullFormatter()

    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.
        """
        # TODO: Always use only_if_default=True? Used only for dual axes right now
        # NOTE: We set isDefault_minloc to True when simply toggling minor ticks
        # on and off with CartesianAxes format command.
        from .config import rc
        if not only_if_default or axis.isDefault_majloc:
            locator = copy.copy(self._default_major_locator)
            axis.set_major_locator(locator)
            axis.isDefault_majloc = True
        if not only_if_default or axis.isDefault_minloc:
            x = axis.axis_name if axis.axis_name in 'xy' else 'x'
            if rc[x + 'tick.minor.visible']:
                locator = copy.copy(self._default_minor_locator)
            else:
                locator = mticker.NullLocator()
            axis.set_minor_locator(locator)
            axis.isDefault_minloc = True
        if not only_if_default or axis.isDefault_majfmt:
            formatter = copy.copy(self._default_major_formatter)
            axis.set_major_formatter(formatter)
            axis.isDefault_majfmt = True
        if not only_if_default or axis.isDefault_minfmt:
            formatter = copy.copy(self._default_minor_formatter)
            axis.set_minor_formatter(formatter)
            axis.isDefault_minfmt = True

    def get_transform(self):
        """
        Return the scale transform.
        """
        return self._transform


[docs]class LinearScale(_Scale, mscale.LinearScale): """ As with `~matplotlib.scale.LinearScale` but with `~proplot.ticker.AutoFormatter` as the default major formatter. """ #: The registered scale name name = 'linear' def __init__(self, **kwargs): """ See also -------- proplot.constructor.Scale """ super().__init__(**kwargs) self._transform = mtransforms.IdentityTransform()
[docs]class LogitScale(_Scale, mscale.LogitScale): """ As with `~matplotlib.scale.LogitScale` but with `~proplot.ticker.AutoFormatter` as the default major formatter. """ #: The registered scale name name = 'logit' def __init__(self, **kwargs): """ Parameters ---------- nonpos : {'mask', 'clip'} Values outside of (0, 1) can be masked as invalid, or clipped to a number very close to 0 or 1. See also -------- proplot.constructor.Scale """ super().__init__(**kwargs) # self._default_major_formatter = mticker.LogitFormatter() self._default_major_locator = mticker.LogitLocator() self._default_minor_locator = mticker.LogitLocator(minor=True)
[docs]class LogScale(_Scale, mscale.LogScale): """ As with `~matplotlib.scale.LogScale` but with `~proplot.ticker.AutoFormatter` as the default major formatter. ``x`` and ``y`` versions of each keyword argument are no longer required. """ #: The registered scale name name = 'log' def __init__(self, **kwargs): """ Parameters ---------- base : float, default: 10 The base of the logarithm. 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 : sequence of int, default: ``[1 2 3 4 5 6 7 8 9]`` 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, nonposx, nonposy, subsx, subsy Aliases for the above keywords. These used to be conditional on the *name* of the axis. See also -------- proplot.constructor.Scale """ keys = ('base', 'nonpos', 'subs') super().__init__(**_parse_logscale_args(*keys, **kwargs)) self._default_major_locator = mticker.LogLocator(self.base) self._default_minor_locator = mticker.LogLocator(self.base, self.subs)
[docs]class SymmetricalLogScale(_Scale, mscale.SymmetricalLogScale): """ As with `~matplotlib.scale.SymmetricalLogScale` but with `~proplot.ticker.AutoFormatter` as the default major formatter. ``x`` and ``y`` versions of each keyword argument are no longer required. """ #: The registered scale name name = 'symlog' def __init__(self, **kwargs): """ Parameters ---------- base : float, default: 10 The base of the logarithm. linthresh : float, default: 1 Defines the range ``(-linthresh, linthresh)``, within which the plot is linear. This avoids having the plot go to infinity around zero. linscale : float, default: 1 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, default: ``[1 2 3 4 5 6 7 8 9]`` 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 keywords used to be conditional on the name of the axis. See also -------- proplot.constructor.Scale """ keys = ('base', 'linthresh', 'linscale', 'subs') super().__init__(**_parse_logscale_args(*keys, **kwargs)) transform = self.get_transform() self._default_major_locator = mticker.SymmetricalLogLocator(transform) self._default_minor_locator = mticker.SymmetricalLogLocator(transform, self.subs) # noqa: E501
[docs]class FuncScale(_Scale, mscale.ScaleBase): """ Axis scale composed of arbitrary forward and inverse transformations. """ #: The registered scale name name = 'function' def __init__(self, transform=None, invert=False, parent_scale=None, **kwargs): """ Parameters ---------- transform : callable, 2-tuple of callable, or scale-spec The transform used to translate units from the parent axis to the secondary axis. Input can be as follows: * A single `linear <https://en.wikipedia.org/wiki/Linear_function>`__ or `involutory <https://en.wikipedia.org/wiki/Involution_(mathematics)>`__ function that accepts a number and returns some transformation of that number. For example, to convert Kelvin to Celsius, use ``ax.dualx(lambda x: x - 273.15)``. To convert kilometers to meters, use ``ax.dualx(lambda x: x * 1e3)``. * A 2-tuple of arbitrary functions. This should only be used if your functions are non-linear and non-involutory. The second function must be the inverse of the first. For example, to apply the square, use ``ax.dualx((lambda x: x ** 2, lambda x: x ** 0.5))``. * A scale specification passed to the `~proplot.constructor.Scale` constructor function. The transform and default locators and formatters are borrowed from the resulting `~matplotlib.scale.ScaleBase` instance. For example, to apply the inverse, use ``ax.dualx('inverse')``. To apply the base-10 exponential, use ``ax.dualx(('exp', 10))``. invert : bool, optional If ``True``, the forward and inverse functions are *swapped*. Used when drawing dual axes. parent_scale : `~matplotlib.scale.ScaleBase`, default: `LinearScale` The axis scale of the "parent" axis. Its forward transform is applied to the `FuncTransform`. major_locator, minor_locator : locator-spec, optional The default major and minor locator. Passed to the `~proplot.constructor.Locator` constructor function. By default, these are the same as the default locators on the input transform. If the input transform was not an axis scale, these are borrowed from `parent_scale`. major_formatter, minor_formatter : formatter-spec, optional The default major and minor formatter. Passed to the `~proplot.constructor.Formatter` constructor function. By default, these are the same as the default formatters on the input transform. If the input transform was not an axis scale, these are borrowed from `parent_scale`. See also -------- proplot.constructor.Scale proplot.axes.CartesianAxes.dualx proplot.axes.CartesianAxes.dualy """ # Parse input args # NOTE: Permit *arbitrary* parent axis scales and infer default locators and # formatters from the input scale (if it was passed) or the parent scale. Use # case for latter is e.g. logarithmic scale with linear transformation. if 'functions' in kwargs: # matplotlib compatibility (critical for >= 3.5) functions = kwargs.pop('functions', None) if transform is None: transform = functions else: warnings._warn_proplot("Ignoring keyword argument 'functions'.") from .constructor import Formatter, Locator, Scale super().__init__() if callable(transform): forward, inverse, inherit_scale = transform, transform, None elif np.iterable(transform) and len(transform) == 2 and all(map(callable, transform)): # noqa: E501 forward, inverse, inherit_scale = *transform, None else: try: inherit_scale = Scale(transform) except ValueError: raise ValueError( 'Expected a function, 2-tuple of forward and inverse functions, ' f'or an axis scale specification. Got {transform!r}.' ) transform = inherit_scale.get_transform() forward, inverse = transform.transform, transform.inverted().transform # Create the transform # NOTE: Linear scale is always identity transform (no-op). # NOTE: Must transform parent scale cutoff arguments as well. Use inverse # function because we are converting from some *other* axis to this one. if invert: # used for dualx and dualy forward, inverse = inverse, forward parent_scale = _not_none(parent_scale, LinearScale()) if not isinstance(parent_scale, mscale.ScaleBase): raise ValueError(f'Parent scale must be ScaleBase. Got {parent_scale!r}.') if isinstance(parent_scale, CutoffScale): args = list(parent_scale.args) # mutable copy args[::2] = (inverse(arg) for arg in args[::2]) # transform cutoffs parent_scale = CutoffScale(*args) if isinstance(parent_scale, mscale.SymmetricalLogScale): keys = ('base', 'linthresh', 'linscale', 'subs') kwsym = {key: getattr(parent_scale, key) for key in keys} kwsym['linthresh'] = inverse(kwsym['linthresh']) parent_scale = SymmetricalLogScale(**kwsym) self.functions = (forward, inverse) self._transform = parent_scale.get_transform() + FuncTransform(forward, inverse) # Apply default locators and formatters # NOTE: We pass these through contructor functions scale = inherit_scale or parent_scale for which in ('major', 'minor'): for type_, parser in (('locator', Locator), ('formatter', Formatter)): key = which + '_' + type_ attr = '_default_' + key ticker = kwargs.pop(key, None) if ticker is None: ticker = getattr(scale, attr, None) if ticker is None: # e.g. someone used a matplotlib scale continue # revert to defaults ticker = parser(ticker) setattr(self, attr, copy.copy(ticker)) if kwargs: raise TypeError(f'FuncScale got unexpected arguments: {kwargs}')
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): with np.errstate(divide='ignore', invalid='ignore'): return self._forward(values)
[docs]class PowerScale(_Scale, mscale.ScaleBase): r""" "Power scale" that performs the transformation .. math:: x^{c} """ #: The registered scale name name = 'power' def __init__(self, power=1, inverse=False): """ Parameters ---------- power : float, optional The power :math:`c` to which :math:`x` is raised. inverse : bool, optional If ``True`` this performs the inverse operation :math:`x^{1/c}`. """ super().__init__() if not inverse: self._transform = PowerTransform(power) else: self._transform = InvertedPowerTransform(power)
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): """ Return the range *vmin* and *vmax* limited to positive numbers. """ if not np.isfinite(minpos): minpos = 1e-300 return ( minpos if vmin <= 0 else vmin, minpos if vmax <= 0 else vmax, )
class PowerTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 has_inverse = True is_separable = True def __init__(self, power): super().__init__() self._power = power def inverted(self): return InvertedPowerTransform(self._power) def transform_non_affine(self, a): with np.errstate(divide='ignore', invalid='ignore'): return np.power(a, self._power) class InvertedPowerTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 has_inverse = True is_separable = True def __init__(self, power): super().__init__() self._power = power def inverted(self): return PowerTransform(self._power) def transform_non_affine(self, a): with np.errstate(divide='ignore', invalid='ignore'): return np.power(a, 1 / self._power)
[docs]class ExpScale(_Scale, mscale.ScaleBase): r""" "Exponential scale" that performs either of two transformations. When `inverse` is ``False`` (the default), 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 appearance is equivalent to `LogScale` since it is just a linear transformation of the logarithm. """ #: The registered scale name name = 'exp' def __init__(self, a=np.e, b=1, c=1, inverse=False): """ 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}`. inverse : bool, optional If ``True``, the "forward" direction performs the inverse operation. See also -------- proplot.constructor.Scale """ super().__init__() if not inverse: self._transform = ExpTransform(a, b, c) else: self._transform = InvertedExpTransform(a, b, c)
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): """ Return the range *vmin* and *vmax* limited to positive numbers. """ if not np.isfinite(minpos): minpos = 1e-300 return ( minpos if vmin <= 0 else vmin, minpos if vmax <= 0 else vmax, )
class ExpTransform(mtransforms.Transform): input_dims = 1 output_dims = 1 has_inverse = True is_separable = True def __init__(self, a, b, c): super().__init__() self._a = a self._b = b self._c = c def inverted(self): return InvertedExpTransform(self._a, self._b, self._c) def transform_non_affine(self, a): with np.errstate(divide='ignore', invalid='ignore'): 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): super().__init__() self._a = a self._b = b self._c = c def inverted(self): return ExpTransform(self._a, self._b, self._c) def transform_non_affine(self, a): with np.errstate(divide='ignore', invalid='ignore'): return np.log(a / self._c) / (self._b * np.log(self._a))
[docs]class MercatorLatitudeScale(_Scale, mscale.ScaleBase): """ Axis scale that is linear in the `Mercator projection latitude \ <http://en.wikipedia.org/wiki/Mercator_projection>`__. Adapted from `this example \ <https://matplotlib.org/2.0.2/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 """ #: The registered scale name name = 'mercator' def __init__(self, thresh=85.0): """ Parameters ---------- thresh : float, optional Threshold between 0 and 90, used to constrain axis limits between ``-thresh`` and ``+thresh``. See also -------- proplot.constructor.Scale """ super().__init__() if thresh >= 90: raise ValueError("Mercator scale 'thresh' must be <= 90.") self._thresh = thresh self._transform = MercatorLatitudeTransform(thresh) self._default_major_formatter = pticker.AutoFormatter(suffix='\N{DEGREE SIGN}')
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): # noqa: U100 """ Return the range *vmin* and *vmax* limited to 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): # NOTE: Critical to truncate valid range inside transform *and* # in limit_range_for_scale or get weird duplicate tick labels. This # is not necessary for positive-only scales because it is harder to # run up right against the scale boundaries. with np.errstate(divide='ignore', invalid='ignore'): m = ma.masked_where((a <= -90) | (a >= 90), a) if m.mask.any(): m = np.deg2rad(m) return ma.log(ma.abs(ma.tan(m) + 1 / ma.cos(m))) else: a = np.deg2rad(a) 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): with np.errstate(divide='ignore', invalid='ignore'): return np.rad2deg(np.arctan2(1, np.sinh(a)))
[docs]class SineLatitudeScale(_Scale, mscale.ScaleBase): r""" Axis scale that is linear in the sine transformation of *x*. The axis limits are constrained to fall between ``-90`` and ``+90`` 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 """ #: The registered scale name name = 'sine' def __init__(self): """ See also -------- proplot.constructor.Scale """ super().__init__() self._transform = SineLatitudeTransform() self._default_major_formatter = pticker.AutoFormatter(suffix='\N{DEGREE SIGN}')
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): # noqa: U100 """ Return the range *vmin* and *vmax* limited to 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): # NOTE: Critical to truncate valid range inside transform *and* # in limit_range_for_scale or get weird duplicate tick labels. This # is not necessary for positive-only scales because it is harder to # run up right against the scale boundaries. with np.errstate(divide='ignore', invalid='ignore'): m = ma.masked_where((a < -90) | (a > 90), a) if m.mask.any(): return ma.sin(np.deg2rad(m)) 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): with np.errstate(divide='ignore', invalid='ignore'): return np.rad2deg(np.arcsin(a))
[docs]class CutoffScale(_Scale, mscale.ScaleBase): """ Axis scale composed of arbitrary piecewise linear transformations. The axis can undergo discrete jumps, "accelerations", or "decelerations" between successive thresholds. """ #: The registered scale name name = 'cutoff' def __init__(self, *args): """ 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 arguments) it is set to ``1``. Each ``scale_i`` in the sequence can be interpreted as follows: * If ``scale_i < 1``, the axis is decelerated from ``thresh_i`` to ``thresh_i+1``. For ``scale_N``, the axis is decelerated everywhere above ``thresh_N``. * If ``scale_i > 1``, the axis is accelerated from ``thresh_i`` to ``thresh_i+1``. For ``scale_N``, the axis is accelerated everywhere above ``thresh_N``. * If ``scale_i == numpy.inf``, the axis *discretely jumps* from ``thresh_i`` to ``thresh_i+1``. The final scale ``scale_N`` *cannot* be ``numpy.inf``. See also -------- proplot.constructor.Scale Example ------- >>> import proplot as pplt >>> import numpy as np >>> scale = pplt.CutoffScale(10, 0.5) # move slower above 10 >>> scale = pplt.CutoffScale(10, 2, 20) # move faster between 10 and 20 >>> scale = pplt.CutoffScale(10, np.inf, 20) # jump from 10 to 20 """ # NOTE: See https://stackoverflow.com/a/5669301/4970632 super().__init__() args = list(args) if len(args) % 2 == 1: args.append(1) self.args = args self.threshs = args[::2] self.scales = args[1::2] self._transform = CutoffTransform(self.threshs, self.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)): if zero_dists is None: raise ValueError('Keyword zero_dists is required for discrete steps.') if any((dists == 0) != (scales == 0)): raise ValueError('Input scales disagree with discrete step locations.') 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.0 / 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(_Scale, mscale.ScaleBase): r""" Axis scale that is linear in the *inverse* of *x*. The forward and inverse scale functions are as follows: .. math:: y = x^{-1} """ #: The registered scale name name = 'inverse' def __init__(self): """ See also -------- proplot.constructor.Scale """ super().__init__() self._transform = InverseTransform() self._default_major_locator = mticker.LogLocator(10) self._default_minor_locator = mticker.LogLocator(10, np.arange(1, 10))
[docs] def limit_range_for_scale(self, vmin, vmax, minpos): """ Return the range *vmin* and *vmax* limited to positive numbers. """ # 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. if not np.isfinite(minpos): minpos = 1e-300 return ( minpos if vmin <= 0 else vmin, minpos if vmax <= 0 else vmax, )
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): with np.errstate(divide='ignore', invalid='ignore'): return 1.0 / a def _scale_factory(scale, axis, *args, **kwargs): # noqa: U100 """ Generate an axis scale. Parameters ---------- scale : str or `~matplotlib.scale.ScaleBase` The axis scale name or scale instance. axis : `~matplotlib.axis.Axis` The axis instance. *args, **kwargs Passed to `~matplotlib.scale.ScaleBase` if `scale` is a string. """ mapping = mscale._scale_mapping if isinstance(scale, mscale.ScaleBase): if args or kwargs: warnings._warn_proplot(f'Ignoring args {args} and keyword args {kwargs}.') return scale # do nothing else: scale = scale.lower() if scale not in mapping: raise ValueError( f'Unknown axis scale {scale!r}. Options are ' + ', '.join(map(repr, mapping)) + '.' ) return mapping[scale](*args, **kwargs) # Monkey patch matplotlib scale factory with version that accepts ScaleBase instances. # This lets set_xscale and set_yscale accept axis scales returned by Scale constructor # and makes things constistent with the other constructor functions. if mscale.scale_factory is not _scale_factory: mscale.scale_factory = _scale_factory