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()