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, dependencies, warnings

scales = mscale._scale_mapping

__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 dependencies._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
        # 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 == 'linthresh' and value is None:
            value = 1 + 1e-10
        if key == 'subs' and value is None:
            value = np.arange(1, 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 = None
        self._default_minor_locator = None
        self._default_major_formatter = None
        self._default_minor_formatter = None

    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? Currently is only for dual axes.
        # Apply isDefault 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 set as 'default' if AutoMinorLocator.
        from .config import rc
        if not only_if_default or axis.isDefault_majloc:
            axis.set_major_locator(
                self._default_major_locator or mticker.AutoLocator()
            )
            axis.isDefault_majloc = True
        if not only_if_default or axis.isDefault_majfmt:
            axis.set_major_formatter(
                self._default_major_formatter or pticker.AutoFormatter()
            )
            axis.isDefault_majfmt = True
        if not only_if_default or axis.isDefault_minloc:
            name = axis.axis_name if axis.axis_name in 'xy' else 'x'
            axis.set_minor_locator(
                self._default_minor_locator or mticker.AutoMinorLocator()
                if rc[name + 'tick.minor.visible']
                else mticker.NullLocator()
            )
            axis.isDefault_minloc = True
        if not only_if_default or axis.isDefault_minfmt:
            axis.set_minor_formatter(
                self._default_minor_formatter or mticker.NullFormatter()
            )
            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): """""" # empty docstring 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. """ 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, 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 : 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, etc. The default is ``subs=numpy.arange(1, 10)``. basex, basey, nonposx, nonposy, subsx, subsy Aliases for the above keywords. These used to be conditional on the *name* of the axis. """ 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, 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. The default is ``subs=numpy.arange(1, 10)``. 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. """ 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, arg, invert=False, parent_scale=None, major_locator=None, minor_locator=None, major_formatter=None, minor_formatter=None, ): """ Parameters ---------- arg : callable, 2-tuple of callable, or `~matplotlib.scale.ScaleBase` The transform used to translate units from the parent axis to the secondary axis. Input can be as follows: * A single function that accepts a number and returns some transformation of that number. If you do not provide the inverse, the function must be `linear \ <https://en.wikipedia.org/wiki/Linear_function>`__ or `involutory \ <https://en.wikipedia.org/wiki/Involution_(mathematics)>`__. 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 such functions. 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))``. Again, if the first function is linear or involutory, you do not need to provide the second! * A scale specification interpreted by the `~proplot.constructor.Scale` constructor function. The forward transformation, inverse transformation, and default axis 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 function, 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` The axis scale of the "parent" axis. Its forward transform is applied to the `FuncTransform`. Used when drawing dual axes. major_locator, minor_locator : `~matplotlib.ticker.Locator`, optional The default major and minor locator. By default these are borrowed from `transform`. If `transform` is not an axis scale, they 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 borrowed from `transform`. If `transform` is not an axis scale, they are the same as `~matplotlib.scale.LinearScale`. """ # NOTE: We permit *arbitrary* parent axis scales. If the parent is # non-linear, we use *its* default locators and formatters. Assumption # is this is a log scale and the child is maybe some multiple or offset # of that scale. If the parent axis scale is linear, use the funcscale # defaults, which can inherit defaults. super().__init__() inherit_scale = None # scale for inheriting properties if callable(arg): forward = inverse = arg elif np.iterable(arg) and len(arg) == 2 and all(map(callable, arg)): forward, inverse = arg else: from .constructor import Scale try: inherit_scale = Scale(arg) except ValueError: raise ValueError( 'Input should be a function, 2-tuple of forward and and inverse ' f'functions, or an axis scale specification, not {arg!r}.' ) trans = inherit_scale.get_transform() forward = trans.transform inverse = trans.inverted().transform # Create the FuncTransform or composite transform used for this class # May need to invert functions for dualx() and dualy() if invert: forward, inverse = inverse, forward functransform = FuncTransform(forward, inverse) # Manage the parent axis scale # NOTE: Must transform parent scale cutoff arguments as well. # NOTE: Makes sense to use the inverse function here because this is # a transformation from some *other* axis to this one, not vice versa. if parent_scale is None: pass elif isinstance(parent_scale, mscale.ScaleBase): if isinstance(parent_scale, mscale.SymmetricalLogScale): keys = ('base', 'linthresh', 'linscale', 'subs') kwargs = {key: getattr(parent_scale, key) for key in keys} kwargs['linthresh'] = inverse(kwargs['linthresh']) parent_scale = SymmetricalLogScale(**kwargs) 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) functransform = parent_scale.get_transform() + functransform else: raise ValueError( f'parent_scale {parent_scale!r} must be a ScaleBase instance, ' f'not {type(parent_scale)!r}.' ) # Transform and default stuff self.functions = (forward, inverse) self._transform = functransform self._default_major_locator = major_locator self._default_minor_locator = minor_locator self._default_major_formatter = major_formatter self._default_minor_formatter = minor_formatter # Try to borrow locators and formatters # WARNING: Using the same locator on multiple axes can evidently # have unintended side effects! So we make copies. for scale in (inherit_scale, parent_scale): if not isinstance(scale, _Scale): continue if isinstance(scale, mscale.LinearScale): continue for name in ( 'major_locator', 'minor_locator', 'major_formatter', 'minor_formatter' ): attr = f'_default_{name}' obj = getattr(scale, attr) if getattr(self, attr) is None and obj is not None: setattr(self, attr, copy.copy(obj))
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``, the "forward" direction 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 appearence 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. """ 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``. """ 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): """""" # no parameters 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``. 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): """""" # empty docstring 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. """ 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 scales: raise ValueError( f'Unknown axis scale {scale!r}. Options are ' + ', '.join(map(repr, scales)) + '.' ) return scales[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