#!/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(',')] if x.strip() else [], help="Comma separated xticklabels.") parser.add_argument( '--yticklabels', type=lambda x: [x.strip() for x in x.split(',')] if x.strip() else [], 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())))