diff --git a/doc/api/next_api_changes/behavior/32206-MMF.rst b/doc/api/next_api_changes/behavior/32206-MMF.rst new file mode 100644 index 000000000000..4c9479e45d83 --- /dev/null +++ b/doc/api/next_api_changes/behavior/32206-MMF.rst @@ -0,0 +1,9 @@ +Contour levels may be decreasing +-------------------------------- + +`~.axes.Axes.contour`, `~.axes.Axes.contourf` and the corresponding +`~.axes.Axes.tricontour`/`~.axes.Axes.tricontourf` functions now accept contour +levels given in monotonically decreasing order; the levels (and any per-level +colors, linewidths, linestyles or hatches supplied with them) are reversed +internally instead of raising a ``ValueError``. Levels that are not monotonic +still raise. diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index 26a25c00dc09..23c2e0994ea5 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -692,6 +692,7 @@ def __init__(self, ax, *args, self.extent = extent self.colors = colors self.extend = extend + self._levels_reversed = False self.nchunk = nchunk self.locator = locator @@ -731,6 +732,31 @@ def __init__(self, ax, *args, self._extend_min = self.extend in ['min', 'both'] self._extend_max = self.extend in ['max', 'both'] + if self._levels_reversed: + # The user supplied the levels in decreasing order, so they were + # reversed internally to increasing order. Reverse any per-level + # styling given by the user so that it stays associated with the + # same levels. + if self.colors is not None: + color_sequence = ( + list(self.colors) + if not mcolors.is_color_like(self.colors) + else [self.colors]) + ncolors = len(self.levels) - int(self.filled) + total_levels = ncolors + int(self._extend_min) + int(self._extend_max) + if len(color_sequence) == total_levels: + # Keep the extended (under/over) colors at the ends. + i0 = int(self._extend_min) + i1 = len(color_sequence) - int(self._extend_max) + color_sequence = (color_sequence[:i0] + + color_sequence[i0:i1][::-1] + + color_sequence[i1:]) + else: + color_sequence = color_sequence[::-1] + self.colors = color_sequence + if (self.filled and self.hatches is not None + and not all(h is None for h in self.hatches)): + self.hatches = list(self.hatches)[::-1] if self.colors is not None: if mcolors.is_color_like(self.colors): color_sequence = [self.colors] @@ -1054,8 +1080,15 @@ def _process_contour_level_args(self, args, z_dtype): if self.filled and len(self.levels) < 2: raise ValueError("Filled contours require at least 2 levels.") - if len(self.levels) > 1 and np.min(np.diff(self.levels)) <= 0.0: - raise ValueError("Contour levels must be increasing") + if len(self.levels) > 1: + diffs = np.diff(self.levels) + if not (np.all(diffs > 0) or np.all(diffs < 0)): + raise ValueError("Contour levels must be increasing") + if np.all(diffs < 0): + # The levels were given in decreasing order; reverse them so + # that the rest of the code can assume increasing levels. + self.levels = self.levels[::-1] + self._levels_reversed = True def _process_levels(self): """ @@ -1149,7 +1182,10 @@ def _process_linewidths(self, linewidths): return [linewidths] * Nlev else: linewidths = list(linewidths) - return (linewidths * math.ceil(Nlev / len(linewidths)))[:Nlev] + linewidths = (linewidths * math.ceil(Nlev / len(linewidths)))[:Nlev] + # If the levels were reversed, reverse the linewidths as well so + # that each width stays with the level it was given for. + return linewidths[::-1] if self._levels_reversed else linewidths def _process_linestyles(self, linestyles): Nlev = len(self.levels) @@ -1170,6 +1206,10 @@ def _process_linestyles(self, linestyles): tlinestyles = tlinestyles * nreps if len(tlinestyles) > Nlev: tlinestyles = tlinestyles[:Nlev] + # If the levels were reversed, reverse the linestyles as well + # so that each style stays with the level it was given for. + if self._levels_reversed: + tlinestyles = tlinestyles[::-1] else: raise ValueError("Unrecognized type for linestyles kwarg") return tlinestyles @@ -1521,7 +1561,8 @@ def _initialize_x_y(self, z): *n*=7 is the default. If array-like, draw contour lines at the specified levels. - The values must be in increasing order. + The values must be in increasing order, or decreasing order, in which + case they are reversed internally. If not specified, a reasonable default is automatically chosen. For linear scales, this corresponds to *levels=7*. For logarithmic diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py index 8b13caa15e67..87915b61515c 100644 --- a/lib/matplotlib/tests/test_contour.py +++ b/lib/matplotlib/tests/test_contour.py @@ -9,6 +9,7 @@ import matplotlib as mpl from matplotlib import pyplot as plt, rc_context, ticker from matplotlib.colors import LogNorm, same_color +from matplotlib import colors as mcolors import matplotlib.patches as mpatches from matplotlib.testing.decorators import check_figures_equal, image_comparison import pytest @@ -337,11 +338,48 @@ def test_corner_mask(): def test_contourf_decreasing_levels(): - # github issue 5477. + # Non-monotonic levels are rejected (github issue 5477). z = [[0.1, 0.3], [0.5, 0.7]] plt.figure() - with pytest.raises(ValueError): - plt.contourf(z, [1.0, 0.0]) + with pytest.raises(ValueError, match="Contour levels must be increasing"): + plt.contourf(z, [1.0, 0.0, 0.5]) + + +@pytest.mark.parametrize("func", ["contour", "contourf"]) +def test_decreasing_levels(func): + # github issue 31227: monotonically decreasing levels are reversed + # internally instead of raising an error. + z = [[0, 1], [1, 2]] + fig, ax = plt.subplots() + cs = getattr(ax, func)(z, levels=[2, 1, 0]) + assert_array_almost_equal(cs.levels, [0, 1, 2]) + # Same levels given positionally or as an array behave identically. + cs2 = getattr(ax, func)(z, np.array([2, 1, 0])) + assert_array_almost_equal(cs2.levels, cs.levels) + cs3 = getattr(ax, func)(z, [2, 1, 0]) + assert_array_almost_equal(cs3.levels, cs.levels) + + +def test_decreasing_levels_styling(): + # Per-level styling stays associated with the level it was given for + # when the levels are provided in decreasing order. + z = [[0, 1], [1, 2]] + fig, (ax1, ax2) = plt.subplots(1, 2) + cs = ax1.contour(z, [2, 1, 0], colors=['red', 'green', 'blue'], + linewidths=[1, 2, 3], + linestyles=['solid', 'dashed', 'dotted']) + assert_array_almost_equal(cs.levels, [0, 1, 2]) + for edgecolor, color in zip(cs.get_edgecolors(), + [mcolors.to_rgba('blue'), + mcolors.to_rgba('green'), + mcolors.to_rgba('red')]): + assert_array_almost_equal(edgecolor, color) + assert_array_almost_equal(cs.get_linewidths(), [3, 2, 1]) + # The reversed contour is equivalent to the same contour drawn with + # increasing levels and reversed styling. + cs_ref = ax2.contour(z, [0, 1, 2], linestyles=['dotted', 'dashed', + 'solid']) + assert cs.get_linestyles() == cs_ref.get_linestyles() def test_contourf_symmetric_locator(): diff --git a/lib/matplotlib/tri/_tricontour.py b/lib/matplotlib/tri/_tricontour.py index 8250515f3ef8..ac20a937e001 100644 --- a/lib/matplotlib/tri/_tricontour.py +++ b/lib/matplotlib/tri/_tricontour.py @@ -122,7 +122,8 @@ def _contour_args(self, args, kwargs): between minimum and maximum numeric values of *Z*. If array-like, draw contour lines at the specified levels. The values must - be in increasing order. + be in increasing order, or decreasing order, in which case they are reversed + internally. Returns -------