Files
littlefs/scripts/plotmpl.py
Christopher Haster cfd4e6029a Added --subplot* to plotmpl.py
Driven primarily by a want to compare measurements of different runtime
complexities (it's difficult to fit O(n) and O(log n) on the same plot),
this adds the ability to nest subplots in the same .svg which try to align
as much as possible. This turned out to be surprisingly complicated.

As a part of this, adopted matplotlib's relatively recent
constrained_layout, which behaves much more consistently.

Also dropped --legend-left, no one should really be using that.
2022-12-16 16:47:30 -06:00

1259 lines
41 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Plot CSV files with matplotlib.
#
# Example:
# ./scripts/plotmpl.py bench.csv -xSIZE -ybench_read -obench.svg
#
# Copyright (c) 2022, The littlefs authors.
# SPDX-License-Identifier: BSD-3-Clause
#
import codecs
import collections as co
import csv
import io
import itertools as it
import logging
import math as m
import numpy as np
import os
import shlex
import shutil
import time
import matplotlib as mpl
import matplotlib.pyplot as plt
# some nicer colors borrowed from Seaborn
# note these include a non-opaque alpha
COLORS = [
'#4c72b0bf', # blue
'#dd8452bf', # orange
'#55a868bf', # green
'#c44e52bf', # red
'#8172b3bf', # purple
'#937860bf', # brown
'#da8bc3bf', # pink
'#8c8c8cbf', # gray
'#ccb974bf', # yellow
'#64b5cdbf', # cyan
]
COLORS_DARK = [
'#a1c9f4bf', # blue
'#ffb482bf', # orange
'#8de5a1bf', # green
'#ff9f9bbf', # red
'#d0bbffbf', # purple
'#debb9bbf', # brown
'#fab0e4bf', # pink
'#cfcfcfbf', # gray
'#fffea3bf', # yellow
'#b9f2f0bf', # cyan
]
ALPHAS = [0.75]
FORMATS = ['-']
FORMATS_POINTS = ['.']
FORMATS_POINTS_AND_LINES = ['.-']
WIDTH = 750
HEIGHT = 350
FONT_SIZE = 11
SI_PREFIXES = {
18: 'E',
15: 'P',
12: 'T',
9: 'G',
6: 'M',
3: 'K',
0: '',
-3: 'm',
-6: 'u',
-9: 'n',
-12: 'p',
-15: 'f',
-18: 'a',
}
SI2_PREFIXES = {
60: 'Ei',
50: 'Pi',
40: 'Ti',
30: 'Gi',
20: 'Mi',
10: 'Ki',
0: '',
-10: 'mi',
-20: 'ui',
-30: 'ni',
-40: 'pi',
-50: 'fi',
-60: 'ai',
}
# formatter for matplotlib
def si(x):
if x == 0:
return '0'
# figure out prefix and scale
p = 3*int(m.log(abs(x), 10**3))
p = min(18, max(-18, p))
# format with 3 digits of precision
s = '%.3f' % (abs(x) / (10.0**p))
s = s[:3+1]
# truncate but only digits that follow the dot
if '.' in s:
s = s.rstrip('0')
s = s.rstrip('.')
return '%s%s%s' % ('-' if x < 0 else '', s, SI_PREFIXES[p])
# formatter for matplotlib
def si2(x):
if x == 0:
return '0'
# figure out prefix and scale
p = 10*int(m.log(abs(x), 2**10))
p = min(30, max(-30, p))
# format with 3 digits of precision
s = '%.3f' % (abs(x) / (2.0**p))
s = s[:3+1]
# truncate but only digits that follow the dot
if '.' in s:
s = s.rstrip('0')
s = s.rstrip('.')
return '%s%s%s' % ('-' if x < 0 else '', s, SI2_PREFIXES[p])
# parse escape strings
def escape(s):
return codecs.escape_decode(s.encode('utf8'))[0].decode('utf8')
# we want to use MaxNLocator, but since MaxNLocator forces multiples of 10
# to be an option, we can't really...
class AutoMultipleLocator(mpl.ticker.MultipleLocator):
def __init__(self, base, nbins=None):
# note base needs to be floats to avoid integer pow issues
self.base = float(base)
self.nbins = nbins
super().__init__(self.base)
def __call__(self):
# find best tick count, conveniently matplotlib has a function for this
vmin, vmax = self.axis.get_view_interval()
vmin, vmax = mpl.transforms.nonsingular(vmin, vmax, 1e-12, 1e-13)
if self.nbins is not None:
nbins = self.nbins
else:
nbins = np.clip(self.axis.get_tick_space(), 1, 9)
# find the best power, use this as our locator's actual base
scale = self.base ** (m.ceil(m.log((vmax-vmin) / (nbins+1), self.base)))
self.set_params(scale)
return super().__call__()
def openio(path, mode='r', buffering=-1):
# allow '-' for stdin/stdout
if path == '-':
if mode == 'r':
return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
else:
return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
else:
return open(path, mode, buffering)
# parse different data representations
def dat(x):
# allow the first part of an a/b fraction
if '/' in x:
x, _ = x.split('/', 1)
# first try as int
try:
return int(x, 0)
except ValueError:
pass
# then try as float
try:
return float(x)
# just don't allow infinity or nan
if m.isinf(x) or m.isnan(x):
raise ValueError("invalid dat %r" % x)
except ValueError:
pass
# else give up
raise ValueError("invalid dat %r" % x)
def collect(csv_paths, renames=[]):
# collect results from CSV files
results = []
for path in csv_paths:
try:
with openio(path) as f:
reader = csv.DictReader(f, restval='')
for r in reader:
results.append(r)
except FileNotFoundError:
pass
if renames:
for r in results:
# make a copy so renames can overlap
r_ = {}
for new_k, old_k in renames:
if old_k in r:
r_[new_k] = r[old_k]
r.update(r_)
return results
def dataset(results, x=None, y=None, define=[]):
# organize by 'by', x, and y
dataset = {}
i = 0
for r in results:
# filter results by matching defines
if not all(k in r and r[k] in vs for k, vs in define):
continue
# find xs
if x is not None:
if x not in r:
continue
try:
x_ = dat(r[x])
except ValueError:
continue
else:
x_ = i
i += 1
# find ys
if y is not None:
if y not in r:
continue
try:
y_ = dat(r[y])
except ValueError:
continue
else:
y_ = None
if y_ is not None:
dataset[x_] = y_ + dataset.get(x_, 0)
else:
dataset[x_] = y_ or dataset.get(x_, None)
return dataset
def datasets(results, by=None, x=None, y=None, define=[]):
# filter results by matching defines
results_ = []
for r in results:
if all(k in r and r[k] in vs for k, vs in define):
results_.append(r)
results = results_
# if y not specified, try to guess from data
if y is None:
y = co.OrderedDict()
for r in results:
for k, v in r.items():
if (by is None or k not in by) and v.strip():
try:
dat(v)
y[k] = True
except ValueError:
y[k] = False
y = list(k for k,v in y.items() if v)
if by is not None:
# find all 'by' values
ks = set()
for r in results:
ks.add(tuple(r.get(k, '') for k in by))
ks = sorted(ks)
# collect all datasets
datasets = co.OrderedDict()
for ks_ in (ks if by is not None else [()]):
for x_ in (x if x is not None else [None]):
for y_ in y:
# hide x/y if there is only one field
k_x = x_ if len(x or []) > 1 else ''
k_y = y_ if len(y or []) > 1 or (not ks_ and not k_x) else ''
datasets[ks_ + (k_x, k_y)] = dataset(
results,
x_,
y_,
[(by_, {k_}) for by_, k_ in zip(by, ks_)]
if by is not None else [])
return datasets
# some classes for organizing subplots into a grid
class Subplot:
def __init__(self, **args):
self.x = 0
self.y = 0
self.xspan = 1
self.yspan = 1
self.args = args
class Grid:
def __init__(self, subplot, width=1.0, height=1.0):
self.xweights = [width]
self.yweights = [height]
self.map = {(0,0): subplot}
self.subplots = [subplot]
def __repr__(self):
return 'Grid(%r, %r)' % (self.xweights, self.yweights)
@property
def width(self):
return len(self.xweights)
@property
def height(self):
return len(self.yweights)
def __iter__(self):
return iter(self.subplots)
def __getitem__(self, i):
x, y = i
if x < 0:
x += len(self.xweights)
if y < 0:
y += len(self.yweights)
return self.map[(x,y)]
def merge(self, other, dir):
if dir in ['above', 'below']:
# first scale the two grids so they line up
self_xweights = self.xweights
other_xweights = other.xweights
self_w = sum(self_xweights)
other_w = sum(other_xweights)
ratio = self_w / other_w
other_xweights = [s*ratio for s in other_xweights]
# now interleave xweights as needed
new_xweights = []
self_map = {}
other_map = {}
self_i = 0
other_i = 0
self_xweight = (self_xweights[self_i]
if self_i < len(self_xweights) else m.inf)
other_xweight = (other_xweights[other_i]
if other_i < len(other_xweights) else m.inf)
while self_i < len(self_xweights) and other_i < len(other_xweights):
if other_xweight - self_xweight > 0.0000001:
new_xweights.append(self_xweight)
other_xweight -= self_xweight
new_i = len(new_xweights)-1
for j in range(len(self.yweights)):
self_map[(new_i, j)] = self.map[(self_i, j)]
for j in range(len(other.yweights)):
other_map[(new_i, j)] = other.map[(other_i, j)]
for s in other.subplots:
if s.x+s.xspan-1 == new_i:
s.xspan += 1
elif s.x > new_i:
s.x += 1
self_i += 1
self_xweight = (self_xweights[self_i]
if self_i < len(self_xweights) else m.inf)
elif self_xweight - other_xweight > 0.0000001:
new_xweights.append(other_xweight)
self_xweight -= other_xweight
new_i = len(new_xweights)-1
for j in range(len(other.yweights)):
other_map[(new_i, j)] = other.map[(other_i, j)]
for j in range(len(self.yweights)):
self_map[(new_i, j)] = self.map[(self_i, j)]
for s in self.subplots:
if s.x+s.xspan-1 == new_i:
s.xspan += 1
elif s.x > new_i:
s.x += 1
other_i += 1
other_xweight = (other_xweights[other_i]
if other_i < len(other_xweights) else m.inf)
else:
new_xweights.append(self_xweight)
new_i = len(new_xweights)-1
for j in range(len(self.yweights)):
self_map[(new_i, j)] = self.map[(self_i, j)]
for j in range(len(other.yweights)):
other_map[(new_i, j)] = other.map[(other_i, j)]
self_i += 1
self_xweight = (self_xweights[self_i]
if self_i < len(self_xweights) else m.inf)
other_i += 1
other_xweight = (other_xweights[other_i]
if other_i < len(other_xweights) else m.inf)
# squish so ratios are preserved
self_h = sum(self.yweights)
other_h = sum(other.yweights)
ratio = (self_h-other_h) / self_h
self_yweights = [s*ratio for s in self.yweights]
# finally concatenate the two grids
if dir == 'above':
for s in other.subplots:
s.y += len(self_yweights)
self.subplots.extend(other.subplots)
self.xweights = new_xweights
self.yweights = self_yweights + other.yweights
self.map = self_map | {(x, y+len(self_yweights)): s
for (x, y), s in other_map.items()}
else:
for s in self.subplots:
s.y += len(other.yweights)
self.subplots.extend(other.subplots)
self.xweights = new_xweights
self.yweights = other.yweights + self_yweights
self.map = other_map | {(x, y+len(other.yweights)): s
for (x, y), s in self_map.items()}
if dir in ['right', 'left']:
# first scale the two grids so they line up
self_yweights = self.yweights
other_yweights = other.yweights
self_h = sum(self_yweights)
other_h = sum(other_yweights)
ratio = self_h / other_h
other_yweights = [s*ratio for s in other_yweights]
# now interleave yweights as needed
new_yweights = []
self_map = {}
other_map = {}
self_i = 0
other_i = 0
self_yweight = (self_yweights[self_i]
if self_i < len(self_yweights) else m.inf)
other_yweight = (other_yweights[other_i]
if other_i < len(other_yweights) else m.inf)
while self_i < len(self_yweights) and other_i < len(other_yweights):
if other_yweight - self_yweight > 0.0000001:
new_yweights.append(self_yweight)
other_yweight -= self_yweight
new_i = len(new_yweights)-1
for j in range(len(self.xweights)):
self_map[(j, new_i)] = self.map[(j, self_i)]
for j in range(len(other.xweights)):
other_map[(j, new_i)] = other.map[(j, other_i)]
for s in other.subplots:
if s.y+s.yspan-1 == new_i:
s.yspan += 1
elif s.y > new_i:
s.y += 1
self_i += 1
self_yweight = (self_yweights[self_i]
if self_i < len(self_yweights) else m.inf)
elif self_yweight - other_yweight > 0.0000001:
new_yweights.append(other_yweight)
self_yweight -= other_yweight
new_i = len(new_yweights)-1
for j in range(len(other.xweights)):
other_map[(j, new_i)] = other.map[(j, other_i)]
for j in range(len(self.xweights)):
self_map[(j, new_i)] = self.map[(j, self_i)]
for s in self.subplots:
if s.y+s.yspan-1 == new_i:
s.yspan += 1
elif s.y > new_i:
s.y += 1
other_i += 1
other_yweight = (other_yweights[other_i]
if other_i < len(other_yweights) else m.inf)
else:
new_yweights.append(self_yweight)
new_i = len(new_yweights)-1
for j in range(len(self.xweights)):
self_map[(j, new_i)] = self.map[(j, self_i)]
for j in range(len(other.xweights)):
other_map[(j, new_i)] = other.map[(j, other_i)]
self_i += 1
self_yweight = (self_yweights[self_i]
if self_i < len(self_yweights) else m.inf)
other_i += 1
other_yweight = (other_yweights[other_i]
if other_i < len(other_yweights) else m.inf)
# squish so ratios are preserved
self_w = sum(self.xweights)
other_w = sum(other.xweights)
ratio = (self_w-other_w) / self_w
self_xweights = [s*ratio for s in self.xweights]
# finally concatenate the two grids
if dir == 'right':
for s in other.subplots:
s.x += len(self_xweights)
self.subplots.extend(other.subplots)
self.xweights = self_xweights + other.xweights
self.yweights = new_yweights
self.map = self_map | {(x+len(self_xweights), y): s
for (x, y), s in other_map.items()}
else:
for s in self.subplots:
s.x += len(other.xweights)
self.subplots.extend(other.subplots)
self.xweights = other.xweights + self_xweights
self.yweights = new_yweights
self.map = other_map | {(x+len(other.xweights), y): s
for (x, y), s in self_map.items()}
def scale(self, width, height):
self.xweights = [s*width for s in self.xweights]
self.yweights = [s*height for s in self.yweights]
@classmethod
def fromargs(cls, width=1.0, height=1.0, *,
subplots=[],
**args):
grid = cls(Subplot(**args))
for dir, subargs in subplots:
subgrid = cls.fromargs(
width=subargs.pop('width',
0.5 if dir in ['right', 'left'] else width),
height=subargs.pop('height',
0.5 if dir in ['above', 'below'] else height),
**subargs)
grid.merge(subgrid, dir)
grid.scale(width, height)
return grid
def main(csv_paths, output, *,
svg=False,
png=False,
quiet=False,
by=None,
x=None,
y=None,
define=[],
points=False,
points_and_lines=False,
colors=None,
formats=None,
width=WIDTH,
height=HEIGHT,
xlim=(None,None),
ylim=(None,None),
xlog=False,
ylog=False,
x2=False,
y2=False,
xticks=None,
yticks=None,
xunits=None,
yunits=None,
xlabel=None,
ylabel=None,
xticklabels=None,
yticklabels=None,
title=None,
legend_right=False,
legend_above=False,
legend_below=False,
dark=False,
ggplot=False,
xkcd=False,
github=False,
font=None,
font_size=FONT_SIZE,
font_color=None,
foreground=None,
background=None,
subplot={},
subplots=[],
**args):
# guess the output format
if not png and not svg:
if output.endswith('.png'):
png = True
else:
svg = True
# some shortcuts for color schemes
if github:
ggplot = True
if font_color is None:
if dark:
font_color = '#c9d1d9'
else:
font_color = '#24292f'
if foreground is None:
if dark:
foreground = '#343942'
else:
foreground = '#eff1f3'
if background is None:
if dark:
background = '#0d1117'
else:
background = '#ffffff'
# what colors/alphas/formats to use?
if colors is not None:
colors_ = colors
elif dark:
colors_ = COLORS_DARK
else:
colors_ = COLORS
if formats is not None:
formats_ = formats
elif points_and_lines:
formats_ = FORMATS_POINTS_AND_LINES
elif points:
formats_ = FORMATS_POINTS
else:
formats_ = FORMATS
if font_color is not None:
font_color_ = font_color
elif dark:
font_color_ = '#ffffff'
else:
font_color_ = '#000000'
if foreground is not None:
foreground_ = foreground
elif dark:
foreground_ = '#333333'
else:
foreground_ = '#e5e5e5'
if background is not None:
background_ = background
elif dark:
background_ = '#000000'
else:
background_ = '#ffffff'
# configure some matplotlib settings
if xkcd:
# the font search here prints a bunch of unhelpful warnings
logging.getLogger('matplotlib.font_manager').setLevel(logging.ERROR)
plt.xkcd()
# turn off the white outline, this breaks some things
plt.rc('path', effects=[])
if ggplot:
plt.style.use('ggplot')
plt.rc('patch', linewidth=0)
plt.rc('axes', facecolor=foreground_, edgecolor=background_)
plt.rc('grid', color=background_)
# fix the the gridlines when ggplot+xkcd
if xkcd:
plt.rc('grid', linewidth=1)
plt.rc('axes.spines', bottom=False, left=False)
if dark:
plt.style.use('dark_background')
plt.rc('savefig', facecolor='auto', edgecolor='auto')
# fix ggplot when dark
if ggplot:
plt.rc('axes',
facecolor=foreground_,
edgecolor=background_)
plt.rc('grid', color=background_)
if font is not None:
plt.rc('font', family=font)
plt.rc('font', size=font_size)
plt.rc('text', color=font_color_)
plt.rc('figure',
titlesize='medium',
labelsize='small')
plt.rc('axes',
titlesize='small',
labelsize='small',
labelcolor=font_color_)
if not ggplot:
plt.rc('axes', edgecolor=font_color_)
plt.rc('xtick', labelsize='small', color=font_color_)
plt.rc('ytick', labelsize='small', color=font_color_)
plt.rc('legend',
fontsize='small',
fancybox=False,
framealpha=None,
edgecolor=foreground_,
borderaxespad=0)
plt.rc('axes.spines', top=False, right=False)
plt.rc('figure', facecolor=background_, edgecolor=background_)
if not ggplot:
plt.rc('axes', facecolor='#00000000')
# I think the svg backend just ignores DPI, but seems to use something
# equivalent to 96, maybe this is the default for SVG rendering?
plt.rc('figure', dpi=96)
# separate out renames
renames = list(it.chain.from_iterable(
((k, v) for v in vs)
for k, vs in it.chain(by or [], x or [], y or [])))
if by is not None:
by = [k for k, _ in by]
if x is not None:
x = [k for k, _ in x]
if y is not None:
y = [k for k, _ in y]
# first collect results from CSV files
results = collect(csv_paths, renames)
# then extract the requested datasets
datasets_ = datasets(results, by, x, y, define)
# figure out formats/colors here so that subplot defines
# don't change them later, that'd be bad
dataformats_ = {
name: formats_[i % len(formats_)]
for i, name in enumerate(datasets_.keys())}
datacolors_ = {
name: colors_[i % len(colors_)]
for i, name in enumerate(datasets_.keys())}
# create a grid of subplots
grid = Grid.fromargs(
subplots=subplots + subplot.pop('subplots', []),
**subplot)
# create a matplotlib plot
fig = plt.figure(figsize=(
width/plt.rcParams['figure.dpi'],
height/plt.rcParams['figure.dpi']),
layout='constrained',
# we need a linewidth to keep xkcd mode happy
linewidth=8 if xkcd else 0)
gs = fig.add_gridspec(
grid.height
+ (1 if legend_above else 0)
+ (1 if legend_below else 0),
grid.width
+ (1 if legend_right else 0),
height_ratios=([0.001] if legend_above else [])
+ [max(s, 0.01) for s in reversed(grid.yweights)]
+ ([0.001] if legend_below else []),
width_ratios=[max(s, 0.01) for s in grid.xweights]
+ ([0.001] if legend_right else []))
# first create axes so that plots can interact with each other
for s in grid:
s.ax = fig.add_subplot(gs[
grid.height-(s.y+s.yspan) + (1 if legend_above else 0)
: grid.height-s.y + (1 if legend_above else 0),
s.x
: s.x+s.xspan])
# now plot each subplot
for s in grid:
# allow subplot params to override global params
define_ = define + s.args.get('define', [])
xlim_ = s.args.get('xlim', xlim)
ylim_ = s.args.get('ylim', ylim)
xlog_ = s.args.get('xlog', False) or xlog
ylog_ = s.args.get('ylog', False) or ylog
x2_ = s.args.get('x2', False) or x2
y2_ = s.args.get('y2', False) or y2
xticks_ = s.args.get('xticks', xticks)
yticks_ = s.args.get('yticks', yticks)
xunits_ = s.args.get('xunits', xunits)
yunits_ = s.args.get('yunits', yunits)
xticklabels_ = s.args.get('xticklabels', xticklabels)
yticklabels_ = s.args.get('yticklabels', yticklabels)
# label/titles are handled a bit differently in subplots
subtitle = s.args.get('title')
xsublabel = s.args.get('xlabel')
ysublabel = s.args.get('ylabel')
# allow shortened ranges
if len(xlim_) == 1:
xlim_ = (0, xlim_[0])
if len(ylim_) == 1:
ylim_ = (0, ylim_[0])
# data can be constrained by subplot-specific defines,
# so re-extract for each plot
subdatasets = datasets(results, by, x, y, define_)
# plot!
ax = s.ax
for name, dataset in subdatasets.items():
dats = sorted((x,y) for x,y in dataset.items())
ax.plot([x for x,_ in dats], [y for _,y in dats],
dataformats_[name],
color=datacolors_[name],
label=','.join(k for k in name if k))
# axes scaling
if xlog_:
ax.set_xscale('symlog')
ax.xaxis.set_minor_locator(mpl.ticker.NullLocator())
if ylog_:
ax.set_yscale('symlog')
ax.yaxis.set_minor_locator(mpl.ticker.NullLocator())
# axes limits
ax.set_xlim(
xlim_[0] if xlim_[0] is not None
else min(it.chain([0], (k
for r in subdatasets.values()
for k, v in r.items()
if v is not None))),
xlim_[1] if xlim_[1] is not None
else max(it.chain([0], (k
for r in subdatasets.values()
for k, v in r.items()
if v is not None))))
ax.set_ylim(
ylim_[0] if ylim_[0] is not None
else min(it.chain([0], (v
for r in subdatasets.values()
for _, v in r.items()
if v is not None))),
ylim_[1] if ylim_[1] is not None
else max(it.chain([0], (v
for r in subdatasets.values()
for _, v in r.items()
if v is not None))))
# axes ticks
if x2_:
ax.xaxis.set_major_formatter(lambda x, pos:
si2(x)+(xunits_ if xunits_ else ''))
if xticklabels_ is not None:
ax.xaxis.set_ticklabels(xticklabels_)
if xticks_ is None:
ax.xaxis.set_major_locator(AutoMultipleLocator(2))
elif isinstance(xticks_, list):
ax.xaxis.set_major_locator(mpl.ticker.FixedLocator(xticks_))
elif xticks_ != 0:
ax.xaxis.set_major_locator(AutoMultipleLocator(2, xticks_-1))
else:
ax.xaxis.set_major_locator(mpl.ticker.NullLocator())
else:
ax.xaxis.set_major_formatter(lambda x, pos:
si(x)+(xunits_ if xunits_ else ''))
if xticklabels_ is not None:
ax.xaxis.set_ticklabels(xticklabels_)
if xticks_ is None:
ax.xaxis.set_major_locator(mpl.ticker.AutoLocator())
elif isinstance(xticks_, list):
ax.xaxis.set_major_locator(mpl.ticker.FixedLocator(xticks_))
elif xticks_ != 0:
ax.xaxis.set_major_locator(mpl.ticker.MaxNLocator(xticks_-1))
else:
ax.xaxis.set_major_locator(mpl.ticker.NullLocator())
if y2_:
ax.yaxis.set_major_formatter(lambda x, pos:
si2(x)+(yunits_ if yunits_ else ''))
if yticklabels_ is not None:
ax.yaxis.set_ticklabels(yticklabels_)
if yticks_ is None:
ax.yaxis.set_major_locator(AutoMultipleLocator(2))
elif isinstance(yticks_, list):
ax.yaxis.set_major_locator(mpl.ticker.FixedLocator(yticks_))
elif yticks_ != 0:
ax.yaxis.set_major_locator(AutoMultipleLocator(2, yticks_-1))
else:
ax.yaxis.set_major_locator(mpl.ticker.NullLocator())
else:
ax.yaxis.set_major_formatter(lambda x, pos:
si(x)+(yunits_ if yunits_ else ''))
if yticklabels_ is not None:
ax.yaxis.set_ticklabels(yticklabels_)
if yticks_ is None:
ax.yaxis.set_major_locator(mpl.ticker.AutoLocator())
elif isinstance(yticks_, list):
ax.yaxis.set_major_locator(mpl.ticker.FixedLocator(yticks_))
elif yticks_ != 0:
ax.yaxis.set_major_locator(mpl.ticker.MaxNLocator(yticks_-1))
else:
ax.yaxis.set_major_locator(mpl.ticker.NullLocator())
if ggplot:
ax.grid(sketch_params=None)
# axes subplot labels
if xsublabel is not None:
ax.set_xlabel(escape(xsublabel))
if ysublabel is not None:
ax.set_ylabel(escape(ysublabel))
if subtitle is not None:
ax.set_title(escape(subtitle))
# add a legend? a bit tricky with matplotlib
#
# the best solution I've found is a dedicated, invisible axes for the
# legend, hacky, but it works.
#
# note this was written before constrained_layout supported legend
# collisions, hopefully this is added in the future
labels = co.OrderedDict()
for s in grid:
for h, l in zip(*s.ax.get_legend_handles_labels()):
labels[l] = h
if legend_right:
ax = fig.add_subplot(gs[(1 if legend_above else 0):,-1])
ax.set_axis_off()
ax.legend(
labels.values(),
labels.keys(),
loc='upper left',
fancybox=False,
borderaxespad=0)
if legend_above:
ax = fig.add_subplot(gs[0, :grid.width])
ax.set_axis_off()
# try different column counts until we fit in the axes
for ncol in reversed(range(1, len(labels)+1)):
legend_ = ax.legend(
labels.values(),
labels.keys(),
loc='upper center',
ncol=ncol,
fancybox=False,
borderaxespad=0)
if (legend_.get_window_extent().width
<= ax.get_window_extent().width):
break
if legend_below:
ax = fig.add_subplot(gs[-1, :grid.width])
ax.set_axis_off()
# big hack to get xlabel above the legend! but hey this
# works really well actually
if xlabel:
ax.set_title(escape(xlabel),
size=plt.rcParams['axes.labelsize'],
weight=plt.rcParams['axes.labelweight'])
# try different column counts until we fit in the axes
for ncol in reversed(range(1, len(labels)+1)):
legend_ = ax.legend(
labels.values(),
labels.keys(),
loc='upper center',
ncol=ncol,
fancybox=False,
borderaxespad=0)
if (legend_.get_window_extent().width
<= ax.get_window_extent().width):
break
# axes labels, NOTE we reposition these below
if xlabel is not None and not legend_below:
fig.supxlabel(escape(xlabel))
if ylabel is not None:
fig.supylabel(escape(ylabel))
if title is not None:
fig.suptitle(escape(title))
# precompute constrained layout and find midpoints to adjust things
# that should be centered so they are actually centered
fig.canvas.draw()
xmid = (grid[0,0].ax.get_position().x0 + grid[-1,0].ax.get_position().x1)/2
ymid = (grid[0,0].ax.get_position().y0 + grid[0,-1].ax.get_position().y1)/2
if xlabel is not None and not legend_below:
fig.supxlabel(escape(xlabel), x=xmid)
if ylabel is not None:
fig.supylabel(escape(ylabel), y=ymid)
if title is not None:
fig.suptitle(escape(title), x=xmid)
# write the figure!
plt.savefig(output, format='png' if png else 'svg')
# some stats
if not quiet:
print('updated %s, %s datasets, %s points' % (
output,
len(datasets_),
sum(len(dataset) for dataset in datasets_.values())))
if __name__ == "__main__":
import sys
import argparse
parser = argparse.ArgumentParser(
description="Plot CSV files with matplotlib.",
allow_abbrev=False)
parser.add_argument(
'csv_paths',
nargs='*',
help="Input *.csv files.")
output_rule = parser.add_argument(
'-o', '--output',
required=True,
help="Output *.svg/*.png file.")
parser.add_argument(
'--svg',
action='store_true',
help="Output an svg file. By default this is infered.")
parser.add_argument(
'--png',
action='store_true',
help="Output a png file. By default this is infered.")
parser.add_argument(
'-q', '--quiet',
action='store_true',
help="Don't print info.")
parser.add_argument(
'-b', '--by',
action='append',
type=lambda x: (
lambda k,v=None: (k, v.split(',') if v is not None else ())
)(*x.split('=', 1)),
help="Group by this field. Can rename fields with new_name=old_name.")
parser.add_argument(
'-x',
action='append',
type=lambda x: (
lambda k,v=None: (k, v.split(',') if v is not None else ())
)(*x.split('=', 1)),
help="Field to use for the x-axis. Can rename fields with "
"new_name=old_name.")
parser.add_argument(
'-y',
action='append',
type=lambda x: (
lambda k,v=None: (k, v.split(',') if v is not None else ())
)(*x.split('=', 1)),
help="Field to use for the y-axis. Can rename fields with "
"new_name=old_name.")
parser.add_argument(
'-D', '--define',
type=lambda x: (lambda k,v: (k, set(v.split(','))))(*x.split('=', 1)),
action='append',
help="Only include results where this field is this value. May include "
"comma-separated options.")
parser.add_argument(
'-.', '--points',
action='store_true',
help="Only draw data points.")
parser.add_argument(
'-!', '--points-and-lines',
action='store_true',
help="Draw data points and lines.")
parser.add_argument(
'--colors',
type=lambda x: [x.strip() for x in x.split(',')],
help="Comma-separated hex colors to use.")
parser.add_argument(
'--formats',
type=lambda x: [x.strip().replace('0',',') for x in x.split(',')],
help="Comma-separated matplotlib formats to use. Allows '0' as an "
"alternative for ','.")
parser.add_argument(
'-W', '--width',
type=lambda x: int(x, 0),
help="Width in pixels. Defaults to %r." % WIDTH)
parser.add_argument(
'-H', '--height',
type=lambda x: int(x, 0),
help="Height in pixels. Defaults to %r." % HEIGHT)
parser.add_argument(
'-X', '--xlim',
type=lambda x: tuple(
dat(x) if x.strip() else None
for x in x.split(',')),
help="Range for the x-axis.")
parser.add_argument(
'-Y', '--ylim',
type=lambda x: tuple(
dat(x) if x.strip() else None
for x in x.split(',')),
help="Range for the y-axis.")
parser.add_argument(
'--xlog',
action='store_true',
help="Use a logarithmic x-axis.")
parser.add_argument(
'--ylog',
action='store_true',
help="Use a logarithmic y-axis.")
parser.add_argument(
'--x2',
action='store_true',
help="Use base-2 prefixes for the x-axis.")
parser.add_argument(
'--y2',
action='store_true',
help="Use base-2 prefixes for the y-axis.")
parser.add_argument(
'--xticks',
type=lambda x: int(x, 0) if ',' not in x
else [dat(x) for x in x.split(',')],
help="Ticks for the x-axis. This can be explicit comma-separated "
"ticks, the number of ticks, or 0 to disable.")
parser.add_argument(
'--yticks',
type=lambda x: int(x, 0) if ',' not in x
else [dat(x) for x in x.split(',')],
help="Ticks for the y-axis. This can be explicit comma-separated "
"ticks, the number of ticks, or 0 to disable.")
parser.add_argument(
'--xunits',
help="Units for the x-axis.")
parser.add_argument(
'--yunits',
help="Units for the y-axis.")
parser.add_argument(
'--xlabel',
help="Add a label to the x-axis.")
parser.add_argument(
'--ylabel',
help="Add a label to the y-axis.")
parser.add_argument(
'--xticklabels',
type=lambda x: [x.strip() for x in x.split(',')],
help="Comma separated xticklabels.")
parser.add_argument(
'--yticklabels',
type=lambda x: [x.strip() for x in x.split(',')],
help="Comma separated yticklabels.")
parser.add_argument(
'-t', '--title',
help="Add a title.")
parser.add_argument(
'-l', '--legend-right',
action='store_true',
help="Place a legend to the right.")
parser.add_argument(
'--legend-above',
action='store_true',
help="Place a legend above.")
parser.add_argument(
'--legend-below',
action='store_true',
help="Place a legend below.")
parser.add_argument(
'--dark',
action='store_true',
help="Use the dark style.")
parser.add_argument(
'--ggplot',
action='store_true',
help="Use the ggplot style.")
parser.add_argument(
'--xkcd',
action='store_true',
help="Use the xkcd style.")
parser.add_argument(
'--github',
action='store_true',
help="Use the ggplot style with GitHub colors.")
parser.add_argument(
'--font',
type=lambda x: [x.strip() for x in x.split(',')],
help="Font family for matplotlib.")
parser.add_argument(
'--font-size',
help="Font size for matplotlib. Defaults to %r." % FONT_SIZE)
parser.add_argument(
'--font-color',
help="Color for the font and other line elements.")
parser.add_argument(
'--foreground',
help="Foreground color to use.")
parser.add_argument(
'--background',
help="Background color to use.")
class AppendSubplot(argparse.Action):
@staticmethod
def parse(value):
import copy
subparser = copy.deepcopy(parser)
next(a for a in subparser._actions
if '--output' in a.option_strings).required = False
next(a for a in subparser._actions
if '--width' in a.option_strings).type = float
next(a for a in subparser._actions
if '--height' in a.option_strings).type = float
return subparser.parse_intermixed_args(shlex.split(value or ""))
def __call__(self, parser, namespace, value, option):
if not hasattr(namespace, 'subplots'):
namespace.subplots = []
namespace.subplots.append((
option.split('-')[-1],
self.__class__.parse(value)))
parser.add_argument(
'--subplot-above',
action=AppendSubplot,
help="Add subplot above with the same dataset. Takes an arg string to "
"control the subplot which supports most (but not all) of the "
"parameters listed here. The relative dimensions of the subplot "
"can be controlled with -W/-H which now take a percentage.")
parser.add_argument(
'--subplot-below',
action=AppendSubplot,
help="Add subplot below with the same dataset.")
parser.add_argument(
'--subplot-left',
action=AppendSubplot,
help="Add subplot left with the same dataset.")
parser.add_argument(
'--subplot-right',
action=AppendSubplot,
help="Add subplot right with the same dataset.")
parser.add_argument(
'--subplot',
type=AppendSubplot.parse,
help="Add subplot-specific arguments to the main plot.")
def dictify(ns):
if hasattr(ns, 'subplots'):
ns.subplots = [(dir, dictify(subplot_ns))
for dir, subplot_ns in ns.subplots]
if ns.subplot is not None:
ns.subplot = dictify(ns.subplot)
return {k: v
for k, v in vars(ns).items()
if v is not None}
sys.exit(main(**dictify(parser.parse_intermixed_args())))