Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ ProPlot v1.0.0 (2020-##-##)
This will be published when some major refactoring tasks are completed.
See :pr:`45`, :pr:`46`, and :pr:`50`.

ProPlot v0.2.5 (2019-12-07)
===========================
Features
--------
- Much better `~proplot.axistools.CutoffScale` algorithm, permit arbitrary
cutoffs (:pr:`83`).


ProPlot v0.2.4 (2019-12-07)
===========================
Deprecated
Expand Down
25 changes: 15 additions & 10 deletions docs/axis.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
"raw_mimetype": "text/restructuredtext"
},
"source": [
"ProPlot also adds several new axis scales. The ``'cutoff'`` scale is great when you have weirdly distributed data (see `~proplot.axistools.CutoffScale`). The ``'sine'`` scale scales the axis as the sine of the coordinate, resulting in an \"area-weighted\" spherical latitude coordinate. The ``'inverse'`` scale is perfect for labeling spectral coordinates (this is more useful with the `~proplot.axes.XYAxes.dualx` and `~proplot.axes.XYAxes.dualy` commands; see :ref:`Dual unit axes`)."
"ProPlot also adds several new axis scales. The ``'cutoff'`` scale is great when you have weirdly distributed data (see `~proplot.axistools.CutoffScale`). The ``'sine'`` scale scales the axis as the sine of the coordinate, resulting in an \"area-weighted\" spherical latitude coordinate, and the ``'Mercator'`` scale scales the axis as with the Mercator projection latitude coordinate. The ``'inverse'`` scale is perfect for working with spectral data (this is more useful with the `~proplot.axes.XYAxes.dualx` and `~proplot.axes.XYAxes.dualy` commands; see :ref:`Dual unit axes`)."
]
},
{
Expand All @@ -213,20 +213,25 @@
"y2 = np.cos(x)\n",
"state = np.random.RandomState(51423)\n",
"data = state.rand(len(dy)-1, len(x)-1)\n",
"scales = [(3, np.pi), (0.3, 3*np.pi),\n",
" (np.inf, np.pi, 2*np.pi), (5, np.pi, 2*np.pi)]\n",
"titles = ('Zoom out of left', 'Zoom into left', 'Discrete cutoff', 'Fast jump')\n",
"locators = [np.pi/3, np.pi/3, *\n",
" ([x*np.pi for x in plot.arange(0, 4, 0.25) if not (1 < x <= 2)] for i in range(2))]\n",
"for ax, scale, title, locator in zip(axs, scales, titles, locators):\n",
"titles = ('Zoom out of left', 'Zoom into left', 'Discrete jump', 'Fast jump')\n",
"args = [\n",
" (np.pi, 3), # speed up\n",
" (3*np.pi, 1/3), # slow down\n",
" (np.pi, np.inf, 3*np.pi), # discrete jump\n",
" (np.pi, 5, 3*np.pi) # fast jump\n",
"]\n",
"locators = (\n",
" 2*[np.pi/3]\n",
" + 2*[[*np.linspace(0, 1, 4) * np.pi, *(np.linspace(0, 1, 4) * np.pi + 3*np.pi)]]\n",
")\n",
"for ax, iargs, title, locator in zip(axs, args, titles, locators):\n",
" ax.pcolormesh(x, dy, data, cmap='grays', cmap_kw={'right': 0.8})\n",
" for y, color in zip((y1, y2), ('coral', 'sky blue')):\n",
" ax.plot(x, y, lw=4, color=color)\n",
" ax.format(xscale=('cutoff', *scale), title=title,\n",
" ax.format(xscale=('cutoff', *iargs), title=title,\n",
" xlim=(0, 4*np.pi), ylabel='wave amplitude',\n",
" xformatter='pi', xlocator=locator,\n",
" xtickminor=False, xgrid=True, ygrid=False, suptitle='Demo of cutoff scales')\n",
"plot.rc.reset()"
" xtickminor=False, xgrid=True, ygrid=False, suptitle='Demo of cutoff scales')"
]
},
{
Expand Down
240 changes: 87 additions & 153 deletions proplot/axistools.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,8 +752,6 @@ def __init__(self, functions, transform=None, scale=None,


class FuncTransform(mtransforms.Transform):
# Arbitrary forward and inverse transform
# Mostly copied from matplotlib
input_dims = 1
output_dims = 1
is_separable = True
Expand Down Expand Up @@ -909,7 +907,6 @@ def limit_range_for_scale(self, vmin, vmax, minpos):


class ExpTransform(mtransforms.Transform):
# Arbitrary exponential function
input_dims = 1
output_dims = 1
has_inverse = True
Expand All @@ -933,7 +930,6 @@ def transform_non_affine(self, a):


class InvertedExpTransform(mtransforms.Transform):
# Inverse exponential transform
input_dims = 1
output_dims = 1
has_inverse = True
Expand All @@ -958,150 +954,6 @@ def transform_non_affine(self, a):
return self.transform(a)


class CutoffScale(_ScaleBase, mscale.ScaleBase):
"""Axis scale with arbitrary cutoffs that "accelerate" parts of the
axis, "decelerate" parts of the axes, or discretely jumps between
numbers.

If `upper` is not provided, you have the following two possibilities.

1. If `scale` is greater than 1, the axis is "accelerated" to the right
of `lower`.
2. If `scale` is less than 1, the axis is "decelerated" to the right
of `lower`.

If `upper` is provided, you have the following three possibilities.

1. If `scale` is `numpy.inf`, this puts a cliff between `lower` and
`upper`. The axis discretely jumps from `lower` to `upper`.
2. If `scale` is greater than 1, the axis is "accelerated" between `lower`
and `upper`.
3. If `scale` is less than 1, the axis is "decelerated" between `lower`
and `upper`.
"""
name = 'cutoff'
"""The registered scale name."""

def __init__(self, scale, lower, upper=None, **kwargs):
"""
Parameters
----------
scale : float
Value satisfying ``0 < scale <= numpy.inf``. If `scale` is
greater than ``1``, values to the right of `lower`, or
between `lower` and `upper`, are "accelerated". Otherwise, values
are "decelerated". Infinity represents a discrete jump.
lower : float
The first cutoff point.
upper : float, optional
The second cutoff point (optional, see above).

Todo
----
Add method for drawing diagonal "cutoff" strokes. See
`this post <https://stackoverflow.com/a/5669301/4970632>`__
for class-based and multi-axis solutions.
"""
# Note the space between 1-9 in Paul's answer is because actual
# cutoffs were 0.1 away (and tick locations are 0.2 apart).
if scale < 0:
raise ValueError('Scale must be a positive float.')
if upper is None and scale == np.inf:
raise ValueError(
'For a discrete jump, need both lower and upper bounds. '
'You just provided lower bounds.')
super().__init__()
self._transform = CutoffTransform(scale, lower, upper)


class CutoffTransform(mtransforms.Transform):
# Create transform object
input_dims = 1
output_dims = 1
has_inverse = True
is_separable = True

def __init__(self, scale, lower, upper=None):
super().__init__()
self._scale = scale
self._lower = lower
self._upper = upper

def inverted(self):
return InvertedCutoffTransform(self._scale, self._lower, self._upper)

def transform(self, a):
a = np.array(a) # very numpy array
aa = a.copy()
scale = self._scale
lower = self._lower
upper = self._upper
if upper is None: # just scale between 2 segments
m = (a > lower)
aa[m] = a[m] - (a[m] - lower) * (1 - 1 / scale)
elif lower is None:
m = (a < upper)
aa[m] = a[m] - (upper - a[m]) * (1 - 1 / scale)
else:
m1 = (a > lower)
m2 = (a > upper)
m3 = (a > lower) & (a < upper)
if scale == np.inf:
aa[m1] = a[m1] - (upper - lower)
aa[m3] = lower
else:
aa[m2] = a[m2] - (upper - lower) * (1 - 1 / scale)
aa[m3] = a[m3] - (a[m3] - lower) * (1 - 1 / scale)
return aa

def transform_non_affine(self, a):
return self.transform(a)


class InvertedCutoffTransform(mtransforms.Transform):
# Inverse of cutoff transform
input_dims = 1
output_dims = 1
has_inverse = True
is_separable = True

def __init__(self, scale, lower, upper=None):
super().__init__()
self._scale = scale
self._lower = lower
self._upper = upper

def inverted(self):
return CutoffTransform(self._scale, self._lower, self._upper)

def transform(self, a):
a = np.array(a)
aa = a.copy()
scale = self._scale
lower = self._lower
upper = self._upper
if upper is None:
m = (a > lower)
aa[m] = a[m] + (a[m] - lower) * (1 - 1 / scale)
elif lower is None:
m = (a < upper)
aa[m] = a[m] + (upper - a[m]) * (1 - 1 / scale)
else:
n = (upper - lower) * (1 - 1 / scale)
m1 = (a > lower)
m2 = (a > upper - n)
m3 = (a > lower) & (a < (upper - n))
if scale == np.inf:
aa[m1] = a[m1] + (upper - lower)
else:
aa[m2] = a[m2] + n
aa[m3] = a[m3] + (a[m3] - lower) * (1 - 1 / scale)
return aa

def transform_non_affine(self, a):
return self.transform(a)


class MercatorLatitudeScale(_ScaleBase, mscale.ScaleBase):
"""
Scales axis as with latitude in the `Mercator projection \
Expand Down Expand Up @@ -1147,7 +999,6 @@ def limit_range_for_scale(self, vmin, vmax, minpos):


class MercatorLatitudeTransform(mtransforms.Transform):
# Default attributes
input_dims = 1
output_dims = 1
is_separable = True
Expand All @@ -1172,7 +1023,6 @@ def transform_non_affine(self, a):


class InvertedMercatorLatitudeTransform(mtransforms.Transform):
# As above, but for the inverse transform
input_dims = 1
output_dims = 1
is_separable = True
Expand Down Expand Up @@ -1222,14 +1072,12 @@ def limit_range_for_scale(self, vmin, vmax, minpos):


class SineLatitudeTransform(mtransforms.Transform):
# Default attributes
input_dims = 1
output_dims = 1
is_separable = True
has_inverse = True

def __init__(self):
# Initialize, declare attribute
super().__init__()

def inverted(self):
Expand All @@ -1248,7 +1096,6 @@ def transform_non_affine(self, a):


class InvertedSineLatitudeTransform(mtransforms.Transform):
# Inverse of SineLatitudeTransform
input_dims = 1
output_dims = 1
is_separable = True
Expand All @@ -1267,6 +1114,93 @@ def transform_non_affine(self, a):
return np.rad2deg(np.arcsin(aa))


class CutoffScale(_ScaleBase, mscale.ScaleBase):
"""
Axis scale with arbitrary successive thresholds between which are
discrete jumps, "accelerations", or "decelerations". Adapted from `this \
stackoverflow post <https://stackoverflow.com/a/5669301/4970632>`__.
"""
name = 'cutoff'
"""The registered scale name."""

def __init__(self, *args, **kwargs):
"""
Parameters
----------
*args : (thresh_1, scale_1, ..., thresh_N, [scale_N]), optional
Sequence of thresholds and scales. If the final scale is omitted
(i.e. you passed an odd number of args) it is set to ``1``.

* If ``scale_i < 1``, the axis is decelerated from ``thresh_i`` to
``thresh_i+1`` or, if ``i == N``, everywhere above ``thresh_i``.
* If ``scale_i > 1``, the axis is accelerated from ``thresh_i`` to
``thresh_i+1`` or, if ``i == N``, everywhere above ``thresh_i``.
* If ``scale_i == np.inf``, the axis *discretely jumps* from
``thresh_i`` to ``thresh_i+1``.

Example
-------

>>> import proplot as plot
... import numpy as np
... thresh = plot.CutoffScale(10, 2) # go "twice as fast" after 10
... skip = plot.CutoffScale(10, 0.5, 20) # zoom in between 10 and 20
... jump = plot.CutoffScale(10, np.inf, 20) # jump from 10 to 20

"""
super().__init__()
args = list(args)
if len(args) % 2 == 1:
args.append(1)
threshs = args[::2]
scales = args[1::2]
self._transform = CutoffTransform(threshs, scales)


class CutoffTransform(mtransforms.Transform):
input_dims = 1
output_dims = 1
has_inverse = True
is_separable = True

def __init__(self, threshs, scales):
super().__init__()
if any(np.diff(threshs) <= 0):
raise ValueError(f'Thresholds must be monotonically increasing.')
if any(np.asarray(scales) < 0):
raise ValueError(f'Scales must be greater than or equal to zero.')
self._threshs = threshs
self._scales = scales
with np.errstate(divide='ignore'):
self._dists = np.concatenate((
threshs[:1], np.diff(threshs) / scales[:-1]))

def inverted(self):
# Use same algorithm for inversion!
scales = self._scales
dists = self._dists
threshs = np.cumsum(dists) # thresholds in transformed space
with np.errstate(divide='ignore'):
scales = 1 / np.array(scales) # new scales are just inverse
return CutoffTransform(threshs, scales)

def transform(self, a):
a = np.atleast_1d(a)
threshs = self._threshs
scales = self._scales
dists = self._dists
idxs = np.searchsorted(threshs, a) # array of indices
with np.errstate(divide='ignore'):
return np.array([
ai if i == 0 else
dists[:i].sum() + (ai - threshs[i - 1]) / scales[i - 1]
for i, ai in zip(idxs, a)
])

def transform_non_affine(self, a):
return self.transform(a)


class InverseScale(_ScaleBase, mscale.ScaleBase):
r"""
Scales axis to be linear in the *inverse* of *x*. The scale
Expand Down