Skip to content

Commit 40b3d79

Browse files
committed
Improve cmap level sanitization
1 parent 8987839 commit 40b3d79

File tree

2 files changed

+70
-56
lines changed

2 files changed

+70
-56
lines changed

proplot/axes/plot.py

Lines changed: 64 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2397,8 +2397,10 @@ def _parse_level_count(
23972397
)
23982398
try:
23992399
levels = locator.tick_values(vmin, vmax)
2400+
except TypeError: # e.g. due to datetime arrays
2401+
return None, kwargs
24002402
except RuntimeError: # too-many-ticks error
2401-
levels = np.linspace(vmin, vmax, levels) # TODO: _autolev used N+1
2403+
levels = np.linspace(vmin, vmax, levels) # TODO: _autolev used N + 1
24022404

24032405
# Possibly trim levels far outside of 'vmin' and 'vmax'
24042406
# NOTE: This part is mostly copied from matplotlib _autolev
@@ -2468,48 +2470,58 @@ def _parse_level_list(
24682470
kwargs
24692471
Unused arguments.
24702472
"""
2471-
# Rigorously check user input levels and values
2473+
# Helper function that restricts levels
2474+
# NOTE: This should have no effect if levels were generated automatically.
2475+
# However want to apply these to manual-input levels as well.
2476+
def _restrict_levels(levels):
2477+
if nozero:
2478+
levels = levels[levels != 0]
2479+
if positive:
2480+
levels = levels[levels >= 0]
2481+
if negative:
2482+
levels = levels[levels <= 0]
2483+
return levels
2484+
2485+
# Helper function to sanitize input levels
24722486
# NOTE: Include special case where color levels are referenced by string labels
2487+
def _sanitize_levels(key, array, minsize):
2488+
if np.iterable(array):
2489+
array, _ = pcolors._sanitize_levels(array, minsize)
2490+
if isinstance(norm, (mcolors.BoundaryNorm, pcolors.SegmentedNorm)):
2491+
if array is not None:
2492+
warnings._warn_proplot(
2493+
f'Ignoring {key}={array}. Using norm={norm!r} {key} instead.'
2494+
)
2495+
if key == 'levels':
2496+
array = _not_none(levels=array, norm_boundaries=norm.boundaries)
2497+
else:
2498+
array = None
2499+
return array
2500+
2501+
# Parse input arguments and resolve incompatibilities
2502+
vmin = vmax = None
24732503
levels = _not_none(N=N, levels=levels, norm_kw_levs=norm_kw.pop('levels', None))
2474-
min_levels = _not_none(min_levels, 2) # q for contour plots
24752504
if positive and negative:
2476-
negative = False
24772505
warnings._warn_proplot(
24782506
'Incompatible args positive=True and negative=True. Using former.'
24792507
)
2508+
negative = False
24802509
if levels is not None and values is not None:
24812510
warnings._warn_proplot(
24822511
f'Incompatible args levels={levels!r} and values={values!r}. Using former.' # noqa: E501
24832512
)
2484-
for key, points in (('levels', levels), ('values', values)):
2485-
if points is None:
2486-
continue
2487-
if isinstance(norm, (mcolors.BoundaryNorm, pcolors.SegmentedNorm)):
2488-
warnings._warn_proplot(
2489-
f'Ignoring {key}={points}. Instead using norm={norm!r} boundaries.'
2490-
)
2491-
if not np.iterable(points):
2492-
continue
2493-
if len(points) < min_levels:
2494-
raise ValueError(
2495-
f'Invalid {key}={points}. Must be at least length {min_levels}.'
2496-
)
2497-
if isinstance(norm, (mcolors.BoundaryNorm, pcolors.SegmentedNorm)):
2498-
levels, values = norm.boundaries, None
2499-
else:
2500-
levels = _not_none(levels, rc['cmap.levels'])
2513+
values = None
2514+
levels = _sanitize_levels('levels', levels, _not_none(min_levels, 2))
2515+
levels = _not_none(levels, rc['cmap.levels'])
2516+
values = _sanitize_levels('values', values, 1)
25012517

25022518
# Infer level edges from level centers if possible
25032519
# NOTE: The only way for user to manually impose BoundaryNorm is by
25042520
# passing one -- users cannot create one using Norm constructor key.
2505-
if isinstance(values, Integral):
2506-
levels = values + 1
2507-
elif values is None:
2521+
if values is None:
25082522
pass
2509-
elif not np.iterable(values):
2510-
raise ValueError(f'Invalid values={values!r}.')
2511-
elif len(values) == 0:
2512-
levels = [] # weird but why not
2523+
elif isinstance(values, Integral):
2524+
levels = values + 1
25132525
elif len(values) == 1:
25142526
levels = [values[0] - 1, values[0] + 1] # weird but why not
25152527
elif norm is not None and norm not in ('segments', 'segmented'):
@@ -2519,16 +2531,16 @@ def _parse_level_list(
25192531
convert = constructor.Norm(norm, **norm_kw)
25202532
levels = convert.inverse(utils.edges(convert(values)))
25212533
else:
2522-
# Try to generate levels so SegmentedNorm will place 'values' ticks at the
2523-
# center of each segment. edges() gives wrong result unless spacing is even.
2534+
# Generate levels so that ticks will be centered between edges
25242535
# Solve: (x1 + x2) / 2 = y --> x2 = 2 * y - x1 with arbitrary starting x1.
2536+
print('hi!!!', values)
25252537
descending = values[1] < values[0]
25262538
if descending: # e.g. [100, 50, 20, 10, 5, 2, 1] successful if reversed
25272539
values = values[::-1]
25282540
levels = [1.5 * values[0] - 0.5 * values[1]] # arbitrary starting point
25292541
for value in values:
25302542
levels.append(2 * value - levels[-1])
2531-
if np.any(np.diff(levels) < 0):
2543+
if np.any(np.diff(levels) < 0): # never happens for evenly spaced levels
25322544
levels = utils.edges(values)
25332545
if descending: # then revert back below
25342546
levels = levels[::-1]
@@ -2541,39 +2553,40 @@ def _parse_level_list(
25412553
pop = _pop_params(kwargs, self._parse_level_count, ignore_internal=True)
25422554
if pop:
25432555
warnings._warn_proplot(f'Ignoring unused keyword arg(s): {pop}')
2544-
elif not skip_autolev:
2556+
if not np.iterable(levels) and not skip_autolev:
25452557
levels, kwargs = self._parse_level_count(
25462558
*args, levels=levels, norm=norm, norm_kw=norm_kw, extend=extend,
25472559
negative=negative, positive=positive, **kwargs
25482560
)
25492561

2550-
# Determine default norm
2551-
# NOTE: DiscreteNorm does not currently support vmin and vmax different
2552-
# from level list minimum and maximum.
2553-
if levels is not None:
2554-
if len(levels) == 1: # use central colormap color
2555-
vmin, vmax = levels[0] - 1, levels[0] + 1
2556-
elif len(levels) > 1: # use minimum and maximum
2557-
vmin, vmax = np.min(levels), np.max(levels)
2558-
if not np.allclose(levels[1] - levels[0], np.diff(levels)):
2559-
norm = _not_none(norm, 'segmented')
2560-
if np.iterable(levels) and norm in ('segments', 'segmented'):
2561-
norm_kw['levels'] = levels
2562-
2563-
# Determine default colorbar locator
2564-
# NOTE: Always show all segmented levels in case distribution is uneven
2562+
# Determine default colorbar locator and norm and apply filters
2563+
# NOTE: DiscreteNorm does not currently support vmin and
2564+
# vmax different from level list minimum and maximum.
2565+
# NOTE: The level restriction should have no effect if levels were generated
2566+
# automatically. However want to apply these to manual-input levels as well.
25652567
locator = values if np.iterable(values) else levels
2566-
if locator is not None and np.iterable(locator):
2568+
if np.iterable(locator):
2569+
locator = _restrict_levels(locator)
25672570
if norm in ('segments', 'segmented') or isinstance(norm, pcolors.SegmentedNorm): # noqa: E501
25682571
locator = mticker.FixedLocator(locator)
25692572
else:
25702573
locator = pticker.DiscreteLocator(locator)
25712574
guides._guide_kw_to_arg('colorbar', kwargs, locator=locator)
2575+
if np.iterable(levels):
2576+
levels = _restrict_levels(levels)
2577+
if len(levels) == 0: # skip
2578+
pass
2579+
elif len(levels) == 1: # use central colormap color
2580+
vmin, vmax = levels[0] - 1, levels[0] + 1
2581+
else: # use minimum and maximum
2582+
vmin, vmax = np.min(levels), np.max(levels)
2583+
if not np.allclose(levels[1] - levels[0], np.diff(levels)):
2584+
norm = _not_none(norm, 'segmented')
2585+
if norm in ('segments', 'segmented'):
2586+
norm_kw['levels'] = levels
25722587

25732588
# Filter the level boundaries
2574-
# NOTE: This should have no effect if levels were generated automatically.
2575-
# However want to apply these to manual-input levels as well.
2576-
if levels is not None and np.iterable(levels):
2589+
if np.iterable(levels):
25772590
if nozero:
25782591
levels = levels[levels != 0]
25792592
if positive:

proplot/colors.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2308,17 +2308,18 @@ def _interpolate_extrapolate_vector(xq, x, y):
23082308
return yq
23092309

23102310

2311-
def _sanitize_levels(levels):
2311+
def _sanitize_levels(levels, minsize=2):
23122312
"""
23132313
Ensure the levels are monotonic. If they are descending, reverse them.
23142314
"""
2315+
# NOTE: Matplotlib does not support datetime colormap levels as of 3.5
23152316
levels = inputs._to_numpy_array(levels)
2316-
if levels.ndim != 1 or levels.size < 2:
2317-
raise ValueError(f'Levels {levels} must be a 1D array with size >= 2.')
2317+
if levels.ndim != 1 or levels.size < minsize:
2318+
raise ValueError(f'Levels {levels} must be a 1D array with size >= {minsize}.')
23182319
if isinstance(levels, ma.core.MaskedArray):
23192320
levels = levels.filled(np.nan)
2320-
if not np.all(np.isfinite(levels)) or not inputs._is_numeric(levels):
2321-
raise ValueError(f'Levels {levels} contain invalid values.')
2321+
if not inputs._is_numeric(levels) or not np.all(np.isfinite(levels)):
2322+
raise ValueError(f'Levels {levels} does not support non-numeric cmap levels.')
23222323
diffs = np.sign(np.diff(levels))
23232324
if np.all(diffs == 1):
23242325
descending = False

0 commit comments

Comments
 (0)