diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 09b99c145..81b8b0ef8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 diff --git a/docs/axis.ipynb b/docs/axis.ipynb index 1619196c5..77c90298d 100644 --- a/docs/axis.ipynb +++ b/docs/axis.ipynb @@ -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`)." ] }, { @@ -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')" ] }, { diff --git a/proplot/axistools.py b/proplot/axistools.py index c3e3d5345..1af54c989 100644 --- a/proplot/axistools.py +++ b/proplot/axistools.py @@ -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 @@ -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 @@ -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 @@ -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 `__ - 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 \ @@ -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 @@ -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 @@ -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): @@ -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 @@ -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 `__. + """ + 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