Skip to content

Fix specifying number of levels with log contour #27576

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 30, 2025
Merged
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
4 changes: 4 additions & 0 deletions doc/users/next_whats_new/log_contour_levels.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Maximum levels on log-scaled contour plots are now respected
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When plotting contours with a log norm, passing an integer value to the ``levels``
argument to cap the maximum number of contour levels now works as intended.
52 changes: 31 additions & 21 deletions lib/matplotlib/contour.py
Original file line number Diff line number Diff line change
Expand Up @@ -964,12 +964,29 @@ def changed(self):
label.set_color(self.labelMappable.to_rgba(cv))
super().changed()

def _autolev(self, N):
def _ensure_locator_exists(self, N):
"""
Select contour levels to span the data.
Set a locator on this ContourSet if it's not already set.

The target number of levels, *N*, is used only when the
scale is not log and default locator is used.
Parameters
----------
N : int or None
If *N* is an int, it is used as the target number of levels.
Otherwise when *N* is None, a reasonable default is chosen;
for logscales the LogLocator chooses, N=7 is the default
otherwise.
"""
if self.locator is None:
if self.logscale:
self.locator = ticker.LogLocator(numticks=N)
else:
if N is None:
N = 7 # Hard coded default
self.locator = ticker.MaxNLocator(N + 1, min_n_ticks=1)

def _autolev(self):
"""
Select contour levels to span the data.

We need two more levels for filled contours than for
line contours, because for the latter we need to specify
Expand All @@ -978,12 +995,6 @@ def _autolev(self, N):
one contour line, but two filled regions, and therefore
three levels to provide boundaries for both regions.
"""
if self.locator is None:
if self.logscale:
self.locator = ticker.LogLocator()
else:
self.locator = ticker.MaxNLocator(N + 1, min_n_ticks=1)

lev = self.locator.tick_values(self.zmin, self.zmax)

try:
Expand Down Expand Up @@ -1011,22 +1022,21 @@ def _process_contour_level_args(self, args, z_dtype):
"""
Determine the contour levels and store in self.levels.
"""
if self.levels is None:
levels_arg = self.levels
if levels_arg is None:
if args:
# Set if levels manually provided
levels_arg = args[0]
Comment on lines 1027 to 1029
Copy link
Member

@timhoffm timhoffm Apr 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could still be made clearer / the current code is still convoluted (but possibly better as a followup PR to not make too many changes and complicate review)

args is not the original args but stripped from leading [X, Y], Z. Effectively it can only be empty or contain a single element that contains levels passed positionally (which is supported, but a bit cumbersome to write out as a signature because the optional [X, Y] require to absorb all positional parameters in *args and we therefore have to reunite a possible positional level from args with the possible kwarg level.

The current implementation also has the surprising side effect that you can do contour(Z, levels1, levels=levels2) and the positional levels1 is sliently ignored.

The only use of args here is to pass on a possible positional level. We can push the logic out of the function and unite the positional and kwarg paths of levels in the caller _contour_args - where it belongs logically.

I think eventually we should also make levels kw-only. contour(Z, 5) or contour(X, Y, Z, [-1, 0, 1]) are not very readable anyway.

elif np.issubdtype(z_dtype, bool):
if self.filled:
levels_arg = [0, .5, 1]
else:
levels_arg = [.5]
else:
levels_arg = 7 # Default, hard-wired.
else:
levels_arg = self.levels
if isinstance(levels_arg, Integral):
self.levels = self._autolev(levels_arg)
# Set default values for bool data types
levels_arg = [0, .5, 1] if self.filled else [.5]

if isinstance(levels_arg, Integral) or levels_arg is None:
self._ensure_locator_exists(levels_arg)
self.levels = self._autolev()
else:
self.levels = np.asarray(levels_arg, np.float64)

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:
Expand Down
15 changes: 15 additions & 0 deletions lib/matplotlib/tests/test_contour.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,21 @@ def test_log_locator_levels():
assert_array_almost_equal(cb.ax.get_yticks(), c.levels)


@pytest.mark.parametrize("n_levels", [2, 3, 4, 5, 6])
def test_lognorm_levels(n_levels):
x, y = np.mgrid[1:10:0.1, 1:10:0.1]
data = np.abs(np.sin(x)*np.exp(y))

fig, ax = plt.subplots()
im = ax.contour(x, y, data, norm=LogNorm(), levels=n_levels)
fig.colorbar(im, ax=ax)

levels = im.levels
visible_levels = levels[(levels <= data.max()) & (levels >= data.min())]
# levels parameter promises "no more than n+1 "nice" contour levels "
assert len(visible_levels) <= n_levels + 1


@image_comparison(['contour_datetime_axis.png'], style='mpl20')
def test_contour_datetime_axis():
fig = plt.figure()
Expand Down
Loading