Skip to content

Commit

Permalink
Implement 'descending levs' support in normalizers instead of plot.py
Browse files Browse the repository at this point in the history
  • Loading branch information
lukelbd committed Sep 19, 2021
1 parent ec1f841 commit 46d8bed
Show file tree
Hide file tree
Showing 2 changed files with 69 additions and 81 deletions.
74 changes: 32 additions & 42 deletions proplot/axes/plot.py
Expand Up @@ -2253,22 +2253,18 @@ def _parse_levels(
warnings._warn_proplot(
f'Incompatible args levels={levels!r} and values={values!r}. Using former.' # noqa: E501
)
for key, val in (('levels', levels), ('values', values)):
if val is None:
for key, points in (('levels', levels), ('values', values)):
if points is None:
continue
if isinstance(norm, (mcolors.BoundaryNorm, pcolors.SegmentedNorm)):
warnings._warn_proplot(
f'Ignoring {key}={val}. Instead using norm={norm!r} boundaries.'
f'Ignoring {key}={points}. Instead using norm={norm!r} boundaries.'
)
if not np.iterable(val):
if not np.iterable(points):
continue
if len(val) < min_levels:
if len(points) < min_levels:
raise ValueError(
f'Invalid {key}={val}. Must be at least length {min_levels}.'
)
if len(val) >= 2 and np.any(np.sign(np.diff(val)) != np.sign(val[1] - val[0])): # noqa: E501
raise ValueError(
f'Invalid {key}={val}. Must be monotonically increasing or decreasing.' # noqa: E501
f'Invalid {key}={points}. Must be at least length {min_levels}.'
)
if isinstance(norm, (mcolors.BoundaryNorm, pcolors.SegmentedNorm)):
levels, values = norm.boundaries, None
Expand All @@ -2278,46 +2274,47 @@ def _parse_levels(
# Infer level edges from level centers if possible
# NOTE: The only way for user to manually impose BoundaryNorm is by
# passing one -- users cannot create one using Norm constructor key.
descending = None
if values is None:
pass
elif isinstance(values, Integral):
if isinstance(values, Integral):
levels = values + 1
elif np.iterable(values) and len(values) == 1:
elif values is None:
pass
elif not np.iterable(values):
raise ValueError(f'Invalid values={values!r}.')
elif len(values) == 0:
levels = [] # weird but why not
elif len(values) == 1:
levels = [values[0] - 1, values[0] + 1] # weird but why not
elif norm is None or norm in ('segments', 'segmented'):
elif norm is not None and norm not in ('segments', 'segmented'):
# Generate levels by finding in-between points in the
# normalized numeric space, e.g. LogNorm space.
norm_kw = norm_kw or {}
convert = constructor.Norm(norm, **norm_kw)
levels = convert.inverse(utils.edges(convert(values)))
else:
# Try to generate levels so SegmentedNorm will place 'values' ticks at the
# center of each segment. edges() gives wrong result unless spacing is even.
# Solve: (x1 + x2) / 2 = y --> x2 = 2 * y - x1 with arbitrary starting x1.
values, descending = pcolors._sanitize_levels(values)
levels = [values[0] - (values[1] - values[0]) / 2] # arbitrary x1
for val in values:
levels.append(2 * val - levels[-1])
if any(np.diff(levels) < 0): # backup plan in event of weird ticks
descending = values[1] < values[0]
if descending: # e.g. [100, 50, 20, 10, 5, 2, 1] successful if reversed
values = values[::-1]
levels = [1.5 * values[0] - 0.5 * values[1]] # arbitrary starting point
for value in values:
levels.append(2 * value - levels[-1])
if np.any(np.diff(levels) < 0):
levels = utils.edges(values)
if descending: # then revert back below
levels = levels[::-1]
else:
# Generate levels by finding in-between points in the
# normalized numeric space, e.g. LogNorm space.
norm_kw = norm_kw or {}
convert = constructor.Norm(norm, **norm_kw)
levels = convert.inverse(utils.edges(convert(values)))

# Process level edges and infer defaults
# NOTE: Matplotlib colorbar algorithm *cannot* handle descending levels so
# this function reverses them and adds special attribute to the normalizer.
# Then colorbar() reads this attr and flips the axis and the colormap direction
if np.iterable(levels) and len(levels) > 2:
levels, descending = pcolors._sanitize_levels(levels)
if not np.iterable(levels) and not skip_autolev:
levels, kwargs = self._parse_autolev(
*args, levels=levels, vmin=vmin, vmax=vmax,
norm=norm, norm_kw=norm_kw, extend=extend, **kwargs
)
ticks = values if np.iterable(values) else levels
if descending is not None:
kwargs.setdefault('descending', descending) # for _parse_discrete
if ticks is not None and np.iterable(ticks):
guides._guide_kw_to_arg('colorbar', kwargs, locator=ticks)

Expand All @@ -2332,8 +2329,7 @@ def _parse_levels(
return levels, kwargs

def _parse_discrete(
self, levels, norm, cmap, *,
extend=None, descending=False, min_levels=None, **kwargs,
self, levels, norm, cmap, *, extend=None, min_levels=None, **kwargs,
):
"""
Create a `~proplot.colors.DiscreteNorm` or `~proplot.colors.BoundaryNorm`
Expand All @@ -2349,8 +2345,6 @@ def _parse_discrete(
The colormap.
extend : str, optional
The extend setting.
descending : bool, optional
Whether levels are descending.
min_levels : int, optional
The minimum number of levels.
Expand All @@ -2371,8 +2365,6 @@ def _parse_discrete(
over = cmap._rgba_over
cyclic = getattr(cmap, '_cyclic', None)
qualitative = isinstance(cmap, pcolors.DiscreteColormap) # see _parse_cmap
if descending:
cmap = cmap.reversed()
if len(levels) < min_levels:
raise ValueError(
f'Invalid levels={levels!r}. Must be at least length {min_levels}.'
Expand Down Expand Up @@ -2424,9 +2416,7 @@ def _parse_discrete(
# Generate DiscreteNorm and update "child" norm with vmin and vmax from
# levels. This lets the colorbar set tick locations properly!
if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1:
norm = pcolors.DiscreteNorm(
levels, norm=norm, descending=descending, unique=unique, step=step,
)
norm = pcolors.DiscreteNorm(levels, norm=norm, unique=unique, step=step)

return norm, cmap, kwargs

Expand Down Expand Up @@ -2552,15 +2542,15 @@ def _parse_cmap(
# NOTE: We create normalizer here only because auto level generation depends
# on the normalizer class (e.g. LogNorm). We don't have to worry about vmin
# and vmax because they get applied to normalizer inside DiscreteNorm.
if norm is None and levels is not None and len(levels) > 0:
if levels is not None and len(levels) > 0:
if len(levels) == 1: # edge case, use central colormap color
vmin = _not_none(vmin, levels[0] - 1)
vmax = _not_none(vmax, levels[0] + 1)
else:
vmin, vmax = np.min(levels), np.max(levels)
diffs = np.diff(levels)
if not np.allclose(diffs[0], diffs):
norm = 'segmented'
norm = _not_none(norm, 'segmented')
if norm in ('segments', 'segmented'):
if np.iterable(levels):
norm_kw['levels'] = levels # apply levels
Expand Down
76 changes: 37 additions & 39 deletions proplot/colors.py
Expand Up @@ -2291,10 +2291,9 @@ def _interpolate_extrapolate(xq, x, y):
return yq


def _sanitize_levels(levels, allow_descending=True):
def _sanitize_levels(levels):
"""
Ensure the levels are monotonic. If they are descending, either
reverse them or raise an error.
Ensure the levels are monotonic. If they are descending, reverse them.
"""
levels = np.atleast_1d(levels)
if levels.ndim != 1 or levels.size < 2:
Expand All @@ -2304,14 +2303,11 @@ def _sanitize_levels(levels, allow_descending=True):
if not np.all(np.isfinite(levels)):
raise ValueError(f'Levels {levels} contain invalid values.')
diffs = np.sign(np.diff(levels))
if all(diffs == 1):
if np.all(diffs == 1):
descending = False
elif all(diffs == -1):
elif np.all(diffs == -1):
descending = True
if allow_descending:
levels = levels[::-1]
else:
raise ValueError(f'Levels {levels} must be monotonically increasing.')
levels = levels[::-1]
else:
raise ValueError(f'Levels {levels} must be monotonic.')
return levels, descending
Expand All @@ -2326,15 +2322,15 @@ class DiscreteNorm(mcolors.BoundaryNorm):
# WARNING: Must be child of BoundaryNorm. Many methods in ColorBarBase
# test for class membership, crucially including _process_values(), which
# if it doesn't detect BoundaryNorm will try to use DiscreteNorm.inverse().
@warnings._rename_kwargs('0.7', extend='unique')
def __init__(
self, levels, norm=None, unique=None, step=None, clip=False, descending=False,
):
@warnings._rename_kwargs(
'0.7', extend='unique', descending='DiscreteNorm(descending_levels)'
)
def __init__(self, levels, norm=None, unique=None, step=None, clip=False):
"""
Parameters
----------
levels : sequence of float
The level boundaries.
The level boundaries. Must be monotonically increasing or decreasing.
norm : `~matplotlib.colors.Normalize`, optional
The normalizer used to transform `levels` and data values passed to
`~DiscreteNorm.__call__` before discretization. The ``vmin`` and ``vmax``
Expand All @@ -2355,10 +2351,6 @@ def __init__(
Whether to clip values falling outside of the level bins. This only
has an effect on lower colors when unique is ``'min'`` or ``'both'``,
and on upper colors when unique is ``'max'`` or ``'both'``.
descending : bool, optional
Whether the levels are meant to be descending. This will cause
the colorbar axis to be reversed when it is drawn with a
`~matplotlib.cm.ScalarMappable` that uses this normalizer.
Note
----
Expand Down Expand Up @@ -2395,11 +2387,11 @@ def __init__(
)

# Ensure monotonicaly increasing levels and add built-in attributes
levels, _ = _sanitize_levels(levels, allow_descending=False)
norm.vmin = vmin = np.min(levels)
norm.vmax = vmax = np.max(levels)
levels, descending_levels = _sanitize_levels(levels)
bins, descending_bins = _sanitize_levels(norm(levels)) # e.g. SegmentedNorm
vcenter = getattr(norm, 'vcenter', None)
bins, _ = _sanitize_levels(norm(levels), allow_descending=False)
vmin = norm.vmin = np.min(levels)
vmax = norm.vmax = np.max(levels)

# Get color coordinates for each bin, plus two extra for out-of-bounds
# For same out-of-bounds colors, looks like [0 - eps, 0, ..., 1, 1 + eps]
Expand All @@ -2420,21 +2412,16 @@ def __init__(
if unique in ('max', 'both'):
mids[-1] += step * (mids[-2] - mids[-3])
if vcenter is None:
mids = _interpolate_basic(
mids, np.min(mids), np.max(mids), vmin, vmax
)
mids = _interpolate_basic(mids, np.min(mids), np.max(mids), vmin, vmax)
else:
mids = mids.copy()
mids[mids < vcenter] = _interpolate_basic(
mids[mids < vcenter], np.min(mids), vcenter, vmin, vcenter,
)
mids[mids >= vcenter] = _interpolate_basic(
mids[mids >= vcenter], vcenter, np.max(mids), vcenter, vmax,
)
eps = 1e-10 # mids and dest are numpy.float64
ipts = (np.min(mids), vcenter, vmin, vcenter)
mids[mids < vcenter] = _interpolate_basic(mids[mids < vcenter], *ipts)
ipts = (vcenter, np.max(mids), vcenter, vmax)
mids[mids >= vcenter] = _interpolate_basic(mids[mids >= vcenter], *ipts)
dest = norm(mids)
dest[0] -= eps
dest[-1] += eps
dest[0] -= 1e-10 # dest guaranteed to be numpy.float64
dest[-1] += 1e-10

# Attributes
# NOTE: If clip is True, we clip values to the centers of the end bins
Expand All @@ -2443,12 +2430,12 @@ def __init__(
# NOTE: With unique='min' the minimimum in-bounds and out-of-bounds
# colors are the same so clip=True will have no effect. Same goes
# for unique='max' with maximum colors.
self._descending = descending_levels or descending_bins
self._bmin = np.min(mids)
self._bmax = np.max(mids)
self._bins = bins
self._dest = dest
self._norm = norm
self._descending = descending
self.vmin = vmin
self.vmax = vmax
self.boundaries = levels
Expand Down Expand Up @@ -2489,6 +2476,8 @@ def __call__(self, value, clip=None):
yq = ma.array(yq, mask=ma.getmask(xq))
if is_scalar:
yq = np.atleast_1d(yq)[0]
if self.descending:
yq = 1 - yq
return yq

def inverse(self, value): # noqa: U100
Expand All @@ -2500,7 +2489,7 @@ def inverse(self, value): # noqa: U100
@property
def descending(self):
"""
Whether the colormap levels are descending.
Whether the normalizer levels are descending.
"""
return self._descending

Expand All @@ -2515,7 +2504,7 @@ def __init__(self, levels, vmin=None, vmax=None, clip=False):
Parameters
----------
levels : sequence of float
The level boundaries. Must be monotonically increasing.
The level boundaries. Must be monotonically increasing or decreasing.
vmin, vmax : None
Ignored. These are set to the minimum and maximum of `levels`.
clip : bool, optional
Expand All @@ -2541,11 +2530,11 @@ def __init__(self, levels, vmin=None, vmax=None, clip=False):
>>> fig, ax = pplt.subplots()
>>> ax.contourf(data, levels=levels)
"""
levels = np.asarray(levels)
levels, _ = _sanitize_levels(levels, allow_descending=False)
levels, descending = _sanitize_levels(levels)
dest = np.linspace(0, 1, len(levels))
vmin, vmax = np.min(levels), np.max(levels)
super().__init__(vmin=vmin, vmax=vmax, clip=clip)
self._descending = descending
self._x = self.boundaries = levels # we use 'boundaries' in plot wrapper
self._y = dest

Expand All @@ -2570,6 +2559,8 @@ def __call__(self, value, clip=None):
yq = _interpolate_extrapolate(xq, self._x, self._y)
if is_scalar:
yq = np.atleast_1d(yq)[0]
if self.descending:
yq = 1 - yq
return yq

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

@property
def descending(self):
"""
Whether the normalizer levels are descending.
"""
return self._descending


class DivergingNorm(mcolors.Normalize):
"""
Expand Down

0 comments on commit 46d8bed

Please sign in to comment.