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
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: