plotmpl.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909
  1. #!/usr/bin/env python3
  2. #
  3. # Plot CSV files with matplotlib.
  4. #
  5. # Example:
  6. # ./scripts/plotmpl.py bench.csv -xSIZE -ybench_read -obench.svg
  7. #
  8. # Copyright (c) 2022, The littlefs authors.
  9. # SPDX-License-Identifier: BSD-3-Clause
  10. #
  11. import codecs
  12. import collections as co
  13. import csv
  14. import io
  15. import itertools as it
  16. import math as m
  17. import numpy as np
  18. import os
  19. import shutil
  20. import time
  21. import matplotlib as mpl
  22. import matplotlib.pyplot as plt
  23. # some nicer colors borrowed from Seaborn
  24. # note these include a non-opaque alpha
  25. COLORS = [
  26. '#4c72b0bf', # blue
  27. '#dd8452bf', # orange
  28. '#55a868bf', # green
  29. '#c44e52bf', # red
  30. '#8172b3bf', # purple
  31. '#937860bf', # brown
  32. '#da8bc3bf', # pink
  33. '#8c8c8cbf', # gray
  34. '#ccb974bf', # yellow
  35. '#64b5cdbf', # cyan
  36. ]
  37. COLORS_DARK = [
  38. '#a1c9f4bf', # blue
  39. '#ffb482bf', # orange
  40. '#8de5a1bf', # green
  41. '#ff9f9bbf', # red
  42. '#d0bbffbf', # purple
  43. '#debb9bbf', # brown
  44. '#fab0e4bf', # pink
  45. '#cfcfcfbf', # gray
  46. '#fffea3bf', # yellow
  47. '#b9f2f0bf', # cyan
  48. ]
  49. ALPHAS = [0.75]
  50. FORMATS = ['-']
  51. FORMATS_POINTS = ['.']
  52. FORMATS_POINTS_AND_LINES = ['.-']
  53. WIDTH = 735
  54. HEIGHT = 350
  55. FONT_SIZE = 11
  56. SI_PREFIXES = {
  57. 18: 'E',
  58. 15: 'P',
  59. 12: 'T',
  60. 9: 'G',
  61. 6: 'M',
  62. 3: 'K',
  63. 0: '',
  64. -3: 'm',
  65. -6: 'u',
  66. -9: 'n',
  67. -12: 'p',
  68. -15: 'f',
  69. -18: 'a',
  70. }
  71. SI2_PREFIXES = {
  72. 60: 'Ei',
  73. 50: 'Pi',
  74. 40: 'Ti',
  75. 30: 'Gi',
  76. 20: 'Mi',
  77. 10: 'Ki',
  78. 0: '',
  79. -10: 'mi',
  80. -20: 'ui',
  81. -30: 'ni',
  82. -40: 'pi',
  83. -50: 'fi',
  84. -60: 'ai',
  85. }
  86. # formatter for matplotlib
  87. def si(x):
  88. if x == 0:
  89. return '0'
  90. # figure out prefix and scale
  91. p = 3*int(m.log(abs(x), 10**3))
  92. p = min(18, max(-18, p))
  93. # format with 3 digits of precision
  94. s = '%.3f' % (abs(x) / (10.0**p))
  95. s = s[:3+1]
  96. # truncate but only digits that follow the dot
  97. if '.' in s:
  98. s = s.rstrip('0')
  99. s = s.rstrip('.')
  100. return '%s%s%s' % ('-' if x < 0 else '', s, SI_PREFIXES[p])
  101. # formatter for matplotlib
  102. def si2(x):
  103. if x == 0:
  104. return '0'
  105. # figure out prefix and scale
  106. p = 10*int(m.log(abs(x), 2**10))
  107. p = min(30, max(-30, p))
  108. # format with 3 digits of precision
  109. s = '%.3f' % (abs(x) / (2.0**p))
  110. s = s[:3+1]
  111. # truncate but only digits that follow the dot
  112. if '.' in s:
  113. s = s.rstrip('0')
  114. s = s.rstrip('.')
  115. return '%s%s%s' % ('-' if x < 0 else '', s, SI2_PREFIXES[p])
  116. # we want to use MaxNLocator, but since MaxNLocator forces multiples of 10
  117. # to be an option, we can't really...
  118. class AutoMultipleLocator(mpl.ticker.MultipleLocator):
  119. def __init__(self, base, nbins=None):
  120. # note base needs to be floats to avoid integer pow issues
  121. self.base = float(base)
  122. self.nbins = nbins
  123. super().__init__(self.base)
  124. def __call__(self):
  125. # find best tick count, conveniently matplotlib has a function for this
  126. vmin, vmax = self.axis.get_view_interval()
  127. vmin, vmax = mpl.transforms.nonsingular(vmin, vmax, 1e-12, 1e-13)
  128. if self.nbins is not None:
  129. nbins = self.nbins
  130. else:
  131. nbins = np.clip(self.axis.get_tick_space(), 1, 9)
  132. # find the best power, use this as our locator's actual base
  133. scale = self.base ** (m.ceil(m.log((vmax-vmin) / (nbins+1), self.base)))
  134. self.set_params(scale)
  135. return super().__call__()
  136. def openio(path, mode='r', buffering=-1):
  137. # allow '-' for stdin/stdout
  138. if path == '-':
  139. if mode == 'r':
  140. return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
  141. else:
  142. return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
  143. else:
  144. return open(path, mode, buffering)
  145. # parse different data representations
  146. def dat(x):
  147. # allow the first part of an a/b fraction
  148. if '/' in x:
  149. x, _ = x.split('/', 1)
  150. # first try as int
  151. try:
  152. return int(x, 0)
  153. except ValueError:
  154. pass
  155. # then try as float
  156. try:
  157. return float(x)
  158. # just don't allow infinity or nan
  159. if m.isinf(x) or m.isnan(x):
  160. raise ValueError("invalid dat %r" % x)
  161. except ValueError:
  162. pass
  163. # else give up
  164. raise ValueError("invalid dat %r" % x)
  165. def collect(csv_paths, renames=[]):
  166. # collect results from CSV files
  167. results = []
  168. for path in csv_paths:
  169. try:
  170. with openio(path) as f:
  171. reader = csv.DictReader(f, restval='')
  172. for r in reader:
  173. results.append(r)
  174. except FileNotFoundError:
  175. pass
  176. if renames:
  177. for r in results:
  178. # make a copy so renames can overlap
  179. r_ = {}
  180. for new_k, old_k in renames:
  181. if old_k in r:
  182. r_[new_k] = r[old_k]
  183. r.update(r_)
  184. return results
  185. def dataset(results, x=None, y=None, define=[]):
  186. # organize by 'by', x, and y
  187. dataset = {}
  188. i = 0
  189. for r in results:
  190. # filter results by matching defines
  191. if not all(k in r and r[k] in vs for k, vs in define):
  192. continue
  193. # find xs
  194. if x is not None:
  195. if x not in r:
  196. continue
  197. try:
  198. x_ = dat(r[x])
  199. except ValueError:
  200. continue
  201. else:
  202. x_ = i
  203. i += 1
  204. # find ys
  205. if y is not None:
  206. if y not in r:
  207. continue
  208. try:
  209. y_ = dat(r[y])
  210. except ValueError:
  211. continue
  212. else:
  213. y_ = None
  214. if y_ is not None:
  215. dataset[x_] = y_ + dataset.get(x_, 0)
  216. else:
  217. dataset[x_] = y_ or dataset.get(x_, None)
  218. return dataset
  219. def datasets(results, by=None, x=None, y=None, define=[]):
  220. # filter results by matching defines
  221. results_ = []
  222. for r in results:
  223. if all(k in r and r[k] in vs for k, vs in define):
  224. results_.append(r)
  225. results = results_
  226. # if y not specified, try to guess from data
  227. if y is None:
  228. y = co.OrderedDict()
  229. for r in results:
  230. for k, v in r.items():
  231. if (by is None or k not in by) and v.strip():
  232. try:
  233. dat(v)
  234. y[k] = True
  235. except ValueError:
  236. y[k] = False
  237. y = list(k for k,v in y.items() if v)
  238. if by is not None:
  239. # find all 'by' values
  240. ks = set()
  241. for r in results:
  242. ks.add(tuple(r.get(k, '') for k in by))
  243. ks = sorted(ks)
  244. # collect all datasets
  245. datasets = co.OrderedDict()
  246. for ks_ in (ks if by is not None else [()]):
  247. for x_ in (x if x is not None else [None]):
  248. for y_ in y:
  249. # hide x/y if there is only one field
  250. k_x = x_ if len(x or []) > 1 else ''
  251. k_y = y_ if len(y or []) > 1 or (not ks_ and not k_x) else ''
  252. datasets[ks_ + (k_x, k_y)] = dataset(
  253. results,
  254. x_,
  255. y_,
  256. [(by_, {k_}) for by_, k_ in zip(by, ks_)]
  257. if by is not None else [])
  258. return datasets
  259. def main(csv_paths, output, *,
  260. svg=False,
  261. png=False,
  262. quiet=False,
  263. by=None,
  264. x=None,
  265. y=None,
  266. define=[],
  267. points=False,
  268. points_and_lines=False,
  269. colors=None,
  270. formats=None,
  271. width=WIDTH,
  272. height=HEIGHT,
  273. xlim=(None,None),
  274. ylim=(None,None),
  275. xlog=False,
  276. ylog=False,
  277. x2=False,
  278. y2=False,
  279. xticks=None,
  280. yticks=None,
  281. xunits=None,
  282. yunits=None,
  283. xlabel=None,
  284. ylabel=None,
  285. xticklabels=None,
  286. yticklabels=None,
  287. title=None,
  288. legend=None,
  289. dark=False,
  290. ggplot=False,
  291. xkcd=False,
  292. github=False,
  293. font=None,
  294. font_size=FONT_SIZE,
  295. font_color=None,
  296. foreground=None,
  297. background=None):
  298. # guess the output format
  299. if not png and not svg:
  300. if output.endswith('.png'):
  301. png = True
  302. else:
  303. svg = True
  304. # allow shortened ranges
  305. if len(xlim) == 1:
  306. xlim = (0, xlim[0])
  307. if len(ylim) == 1:
  308. ylim = (0, ylim[0])
  309. # separate out renames
  310. renames = list(it.chain.from_iterable(
  311. ((k, v) for v in vs)
  312. for k, vs in it.chain(by or [], x or [], y or [])))
  313. if by is not None:
  314. by = [k for k, _ in by]
  315. if x is not None:
  316. x = [k for k, _ in x]
  317. if y is not None:
  318. y = [k for k, _ in y]
  319. # some shortcuts for color schemes
  320. if github:
  321. ggplot = True
  322. if font_color is None:
  323. if dark:
  324. font_color = '#c9d1d9'
  325. else:
  326. font_color = '#24292f'
  327. if foreground is None:
  328. if dark:
  329. foreground = '#343942'
  330. else:
  331. foreground = '#eff1f3'
  332. if background is None:
  333. if dark:
  334. background = '#0d1117'
  335. else:
  336. background = '#ffffff'
  337. # what colors/alphas/formats to use?
  338. if colors is not None:
  339. colors_ = colors
  340. elif dark:
  341. colors_ = COLORS_DARK
  342. else:
  343. colors_ = COLORS
  344. if formats is not None:
  345. formats_ = formats
  346. elif points_and_lines:
  347. formats_ = FORMATS_POINTS_AND_LINES
  348. elif points:
  349. formats_ = FORMATS_POINTS
  350. else:
  351. formats_ = FORMATS
  352. if font_color is not None:
  353. font_color_ = font_color
  354. elif dark:
  355. font_color_ = '#ffffff'
  356. else:
  357. font_color_ = '#000000'
  358. if foreground is not None:
  359. foreground_ = foreground
  360. elif dark:
  361. foreground_ = '#333333'
  362. else:
  363. foreground_ = '#e5e5e5'
  364. if background is not None:
  365. background_ = background
  366. elif dark:
  367. background_ = '#000000'
  368. else:
  369. background_ = '#ffffff'
  370. # allow escape codes in labels/titles
  371. if title is not None:
  372. title = codecs.escape_decode(title.encode('utf8'))[0].decode('utf8')
  373. if xlabel is not None:
  374. xlabel = codecs.escape_decode(xlabel.encode('utf8'))[0].decode('utf8')
  375. if ylabel is not None:
  376. ylabel = codecs.escape_decode(ylabel.encode('utf8'))[0].decode('utf8')
  377. # first collect results from CSV files
  378. results = collect(csv_paths, renames)
  379. # then extract the requested datasets
  380. datasets_ = datasets(results, by, x, y, define)
  381. # configure some matplotlib settings
  382. if xkcd:
  383. plt.xkcd()
  384. # turn off the white outline, this breaks some things
  385. plt.rc('path', effects=[])
  386. if ggplot:
  387. plt.style.use('ggplot')
  388. plt.rc('patch', linewidth=0)
  389. plt.rc('axes', facecolor=foreground_, edgecolor=background_)
  390. plt.rc('grid', color=background_)
  391. # fix the the gridlines when ggplot+xkcd
  392. if xkcd:
  393. plt.rc('grid', linewidth=1)
  394. plt.rc('axes.spines', bottom=False, left=False)
  395. if dark:
  396. plt.style.use('dark_background')
  397. plt.rc('savefig', facecolor='auto', edgecolor='auto')
  398. # fix ggplot when dark
  399. if ggplot:
  400. plt.rc('axes',
  401. facecolor=foreground_,
  402. edgecolor=background_)
  403. plt.rc('grid', color=background_)
  404. if font is not None:
  405. plt.rc('font', family=font)
  406. plt.rc('font', size=font_size)
  407. plt.rc('text', color=font_color_)
  408. plt.rc('figure', titlesize='medium')
  409. plt.rc('axes',
  410. titlesize='medium',
  411. labelsize='small',
  412. labelcolor=font_color_)
  413. if not ggplot:
  414. plt.rc('axes', edgecolor=font_color_)
  415. plt.rc('xtick', labelsize='small', color=font_color_)
  416. plt.rc('ytick', labelsize='small', color=font_color_)
  417. plt.rc('legend',
  418. fontsize='small',
  419. fancybox=False,
  420. framealpha=None,
  421. edgecolor=foreground_,
  422. borderaxespad=0)
  423. plt.rc('axes.spines', top=False, right=False)
  424. plt.rc('figure', facecolor=background_, edgecolor=background_)
  425. if not ggplot:
  426. plt.rc('axes', facecolor='#00000000')
  427. # create a matplotlib plot
  428. fig = plt.figure(figsize=(
  429. width/plt.rcParams['figure.dpi'],
  430. height/plt.rcParams['figure.dpi']),
  431. # we need a linewidth to keep xkcd mode happy
  432. linewidth=8 if xkcd else 0)
  433. ax = fig.subplots()
  434. for i, (name, dataset) in enumerate(datasets_.items()):
  435. dats = sorted((x,y) for x,y in dataset.items())
  436. ax.plot([x for x,_ in dats], [y for _,y in dats],
  437. formats_[i % len(formats_)],
  438. color=colors_[i % len(colors_)],
  439. label=','.join(k for k in name if k))
  440. # axes scaling
  441. if xlog:
  442. ax.set_xscale('symlog')
  443. ax.xaxis.set_minor_locator(mpl.ticker.NullLocator())
  444. if ylog:
  445. ax.set_yscale('symlog')
  446. ax.yaxis.set_minor_locator(mpl.ticker.NullLocator())
  447. # axes limits
  448. ax.set_xlim(
  449. xlim[0] if xlim[0] is not None
  450. else min(it.chain([0], (k
  451. for r in datasets_.values()
  452. for k, v in r.items()
  453. if v is not None))),
  454. xlim[1] if xlim[1] is not None
  455. else max(it.chain([0], (k
  456. for r in datasets_.values()
  457. for k, v in r.items()
  458. if v is not None))))
  459. ax.set_ylim(
  460. ylim[0] if ylim[0] is not None
  461. else min(it.chain([0], (v
  462. for r in datasets_.values()
  463. for _, v in r.items()
  464. if v is not None))),
  465. ylim[1] if ylim[1] is not None
  466. else max(it.chain([0], (v
  467. for r in datasets_.values()
  468. for _, v in r.items()
  469. if v is not None))))
  470. # axes ticks
  471. if x2:
  472. ax.xaxis.set_major_formatter(lambda x, pos:
  473. si2(x)+(xunits if xunits else ''))
  474. if xticklabels is not None:
  475. ax.xaxis.set_ticklabels(xticklabels)
  476. if xticks is None:
  477. ax.xaxis.set_major_locator(AutoMultipleLocator(2))
  478. elif isinstance(xticks, list):
  479. ax.xaxis.set_major_locator(mpl.ticker.FixedLocator(xticks))
  480. elif xticks != 0:
  481. ax.xaxis.set_major_locator(AutoMultipleLocator(2, xticks-1))
  482. else:
  483. ax.xaxis.set_major_locator(mpl.ticker.NullLocator())
  484. else:
  485. ax.xaxis.set_major_formatter(lambda x, pos:
  486. si(x)+(xunits if xunits else ''))
  487. if xticklabels is not None:
  488. ax.xaxis.set_ticklabels(xticklabels)
  489. if xticks is None:
  490. ax.xaxis.set_major_locator(mpl.ticker.AutoLocator())
  491. elif isinstance(xticks, list):
  492. ax.xaxis.set_major_locator(mpl.ticker.FixedLocator(xticks))
  493. elif xticks != 0:
  494. ax.xaxis.set_major_locator(mpl.ticker.MaxNLocator(xticks-1))
  495. else:
  496. ax.xaxis.set_major_locator(mpl.ticker.NullLocator())
  497. if y2:
  498. ax.yaxis.set_major_formatter(lambda x, pos:
  499. si2(x)+(yunits if yunits else ''))
  500. if yticklabels is not None:
  501. ax.yaxis.set_ticklabels(yticklabels)
  502. if yticks is None:
  503. ax.yaxis.set_major_locator(AutoMultipleLocator(2))
  504. elif isinstance(yticks, list):
  505. ax.yaxis.set_major_locator(mpl.ticker.FixedLocator(yticks))
  506. elif yticks != 0:
  507. ax.yaxis.set_major_locator(AutoMultipleLocator(2, yticks-1))
  508. else:
  509. ax.yaxis.set_major_locator(mpl.ticker.NullLocator())
  510. else:
  511. ax.yaxis.set_major_formatter(lambda x, pos:
  512. si(x)+(yunits if yunits else ''))
  513. if yticklabels is not None:
  514. ax.yaxis.set_ticklabels(yticklabels)
  515. if yticks is None:
  516. ax.yaxis.set_major_locator(mpl.ticker.AutoLocator())
  517. elif isinstance(yticks, list):
  518. ax.yaxis.set_major_locator(mpl.ticker.FixedLocator(yticks))
  519. elif yticks != 0:
  520. ax.yaxis.set_major_locator(mpl.ticker.MaxNLocator(yticks-1))
  521. else:
  522. ax.yaxis.set_major_locator(mpl.ticker.NullLocator())
  523. # axes labels
  524. if xlabel is not None:
  525. ax.set_xlabel(xlabel)
  526. if ylabel is not None:
  527. ax.set_ylabel(ylabel)
  528. if ggplot:
  529. ax.grid(sketch_params=None)
  530. if title is not None:
  531. ax.set_title(title)
  532. # pre-render so we can derive some bboxes
  533. fig.tight_layout()
  534. # it's not clear how you're actually supposed to get the renderer if
  535. # get_renderer isn't supported
  536. try:
  537. renderer = fig.canvas.get_renderer()
  538. except AttributeError:
  539. renderer = fig._cachedRenderer
  540. # add a legend? this actually ends up being _really_ complicated
  541. if legend == 'right':
  542. l_pad = fig.transFigure.inverted().transform((
  543. mpl.font_manager.FontProperties('small')
  544. .get_size_in_points()/2,
  545. 0))[0]
  546. legend_ = ax.legend(
  547. bbox_to_anchor=(1+l_pad, 1),
  548. loc='upper left',
  549. fancybox=False,
  550. borderaxespad=0)
  551. if ggplot:
  552. legend_.get_frame().set_linewidth(0)
  553. fig.tight_layout()
  554. elif legend == 'left':
  555. l_pad = fig.transFigure.inverted().transform((
  556. mpl.font_manager.FontProperties('small')
  557. .get_size_in_points()/2,
  558. 0))[0]
  559. # place legend somewhere to get its bbox
  560. legend_ = ax.legend(
  561. bbox_to_anchor=(0, 1),
  562. loc='upper right',
  563. fancybox=False,
  564. borderaxespad=0)
  565. # first make space for legend without the legend in the figure
  566. l_bbox = (legend_.get_tightbbox(renderer)
  567. .transformed(fig.transFigure.inverted()))
  568. legend_.remove()
  569. fig.tight_layout(rect=(0, 0, 1-l_bbox.width-l_pad, 1))
  570. # place legend after tight_layout computation
  571. bbox = (ax.get_tightbbox(renderer)
  572. .transformed(ax.transAxes.inverted()))
  573. legend_ = ax.legend(
  574. bbox_to_anchor=(bbox.x0-l_pad, 1),
  575. loc='upper right',
  576. fancybox=False,
  577. borderaxespad=0)
  578. if ggplot:
  579. legend_.get_frame().set_linewidth(0)
  580. elif legend == 'above':
  581. l_pad = fig.transFigure.inverted().transform((
  582. 0,
  583. mpl.font_manager.FontProperties('small')
  584. .get_size_in_points()/2))[1]
  585. # try different column counts until we fit in the axes
  586. for ncol in reversed(range(1, len(datasets_)+1)):
  587. legend_ = ax.legend(
  588. bbox_to_anchor=(0.5, 1+l_pad),
  589. loc='lower center',
  590. ncol=ncol,
  591. fancybox=False,
  592. borderaxespad=0)
  593. if ggplot:
  594. legend_.get_frame().set_linewidth(0)
  595. l_bbox = (legend_.get_tightbbox(renderer)
  596. .transformed(ax.transAxes.inverted()))
  597. if l_bbox.x0 >= 0:
  598. break
  599. # fix the title
  600. if title is not None:
  601. t_bbox = (ax.title.get_tightbbox(renderer)
  602. .transformed(ax.transAxes.inverted()))
  603. ax.set_title(None)
  604. fig.tight_layout(rect=(0, 0, 1, 1-t_bbox.height))
  605. l_bbox = (legend_.get_tightbbox(renderer)
  606. .transformed(ax.transAxes.inverted()))
  607. ax.set_title(title, y=1+l_bbox.height+l_pad)
  608. elif legend == 'below':
  609. l_pad = fig.transFigure.inverted().transform((
  610. 0,
  611. mpl.font_manager.FontProperties('small')
  612. .get_size_in_points()/2))[1]
  613. # try different column counts until we fit in the axes
  614. for ncol in reversed(range(1, len(datasets_)+1)):
  615. legend_ = ax.legend(
  616. bbox_to_anchor=(0.5, 0),
  617. loc='upper center',
  618. ncol=ncol,
  619. fancybox=False,
  620. borderaxespad=0)
  621. l_bbox = (legend_.get_tightbbox(renderer)
  622. .transformed(ax.transAxes.inverted()))
  623. if l_bbox.x0 >= 0:
  624. break
  625. # first make space for legend without the legend in the figure
  626. l_bbox = (legend_.get_tightbbox(renderer)
  627. .transformed(fig.transFigure.inverted()))
  628. legend_.remove()
  629. fig.tight_layout(rect=(0, 0, 1, 1-l_bbox.height-l_pad))
  630. bbox = (ax.get_tightbbox(renderer)
  631. .transformed(ax.transAxes.inverted()))
  632. legend_ = ax.legend(
  633. bbox_to_anchor=(0.5, bbox.y0-l_pad),
  634. loc='upper center',
  635. ncol=ncol,
  636. fancybox=False,
  637. borderaxespad=0)
  638. if ggplot:
  639. legend_.get_frame().set_linewidth(0)
  640. # compute another tight_layout for good measure, because this _does_
  641. # fix some things... I don't really know why though
  642. fig.tight_layout()
  643. plt.savefig(output, format='png' if png else 'svg', bbox_inches='tight')
  644. # some stats
  645. if not quiet:
  646. print('updated %s, %s datasets, %s points' % (
  647. output,
  648. len(datasets_),
  649. sum(len(dataset) for dataset in datasets_.values())))
  650. if __name__ == "__main__":
  651. import sys
  652. import argparse
  653. parser = argparse.ArgumentParser(
  654. description="Plot CSV files with matplotlib.",
  655. allow_abbrev=False)
  656. parser.add_argument(
  657. 'csv_paths',
  658. nargs='*',
  659. help="Input *.csv files.")
  660. parser.add_argument(
  661. '-o', '--output',
  662. required=True,
  663. help="Output *.svg/*.png file.")
  664. parser.add_argument(
  665. '--svg',
  666. action='store_true',
  667. help="Output an svg file. By default this is infered.")
  668. parser.add_argument(
  669. '--png',
  670. action='store_true',
  671. help="Output a png file. By default this is infered.")
  672. parser.add_argument(
  673. '-q', '--quiet',
  674. action='store_true',
  675. help="Don't print info.")
  676. parser.add_argument(
  677. '-b', '--by',
  678. action='append',
  679. type=lambda x: (
  680. lambda k,v=None: (k, v.split(',') if v is not None else ())
  681. )(*x.split('=', 1)),
  682. help="Group by this field. Can rename fields with new_name=old_name.")
  683. parser.add_argument(
  684. '-x',
  685. action='append',
  686. type=lambda x: (
  687. lambda k,v=None: (k, v.split(',') if v is not None else ())
  688. )(*x.split('=', 1)),
  689. help="Field to use for the x-axis. Can rename fields with "
  690. "new_name=old_name.")
  691. parser.add_argument(
  692. '-y',
  693. action='append',
  694. type=lambda x: (
  695. lambda k,v=None: (k, v.split(',') if v is not None else ())
  696. )(*x.split('=', 1)),
  697. help="Field to use for the y-axis. Can rename fields with "
  698. "new_name=old_name.")
  699. parser.add_argument(
  700. '-D', '--define',
  701. type=lambda x: (lambda k,v: (k, set(v.split(','))))(*x.split('=', 1)),
  702. action='append',
  703. help="Only include results where this field is this value. May include "
  704. "comma-separated options.")
  705. parser.add_argument(
  706. '-.', '--points',
  707. action='store_true',
  708. help="Only draw data points.")
  709. parser.add_argument(
  710. '-!', '--points-and-lines',
  711. action='store_true',
  712. help="Draw data points and lines.")
  713. parser.add_argument(
  714. '--colors',
  715. type=lambda x: [x.strip() for x in x.split(',')],
  716. help="Comma-separated hex colors to use.")
  717. parser.add_argument(
  718. '--formats',
  719. type=lambda x: [x.strip().replace('0',',') for x in x.split(',')],
  720. help="Comma-separated matplotlib formats to use. Allows '0' as an "
  721. "alternative for ','.")
  722. parser.add_argument(
  723. '-W', '--width',
  724. type=lambda x: int(x, 0),
  725. help="Width in pixels. Defaults to %r." % WIDTH)
  726. parser.add_argument(
  727. '-H', '--height',
  728. type=lambda x: int(x, 0),
  729. help="Height in pixels. Defaults to %r." % HEIGHT)
  730. parser.add_argument(
  731. '-X', '--xlim',
  732. type=lambda x: tuple(
  733. dat(x) if x.strip() else None
  734. for x in x.split(',')),
  735. help="Range for the x-axis.")
  736. parser.add_argument(
  737. '-Y', '--ylim',
  738. type=lambda x: tuple(
  739. dat(x) if x.strip() else None
  740. for x in x.split(',')),
  741. help="Range for the y-axis.")
  742. parser.add_argument(
  743. '--xlog',
  744. action='store_true',
  745. help="Use a logarithmic x-axis.")
  746. parser.add_argument(
  747. '--ylog',
  748. action='store_true',
  749. help="Use a logarithmic y-axis.")
  750. parser.add_argument(
  751. '--x2',
  752. action='store_true',
  753. help="Use base-2 prefixes for the x-axis.")
  754. parser.add_argument(
  755. '--y2',
  756. action='store_true',
  757. help="Use base-2 prefixes for the y-axis.")
  758. parser.add_argument(
  759. '--xticks',
  760. type=lambda x: int(x, 0) if ',' not in x
  761. else [dat(x) for x in x.split(',')],
  762. help="Ticks for the x-axis. This can be explicit comma-separated "
  763. "ticks, the number of ticks, or 0 to disable.")
  764. parser.add_argument(
  765. '--yticks',
  766. type=lambda x: int(x, 0) if ',' not in x
  767. else [dat(x) for x in x.split(',')],
  768. help="Ticks for the y-axis. This can be explicit comma-separated "
  769. "ticks, the number of ticks, or 0 to disable.")
  770. parser.add_argument(
  771. '--xunits',
  772. help="Units for the x-axis.")
  773. parser.add_argument(
  774. '--yunits',
  775. help="Units for the y-axis.")
  776. parser.add_argument(
  777. '--xlabel',
  778. help="Add a label to the x-axis.")
  779. parser.add_argument(
  780. '--ylabel',
  781. help="Add a label to the y-axis.")
  782. parser.add_argument(
  783. '--xticklabels',
  784. type=lambda x: [x.strip() for x in x.split(',')],
  785. help="Comma separated xticklabels.")
  786. parser.add_argument(
  787. '--yticklabels',
  788. type=lambda x: [x.strip() for x in x.split(',')],
  789. help="Comma separated yticklabels.")
  790. parser.add_argument(
  791. '-t', '--title',
  792. help="Add a title.")
  793. parser.add_argument(
  794. '-l', '--legend',
  795. nargs='?',
  796. choices=['above', 'below', 'left', 'right'],
  797. const='right',
  798. help="Place a legend here.")
  799. parser.add_argument(
  800. '--dark',
  801. action='store_true',
  802. help="Use the dark style.")
  803. parser.add_argument(
  804. '--ggplot',
  805. action='store_true',
  806. help="Use the ggplot style.")
  807. parser.add_argument(
  808. '--xkcd',
  809. action='store_true',
  810. help="Use the xkcd style.")
  811. parser.add_argument(
  812. '--github',
  813. action='store_true',
  814. help="Use the ggplot style with GitHub colors.")
  815. parser.add_argument(
  816. '--font',
  817. type=lambda x: [x.strip() for x in x.split(',')],
  818. help="Font family for matplotlib.")
  819. parser.add_argument(
  820. '--font-size',
  821. help="Font size for matplotlib. Defaults to %r." % FONT_SIZE)
  822. parser.add_argument(
  823. '--font-color',
  824. help="Color for the font and other line elements.")
  825. parser.add_argument(
  826. '--foreground',
  827. help="Foreground color to use.")
  828. parser.add_argument(
  829. '--background',
  830. help="Background color to use.")
  831. sys.exit(main(**{k: v
  832. for k, v in vars(parser.parse_intermixed_args()).items()
  833. if v is not None}))