|
|
@@ -0,0 +1,770 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+#
|
|
|
+# Plot CSV files in terminal.
|
|
|
+#
|
|
|
+# Example:
|
|
|
+# ./scripts/plot.py bench.csv -xSIZE -ybench_read -W80 -H17
|
|
|
+#
|
|
|
+# Copyright (c) 2022, The littlefs authors.
|
|
|
+# SPDX-License-Identifier: BSD-3-Clause
|
|
|
+#
|
|
|
+
|
|
|
+import collections as co
|
|
|
+import csv
|
|
|
+import glob
|
|
|
+import io
|
|
|
+import itertools as it
|
|
|
+import math as m
|
|
|
+import os
|
|
|
+import shutil
|
|
|
+import time
|
|
|
+
|
|
|
+CSV_PATHS = ['*.csv']
|
|
|
+COLORS = [
|
|
|
+ '1;34', # bold blue
|
|
|
+ '1;31', # bold red
|
|
|
+ '1;32', # bold green
|
|
|
+ '1;35', # bold purple
|
|
|
+ '1;33', # bold yellow
|
|
|
+ '1;36', # bold cyan
|
|
|
+ '34', # blue
|
|
|
+ '31', # red
|
|
|
+ '32', # green
|
|
|
+ '35', # purple
|
|
|
+ '33', # yellow
|
|
|
+ '36', # cyan
|
|
|
+]
|
|
|
+
|
|
|
+CHARS_DOTS = " .':"
|
|
|
+CHARS_BRAILLE = (
|
|
|
+ '⠀⢀⡀⣀⠠⢠⡠⣠⠄⢄⡄⣄⠤⢤⡤⣤' '⠐⢐⡐⣐⠰⢰⡰⣰⠔⢔⡔⣔⠴⢴⡴⣴'
|
|
|
+ '⠂⢂⡂⣂⠢⢢⡢⣢⠆⢆⡆⣆⠦⢦⡦⣦' '⠒⢒⡒⣒⠲⢲⡲⣲⠖⢖⡖⣖⠶⢶⡶⣶'
|
|
|
+ '⠈⢈⡈⣈⠨⢨⡨⣨⠌⢌⡌⣌⠬⢬⡬⣬' '⠘⢘⡘⣘⠸⢸⡸⣸⠜⢜⡜⣜⠼⢼⡼⣼'
|
|
|
+ '⠊⢊⡊⣊⠪⢪⡪⣪⠎⢎⡎⣎⠮⢮⡮⣮' '⠚⢚⡚⣚⠺⢺⡺⣺⠞⢞⡞⣞⠾⢾⡾⣾'
|
|
|
+ '⠁⢁⡁⣁⠡⢡⡡⣡⠅⢅⡅⣅⠥⢥⡥⣥' '⠑⢑⡑⣑⠱⢱⡱⣱⠕⢕⡕⣕⠵⢵⡵⣵'
|
|
|
+ '⠃⢃⡃⣃⠣⢣⡣⣣⠇⢇⡇⣇⠧⢧⡧⣧' '⠓⢓⡓⣓⠳⢳⡳⣳⠗⢗⡗⣗⠷⢷⡷⣷'
|
|
|
+ '⠉⢉⡉⣉⠩⢩⡩⣩⠍⢍⡍⣍⠭⢭⡭⣭' '⠙⢙⡙⣙⠹⢹⡹⣹⠝⢝⡝⣝⠽⢽⡽⣽'
|
|
|
+ '⠋⢋⡋⣋⠫⢫⡫⣫⠏⢏⡏⣏⠯⢯⡯⣯' '⠛⢛⡛⣛⠻⢻⡻⣻⠟⢟⡟⣟⠿⢿⡿⣿')
|
|
|
+
|
|
|
+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',
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+# 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
|
|
|
+ p = 3*int(m.log(abs(x)*10, 10**3))
|
|
|
+ p = min(18, max(-18, p))
|
|
|
+ # format with enough digits
|
|
|
+ s = '%.*f' % (w, abs(x) / (10.0**p))
|
|
|
+ s = s.lstrip('0')
|
|
|
+ # truncate but only digits that follow the dot
|
|
|
+ if '.' in s:
|
|
|
+ s = s[:max(s.find('.'), w-(2 if x < 0 else 1))]
|
|
|
+ s = s.rstrip('0')
|
|
|
+ s = s.rstrip('.')
|
|
|
+ return '%s%s%s' % ('-' if x < 0 else '', s, SI_PREFIXES[p])
|
|
|
+
|
|
|
+def openio(path, mode='r'):
|
|
|
+ if path == '-':
|
|
|
+ if mode == 'r':
|
|
|
+ return os.fdopen(os.dup(sys.stdin.fileno()), 'r')
|
|
|
+ else:
|
|
|
+ return os.fdopen(os.dup(sys.stdout.fileno()), 'w')
|
|
|
+ else:
|
|
|
+ return open(path, mode)
|
|
|
+
|
|
|
+
|
|
|
+# 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:
|
|
|
+ x = 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)
|
|
|
+
|
|
|
+# 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)
|
|
|
+ else:
|
|
|
+ return -m.log10(-x)
|
|
|
+
|
|
|
+
|
|
|
+class Plot:
|
|
|
+ def __init__(self, width, height, *,
|
|
|
+ xlim=None,
|
|
|
+ ylim=None,
|
|
|
+ xlog=False,
|
|
|
+ ylog=False,
|
|
|
+ **_):
|
|
|
+ self.width = width
|
|
|
+ self.height = height
|
|
|
+ self.xlim = xlim or (0, width)
|
|
|
+ self.ylim = ylim or (0, height)
|
|
|
+ self.xlog = xlog
|
|
|
+ self.ylog = ylog
|
|
|
+ self.grid = [('',False)]*(self.width*self.height)
|
|
|
+
|
|
|
+ def scale(self, x, y):
|
|
|
+ # scale and clamp
|
|
|
+ try:
|
|
|
+ if self.xlog:
|
|
|
+ x = int(self.width * (
|
|
|
+ (slog10(x)-slog10(self.xlim[0]))
|
|
|
+ / (slog10(self.xlim[1])-slog10(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]))))
|
|
|
+ else:
|
|
|
+ y = int(self.height * (
|
|
|
+ (y-self.ylim[0])
|
|
|
+ / (self.ylim[1]-self.ylim[0])))
|
|
|
+ except ZeroDivisionError:
|
|
|
+ x = 0
|
|
|
+ y = 0
|
|
|
+ return x, y
|
|
|
+
|
|
|
+ def point(self, x, y, *,
|
|
|
+ color=COLORS[0],
|
|
|
+ char=True):
|
|
|
+ # scale
|
|
|
+ x, y = self.scale(x, y)
|
|
|
+
|
|
|
+ # ignore out of bounds points
|
|
|
+ if x >= 0 and x < self.width and y >= 0 and y < self.height:
|
|
|
+ self.grid[x + y*self.width] = (color, char)
|
|
|
+
|
|
|
+ def line(self, x1, y1, x2, y2, *,
|
|
|
+ color=COLORS[0],
|
|
|
+ char=True):
|
|
|
+ # scale
|
|
|
+ x1, y1 = self.scale(x1, y1)
|
|
|
+ x2, y2 = self.scale(x2, y2)
|
|
|
+
|
|
|
+ # incremental error line algorithm
|
|
|
+ ex = abs(x2 - x1)
|
|
|
+ ey = -abs(y2 - y1)
|
|
|
+ dx = +1 if x1 < x2 else -1
|
|
|
+ dy = +1 if y1 < y2 else -1
|
|
|
+ e = ex + ey
|
|
|
+
|
|
|
+ while True:
|
|
|
+ if x1 >= 0 and x1 < self.width and y1 >= 0 and y1 < self.height:
|
|
|
+ self.grid[x1 + y1*self.width] = (color, char)
|
|
|
+ e2 = 2*e
|
|
|
+
|
|
|
+ if x1 == x2 and y1 == y2:
|
|
|
+ break
|
|
|
+
|
|
|
+ if e2 > ey:
|
|
|
+ e += ey
|
|
|
+ x1 += dx
|
|
|
+
|
|
|
+ if x1 == x2 and y1 == y2:
|
|
|
+ break
|
|
|
+
|
|
|
+ if e2 < ex:
|
|
|
+ e += ex
|
|
|
+ y1 += dy
|
|
|
+
|
|
|
+ if x2 >= 0 and x2 < self.width and y2 >= 0 and y2 < self.height:
|
|
|
+ self.grid[x2 + y2*self.width] = (color, char)
|
|
|
+
|
|
|
+ def plot(self, coords, *,
|
|
|
+ color=COLORS[0],
|
|
|
+ char=True,
|
|
|
+ line_char=True):
|
|
|
+ # draw lines
|
|
|
+ if line_char:
|
|
|
+ for (x1, y1), (x2, y2) in zip(coords, coords[1:]):
|
|
|
+ if y1 is not None and y2 is not None:
|
|
|
+ self.line(x1, y1, x2, y2,
|
|
|
+ color=color,
|
|
|
+ char=line_char)
|
|
|
+
|
|
|
+ # draw points
|
|
|
+ if char and (not line_char or char is not True):
|
|
|
+ for x, y in coords:
|
|
|
+ if y is not None:
|
|
|
+ self.point(x, y,
|
|
|
+ color=color,
|
|
|
+ char=char)
|
|
|
+
|
|
|
+ def draw(self, row, *,
|
|
|
+ dots=False,
|
|
|
+ braille=False,
|
|
|
+ color=False,
|
|
|
+ **_):
|
|
|
+ # scale if needed
|
|
|
+ if braille:
|
|
|
+ xscale, yscale = 2, 4
|
|
|
+ elif dots:
|
|
|
+ xscale, yscale = 1, 2
|
|
|
+ else:
|
|
|
+ xscale, yscale = 1, 1
|
|
|
+
|
|
|
+ y = self.height//yscale-1 - row
|
|
|
+ row_ = []
|
|
|
+ for x in range(self.width//xscale):
|
|
|
+ best_f = ''
|
|
|
+ best_c = False
|
|
|
+
|
|
|
+ # encode into a byte
|
|
|
+ b = 0
|
|
|
+ for i in range(xscale*yscale):
|
|
|
+ f, c = self.grid[x*xscale+(xscale-1-(i%xscale))
|
|
|
+ + (y*yscale+(i//xscale))*self.width]
|
|
|
+ if c:
|
|
|
+ b |= 1 << i
|
|
|
+
|
|
|
+ if f:
|
|
|
+ best_f = f
|
|
|
+ if c and c is not True:
|
|
|
+ best_c = c
|
|
|
+
|
|
|
+ # use byte to lookup character
|
|
|
+ if b:
|
|
|
+ if best_c:
|
|
|
+ c = best_c
|
|
|
+ elif braille:
|
|
|
+ c = CHARS_BRAILLE[b]
|
|
|
+ else:
|
|
|
+ c = CHARS_DOTS[b]
|
|
|
+ else:
|
|
|
+ c = ' '
|
|
|
+
|
|
|
+ # color?
|
|
|
+ if b and color and best_f:
|
|
|
+ c = '\x1b[%sm%s\x1b[m' % (best_f, c)
|
|
|
+
|
|
|
+ # draw axis in blank spaces
|
|
|
+ if not b:
|
|
|
+ zx, zy = self.scale(0, 0)
|
|
|
+ if x == zx // xscale and y == zy // yscale:
|
|
|
+ c = '+'
|
|
|
+ elif x == zx // xscale and y == 0:
|
|
|
+ c = 'v'
|
|
|
+ elif x == zx // xscale 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:
|
|
|
+ c = '>'
|
|
|
+ elif x == zx // xscale:
|
|
|
+ c = '|'
|
|
|
+ elif y == zy // yscale:
|
|
|
+ c = '-'
|
|
|
+
|
|
|
+ row_.append(c)
|
|
|
+
|
|
|
+ return ''.join(row_)
|
|
|
+
|
|
|
+
|
|
|
+def collect(csv_paths, renames=[]):
|
|
|
+ # collect results from CSV files
|
|
|
+ paths = []
|
|
|
+ for path in csv_paths:
|
|
|
+ if os.path.isdir(path):
|
|
|
+ path = path + '/*.csv'
|
|
|
+
|
|
|
+ for path in glob.glob(path):
|
|
|
+ paths.append(path)
|
|
|
+
|
|
|
+ results = []
|
|
|
+ for path in 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, defines={}):
|
|
|
+ # organize by 'by', x, and y
|
|
|
+ dataset = {}
|
|
|
+ for i, r in enumerate(results):
|
|
|
+ # filter results by matching defines
|
|
|
+ if not all(k in r and r[k] in vs for k, vs in defines.items()):
|
|
|
+ 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
|
|
|
+
|
|
|
+ # 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, defines={}):
|
|
|
+ # filter results by matching defines
|
|
|
+ results_ = []
|
|
|
+ for r in results:
|
|
|
+ if all(k in r and r[k] in vs for k, vs in defines.items()):
|
|
|
+ results_.append(r)
|
|
|
+ results = results_
|
|
|
+
|
|
|
+ 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 if y is not None else [None]):
|
|
|
+ datasets[ks_ + (x_, 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, *,
|
|
|
+ by=None,
|
|
|
+ x=None,
|
|
|
+ y=None,
|
|
|
+ define=[],
|
|
|
+ xlim=None,
|
|
|
+ ylim=None,
|
|
|
+ width=None,
|
|
|
+ height=None,
|
|
|
+ color=False,
|
|
|
+ braille=False,
|
|
|
+ colors=None,
|
|
|
+ chars=None,
|
|
|
+ line_chars=None,
|
|
|
+ no_lines=False,
|
|
|
+ legend=None,
|
|
|
+ keep_open=False,
|
|
|
+ sleep=None,
|
|
|
+ **args):
|
|
|
+ # figure out what color should be
|
|
|
+ if color == 'auto':
|
|
|
+ color = sys.stdout.isatty()
|
|
|
+ elif color == 'always':
|
|
|
+ color = True
|
|
|
+ else:
|
|
|
+ color = False
|
|
|
+
|
|
|
+ # allow shortened ranges
|
|
|
+ if xlim is not None and len(xlim) == 1:
|
|
|
+ xlim = (0, xlim[0])
|
|
|
+ if ylim is not None and len(ylim) == 1:
|
|
|
+ ylim = (0, ylim[0])
|
|
|
+
|
|
|
+ # seperate out renames
|
|
|
+ renames = [k.split('=', 1)
|
|
|
+ for k in it.chain(by or [], x or [], y or [])
|
|
|
+ if '=' in k]
|
|
|
+ if by is not None:
|
|
|
+ by = [k.split('=', 1)[0] for k in by]
|
|
|
+ if x is not None:
|
|
|
+ x = [k.split('=', 1)[0] for k in x]
|
|
|
+ if y is not None:
|
|
|
+ y = [k.split('=', 1)[0] for k in y]
|
|
|
+
|
|
|
+ def draw(f):
|
|
|
+ def writeln(s=''):
|
|
|
+ f.write(s)
|
|
|
+ f.write('\n')
|
|
|
+ f.writeln = writeln
|
|
|
+
|
|
|
+ # first collect results from CSV files
|
|
|
+ results = collect(csv_paths, renames)
|
|
|
+
|
|
|
+ # then extract the requested datasets
|
|
|
+ datasets_ = datasets(results, by, x, y, dict(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 no_lines:
|
|
|
+ line_chars_ = [True]
|
|
|
+ else:
|
|
|
+ line_chars_ = [False]
|
|
|
+
|
|
|
+ # build legend?
|
|
|
+ legend_width = 0
|
|
|
+ if legend:
|
|
|
+ legend_ = []
|
|
|
+ for i, k in enumerate(datasets_.keys()):
|
|
|
+ label = '%s%s' % (
|
|
|
+ '%s ' % chars_[i % len(chars_)]
|
|
|
+ if chars is not None
|
|
|
+ else '%s ' % line_chars_[i % len(line_chars_)]
|
|
|
+ if line_chars is not None
|
|
|
+ else '',
|
|
|
+ ','.join(k_ for i, k_ in enumerate(k)
|
|
|
+ if k_
|
|
|
+ if not (i == len(k)-2 and len(x) == 1)
|
|
|
+ if not (i == len(k)-1 and len(y) == 1)))
|
|
|
+
|
|
|
+ if label:
|
|
|
+ legend_.append(label)
|
|
|
+ legend_width = max(legend_width, len(label)+1)
|
|
|
+
|
|
|
+ # find xlim/ylim
|
|
|
+ if xlim is not None:
|
|
|
+ xlim_ = xlim
|
|
|
+ else:
|
|
|
+ xlim_ = (
|
|
|
+ min(it.chain([0], (k
|
|
|
+ for r in datasets_.values()
|
|
|
+ for k, v in r.items()
|
|
|
+ if v is not None))),
|
|
|
+ max(it.chain([0], (k
|
|
|
+ for r in datasets_.values()
|
|
|
+ for k, v in r.items()
|
|
|
+ if v is not None))))
|
|
|
+
|
|
|
+ if ylim is not None:
|
|
|
+ ylim_ = ylim
|
|
|
+ else:
|
|
|
+ ylim_ = (
|
|
|
+ min(it.chain([0], (v
|
|
|
+ for r in datasets_.values()
|
|
|
+ for _, v in r.items()
|
|
|
+ if v is not None))),
|
|
|
+ max(it.chain([0], (v
|
|
|
+ for r in datasets_.values()
|
|
|
+ for _, v in r.items()
|
|
|
+ if v is not None))))
|
|
|
+
|
|
|
+ # figure out our plot size
|
|
|
+ if width is not None:
|
|
|
+ width_ = width
|
|
|
+ else:
|
|
|
+ width_ = shutil.get_terminal_size((80, 8))[0]
|
|
|
+ # make space for units
|
|
|
+ width_ -= 5
|
|
|
+ # make space for legend
|
|
|
+ if legend in {'left', 'right'} and legend_:
|
|
|
+ width_ -= legend_width
|
|
|
+ # limit a bit
|
|
|
+ width_ = max(2*4, width_)
|
|
|
+
|
|
|
+ if height is not None:
|
|
|
+ height_ = height
|
|
|
+ else:
|
|
|
+ height_ = shutil.get_terminal_size((80, 8))[1]
|
|
|
+ # make space for shell prompt
|
|
|
+ if not keep_open:
|
|
|
+ height_ -= 1
|
|
|
+ # make space for units
|
|
|
+ height_ -= 1
|
|
|
+ # make space for legend
|
|
|
+ if legend in {'above', 'below'} and legend_:
|
|
|
+ legend_cols = min(len(legend_), max(1, width_//legend_width))
|
|
|
+ height_ -= (len(legend_)+legend_cols-1) // legend_cols
|
|
|
+ # limit a bit
|
|
|
+ height_ = max(2, height_)
|
|
|
+
|
|
|
+ # create a plot and draw our coordinates
|
|
|
+ plot = Plot(
|
|
|
+ # scale if we're printing with dots or braille
|
|
|
+ 2*width_ if line_chars is None and braille else width_,
|
|
|
+ 4*height_ if line_chars is None and braille
|
|
|
+ else 2*height_ if line_chars is None
|
|
|
+ else height_,
|
|
|
+ xlim=xlim_,
|
|
|
+ ylim=ylim_,
|
|
|
+ **args)
|
|
|
+
|
|
|
+ for i, (k, dataset) in enumerate(datasets_.items()):
|
|
|
+ plot.plot(
|
|
|
+ sorted((x,y) for x,y in dataset.items()),
|
|
|
+ color=colors_[i % len(colors_)],
|
|
|
+ char=chars_[i % len(chars_)],
|
|
|
+ line_char=line_chars_[i % len(line_chars_)])
|
|
|
+
|
|
|
+ # draw legend=above?
|
|
|
+ if legend == 'above' and legend_:
|
|
|
+ for i in range(0, len(legend_), legend_cols):
|
|
|
+ f.writeln('%4s %*s%s' % (
|
|
|
+ '',
|
|
|
+ max(width_ - sum(len(label)+1
|
|
|
+ for label in legend_[i:i+legend_cols]),
|
|
|
+ 0) // 2,
|
|
|
+ '',
|
|
|
+ ' '.join('%s%s%s' % (
|
|
|
+ '\x1b[%sm' % colors_[j % len(colors_)] if color else '',
|
|
|
+ legend_[j],
|
|
|
+ '\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' % (
|
|
|
+ # draw legend=left?
|
|
|
+ ('%s%-*s %s' % (
|
|
|
+ '\x1b[%sm' % colors_[row % len(colors_)] if color else '',
|
|
|
+ legend_width-1,
|
|
|
+ legend_[row] if row < len(legend_) else '',
|
|
|
+ '\x1b[m' if color else ''))
|
|
|
+ if legend == 'left' and legend_ else '',
|
|
|
+ # draw plot
|
|
|
+ si(ylim_[0], 4) if row == height_-1
|
|
|
+ else si(ylim_[1], 4) if row == 0
|
|
|
+ else '',
|
|
|
+ plot.draw(row,
|
|
|
+ braille=line_chars is None and braille,
|
|
|
+ dots=line_chars is None and not braille,
|
|
|
+ color=color,
|
|
|
+ **args),
|
|
|
+ # draw legend=right?
|
|
|
+ (' %s%s%s' % (
|
|
|
+ '\x1b[%sm' % colors_[row % len(colors_)] if color else '',
|
|
|
+ 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),
|
|
|
+ '',
|
|
|
+ si(xlim_[0], 4),
|
|
|
+ width_ - 2*4,
|
|
|
+ '',
|
|
|
+ si(xlim_[1], 4)))
|
|
|
+ # draw legend=below?
|
|
|
+ if legend == 'below' and legend_:
|
|
|
+ for i in range(0, len(legend_), legend_cols):
|
|
|
+ f.writeln('%4s %*s%s' % (
|
|
|
+ '',
|
|
|
+ max(width_ - sum(len(label)+1
|
|
|
+ for label in legend_[i:i+legend_cols]),
|
|
|
+ 0) // 2,
|
|
|
+ '',
|
|
|
+ ' '.join('%s%s%s' % (
|
|
|
+ '\x1b[%sm' % colors_[j % len(colors_)] if color else '',
|
|
|
+ legend_[j],
|
|
|
+ '\x1b[m' if color else '')
|
|
|
+ for j in range(i, min(i+legend_cols, len(legend_))))))
|
|
|
+
|
|
|
+
|
|
|
+ last_lines = 1
|
|
|
+ def redraw():
|
|
|
+ nonlocal last_lines
|
|
|
+
|
|
|
+ canvas = io.StringIO()
|
|
|
+ draw(canvas)
|
|
|
+ canvas = canvas.getvalue().splitlines()
|
|
|
+
|
|
|
+ # give ourself a canvas
|
|
|
+ while last_lines < len(canvas):
|
|
|
+ sys.stdout.write('\n')
|
|
|
+ last_lines += 1
|
|
|
+
|
|
|
+ for i, line in enumerate(canvas):
|
|
|
+ jump = len(canvas)-1-i
|
|
|
+ # move cursor, clear line, disable/reenable line wrapping
|
|
|
+ sys.stdout.write('\r')
|
|
|
+ if jump > 0:
|
|
|
+ sys.stdout.write('\x1b[%dA' % jump)
|
|
|
+ sys.stdout.write('\x1b[K')
|
|
|
+ sys.stdout.write('\x1b[?7l')
|
|
|
+ sys.stdout.write(line)
|
|
|
+ sys.stdout.write('\x1b[?7h')
|
|
|
+ if jump > 0:
|
|
|
+ sys.stdout.write('\x1b[%dB' % jump)
|
|
|
+
|
|
|
+ sys.stdout.flush()
|
|
|
+
|
|
|
+ if keep_open:
|
|
|
+ try:
|
|
|
+ while True:
|
|
|
+ redraw()
|
|
|
+ # don't just flood open calls
|
|
|
+ time.sleep(sleep or 0.1)
|
|
|
+ except KeyboardInterrupt:
|
|
|
+ pass
|
|
|
+
|
|
|
+ redraw()
|
|
|
+ sys.stdout.write('\n')
|
|
|
+ else:
|
|
|
+ draw(sys.stdout)
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ import sys
|
|
|
+ import argparse
|
|
|
+ parser = argparse.ArgumentParser(
|
|
|
+ description="Plot CSV files in terminal.")
|
|
|
+ parser.add_argument(
|
|
|
+ 'csv_paths',
|
|
|
+ nargs='*',
|
|
|
+ default=CSV_PATHS,
|
|
|
+ help="Description of where to find *.csv files. May be a directory "
|
|
|
+ "or list of paths. Defaults to %r." % CSV_PATHS)
|
|
|
+ parser.add_argument(
|
|
|
+ '-b', '--by',
|
|
|
+ type=lambda x: [x.strip() for x in x.split(',')],
|
|
|
+ help="Fields to render as separate plots. All other fields will be "
|
|
|
+ "summed. Can rename fields with new_name=old_name.")
|
|
|
+ parser.add_argument(
|
|
|
+ '-x',
|
|
|
+ type=lambda x: [x.strip() for x in x.split(',')],
|
|
|
+ help="Fields to use for the x-axis. Can rename fields with "
|
|
|
+ "new_name=old_name.")
|
|
|
+ parser.add_argument(
|
|
|
+ '-y',
|
|
|
+ type=lambda x: [x.strip() for x in x.split(',')],
|
|
|
+ required=True,
|
|
|
+ help="Fields 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 rows where this field is this value (field=value). "
|
|
|
+ "May include comma-separated options.")
|
|
|
+ parser.add_argument(
|
|
|
+ '--color',
|
|
|
+ choices=['never', 'always', 'auto'],
|
|
|
+ default='auto',
|
|
|
+ help="When to use terminal colors. Defaults to 'auto'.")
|
|
|
+ parser.add_argument(
|
|
|
+ '--braille',
|
|
|
+ action='store_true',
|
|
|
+ help="Use unicode braille characters. Note that braille characters "
|
|
|
+ "sometimes suffer from inconsistent widths.")
|
|
|
+ parser.add_argument(
|
|
|
+ '--colors',
|
|
|
+ type=lambda x: x.split(','),
|
|
|
+ help="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(
|
|
|
+ '-L', '--no-lines',
|
|
|
+ action='store_true',
|
|
|
+ help="Only draw the data points.")
|
|
|
+ parser.add_argument(
|
|
|
+ '-W', '--width',
|
|
|
+ type=lambda x: int(x, 0),
|
|
|
+ help="Width in columns. A width of 0 indicates no limit. Defaults "
|
|
|
+ "to terminal width or 80.")
|
|
|
+ parser.add_argument(
|
|
|
+ '-H', '--height',
|
|
|
+ type=lambda x: int(x, 0),
|
|
|
+ help="Height in rows. Defaults to terminal height or 8.")
|
|
|
+ parser.add_argument(
|
|
|
+ '-X', '--xlim',
|
|
|
+ type=lambda x: tuple(dat(x) if x 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 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(
|
|
|
+ '-l', '--legend',
|
|
|
+ choices=['above', 'below', 'left', 'right'],
|
|
|
+ help="Place a legend here.")
|
|
|
+ parser.add_argument(
|
|
|
+ '-k', '--keep-open',
|
|
|
+ action='store_true',
|
|
|
+ help="Continue to open and redraw the CSV files in a loop.")
|
|
|
+ parser.add_argument(
|
|
|
+ '-s', '--sleep',
|
|
|
+ type=float,
|
|
|
+ help="Time in seconds to sleep between redraws when running with -k. "
|
|
|
+ "Defaults to 0.01.")
|
|
|
+ sys.exit(main(**{k: v
|
|
|
+ for k, v in vars(parser.parse_intermixed_args()).items()
|
|
|
+ if v is not None}))
|