Skip to content

Commit 46d8bed

Browse files
committed
Implement 'descending levs' support in normalizers instead of plot.py
1 parent ec1f841 commit 46d8bed

2 files changed

Lines changed: 69 additions & 81 deletions

File tree

proplot/axes/plot.py

Lines changed: 32 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2253,22 +2253,18 @@ def _parse_levels(
22532253
warnings._warn_proplot(
22542254
f'Incompatible args levels={levels!r} and values={values!r}. Using former.' # noqa: E501
22552255
)
2256-
for key, val in (('levels', levels), ('values', values)):
2257-
if val is None:
2256+
for key, points in (('levels', levels), ('values', values)):
2257+
if points is None:
22582258
continue
22592259
if isinstance(norm, (mcolors.BoundaryNorm, pcolors.SegmentedNorm)):
22602260
warnings._warn_proplot(
2261-
f'Ignoring {key}={val}. Instead using norm={norm!r} boundaries.'
2261+
f'Ignoring {key}={points}. Instead using norm={norm!r} boundaries.'
22622262
)
2263-
if not np.iterable(val):
2263+
if not np.iterable(points):
22642264
continue
2265-
if len(val) < min_levels:
2265+
if len(points) < min_levels:
22662266
raise ValueError(
2267-
f'Invalid {key}={val}. Must be at least length {min_levels}.'
2268-
)
2269-
if len(val) >= 2 and np.any(np.sign(np.diff(val)) != np.sign(val[1] - val[0])): # noqa: E501
2270-
raise ValueError(
2271-
f'Invalid {key}={val}. Must be monotonically increasing or decreasing.' # noqa: E501
2267+
f'Invalid {key}={points}. Must be at least length {min_levels}.'
22722268
)
22732269
if isinstance(norm, (mcolors.BoundaryNorm, pcolors.SegmentedNorm)):
22742270
levels, values = norm.boundaries, None
@@ -2278,46 +2274,47 @@ def _parse_levels(
22782274
# Infer level edges from level centers if possible
22792275
# NOTE: The only way for user to manually impose BoundaryNorm is by
22802276
# passing one -- users cannot create one using Norm constructor key.
2281-
descending = None
2282-
if values is None:
2283-
pass
2284-
elif isinstance(values, Integral):
2277+
if isinstance(values, Integral):
22852278
levels = values + 1
2286-
elif np.iterable(values) and len(values) == 1:
2279+
elif values is None:
2280+
pass
2281+
elif not np.iterable(values):
2282+
raise ValueError(f'Invalid values={values!r}.')
2283+
elif len(values) == 0:
2284+
levels = [] # weird but why not
2285+
elif len(values) == 1:
22872286
levels = [values[0] - 1, values[0] + 1] # weird but why not
2288-
elif norm is None or norm in ('segments', 'segmented'):
2287+
elif norm is not None and norm not in ('segments', 'segmented'):
2288+
# Generate levels by finding in-between points in the
2289+
# normalized numeric space, e.g. LogNorm space.
2290+
norm_kw = norm_kw or {}
2291+
convert = constructor.Norm(norm, **norm_kw)
2292+
levels = convert.inverse(utils.edges(convert(values)))
2293+
else:
22892294
# Try to generate levels so SegmentedNorm will place 'values' ticks at the
22902295
# center of each segment. edges() gives wrong result unless spacing is even.
22912296
# Solve: (x1 + x2) / 2 = y --> x2 = 2 * y - x1 with arbitrary starting x1.
2292-
values, descending = pcolors._sanitize_levels(values)
2293-
levels = [values[0] - (values[1] - values[0]) / 2] # arbitrary x1
2294-
for val in values:
2295-
levels.append(2 * val - levels[-1])
2296-
if any(np.diff(levels) < 0): # backup plan in event of weird ticks
2297+
descending = values[1] < values[0]
2298+
if descending: # e.g. [100, 50, 20, 10, 5, 2, 1] successful if reversed
2299+
values = values[::-1]
2300+
levels = [1.5 * values[0] - 0.5 * values[1]] # arbitrary starting point
2301+
for value in values:
2302+
levels.append(2 * value - levels[-1])
2303+
if np.any(np.diff(levels) < 0):
22972304
levels = utils.edges(values)
22982305
if descending: # then revert back below
22992306
levels = levels[::-1]
2300-
else:
2301-
# Generate levels by finding in-between points in the
2302-
# normalized numeric space, e.g. LogNorm space.
2303-
norm_kw = norm_kw or {}
2304-
convert = constructor.Norm(norm, **norm_kw)
2305-
levels = convert.inverse(utils.edges(convert(values)))
23062307

23072308
# Process level edges and infer defaults
23082309
# NOTE: Matplotlib colorbar algorithm *cannot* handle descending levels so
23092310
# this function reverses them and adds special attribute to the normalizer.
23102311
# Then colorbar() reads this attr and flips the axis and the colormap direction
2311-
if np.iterable(levels) and len(levels) > 2:
2312-
levels, descending = pcolors._sanitize_levels(levels)
23132312
if not np.iterable(levels) and not skip_autolev:
23142313
levels, kwargs = self._parse_autolev(
23152314
*args, levels=levels, vmin=vmin, vmax=vmax,
23162315
norm=norm, norm_kw=norm_kw, extend=extend, **kwargs
23172316
)
23182317
ticks = values if np.iterable(values) else levels
2319-
if descending is not None:
2320-
kwargs.setdefault('descending', descending) # for _parse_discrete
23212318
if ticks is not None and np.iterable(ticks):
23222319
guides._guide_kw_to_arg('colorbar', kwargs, locator=ticks)
23232320

@@ -2332,8 +2329,7 @@ def _parse_levels(
23322329
return levels, kwargs
23332330

23342331
def _parse_discrete(
2335-
self, levels, norm, cmap, *,
2336-
extend=None, descending=False, min_levels=None, **kwargs,
2332+
self, levels, norm, cmap, *, extend=None, min_levels=None, **kwargs,
23372333
):
23382334
"""
23392335
Create a `~proplot.colors.DiscreteNorm` or `~proplot.colors.BoundaryNorm`
@@ -2349,8 +2345,6 @@ def _parse_discrete(
23492345
The colormap.
23502346
extend : str, optional
23512347
The extend setting.
2352-
descending : bool, optional
2353-
Whether levels are descending.
23542348
min_levels : int, optional
23552349
The minimum number of levels.
23562350
@@ -2371,8 +2365,6 @@ def _parse_discrete(
23712365
over = cmap._rgba_over
23722366
cyclic = getattr(cmap, '_cyclic', None)
23732367
qualitative = isinstance(cmap, pcolors.DiscreteColormap) # see _parse_cmap
2374-
if descending:
2375-
cmap = cmap.reversed()
23762368
if len(levels) < min_levels:
23772369
raise ValueError(
23782370
f'Invalid levels={levels!r}. Must be at least length {min_levels}.'
@@ -2424,9 +2416,7 @@ def _parse_discrete(
24242416
# Generate DiscreteNorm and update "child" norm with vmin and vmax from
24252417
# levels. This lets the colorbar set tick locations properly!
24262418
if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1:
2427-
norm = pcolors.DiscreteNorm(
2428-
levels, norm=norm, descending=descending, unique=unique, step=step,
2429-
)
2419+
norm = pcolors.DiscreteNorm(levels, norm=norm, unique=unique, step=step)
24302420

24312421
return norm, cmap, kwargs
24322422

@@ -2552,15 +2542,15 @@ def _parse_cmap(
25522542
# NOTE: We create normalizer here only because auto level generation depends
25532543
# on the normalizer class (e.g. LogNorm). We don't have to worry about vmin
25542544
# and vmax because they get applied to normalizer inside DiscreteNorm.
2555-
if norm is None and levels is not None and len(levels) > 0:
2545+
if levels is not None and len(levels) > 0:
25562546
if len(levels) == 1: # edge case, use central colormap color
25572547
vmin = _not_none(vmin, levels[0] - 1)
25582548
vmax = _not_none(vmax, levels[0] + 1)
25592549
else:
25602550
vmin, vmax = np.min(levels), np.max(levels)
25612551
diffs = np.diff(levels)
25622552
if not np.allclose(diffs[0], diffs):
2563-
norm = 'segmented'
2553+
norm = _not_none(norm, 'segmented')
25642554
if norm in ('segments', 'segmented'):
25652555
if np.iterable(levels):
25662556
norm_kw['levels'] = levels # apply levels

proplot/colors.py

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2291,10 +2291,9 @@ def _interpolate_extrapolate(xq, x, y):
22912291
return yq
22922292

22932293

2294-
def _sanitize_levels(levels, allow_descending=True):
2294+
def _sanitize_levels(levels):
22952295
"""
2296-
Ensure the levels are monotonic. If they are descending, either
2297-
reverse them or raise an error.
2296+
Ensure the levels are monotonic. If they are descending, reverse them.
22982297
"""
22992298
levels = np.atleast_1d(levels)
23002299
if levels.ndim != 1 or levels.size < 2:
@@ -2304,14 +2303,11 @@ def _sanitize_levels(levels, allow_descending=True):
23042303
if not np.all(np.isfinite(levels)):
23052304
raise ValueError(f'Levels {levels} contain invalid values.')
23062305
diffs = np.sign(np.diff(levels))
2307-
if all(diffs == 1):
2306+
if np.all(diffs == 1):
23082307
descending = False
2309-
elif all(diffs == -1):
2308+
elif np.all(diffs == -1):
23102309
descending = True
2311-
if allow_descending:
2312-
levels = levels[::-1]
2313-
else:
2314-
raise ValueError(f'Levels {levels} must be monotonically increasing.')
2310+
levels = levels[::-1]
23152311
else:
23162312
raise ValueError(f'Levels {levels} must be monotonic.')
23172313
return levels, descending
@@ -2326,15 +2322,15 @@ class DiscreteNorm(mcolors.BoundaryNorm):
23262322
# WARNING: Must be child of BoundaryNorm. Many methods in ColorBarBase
23272323
# test for class membership, crucially including _process_values(), which
23282324
# if it doesn't detect BoundaryNorm will try to use DiscreteNorm.inverse().
2329-
@warnings._rename_kwargs('0.7', extend='unique')
2330-
def __init__(
2331-
self, levels, norm=None, unique=None, step=None, clip=False, descending=False,
2332-
):
2325+
@warnings._rename_kwargs(
2326+
'0.7', extend='unique', descending='DiscreteNorm(descending_levels)'
2327+
)
2328+
def __init__(self, levels, norm=None, unique=None, step=None, clip=False):
23332329
"""
23342330
Parameters
23352331
----------
23362332
levels : sequence of float
2337-
The level boundaries.
2333+
The level boundaries. Must be monotonically increasing or decreasing.
23382334
norm : `~matplotlib.colors.Normalize`, optional
23392335
The normalizer used to transform `levels` and data values passed to
23402336
`~DiscreteNorm.__call__` before discretization. The ``vmin`` and ``vmax``
@@ -2355,10 +2351,6 @@ def __init__(
23552351
Whether to clip values falling outside of the level bins. This only
23562352
has an effect on lower colors when unique is ``'min'`` or ``'both'``,
23572353
and on upper colors when unique is ``'max'`` or ``'both'``.
2358-
descending : bool, optional
2359-
Whether the levels are meant to be descending. This will cause
2360-
the colorbar axis to be reversed when it is drawn with a
2361-
`~matplotlib.cm.ScalarMappable` that uses this normalizer.
23622354
23632355
Note
23642356
----
@@ -2395,11 +2387,11 @@ def __init__(
23952387
)
23962388

23972389
# Ensure monotonicaly increasing levels and add built-in attributes
2398-
levels, _ = _sanitize_levels(levels, allow_descending=False)
2399-
norm.vmin = vmin = np.min(levels)
2400-
norm.vmax = vmax = np.max(levels)
2390+
levels, descending_levels = _sanitize_levels(levels)
2391+
bins, descending_bins = _sanitize_levels(norm(levels)) # e.g. SegmentedNorm
24012392
vcenter = getattr(norm, 'vcenter', None)
2402-
bins, _ = _sanitize_levels(norm(levels), allow_descending=False)
2393+
vmin = norm.vmin = np.min(levels)
2394+
vmax = norm.vmax = np.max(levels)
24032395

24042396
# Get color coordinates for each bin, plus two extra for out-of-bounds
24052397
# For same out-of-bounds colors, looks like [0 - eps, 0, ..., 1, 1 + eps]
@@ -2420,21 +2412,16 @@ def __init__(
24202412
if unique in ('max', 'both'):
24212413
mids[-1] += step * (mids[-2] - mids[-3])
24222414
if vcenter is None:
2423-
mids = _interpolate_basic(
2424-
mids, np.min(mids), np.max(mids), vmin, vmax
2425-
)
2415+
mids = _interpolate_basic(mids, np.min(mids), np.max(mids), vmin, vmax)
24262416
else:
24272417
mids = mids.copy()
2428-
mids[mids < vcenter] = _interpolate_basic(
2429-
mids[mids < vcenter], np.min(mids), vcenter, vmin, vcenter,
2430-
)
2431-
mids[mids >= vcenter] = _interpolate_basic(
2432-
mids[mids >= vcenter], vcenter, np.max(mids), vcenter, vmax,
2433-
)
2434-
eps = 1e-10 # mids and dest are numpy.float64
2418+
ipts = (np.min(mids), vcenter, vmin, vcenter)
2419+
mids[mids < vcenter] = _interpolate_basic(mids[mids < vcenter], *ipts)
2420+
ipts = (vcenter, np.max(mids), vcenter, vmax)
2421+
mids[mids >= vcenter] = _interpolate_basic(mids[mids >= vcenter], *ipts)
24352422
dest = norm(mids)
2436-
dest[0] -= eps
2437-
dest[-1] += eps
2423+
dest[0] -= 1e-10 # dest guaranteed to be numpy.float64
2424+
dest[-1] += 1e-10
24382425

24392426
# Attributes
24402427
# NOTE: If clip is True, we clip values to the centers of the end bins
@@ -2443,12 +2430,12 @@ def __init__(
24432430
# NOTE: With unique='min' the minimimum in-bounds and out-of-bounds
24442431
# colors are the same so clip=True will have no effect. Same goes
24452432
# for unique='max' with maximum colors.
2433+
self._descending = descending_levels or descending_bins
24462434
self._bmin = np.min(mids)
24472435
self._bmax = np.max(mids)
24482436
self._bins = bins
24492437
self._dest = dest
24502438
self._norm = norm
2451-
self._descending = descending
24522439
self.vmin = vmin
24532440
self.vmax = vmax
24542441
self.boundaries = levels
@@ -2489,6 +2476,8 @@ def __call__(self, value, clip=None):
24892476
yq = ma.array(yq, mask=ma.getmask(xq))
24902477
if is_scalar:
24912478
yq = np.atleast_1d(yq)[0]
2479+
if self.descending:
2480+
yq = 1 - yq
24922481
return yq
24932482

24942483
def inverse(self, value): # noqa: U100
@@ -2500,7 +2489,7 @@ def inverse(self, value): # noqa: U100
25002489
@property
25012490
def descending(self):
25022491
"""
2503-
Whether the colormap levels are descending.
2492+
Whether the normalizer levels are descending.
25042493
"""
25052494
return self._descending
25062495

@@ -2515,7 +2504,7 @@ def __init__(self, levels, vmin=None, vmax=None, clip=False):
25152504
Parameters
25162505
----------
25172506
levels : sequence of float
2518-
The level boundaries. Must be monotonically increasing.
2507+
The level boundaries. Must be monotonically increasing or decreasing.
25192508
vmin, vmax : None
25202509
Ignored. These are set to the minimum and maximum of `levels`.
25212510
clip : bool, optional
@@ -2541,11 +2530,11 @@ def __init__(self, levels, vmin=None, vmax=None, clip=False):
25412530
>>> fig, ax = pplt.subplots()
25422531
>>> ax.contourf(data, levels=levels)
25432532
"""
2544-
levels = np.asarray(levels)
2545-
levels, _ = _sanitize_levels(levels, allow_descending=False)
2533+
levels, descending = _sanitize_levels(levels)
25462534
dest = np.linspace(0, 1, len(levels))
25472535
vmin, vmax = np.min(levels), np.max(levels)
25482536
super().__init__(vmin=vmin, vmax=vmax, clip=clip)
2537+
self._descending = descending
25492538
self._x = self.boundaries = levels # we use 'boundaries' in plot wrapper
25502539
self._y = dest
25512540

@@ -2570,6 +2559,8 @@ def __call__(self, value, clip=None):
25702559
yq = _interpolate_extrapolate(xq, self._x, self._y)
25712560
if is_scalar:
25722561
yq = np.atleast_1d(yq)[0]
2562+
if self.descending:
2563+
yq = 1 - yq
25732564
return yq
25742565

25752566
def inverse(self, value):
@@ -2587,6 +2578,13 @@ def inverse(self, value):
25872578
xq = np.atleast_1d(xq)[0]
25882579
return xq
25892580

2581+
@property
2582+
def descending(self):
2583+
"""
2584+
Whether the normalizer levels are descending.
2585+
"""
2586+
return self._descending
2587+
25902588

25912589
class DivergingNorm(mcolors.Normalize):
25922590
"""

0 commit comments

Comments
 (0)