Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/api/next_api_changes/behavior/31903-SN.rst
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 23 additions & 2 deletions lib/matplotlib/contour.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions lib/matplotlib/tests/test_contour.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading