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
Refactor contour norm routing flags
  • Loading branch information
cvanelteren committed Feb 26, 2026
commit 0008b82c7a95ed03dd3c3111bdb86e3a6ea36c84
51 changes: 17 additions & 34 deletions ultraplot/axes/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -4141,7 +4141,7 @@ def _parse_cmap(
# NOTE: Unlike xarray, but like matplotlib, vmin and vmax only approximately
# determine level range. Levels are selected with Locator.tick_values().
levels = None # unused
preserve_line_limits = False
explicit_limits = False
isdiverging = False
if not discrete and not skip_autolev:
vmin, vmax, kwargs = self._parse_level_lim(
Expand All @@ -4157,7 +4157,7 @@ def _parse_cmap(
vmax,
norm,
norm_kw,
preserve_line_limits,
explicit_limits,
kwargs,
) = self._parse_level_vals(
*args,
Expand Down Expand Up @@ -4208,7 +4208,7 @@ def _parse_cmap(
center_levels=center_levels,
extend=extend,
min_levels=min_levels,
preserve_line_limits=preserve_line_limits,
explicit_limits=explicit_limits,
**kwargs,
)
params = _pop_params(kwargs, *self._level_parsers, ignore_internal=True)
Expand Down Expand Up @@ -4600,9 +4600,8 @@ def _parse_level_vals(
-------
levels : list of float
The level edges.
preserve_line_limits : bool
Whether explicit line contour limits should be preserved when routing
to normalizer construction.
explicit_limits : bool
Whether the user explicitly provided `vmin` and/or `vmax`.
**kwargs
Unused arguments.
"""
Expand Down Expand Up @@ -4642,9 +4641,8 @@ def _sanitize_levels(key, array, minsize):

# Parse input arguments and resolve incompatibilities
explicit_limits = vmin is not None or vmax is not None
preserve_line_limits = self._use_continuous_line_norm(
min_levels, explicit_limits=explicit_limits
)
line_contours = min_levels == 1
keep_explicit_line_limits = line_contours and explicit_limits
levels = _not_none(N=N, levels=levels, norm_kw_levs=norm_kw.pop("levels", None))
if positive and negative:
warnings._warn_ultraplot(
Expand Down Expand Up @@ -4726,30 +4724,21 @@ def _sanitize_levels(key, array, minsize):
if len(levels) == 0: # skip
pass
elif len(levels) == 1: # use central colormap color
if not preserve_line_limits or vmin is None:
if not keep_explicit_line_limits or vmin is None:
vmin = levels[0] - 1
if not preserve_line_limits or vmax is None:
if not keep_explicit_line_limits or vmax is None:
vmax = levels[0] + 1
else: # use minimum and maximum
if not preserve_line_limits or vmin is None:
if not keep_explicit_line_limits or vmin is None:
vmin = np.min(levels)
if not preserve_line_limits or vmax is None:
if not keep_explicit_line_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")
if norm in ("segments", "segmented"):
norm_kw["levels"] = levels

return levels, vmin, vmax, norm, norm_kw, preserve_line_limits, kwargs

@staticmethod
def _use_continuous_line_norm(
min_levels, *, explicit_limits=False, qualitative=False
):
"""
Whether line contours should keep a continuous normalizer.
"""
return min_levels == 1 and (explicit_limits or qualitative)
return levels, vmin, vmax, norm, norm_kw, explicit_limits, kwargs

@staticmethod
def _parse_level_norm(
Expand All @@ -4762,7 +4751,7 @@ def _parse_level_norm(
discrete_ticks=None,
discrete_labels=None,
center_levels=None,
preserve_line_limits=False,
explicit_limits=False,
**kwargs,
):
"""
Expand All @@ -4785,16 +4774,14 @@ def _parse_level_norm(
The colorbar locations to tick.
discrete_labels : array-like, optional
The colorbar tick labels.
preserve_line_limits : bool, optional
Whether to preserve explicit line-contour normalization limits instead
of converting to `~ultraplot.colors.DiscreteNorm`.
explicit_limits : bool, optional
Whether `vmin`/`vmax` were explicitly provided by the user.

Returns
-------
norm : `~ultraplot.colors.DiscreteNorm` or `~matplotlib.colors.Normalize`
The discrete normalizer, or the original continuous normalizer when
`preserve_line_limits` is `True` (line contours with explicit limits)
or for line contours using qualitative color lists.
line contours have explicit limits or use qualitative color lists.
cmap : `~matplotlib.colors.Colormap`
The possibly-modified colormap.
kwargs
Expand Down Expand Up @@ -4860,11 +4847,7 @@ def _parse_level_norm(
# with explicit limits or qualitative color lists, keep the continuous
# normalizer to preserve one-to-one value->color mapping.
center_levels = _not_none(center_levels, rc["colorbar.center_levels"])
Comment on lines +4846 to 4849
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The _parse_level_norm docstring says it returns a DiscreteNorm/BoundaryNorm, but with the new min_levels == 1 behavior it can return the original continuous Normalize (line contours). Please update the Returns section to reflect the possible return types so users don't rely on an incorrect contract.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

preserve_line_mapping = PlotAxes._use_continuous_line_norm(
min_levels,
explicit_limits=preserve_line_limits,
qualitative=qualitative,
)
preserve_line_mapping = min_levels == 1 and (explicit_limits or qualitative)
if (
not preserve_line_mapping
and not isinstance(norm, mcolors.BoundaryNorm)
Expand Down
61 changes: 49 additions & 12 deletions ultraplot/tests/test_2dplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from matplotlib.colors import Normalize

import ultraplot as uplt, warnings
from ultraplot.axes.plot import PlotAxes


@pytest.mark.skip("not sure what this does")
Expand Down Expand Up @@ -332,17 +331,6 @@ def test_contour_levels_default_use_discrete_norm():
assert m.norm(5) == pytest.approx(1.0)


def test_contour_line_norm_routing_helper():
"""
Route line contour norms to continuous only for explicit limits or qualitative.
"""
helper = PlotAxes._use_continuous_line_norm
assert helper(1, explicit_limits=False, qualitative=False) is False
assert helper(1, explicit_limits=True, qualitative=False) is True
assert helper(1, explicit_limits=False, qualitative=True) is True
assert helper(2, explicit_limits=True, qualitative=True) is False


def test_contourf_levels_keep_level_range_with_explicit_vmin_vmax():
"""
Filled contour bins keep level-based discrete scaling.
Expand Down Expand Up @@ -375,6 +363,55 @@ def test_contour_explicit_colors_match_levels():
assert np.allclose(np.asarray(m.get_edgecolor()), colors)


def test_tricontour_default_use_discrete_norm():
"""
Triangular line contours should default to DiscreteNorm bin mapping.
"""
rng = np.random.default_rng(51423)
x = rng.random(40)
y = rng.random(40)
z = np.sin(3 * x) + np.cos(3 * y)
levels = [-1.0, 0.0, 1.0]
_, ax = uplt.subplots()
m = ax.tricontour(x, y, z, levels=levels, cmap="viridis")
assert hasattr(m.norm, "_norm")
assert m.norm(-0.5) == pytest.approx(0.0)
assert m.norm(0.5) == pytest.approx(1.0)


def test_tricontour_levels_respect_explicit_vmin_vmax():
"""
Triangular line contours preserve explicit normalization limits.
"""
rng = np.random.default_rng(51423)
x = rng.random(40)
y = rng.random(40)
z = np.sin(3 * x) + np.cos(3 * y)
levels = [-1.0, 0.0, 1.0]
_, ax = uplt.subplots()
m = ax.tricontour(x, y, z, levels=levels, cmap="viridis", vmin=-2, vmax=2)
assert m.norm.vmin == pytest.approx(-2)
assert m.norm.vmax == pytest.approx(2)
assert m.norm(-0.5) == pytest.approx(0.375)
assert m.norm(0.5) == pytest.approx(0.625)


def test_tricontour_explicit_colors_match_levels():
"""
Explicit triangular contour colors should map one-to-one with levels.
"""
rng = np.random.default_rng(51423)
x = rng.random(40)
y = rng.random(40)
z = np.sin(3 * x) + np.cos(3 * y)
levels = [-1.0, 0.0, 1.0]
turbo = uplt.Colormap("turbo")
colors = turbo(Normalize(vmin=-2, vmax=2)(levels))
_, ax = uplt.subplots()
m = ax.tricontour(x, y, z, levels=levels, colors=colors, linewidths=1)
assert np.allclose(np.asarray(m.get_edgecolor()), colors)


@pytest.mark.mpl_image_compare
def test_level_restriction(rng):
"""
Expand Down