diff --git a/doc/conf.py b/doc/conf.py index 571def2e02c1..366c894ab9df 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -43,6 +43,7 @@ extensions.append('matplotlib.sphinxext.ipython_console_highlighting') else: print("Using IPython's ipython_console_highlighting directive") + extensions.append('IPython.sphinxext.ipython_directive') extensions.append('IPython.sphinxext.ipython_console_highlighting') try: diff --git a/doc/users/cycler.rst b/doc/users/cycler.rst new file mode 100644 index 000000000000..8dd761884fbf --- /dev/null +++ b/doc/users/cycler.rst @@ -0,0 +1,285 @@ +.. _cycler_guide: +.. currentmodule:: matplotlib.cycler + +========================== + Style/kwarg cycler Guide +========================== + +`~matplotlib.cycler.Cycler` API +=============================== + +.. autosummary:: + :toctree: generated/ + + cycler + Cycler + + +The public API of `Cycler` consists of a class +`~matplotlib.cycler.Cycler` and a factory function +`~matplotlib.cycler.cycler`. The class takes care of the composition +and iteration logic while the function provides a simple interface for +creating 'base' `Cycler` objects. + + +Motivation +========== + + +When plotting more than one line it is common to want to be able to cycle over one +or more artist styles. For simple cases than can be done with out too much trouble: + +.. plot:: + :include-source: + + fig, ax = plt.subplots(tight_layout=True) + x = np.linspace(0, 2*np.pi, 1024) + + for i, (lw, c) in enumerate(zip(range(4), ['r', 'g', 'b', 'k'])): + ax.plot(x, np.sin(x - i * np.pi / 4), + label=r'$\phi = {{{0}}} \pi / 4$'.format(i), + lw=lw + 1, + c=c) + + ax.set_xlim([0, 2*np.pi]) + ax.set_title(r'$y=\sin(\theta + \phi)$') + ax.set_ylabel(r'[arb]') + ax.set_xlabel(r'$\theta$ [rad]') + + ax.legend(loc=0) + +However, if you want to do something more complicated: + +.. plot:: + :include-source: + + fig, ax = plt.subplots(tight_layout=True) + x = np.linspace(0, 2*np.pi, 1024) + + for i, (lw, c) in enumerate(zip(range(4), ['r', 'g', 'b', 'k'])): + if i % 2: + ls = '-' + else: + ls = '--' + ax.plot(x, np.sin(x - i * np.pi / 4), + label=r'$\phi = {{{0}}} \pi / 4$'.format(i), + lw=lw + 1, + c=c, + ls=ls) + + ax.set_xlim([0, 2*np.pi]) + ax.set_title(r'$y=\sin(\theta + \phi)$') + ax.set_ylabel(r'[arb]') + ax.set_xlabel(r'$\theta$ [rad]') + + ax.legend(loc=0) + +the plotting logic can quickly become very involved. To address this and allow easy +cycling over arbitrary ``kwargs`` the `~matplotlib.cycler.Cycler` class, a composable +kwarg iterator, was developed. + +`Cycler` Usage +============== + +Basic +----- + +A 'base' `Cycler` object is somewhat useful and can be used to easily +cycle over a single style. To create a base `Cycler` use the `cycler` +function to link a key/style/kwarg to series of values. The key can be +any hashable object (as it will eventually be used as the key in a `dict`). + +.. ipython:: python + + from __future__ import print_function + from matplotlib.cycler import cycler + + + color_cycle = cycler('color', ['r', 'g', 'b']) + color_cycle + +The `Cycler` object knows it's length and keys: + +.. ipython:: python + + + len(color_cycle) + color_cycle.keys + +Iterating over this object will yield a series of `dicts` keyed on +the key with a single value from the series + +.. ipython:: python + + for v in color_cycle: + print(v) + +Basic `Cycler` objects can be passed as the second argument to `cycler` +which is copy cyclers to a new key. + +.. ipython:: python + + cycler('ec', color_cycle) + + +Composition +----------- + +A single `Cycler` is not all that useful, they can just as easily be +replaced by a single `for` loop. Fortunately, `Cycler` objects can be +composed to easily generate complex, multi-key cycles. + +Addition +~~~~~~~~ + +Equal length `Cycler` s with different keys can be added to get the +'inner' product of two cycles + +.. ipython:: python + + lw_cycle = cycler('lw', range(1, 4)) + + wc = lw_cycle + color_cycle + +The result has the same length and has keys which are the union of the +two input `Cycler` s. + +.. ipython:: python + + len(wc) + wc.keys + +and iterating over the result is the zip of the two input cycles + +.. ipython:: python + + for s in wc: + print(s) + +As with arithmetic, addition is commutative + +.. ipython:: python + + for a, b in zip(lw_cycle + color_cycle, color_cycle + lw_cycle): + print(a == b) + + +Multiplication +~~~~~~~~~~~~~~ + +Any pair of `Cycler` can be multiplied + +.. ipython:: python + + m_cycle = cycler('marker', ['s', 'o']) + + m_c = m_cycle * color_cycle + +which gives the 'outer product' of the two cycles (same as +:func:`itertools.prod` ) + +.. ipython:: python + + len(m_c) + m_c.keys + for s in m_c: + print(s) + +Note that unlike addition, multiplication is not commutative (like +matrices) + +.. ipython:: python + + c_m = color_cycle * m_cycle + for a, b in zip(c_m, m_c): + print(a, b) + + + + +Integer Multiplication +~~~~~~~~~~~~~~~~~~~~~~ + +`Cycler` s can also be multiplied by integer values to increase the length. + +.. ipython:: python + + color_cycle * 2 + 2 * color_cycle + + + +Slicing +------- + +Cycles can be sliced with `silce` objects + +.. ipython:: python + + color_cycle[::-1] + color_cycle[:2] + color_cycle[1:] + +to return a sub-set of the cycle as a new `Cycler`. They can also be multiplied +by scalars to make fixed length periodic cycles + +Examples +-------- + + +.. plot:: + :include-source: + + from matplotlib.cycler import cycler + from itertools import cycle + + fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True, figsize=(8, 4)) + x = np.arange(10) + + color_cycle = cycler('c', ['r', 'g', 'b']) + + for i, sty in enumerate(color_cycle): + ax1.plot(x, x*(i+1), **sty) + + + for i, sty in zip(range(1, 10), cycle(color_cycle)): + ax2.plot(x, x*i, **sty) + + +.. plot:: + :include-source: + + from matplotlib.cycler import cycler + from itertools import cycle + + fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True, figsize=(8, 4)) + x = np.arange(10) + + color_cycle = cycler('c', ['r', 'g', 'b']) + + for i, sty in enumerate(color_cycle): + ax1.plot(x, x*(i+1), **sty) + + + for i, sty in zip(range(1, 10), cycle(color_cycle)): + ax2.plot(x, x*i, **sty) + + +Exceptions +---------- + + +A `ValueError` is raised if unequal length `Cycler` s are added together + +.. ipython:: python + :okexcept: + + color_cycle + ls_cycle + +or if two cycles which have overlapping keys are composed + +.. ipython:: python + :okexcept: + + color_cycle + color_cycle + color_cycle * color_cycle diff --git a/doc/users/index.rst b/doc/users/index.rst index eca6241e139e..141c8741d2a2 100644 --- a/doc/users/index.rst +++ b/doc/users/index.rst @@ -14,13 +14,10 @@ User's Guide intro.rst configuration.rst + cycler.rst beginner.rst developer.rst whats_new.rst github_stats.rst license.rst credits.rst - - - - diff --git a/lib/matplotlib/cycler.py b/lib/matplotlib/cycler.py new file mode 100644 index 000000000000..1af3c3e6a142 --- /dev/null +++ b/lib/matplotlib/cycler.py @@ -0,0 +1,253 @@ +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import six +from itertools import product +from six.moves import zip, reduce +from operator import mul, add +import copy + + +def _process_keys(left, right): + """ + Helper function to compose cycler keys + + Parameters + ---------- + left, right : Cycler or None + The cyclers to be composed + Returns + ------- + keys : set + The keys in the composition of the two cyclers + """ + l_key = left.keys if left is not None else set() + r_key = right.keys if right is not None else set() + if l_key & r_key: + raise ValueError("Can not compose overlapping cycles") + return l_key | r_key + + +class Cycler(object): + """ + A class to handle cycling multiple artist properties. + + This class has two compositions methods '+' for 'inner' + products of the cycles and '*' for outer products of the + cycles. + + Parameters + ---------- + left : Cycler or None + The 'left' cycler + + right : Cycler or None + The 'right' cycler + + op : func or None + Function which composes the 'left' and 'right' cyclers. + + """ + def __init__(self, left, right=None, op=None): + self._keys = _process_keys(left, right) + self._left = copy.copy(left) + self._right = copy.copy(right) + self._op = op + + @property + def keys(self): + return set(self._keys) + + def _compose(self): + """ + Compose the 'left' and 'right' components of this cycle + with the proper operation (zip or product as of now) + """ + for a, b in self._op(self._left, self._right): + out = dict() + out.update(a) + out.update(b) + yield out + + @classmethod + def _from_iter(cls, label, itr): + """ + Class method to create 'base' Cycler objects + that do not have a 'right' or 'op' and for which + the 'left' object is not another Cycler. + + Parameters + ---------- + label : str + The property key. + + itr : iterable + Finite length iterable of the property values. + + Returns + ------- + cycler : Cycler + New 'base' `Cycler` + """ + ret = cls(None) + ret._left = list({label: v} for v in itr) + ret._keys = set([label]) + return ret + + def __getitem__(self, key): + # TODO : maybe add numpy style fancy slicing + if isinstance(key, slice): + trans = self._transpose() + return reduce(add, (cycler(k, v[key]) + for k, v in six.iteritems(trans))) + else: + raise ValueError("Can only use slices with Cycler.__getitem__") + + def __iter__(self): + if self._right is None: + return iter(self._left) + + return self._compose() + + def __add__(self, other): + if len(self) != len(other): + raise ValueError("Can only add equal length cycles, " + "not {0} and {1}".format(len(self), len(other))) + return Cycler(self, other, zip) + + def __mul__(self, other): + if isinstance(other, Cycler): + return Cycler(self, other, product) + elif isinstance(other, int): + trans = self._transpose() + return reduce(add, (cycler(k, v*other) + for k, v in six.iteritems(trans))) + else: + return NotImplemented + + def __rmul__(self, other): + return self * other + + def __len__(self): + op_dict = {zip: min, product: mul} + if self._right is None: + return len(self._left) + l_len = len(self._left) + r_len = len(self._right) + return op_dict[self._op](l_len, r_len) + + def __iadd__(self, other): + old_self = copy.copy(self) + self._keys = _process_keys(old_self, other) + self._left = old_self + self._op = zip + self._right = copy.copy(other) + return self + + def __imul__(self, other): + old_self = copy.copy(self) + self._keys = _process_keys(old_self, other) + self._left = old_self + self._op = product + self._right = copy.copy(other) + return self + + def __repr__(self): + op_map = {zip: '+', product: '*'} + if self._right is None: + lab = self.keys.pop() + itr = list(v[lab] for v in self) + return "cycler({lab!r}, {itr!r})".format(lab=lab, itr=itr) + else: + op = op_map.get(self._op, '?') + msg = "({left!r} {op} {right!r})" + return msg.format(left=self._left, op=op, right=self._right) + + def _repr_html_(self): + # an table showing the value of each key through a full cycle + output = "" + for key in self.keys: + output += "".format(key=key) + for d in iter(self): + output += "" + for val in d.values(): + output += "".format(val=val) + output += "" + output += "
{key!r}
{val!r}
" + return output + + def _transpose(self): + """ + Internal helper function which iterates through the + styles and returns a dict of lists instead of a list of + dicts. This is needed for multiplying by integers and + for __getitem__ + + Returns + ------- + trans : dict + dict of lists for the styles + """ + + # TODO : sort out if this is a bottle neck, if there is a better way + # and if we care. + + keys = self.keys + out = {k: list() for k in keys} + + for d in self: + for k in keys: + out[k].append(d[k]) + return out + + def simplify(self): + """ + Simplify the Cycler and return as a composition only + sums (no multiplications) + + Returns + ------- + simple : Cycler + An equivalent cycler using only summation + """ + # TODO: sort out if it is worth the effort to make sure this is + # balanced. Currently it is is + # (((a + b) + c) + d) vs + # ((a + b) + (c + d)) + # I would believe that there is some performance implications + + trans = self._transpose() + return reduce(add, (cycler(k, v) for k, v in six.iteritems(trans))) + + +def cycler(label, itr): + """ + Create a new `Cycler` object from a property name and + iterable of values. + + Parameters + ---------- + label : str + The property key. + + itr : iterable + Finite length iterable of the property values. + + Returns + ------- + cycler : Cycler + New `Cycler` for the given property + """ + if isinstance(itr, Cycler): + keys = itr.keys + if len(keys) != 1: + msg = "Can not create Cycler from a multi-property Cycler" + raise ValueError(msg) + + if label in keys: + return copy.copy(itr) + else: + lab = keys.pop() + itr = list(v[lab] for v in itr) + + return Cycler._from_iter(label, itr) diff --git a/lib/matplotlib/tests/test_cycler.py b/lib/matplotlib/tests/test_cycler.py new file mode 100644 index 000000000000..39bb244fae6e --- /dev/null +++ b/lib/matplotlib/tests/test_cycler.py @@ -0,0 +1,126 @@ +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import six +from six.moves import zip +from matplotlib.cycler import cycler, Cycler +from nose.tools import assert_equal, assert_raises +from itertools import product +from operator import add, iadd, mul, imul + + +def _cycler_helper(c, length, keys, values): + assert_equal(len(c), length) + assert_equal(len(c), len(list(c))) + assert_equal(c.keys, set(keys)) + + for k, vals in zip(keys, values): + for v, v_target in zip(c, vals): + assert_equal(v[k], v_target) + + +def _cycles_equal(c1, c2): + assert_equal(list(c1), list(c2)) + + +def test_creation(): + c = cycler('c', 'rgb') + yield _cycler_helper, c, 3, ['c'], [['r', 'g', 'b']] + c = cycler('c', list('rgb')) + yield _cycler_helper, c, 3, ['c'], [['r', 'g', 'b']] + + +def test_compose(): + c1 = cycler('c', 'rgb') + c2 = cycler('lw', range(3)) + c3 = cycler('lw', range(15)) + # addition + yield _cycler_helper, c1+c2, 3, ['c', 'lw'], [list('rgb'), range(3)] + yield _cycler_helper, c2+c1, 3, ['c', 'lw'], [list('rgb'), range(3)] + yield _cycles_equal, c2+c1, c1+c2 + # miss-matched add lengths + assert_raises(ValueError, add, c1, c3) + assert_raises(ValueError, add, c3, c1) + + # multiplication + target = zip(*product(list('rgb'), range(3))) + yield (_cycler_helper, c1 * c2, 9, ['c', 'lw'], target) + + target = zip(*product(range(3), list('rgb'))) + yield (_cycler_helper, c2 * c1, 9, ['lw', 'c'], target) + + target = zip(*product(range(15), list('rgb'))) + yield (_cycler_helper, c3 * c1, 45, ['lw', 'c'], target) + + +def test_inplace(): + c1 = cycler('c', 'rgb') + c2 = cycler('lw', range(3)) + c2 += c1 + yield _cycler_helper, c2, 3, ['c', 'lw'], [list('rgb'), range(3)] + + c3 = cycler('c', 'rgb') + c4 = cycler('lw', range(3)) + c3 *= c4 + target = zip(*product(list('rgb'), range(3))) + yield (_cycler_helper, c3, 9, ['c', 'lw'], target) + + +def test_constructor(): + c1 = cycler('c', 'rgb') + c2 = cycler('ec', c1) + yield _cycler_helper, c1+c2, 3, ['c', 'ec'], [['r', 'g', 'b']]*2 + c3 = cycler('c', c1) + yield _cycler_helper, c3+c2, 3, ['c', 'ec'], [['r', 'g', 'b']]*2 + + +def test_failures(): + c1 = cycler('c', 'rgb') + c2 = cycler('c', c1) + assert_raises(ValueError, add, c1, c2) + assert_raises(ValueError, iadd, c1, c2) + assert_raises(ValueError, mul, c1, c2) + assert_raises(ValueError, imul, c1, c2) + + c3 = cycler('ec', c1) + + assert_raises(ValueError, cycler, 'c', c2 + c3) + + +def test_simplify(): + c1 = cycler('c', 'rgb') + c2 = cycler('ec', c1) + for c in [c1 * c2, c2 * c1, c1 + c2]: + yield _cycles_equal, c, c.simplify() + + +def test_multiply(): + c1 = cycler('c', 'rgb') + yield _cycler_helper, 2*c1, 6, ['c'], ['rgb'*2] + + c2 = cycler('ec', c1) + c3 = c1 * c2 + + yield _cycles_equal, 2*c3, c3*2 + + +def test_mul_fails(): + c1 = cycler('c', 'rgb') + assert_raises(TypeError, mul, c1, 2.0) + assert_raises(TypeError, mul, c1, 'a') + assert_raises(TypeError, mul, c1, []) + + +def test_getitem(): + c1 = cycler('lw', range(15)) + for slc in (slice(None, None, None), + slice(None, None, -1), + slice(1, 5, None), + slice(0, 5, 2)): + yield _cycles_equal, c1[slc], cycler('lw', range(15)[slc]) + + +def test_fail_getime(): + c1 = cycler('lw', range(15)) + assert_raises(ValueError, Cycler.__getitem__, c1, 0) + assert_raises(ValueError, Cycler.__getitem__, c1, [0, 1])