Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/littlefs-project/littlefs.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristopher Haster <geky@geky.net>2022-10-20 20:31:08 +0300
committerChristopher Haster <geky@geky.net>2022-11-15 22:38:13 +0300
commit559e17466053e8bb49002d3dcdaa1c158ae9c1fc (patch)
tree936a69faa1d16b0f6a4381249e3ae594e3acde97 /scripts
parentb2a2cc9a19bb034e05dd673dc0a0a886e17b8e4b (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-xscripts/plot.py245
-rwxr-xr-xscripts/plotmpl.py860
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}))