From d70e9b9674811dc0c7f5fefd90a82c5295b5eeae Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Mon, 15 Jun 2026 12:44:30 +0530 Subject: [PATCH] FIX: fill contourf minimum region when it falls just below the lowest level (#21382) contourf left the minimum-valued region unfilled when the data minimum fell a floating-point hair below the lowest contour level instead of being exactly equal to it (e.g. a computed -1.7e-13 where 0 was meant). ContourSet._get_lowers_and_uppers only extended the lowest filled interval down to the minimum when self.zmin == lowers[0] exactly, so such near-equal minima, or rounding during automatic level selection, slipped through and the region was clipped. On a linear scale, compare the level against the data minimum with a tolerance scaled to the data range rather than strict equality, the same way Colorbar._add_solids does. The check is one-sided and tolerance- bounded, so genuine gaps such as user-specified levels starting above the data are left untouched, and every case the old equality test caught still extends identically. Log scales span many decades where an additive tolerance is not meaningful, so the exact-equality test is kept there unchanged. Adds check_figures_equal regression tests that a float-noise minimum renders like a clean zero, including at very small and very large data scales, plus guard tests that a user-specified lowest level above the data is not back-filled, even when a large data range inflates the tolerance to order unity. Includes a behaviour-change note under doc/api/next_api_changes/. --- .../next_api_changes/behavior/31903-SN.rst | 10 ++++ lib/matplotlib/contour.py | 25 ++++++++- lib/matplotlib/tests/test_contour.py | 52 +++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/31903-SN.rst diff --git a/doc/api/next_api_changes/behavior/31903-SN.rst b/doc/api/next_api_changes/behavior/31903-SN.rst new file mode 100644 index 000000000000..db47ad900e88 --- /dev/null +++ b/doc/api/next_api_changes/behavior/31903-SN.rst @@ -0,0 +1,10 @@ +``contourf`` now fills the region at the data minimum +----------------------------------------------------- + +`~matplotlib.axes.Axes.contourf` previously left the lowest-valued region +unfilled when the data minimum fell a tiny floating-point step below the +lowest contour level instead of being exactly equal to it (for example, a +computed value of ``-1.7e-13`` that was meant to be ``0``). On a linear scale +the lowest level is now treated as coincident with the data minimum when it +lies within a small tolerance of it, so that region is filled as expected. +Log-scaled contours are unchanged. diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index 26a25c00dc09..59b44668aef7 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -953,8 +953,29 @@ def _get_lowers_and_uppers(self): Return ``(lowers, uppers)`` for filled contours. """ lowers = self._levels[:-1] - if self.zmin == lowers[0]: - # Include minimum values in lowest interval + # Include minimum values in the lowest interval. The lowest level can + # end up a hair above the data minimum rather than exactly equal to it, + # either because the data minimum is itself the result of a computation + # (e.g. a value of -1.7e-13 that should be 0) or because of rounding in + # automatic level selection. A strict equality test misses these cases + # and leaves the minimum-valued region unfilled (see #21382), so on a + # linear scale treat the level as coincident with the data minimum when + # it sits within a small tolerance of it. The tolerance is scaled to + # the data range (as Colorbar._add_solids does) rather than to a fixed + # absolute value: the round-off being absorbed grows with the data, and + # the operands here are often ~0, so an operand-relative test such as + # np.isclose would collapse to its atol and stop adapting. The 1e-12 + # factor is a defensive choice, well above double-precision round-off + # (~1e-15) yet far below any visible feature, since a real gap spans a + # sizeable fraction of the range and so dwarfs the tolerance. On a log + # scale the range spans many decades, so an additive tolerance is not + # meaningful and the historical exact test is kept. + if self.logscale: + coincident = self.zmin == lowers[0] + else: + tol = (self.zmax - self.zmin) * 1e-12 + coincident = 0 <= lowers[0] - self.zmin <= tol + if coincident: lowers = lowers.copy() # so we don't change self._levels if self.logscale: lowers[0] = 0.99 * self.zmin diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py index 8b13caa15e67..109499d47970 100644 --- a/lib/matplotlib/tests/test_contour.py +++ b/lib/matplotlib/tests/test_contour.py @@ -352,6 +352,58 @@ def test_contourf_symmetric_locator(): assert_array_almost_equal(cs.levels, np.linspace(-12, 12, 5)) +@pytest.mark.parametrize('z_min', [ + 0.0, # data minimum exactly on the lowest level + -1e-13, # a floating-point hair below it ... + -1.7e-13, # github issue 21382 +]) +@check_figures_equal() +def test_contourf_min_at_lowest_level(fig_test, fig_ref, z_min): + # The region at the data minimum must be filled even when that minimum is + # a floating-point hair below the lowest contour level rather than exactly + # equal to it. Each case must therefore match a clean minimum of zero. + levels = [0, 0.5, 1, 1.5, 2] + z = np.array([[0., 0., 2.], [0., 0., 1.], [2., 1., z_min]]) + z_ref = np.array([[0., 0., 2.], [0., 0., 1.], [2., 1., 0.]]) + fig_test.subplots().contourf(z, levels=levels) + fig_ref.subplots().contourf(z_ref, levels=levels) + + +@pytest.mark.parametrize('scale', [1e-6, 1e6, 1e12]) +@check_figures_equal() +def test_contourf_min_at_lowest_level_scaled(fig_test, fig_ref, scale): + # The tolerance is scaled to the data range, so the fix must hold when the + # whole problem is shrunk or blown up by many orders of magnitude. The + # data, levels and the float-noise minimum all scale together, so the noisy + # case must still match a clean zero minimum at every scale. + levels = np.array([0, 0.5, 1, 1.5, 2]) * scale + z = np.array([[0., 0., 2.], [0., 0., 1.], [2., 1., -1.7e-13]]) * scale + z_ref = np.array([[0., 0., 2.], [0., 0., 1.], [2., 1., 0.]]) * scale + fig_test.subplots().contourf(z, levels=levels) + fig_ref.subplots().contourf(z_ref, levels=levels) + + +def test_contourf_lowest_level_gap_not_filled(): + # The floating-point tolerance that fixes issue 21382 must not back-fill a + # genuine gap: a user-specified lowest level clearly above the data minimum + # marks a region that should stay unfilled, so the lowest interval keeps the + # chosen level as its lower bound rather than being extended downwards. + z = np.array([[0.2, 0.2, 5.], [0.2, 3., 1.], [5., 1., 0.2]]) + cs = plt.figure().subplots().contourf(z, levels=[2, 3, 4, 5]) + lowers, _ = cs._get_lowers_and_uppers() + assert lowers[0] == 2 + + +def test_contourf_large_range_gap_not_filled(): + # With a very large data range the tolerance grows to order unity, but a + # user-specified lowest level that is well above the data minimum still + # marks a gap the viewer can see, and it must not be back-filled. + z = np.array([[0., 0., 2e12], [0., 1e12, 1e12], [2e12, 1e12, 0.]]) + cs = plt.figure().subplots().contourf(z, levels=[5e11, 1e12, 1.5e12, 2e12]) + lowers, _ = cs._get_lowers_and_uppers() + assert lowers[0] == 5e11 + + def test_circular_contour_warning(): # Check that almost circular contours don't throw a warning x, y = np.meshgrid(np.linspace(-2, 2, 4), np.linspace(-2, 2, 4))