Skip to content

Commit 3378000

Browse files
cvanelterenCopilotCopilot
authored
Fix contour level color mapping with explicit limits (#599)
* Fix contour level color mapping with explicit limits * Update ultraplot/axes/plot.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix `_parse_level_norm` docstring to reflect conditional return type (#600) * Initial plan * Update _parse_level_norm docstring Returns section to reflect possible return types Co-authored-by: cvanelteren <19485143+cvanelteren@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Casper van Elteren <caspervanelteren@gmail.com> Co-authored-by: cvanelteren <19485143+cvanelteren@users.noreply.github.com> * Scope explicit contour limits to line contours * Pass vmin/vmax through automatic level generation * Restore default tricontour discrete mapping * Format contour norm routing changes * Clarify contour norm routing docs * Refactor contour norm routing flow * Refactor contour norm routing flags --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: cvanelteren <19485143+cvanelteren@users.noreply.github.com>
1 parent e6a44c9 commit 3378000

File tree

2 files changed

+170
-14
lines changed

2 files changed

+170
-14
lines changed

ultraplot/axes/plot.py

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4141,6 +4141,7 @@ def _parse_cmap(
41414141
# NOTE: Unlike xarray, but like matplotlib, vmin and vmax only approximately
41424142
# determine level range. Levels are selected with Locator.tick_values().
41434143
levels = None # unused
4144+
explicit_limits = False
41444145
isdiverging = False
41454146
if not discrete and not skip_autolev:
41464147
vmin, vmax, kwargs = self._parse_level_lim(
@@ -4150,7 +4151,15 @@ def _parse_cmap(
41504151
if abs(np.sign(vmax) - np.sign(vmin)) == 2:
41514152
isdiverging = True
41524153
if discrete:
4153-
levels, vmin, vmax, norm, norm_kw, kwargs = self._parse_level_vals(
4154+
(
4155+
levels,
4156+
vmin,
4157+
vmax,
4158+
norm,
4159+
norm_kw,
4160+
explicit_limits,
4161+
kwargs,
4162+
) = self._parse_level_vals(
41544163
*args,
41554164
vmin=vmin,
41564165
vmax=vmax,
@@ -4199,6 +4208,7 @@ def _parse_cmap(
41994208
center_levels=center_levels,
42004209
extend=extend,
42014210
min_levels=min_levels,
4211+
explicit_limits=explicit_limits,
42024212
**kwargs,
42034213
)
42044214
params = _pop_params(kwargs, *self._level_parsers, ignore_internal=True)
@@ -4462,7 +4472,6 @@ def _parse_level_num(
44624472
center_levels = _not_none(center_levels, rc["colorbar.center_levels"])
44634473
vmin = _not_none(vmin=vmin, norm_kw_vmin=norm_kw.pop("vmin", None))
44644474
vmax = _not_none(vmax=vmax, norm_kw_vmax=norm_kw.pop("vmax", None))
4465-
norm = constructor.Norm(norm or "linear", **norm_kw)
44664475
symmetric = _not_none(
44674476
symmetric=symmetric,
44684477
locator_kw_symmetric=locator_kw.pop("symmetric", None),
@@ -4555,6 +4564,8 @@ def _parse_level_vals(
45554564
nozero=False,
45564565
norm=None,
45574566
norm_kw=None,
4567+
vmin=None,
4568+
vmax=None,
45584569
skip_autolev=False,
45594570
min_levels=None,
45604571
center_levels=None,
@@ -4577,7 +4588,9 @@ def _parse_level_vals(
45774588
Whether to remove out non-positive, non-negative, and zero-valued
45784589
levels. The latter is useful for single-color contour plots.
45794590
norm, norm_kw : optional
4580-
Passed to `Norm`. Used to possbily infer levels or to convert values.
4591+
Passed to `Norm`. Used to possibly infer levels or to convert values.
4592+
vmin, vmax : float, optional
4593+
The user input normalization range.
45814594
skip_autolev : bool, optional
45824595
Whether to skip automatic level generation.
45834596
min_levels : int, optional
@@ -4587,6 +4600,8 @@ def _parse_level_vals(
45874600
-------
45884601
levels : list of float
45894602
The level edges.
4603+
explicit_limits : bool
4604+
Whether the user explicitly provided `vmin` and/or `vmax`.
45904605
**kwargs
45914606
Unused arguments.
45924607
"""
@@ -4625,7 +4640,9 @@ def _sanitize_levels(key, array, minsize):
46254640
return array
46264641

46274642
# Parse input arguments and resolve incompatibilities
4628-
vmin = vmax = None
4643+
explicit_limits = vmin is not None or vmax is not None
4644+
line_contours = min_levels == 1
4645+
keep_explicit_line_limits = line_contours and explicit_limits
46294646
levels = _not_none(N=N, levels=levels, norm_kw_levs=norm_kw.pop("levels", None))
46304647
if positive and negative:
46314648
warnings._warn_ultraplot(
@@ -4684,6 +4701,8 @@ def _sanitize_levels(key, array, minsize):
46844701
levels, kwargs = self._parse_level_num(
46854702
*args,
46864703
levels=levels,
4704+
vmin=vmin,
4705+
vmax=vmax,
46874706
norm=norm,
46884707
norm_kw=norm_kw,
46894708
extend=extend,
@@ -4696,24 +4715,30 @@ def _sanitize_levels(key, array, minsize):
46964715
levels = values = None
46974716

46984717
# Determine default colorbar locator and norm and apply filters
4699-
# NOTE: DiscreteNorm does not currently support vmin and
4700-
# vmax different from level list minimum and maximum.
4718+
# NOTE: Preserve explicit vmin/vmax only for line contours, where levels
4719+
# represent contour values rather than filled bins.
47014720
# NOTE: The level restriction should have no effect if levels were generated
47024721
# automatically. However want to apply these to manual-input levels as well.
47034722
if levels is not None:
47044723
levels = _restrict_levels(levels)
47054724
if len(levels) == 0: # skip
47064725
pass
47074726
elif len(levels) == 1: # use central colormap color
4708-
vmin, vmax = levels[0] - 1, levels[0] + 1
4727+
if not keep_explicit_line_limits or vmin is None:
4728+
vmin = levels[0] - 1
4729+
if not keep_explicit_line_limits or vmax is None:
4730+
vmax = levels[0] + 1
47094731
else: # use minimum and maximum
4710-
vmin, vmax = np.min(levels), np.max(levels)
4732+
if not keep_explicit_line_limits or vmin is None:
4733+
vmin = np.min(levels)
4734+
if not keep_explicit_line_limits or vmax is None:
4735+
vmax = np.max(levels)
47114736
if not np.allclose(levels[1] - levels[0], np.diff(levels)):
47124737
norm = _not_none(norm, "segmented")
47134738
if norm in ("segments", "segmented"):
47144739
norm_kw["levels"] = levels
47154740

4716-
return levels, vmin, vmax, norm, norm_kw, kwargs
4741+
return levels, vmin, vmax, norm, norm_kw, explicit_limits, kwargs
47174742

47184743
@staticmethod
47194744
def _parse_level_norm(
@@ -4726,6 +4751,7 @@ def _parse_level_norm(
47264751
discrete_ticks=None,
47274752
discrete_labels=None,
47284753
center_levels=None,
4754+
explicit_limits=False,
47294755
**kwargs,
47304756
):
47314757
"""
@@ -4748,11 +4774,14 @@ def _parse_level_norm(
47484774
The colorbar locations to tick.
47494775
discrete_labels : array-like, optional
47504776
The colorbar tick labels.
4777+
explicit_limits : bool, optional
4778+
Whether `vmin`/`vmax` were explicitly provided by the user.
47514779
47524780
Returns
47534781
-------
4754-
norm : `~ultraplot.colors.DiscreteNorm`
4755-
The discrete normalizer.
4782+
norm : `~ultraplot.colors.DiscreteNorm` or `~matplotlib.colors.Normalize`
4783+
The discrete normalizer, or the original continuous normalizer when
4784+
line contours have explicit limits or use qualitative color lists.
47564785
cmap : `~matplotlib.colors.Colormap`
47574786
The possibly-modified colormap.
47584787
kwargs
@@ -4814,10 +4843,16 @@ def _parse_level_norm(
48144843
elif extend == "max":
48154844
unique = "neither"
48164845

4817-
# Generate DiscreteNorm and update "child" norm with vmin and vmax from
4818-
# levels. This lets the colorbar set tick locations properly!
4846+
# Generate DiscreteNorm for filled-contour style bins. For line contours
4847+
# with explicit limits or qualitative color lists, keep the continuous
4848+
# normalizer to preserve one-to-one value->color mapping.
48194849
center_levels = _not_none(center_levels, rc["colorbar.center_levels"])
4820-
if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1:
4850+
preserve_line_mapping = min_levels == 1 and (explicit_limits or qualitative)
4851+
if (
4852+
not preserve_line_mapping
4853+
and not isinstance(norm, mcolors.BoundaryNorm)
4854+
and len(levels) > 1
4855+
):
48214856
norm = pcolors.DiscreteNorm(
48224857
levels,
48234858
norm=norm,

ultraplot/tests/test_2dplots.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import numpy as np
77
import pytest
88
import xarray as xr
9+
from matplotlib.colors import Normalize
910

1011
import ultraplot as uplt, warnings
1112

@@ -291,6 +292,126 @@ def test_levels_with_vmin_vmax(rng):
291292
return fig
292293

293294

295+
def test_contour_levels_respect_explicit_vmin_vmax():
296+
"""
297+
Explicit `vmin` and `vmax` should be preserved for line contours.
298+
"""
299+
data = np.linspace(0, 10, 25).reshape((5, 5))
300+
levels = [2, 4, 6]
301+
_, ax = uplt.subplots()
302+
m = ax.contour(data, levels=levels, cmap="viridis", vmin=0, vmax=10)
303+
assert m.norm.vmin == pytest.approx(0)
304+
assert m.norm.vmax == pytest.approx(10)
305+
assert m.norm(3) == pytest.approx(0.3)
306+
assert m.norm(5) == pytest.approx(0.5)
307+
308+
309+
def test_contour_levels_default_stretch():
310+
"""
311+
Without explicit limits, level bins should continue to span full cmap range.
312+
"""
313+
data = np.linspace(0, 10, 25).reshape((5, 5))
314+
levels = [2, 4, 6]
315+
_, ax = uplt.subplots()
316+
m = ax.contourf(data, levels=levels, cmap="viridis")
317+
assert m.norm(3) == pytest.approx(0.0)
318+
assert m.norm(5) == pytest.approx(1.0)
319+
320+
321+
def test_contour_levels_default_use_discrete_norm():
322+
"""
323+
Line contours should retain DiscreteNorm behavior unless limits are explicit.
324+
"""
325+
data = np.linspace(0, 10, 25).reshape((5, 5))
326+
levels = [2, 4, 6]
327+
_, ax = uplt.subplots()
328+
m = ax.contour(data, levels=levels, cmap="viridis")
329+
assert hasattr(m.norm, "_norm")
330+
assert m.norm(3) == pytest.approx(0.0)
331+
assert m.norm(5) == pytest.approx(1.0)
332+
333+
334+
def test_contourf_levels_keep_level_range_with_explicit_vmin_vmax():
335+
"""
336+
Filled contour bins keep level-based discrete scaling.
337+
"""
338+
data = np.linspace(0, 10, 25).reshape((5, 5))
339+
levels = [2, 4, 6]
340+
_, ax = uplt.subplots()
341+
m = ax.contourf(data, levels=levels, cmap="viridis", vmin=0, vmax=10)
342+
assert m.norm.vmin == pytest.approx(2)
343+
assert m.norm.vmax == pytest.approx(6)
344+
assert m.norm._norm.vmin == pytest.approx(2)
345+
assert m.norm._norm.vmax == pytest.approx(6)
346+
assert m.norm(3) == pytest.approx(0.0)
347+
assert m.norm(5) == pytest.approx(1.0)
348+
349+
350+
def test_contour_explicit_colors_match_levels():
351+
"""
352+
Explicit contour line colors should map one-to-one with contour levels.
353+
"""
354+
x = np.linspace(-1, 1, 100)
355+
y = np.linspace(-1, 1, 100)
356+
X, Y = np.meshgrid(x, y)
357+
Z = np.exp(-(X**2 + Y**2))
358+
levels = [0.3, 0.6, 0.9]
359+
turbo = uplt.Colormap("turbo")
360+
colors = turbo(Normalize(vmin=0, vmax=1)(levels))
361+
_, ax = uplt.subplots()
362+
m = ax.contour(X, Y, Z, levels=levels, colors=colors, linewidths=1)
363+
assert np.allclose(np.asarray(m.get_edgecolor()), colors)
364+
365+
366+
def test_tricontour_default_use_discrete_norm():
367+
"""
368+
Triangular line contours should default to DiscreteNorm bin mapping.
369+
"""
370+
rng = np.random.default_rng(51423)
371+
x = rng.random(40)
372+
y = rng.random(40)
373+
z = np.sin(3 * x) + np.cos(3 * y)
374+
levels = [-1.0, 0.0, 1.0]
375+
_, ax = uplt.subplots()
376+
m = ax.tricontour(x, y, z, levels=levels, cmap="viridis")
377+
assert hasattr(m.norm, "_norm")
378+
assert m.norm(-0.5) == pytest.approx(0.0)
379+
assert m.norm(0.5) == pytest.approx(1.0)
380+
381+
382+
def test_tricontour_levels_respect_explicit_vmin_vmax():
383+
"""
384+
Triangular line contours preserve explicit normalization limits.
385+
"""
386+
rng = np.random.default_rng(51423)
387+
x = rng.random(40)
388+
y = rng.random(40)
389+
z = np.sin(3 * x) + np.cos(3 * y)
390+
levels = [-1.0, 0.0, 1.0]
391+
_, ax = uplt.subplots()
392+
m = ax.tricontour(x, y, z, levels=levels, cmap="viridis", vmin=-2, vmax=2)
393+
assert m.norm.vmin == pytest.approx(-2)
394+
assert m.norm.vmax == pytest.approx(2)
395+
assert m.norm(-0.5) == pytest.approx(0.375)
396+
assert m.norm(0.5) == pytest.approx(0.625)
397+
398+
399+
def test_tricontour_explicit_colors_match_levels():
400+
"""
401+
Explicit triangular contour colors should map one-to-one with levels.
402+
"""
403+
rng = np.random.default_rng(51423)
404+
x = rng.random(40)
405+
y = rng.random(40)
406+
z = np.sin(3 * x) + np.cos(3 * y)
407+
levels = [-1.0, 0.0, 1.0]
408+
turbo = uplt.Colormap("turbo")
409+
colors = turbo(Normalize(vmin=-2, vmax=2)(levels))
410+
_, ax = uplt.subplots()
411+
m = ax.tricontour(x, y, z, levels=levels, colors=colors, linewidths=1)
412+
assert np.allclose(np.asarray(m.get_edgecolor()), colors)
413+
414+
294415
@pytest.mark.mpl_image_compare
295416
def test_level_restriction(rng):
296417
"""

0 commit comments

Comments
 (0)