diff --git a/doc/api/colors_api.rst b/doc/api/colors_api.rst index e7b6da70f641..44f8cca303fd 100644 --- a/doc/api/colors_api.rst +++ b/doc/api/colors_api.rst @@ -21,6 +21,7 @@ Classes :toctree: _as_gen/ :template: autosummary.rst + AsinhNorm BoundaryNorm Colormap CenteredNorm diff --git a/doc/users/next_whats_new/asinh_scale.rst b/doc/users/next_whats_new/asinh_scale.rst new file mode 100644 index 000000000000..69238ebe220a --- /dev/null +++ b/doc/users/next_whats_new/asinh_scale.rst @@ -0,0 +1,32 @@ +New axis scale ``asinh`` (experimental) +--------------------------------------- + +The new ``asinh`` axis scale offers an alternative to ``symlog`` that +smoothly transitions between the quasi-linear and asymptotically logarithmic +regions of the scale. This is based on an arcsinh transformation that +allows plotting both positive and negative values that span many orders +of magnitude. + +.. plot:: + + import matplotlib.pyplot as plt + import numpy as np + + fig, (ax0, ax1) = plt.subplots(1, 2, sharex=True) + x = np.linspace(-3, 6, 100) + + ax0.plot(x, x) + ax0.set_yscale('symlog') + ax0.grid() + ax0.set_title('symlog') + + ax1.plot(x, x) + ax1.set_yscale('asinh') + ax1.grid() + ax1.set_title(r'$sinh^{-1}$') + + for p in (-2, 2): + for ax in (ax0, ax1): + c = plt.Circle((p, p), radius=0.5, fill=False, + color='red', alpha=0.8, lw=3) + ax.add_patch(c) diff --git a/examples/images_contours_and_fields/colormap_normalizations_symlognorm.py b/examples/images_contours_and_fields/colormap_normalizations_symlognorm.py index 7856068fd1d8..42a3878143d3 100644 --- a/examples/images_contours_and_fields/colormap_normalizations_symlognorm.py +++ b/examples/images_contours_and_fields/colormap_normalizations_symlognorm.py @@ -1,6 +1,6 @@ """ ================================== -Colormap Normalizations Symlognorm +Colormap Normalizations SymLogNorm ================================== Demonstration of using norm to map colormaps onto data in non-linear ways. @@ -8,35 +8,77 @@ .. redirect-from:: /gallery/userdemo/colormap_normalization_symlognorm """ +############################################################################### +# Synthetic dataset consisting of two humps, one negative and one positive, +# the positive with 8-times the amplitude. +# Linearly, the negative hump is almost invisible, +# and it is very difficult to see any detail of its profile. +# With the logarithmic scaling applied to both positive and negative values, +# it is much easier to see the shape of each hump. +# +# See `~.colors.SymLogNorm`. + import numpy as np import matplotlib.pyplot as plt import matplotlib.colors as colors -""" -SymLogNorm: two humps, one negative and one positive, The positive -with 5-times the amplitude. Linearly, you cannot see detail in the -negative hump. Here we logarithmically scale the positive and -negative data separately. -Note that colorbar labels do not come out looking very good. -""" +def rbf(x, y): + return 1.0 / (1 + 5 * ((x ** 2) + (y ** 2))) -N = 100 +N = 200 +gain = 8 X, Y = np.mgrid[-3:3:complex(0, N), -2:2:complex(0, N)] -Z1 = np.exp(-X**2 - Y**2) -Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) -Z = (Z1 - Z2) * 2 +Z1 = rbf(X + 0.5, Y + 0.5) +Z2 = rbf(X - 0.5, Y - 0.5) +Z = gain * Z1 - Z2 + +shadeopts = {'cmap': 'PRGn', 'shading': 'gouraud'} +colormap = 'PRGn' +lnrwidth = 0.5 -fig, ax = plt.subplots(2, 1) +fig, ax = plt.subplots(2, 1, sharex=True, sharey=True) pcm = ax[0].pcolormesh(X, Y, Z, - norm=colors.SymLogNorm(linthresh=0.03, linscale=0.03, - vmin=-1.0, vmax=1.0, base=10), - cmap='RdBu_r', shading='nearest') + norm=colors.SymLogNorm(linthresh=lnrwidth, linscale=1, + vmin=-gain, vmax=gain, base=10), + **shadeopts) fig.colorbar(pcm, ax=ax[0], extend='both') +ax[0].text(-2.5, 1.5, 'symlog') -pcm = ax[1].pcolormesh(X, Y, Z, cmap='RdBu_r', vmin=-np.max(Z), - shading='nearest') +pcm = ax[1].pcolormesh(X, Y, Z, vmin=-gain, vmax=gain, + **shadeopts) fig.colorbar(pcm, ax=ax[1], extend='both') +ax[1].text(-2.5, 1.5, 'linear') + + +############################################################################### +# In order to find the best visualization for any particular dataset, +# it may be necessary to experiment with multiple different color scales. +# As well as the `~.colors.SymLogNorm` scaling, there is also +# the option of using `~.colors.AsinhNorm` (experimental), which has a smoother +# transition between the linear and logarithmic regions of the transformation +# applied to the data values, "Z". +# In the plots below, it may be possible to see contour-like artifacts +# around each hump despite there being no sharp features +# in the dataset itself. The ``asinh`` scaling shows a smoother shading +# of each hump. + +fig, ax = plt.subplots(2, 1, sharex=True, sharey=True) + +pcm = ax[0].pcolormesh(X, Y, Z, + norm=colors.SymLogNorm(linthresh=lnrwidth, linscale=1, + vmin=-gain, vmax=gain, base=10), + **shadeopts) +fig.colorbar(pcm, ax=ax[0], extend='both') +ax[0].text(-2.5, 1.5, 'symlog') + +pcm = ax[1].pcolormesh(X, Y, Z, + norm=colors.AsinhNorm(linear_width=lnrwidth, + vmin=-gain, vmax=gain), + **shadeopts) +fig.colorbar(pcm, ax=ax[1], extend='both') +ax[1].text(-2.5, 1.5, 'asinh') + plt.show() diff --git a/examples/scales/asinh_demo.py b/examples/scales/asinh_demo.py new file mode 100644 index 000000000000..f1ee691a89e7 --- /dev/null +++ b/examples/scales/asinh_demo.py @@ -0,0 +1,109 @@ +""" +============ +Asinh Demo +============ + +Illustration of the `asinh <.scale.AsinhScale>` axis scaling, +which uses the transformation + +.. math:: + + a \\rightarrow a_0 \\sinh^{-1} (a / a_0) + +For coordinate values close to zero (i.e. much smaller than +the "linear width" :math:`a_0`), this leaves values essentially unchanged: + +.. math:: + + a \\rightarrow a + {\\cal O}(a^3) + +but for larger values (i.e. :math:`|a| \\gg a_0`, this is asymptotically + +.. math:: + + a \\rightarrow a_0 \\, {\\rm sgn}(a) \\ln |a| + {\\cal O}(1) + +As with the `symlog <.scale.SymmetricalLogScale>` scaling, +this allows one to plot quantities +that cover a very wide dynamic range that includes both positive +and negative values. However, ``symlog`` involves a transformation +that has discontinuities in its gradient because it is built +from *separate* linear and logarithmic transformations. +The ``asinh`` scaling uses a transformation that is smooth +for all (finite) values, which is both mathematically cleaner +and reduces visual artifacts associated with an abrupt +transition between linear and logarithmic regions of the plot. + +.. note:: + `.scale.AsinhScale` is experimental, and the API may change. + +See `~.scale.AsinhScale`, `~.scale.SymmetricalLogScale`. +""" + +import numpy as np +import matplotlib.pyplot as plt + +# Prepare sample values for variations on y=x graph: +x = np.linspace(-3, 6, 500) + +############################################################################### +# Compare "symlog" and "asinh" behaviour on sample y=x graph, +# where there is a discontinuous gradient in "symlog" near y=2: +fig1 = plt.figure() +ax0, ax1 = fig1.subplots(1, 2, sharex=True) + +ax0.plot(x, x) +ax0.set_yscale('symlog') +ax0.grid() +ax0.set_title('symlog') + +ax1.plot(x, x) +ax1.set_yscale('asinh') +ax1.grid() +ax1.set_title('asinh') + + +############################################################################### +# Compare "asinh" graphs with different scale parameter "linear_width": +fig2 = plt.figure(constrained_layout=True) +axs = fig2.subplots(1, 3, sharex=True) +for ax, (a0, base) in zip(axs, ((0.2, 2), (1.0, 0), (5.0, 10))): + ax.set_title('linear_width={:.3g}'.format(a0)) + ax.plot(x, x, label='y=x') + ax.plot(x, 10*x, label='y=10x') + ax.plot(x, 100*x, label='y=100x') + ax.set_yscale('asinh', linear_width=a0, base=base) + ax.grid() + ax.legend(loc='best', fontsize='small') + + +############################################################################### +# Compare "symlog" and "asinh" scalings +# on 2D Cauchy-distributed random numbers, +# where one may be able to see more subtle artifacts near y=2 +# due to the gradient-discontinuity in "symlog": +fig3 = plt.figure() +ax = fig3.subplots(1, 1) +r = 3 * np.tan(np.random.uniform(-np.pi / 2.02, np.pi / 2.02, + size=(5000,))) +th = np.random.uniform(0, 2*np.pi, size=r.shape) + +ax.scatter(r * np.cos(th), r * np.sin(th), s=4, alpha=0.5) +ax.set_xscale('asinh') +ax.set_yscale('symlog') +ax.set_xlabel('asinh') +ax.set_ylabel('symlog') +ax.set_title('2D Cauchy random deviates') +ax.set_xlim(-50, 50) +ax.set_ylim(-50, 50) +ax.grid() + +plt.show() + +############################################################################### +# +# .. admonition:: References +# +# - `matplotlib.scale.AsinhScale` +# - `matplotlib.ticker.AsinhLocator` +# - `matplotlib.scale.SymmetricalLogScale` diff --git a/examples/scales/symlog_demo.py b/examples/scales/symlog_demo.py index e1c433b22b88..e9cdfff5355e 100644 --- a/examples/scales/symlog_demo.py +++ b/examples/scales/symlog_demo.py @@ -33,3 +33,17 @@ fig.tight_layout() plt.show() + +############################################################################### +# It should be noted that the coordinate transform used by ``symlog`` +# has a discontinuous gradient at the transition between its linear +# and logarithmic regions. The ``asinh`` axis scale is an alternative +# technique that may avoid visual artifacts caused by these disconinuities. + +############################################################################### +# +# .. admonition:: References +# +# - `matplotlib.scale.SymmetricalLogScale` +# - `matplotlib.ticker.SymmetricalLogLocator` +# - `matplotlib.scale.AsinhScale` diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index f3b4f86765e7..e5790a5694f7 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1680,6 +1680,38 @@ def linthresh(self, value): self._scale.linthresh = value +@make_norm_from_scale( + scale.AsinhScale, + init=lambda linear_width=1, vmin=None, vmax=None, clip=False: None) +class AsinhNorm(Normalize): + """ + The inverse hyperbolic sine scale is approximately linear near + the origin, but becomes logarithmic for larger positive + or negative values. Unlike the `SymLogNorm`, the transition between + these linear and logarithmic regions is smooth, which may reduce + the risk of visual artifacts. + + .. note:: + + This API is provisional and may be revised in the future + based on early user feedback. + + Parameters + ---------- + linear_width : float, default: 1 + The effective width of the linear region, beyond which + the transformation becomes asymptotically logarithmic + """ + + @property + def linear_width(self): + return self._scale.linear_width + + @linear_width.setter + def linear_width(self, value): + self._scale.linear_width = value + + class PowerNorm(Normalize): """ Linearly map a given value to the 0-1 range and then apply diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 3ee9b5d0a2fd..51fe384f993e 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -22,7 +22,7 @@ from matplotlib.ticker import ( NullFormatter, ScalarFormatter, LogFormatterSciNotation, LogitFormatter, NullLocator, LogLocator, AutoLocator, AutoMinorLocator, - SymmetricalLogLocator, LogitLocator) + SymmetricalLogLocator, AsinhLocator, LogitLocator) from matplotlib.transforms import Transform, IdentityTransform @@ -457,6 +457,123 @@ def get_transform(self): return self._transform +class AsinhTransform(Transform): + """Inverse hyperbolic-sine transformation used by `.AsinhScale`""" + input_dims = output_dims = 1 + + def __init__(self, linear_width): + super().__init__() + if linear_width <= 0.0: + raise ValueError("Scale parameter 'linear_width' " + + "must be strictly positive") + self.linear_width = linear_width + + def transform_non_affine(self, a): + return self.linear_width * np.arcsinh(a / self.linear_width) + + def inverted(self): + return InvertedAsinhTransform(self.linear_width) + + +class InvertedAsinhTransform(Transform): + """Hyperbolic sine transformation used by `.AsinhScale`""" + input_dims = output_dims = 1 + + def __init__(self, linear_width): + super().__init__() + self.linear_width = linear_width + + def transform_non_affine(self, a): + return self.linear_width * np.sinh(a / self.linear_width) + + def inverted(self): + return AsinhTransform(self.linear_width) + + +class AsinhScale(ScaleBase): + """ + A quasi-logarithmic scale based on the inverse hyperbolic sine (asinh) + + For values close to zero, this is essentially a linear scale, + but for large magnitude values (either positive or negative) + it is asymptotically logarithmic. The transition between these + linear and logarithmic regimes is smooth, and has no discontinuities + in the function gradient in contrast to + the `.SymmetricalLogScale` ("symlog") scale. + + Specifically, the transformation of an axis coordinate :math:`a` is + :math:`a \\rightarrow a_0 \\sinh^{-1} (a / a_0)` where :math:`a_0` + is the effective width of the linear region of the transformation. + In that region, the transformation is + :math:`a \\rightarrow a + {\\cal O}(a^3)`. + For large values of :math:`a` the transformation behaves as + :math:`a \\rightarrow a_0 \\, {\\rm sgn}(a) \\ln |a| + {\\cal O}(1)`. + + .. note:: + + This API is provisional and may be revised in the future + based on early user feedback. + """ + + name = 'asinh' + + auto_tick_multipliers = { + 3: (2, ), + 4: (2, ), + 5: (2, ), + 8: (2, 4), + 10: (2, 5), + 16: (2, 4, 8), + 64: (4, 16), + 1024: (256, 512) + } + + def __init__(self, axis, *, linear_width=1.0, + base=10, subs='auto', **kwargs): + """ + Parameters + ---------- + linear_width : float, default: 1 + The scale parameter (elsewhere referred to as :math:`a_0`) + defining the extent of the quasi-linear region, + and the coordinate values beyond which the transformation + becomes asympotically logarithmic. + base : int, default: 10 + The number base used for rounding tick locations + on a logarithmic scale. If this is less than one, + then rounding is to the nearest integer multiple + of powers of ten. + subs : sequence of int + Multiples of the number base used for minor ticks. + If set to 'auto', this will use built-in defaults, + e.g. (2, 5) for base=10. + """ + super().__init__(axis) + self._transform = AsinhTransform(linear_width) + self._base = int(base) + if subs == 'auto': + self._subs = self.auto_tick_multipliers.get(self._base) + else: + self._subs = subs + + linear_width = property(lambda self: self._transform.linear_width) + + def get_transform(self): + return self._transform + + def set_default_locators_and_formatters(self, axis): + axis.set(major_locator=AsinhLocator(self.linear_width, + base=self._base), + minor_locator=AsinhLocator(self.linear_width, + base=self._base, + subs=self._subs), + minor_formatter=NullFormatter()) + if self._base > 1: + axis.set_major_formatter(LogFormatterSciNotation(self._base)) + else: + axis.set_major_formatter('{x:.3g}'), + + class LogitTransform(Transform): input_dims = output_dims = 1 @@ -567,6 +684,7 @@ def limit_range_for_scale(self, vmin, vmax, minpos): 'linear': LinearScale, 'log': LogScale, 'symlog': SymmetricalLogScale, + 'asinh': AsinhScale, 'logit': LogitScale, 'function': FuncScale, 'functionlog': FuncScaleLog, diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index bedff6341af1..88f01b2ff9ce 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -745,6 +745,28 @@ def test_SymLogNorm_single_zero(): plt.close(fig) +class TestAsinhNorm: + """ + Tests for `~.colors.AsinhNorm` + """ + + def test_init(self): + norm0 = mcolors.AsinhNorm() + assert norm0.linear_width == 1 + + norm5 = mcolors.AsinhNorm(linear_width=5) + assert norm5.linear_width == 5 + + def test_norm(self): + norm = mcolors.AsinhNorm(2, vmin=-4, vmax=4) + vals = np.arange(-3.5, 3.5, 10) + normed_vals = norm(vals) + asinh2 = np.arcsinh(2) + + expected = (2 * np.arcsinh(vals / 2) + 2 * asinh2) / (4 * asinh2) + assert_array_almost_equal(normed_vals, expected) + + def _inverse_tester(norm_instance, vals): """ Checks if the inverse of the given normalization is working. diff --git a/lib/matplotlib/tests/test_scale.py b/lib/matplotlib/tests/test_scale.py index 8fba86d2e82e..7f1130560581 100644 --- a/lib/matplotlib/tests/test_scale.py +++ b/lib/matplotlib/tests/test_scale.py @@ -2,9 +2,11 @@ import matplotlib.pyplot as plt from matplotlib.scale import ( + AsinhScale, AsinhTransform, LogTransform, InvertedLogTransform, SymmetricalLogTransform) import matplotlib.scale as mscale +from matplotlib.ticker import AsinhLocator, LogFormatterSciNotation from matplotlib.testing.decorators import check_figures_equal, image_comparison import numpy as np @@ -219,3 +221,75 @@ def test_scale_deepcopy(): sc2 = copy.deepcopy(sc) assert str(sc.get_transform()) == str(sc2.get_transform()) assert sc._transform is not sc2._transform + + +class TestAsinhScale: + def test_transforms(self): + a0 = 17.0 + a = np.linspace(-50, 50, 100) + + forward = AsinhTransform(a0) + inverse = forward.inverted() + invinv = inverse.inverted() + + a_forward = forward.transform_non_affine(a) + a_inverted = inverse.transform_non_affine(a_forward) + assert_allclose(a_inverted, a) + + a_invinv = invinv.transform_non_affine(a) + assert_allclose(a_invinv, a0 * np.arcsinh(a / a0)) + + def test_init(self): + fig, ax = plt.subplots() + + s = AsinhScale(axis=None, linear_width=23.0) + assert s.linear_width == 23 + assert s._base == 10 + assert s._subs == (2, 5) + + tx = s.get_transform() + assert isinstance(tx, AsinhTransform) + assert tx.linear_width == s.linear_width + + def test_base_init(self): + fig, ax = plt.subplots() + + s3 = AsinhScale(axis=None, base=3) + assert s3._base == 3 + assert s3._subs == (2,) + + s7 = AsinhScale(axis=None, base=7, subs=(2, 4)) + assert s7._base == 7 + assert s7._subs == (2, 4) + + def test_fmtloc(self): + class DummyAxis: + def __init__(self): + self.fields = {} + def set(self, **kwargs): + self.fields.update(**kwargs) + def set_major_formatter(self, f): + self.fields['major_formatter'] = f + + ax0 = DummyAxis() + s0 = AsinhScale(axis=ax0, base=0) + s0.set_default_locators_and_formatters(ax0) + assert isinstance(ax0.fields['major_locator'], AsinhLocator) + assert isinstance(ax0.fields['major_formatter'], str) + + ax5 = DummyAxis() + s7 = AsinhScale(axis=ax5, base=5) + s7.set_default_locators_and_formatters(ax5) + assert isinstance(ax5.fields['major_locator'], AsinhLocator) + assert isinstance(ax5.fields['major_formatter'], + LogFormatterSciNotation) + + def test_bad_scale(self): + fig, ax = plt.subplots() + + with pytest.raises(ValueError): + AsinhScale(axis=None, linear_width=0) + with pytest.raises(ValueError): + AsinhScale(axis=None, linear_width=-1) + s0 = AsinhScale(axis=None, ) + s1 = AsinhScale(axis=None, linear_width=3.0) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 5c551a296c3e..2486efe3e8f7 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -443,6 +443,112 @@ def test_set_params(self): assert sym.numticks == 8 +class TestAsinhLocator: + def test_init(self): + lctr = mticker.AsinhLocator(linear_width=2.718, numticks=19) + assert lctr.linear_width == 2.718 + assert lctr.numticks == 19 + assert lctr.base == 10 + + def test_set_params(self): + lctr = mticker.AsinhLocator(linear_width=5, + numticks=17, symthresh=0.125, + base=4, subs=(2.5, 3.25)) + assert lctr.numticks == 17 + assert lctr.symthresh == 0.125 + assert lctr.base == 4 + assert lctr.subs == (2.5, 3.25) + + lctr.set_params(numticks=23) + assert lctr.numticks == 23 + lctr.set_params(None) + assert lctr.numticks == 23 + + lctr.set_params(symthresh=0.5) + assert lctr.symthresh == 0.5 + lctr.set_params(symthresh=None) + assert lctr.symthresh == 0.5 + + lctr.set_params(base=7) + assert lctr.base == 7 + lctr.set_params(base=None) + assert lctr.base == 7 + + lctr.set_params(subs=(2, 4.125)) + assert lctr.subs == (2, 4.125) + lctr.set_params(subs=None) + assert lctr.subs == (2, 4.125) + lctr.set_params(subs=[]) + assert lctr.subs is None + + def test_linear_values(self): + lctr = mticker.AsinhLocator(linear_width=100, numticks=11, base=0) + + assert_almost_equal(lctr.tick_values(-1, 1), + np.arange(-1, 1.01, 0.2)) + assert_almost_equal(lctr.tick_values(-0.1, 0.1), + np.arange(-0.1, 0.101, 0.02)) + assert_almost_equal(lctr.tick_values(-0.01, 0.01), + np.arange(-0.01, 0.0101, 0.002)) + + def test_wide_values(self): + lctr = mticker.AsinhLocator(linear_width=0.1, numticks=11, base=0) + + assert_almost_equal(lctr.tick_values(-100, 100), + [-100, -20, -5, -1, -0.2, + 0, 0.2, 1, 5, 20, 100]) + assert_almost_equal(lctr.tick_values(-1000, 1000), + [-1000, -100, -20, -3, -0.4, + 0, 0.4, 3, 20, 100, 1000]) + + def test_near_zero(self): + """Check that manually injected zero will supersede nearby tick""" + lctr = mticker.AsinhLocator(linear_width=100, numticks=3, base=0) + + assert_almost_equal(lctr.tick_values(-1.1, 0.9), [-1.0, 0.0, 0.9]) + + def test_fallback(self): + lctr = mticker.AsinhLocator(1.0, numticks=11) + + assert_almost_equal(lctr.tick_values(100, 101), + np.arange(100, 101.01, 0.1)) + + def test_symmetrizing(self): + class DummyAxis: + bounds = (-1, 1) + @classmethod + def get_view_interval(cls): return cls.bounds + + lctr = mticker.AsinhLocator(linear_width=1, numticks=3, + symthresh=0.25, base=0) + lctr.axis = DummyAxis + + DummyAxis.bounds = (-1, 2) + assert_almost_equal(lctr(), [-1, 0, 2]) + + DummyAxis.bounds = (-1, 0.9) + assert_almost_equal(lctr(), [-1, 0, 1]) + + DummyAxis.bounds = (-0.85, 1.05) + assert_almost_equal(lctr(), [-1, 0, 1]) + + DummyAxis.bounds = (1, 1.1) + assert_almost_equal(lctr(), [1, 1.05, 1.1]) + + def test_base_rounding(self): + lctr10 = mticker.AsinhLocator(linear_width=1, numticks=8, + base=10, subs=(1, 3, 5)) + assert_almost_equal(lctr10.tick_values(-110, 110), + [-500, -300, -100, -50, -30, -10, -5, -3, -1, + -0.5, -0.3, -0.1, 0, 0.1, 0.3, 0.5, + 1, 3, 5, 10, 30, 50, 100, 300, 500]) + + lctr5 = mticker.AsinhLocator(linear_width=1, numticks=20, base=5) + assert_almost_equal(lctr5.tick_values(-1050, 1050), + [-625, -125, -25, -5, -1, -0.2, 0, + 0.2, 1, 5, 25, 125, 625]) + + class TestScalarFormatter: offset_data = [ (123, 189, 0), diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 6482f127c434..ff2e4153cbf5 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -36,6 +36,8 @@ `SymmetricalLogLocator` Locator for use with with the symlog norm; works like `LogLocator` for the part outside of the threshold and adds 0 if inside the limits. +`AsinhLocator` Locator for use with the asinh norm, attempting to + space ticks approximately uniformly. `LogitLocator` Locator for logit scaling. `AutoMinorLocator` Locator for minor ticks when the axis is linear and the major ticks are uniformly spaced. Subdivides the major @@ -146,7 +148,7 @@ 'Locator', 'IndexLocator', 'FixedLocator', 'NullLocator', 'LinearLocator', 'LogLocator', 'AutoLocator', 'MultipleLocator', 'MaxNLocator', 'AutoMinorLocator', - 'SymmetricalLogLocator', 'LogitLocator') + 'SymmetricalLogLocator', 'AsinhLocator', 'LogitLocator') class _DummyAxis: @@ -2605,6 +2607,118 @@ def view_limits(self, vmin, vmax): return result +class AsinhLocator(Locator): + """ + An axis tick locator specialized for the inverse-sinh scale + + This is very unlikely to have any use beyond + the `~.scale.AsinhScale` class. + + .. note:: + + This API is provisional and may be revised in the future + based on early user feedback. + """ + def __init__(self, linear_width, numticks=11, symthresh=0.2, + base=10, subs=None): + """ + Parameters + ---------- + linear_width : float + The scale parameter defining the extent + of the quasi-linear region. + numticks : int, default: 11 + The approximate number of major ticks that will fit + along the entire axis + symthresh : float, default: 0.2 + The fractional threshold beneath which data which covers + a range that is approximately symmetric about zero + will have ticks that are exactly symmetric. + base : int, default: 10 + The number base used for rounding tick locations + on a logarithmic scale. If this is less than one, + then rounding is to the nearest integer multiple + of powers of ten. + subs : tuple, default: None + Multiples of the number base, typically used + for the minor ticks, e.g. (2, 5) when base=10. + """ + super().__init__() + self.linear_width = linear_width + self.numticks = numticks + self.symthresh = symthresh + self.base = base + self.subs = subs + + def set_params(self, numticks=None, symthresh=None, + base=None, subs=None): + """Set parameters within this locator.""" + if numticks is not None: + self.numticks = numticks + if symthresh is not None: + self.symthresh = symthresh + if base is not None: + self.base = base + if subs is not None: + self.subs = subs if len(subs) > 0 else None + + def __call__(self): + vmin, vmax = self.axis.get_view_interval() + if (vmin * vmax) < 0 and abs(1 + vmax / vmin) < self.symthresh: + # Data-range appears to be almost symmetric, so round up: + bound = max(abs(vmin), abs(vmax)) + return self.tick_values(-bound, bound) + else: + return self.tick_values(vmin, vmax) + + def tick_values(self, vmin, vmax): + # Construct a set of "on-screen" locations + # that are uniformly spaced: + ymin, ymax = self.linear_width * np.arcsinh(np.array([vmin, vmax]) + / self.linear_width) + ys = np.linspace(ymin, ymax, self.numticks) + zero_dev = np.abs(ys / (ymax - ymin)) + if (ymin * ymax) < 0: + # Ensure that the zero tick-mark is included, + # if the axis straddles zero + ys = np.hstack([ys[(zero_dev > 0.5 / self.numticks)], 0.0]) + + # Transform the "on-screen" grid to the data space: + xs = self.linear_width * np.sinh(ys / self.linear_width) + zero_xs = (ys == 0) + + # Round the data-space values to be intuitive base-n numbers, + # keeping track of positive and negative values separately, + # but giving careful treatment to the zero value: + if self.base > 1: + log_base = math.log(self.base) + powers = ( + np.where(zero_xs, 0, np.sign(xs)) * + np.power(self.base, + np.where(zero_xs, 0.0, + np.floor(np.log(np.abs(xs) + zero_xs*1e-6) + / log_base))) + ) + if self.subs: + qs = np.outer(powers, self.subs).flatten() + else: + qs = powers + else: + powers = ( + np.where(xs >= 0, 1, -1) * + np.power(10, np.where(zero_xs, 0.0, + np.floor(np.log10(np.abs(xs) + + zero_xs*1e-6)))) + ) + qs = powers * np.round(xs / powers) + ticks = np.array(sorted(set(qs))) + + if len(ticks) >= 2: + return ticks + else: + return np.linspace(vmin, vmax, self.numticks) + + class LogitLocator(MaxNLocator): """ Determine the tick locations for logit axes