diff --git a/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst b/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst index 0d8933fb363b..58528fe6dd4d 100644 --- a/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst +++ b/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst @@ -2,9 +2,19 @@ Behavior changes ---------------- +Fixed calculations in `.SymLogNorm` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The symmetrical log normalizer was previously returning erroneous values, so has +be re-written to give the correct behaviour. Aside from returning different +values: + +- The base in which the number of decades in the log range is calculated can + now be specified by the ``base`` keyword argument, which defaults to 10. +- The behaviour when passing both ``vmin`` and ``vmax`` which were not negative + of each other is ill-defined, so this has been disallowed. + Reduced default value of :rc:`axes.formatter.limits` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Changed the default value of :rc:`axes.formatter.limits` from -7, 7 to -5, 6 for better readability. diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 9f58a03a9f90..7906d21cda74 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1214,8 +1214,8 @@ class SymLogNorm(Normalize): (-*linthresh*, *linthresh*). """ def __init__(self, linthresh, linscale=1.0, - vmin=None, vmax=None, clip=False): - """ + vmin=None, vmax=None, clip=False, base=10): + r""" Parameters ---------- linthresh : float @@ -1228,12 +1228,34 @@ def __init__(self, linthresh, linscale=1.0, example, when *linscale* == 1.0 (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. + base : float, default: 10 + The base in which the number of decades in the log range is + calculated. For example, if ``base=2``, ``linthresh=2`` and + ``vmax=8``, the number of decades is :math:`\log_{2} (8 / 2) = 2`. + + Notes + ----- + Currently SymLogNorm only works with ``vmin == -vmax``, such that an + input of 0 always maps to half-way in the scale. """ + if vmin is not None and vmax is not None and vmin != -vmax: + raise ValueError('SymLogNorm only works for vmin = -vmax ' + f'(got vmin={vmin}, vmax={vmax})') Normalize.__init__(self, vmin, vmax, clip) self.linthresh = float(linthresh) - self._linscale_adj = (linscale / (1.0 - np.e ** -1)) - if vmin is not None and vmax is not None: - self._transform_vmin_vmax() + self.base = float(base) + self.linscale = float(linscale) + + @property + def _linear_size(self): + 'Return the size of the linear portion in transformed space' + # Number of decades in the logarithmic range + ndec = self._logbase(self.vmax / self.linthresh) + return 1 / (1 + self.linscale / ndec) + + def _logbase(self, val): + 'Take the log of val in the base `self.base`' + return np.log(val) / np.log(self.base) def __call__(self, value, clip=None): if clip is None: @@ -1254,57 +1276,53 @@ def __call__(self, value, clip=None): mask=mask) # in-place equivalent of above can be much faster resdat = self._transform(result.data) - resdat -= self._lower - resdat /= (self._upper - self._lower) if is_scalar: result = result[0] return result def _transform(self, a): - """Inplace transformation.""" + """In-place mapping from *a* to [0, 1]""" with np.errstate(invalid="ignore"): - masked = np.abs(a) > self.linthresh - sign = np.sign(a[masked]) - log = (self._linscale_adj + np.log(np.abs(a[masked]) / self.linthresh)) - log *= sign * self.linthresh - a[masked] = log - a[~masked] *= self._linscale_adj + logregion = np.abs(a) > self.linthresh + + # Transform log value + sign = np.sign(a[logregion]) + log = ((1 - self._linear_size) * self._logbase(np.abs(a[logregion])) + + self._linear_size) + a[logregion] = log * sign + + # Transform linear values + a[~logregion] *= self._linear_size / self.linthresh + + # Transform from [-1, 1] to [0, 1] + a += 1 + a /= 2 return a def _inv_transform(self, a): """Inverse inplace Transformation.""" - masked = np.abs(a) > (self.linthresh * self._linscale_adj) - sign = np.sign(a[masked]) - exp = np.exp(sign * a[masked] / self.linthresh - self._linscale_adj) - exp *= sign * self.linthresh - a[masked] = exp - a[~masked] /= self._linscale_adj + # Transform from [0, 1] to [-1, 1] + a *= 2 + a -= 1 + + # Transform back log values + logregion = np.abs(a) > self._linear_size + sign = np.sign(a[logregion]) + exp = self.base**((np.abs(a[logregion]) - self._linear_size) / + (1 - self._linear_size)) + a[logregion] = exp * sign + + # Transform back linear values + a[~logregion] /= self._linear_size / self.linthresh return a - def _transform_vmin_vmax(self): - """Calculates vmin and vmax in the transformed system.""" - vmin, vmax = self.vmin, self.vmax - arr = np.array([vmax, vmin]).astype(float) - self._upper, self._lower = self._transform(arr) - def inverse(self, value): if not self.scaled(): raise ValueError("Not invertible until scaled") val = np.ma.asarray(value) - val = val * (self._upper - self._lower) + self._lower return self._inv_transform(val) - def autoscale(self, A): - # docstring inherited. - super().autoscale(A) - self._transform_vmin_vmax() - - def autoscale_None(self, A): - # docstring inherited. - super().autoscale_None(A) - self._transform_vmin_vmax() - class PowerNorm(Normalize): """ diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 05a81e26def3..062f1dc9ae38 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -395,22 +395,48 @@ def test_TwoSlopeNorm_premature_scaling(): def test_SymLogNorm(): - """ - Test SymLogNorm behavior - """ - norm = mcolors.SymLogNorm(3, vmax=5, linscale=1.2) + # Test SymLogNorm behavior + norm = mcolors.SymLogNorm(linthresh=3, vmax=5, linscale=1.2, base=np.e) vals = np.array([-30, -1, 2, 6], dtype=float) normed_vals = norm(vals) - expected = [0., 0.53980074, 0.826991, 1.02758204] + expected = [-0.842119, 0.450236, 0.599528, 1.277676] assert_array_almost_equal(normed_vals, expected) _inverse_tester(norm, vals) _scalar_tester(norm, vals) _mask_tester(norm, vals) - # Ensure that specifying vmin returns the same result as above - norm = mcolors.SymLogNorm(3, vmin=-30, vmax=5, linscale=1.2) + +def test_symlognorm_vals(): + vals = [-10, -1, 0, 1, 10] + + norm = mcolors.SymLogNorm(linthresh=1, vmin=-10, vmax=10, linscale=1) normed_vals = norm(vals) - assert_array_almost_equal(normed_vals, expected) + expected = [0, 0.25, 0.5, 0.75, 1] + assert_array_almost_equal(norm(vals), normed_vals) + assert_array_almost_equal(norm.inverse(norm(vals)), vals) + + # If we increase linscale to 2, the space for the linear range [0, 1] + # should be twice as large as the space for the logarithmic range [1, 10] + norm = mcolors.SymLogNorm(linthresh=1, vmin=-10, vmax=10, linscale=2) + normed_vals = norm(vals) + expected = [0, 1/6, 0.5, 5/6, 1] + assert_array_almost_equal(norm(vals), normed_vals) + assert_array_almost_equal(norm.inverse(norm(vals)), vals) + + # Similarly, going the other way means the linear range should shrink + norm = mcolors.SymLogNorm(linthresh=1, vmin=-10, vmax=10, linscale=0.5) + normed_vals = norm(vals) + expected = [0, 2/6, 0.5, 4/6, 1] + assert_array_almost_equal(norm(vals), normed_vals) + assert_array_almost_equal(norm.inverse(norm(vals)), vals) + + # Now check a different base to base 10 + vals = [-8, 4, -2, 0, 2, 4, 8] + norm = mcolors.SymLogNorm(linthresh=2, vmax=8, linscale=1, base=2) + normed_vals = norm(vals) + expected = [0, 1/8, 2/8, 3/8, 0.5, 5/8, 6/8, 7/8, 1] + assert_array_almost_equal(norm(vals), normed_vals) + assert_array_almost_equal(norm.inverse(norm(vals)), vals) def test_SymLogNorm_colorbar(): @@ -907,7 +933,7 @@ def __add__(self, other): for norm in [mcolors.Normalize(), mcolors.LogNorm(), mcolors.SymLogNorm(3, vmax=5, linscale=1), mcolors.Normalize(vmin=mydata.min(), vmax=mydata.max()), - mcolors.SymLogNorm(3, vmin=mydata.min(), vmax=mydata.max()), + mcolors.SymLogNorm(3, vmin=-10, vmax=10), mcolors.PowerNorm(1)]: assert_array_equal(norm(mydata), norm(data)) fig, ax = plt.subplots() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy