diff options
author | Christopher Haster <geky@geky.net> | 2022-10-20 20:31:08 +0300 |
---|---|---|
committer | Christopher Haster <geky@geky.net> | 2022-11-15 22:38:13 +0300 |
commit | 559e17466053e8bb49002d3dcdaa1c158ae9c1fc (patch) | |
tree | 936a69faa1d16b0f6a4381249e3ae594e3acde97 /scripts | |
parent | b2a2cc9a19bb034e05dd673dc0a0a886e17b8e4b (diff) |
Added plotmpl.py for creating svg/png plots with matplotlib
Note that plotmpl.py tries to share many arguments with plot.py,
allowing plot.py to act as a sort of draft mode for previewing plots
before creating an svg.
Diffstat (limited to 'scripts')
-rwxr-xr-x | scripts/plot.py | 245 | ||||
-rwxr-xr-x | scripts/plotmpl.py | 860 |
2 files changed, 1042 insertions, 63 deletions
diff --git a/scripts/plot.py b/scripts/plot.py index 2458093..a163b7e 100755 --- a/scripts/plot.py +++ b/scripts/plot.py @@ -9,6 +9,7 @@ # SPDX-License-Identifier: BSD-3-Clause # +import codecs import collections as co import csv import io @@ -49,6 +50,7 @@ CHARS_BRAILLE = ( '⠃⢃⡃⣃⠣⢣⡣⣣⠇⢇⡇⣇⠧⢧⡧⣧' '⠓⢓⡓⣓⠳⢳⡳⣳⠗⢗⡗⣗⠷⢷⡷⣷' '⠉⢉⡉⣉⠩⢩⡩⣩⠍⢍⡍⣍⠭⢭⡭⣭' '⠙⢙⡙⣙⠹⢹⡹⣹⠝⢝⡝⣝⠽⢽⡽⣽' '⠋⢋⡋⣋⠫⢫⡫⣫⠏⢏⡏⣏⠯⢯⡯⣯' '⠛⢛⡛⣛⠻⢻⡻⣻⠟⢟⡟⣟⠿⢿⡿⣿') +CHARS_POINTS_AND_LINES = 'o' SI_PREFIXES = { 18: 'E', @@ -66,12 +68,31 @@ SI_PREFIXES = { -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', +} + # format a number to a strict character width using SI prefixes def si(x, w=4): if x == 0: return '0' # figure out prefix and scale + # + # note we adjust this so that 100K = .1M, which has more info + # per character p = 3*int(m.log(abs(x)*10, 10**3)) p = min(18, max(-18, p)) # format with enough digits @@ -84,6 +105,25 @@ def si(x, w=4): s = s.rstrip('.') return '%s%s%s' % ('-' if x < 0 else '', s, SI_PREFIXES[p]) +def si2(x, w=5): + if x == 0: + return '0' + # figure out prefix and scale + # + # note we adjust this so that 128Ki = .1Mi, which has more info + # per character + p = 10*int(m.log(abs(x)*10, 2**10)) + p = min(30, max(-30, p)) + # format with enough digits + s = '%.*f' % (w, abs(x) / (2.0**p)) + s = s.lstrip('0') + # truncate but only digits that follow the dot + if '.' in s: + s = s[:max(s.find('.'), w-(3 if x < 0 else 2))] + s = s.rstrip('0') + s = s.rstrip('.') + return '%s%s%s' % ('-' if x < 0 else '', s, SI2_PREFIXES[p]) + def openio(path, mode='r', buffering=-1): # allow '-' for stdin/stdout if path == '-': @@ -202,7 +242,7 @@ def dat(x): # then try as float try: - x = float(x) + return float(x) # just don't allow infinity or nan if m.isinf(x) or m.isnan(x): raise ValueError("invalid dat %r" % x) @@ -213,14 +253,14 @@ def dat(x): raise ValueError("invalid dat %r" % x) -# a hack log10 that preserves sign, and passes zero as zero -def slog10(x): - if x == 0: - return x - elif x > 0: - return m.log10(x) +# a hack log that preserves sign, with a linear region between -1 and 1 +def symlog(x): + if x > 1: + return m.log(x)+1 + elif x < -1: + return -m.log(-x)-1 else: - return -m.log10(-x) + return x class Plot: def __init__(self, width, height, *, @@ -242,16 +282,16 @@ class Plot: try: if self.xlog: x = int(self.width * ( - (slog10(x)-slog10(self.xlim[0])) - / (slog10(self.xlim[1])-slog10(self.xlim[0])))) + (symlog(x)-symlog(self.xlim[0])) + / (symlog(self.xlim[1])-symlog(self.xlim[0])))) else: x = int(self.width * ( (x-self.xlim[0]) / (self.xlim[1]-self.xlim[0]))) if self.ylog: y = int(self.height * ( - (slog10(y)-slog10(self.ylim[0])) - / (slog10(self.ylim[1])-slog10(self.ylim[0])))) + (symlog(y)-symlog(self.ylim[0])) + / (symlog(self.ylim[1])-symlog(self.ylim[0])))) else: y = int(self.height * ( (y-self.ylim[0]) @@ -376,20 +416,15 @@ class Plot: # draw axis in blank spaces if not b: - zx, zy = self.scale(0, 0) - if x == zx // xscale and y == zy // yscale: + if x == 0 and y == 0: c = '+' - elif x == zx // xscale and y == 0: - c = 'v' - elif x == zx // xscale and y == self.height//yscale-1: + elif x == 0 and y == self.height//yscale-1: c = '^' - elif y == zy // yscale and x == 0: - c = '<' - elif y == zy // yscale and x == self.width//xscale-1: + elif x == self.width//xscale-1 and y == 0: c = '>' - elif x == zx // xscale: + elif x == 0: c = '|' - elif y == zy // yscale: + elif y == 0: c = '-' row_.append(c) @@ -512,10 +547,16 @@ def main(csv_paths, *, x=None, y=None, define=[], + width=None, + height=None, xlim=(None,None), ylim=(None,None), - width=None, - height=17, + x2=False, + y2=False, + xunits='', + yunits='', + xlabel=None, + ylabel=None, cat=False, color=False, braille=False, @@ -523,6 +564,8 @@ def main(csv_paths, *, chars=None, line_chars=None, points=False, + points_and_lines=False, + title=None, legend=None, keep_open=False, sleep=None, @@ -552,6 +595,38 @@ def main(csv_paths, *, if y is not None: y = [k for k, _ in y] + # what colors to use? + if colors is not None: + colors_ = colors + else: + colors_ = COLORS + + if chars is not None: + chars_ = chars + elif points_and_lines: + chars_ = CHARS_POINTS_AND_LINES + else: + chars_ = [True] + + if line_chars is not None: + line_chars_ = line_chars + elif points_and_lines or not points: + line_chars_ = [True] + else: + line_chars_ = [False] + + # allow escape codes in labels/titles + if title is not None: + title = codecs.escape_decode(title.encode('utf8'))[0].decode('utf8') + if xlabel is not None: + xlabel = codecs.escape_decode(xlabel.encode('utf8'))[0].decode('utf8') + if ylabel is not None: + ylabel = codecs.escape_decode(ylabel.encode('utf8'))[0].decode('utf8') + + title = title.splitlines() if title is not None else [] + xlabel = xlabel.splitlines() if xlabel is not None else [] + ylabel = ylabel.splitlines() if ylabel is not None else [] + def draw(f): def writeln(s=''): f.write(s) @@ -564,24 +639,6 @@ def main(csv_paths, *, # then extract the requested datasets datasets_ = datasets(results, by, x, y, define) - # what colors to use? - if colors is not None: - colors_ = colors - else: - colors_ = COLORS - - if chars is not None: - chars_ = chars - else: - chars_ = [True] - - if line_chars is not None: - line_chars_ = line_chars - elif not points: - line_chars_ = [True] - else: - line_chars_ = [False] - # build legend? legend_width = 0 if legend: @@ -626,28 +683,37 @@ def main(csv_paths, *, # figure out our plot size if width is None: - width_ = min(80, shutil.get_terminal_size((80, 17))[0]) + width_ = min(80, shutil.get_terminal_size((80, None))[0]) elif width: width_ = width else: - width_ = shutil.get_terminal_size((80, 17))[0] + width_ = shutil.get_terminal_size((80, None))[0] # make space for units - width_ -= 5 + width_ -= (5 if y2 else 4)+1+len(yunits) + # make space for label + width_ -= len(ylabel) # make space for legend if legend in {'left', 'right'} and legend_: width_ -= legend_width # limit a bit - width_ = max(2*4, width_) + width_ = max(2*((5 if x2 else 4)+len(xunits)), width_) - if height: + if height is None: + height_ = 17 + len(title) + len(xlabel) + elif height: height_ = height else: - height_ = shutil.get_terminal_size((80, 17))[1] + height_ = shutil.get_terminal_size((None, + 17 + len(title) + len(xlabel)))[1] # make space for shell prompt if not keep_open: height_ -= 1 # make space for units height_ -= 1 + # make space for label + height_ -= len(xlabel) + # make space for title + height_ -= len(title) # make space for legend if legend in {'above', 'below'} and legend_: legend_cols = min(len(legend_), max(1, width_//legend_width)) @@ -655,6 +721,14 @@ def main(csv_paths, *, # limit a bit height_ = max(2, height_) + # figure out margin for label/units/legend + margin = (5 if y2 else 4) + len(yunits) + len(ylabel) + if legend == 'left' and legend_: + margin += legend_width + + # make it easier to transpose ylabel + ylabel_ = [l.center(height_) for l in ylabel] + # create a plot and draw our coordinates plot = Plot( # scale if we're printing with dots or braille @@ -672,11 +746,16 @@ def main(csv_paths, *, color=colors_[i % len(colors_)], char=chars_[i % len(chars_)], line_char=line_chars_[i % len(line_chars_)]) + + # draw title? + for line in title: + f.writeln('%*s %s' % (margin, '', line.center(width_))) # draw legend=above? if legend == 'above' and legend_: for i in range(0, len(legend_), legend_cols): - f.writeln('%4s %*s%s' % ( + f.writeln('%*s %*s%s' % ( + margin, '', max(width_ - sum(len(label)+1 for label in legend_[i:i+legend_cols]), @@ -688,7 +767,7 @@ def main(csv_paths, *, '\x1b[m' if color else '') for j in range(i, min(i+legend_cols, len(legend_)))))) for row in range(height_): - f.writeln('%s%4s %s%s' % ( + f.writeln('%s%s%*s %s%s' % ( # draw legend=left? ('%s%-*s %s' % ( '\x1b[%sm' % colors_[row % len(colors_)] if color else '', @@ -696,9 +775,14 @@ def main(csv_paths, *, legend_[row] if row < len(legend_) else '', '\x1b[m' if color else '')) if legend == 'left' and legend_ else '', + # draw ylabel? + ('%*s' % ( + len(ylabel), + ''.join(l[row] for l in ylabel_))), # draw plot - si(ylim_[0], 4) if row == height_-1 - else si(ylim_[1], 4) if row == 0 + (5 if y2 else 4)+len(yunits), + (si2 if y2 else si)(ylim_[0])+yunits if row == height_-1 + else (si2 if y2 else si)(ylim_[1])+yunits if row == 0 else '', plot.draw(row, braille=line_chars is None and braille, @@ -711,17 +795,23 @@ def main(csv_paths, *, legend_[row] if row < len(legend_) else '', '\x1b[m' if color else '')) if legend == 'right' and legend_ else '')) - f.writeln('%*s %-4s%*s%4s' % ( - 4 + (legend_width if legend == 'left' and legend_ else 0), + f.writeln('%*s %-*s%*s%*s' % ( + margin, '', - si(xlim_[0], 4), - width_ - 2*4, + (5 if x2 else 4)+len(xunits), + (si2 if x2 else si)(xlim_[0])+xunits, + width_ - 2*((5 if x2 else 4)+len(xunits)), '', - si(xlim_[1], 4))) + (5 if x2 else 4)+len(xunits), + (si2 if x2 else si)(xlim_[1])+xunits)) + # draw xlabel? + for line in xlabel: + f.writeln('%*s %s' % (margin, '', line.center(width_))) # draw legend=below? if legend == 'below' and legend_: for i in range(0, len(legend_), legend_cols): - f.writeln('%4s %*s%s' % ( + f.writeln('%*s %*s%s' % ( + margin, '', max(width_ - sum(len(label)+1 for label in legend_[i:i+legend_cols]), @@ -816,9 +906,17 @@ if __name__ == "__main__": help="Use 2x4 unicode braille characters. Note that braille characters " "sometimes suffer from inconsistent widths.") 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="Colors to use.") + help="Comma-separated colors to use.") parser.add_argument( '--chars', help="Characters to use for points.") @@ -826,10 +924,6 @@ if __name__ == "__main__": '--line-chars', help="Characters to use for lines.") parser.add_argument( - '-.', '--points', - action='store_true', - help="Only draw the data points.") - parser.add_argument( '-W', '--width', nargs='?', type=lambda x: int(x, 0), @@ -867,8 +961,33 @@ if __name__ == "__main__": 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( + '--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( + '-t', '--title', + help="Add a title.") + parser.add_argument( '-l', '--legend', + nargs='?', choices=['above', 'below', 'left', 'right'], + const='right', help="Place a legend here.") parser.add_argument( '-k', '--keep-open', diff --git a/scripts/plotmpl.py b/scripts/plotmpl.py new file mode 100755 index 0000000..bdb3c34 --- /dev/null +++ b/scripts/plotmpl.py @@ -0,0 +1,860 @@ +#!/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 math as m +import numpy as np +import os +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 = 735 +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]) + +# 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: + y_ = None + else: + try: + y_ = dat(r[y]) + except ValueError: + y_ = None + 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 + + +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=None, + dark=False, + ggplot=False, + xkcd=False, + font=None, + font_size=FONT_SIZE, + background=None): + # guess the output format + if not png and not svg: + if output.endswith('.png'): + png = True + else: + svg = True + + # allow shortened ranges + if len(xlim) == 1: + xlim = (0, xlim[0]) + if len(ylim) == 1: + ylim = (0, ylim[0]) + + # 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] + + # 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 background is not None: + background_ = background + elif dark: + background_ = mpl.style.library['dark_background']['figure.facecolor'] + else: + background_ = plt.rcParams['figure.facecolor'] + + # allow escape codes in labels/titles + if title is not None: + title = codecs.escape_decode(title.encode('utf8'))[0].decode('utf8') + if xlabel is not None: + xlabel = codecs.escape_decode(xlabel.encode('utf8'))[0].decode('utf8') + if ylabel is not None: + ylabel = codecs.escape_decode(ylabel.encode('utf8'))[0].decode('utf8') + + # first collect results from CSV files + results = collect(csv_paths, renames) + + # then extract the requested datasets + datasets_ = datasets(results, by, x, y, define) + + # configure some matplotlib settings + if xkcd: + 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', 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') + # fix ggplot when dark + if ggplot: + plt.rc('axes', + facecolor='#333333', + edgecolor=background_, + labelcolor='#aaaaaa') + plt.rc('xtick', color='#aaaaaa') + plt.rc('ytick', color='#aaaaaa') + plt.rc('grid', color=background_) + + if font is not None: + plt.rc('font', family=font) + plt.rc('font', size=font_size) + plt.rc('figure', titlesize='medium') + plt.rc('axes', titlesize='medium', labelsize='small') + plt.rc('xtick', labelsize='small') + plt.rc('ytick', labelsize='small') + plt.rc('legend', + fontsize='small', + fancybox=False, + framealpha=None, + 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') + + # create a matplotlib plot + fig = plt.figure(figsize=( + width/plt.rcParams['figure.dpi'], + height/plt.rcParams['figure.dpi']), + # note we need a linewidth to keep xkcd mode happy + linewidth=8) + ax = fig.subplots() + + for i, (name, dataset) in enumerate(datasets_.items()): + dats = sorted((x,y) for x,y in dataset.items()) + ax.plot([x for x,_ in dats], [y for _,y in dats], + formats_[i % len(formats_)], + color=colors_[i % len(colors_)], + 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 datasets_.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 datasets_.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 datasets_.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 datasets_.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()) + # axes labels + if xlabel is not None: + ax.set_xlabel(xlabel) + if ylabel is not None: + ax.set_ylabel(ylabel) + if ggplot: + ax.grid(sketch_params=None) + + if title is not None: + ax.set_title(title) + + # pre-render so we can derive some bboxes + fig.tight_layout() + # it's not clear how you're actually supposed to get the renderer if + # get_renderer isn't supported + try: + renderer = fig.canvas.get_renderer() + except AttributeError: + renderer = fig._cachedRenderer + + # add a legend? this actually ends up being _really_ complicated + if legend == 'right': + l_pad = fig.transFigure.inverted().transform(( + mpl.font_manager.FontProperties('small') + .get_size_in_points()/2, + 0))[0] + + legend_ = ax.legend( + bbox_to_anchor=(1+l_pad, 1), + loc='upper left', + fancybox=False, + borderaxespad=0) + if ggplot: + legend_.get_frame().set_linewidth(0) + fig.tight_layout() + + elif legend == 'left': + l_pad = fig.transFigure.inverted().transform(( + mpl.font_manager.FontProperties('small') + .get_size_in_points()/2, + 0))[0] + + # place legend somewhere to get its bbox + legend_ = ax.legend( + bbox_to_anchor=(0, 1), + loc='upper right', + fancybox=False, + borderaxespad=0) + + # first make space for legend without the legend in the figure + l_bbox = (legend_.get_tightbbox(renderer) + .transformed(fig.transFigure.inverted())) + legend_.remove() + fig.tight_layout(rect=(0, 0, 1-l_bbox.width-l_pad, 1)) + + # place legend after tight_layout computation + bbox = (ax.get_tightbbox(renderer) + .transformed(ax.transAxes.inverted())) + legend_ = ax.legend( + bbox_to_anchor=(bbox.x0-l_pad, 1), + loc='upper right', + fancybox=False, + borderaxespad=0) + if ggplot: + legend_.get_frame().set_linewidth(0) + + elif legend == 'above': + l_pad = fig.transFigure.inverted().transform(( + 0, + mpl.font_manager.FontProperties('small') + .get_size_in_points()/2))[1] + + # try different column counts until we fit in the axes + for ncol in reversed(range(1, len(datasets_)+1)): + legend_ = ax.legend( + bbox_to_anchor=(0.5, 1+l_pad), + loc='lower center', + ncol=ncol, + fancybox=False, + borderaxespad=0) + if ggplot: + legend_.get_frame().set_linewidth(0) + + l_bbox = (legend_.get_tightbbox(renderer) + .transformed(ax.transAxes.inverted())) + if l_bbox.x0 >= 0: + break + + # fix the title + if title is not None: + t_bbox = (ax.title.get_tightbbox(renderer) + .transformed(ax.transAxes.inverted())) + ax.set_title(None) + fig.tight_layout(rect=(0, 0, 1, 1-t_bbox.height)) + + l_bbox = (legend_.get_tightbbox(renderer) + .transformed(ax.transAxes.inverted())) + ax.set_title(title, y=1+l_bbox.height+l_pad) + + elif legend == 'below': + l_pad = fig.transFigure.inverted().transform(( + 0, + mpl.font_manager.FontProperties('small') + .get_size_in_points()/2))[1] + + # try different column counts until we fit in the axes + for ncol in reversed(range(1, len(datasets_)+1)): + legend_ = ax.legend( + bbox_to_anchor=(0.5, 0), + loc='upper center', + ncol=ncol, + fancybox=False, + borderaxespad=0) + + l_bbox = (legend_.get_tightbbox(renderer) + .transformed(ax.transAxes.inverted())) + if l_bbox.x0 >= 0: + break + + # first make space for legend without the legend in the figure + l_bbox = (legend_.get_tightbbox(renderer) + .transformed(fig.transFigure.inverted())) + legend_.remove() + fig.tight_layout(rect=(0, 0, 1, 1-l_bbox.height-l_pad)) + + bbox = (ax.get_tightbbox(renderer) + .transformed(ax.transAxes.inverted())) + legend_ = ax.legend( + bbox_to_anchor=(0.5, bbox.y0-l_pad), + loc='upper center', + ncol=ncol, + fancybox=False, + borderaxespad=0) + if ggplot: + legend_.get_frame().set_linewidth(0) + + # compute another tight_layout for good measure, because this _does_ + # fix some things... I don't really know why though + fig.tight_layout() + + plt.savefig(output, format='png' if png else 'svg', bbox_inches='tight') + + # 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.") + 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', + nargs='?', + choices=['above', 'below', 'left', 'right'], + const='right', + help="Place a legend here.") + 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( + '--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( + '--background', + help="Background color to use.") + sys.exit(main(**{k: v + for k, v in vars(parser.parse_intermixed_args()).items() + if v is not None})) |