Przeglądaj źródła

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.
Christopher Haster 3 lat temu
rodzic
commit
559e174660
2 zmienionych plików z 1042 dodań i 63 usunięć
  1. 182 63
      scripts/plot.py
  2. 860 0
      scripts/plotmpl.py

+ 182 - 63
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]),
@@ -815,20 +905,24 @@ if __name__ == "__main__":
         action='store_true',
         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.")
     parser.add_argument(
         '--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='?',
@@ -866,9 +960,34 @@ if __name__ == "__main__":
         '--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(
+        '--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',

+ 860 - 0
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}))