diff --git a/.flake8 b/.flake8 index b3c38d015490..3719d82131e5 100644 --- a/.flake8 +++ b/.flake8 @@ -197,6 +197,7 @@ per-file-ignores = examples/pyplots/whats_new_99_spines.py: E231, E402 examples/recipes/placing_text_boxes.py: E501 examples/scales/power_norm.py: E402 + examples/scales/scales.py: E402 examples/shapes_and_collections/artist_reference.py: E402 examples/shapes_and_collections/collections.py: E402 examples/shapes_and_collections/compound_path.py: E402 diff --git a/doc/users/next_whats_new/2018-11-25-JMK.rst b/doc/users/next_whats_new/2018-11-25-JMK.rst new file mode 100644 index 000000000000..6915de01700a --- /dev/null +++ b/doc/users/next_whats_new/2018-11-25-JMK.rst @@ -0,0 +1,11 @@ +:orphan: + +New `~.scale.FuncScale` added for arbitrary axes scales +```````````````````````````````````````````````````````` + +A new `~.scale.FuncScale` class was added (and `~.scale.FuncTransform`) +to allow the user to have arbitrary scale transformations without having to +write a new subclass of `~.scale.ScaleBase`. This can be accessed by +``ax.set_yscale('function', functions=(forward, inverse))``, where +``forward`` and ``inverse`` are callables that return the scale transform and +its inverse. See the last example in :doc:`/gallery/scales/scales`. diff --git a/examples/scales/custom_scale.py b/examples/scales/custom_scale.py index ea73b9d45e27..b4a4ea243527 100644 --- a/examples/scales/custom_scale.py +++ b/examples/scales/custom_scale.py @@ -5,6 +5,12 @@ Create a custom scale, by implementing the scaling use for latitude data in a Mercator Projection. + +Unless you are making special use of the `~.Transform` class, you probably +don't need to use this verbose method, and instead can use +`~.matplotlib.scale.FuncScale` and the ``'function'`` option of +`~.matplotlib.axes.Axes.set_xscale` and `~.matplotlib.axes.Axes.set_yscale`. +See the last example in :doc:`/gallery/scales/scales`. """ import numpy as np diff --git a/examples/scales/scales.py b/examples/scales/scales.py index 37a783ae2d30..89352c4351a5 100644 --- a/examples/scales/scales.py +++ b/examples/scales/scales.py @@ -4,10 +4,13 @@ ====== Illustrate the scale transformations applied to axes, e.g. log, symlog, logit. + +The last two examples are examples of using the ``'function'`` scale by +supplying forward and inverse functions for the scale transformation. """ import numpy as np import matplotlib.pyplot as plt -from matplotlib.ticker import NullFormatter +from matplotlib.ticker import NullFormatter, FixedLocator # Fixing random state for reproducibility np.random.seed(19680801) @@ -19,8 +22,8 @@ x = np.arange(len(y)) # plot with various axes scales -fig, axs = plt.subplots(2, 2, sharex=True) -fig.subplots_adjust(left=0.08, right=0.98, wspace=0.3) +fig, axs = plt.subplots(3, 2, figsize=(6, 8), + constrained_layout=True) # linear ax = axs[0, 0] @@ -54,4 +57,66 @@ ax.yaxis.set_minor_formatter(NullFormatter()) +# Function x**(1/2) +def forward(x): + return x**(1/2) + + +def inverse(x): + return x**2 + + +ax = axs[2, 0] +ax.plot(x, y) +ax.set_yscale('function', functions=(forward, inverse)) +ax.set_title('function: $x^{1/2}$') +ax.grid(True) +ax.yaxis.set_major_locator(FixedLocator(np.arange(0, 1, 0.2)**2)) +ax.yaxis.set_major_locator(FixedLocator(np.arange(0, 1, 0.2))) + + +# Function Mercator transform +def forward(a): + a = np.deg2rad(a) + return np.rad2deg(np.log(np.abs(np.tan(a) + 1.0 / np.cos(a)))) + + +def inverse(a): + a = np.deg2rad(a) + return np.rad2deg(np.arctan(np.sinh(a))) + +ax = axs[2, 1] + +t = np.arange(-170.0, 170.0, 0.1) +s = t / 2. + +ax.plot(t, s, '-', lw=2) + +ax.set_yscale('function', functions=(forward, inverse)) +ax.set_title('function: Mercator') +ax.grid(True) +ax.set_xlim([-180, 180]) +ax.yaxis.set_minor_formatter(NullFormatter()) +ax.yaxis.set_major_locator(FixedLocator(np.arange(-90, 90, 30))) + plt.show() + +############################################################################# +# +# ------------ +# +# References +# """""""""" +# +# The use of the following functions, methods, classes and modules is shown +# in this example: + +import matplotlib +matplotlib.axes.Axes.set_yscale +matplotlib.axes.Axes.set_xscale +matplotlib.axis.Axis.set_major_locator +matplotlib.scale.LogitScale +matplotlib.scale.LogScale +matplotlib.scale.LinearScale +matplotlib.scale.SymmetricalLogScale +matplotlib.scale.FuncScale diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index c9f0fea1d791..9a6bef33d13d 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -94,6 +94,96 @@ def get_transform(self): return IdentityTransform() +class FuncTransform(Transform): + """ + A simple transform that takes and arbitrary function for the + forward and inverse transform. + """ + + input_dims = 1 + output_dims = 1 + is_separable = True + has_inverse = True + + def __init__(self, forward, inverse): + """ + Parameters + ---------- + + forward : callable + The forward function for the transform. This function must have + an inverse and, for best behavior, be monotonic. + It must have the signature:: + + def forward(values: array-like) -> array-like + + inverse : callable + The inverse of the forward function. Signature as ``forward``. + """ + super().__init__() + if callable(forward) and callable(inverse): + self._forward = forward + self._inverse = inverse + else: + raise ValueError('arguments to FuncTransform must ' + 'be functions') + + def transform_non_affine(self, values): + return self._forward(values) + + def inverted(self): + return FuncTransform(self._inverse, self._forward) + + +class FuncScale(ScaleBase): + """ + Provide an arbitrary scale with user-supplied function for the axis. + """ + + name = 'function' + + def __init__(self, axis, functions): + """ + Parameters + ---------- + + axis: the axis for the scale + + functions : (callable, callable) + two-tuple of the forward and inverse functions for the scale. + The forward function must have an inverse and, for best behavior, + be monotonic. + + Both functions must have the signature:: + + def forward(values: array-like) -> array-like + """ + forward, inverse = functions + transform = FuncTransform(forward, inverse) + self._transform = transform + + def get_transform(self): + """ + The transform for arbitrary scaling + """ + return self._transform + + def set_default_locators_and_formatters(self, axis): + """ + Set the locators and formatters to the same defaults as the + linear scale. + """ + axis.set_major_locator(AutoLocator()) + axis.set_major_formatter(ScalarFormatter()) + axis.set_minor_formatter(NullFormatter()) + # update the minor locator for x and y axis based on rcParams + if (axis.axis_name == 'x' and rcParams['xtick.minor.visible'] + or axis.axis_name == 'y' and rcParams['ytick.minor.visible']): + axis.set_minor_locator(AutoMinorLocator()) + else: + axis.set_minor_locator(NullLocator()) + + class LogTransformBase(Transform): input_dims = 1 output_dims = 1 @@ -557,6 +647,7 @@ def limit_range_for_scale(self, vmin, vmax, minpos): 'log': LogScale, 'symlog': SymmetricalLogScale, 'logit': LogitScale, + 'function': FuncScale, } diff --git a/lib/matplotlib/tests/baseline_images/test_scale/function_scales.png b/lib/matplotlib/tests/baseline_images/test_scale/function_scales.png new file mode 100644 index 000000000000..8789aea213fd Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_scale/function_scales.png differ diff --git a/lib/matplotlib/tests/test_scale.py b/lib/matplotlib/tests/test_scale.py index ebe5c4de9ed7..26822a7adc69 100644 --- a/lib/matplotlib/tests/test_scale.py +++ b/lib/matplotlib/tests/test_scale.py @@ -1,6 +1,7 @@ from matplotlib.testing.decorators import image_comparison import matplotlib.pyplot as plt from matplotlib.scale import Log10Transform, InvertedLog10Transform + import numpy as np import io import platform @@ -148,3 +149,21 @@ def test_invalid_log_lims(): with pytest.warns(UserWarning): ax.set_ylim(top=-1) assert ax.get_ylim() == original_ylim + + +@image_comparison(baseline_images=['function_scales'], remove_text=True, + extensions=['png'], style='mpl20') +def test_function_scale(): + def inverse(x): + return x**2 + + def forward(x): + return x**(1/2) + + fig, ax = plt.subplots() + + x = np.arange(1, 1000) + + ax.plot(x, x) + ax.set_xscale('function', functions=(forward, inverse)) + ax.set_xlim(1, 1000)