Source code for ultraplot.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 (
    _canonicalize_kwargs,
    _not_none,
    _version_mpl,
    ic,  # noqa: F401
    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.
    scope = "scale.symlog" if "linthresh" in keys else "scale.log"
    kwargs = _canonicalize_kwargs(scope, kwargs)
    kwsuffix = "" if _version_mpl >= "3.3" else "x"
    for key in keys:
        value = kwargs.pop(key, None)

        # 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 `~ultraplot.ticker.AutoFormatter` as the default major formatter. """ #: The registered scale name name = "linear" def __init__(self, **kwargs): """ See also -------- ultraplot.constructor.Scale """ super().__init__(**kwargs) self._transform = mtransforms.IdentityTransform()
[docs] class LogitScale(_Scale, mscale.LogitScale): """ As with `~matplotlib.scale.LogitScale` but with `~ultraplot.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 -------- ultraplot.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 `~ultraplot.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. See also -------- ultraplot.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 `~ultraplot.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. See also -------- ultraplot.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 `~ultraplot.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 `~ultraplot.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 `~ultraplot.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 -------- ultraplot.constructor.Scale ultraplot.axes.CartesianAxes.dualx ultraplot.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_ultraplot("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) # Apply the function in data space, then parent scale (e.g., log). # This ensures dual axes behave correctly when the parent is non-linear. self._transform = FuncTransform(forward, inverse) + parent_scale.get_transform() # 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 -------- ultraplot.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 -------- ultraplot.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.arctan(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 -------- ultraplot.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 -------- ultraplot.constructor.Scale Example ------- >>> import ultraplot as uplt >>> import numpy as np >>> scale = uplt.CutoffScale(10, 0.5) # move slower above 10 >>> scale = uplt.CutoffScale(10, 2, 20) # move faster between 10 and 20 >>> scale = uplt.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: zero_idx = np.flatnonzero(scales[:-1] == 0) + 1 dists[zero_idx] = 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 -------- ultraplot.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_ultraplot(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)) + "." ) # Ensure that if the scaler exists in mpl or otherwise it will attempt to return it, which has a different signature than the _Scale class above scaler = mapping[scale] # Assume it is a MPL class if not issubclass(scaler, _Scale): return scaler(axis, *args, **kwargs) return scaler(*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