#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# yampex:
# Yet Another Matplotlib Extension
#
# Copyright (C) 2017-2021 by Edwin A. Suominen,
# http://edsuom.com/yampex
#
# See edsuom.com for API documentation as well as information about
# Ed's background and other projects, software and otherwise.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the
# License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an "AS
# IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
"""
The L{Plotter} methods defined by its L{OptsBase} base class give
you a convenient API for setting tons of options for the next subplot.
Or, if you call your C{Plotter} instance outside the subplot in
context (before the C{with ... as} statement), calling these methods
defines options for all subplots.
Keep the API for L{OptsBase} handy, and maybe a copy of the
U{source<http://edsuom.com/yampex/yampex.options.py>} open in your
editor, as a handy reference for all the plotting options you can set.
"""
from copy import copy, deepcopy
from contextlib import contextmanager
from yampex.util import *
class Opts(object):
"""
I am a dict-like object of options. I make it easy to override
global options with local ones just for the present subplot, and
provide a few useful methods.
@ivar go: A dict of global options that are set from my I{_opts}
defaults and then from any option-setting calls to the
L{OptsBase} subclass outside subplotting context.
@ivar lo: A dict of local options that are set only from
option-sitting calls inside the subplotting context, for the
present subplot.
"""
_colors = ['b', 'g', 'r', '#40C0C0', '#C0C040', '#C040C0', '#8080FF']
_opts = {
'hideAxis': False,
'colors': [],
'settings': {},
'plotKeywords': {},
'marker': ('', None),
'markers': [],
'linestyles': [],
'grid': (None, None),
'firstVectorTop': False,
'call': "plot",
'autolegend': False,
'legend': [],
'annotations': [],
'textBoxes': {},
'xscale': 1.0,
'yscale': 1.0,
'axisExact': {},
'ticks': {},
'useLabels': False,
'axvlines': [],
'bump': False,
'timex': False,
'xlabel': "",
'ylabel': "",
'title': "",
'zeroBottom': False,
'zeroLine': {},
'fontsizes': {},
}
def __init__(self):
self.go = deepcopy(self._opts)
self.lo = None
self.loList = []
def __repr__(self):
maybeMore = ""
if self.lo in self.loList:
maybeMore = sub(" for subplot #{:d}", self.loList.index(self.lo)+1)
lines = [sub(
"Options at {}{}: global (local)", hex(
id(self)), maybeMore), "-"*50]
for name in sorted(self.go.keys()):
goVal = self.go[name]
if self.lo is None or name not in self.lo:
loVal = ""
else: loVal = sub(" ({})", self.lo[name])
lines.append(sub("{:>18s} {}{}", name, goVal, loVal))
return "\n".join(lines)
def __contains__(self, key):
if key in self.go: return True
if self.lo is None: return False
return key in self.lo
def __setitem__(self, key, value):
if self.lo is None:
self.go[key] = value
else: self.lo[key] = value
def __getitem__(self, key):
if self.lo and key in self.lo:
return self.lo[key]
value = self.go[key]
if self.lo is None:
return value
if isinstance(value, (list, dict)):
value = copy(value)
self.lo[key] = value
return value
def newLocal(self):
"""
Sets a new local options context, appending it to my I{loList}.
All option-setting calls to your L{OptsBase} subclass will now
go to a new set of local options, and all option inquiries
will start with the local options and then go to globals,
ignoring all previously set local options.
"""
self.lo = {}
self.loList.append(self.lo)
def useLocal(self, kSubplot):
kMax = len(self.loList) - 1
if kSubplot > kMax : kSubplot = kMax
self.lo = self.loList[kSubplot]
def usePrevLocal(self):
k = -2 if len(self.loList) > 1 else -1
self.lo = self.loList[k]
def useLastLocal(self):
self.lo = self.loList[-1]
@contextmanager
def prevLocal(self):
"""
Lets you use my previous local options (or the current ones, if
there are no previous ones) inside a context call.
"""
self.usePrevLocal()
yield
self.useLastLocal()
def goGlobal(self):
"""
Sets my options context to global, where it began before the first
call to L{newLocal}.
Any further option-setting calls will affect global options,
and any option inquiries will refer strictly to global
options.
"""
self.lo = None
del self.loList[:]
def getColor(self, k):
"""
Supply an integer index starting from zero and this returns a
color from a clean and simple default palette.
"""
colors = self['colors']
if not colors: colors = self._colors
return colors[k % len(colors)]
def getLast(self, key, k):
"""
Assuming that I{key} refers to one of my options with a sequence
value, returns its item at index I{k}, or its last item if the
index is beyond its end.
"""
obj = self[key]
return obj[k] if k < len(obj) else obj[-1]
def useLegend(self):
"""
Returns C{True} if a legend should be added, given my current set
of options.
"""
if self['autolegend']: return True
if not self['legend']: return
return not self['useLabels']
def kwModified(self, k, kw_orig):
"""
Adds 'linestyle', 'linewidth', 'marker', and 'color' entries for
the next plot to I{kw} if not already defined.
Returns a new kw dict, leaving the original one alone.
"""
def nd(*keys):
"""
Returns C{True} if none of the I{keys} are defined in I{kw}.
"""
for key in keys:
if key in kw:
return False
return True
kw = kw_orig.copy()
if self['markers']:
marker, size = self.getLast('markers', k)
else: marker, size = self['marker']
if nd('marker'): kw['marker'] = marker
if nd('markersize', 'ms') and size is not None:
kw['markersize'] = size
width = 2
if nd('linestyle', 'ls'):
if self['linestyles']:
kw['linestyle'], width = self.getLast('linestyles', k)
elif kw['marker'] in (',', '.',):
kw['linestyle'] = ''
else: kw['linestyle'] = '-'
if nd('linewidth', 'lw'):
kw['linewidth'] = width
if nd('color', 'c'):
color = self.getColor(k)
kw['color'] = color
return kw
class OptsBase(object):
"""
I am an abstract base class with the option setting methods of the
L{Plotter}.
Keep the API for L{OptsBase} handy, and maybe a copy of the
U{source<http://edsuom.com/yampex/yampex.options.py>} open in your
editor, as a handy reference for all the plotting options you can
set.
@ivar opts: A dict of options.
"""
def set(self, name, value):
"""
Before this subplot is drawn, do a C{set_name=value} command to the
axes. You can call this method as many times as you like with
a different attribute I{name} and I{value}.
Use this method in place of calling the Plotter instance with
keywords. Now, such keywords are sent directly to the
underlying Matplotlib plotting call.
"""
self.opts['settings'][name] = value
@contextmanager
def prevOpts(self):
"""
Lets you use my previous local options (or the current ones, if
there are no previous ones) inside a context call.
"""
self.opts.usePrevLocal()
yield
self.opts.useLastLocal()
def add_annotation(self, k, proto, *args, **kw):
"""
Adds the text supplied after index I{k} at an annotation of the
plotted vector.
If there is more than one vector being plotted within the same
subplot, you can have the annotation refer to a vector other
than the first one by setting the keyword I{kVector} to its
non-zero index.
The annotation points to the point at index I{k} of the
plotted vector, unless I{k} is a float. In that case, it
points to the point where the vector is closest to that float
value.
You may include a text prototype with format-substitution args
following it, or just supply the final text string with no
further arguments. If you supply just an integer or float
value with no further arguments, it will be formatted
reasonably.
You can set the annotation to the first y-axis value that
crosses a float value of I{k} by setting I{y} C{True}.
B{Note}: Your code needs to make sure that you are really
passing an integer value of I{k} if that's what you intend. If
it's a float, the annotation will go where you don't expect it
to.
@see: L{clear_annotations}.
"""
kVector = None
if args:
if "{" not in proto:
# Called with kVector as a third argument, per API of
# commit 15c49b and earlier
text = proto
kVector = args[0]
else: text = sub(proto, *args)
elif isinstance(proto, int):
text = sub("{:+d}", proto)
elif isinstance(proto, float):
text = sub("{:+.4g}", proto)
else: text = proto
if kVector is None:
kVector = kw.get('kVector', 0)
y = kw.get('y', False)
self.opts['annotations'].append((k, text, kVector, y))
def add_axvline(self, k):
"""
Adds a vertical dashed line at the data point with integer index
I{k}. You can use negative indices, e.g., -1 for the last data
point.
To place the dashed line at (or at least near) an x value, use
a float for I{k}.
"""
self.opts['axvlines'].append(k)
def add_color(self, x=None):
"""
Appends the supplied line style character to the list of colors
being used.
The first and possibly only color in the list applies to the
first vector plotted within a given subplot. If there are
additional vectors being plotted within a given subplot (three
or more arguments supplied when calling the L{Plotter} object,
more than the number of colors that have been added to the
list, then the colors rotate back to the first one in the
list.
If you never call this and don't set your own list with a call
to L{set_colors}, a default color list is used.
If no color code or C{None} is supplied, reverts to the
default color scheme.
"""
if x is None:
self.opts['colors'] = []
return
self.opts['colors'].append(x)
def add_legend(self, proto, *args):
"""
Adds the supplied format-substituted text to the list of legend
entries.
You may include a text I{proto}type with format-substitution
I{args}, or just supply the final text string with no further
arguments.
@see: L{clear_legend}, L{set_legend}.
"""
text = sub(proto, *args)
self.opts['legend'].append(text)
def add_line(self, *args, **kw):
"""
Appends the supplied line style string(s) to my list of line
styles being used.
The first and possibly only line style in the list applies to
the first vector plotted within a given subplot. If there is
an additional vector being plotted within a given subplot
(three or more arguments supplied when calling the L{Plotter}
object, and more than one line style has been added to the
list, then the second line style in the list is used for that
second vector plot line. Otherwise, the first (only) line
style in the list is used for the second plot line as well.
If no line style character or C{None} is supplied, clears the
list of line styles.
@keyword width: A I{width} for the line(s). If you want
separate line widths for different lines, call this
repeatedly with each seperate line style and width. (You
can make the width a second argument of a 2-arg call,
after a single line style string.)
"""
if not args or args[0] is None:
self.opts['linestyles'] = []
return
if len(args) == 2 and not isinstance(args[1], str):
width = args[1]
args = [args[0]]
else: width = kw.get('width', None)
for x in args:
self.opts['linestyles'].append((x, width))
def add_lines(self, *args, **kw):
"""
Alias for L{add_line}.
"""
return self.add_line(*args, **kw)
def add_marker(self, x, size=None):
"""
Appends the supplied marker style character to the list of markers
being used.
The first and possibly only marker in the list applies to the
first vector plotted within a given subplot. If there is an
additional vector being plotted within a given subplot (three
or more arguments supplied when calling the L{Plotter} object,
and more than one marker has been added to the list, then the
second marker in the list is used for that second vector plot
line. Otherwise, the first (only) marker in the list is used
for the second plot line as well.
You can specify a I{size} for the marker as well.
"""
self.opts['markers'].append((x, size))
def add_plotKeyword(self, name, value):
"""
Add a keyword to the underlying Matplotlib plotting call.
@see: L{clear_plotKeywords}.
"""
self.opts['plotKeywords'][name] = value
def add_textBox(self, location, proto, *args):
"""
Adds a text box to the specified I{location} of the subplot.
Here are the locations (you can use the integer instead of the
abbreviation if you want), along with their text alignments:
1. "NE": right, top.
2. "E": right, middle.
3. "SE": right, bottom.
4. "S": middle, bottom.
5. "SW": left, bottom.
6. "W": left, middle.
7. "NW": left, top.
8. "N": middle, top.
9. "M": middle of plot.
If there's already a text box at the specified location, a
line will be added to it.
You may include a text I{proto}type with format-substitution
I{args}, or just supply the final text string with no further
arguments.
@see: L{clear_textBoxes}.
"""
text = sub(proto, *args)
prevText = self.opts['textBoxes'].get(location, None)
if prevText:
text = prevText + "\n" + text
self.opts['textBoxes'][location] = text.strip()
def clear_annotations(self):
"""
Clears the list of annotations.
"""
self.opts['annotations'] = []
def clear_legend(self):
"""
Clears the list of legend entries.
"""
self.opts['legend'] = []
def clear_plotKeywords(self):
"""
Clears all keywords for this subplot.
"""
self.opts['plotKeywords'].clear()
def clear_textBoxes(self):
"""
Clears the dict of text boxes.
"""
self.opts['textBoxes'].clear()
def plot(self, call="plot"):
"""
Specifies a non-logarithmic regular plot, unless called with the
name of a different plot type.
"""
if call not in PLOTTER_NAMES:
raise ValueError(sub("Unrecognized plot type '{}'", call))
self.opts['call'] = call
def plot_bar(self, yes=True):
"""
Specifies a bar plot, unless called with C{False}.
"""
self.opts['call'] = "bar" if yes else "plot"
def plot_error(self, yes=True):
"""
Specifies an error bar plot, unless called with C{False}.
"""
self.opts['call'] = "error" if yes else "plot"
def plot_loglog(self, yes=True):
"""
Makes both axes logarithmic, unless called with C{False}.
"""
self.opts['call'] = "loglog" if yes else "plot"
def plot_semilogx(self, yes=True):
"""
Makes x-axis logarithmic, unless called with C{False}.
"""
self.opts['call'] = "semilogx" if yes else "plot"
def plot_semilogy(self, yes=True):
"""
Makes y-axis logarithmic, unless called with C{False}.
"""
self.opts['call'] = "semilogy" if yes else "plot"
def plot_stem(self, yes=True):
"""
Specifies a stem plot, unless called with C{False}.
"""
self.opts['call'] = "stem" if yes else "plot"
def plot_step(self, yes=True):
"""
Specifies a step plot, unless called with C{False}.
"""
self.opts['call'] = "step" if yes else "plot"
def set_axisExact(self, axisName, yes=True):
"""
Forces the limits of the named axis ("x" or "y") to exactly the
data range, unless called with C{False}.
"""
self.opts['axisExact'][axisName] = yes
def set_colors(self, *args):
"""
Sets the list of colors. Call with no args to clear the list and
revert to default color scheme.
Supply a list of color codes, either as a single argument or
with one entry per argument.
"""
if len(args) == 1 and hasattr(args[0], '__iter__'):
args = args[0]
self.opts['colors'] = list(args)
def set_firstVectorTop(self):
"""
Has the first dependent vector (the second argument to the
L{Plotter} object call) determine the top (maximum) of the
displayed plot boundary.
"""
self.opts['firstVectorTop'] = True
def set_fontsize(self, name, fontsize):
"""
Sets the I{fontsize} of the specified artist I{name}.
Recognized names are 'title', 'xlabel', 'ylabel', 'legend',
'annotations', and 'textbox'.
"""
self.opts['fontsizes'][name] = fontsize
def set_legend(self, *args, **kw):
"""
Sets the list of legend entries.
Supply a list of legend entries, either as a single argument
or with one entry per argument. You can set the I{fontsize}
keyword to the desired fontsize of the legend; the default is
'small'.
@see: L{add_legend}, L{clear_legend}.
"""
if len(args) == 1 and hasattr(args[0], '__iter__'):
args = args[0]
self.opts['legend'] = list(args)
if 'fontsize' in kw:
self.opts['fontsizes']['legend'] = kw['fontsize']
def set_tickSpacing(self, axisName, major, minor=None):
"""
Sets the major tick spacing for I{axisName} ("x" or "y"), and
minor tick spacing as well.
For each setting, an C{int} will set a maximum number of tick
intervals, and a C{float} will set a spacing between
intervals.
You can set I{minor} C{True} to have minor ticks set
automatically, or C{False} to have them turned off. (Major
ticks are set automatically by default, and cannot be turned
off.)
"""
key = optkey(axisName, 'major')
self.opts['ticks'][key] = major
if minor is None: return
key = optkey(axisName, 'minor')
self.opts['ticks'][key] = minor
def set_title(self, proto, *args):
"""
Sets a title for all subplots (if called out of context) or for
just the present subplot (if called in context).
You may include a text I{proto}type with format-substitution
I{args}, or just supply the final text string with no further
arguments.
"""
text = sub(proto, *args)
if self._isSubplot:
self.opts['title'] = text
else: self._figTitle = text
def set_xlabel(self, x):
"""
Sets the x-axis label.
Ignored if time-x mode has been activated with a call to
L{set_timex}. If called out of context, on the L{Plotter}
instance, this x-label is used for all subplots and only
appears in the last (bottom) subplot of each column of
subplots.
"""
self.opts['xlabel'] = x
if not self._isSubplot:
self._universal_xlabel = True
def set_ylabel(self, x):
"""
Sets the y-axis label.
"""
self.opts['ylabel'] = x
def set_zeroBottom(self, yes=True):
"""
Sets the bottom (minimum) of the Y-axis range to zero, unless
called with C{False}.
This is useful for plotting values that are never negative and
where zero is a meaningful absolute minimum.
"""
self.opts['zeroBottom'] = yes
def set_zeroLine(self, y=0, color="black", linestyle='--', linewidth=1):
"""
Draws a horizontal line at the specified I{y} value (default is
y=0) if the Y-axis range includes that value.
If y is C{None} or C{False}, clears any previously set line.
@keyword color: Set the line color (default: black).
@keyword linestyle: Set the line type (default: '--').
@keyword linewidth: Set the line width (default: 1).
"""
self.opts['zeroLine'] = {
'y': y,
'color': color,
'linestyle': linestyle,
'linewidth': linewidth,
}
def use_bump(self, yes=True):
"""
Bumps up the common y-axis upper limit to 120% of what Matplotlib
decides. Call with C{False} to disable the bump.
"""
self.opts['bump'] = yes
def use_grid(self, *args):
"""
Adds a grid, unless called with C{False}.
The behavior this method is a little different than it used to
be. You can still enable a default grid by calling it with no
arguments, or turn off the grid entirely by supplying C{False}
as that sole argument. But now specifying a custom grid has
become a lot more powerful.
To turn gridlines on for just one axis's major tics, supply
that axis name as an argument followed by 'major'. (Not
case-sensitive.) For example, C{use_grid('x', 'major')}
enables only vertical grid lines at major tics of the 'x'
axis.
To turn gridlines on for both major and minor ticks, use
'both', like this: C{use_grid('both')}. Or, for major and
minor ticks on just the 'y' axis, for example, C{use_grid('y',
'both')}. You can also use the term 'minor' to have grid lines
just at minor ticks, though it's not evident why you would
want that as opposed to 'both'.
To have different grid lines for different axes, supply each
axis name followed by its tick specifier. For example, to have
horizontal grid lines at both major and minor ticks of the 'y'
axis but vertical lines just at major tick locations on the
'x' axis, do this:::
use_grid('x', 'both', 'y', 'major')
"""
x, y = self.opts['grid']
if not args:
# Default, no arg behavior
x = 'major'
y = 'major'
elif args[0]:
# Not just None or False
args = [x.lower() for x in args]
while args:
arg = args.pop(0)
if arg == 'x':
if args:
x = args.pop(0)
else: x = None
elif arg == 'y':
if args:
y = args.pop(0)
else: y = None
else:
x = y = arg
break
self.opts['grid'] = (x, y)
def use_legend(self, yes=True):
"""
Has an automatic legend entry added for each plot line, unless
called with C{False}.
"""
self.opts['autolegend'] = yes
def use_labels(self, yes=True):
"""
Has annotation labels point to each plot line instead of a legend,
with text taken from the legend list. (Works best in
interactive apps.)
Call with C{False} to revert to default legend-box behavior.
"""
self.opts['useLabels'] = yes
def use_minorTicks(self, axisName=None, yes=True):
"""
Enables minor ticks for I{axisName} ("x" or "y", omit for
both). Call with C{False} after the I{axisName} to disable.
To enable with a specific tick spacing, supply a float instead
of just C{True}. Or, for a specific number of ticks, supply
that as an int.
Note that, due to a Matplotlib limitation, you can't enable
minor ticks for just one axis, although you can independently
set the tick spacings.
"""
if axisName is None:
for axisName in ('x', 'y'):
self.use_minorTicks(axisName, yes)
return
key = optkey(axisName, 'minor')
self.opts['ticks'][key] = yes
def use_timex(self, yes=True):
"""
Uses intelligent time scaling for the x-axis, unless called with
C{False}.
If your x-axis is for time with units in seconds, you can call
this to have the X values rescaled to the most sensible units,
e.g., nanoseconds for values < 1E-6. Any I{xlabel} option is
disregarded in such case because the x label is set
automatically.
"""
self.opts['timex'] = yes
if not self._isSubplot:
self._universal_xlabel = True
def hide_axis(self, yes=True):
"""
Hide the x-axis and y-axis (for images).
Call with C{False} to revert to default show-axis behavior.
"""
self.opts['hideAxis'] = yes