Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Scope explicit contour limits to line contours
  • Loading branch information
cvanelteren committed Feb 26, 2026
commit 21195ec5ee7974dd050e25470f6d8ea33b22049f
12 changes: 7 additions & 5 deletions ultraplot/axes/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -4699,22 +4699,24 @@ def _sanitize_levels(key, array, minsize):
levels = values = None

# Determine default colorbar locator and norm and apply filters
# NOTE: Explicit vmin/vmax should override defaults inferred from levels.
# NOTE: Preserve explicit vmin/vmax only for line contours, where levels
# represent contour values rather than filled bins.
# NOTE: The level restriction should have no effect if levels were generated
# automatically. However want to apply these to manual-input levels as well.
if levels is not None:
preserve_limits = min_levels == 1
levels = _restrict_levels(levels)
if len(levels) == 0: # skip
pass
elif len(levels) == 1: # use central colormap color
if vmin is None:
if not preserve_limits or vmin is None:
vmin = levels[0] - 1
if vmax is None:
if not preserve_limits or vmax is None:
vmax = levels[0] + 1
else: # use minimum and maximum
if vmin is None:
if not preserve_limits or vmin is None:
vmin = np.min(levels)
if vmax is None:
if not preserve_limits or vmax is None:
vmax = np.max(levels)
if not np.allclose(levels[1] - levels[0], np.diff(levels)):
norm = _not_none(norm, "segmented")
Expand Down
42 changes: 16 additions & 26 deletions ultraplot/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2492,9 +2492,8 @@ def __init__(
colorbar axis drawn with this normalizer will be reversed.
norm : `~matplotlib.colors.Normalize`, optional
The normalizer used to transform `levels` and data values passed to
`~DiscreteNorm.__call__` before discretization. If the normalizer
``vmin`` and ``vmax`` are unset, they default to the minimum and
maximum values in `levels`.
`~DiscreteNorm.__call__` before discretization. The ``vmin`` and ``vmax``
of the normalizer are set to the minimum and maximum values in `levels`.
unique : {'neither', 'both', 'min', 'max'}, optional
Which out-of-bounds regions should be assigned unique colormap colors.
Possible values are equivalent to the `extend` values. Internally, ultraplot
Expand Down Expand Up @@ -2523,10 +2522,11 @@ def __init__(

Note
----
By default this normalizer makes level bins span the full colormap range,
including when `extend` is set to ``'min'``, ``'max'``, ``'neither'``, or
``'both'``. If the input normalizer has explicit ``vmin`` and ``vmax`` that
differ from the level bounds, bins instead follow that explicit range.
This normalizer makes sure that levels always span the full range of
colors in the colormap, whether `extend` is set to ``'min'``, ``'max'``,
``'neither'``, or ``'both'``. In matplotlib, when `extend` is not ``'both'``,
the most intense colors are cut off (reserved for "out of bounds" data),
even though they are not being used.

See also
--------
Expand Down Expand Up @@ -2561,12 +2561,8 @@ def __init__(
# Instead user-reversed levels will always get passed here just as
# they are passed to SegmentedNorm inside plot.py
levels, descending = _sanitize_levels(levels)
level_min = np.min(levels)
level_max = np.max(levels)
vmin = _not_none(norm.vmin, level_min)
vmax = _not_none(norm.vmax, level_max)
norm.vmin = vmin
norm.vmax = vmax
vmin = norm.vmin = np.min(levels)
vmax = norm.vmax = np.max(levels)
bins, _ = _sanitize_levels(norm(levels))
vcenter = getattr(norm, "vcenter", None)
mids = np.zeros((levels.size + 1,))
Expand All @@ -2586,19 +2582,13 @@ def __init__(
mids[0] += step * (mids[1] - mids[2])
if unique in ("max", "both"):
mids[-1] += step * (mids[-2] - mids[-3])
stretch = np.isclose(vmin, level_min) and np.isclose(vmax, level_max)
if stretch:
mmin, mmax = np.min(mids), np.max(mids)
if vcenter is None:
mids = _interpolate_scalar(mids, mmin, mmax, vmin, vmax)
else:
mask1, mask2 = mids < vcenter, mids >= vcenter
mids[mask1] = _interpolate_scalar(
mids[mask1], mmin, vcenter, vmin, vcenter
)
mids[mask2] = _interpolate_scalar(
mids[mask2], vcenter, mmax, vcenter, vmax
)
mmin, mmax = np.min(mids), np.max(mids)
if vcenter is None:
mids = _interpolate_scalar(mids, mmin, mmax, vmin, vmax)
else:
mask1, mask2 = mids < vcenter, mids >= vcenter
mids[mask1] = _interpolate_scalar(mids[mask1], mmin, vcenter, vmin, vcenter)
mids[mask2] = _interpolate_scalar(mids[mask2], vcenter, mmax, vcenter, vmax)

# Instance attributes
# NOTE: If clip is True, we clip values to the centers of the end bins
Expand Down
22 changes: 18 additions & 4 deletions ultraplot/tests/test_2dplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,16 +294,14 @@ def test_levels_with_vmin_vmax(rng):

def test_contour_levels_respect_explicit_vmin_vmax():
"""
Explicit `vmin` and `vmax` should be preserved with manual contour levels.
Explicit `vmin` and `vmax` should be preserved for line contours.
"""
data = np.linspace(0, 10, 25).reshape((5, 5))
levels = [2, 4, 6]
_, ax = uplt.subplots()
m = ax.contourf(data, levels=levels, cmap="viridis", vmin=0, vmax=10)
m = ax.contour(data, levels=levels, cmap="viridis", vmin=0, vmax=10)
assert m.norm.vmin == pytest.approx(0)
assert m.norm.vmax == pytest.approx(10)
assert m.norm._norm.vmin == pytest.approx(0)
assert m.norm._norm.vmax == pytest.approx(10)
assert m.norm(3) == pytest.approx(0.3)
assert m.norm(5) == pytest.approx(0.5)

Expand All @@ -320,6 +318,22 @@ def test_contour_levels_default_stretch():
assert m.norm(5) == pytest.approx(1.0)


def test_contourf_levels_keep_level_range_with_explicit_vmin_vmax():
"""
Filled contour bins keep level-based discrete scaling.
"""
data = np.linspace(0, 10, 25).reshape((5, 5))
levels = [2, 4, 6]
_, ax = uplt.subplots()
m = ax.contourf(data, levels=levels, cmap="viridis", vmin=0, vmax=10)
assert m.norm.vmin == pytest.approx(2)
assert m.norm.vmax == pytest.approx(6)
assert m.norm._norm.vmin == pytest.approx(2)
assert m.norm._norm.vmax == pytest.approx(6)
assert m.norm(3) == pytest.approx(0.0)
assert m.norm(5) == pytest.approx(1.0)


def test_contour_explicit_colors_match_levels():
"""
Explicit contour line colors should map one-to-one with contour levels.
Expand Down
Loading